Skip to content

Cooperative hooks

Some mutants don’t fail — they hang. An inflated delay or a broken completion condition makes a test wait forever. Mutato’s outer timeout and respawn always catch these, but that’s the slow path. A test author can convert many of them into fast in-process kills by referencing the optional Mutato.Testing package and wiring hooks where the tests already control time or waiting.

The library is inert under a normal test run — the hooks only engage when the Mutato runner is driving. Both shipped assemblies are strong-named, so strong-named test projects don’t need NoWarn CS8002.

This page walks through the idea with the most common case: a test suite built on a fake clock (FakeTimeProvider).

With a fake clock there is no wall clock in the test at all. The code under test asks for a 2-second backoff timer, the test calls Advance(5 s), the timer fires, and the whole thing completes in milliseconds. Waiting is free — time only moves when the test says so.

Normal run · waiting is free fakeTime.Advance(5 s) 0 s 2 s 5 s backoff timer fires test completes in milliseconds

Now Mutato switches on a mutant that inflates the backoff — say an arithmetic mutation turns the 2-second delay into 2 hours. Here’s the crucial bit: under a fake clock this mutant is not slow. The code arms a timer at 2 hours of virtual time, the test only ever advances 5 seconds, so the timer never fires, the awaited task never completes — and the test hangs. Not slowly. Forever.

Mutant run · a wait nothing will ever reach 2 s → 2 h Advance(5 s) 0 s 5 s 2 h the clock never gets here timer armed at 2 h await never completes — hang hard-kill + respawn worker the slow path: seconds of wall-clock per hanging mutant

The mutant is eventually counted as killed — the outer wall-clock timeout sees to that. The cost is speed: every hanging mutant burns seconds of real time and takes a warm worker process down with it. On a fake-clock-heavy suite, hangs like this can dominate the whole run.

The engine can’t see your test’s synchronization — but you can. The fix is a one-line declaration at the moment the code under test requests a wait. A fake clock changes how time passes, never how much time the code asks for — so the requested duration is still a perfectly good signal, and it’s available before any waiting (virtual or real) begins.

The ready-made way is MutationTimeGuard.Wrap: wrap the provider once, hand the wrapped instance to the code under test, and keep driving the clock through the original fake. Outside a mutation run the wrapper is a transparent pass-through, so it’s safe to leave wired in permanently.

var fakeTime = new FakeTimeProvider();
TimeProvider provider = MutationTimeGuard.Wrap(fakeTime);
// hand `provider` to the code under test;
// the test keeps driving the clock with fakeTime.Advance(...)

It’s worth seeing what Wrap does internally, because the pattern — declare at the request site — isn’t specific to TimeProvider; it applies to any synchronization your tests own. The whole adapter is essentially a ten-line subclass:

sealed class GuardedFakeTimeProvider : FakeTimeProvider
{
public override ITimer CreateTimer(
TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
// Declare the delay so an inflated-delay mutant dies at the request site
// instead of hanging until the outer timeout.
MutationTesting.DeclareOperationSize(dueTime.TotalMilliseconds);
return base.CreateTimer(callback, state, dueTime, period);
}
}

Clock reads delegate unchanged, so .Advance() calls and every fake-clock assertion keep working. Reach for this subclass form directly when your tests need the guarded instance to be a FakeTimeProvider (say, an identity assertion on the provider they registered) — but mostly, treat it as the template: a fake message queue, a polling helper, anything that knows the size of the work it’s about to do can declare it the same way and turn its own hang cases into instant kills.

4. The envelope: baseline learns, mutant dies

Section titled “4. The envelope: baseline learns, mutant dies”

DeclareOperationSize takes a plain size — the engine doesn’t know or care that it’s milliseconds. During the baseline pass (your unmutated tests, all green) it learns each test’s operating envelope: the largest operation the test legitimately performs. During a mutant run, any declaration far outside that envelope throws a catchable MutationEnvelopeException right at the request site — the test fails instantly, the deadlock never forms, and the worker lives on to run the next mutant.

1 · Baseline learns the envelope 2 · Mutant declares outside it the envelope ≈ 2 s (+ headroom) 500 ms 1 200 ms 2 000 ms every declaration during green tests stretches the envelope 7 200 000 ms MutationEnvelopeException thrown at the request site, catchable — no hang begins killed in ms · worker survives

