Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .claude/skills/async/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ Full reference:

### 🚨🚨🚨 ABSOLUTE: no hand-woven async/concurrency primitives — not even a `SemaphoreSlim`

**A `SemaphoreSlim` (or any hand-rolled async gate / lock-for-async / signal) anywhere in `src/` is
FORBIDDEN — outside the one place sealed inside `IoPool`.** `SemaphoreSlim.WaitAsync()`
**A `SemaphoreSlim` (or any hand-rolled async gate / lock-for-async / signal) anywhere in `src/` OR
`test/` is FORBIDDEN — outside the one place sealed inside `IoPool`.** `SemaphoreSlim.WaitAsync()`
blocks/parks a thread and its continuation captures the awaiting scheduler. On a hub it parks the
single-threaded action block (or a grain turn) → the message you're waiting on can never be
processed → **deadlock**. This is the same defect class as `async`/`await`/`Task<T>` in hub code; a
Expand Down Expand Up @@ -220,6 +220,19 @@ hub with `ConfigureAwait(false)`. Everywhere else, a `SemaphoreSlim` is a bug to
test: if your gate runs on (or is awaited from) a hub action block / grain turn / Blazor circuit, it
deadlocks — channel it through a hub or `IIoPool` instead.

🚨 **`test/` is at ZERO too, as of 2026-08-30 — there is no allow file left.** The 79 seeded
`ManualResetEventSlim` sites across 23 test files were all converted, and `HandWovenGateRatchetGuard`
now scans `test` in the same zero-tolerance root list as `src`. Two shapes, chosen by which way the
signal travels: **producer → test** is an `AsyncSubject<Unit>` the producer completes
(`OnNext` then `OnCompleted`), awaited through the assertion helpers
(`await x.Should().Within(...).Emit(because)`, or `.NotEmit(within)` for the negative) — **never
`.Wait()` on it, which merely trips `BlockingBridgeInTestRatchetGuard` instead**; **test → a worker
the test deliberately parks** (a wedged action block, a leaf that ignores its token) keeps the park,
because the park IS the subject, but drops the handle: a volatile `int` polled under a bounded
`SpinWait.SpinUntil(predicate, budget)`, written in a **`finally`** so an assertion that throws
first cannot strand that worker. Full treatment:
[RemovingHandWovenGates.md](../../../src/MeshWeaver.Documentation/Data/Architecture/RemovingHandWovenGates.md).

## Rule 2 — Carry the identity across every boundary

