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 connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.