Removing Hand-Woven Concurrency Gates

A hand-woven concurrency gate is SemaphoreSlim, ManualResetEventSlim, ManualResetEvent, AutoResetEvent, CountdownEvent or Monitor.Wait — anything that parks a thread until another party signals it. Asynchronous Calls carries the rule; this page is the operational companion: what to write instead, why the finally is load-bearing, and what the sweep that emptied test/ actually measured.

Why a gate in a TEST is a defect, not a style choice

In product code the failure is obvious: the parked thread is a single-threaded hub action block or a grain turn, so the message the gate is waiting on can never be processed — deadlock.

In a test the same primitive fails more quietly, and it has bitten here twice:

The two shapes, and how to tell them apart

Ask which way the signal travels.

1. Producer → test: an AsyncSubject<Unit> the producer completes

A handler, a pooled leaf, a render body, a .Finally(...), a Disposable.Create(...) — anything inside the system telling the test "I got here".

var entered = new AsyncSubject<Unit>();

pool.InvokeBlocking(_ =>
{
    entered.OnNext(Unit.Default);
    entered.OnCompleted();     // AsyncSubject replays after completion — a late awaiter still sees it
    …
});

await entered.Should().Within(5.Seconds()).Emit("the leaf must be running before the drain");

The negative direction — nothing should have happened yet — is the same subject with NotEmit, which is the one place a fixed wait is correct because there is no positive signal to await:

await fired.Should().NotEmit(300.Milliseconds(),
    "Disposed must not fire while the leaf is still running");

🚨 Never .Wait() the subject. await …Should()…Emit() suspends the test; .Wait() parks its thread and is the very defect the other ratchet exists for. Where an assertion helper does not fit, await x.Timeout(...).Await(ct) (ObservableAwait.Await, in MeshWeaver.Messaging.Hub) is the one sanctioned wait.

Making the test async Task to accommodate this is normal and expected; several void tests were converted in exactly that way.

2. Test → a worker it deliberately parks: a volatile flag under a bounded SpinUntil

Half of these tests exist because something blocks: a wedged action block, "a leaf that ignores its cancellation token", a subscribe that never returns, a merge turn held open so an ack provably cannot arrive. The park stays — it is the subject. What goes is the kernel handle:

var releaseGate = 0;

primary.Update(_ =>
{
    gateEntered.OnNext(Unit.Default);
    gateEntered.OnCompleted();
    SpinWait.SpinUntil(() => Volatile.Read(ref releaseGate) == 1, TimeSpan.FromSeconds(60));
    return null;
}, _ => { });

try
{
    await gateEntered.Should().Within(10.Seconds()).Emit("the turn must be parked before the write");
    …
    Volatile.Write(ref releaseGate, 1);
    …
}
finally
{
    // 🚨 THE LOAD-BEARING LINE
    Volatile.Write(ref releaseGate, 1);
}

SpinWait.SpinUntil(predicate, timeout) returns the same bool the event's Wait(timeout) did, so a site that measured whether the release arrived (rather than merely waiting for it) keeps its assertion unchanged. It allocates no handle, needs no using, and — like the event — never pumps a SynchronizationContext, which matters where the test's whole point is that the blocked thread must not run a queued continuation.

3. "Did that thread finish inside a budget?" → Thread.Join(timeout)

Not an event. Join returns only on real termination, whereas an event fired from a finally signals before the thread has actually ended (#2792).

4. No signal back at all, where the block is the whole subject

When a test needs only "this leaf outlives the drain budget", a bounded Thread.Sleep is the subject and needs no release — see IoPoolResidualNamesItsPoolTest. Prefer this when it does not cost wall-clock time; prefer the flag when the release must be prompt.

🚨 The release goes in a finally

Every one of the ~20 converted parks in this sweep writes its release flag in a finally, and several gained a try that was not there before. This is not tidiness: without it, an assertion that throws before the release leaves a worker parked for its full bound — 20, 30, 60 seconds — and that hold outlives the test. Where a helper owns the park (WriteWaitsForCommitVerdictTest's OwnerGate), Dispose() releases and the call site uses using var gate = await Park…(path), which gives the same guarantee.

Idempotence is what makes this safe: a mid-test Volatile.Write(ref flag, 1) followed by the same write in the finally is harmless, so the happy path stays readable.

What the sweep measured

Seeded 2026-08-30 by the guard's own scanner (comments and string literals masked, so a doc comment naming the ban is not a site):

Sites 79 → 0
Files 23
Primitive ManualResetEventSlim, every one
Largest single file test/MeshWeaver.Hosting.Test/IoPoolTest.cs — 32
Projects Hosting, Hosting.Monolith, Hosting.Orleans, Messaging.Hub, Layout, Graph, Persistence, Autocomplete

src/, tools/, samples/, clients/ and memex/ were already at zero and stayed there.

The ratchet after the sweep

HandWovenGateRatchetGuard now runs one tier, not two. test sits in ProductionRoots alongside every other root, held at zero with no allow file anywhere — there is nothing to append a line to, which is the strongest form this rule can take. Two things keep that honest:

The only surviving exemptions are the two verified SanctionedGates: the one sealed inside IoPool (the mesh's single async/IO boundary) and the standalone ThumbnailGenerator CLI, whose exemption is re-checked every run against the premise it rests on — that its csproj references no MeshWeaver assembly, so there is no hub in that process to park.

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