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).
1. A test that owns time
Section titled “1. A test that owns time”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.
2. The mutant that hangs it
Section titled “2. The mutant that hangs it”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.
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.
3. Wire the guard
Section titled “3. Wire the guard”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.
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).
Your framework’s token vs Mutato’s — why link both?
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 |
The API
Section titled “The API”MutationTesting.CancellationToken— cancelled at the engine’s per-mutant deadline;Noneoutside 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 delegatingTimeProvideradapter forDeclareOperationSize.