Every framework write primitive (`meshService.CreateNode/UpdateNode/DeleteNode`,
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ If active bleeding genuinely needs a stopgap first, say so EXPLICITLY ("this is

## 🚨🚨🚨 ABSOLUTE: No hand-woven async/concurrency primitives — the actor model does NOT tolerate `SemaphoreSlim`

**A `SemaphoreSlim` — or any hand-rolled async gate, lock-for-async, or signal (`TaskCompletionSource` as a gate, a `Task.Delay` timeout race, `ManualResetEventSlim`, `lock`-around-`await`) — anywhere in `src/` is FORBIDDEN, outside the one place sealed inside `IoPool`.** It parks the single-threaded action block (or grain turn), so the message you are waiting on can never be processed → deadlock. **Serialization channels through the hub** (a `Subject<T>` + `.Select(Run).Concat().Subscribe(...)`, or `GetMeshNodeStream(path).Update(...)`), and **concurrency bounding / one-shot init channels through `IIoPool`** — `pool.Run(...)` held in an *instance* `PromiseCache`/`PromiseSlot`, never a `SemaphoreSlim(1,1)` and never a bare `ConcurrentDictionary<key, IObservable<T>>` (a ReplaySubject latches `OnError` and replays one transient fault forever, #1369).
**A `SemaphoreSlim` — or any hand-rolled async gate, lock-for-async, or signal (`TaskCompletionSource` as a gate, a `Task.Delay` timeout race, `ManualResetEventSlim`, `lock`-around-`await`) — anywhere in `src/` **or `test/`** is FORBIDDEN, outside the one place sealed inside `IoPool`.** It parks the single-threaded action block (or grain turn), so the message you are waiting on can never be processed → deadlock. **Serialization channels through the hub** (a `Subject<T>` + `.Select(Run).Concat().Subscribe(...)`, or `GetMeshNodeStream(path).Update(...)`), and **concurrency bounding / one-shot init channels through `IIoPool`** — `pool.Run(...)` held in an *instance* `PromiseCache`/`PromiseSlot`, never a `SemaphoreSlim(1,1)` and never a bare `ConcurrentDictionary<key, IObservable<T>>` (a ReplaySubject latches `OnError` and replays one transient fault forever, #1369).

Full reference: [/async](.claude/skills/async/SKILL.md) · [ControlledIoPooling.md](src/MeshWeaver.Documentation/Data/Architecture/ControlledIoPooling.md) · memory `feedback_no_semaphoreslim`.
🚨 **`test/` reached ZERO on 2026-08-30 and the allow file is DELETED** — every root is now scanned with no escape hatch. In a test the two shapes are: a producer→test signal is an `AsyncSubject<Unit>` the producer completes, awaited through the assertion helpers (`await x.Should().Within(...).Emit(because)` / `.NotEmit(within)`) — **never `.Wait()` on it**, which only trips `BlockingBridgeInTestRatchetGuard` instead; and a release INTO a worker the test deliberately parks (the park being the subject) is a volatile `int` polled under a bounded `SpinWait.SpinUntil`, written in a **`finally`** so a failing assertion cannot strand it.

Full reference: [/async](.claude/skills/async/SKILL.md) · [RemovingHandWovenGates.md](src/MeshWeaver.Documentation/Data/Architecture/RemovingHandWovenGates.md) · [ControlledIoPooling.md](src/MeshWeaver.Documentation/Data/Architecture/ControlledIoPooling.md) · memory `feedback_no_semaphoreslim`.

## 🚨🚨🚨 ABSOLUTE: Never hand-roll UI / data-binding / persistence / submit — use the framework

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ If you find yourself using `.Take(N)` outside a `SelectMany` that immediately pr

9. **🚨 The async boundary lives at the real I/O edge — defer it as deep as possible.** `async` / `await` / `IAsyncEnumerable` bridge across a *genuine* I/O wait (Postgres, file-system, network). In-memory work is never async: anything that only touches in-process state projects synchronously and lifts to `IObservable<T>` via `IEnumerable<T>.ToObservable()`. An `async IAsyncEnumerable` method that never awaits I/O is a bug. Only the leaf that actually performs I/O is allowed to be async, and it bridges back to the observable contract at one sealed point (`Observable.Create` + `await foreach`), pooling at that edge via the shared **`IIoPool`** governor (`MeshWeaver.Mesh.Threading`). Litmus test: before writing `async`, name the I/O it awaits — if you can't, delete it and return `IObservable<T>`. Full treatment: [Aggregating Providers → "The async boundary lives at the I/O edge"](/Doc/Architecture/AggregatingProviders).

10. **🚨 No hand-woven async/concurrency primitives — the actor model does NOT tolerate `SemaphoreSlim`.** A `SemaphoreSlim` / hand-rolled async gate / lock-for-async / `TaskCompletionSource`-as-a-signal / `ManualResetEventSlim` / `Task.Delay`-timeout-race anywhere in `src/` is FORBIDDEN, outside the one sealed inside `IoPool`. `WaitAsync()` blocks/parks a thread and its continuation captures the awaiting scheduler — on a hub it parks the single-threaded action block (or grain turn) so the message you're waiting on can never be processed → **deadlock** (the lock-shaped twin of rule 1). Serialization ("one at a time") is what the hub action block gives for free — channel through it: `Subject<T>` + `.Select(Run).Concat().Subscribe(...)` (canonical: `KernelExecutor`'s REPL queue, which *replaced* a `SemaphoreSlim`; same shape: `RoutingServiceBase.ActivationSerializer`), or `GetMeshNodeStream(path).Update(...)`. Concurrency bound / one-shot init / connect handshake → `IIoPool` + the promise-cache (`pool.Run(...)` in an instance `ConcurrentDictionary`), never a `SemaphoreSlim(1,1)` `_initLock`/`_connectGate`. The ONLY sanctioned `SemaphoreSlim` is sealed inside `IoPool` (the off-hub I/O boundary). Full treatment: [ControlledIoPooling](/Doc/Architecture/ControlledIoPooling).
10. **🚨 No hand-woven async/concurrency primitives — the actor model does NOT tolerate `SemaphoreSlim`.** A `SemaphoreSlim` / hand-rolled async gate / lock-for-async / `TaskCompletionSource`-as-a-signal / `ManualResetEventSlim` / `Task.Delay`-timeout-race anywhere in `src/` **or `test/`** is FORBIDDEN, outside the one sealed inside `IoPool`. `WaitAsync()` blocks/parks a thread and its continuation captures the awaiting scheduler — on a hub it parks the single-threaded action block (or grain turn) so the message you're waiting on can never be processed → **deadlock** (the lock-shaped twin of rule 1). Serialization ("one at a time") is what the hub action block gives for free — channel through it: `Subject<T>` + `.Select(Run).Concat().Subscribe(...)` (canonical: `KernelExecutor`'s REPL queue, which *replaced* a `SemaphoreSlim`; same shape: `RoutingServiceBase.ActivationSerializer`), or `GetMeshNodeStream(path).Update(...)`. Concurrency bound / one-shot init / connect handshake → `IIoPool` + the promise-cache (`pool.Run(...)` in an instance `ConcurrentDictionary`), never a `SemaphoreSlim(1,1)` `_initLock`/`_connectGate`. The ONLY sanctioned `SemaphoreSlim` is sealed inside `IoPool` (the off-hub I/O boundary). **In a TEST** the same primitive strands a deliberately blocked worker whenever an assertion throws before the release runs, so `test/` is held at zero too, with no allow file: a producer→test signal is an `AsyncSubject<Unit>` awaited through the assertion helpers, and a release INTO a worker the test parks on purpose is a volatile flag polled under a bounded `SpinWait.SpinUntil`, written in a `finally`. Full treatment: [ControlledIoPooling](/Doc/Architecture/ControlledIoPooling) · [Removing Hand-Woven Concurrency Gates](/Doc/Architecture/RemovingHandWovenGates).

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
Name: Removing Hand-Woven Concurrency Gates
Category: Architecture
Description: The two shapes that replace a ManualResetEventSlim in a test, why the release must live in a finally, what the 79-site sweep measured, and the ratchet that now holds every root at zero with no allow file.
Icon: <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
---

# 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](../AsynchronousCalls) 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:

- **A stranded worker.** `IoPoolResidualNamesItsPoolTest` put its `release.Set()` *after* an
`await` of the method-timeout token. On timeout that line never ran, so the leaf it had
deliberately blocked kept a pool thread for two full minutes — **into the next test**, which then
failed for reasons that had nothing to do with it.
- **A ratchet swap.** The first repair traded the event for an `AsyncSubject` plus `.Wait()`. That
is an observable→blocking bridge, which parks the thread just the same and trips
`BlockingBridgeInTestRatchetGuard` instead. Satisfying one guard by violating another is not a
fix.

## 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".

```csharp
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:

```csharp
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:

```csharp
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:

- `TheTestTreeAllowFileStaysDeleted` fails if `test/HandWovenGateSites.allow` reappears. A cleared
inventory that is quietly re-seeded is exactly how one grows back, and the allow-file reader is
explicit that a *regenerated* file blesses whatever happens to be in the tree.
- `TheScannerSeesWhatItClaimsTo` plants a real temp tree and runs the **real** scan over it —
proving the scanner sees a genuine gate, sees a `.csx` script, and does **not** see a gate named
in a comment or a string literal. A guard whose self-test only exercises its regex can lose half
its scope and stay green.

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.

## Related

- [Asynchronous Calls](../AsynchronousCalls) — the rule itself, and the no-`async` contract it belongs to
- [Removing Observable-to-Task Bridges](../RemovingObservableToTaskBridges) — the sibling ratchet, and why `.Wait()` is not an escape from this one
- [Controlled I/O Pooling](../ControlledIoPooling) — where concurrency bounding legitimately lives
- [Writing Tests](../WritingTests) — the house rules the converted assertions follow
Loading
Loading