Cancel and Join Are Two Questions

Wherever this estate bounds work it does not own — a test case on its own thread, a leaf in Controlled I/O Pooling, a hub being torn down — the same two deadlines appear:

Question Instrument
Did the work finish inside its budget? a clock, or a wait with a timeout
Having been ASKED to stop, did it actually stop? a join on real termination

They must be answered in sequence, on two clocks. A single deadline that both trips the cancellation and gives up waiting is a race, and it is the work's own unwind that loses it.

The failure shape

The runner arms the token to fire a little before it stops waiting, so the two do not fire at the same instant:

// ❌ two deadlines, one clock — the unwind gets only `lead`
budget.CancelAfter(timeout - lead);
if (!thread.Join(timeout))
    return "did not return within Ns — a hung case; the thread is abandoned";

lead looks like a safety margin. It is in fact the entire allowance for terminating: waking from the wait, throwing, unwinding through reflection, running the case's own finallys, disposing a [ThreadStatic] context, and the OS reaping the thread all have to fit inside it. When the machine is loaded — which is exactly when the budget expires at all — they do not, and the work is reported with the opposite verdict to the truth: cooperative work is declared abandoned.

The two verdicts are not interchangeable. "Ended on its token" means the thread is gone; "abandoned" means the process carried a live thread on. A caller that treats them alike has stopped measuring the thing it exists to measure.

What made the allowance shrink to nothing

The instructive part of the measured instance (StaticTestRunner, issue #3442) is that nobody chose a 200 ms unwind allowance. It was inherited, when the completion signal was made stricter:

signal what "done" meant
before an event Set() from the case's finally the body finished unwinding
after (#2792) Thread.Join(timeout) the thread actually TERMINATED

Join is the better primitive — an event fired from a finally signals before the thread has terminated, whereas Join returns only on real termination, and that is what gives a field written on the worker its happens-before. But swapping it in moved the finish line later without moving the deadline, and the slack that had covered thread teardown silently became zero.

🚨 When you tighten what a signal means, re-derive every deadline measured against it. A strictly better primitive can strictly worsen a race.

The shape that is correct

Measure the budget. Then ask. Then allow the unwind a window of its own, which begins at the ask:

// ✅ two questions, in sequence
var overBudget = false;
if (!thread.Join(timeout))                 // 1. is the budget spent? MEASURED, not predicted
{
    overBudget = true;
    AskToStop(budget, name, capture);      // 2. only now — see "the ask goes on its own thread"
    if (!thread.Join(UnwindGrace))         // 3. having been asked, did it stop?
        return Abandoned(...);             //    …no: the thread is genuinely hung
}

Three properties follow, and each of them is the point:

The ask goes on its own thread

CancellationTokenSource.Cancel() runs the token's registrations synchronously on whoever calls it, and those registrations belong to the work being cancelled — a Task continuation, an Rx subscription's disposal, a wait's cleanup. Calling it from the supervising thread lets the supervised code run on, or park, the very thread whose job is to declare that work abandoned.

That is not hypothetical here: IoPool.Drain cancels off the caller's thread for exactly this reason and reports a named residual when the cancel itself never returns (see Controlled I/O Pooling"A residual with NO site is the CANCEL join"). A dedicated thread is also immune to thread-pool starvation, which is the condition the machine is in whenever this path is reached.

new Thread(() =>
{
    try { budget.Cancel(); }
    catch (Exception ex) { capture($"the cancellation callback threw: {Innermost(ex)}"); }
})
{ IsBackground = true, Name = $"cancel:{name}" }.Start();

The catch is surfacing, not swallowingCancel() aggregates whatever the registrations threw, and an exception left unhandled on a bare Thread takes the process down.

🚨 Do not then dispose the CancellationTokenSource. On the path where an ask was issued, the canceller may still be inside Cancel() and the abandoned worker still holds the token; disposing under either is a use-after-dispose, which in this estate surfaces as an exit-139 nobody can reproduce. Dispose only on the path where the work finished inside its budget and no ask was ever made.

Reproducing a race like this deterministically

A race you cannot provoke is a race you have not understood, and re-running until it fails is not an experiment. Here the mechanism names the stimulus directly: the window is too small for the unwind, so make the unwind cost time on purpose and the failure becomes deterministic.

public static void EndsOnItsBudgetButTakesTimeToDie()
{
    try
    {
        TestContext.Current.CancellationToken.WaitHandle.WaitOne();
        TestContext.Current.CancellationToken.ThrowIfCancellationRequested();
    }
    finally
    {
        Thread.Sleep(500);   // stands in for the scheduling delay a loaded runner imposes
    }
}

A 500 ms unwind against a 200 ms allowance fails every time; against a whole allowance it passes with seconds to spare, so the same assertion is both the falsification arm and the shipped regression test. The Thread.Sleep is the stimulus, not a wait for a condition — the distinction that separates it from the sleeps Writing Tests forbids.

Checklist

Reach for this page whenever a cancellation and a wait are aimed at the same work:

See also

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.