Repository navigation
A hub past [QUIESCE-OK] still accepts new correlated requests — the intake gate opens one phase too late #3506
Description
A hub past [QUIESCE-OK] still ACCEPTS new correlated requests, and they can never be answered
Split out of #3499, which measured this shape but was wedged by a different one. #3499's fix does
NOT cover this; nothing does.
The defect
MessageService.ScheduleNotify's shutdown gate refuses new deliveries only from
MessageHubRunLevel.DisposeHostedHubs onward:
if (hub.RunLevel >= MessageHubRunLevel.DisposeHostedHubs
&& delivery.Message is not ShutdownRequest and not DisposeRequest)The Quiescing phase is therefore wide open. A hub that has entered [QUIESCE-START] — and even
one that has already logged [QUIESCE-OK], having drained everything it owed — keeps accepting
inbound deliveries, keeps handling them, and keeps registering response callbacks for requests it
POSTS. #3261 measured it creating hosted hubs in that window too:
11.928 [QUIESCE-START] TestData/dnt…: 0 pending callbacks at dispose entry: <none>
11.928 [QUIESCE-OK] TestData/dnt…: drained 0 callback(s) in 0ms
11.968 Start processing SubscribeRequest (ID: p2qsFux3…, cache/iIN4… -> TestData/dnt…)
11.969 Creating new hosted hub for address sync/4wzOafy… in host TestData/dnt…
Work taken on after the drain has, by construction, no drain left to finish it: the quiesce budget
has already been spent, so the next phase cancels it.
Measurement (2026-09-06, three independent bake runs)
Counted from the RECEIVED runLevel=<level>@<issuing hub> stage in the request-fate trails printed
by [QUIESCE-TIMEOUT]. Six of nine pending callbacks were registered by a hub that was ALREADY
Quiescing:
| run | hub | runLevel at intake |
|---|---|---|
core main-cd #7937 (34058018897) |
Cornerstone, Publish, RemoteControl, Voice | Quiescing |
core main-cd #7937 |
Chess | Started |
core main-cd #7941 (34061838257) |
RolePlay | Started |
core main-cd #7941 |
Video, Essentials | Quiescing |
| MeshWeaver.Plugins #1422 gate (34059652860) | Chess | Quiescing |
Each one costs the full 2 s quiesce budget, ends in forcibly cancelling, and hands the requester a
HubDisposedBeforeResponseException.
Why this is filed separately rather than fixed with #3499
It is benign in every occurrence measured so far, and it is not what wedges an install. The
requester DOES get a typed, transient answer, and eight packages carrying this shape passed the gate
on run #7937 alone. The wedge #3499 fixed is the other row of the table: a request accepted while the
hub was Started, whose owner-side patch had already committed and whose teardown fault was
classified as the terminal Unknown instead of the retryable OwnerDisposing
(#3505).
That said, forcibly cancelling is the tell of an unfixed root, and this is the root: teardown lets
accepted work finish; it must not keep accepting new work it cannot finish.
🚨 Not the fix
Widening the 2 s QuiesceTimeout. #3261 settled that already: a bigger budget converts a leaked
callback into a slower leaked callback. The same goes for re-arming it further
(MaxQuiesceRearms exists for a different case — a reply owed by a sibling in this mesh that is
itself shutting down and WILL answer).
Acceptance
- A hub that has entered
Quiescingdoes not take on new correlated work it cannot answer: the
intake gate refuses from the first instant of disposal, not fromDisposeHostedHubs, with the same
transientErrorType.ShuttingDownNACK the later gate already posts (NackThroughParent, activation
identity and all). - The exemptions the later gate needs are preserved —
ShutdownRequest/DisposeRequest, and
whatever else teardown itself must route — plus, deliberately, any delivery that settles work
already accepted (a response correlating to a pending callback), which is exactly what the
quiesce drain is waiting for and must not be refused. - A deterministic pin that goes RED on today's code.
[QUIESCE-OK]followed by an intake is a
reproducible sequence (A hosted hub's leaked callback is reported on its owner, and a subtree drains under one Quiescing budget #3261'sDanglingNodeTypeUpdateTestwithMESHWEAVER_MSG_TRACE=1and
MeshWeaver.Messaging: Debugproduced it), and after fix(#3499): a teardown fault is a RETRYABLE verdict, not a terminal one — an install survives its own root recycling #3505 the tester image
logs[QUIESCE-START]so a bake occurrence is attributable in one read. - No bound is widened.
Provenance
Measured while root-causing #3499; the [QUIESCE-OK]-then-late-intake trace is #3261's.
Activity
review: re-aim SubscribeDuringRecycleTest — the gate removed its prec…
review: a doc comment that still described the open drain
rbuergi commentedon Sep 7, 2026
Closing — all four acceptance criteria measured on
origin/mainVerified 2026-09-07T12:25Z against
origin/main(fbf9e9be1); the merge queue does not fireCloseslinks, so this is measurement rather than the PR's declaration.Merged: #3557, merge commit
44ea4f6a3— "Merge pull request #3557 fromSystemorph/fix/3506-quiescing-intake-gate".
DisposeHostedHubsMessageService.RefusesIntake(src/MeshWeaver.Messaging.Hub/MessageService.cs:536-560) opens withif (runLevel < MessageHubRunLevel.Quiescing) return false;.Quiescingis the run level immediately afterStarted(MessageHubRunLevel.cs:11-23), i.e. the first disposal state. It is the single call site of the intake gate (:919, inScheduleNotify).ShuttingDownNACK the later gate postsAnswerUnreleasableDelivery→NackThroughParent/ReportFailure,ErrorType.ShuttingDown, activation identity includedShutdownRequest/DisposeRequestpass at every level; tier 1 additionally lets through anything carryingPostOptions.RequestId(a delivery that settles work already accepted — exactly what the quiesce drain waits for), anything addressed elsewhere (transit to hosted children still alive), and fire-and-forget.IsAwaitedBySenderis factored so "refused" and "NACKed" are the same set by constructiontest/MeshWeaver.Messaging.Hub.Test/QuiescingHubRefusesNewWorkTest.cs(new, 13,955 bytes):NewCorrelatedRequest_IsRefused_WhileTheHubIsDrainingItsQuiesceassertsErrorType.ShuttingDown,ShutdownNack.IsAnsweredByOwner,RunLevel=Quiescingin the message and that the handler never ran;ReplySettlingWorkAcceptedBeforeQuiesce_StillLands_AndTheDrainCompletespins the narrowness in the other direction and that the victim reachesDead44ea4f6a3changes noTimeout/Budget/Bound/Rearmconstant insrc/— the only such symbol added isHeldQuiesceBudgetinside the new test fileSubscribeDuringRecycleTestwas extended in the same change (+218/-…) rather than relaxed.Measured 2026-09-07T12:25Z on
origin/mainfbf9e9be1:git log --merges --grepfor the mergecommit;
grep -nonMessageService.csandMessageHubRunLevel.cs;git show --statandgit diff 44ea4f6a3^1 44ea4f6a3filtered for bound-shaped assignments.