Mesh Lifecycle: Build Up & Tear Down
A mesh is an actor system wired into a DI (Autofac) service scope. Standing it up and taking it down are mirror images, and the tear-down side has ONE rule that is easy to get wrong and produces a brutal, run-aborting failure when you do:
You may not dispose the service scope until every piece of the mesh's work has finished — and "work" is TWO things, not one.
Skip it and a late continuation resolves a service from the already-disposed scope
and throws
ObjectDisposedException: …LifetimeScope… has already been disposed. Unobserved,
xUnit reports it as a "catastrophic failure" that aborts the whole test
collection — every later test in the run then times out for reasons unrelated to
its own logic.
Build Up
var mesh = new MeshBuilder(/* … */)
.UseMonolithMesh() // or UseOrleansMeshServer() on a silo
.ConfigureServices(s => s.AddSingleton<MyRepository>()) // mesh-scoped singletons
.AddGraph()
.Build(); // builds the Autofac scope + root hub
Two invariants make tear-down tractable, so honor them at build time:
- Everything stateful is a mesh-scoped singleton, registered in
MeshBuilder(ConfigureServices/WithServices). Its lifetime IS the mesh's — it dies with the scope, so there is nothing toClear(). Nostaticcollections/caches (see NoStaticState.md). TheIoPoolRegistryis exactly this: a mesh-scoped singleton owning everyIIoPool. - Every async/blocking edge goes through
IIoPool(see ControlledIoPooling.md). This is what makes the offloaded I/O countable at tear-down — the registry knows how many operations are in flight. BareObservable.FromAsync/Task.Runwork is invisible to tear-down and is exactly the leak that throwsObjectDisposedException.
Tear Down — quiesce activities, drain ALL THREE phases, THEN dispose the scope
IMessageHub.Dispose() is reactive and returns immediately — it only kicks off
the disposal state machine. Completion is signalled later through
IMessageHub.DisposalCompleted. But DisposalCompleted covers only the first of
three kinds of in-flight work; tear-down must drain all three before the service
scope is disposed:
| # | In-flight work | Drained by | Why it's separate |
|---|---|---|---|
| 1 | Hub action blocks + in-flight message round-trips (hub.Observe, GetMeshNode, …) |
IMessageHub.DisposalCompleted |
Runs on the hub's single-threaded action block; the disposal state machine waits for the response subjects to drain. |
| 2 | Offloaded I/O — anything sent through IIoPool (DB, blob, HTTP, compile) |
IoPoolRegistry.DrainAll() — grace, then cancel, then join, synchronous; returns the count of leaves that did not stop and names the ones it had to cancel |
Runs on the ThreadPool, independent of the action block. DisposalCompleted does not know about it. |
| 3 | Async cleanup a resource cannot finish inside its synchronous Dispose() (flush a write queue, await a stream) |
AsyncDisposeQueue.DrainAsync(quiesce) |
Dispose() may not block; resources Enqueue their async cleanup onto the queue and a TPL ActionBlock drains it. |
The async dispose queue is the key to phase 3: Dispose() is synchronous and must
never block, but some cleanup is genuinely async. So in their sync Dispose(),
resources Enqueue(ct => …) their async cleanup onto the mesh-scoped
AsyncDisposeQueue instead of running or leaking it. A single-consumer ActionBlock
drains it in the background; tear-down gives it a bounded quiesce budget to finish.
DrainAsynccompletes the block, it does not wait for a version target. The queue is a message stream — under continuous influx, "wait until the drained version reaches N" never converges (endless messages).DrainAsyncinsteadComplete()s the block (stops acceptance) and awaits the remainder, so it is bounded even while producers are still posting. TheDrainedVersioncounter (one per item) is the test hook: enqueue N, drain, assert it advanced by N.
There is also a phase 0 that is easy to miss: an in-flight activity falls through all
three phases above — its trigger returned as soon as the activity existed, its command runs
off-turn so it holds no grain turn, and the subscribe-window pool permit is long released, so
DrainAll() joins nothing. So teardown quiesces the activities first, before anything is
disposed — ActivityTracker.Quiesce(ActivityStallBudget): a run that keeps reporting progress
(every ctx.Log line) is waited for; one that has made no progress for the stall budget (8 s) is
cancelled once, through the same token a user's cancel would trip, and named on the report; one
that ignores that is named as abandoned a budget later so the teardown can proceed. Those runs
still write their terminal ActivityLog status through hubs that must still be alive. (Measured
before any of this existed: a 5 s activity, and teardown returned after 2028 ms with the command
still running.) Policy and evidence: Teardown Layers.
The canonical helper does all of it:
// MeshWeaver.Mesh.MeshTeardownExtensions
var report = await mesh.TeardownAsync(timeout); // activities → Dispose() → DisposalCompleted → DrainAll → AsyncDisposeQueue
// ONLY NOW is it safe to dispose the Autofac scope:
await ((IAsyncDisposable)mesh.ServiceProvider).DisposeAsync();
TeardownAsync:
- captures the
IoPoolRegistry,AsyncDisposeQueue,ActivityTrackerandMeshTeardownSignalwhile the scope is still alive (never resolve DI once disposal has begun — see the note inMessageHub's ShutDownfinally), - runs
ActivityTracker.Quiesce(bounded by the caller's timeout) — phase 0 — and logs every run it had to cancel or abandon at Error, - calls
mesh.Dispose()(resources enqueue their async cleanup during this reactive disposal), - awaits
DisposalCompleted(phase 1), - calls
IoPoolRegistry.DrainAll()(phase 2), then - awaits
AsyncDisposeQueue.DrainAsync(timeout)(phase 3), and finally - fires
MeshTeardownSignalwith theTeardownReport.
🚨 Phase 2 gives a GRACE, then CANCELS, then joins — it never merely waits.
DrainAll()first lets every in-flight leaf finish on its own: it re-acquires the gate permits one at a time underIoPoolOptions.DrainGrace(8 s per completion — a stall bound, so a burst of short writes drains in as many completions), names the leaves that outlived it, and only then cancels. The cancel is still required: a live change-feed leaf never completes on its own, and the wait-only, polledWhenDrained(timeout)— which still exists onIoPoolRegistryand is the wrong primitive here — timed out and let the scope dispose while the leaf still ran; its ThreadPool thread then dereferenced a collectible node ALC's freed metadata after unload → a native use-after-unload SIGSEGV. After the cancelDrainAll()joins, and returns how many leaves did not unwind. A leaf it had to cancel after the grace is a unit of work that did not finish — reported at Error with its call site (TeardownReport.CancelledIoByPool).
TeardownAsync returns a TeardownReport (leaked I/O leaves + whether the async dispose
queue drained clean). Surface a dirty report — fail the test class, error-log the shutdown.
Proceeding silently over live work is the use-after-unload crash above.
If a caller drives Dispose() itself and keeps its own progress/diagnostic loop
around DisposalCompleted (the monolith test base does), it uses the wait half
directly — pass the services captured before Dispose():
var ioPools = mesh.ServiceProvider.GetService<IoPoolRegistry>(); // capture first
var disposeQueue = mesh.ServiceProvider.GetService<AsyncDisposeQueue>();
mesh.Dispose();
await WaitWithProgressAsync(...); // phase 1 (DisposalCompleted)
var leakedIoLeaves = ioPools?.DrainAll() ?? 0; // phase 2 (cancel + join)
if (disposeQueue is not null)
await disposeQueue.DrainAsync(timeout); // phase 3 (async cleanup)
// THEN dispose the scope
Every phase is bounded by a timeout. A wait that times out means a real bug — a
wedged action block (surfaced separately by AnyHubQuiescingTimedOut), a leaked
I/O slot (a non-zero leakedIoLeaves / IoPoolRegistry.TotalInFlight), or a wedged async
cleanup. The timeout keeps tear-down from hanging; it does not paper over the leak
— log it and fix the leak, never just widen the timeout (see the no-band-aids rule).
🚨 Phase 2 was the exception, and it was invisible.
IoPool.Drain()'s budget covered only the gate join; the_poolCts.Cancel()ahead of it ran on the CALLING thread — i.e. this very teardown thread — andCancellationTokenSource.Cancel()executes every registered callback synchronously there. Those callbacks tear down whole pooled subscriptions (see Controlled I/O Pooling), so one clean-up leg that would not return parked teardown for as long as anyone waited, writing nothing anywhere: issue #2394, a whole test assembly killed at its 8 min wall-clock cap with no test named and not one line afterDISPOSE_INVOKED. The cancel now runs on its own thread and is joined under the same budget, so a stuck leg is reported inleakedIoLeavesinstead of hanging.
The order is the whole point
WhenIdle → Dispose() → DisposalCompleted → DrainAll() → AsyncDisposeQueue drain → dispose scope
(activities) (enqueue async (action blocks) (cancel+join (async cleanup)
cleanup here) ThreadPool I/O)
└────────────── nothing may resolve DI after this ──────────────┘
Dispose the scope one step early — before phase 2 or 3 — and any straggler IIoPool
continuation or un-run async cleanup that resolves a service hits a dead scope. That is
THE catastrophic ObjectDisposedException.
Where this is wired
| Context | Tear-down site |
|---|---|
| Monolith tests | MonolithMeshTestBase.DisposeAsync — WaitWithProgressAsync (phase 1) + IoPoolRegistry.DrainAll() (phase 2) + AsyncDisposeQueue.DrainAsync (phase 3) before base.DisposeAsync releases the per-[Fact] provider. |
| Host shutdown (prod) | Same shape — TeardownAsync (or the three phases by hand) before the host disposes the root scope. |
| Orleans test cluster | Exception — do NOT hand-roll this. The whole TestCluster.DisposeAsync is handed to a background pool (OrleansClusterDisposal.DisposeInBackground) because awaiting any part of silo shutdown on the xUnit teardown thread deadlocks — silo shutdown drives continuations that the blocked thread owns. So you must not manually Dispose()/TeardownAsync a live silo's root mesh hub at fixture teardown (double-dispose + the same deadlock). The "offloaded work draining against a disposed scope" race on the silo side is absorbed by OrleansShutdownRaceSuppressor, not by an inline drain. |
See also: TestStateIsolation.md (per-test disposal + static seed), ControlledIoPooling.md (the I/O pool the drain waits on), NoStaticState.md (why everything is mesh-scoped).