A timed-out test stops in its helpers too

The previous entry closed the gap between a test's Timeout and the wait it parks in. It closed it for the waits a test reaches directly. This one closes it for the waits a test reaches through its own helper methods, which is where a long test actually spends its time.

Why the analyzer could not see this. xUnit1069 is satisfied by one reference to TestContext.Current.CancellationToken anywhere in a test method. A test that hands the token to its first await and then calls await SeedTheSpace() — where SeedTheSpace polls on a bare Task.Delay(300) or waits on an untokened .Await() — is green to the analyzer and still outlives its verdict: xunit records the timeout, the body stays blocked in the helper for up to that helper's own deadline, and the runner carries it into the next test. That is the hung-shard behaviour the rule exists to end, and buying the analyzer's approval without it was the one thing not to do.

What changed. Every wait in the test tree was located and attributed to the member that lexically owns it — .Await(), Task.Delay, WaitAsync, ReactiveWait.First, ObserveCompletion, and the reactive assertion terminals .Emit() / .Match() / .Complete() / .NotEmit(). Of 2,847 wait sites, 194 in 110 files sat in a helper and took no token. 185 of them now take one, across 106 files: the helper's own CancellationToken parameter where it has one, and TestContext.Current.CancellationToken otherwise — the same token the test body holds, and the documented default of the repository's own test-base primitives.

The nine sites that deliberately do not, each saying so where it sits — because a wait that must NOT be cancelled is a decision, not an oversight:

left untokened why
PortalFixture.WaitForPortalAsync fixture construction, before any test owns a token
CollectibleUnloadDrain.WaitUntilCollectedAsync a teardown drain — teardown lets work finish, it is never forced
QuiescingHubRefusesNewWorkTest.ReleaseAndDispose releases the held victim so the fixture tears down cleanly
RxFanOutInversionHarness.BothGatedHandlersComplete the delay is the deadlock bound; cancelling it would report "deadlock detected" for a test that merely ran out of time
PodHubTransportTest.Settled the same shape, worse: its result is read as .Should().BeFalse(…), so a cancelled delay would make a timed-out test pass a negative assertion it never finished
MeshTestRunnerTests' Sample cases they are the in-mesh runner's subject, including one that parks forever so the runner's own timeout can be observed

And what is still not fixed, said plainly: 1,292 wait sites inside test bodies still take no token. Each is analyzer-compliant (the method references the token somewhere else) and bounded by its own .Timeout(…), so none can hang a run — but a test with several waits can still be sitting in its third one when the verdict is written. Same defect, larger population, not fixed here. Claiming otherwise would be the failure this change exists to remove.

Two tooling facts, measured rather than asserted

The move to Microsoft.Testing.Platform raised a question about the reusable CI lanes that run satellite repositories' suites. dotnet test picks its runner from the first global.json found walking up from the current directory — measured on the .NET 10.0.400 SDK with dotnet test --help as the instrument: a global.json in a sibling directory does not apply, one in the cwd or an ancestor does. Both lanes run with the caller's checkout as the working directory and keep the platform in a sibling checkout, so the platform's global.json was never going to reach them — and the caller's own always would.

So rather than leave a trap for the first repository that opts in, node-repo-module-pack.yml and node-repo-platform-canary.yml now read the caller's global.json and choose their own flags: VSTest when it selects nothing, xunit's MTP trx reporter when it selects Microsoft.Testing.Platform. Nothing changes for a caller that has not moved. The failure this avoids is a quiet one — under MTP both --logger and -warnaserror were measured to end a run as Zero tests ran, exit 5, which the module lane would have recorded as "the module's own suite failed".

The canary's build-failure classifier widened in the same change: it recognised only CS/FS/BC diagnostics, so an analyzer error — exactly what a pin move onto the 4.x line produces, in the thousands — was filed as "comparison incomplete" instead of the BUILD FAILED at main the lane exists to report. MSB, NU and NETSDK stay classified as infrastructure, so a restore outage is still never reported as "the platform broke you".

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.