Repository navigation
fix(messaging): close the teardown intake gate at the first instant of disposal - #3557
Conversation
…f disposal The shutdown gate in MessageService.ScheduleNotify refused new deliveries only from RunLevel >= DisposeHostedHubs, so the whole Quiescing phase was wide open. A hub inside its drain — including one already past [QUIESCE-OK], having drained everything it owed — kept accepting requests, running their handlers, creating hosted hubs for them and registering response callbacks for the sub-requests those handlers issued. Work taken on there cannot finish: the quiesce budget is the only time the hub has left and it is already spent, so the next phase cancels it and the requester burns its full bound before hearing HubDisposedBeforeResponseException. Measured on three bake runs (2026-09-06) off the RECEIVED runLevel= stage in the request-fate trails [QUIESCE-TIMEOUT] prints: six of nine callbacks pending at the timeout had been taken on by a hub that was already Quiescing. The gate is now two tiers. From DisposeHostedHubs it is unchanged. From Quiescing it refuses exactly "a NEW request, addressed to this hub, whose sender is waiting for an answer this hub can no longer promise", with the same transient ErrorType.ShuttingDown NACK — ShutdownNack.RejectingNow, activation identity and all — that the later tier posts. It deliberately does NOT refuse: a delivery carrying PostOptions.RequestId (an answer, which is what the drain is waiting for); transit to a hosted child (alive until the next phase); or fire-and-forget nobody awaits. The admission test is factored out of NackThroughParent as IsAwaitedBySender so "refused" and "NACKed" are the same set by construction — a refusal the gate could not answer would be the silent drop the NACK exists to remove. A root hub with no parent to carry the NACK answers the sender itself through ReportFailure, which is available at Quiescing and is not past DisposeHostedHubs. No bound is widened. #3261 settled that a bigger QuiesceTimeout buys a slower leak, and teardown still lets accepted work finish. QuiescingHubRefusesNewWorkTest pins both halves; the refusal half fails on the pre-fix gate with "Assert.Throws() Failure: No exception was thrown" plus a 10s disposal stall — the leaked callback itself. Closes #3506 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LzrcSMRzD2R5UcGDk5TKU
There was a problem hiding this comment.
🟡 Changes recommended
The new shared IsAwaitedBySender RawJson exclusion relies on a broad substring match for DeliveryFailure, which can suppress NACK/refusal behavior for unrelated awaited RawJson payloads containing that text.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR tightens MeshWeaver’s messaging teardown behavior by closing the “intake gate” at the start of disposal (Quiescing), preventing hubs that are already draining from accepting new correlated work that cannot complete before teardown progresses.
Changes:
- Introduces a two-tier intake gate in
MessageService.ScheduleNotify, refusing new awaited requests duringQuiescingwhile still allowing replies (correlated byPostOptions.RequestId) and transit traffic to hosted children. - Refactors the “is this awaited by the sender?” admission predicate to be shared by both the intake gate and
NackThroughParent, keeping “refused” and “NACKed” sets aligned. - Adds deterministic tests pinning both halves: (1) new correlated work is refused while draining, and (2) replies to pre-quiesce accepted work still land and disposal completes.
File summaries
| File | Description |
|---|---|
src/MeshWeaver.Messaging.Hub/MessageService.cs |
Adds the Quiescing-tier intake gate and factors shared admission logic used for refusal/NACK behavior during teardown. |
test/MeshWeaver.Messaging.Hub.Test/QuiescingHubRefusesNewWorkTest.cs |
Adds deterministic regression tests ensuring Quiescing refuses new correlated work but still admits replies that settle existing callbacks. |
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-07-a-hub-that-is-shutting-down-stops-taking-on-new-work.md |
Adds a user-facing “What’s New” entry describing the behavioral fix. |
src/MeshWeaver.Documentation/Data/Architecture/TeardownLayers.md |
Documents the updated teardown intake-gate layering and rationale. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| private static bool IsAwaitedBySender(IMessageDelivery delivery) => | ||
| (delivery.Message is IRequest | ||
| || (delivery.Message is RawJson rawJson | ||
| && !rawJson.Content.Contains(nameof(DeliveryFailure), StringComparison.Ordinal))) | ||
| && delivery.MayAnswer(); |
There was a problem hiding this comment.
Good catch on the shape, but this is a verbatim move of the pre-existing test out of NackThroughParent (it sat two lines above the intake gate before this diff), and both halves of the concern point the other way here.
The false positive is benign in both callers, and it is the direction that was chosen deliberately.
- At the new Quiescing tier a false positive makes
IsAwaitedBySenderreturnfalse, soRefusesIntakedoes not refuse — the delivery is accepted and handled exactly as it is on today'smain. There is no silent drop: the request is served. - In
NackThroughParenta false positive means "do not NACK", which reverts that one delivery to the historical silent drop. The comment that shipped with Both Orleans routers' answer-once guards are dead code: Package() erases the payload type before they run, so the router NACKs heartbeats and can NACK a NACK #1485 says exactly that, which is why the sniff is a fallback rather than the rule.
And the sniff only ever decides an UNPACKAGED RawJson. Every delivery that crossed a hub boundary went through MessageDelivery.Package, which stamps AnswerPolicy.SuppressedProperty on the envelope before the payload type is erased — so delivery.MayAnswer() (the second half of the &&) already answers "is this a DeliveryFailure / [CanBeIgnored]" authoritatively for those, off the envelope rather than off the bytes. The content test exists solely for a frame that never went through Package: an external client's pre-serialised body, which carries no stamp.
Scoping it to the $type value would trade a benign failure for the storm. The tight form has to match a literal ("$type":"DeliveryFailure"), and the writer's settings decide whether there is a space after the colon. A miss there is not the safe direction: it would let a DeliveryFailure through as "awaited", and NACKing a NACK between two concurrently-disposing hubs is the ping-pong this exclusion exists to prevent — the 465k-DeliveryFailure storm documented in ReportFailure. A brittle exact match guarding against a storm is worse than a loose match whose miss costs one delivery a NACK it was never guaranteed.
If the substring test is worth tightening it should be its own change against AnswerPolicy — reading the discriminator through the JSON parser rather than by string search, for both call sites at once — pinned by a test that feeds it a payload whose user text contains "DeliveryFailure". It is not something to change inside a fix whose whole point is that a teardown refusal must be answerable.
|
Good catch on the shape, but this is a verbatim move of the pre-existing test out of The false positive is benign in both callers, and it is the direction that was chosen deliberately.
And the sniff only ever decides an UNPACKAGED RawJson. Every delivery that crossed a hub boundary went through Scoping it to the If the substring test is worth tightening it should be its own change against |
…ondition CI shard 5 red: SubscribeDuringRecycleTest asserted its NACK names HubDisposingException, and explicitly that it "was not answered by MessageService's intake NACK". Its own class doc states the precondition it relied on: "Message intake, meanwhile, stays open until DisposeHostedHubs — the whole Quiescing drain sits inside the gap. So a disposing hub happily ACCEPTS work it can no longer perform." That IS #3506, written down as a fixture. Closing the gate at Quiescing removes the ARRIVAL path into the layout stack, which is the point: the subscribe is now turned away at the door before the hub takes on work it has no drain left to finish, and the caller still gets a transient, owner-minted ShuttingDown NACK, so SynchronizationStream's resubscribe latch survives exactly as before. The stream-creation seam is NOT gone and must stay pinned, so the test now pins BOTH doors instead of one: - Door one (re-aimed, and the one the original test was about): a subscribe ACCEPTED while the hub was Started whose turn runs after Dispose froze hosted-hub creation. Deterministic without sleeps or racing — a Blocker parks the turn loop, Dispose() is called (freezing creation synchronously while its ShutdownRequest queues behind the blocker, so RunLevel is provably still Started), the subscribe is admitted at Started, and the fate ledger is polled for ENQUEUED@area/1 before the blocker is released. The intake gate cannot help here by construction: the delivery was already in the queue when the door closed. Assertions unchanged — ShuttingDown, names HubDisposingException, never NullReferenceException. - Door two (new): the subscribe that merely ARRIVES during the quiesce — the old fixture, kept whole — is refused at the door, and the assertions state what a subscriber actually acts on: ShuttingDown, ShutdownNack.IsAnsweredByOwner, RunLevel=Quiescing in the banner, and still no NullReferenceException. TeardownLayers gains the same distinction: the gate is at INTAKE, so it cannot un-accept a queued delivery, and Dispose freezing creation before RunLevel leaves Started is a second, narrower window it does not touch — answered one layer down, by design. Local: SubscribeDuringRecycleTest 2/2, MeshWeaver.Layout.Test 451/451, MeshWeaver.Documentation.Test 304/304, Messaging.Hub QuiescingHubRefusesNewWork + DisposalRaceNack 4/4. Release -warnaserror clean on MeshWeaver.Layout.Test, MeshWeaver.Hosting.Monolith, Memex.Portal.Shared and MeshWeaver.Documentation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LzrcSMRzD2R5UcGDk5TKU
HubRunLevelObservableTest's class summary called the disposal window "the interval where the hub is Quiescing/DisposeHostedHubs but intake is still open". That was the defect #3506 fixes, stated as fact in a test file — the exact prose-asserts-a-behaviour-that-no-longer-exists drift that made SubscribeDuringRecycleTest's fixture obsolete without anyone noticing until CI ran it. The test itself asserts RunLevelChanged emissions and is unaffected; only the sentence was wrong. MeshWeaver.Hosting.Test 441/441, Release -warnaserror clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LzrcSMRzD2R5UcGDk5TKU
CI found the one thing local runs could not: a test built on the precondition this fix removesThe first run was green everywhere except
That is #3506, written down and depended on. With the gate closed the subscribe is turned away at The seam it guards is real and still reachable, so it is now pinned properly rather than
Two follow-ups came out of the same read:
Local after the change: |
CI shard 4 red, and NOT on anything the test was measuring: the line it was
waiting for had already been logged. It failed inside the COLLECTOR.
System.ArgumentException : Value does not fall within the expected range.
at System.Collections.Immutable.ImmutableExtensions.ToArray[T](IEnumerable`1, Int32)
at System.Collections.Immutable.ImmutableArray.CreateRange[T](IEnumerable`1)
at ...CapturingLoggerProvider...FirstMatching...b__0(Int64)
`lines` is a ConcurrentQueue the hub's action-block thread writes to while the
poller reads it. `lines.ToImmutableArray()` goes through
ImmutableArray.CreateRange, which reads the sequence's Count, allocates exactly
that many slots, and THEN enumerates — two separate reads of a moving
collection. One enqueue between them yields more items than slots and
ImmutableExtensions.ToArray throws. Nothing about the queue is unsafe; the
materialisation is, because it asks the queue two questions and requires the
answers to agree.
Enumerate the queue directly instead. ConcurrentQueue's own enumerator is a
single moment-in-time snapshot, so it cannot disagree with itself, and the
poll shape the surrounding remarks argue for (never await a live Subject) is
untouched.
Attributed rather than assumed: shard 4 was GREEN on this PR's previous run
(34099955896) and red on 34102515449 with the same code under test, which is
the signature of a race rather than a break — and the stack names the racing
line exactly. Swept the rest of test/ for the same shape: every other
ToImmutableArray() sits on a static file list or a finished LINQ pipeline.
MeshWeaver.Graph.Test LateNackReenqueue* 2/2 locally, Release -warnaserror clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second CI red, root-caused rather than re-run: the log collector disagreed with itselfRun 34102515449,
Fixed by enumerating the queue directly — its own enumerator is a single moment-in-time snapshot, so Attributed, not assumed. Shard 4 was green on this PR's previous run (34099955896) and red Local after the change: |
…take-gate # Conflicts: # test/MeshWeaver.Graph.Test/LateNackReenqueueCorrelationTest.cs
The defect
MessageService.ScheduleNotify's shutdown gate refused new deliveries only fromRunLevel >= MessageHubRunLevel.DisposeHostedHubs, so the entireQuiescingphase was wideopen. A hub inside its drain — including one that had already logged
[QUIESCE-OK], havingdrained everything it owed — kept accepting inbound requests, kept running their handlers, kept
creating hosted hubs for them, and kept registering response callbacks for the sub-requests those
handlers issued.
Work taken on there cannot finish. The quiesce budget is the only time the hub has left and it is
already spent on what it owed at entry; the next phase cancels whatever the new work is waiting
for, and the requester gets
HubDisposedBeforeResponseExceptionafter burning its full bound.[QUIESCE-TIMEOUT] … forcibly cancellingis the tell — and this is the root.Measured on three bake runs (2026-09-06), counting the
RECEIVED runLevel=…stage in therequest-fate trails
[QUIESCE-TIMEOUT]prints: six of nine pending callbacks at the timeout hadbeen taken on by a hub that was already
Quiescing.The fix
The gate is now two tiers, and the new one is deliberately narrower than the old, because a
Quiescinghub is still a live poster and a live router:Quiescing(new)DisposeHostedHubsand beyond (unchanged)ShutdownRequest/DisposeRequestPostOptions.RequestIdAnswerPolicypreventsErrorType.ShuttingDownErrorType.ShuttingDownThe refusal is the same transient, owner-minted NACK the later tier already posted —
ShutdownNack.RejectingNow, activation identity and all — so a caller reads "ask again at thefresh activation", never "gone", and
ShutdownNack.IsAnsweredByOwnerstill identifies the speaker.The admission test is factored out of
NackThroughParentasIsAwaitedBySender, so "refused" and"NACKed" are the same set by construction rather than by two lists staying in step — a refusal the
gate could not answer would be exactly the silent drop the NACK exists to remove. And because a
Quiescinghub can still post (unlike one pastDisposeHostedHubs, wherePostandReportFailureboth decline), a root hub with no parent to carry the NACK now answers thesender itself through
ReportFailureinstead of falling back to the historical silent drop.No bound is widened. #3261 settled the other option: a bigger
QuiesceTimeoutconverts a leakedcallback into a slower leaked callback. Teardown still lets ACCEPTED work FINISH — the second test
below is the half that says so.
Verification
test/MeshWeaver.Messaging.Hub.Test/QuiescingHubRefusesNewWorkTest.cs, two[Fact]s onHubTestBase(no mocking, noTask.Delay, noSemaphoreSlim— the sink→test signal is anAsyncSubjectawaited through.Should().Within(...).Emit(...), and the victim is held inQuiescingby a parked callback rather than caught there by timing):NewCorrelatedRequest_IsRefused_WhileTheHubIsDrainingItsQuiesce— the NACK isErrorType.ShuttingDown, passesShutdownNack.IsAnsweredByOwner, containsRunLevel=Quiescing(so it pins the new tier, not the pre-existing one), and the victim's handler — which would
have answered — provably never ran.
ReplySettlingWorkAcceptedBeforeQuiesce_StillLands_AndTheDrainCompletes— the reply to acallback registered before the drain still lands, and disposal completes well inside
TestTimeouts.Convergenceagainst a 2-minute held quiesce budget, i.e. the hub left the phase bydraining (
[QUIESCE-OK]) rather than by timing out.Negative control. With the tier-1 threshold put back to
DisposeHostedHubs, test 1 failsexactly as the defect predicts —
Assert.Throws() Failure: No exception was thrown(the quiescinghub accepted the request and answered it), plus the secondary symptom
Mesh mesh/1: disposal did not complete within 00:00:10, which is the leaked callback itself.With the fix:
Failed: 0, Passed: 2. Whole project green:Failed: 0, Passed: 370.dotnet build -c Release -warnaserrorclean (0 Warning(s), 0 Error(s)) onMeshWeaver.Messaging.Hub,MeshWeaver.Documentationand the test project.Public surface
No public type or member removed; nothing added to a public interface. Both new methods
(
RefusesIntake,IsAwaitedBySender) areprivate.Pairs-with: none — no public surface is removed or changed, so no in-mesh or satellite caller can be affected.
Work products conserved
src/MeshWeaver.Documentation/Data/Architecture/TeardownLayers.md— new section"The intake gate closes at the FIRST instant of disposal, not the third", with the tier table and
the measurement.
Category: Fix).Closes #3506
🤖 Generated with Claude Code
https://claude.ai/code/session_018LzrcSMRzD2R5UcGDk5TKU