Because the declaration fires when the timer is created, the kill happens before the code ever starts an uncancellable wait — the fatal 2-hour timer from step 2 is never even armed. And since the size is generic, the same hook guards retry counts, queue depths, or anything else a mutant might inflate.

5. The cancellation token — the other kind of hang

Section titled “5. The cancellation token — the other kind of hang”

Fake-clock suites usually still contain real waiting around the edges: helpers that await a completion signal, poll a condition “for up to 30 seconds”, wait on a semaphore. A mutant that makes the condition never come true stalls those waits in genuine real time — no size was ever declared, because there was no oversized request; the code is simply blocked.

For that class, use the standard .NET idiom: a cancellation token. MutationTesting.CancellationToken is cancelled when the engine’s per-mutant deadline passes (and is CancellationToken.None outside a mutation run, so it’s inert under normal dotnet test). Flow it into your helpers’ waits, linked with your test framework’s own token:

async Task WaitForConsumedAsync(TimeSpan timeout)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken, // your framework's token
MutationTesting.CancellationToken); // Mutato's per-mutant deadline
await _consumed.Task.WaitAsync(timeout, linked.Token);
}

What makes this the workhorse hook is that it’s push, not pull: cancelling the wait itself reaches a mutant blocked inside a single await, which no in-loop check ever could (a blocked helper never reaches its next iteration).

Section titled “Your framework’s token vs Mutato’s — why link both?”

The two tokens answer different questions. Your framework’s token answers “should this test stop?” — it fires on Ctrl+C, a test timeout, session teardown, and, under Mutato, the mutation deadline too. Mutato’s token answers “should this mutant stop?” — armed in-process by the engine, so it behaves identically on every host. Flowing the framework’s token is always right, and alone it already buys cooperative kills on every warm host; what changes per host is how fast it fires:

Warm host When the framework’s token fires
xUnit v3 (in-proc) at the tight per-test watchdog (≈1 s past the stalled test’s own budget) — Mutato owns xUnit’s pipeline token
TUnit direct same — the CancellationToken TUnit injects is Mutato’s deadline token
MTP (any framework) only at the mutant’s full adaptive budget, via MTP’s --timeout — the per-test watchdog can’t reach inside a running MTP session

MutationTesting.CancellationToken closes the gaps. It is tripped by the per-test watchdog and by the first-failure bail on every host — under the MTP host a stalled test dies in about a second instead of waiting out the whole mutant budget. And it needs no ambient test context: TestContext.Current is async-local and disappears on pooled threads, background pumps and shared fixtures — exactly where wait helpers tend to live — while Mutato’s token is process-global for the active mutant.

So: the framework token carries every non-Mutato reason to stop; Mutato’s carries the tight per-mutant deadline everywhere, with no context required. Each alone leaves a gap — the linked source covers both. (Cold-run .cctor mutants execute in a one-shot subprocess where the framework’s token never fires; Mutato arms its own deadline there through the environment, so MutationTesting.CancellationToken works in cold runs too.)

The two hooks split the hang space between them:

The hang The hook How it dies
Inflated request — a fake-clock timer the test will never advance to DeclareOperationSize at the request site (e.g. via MutationTimeGuard.Wrap) MutationEnvelopeException, before the wait starts
Stalled wait — a blocked await or poll a mutant made unreachable flow MutationTesting.CancellationToken into the wait OperationCanceledException at the deadline
  • MutationTesting.CancellationToken — cancelled at the engine’s per-mutant deadline; None outside a mutation run. Flow it into (or link it with) your test helpers’ waits.
  • MutationTesting.DeclareOperationSize(size) — declare the size of an operation about to start (a delay in ms, a retry count, …). The baseline pass learns each test’s envelope; a mutant that declares far outside it throws a catchable exception at the request site, before the wait begins.
  • MutationTesting.IsActive / IsMutantActive — gate hooks so they only engage under the runner.
  • MutationTimeGuard.Wrap(timeProvider) — a ready-made delegating TimeProvider adapter for DeclareOperationSize.