Ambient Test-Host Hangs

A test host that is killed rather than failing an assertion leaves almost nothing behind. This page is the standing record of that family: what its signatures mean, the two readings of it that have already turned out to be wrong, the one property that decides whether an occurrence is investigable at all, and what is still genuinely unexplained.

Read it before opening a new investigation. The most recent one began from an issue body whose own author had retracted its central claim in the comments, and from a root cause the same issue recorded as reverted after it had been re-fixed by another route. Both were one gh issue view --comments away.

🚨 The first rule: re-run the issue's own measurement before acting on it

Every claim below is dated, because they change. Two examples from a single day, 2026-09-01:

So: before investigating, re-measure. Read the comments, not only the body; check whether the named root has landed since the occurrences; and check the dates on both.

Silence is not a symptom

dotnet test with the console logger prints two header lines per suite and then nothing until the final summary. A suite that is running normally and a suite that is wedged look identical for their entire duration. A passing 383-test suite in the same run showed 1 m 52 s of unbroken silence.

"No output for N minutes" therefore distinguishes nothing. It is the normal shape. Do not offer it as evidence, and do not accept it as evidence.

The same goes for Terminate orphan process: pid (…) in the runner's cleanup. xUnit v3 launches the test assembly as its own child process, so that line is true of any suite killed mid-run. It is not by itself a leak, a wedge, or a failure to exit.

🚨 What decides whether an occurrence can be diagnosed: how the test dies

This is the single most useful fact about the family, and it is a property of configuration, not of the bug:

How the test dies Transcript of the hung test Usable as evidence
xUnit methodTimeout (30 s) written in full yes
host wall-clock cap (exit=124 / HOST_CRASHED) destroyed β€” no trx entry, no captured stdout no

The family's defining artefact is destroyed by the family's own failure mode. An investigation that waits for a CI occurrence and then reads the artifacts is reading everything except the thing that hung: the flood of log lines that survives belongs to the tests that completed, so it cannot tell you whether any of it was co-located with the hang.

Two consequences worth acting on:

  1. Triage should hunt this signature among method-timeout failures, not only among crashed shards. Those instances carry the evidence and are routinely discarded as ordinary flakes.
  2. methodTimeout reaching the test host is a diagnosability dependency, which is why the next section is about MSBuild rather than about the mesh.

The guard that could not reach the fleet

test/Directory.Build.props selects one of two Content branches for xunit.runner.json and for appsettings.json β€” the shared default, or a project-local override. If a rename or a reworked condition ever makes it select neither, the build still succeeds and the tests still run: under xUnit's own defaults, where parallelizeTestCollections is true, the thread count is the machine's core count, and there is no methodTimeout at all.

That last one turns every wedge in the affected assembly from a bounded, attributable failure that carries its own transcript into a host kill that destroys its evidence β€” the top row of the table above silently becoming the bottom row.

VerifyXunitRunnerConfigCopied exists to make that regression red. It used to live in test/Directory.Build.targets. MSBuild imports Directory.Build.props and Directory.Build.targets independently, and every satellite that consumes the plumbing imports the props explicitly from its own src/Directory.Build.props β€” MeshWeaver.Plugins (78 *.Test projects) and MeshWeaver.SocialMedia (1) β€” while neither imports the targets. Measured 2026-09-01: zero of the five satellite checkouts (.Plugins, .Education, .Reinsurance, .SocialMedia, .Manufacturing) reference test/Directory.Build.targets at all. They inherited the plumbing without the assertion that it had worked: 79 test projects without the guard, against core's ~15 with it.

The guard now lives in test/Directory.Build.props, in the same file as the includes it validates, so importing the mechanism cannot leave the check behind β€” no satellite change required. DirectoryBuildGuardTravelsWithItsSubjectTest fails if it drifts back apart.

This is the rule AGENTS.md states for CI gates, one layer down: a gate must never be separable from the input it validates, because a gate that never runs is indistinguishable from a gate that passed. See Reading CI Signals for the same failure at the workflow level.

Signatures, and what they are worth

The line most often quoted for this family is:

Dropping StreamEndedEvent … no synchronization hub found on this hub or any parent
  β€” the target stream is gone

On its own it is worth nothing. It has been measured in at least two unrelated defects, and a hub that is going down for any reason emits it. Grouping by this string merges populations that need separating.

🚨 That exact string no longer exists, so re-running the old grep returns a FALSE NEGATIVE. On 2026-09-01 the drop diagnostic was split by message type: a StreamEndedEvent to an already-departed subscriber is a semantic no-op (its whole meaning is "nothing more comes"), so it moved to Debug and got different wording β€” "the target stream is already gone, and a terminal notice to a departed subscriber is a no-op". It no longer contains "no synchronization hub found on this hub or any parent" at all. Two independent reasons a search for the historical line now finds nothing: the level and the text. Every other dropped StreamMessage keeps the original warning, because a lost Full renders a region blank.

Searching a post-2026-09-01 capture, match on Dropping StreamEndedEvent alone and make sure the capture includes Debug for MeshWeaver.Data.DataExtensions.

The discriminator that has actually separated cases is whether [UpdateQueue] ADVANCE_WITHOUT_HANDOFF … bound=5000ms appears inside the hung test's own window, within milliseconds and on the same path. Present β‡’ the update queue advanced because the owner never acknowledged a write, which is the missed-observation mechanism. Absent β‡’ a different mechanism wearing the same log line. It has returned both answers on real data, which is what makes its positive answer worth something.

A second family of evidence that reads as damning and is not: teardown-stragglers.log files full of ObjectDisposedException with TeardownOrderedScopeDisposal.CloseWhenDrained on top. Those are first-chance exceptions, and that call site catches exactly that type:

catch (ObjectDisposedException)
{
    // The root itself is gone β€” the drains ran (or nothing will run them). Close now.
    closeScope();
    return;
}

They are a clock, not a cause β€” and they arrive in bursts at a handful of distinct instants, which is the shape of successive test classes disposing their meshes, i.e. progress. On one captured run the last straggler landed 7 m 53 s before the kill.

What is still open

What ends a live node's synchronization streams roughly five seconds into an operation that is still running. The caller-side consequences are understood and fixed β€” the verdict now reaches the waiter rather than being posted into a hub that has stopped listening, and the queue's 5 s advance bound is deliberately independent of the caller's terminal (failing the caller there would abandon writes that are about to land). What remains unexplained is the owner going away mid-run.

Both suites that witness it reproducibly live in MeshWeaver.Plugins, and the class is bulk-only β€” it passes in isolation and needs the co-tenancy of a full run. So the productive route is a local bulk run with the process inspected while it is still wedged, not another artifact dive.

🚨 Measuring a hang on a loaded machine measures the machine

CPU starvation produces this family's exact signature: a host that starts, emits nothing, and sits there. A "reproduction" observed on a saturated box is indistinguishable from the real thing.

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