Repository navigation
fix: a handler that faults past DisposeHostedHubs answers its requester (#4072) - #4154
Conversation
…er (#4072) A handler fault on a hub already past DisposeHostedHubs was classified and then dropped: ReportFailure declined on its own run level before reaching the post seam that already forwards correlated replies through a live parent, and when the parent was past DisposeHostedHubs too (every sibling's state during a whole-tree teardown) NackThroughParent declined as well while the requester sat Quiescing on exactly that callback, re-arming its budget for an answer that existed the whole time. Measured on LeavingHubAdoptionSweepTest (14 s, PASSING): teardown 4.0 s with a pending-callback snapshot at 3 s; now 2.0 s and no snapshot. - ReportFailure has no run-level gate of its own any more; the post seam decides and stamps the request's trail with what it could not do (REPLY_REFUSED_SHUTTING_DOWN). - MessageHub.TryDeliverNackInProcess: a DeliveryFailure no transport can carry is handed straight to the in-process hub its requester lives under, when that hub still admits replies. NACKs only (no state to reorder), offered only where the drop happens — the post seam's refusal arm, the two routing drop arms after IUndeliverableReplySink, and through ReportFailure the intake gate's tier 2. - HubDisposingException.IsDisposedContainer is a teardown fact in the handler fault chain: answered ShuttingDown in the owner's refusal vocabulary, not Unknown. - The fate ledger names the inner exception types on HANDLER_FAULT and stamps every carrier tried or declined (NACK_DECLINED reason=…, NACKED_THROUGH_PARENT, FAILURE_REPORTED, REPLY_FORWARDED_THROUGH_PARENT, NACK_DELIVERED_IN_PROCESS, REPLY_REFUSED_SHUTTING_DOWN), and the verdict reads the last post-seam stage instead of RESPONSE_POSTED alone. Pinned by HandlerFaultPastDisposeHostedHubsTest (4 cases, 0.5 s); falsified by reverting the production change: all four then time out with the requester unanswered and the whole-tree cases hold the mesh teardown open on the pending callback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
Test Results (shard 0)261 tests 261 ✅ 2m 6s ⏱️ Results for commit a44948c. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)508 tests 508 ✅ 44s ⏱️ Results for commit a44948c. ♻️ This comment has been updated with latest results. |
Test Results (shard 3)390 tests 390 ✅ 43s ⏱️ Results for commit a44948c. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 336 tests 1 334 ✅ 3m 58s ⏱️ Results for commit a44948c. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)2 036 tests 2 036 ✅ 5m 8s ⏱️ Results for commit a44948c. ♻️ This comment has been updated with latest results. |
A reply is posted under a fresh delivery id nobody awaits, so every stage after RESPONSE_POSTED was recorded nowhere and the verdict could only say "chase the response delivery" — the exact sentence the 2026-09-13 merge-queue failure on DanglingNodeTypeUpdateTest (run 34744471576, shard 4) left a reader with, and nothing to chase it by. The ledger now aliases the reply's id to the request at RESPONSE_POSTED; the same Find(id)?.Add(...) every hub already performs then writes the reply's stages into a sub-trail rendered as `↩ reply#1: RECEIVED@… → ROUTED … → HANDLER_EXIT@<requester>`, and the verdict reads its last stage (no stage: the post seam refused it or the target is outside the tree; ROUTED state=Failed: routing dropped it, stage names the hub; RESPONSE_ARRIVED_NO_SUBJECT: it arrived where no callback was held; a last RECEIVED: it is in that hub's queue). Bounded: three replies per request, a 512-entry alias ring. QuiescingTimeoutNamesHandlerSideTest's misrouted-reply case now asserts the sharper verdict — the sub-trail names the mesh hub the reply was handled on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
There was a problem hiding this comment.
🟡 Changes recommended
Delivery-state races and teardown classification issues may suppress fallback responses or misclassify faults.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Fixes lost DeliveryFailure responses when handlers fault during hub teardown.
Changes:
- Adds in-process NACK fallback routing.
- Improves teardown fault classification and fate diagnostics.
- Adds regression tests and documentation.
File summaries
| File | Summary |
|---|---|
test/MeshWeaver.Messaging.Hub.Test/HandlerFaultPastDisposeHostedHubsTest.cs |
Adds teardown fault regression coverage. |
src/MeshWeaver.Messaging.Hub/RequestFateLedger.cs |
Adds teardown carrier diagnostics. |
src/MeshWeaver.Messaging.Hub/MessageService.cs |
Updates failure reporting and reply routing. |
src/MeshWeaver.Messaging.Hub/MessageHub.cs |
Adds in-process NACK delivery. |
src/MeshWeaver.Messaging.Hub/HierarchicalRouting.cs |
Integrates NACK fallback into routing drops. |
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-a-handler-that-fails-while-its-hub-shuts-down-still-answers.md |
Documents the fix in What's New. |
src/MeshWeaver.Documentation/Data/Architecture/RefusedRepliesDuringTeardown.md |
Documents teardown reply behavior and remaining asymmetry. |
Review details
Suppressed comments (5)
src/MeshWeaver.Messaging.Hub/MessageService.cs:2245
IsDisposedContainerclassifies any matching AutofacObjectDisposedExceptionas hub teardown, even when a handler merely used an independently disposed nested scope while this hub's own scope is still live. That converts a genuine handler fault into a retryableShuttingDownNACK. Use the existing probe-gatedhub.IsTerminatedByScopeTeardown(e)here (while retaining the explicitHubDisposingExceptioncase) so only disposal of this hub's scope is treated as teardown.
else if (HubDisposingException.IsHubDisposal(e) || HubDisposingException.IsDisposedContainer(e))
src/MeshWeaver.Messaging.Hub/MessageService.cs:998
- This success test assumes
Postpropagates the result of its scheduling. The normalPostImplGenericpath callsScheduleNotifybut returns the original submitted delivery, so a late intake/routing rejection can still appear non-failed here.TryReportFailurethen returnstrueand callers useFailedAndNacked, suppressing further reporting even though noDeliveryFailurereached the requester. Make the post path expose the actual rejection before using it as the answer-once decision.
var posted = Post(new DeliveryFailure(delivery, message) { ErrorType = errorType },
new PostOptions(Address).ResponseFor(delivery));
return posted is { State: not MessageDeliveryState.Failed };
src/MeshWeaver.Messaging.Hub/MessageService.cs:2615
replyParent.Postcan return aFaileddelivery without throwing, but this branch treats every return as an accepted forward and immediately returns. During the same teardown race, that suppresses the NACK in-process fallback forDeliveryFailurereplies and records a misleadingREPLY_FORWARDED_THROUGH_PARENTstage even though the parent rejected the post. Check the returned delivery state and only record/return on acceptance; otherwise fall through to the refusal/fallback paths.
&& ParentHub is { } replyParent
&& replyParent.RunLevel < MessageHubRunLevel.DisposeHostedHubs)
{
try
src/MeshWeaver.Messaging.Hub/MessageService.cs:890
- This new helper uses a mutable
List<string>insrc/, contrary to the repository's immutable-collection rule; it only needs a bounded immutable accumulator, as the surrounding fate ledger already uses. Please useImmutableList<string>here.
var names = new List<string>(4);
for (Exception? current = e; current is not null && names.Count < 4; current = current.InnerException)
names.Add(current.GetType().Name);
return string.Join("→", names);
src/MeshWeaver.Messaging.Hub/RequestFateLedger.cs:263
REPLY_REFUSED_SHUTTING_DOWNis emitted for every correlated message inPostImplGeneric, including aDeliveryFailurewhenTryDeliverNackInProcessreturns false. This branch can therefore report a refused NACK as a “typed reply” and cite the typed-reply residue, making the new diagnostic verdict factually wrong and sending teardown investigation to the wrong path. DistinguishRESPONSE_POSTED type=DeliveryFailurebefore applying the typed-reply wording (or record a separate NACK-refusal stage).
}
// Sliding tail: the newest TailStages stages always survive, so the terminal
// evidence the verdict reads can never be the part that gets dropped.
tail = tail.Add(entry);
if (tail.Count > TailStages)
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| requester.DeliverMessage(failure); | ||
| return true; |
| parent.Post( | ||
| new DeliveryFailure(delivery) { ErrorType = errorType, Message = message }, | ||
| o => o.ResponseFor(delivery)); | ||
| fate?.Add($"NACKED_THROUGH_PARENT errorType={errorType} parent={parent.Address}", Address); | ||
| return true; |
…ed-scope classification Copilot review on #4154: - NackThroughParent reported "answered" on a parent Post that returned Failed (the parent crossed the shutdown boundary after the check, or its post pipeline rejected) — now NACK_DECLINED reason=parent-post-refused, so the next carrier is tried. - TryDeliverNackInProcess discarded DeliverMessage's verdict — a requester whose intake refused (Failed/Ignored) is no longer reported as delivered. - The disposed-scope teardown fact is classified by ScopeTeardown.IsTerminatedByScopeTeardown (ObjectDisposedException AND this hub's own scope disposed), not by IsDisposedContainer's message match, so a handler that used some OTHER disposed scope keeps its genuine-fault verdict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
|
Addressed in
Plus, since the review: the ledger now writes a reply's own journey onto the request's trail ( |
A trail ending `RECEIVED → ENQUEUED → QUEUED depth=1` at the target and then nothing says the target never dequeued the request, and nothing said why. Under each pending callback whose target is a hub in this process, [STALE-CALLBACK], [QUIESCE-TIMEOUT] and the disposal snapshot now print `target pump now: <address> RunLevel=… turn=<message> running <ms> buffer=… deferred=… drainsInFlight=… awaitingScheduler=… draining=…` — the same snapshot a hub prints for its own queue, taken for the hub the callback waits ON. Measured 2026-09-13 on the Plugins gate (#2543/#4141): 25 stale callbacks, every SubscribeRequest among them parked exactly that way at a NodeType hub with the pool at pendingWork=316. It is also #3593's M1/M2 discriminator, readable from a live hub. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
Test Results (shard 5) 6 files 6 suites 7m 42s ⏱️ Results for commit a44948c. |
Test Results 17 files 17 suites 20m 25s ⏱️ Results for commit a44948c. |
…ide of a coin toss CI (queue build 34750238233, shard 1) failed the BuildupAction row with "No exception was thrown". Its own output says why: TEST START …065 → "retiring this activation" …067 (+2 ms) → FAILED …078. The activation faulted and retired BEFORE the request was posted, so the request activated a fresh hub and was SERVED — which is exactly what step 3 of the same test asserts as the contract. Both orderings are legal; the assertion was written for one of them, and which one occurs was decided by how long GetClient()'s own hub construction took against an immediate fault. Only BuildupAction faults immediately (a DataContext's init is slower), which is why only that row failed. NOT a #4165/#4151 interaction: the BuildupAction host has no AddData and therefore no SynchronizationStream — its output block carries ZERO SYNC_STREAM lines where the passing DataContext row carries five — so #4151's FaultStore/GetStream change cannot reach it. The fence: the first init attempt parks until the test releases it, and the test releases only once the request is provably in flight AT that activation. Step 3 already pins the other ordering, so the test now states the whole contract instead of one side of it. 🚨 The first fence was wrong, and #4154's new report is what showed it: target pump now: host/1 RunLevel=Starting turn=InitializeHubRequest running 35004ms buffer=1 deferred=0 … draining=True openGates=1 A BuildupAction runs ON the init turn, so the pump is busy and the request sits in the MAIN queue, never reaching the deferral decision; a DataContext initialises OFF the turn, so the request parks in the DEFERRED queue. InFlightAt accepts either queue, at that hub. Verified on origin/main merged in (bd4314b, with #4151 and #4165): 4/4 green, MeshWeaver.Data.Test 512/512, MeshWeaver.Messaging.Hub.Test 432/432. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #4072.
The defect, reproduced
LeavingHubAdoptionSweepTest(coremain, 14 s, passing) tears its mesh down with aSubscribeRequestin flight fromcache/…to a per-node hub. With the fate ledger stamped at every carrier (this PR), the responder's trail reads:Before this PR the trail ended at
HANDLER_FAULT:ReportFailuredeclined on its own run level (>= DisposeHostedHubs) before reaching the post seam — the loss the issue names — andNackThroughParenthad already declined because the parent was pastDisposeHostedHubs. The requester (cache/…, Quiescing,PendingCallbacks=1) re-armed its quiesce budget for the answer ([QUIESCE-WAIT] — "a shutting-down sibling answers before it goes") and gotHubDisposedBeforeResponseExceptionat 4 s. Mesh teardown: 4.0 s with a "still waiting" snapshot at 3 s → 2.0 s and no snapshot after this PR.Why a handler runs on a hub past
DisposeHostedHubsat all:AccessControlPipelinedetachesnextfrom the turn (decision outside the gate), so the handler runs concurrently with the hub's own shutdown phases — the hub readDead7 ms after the fault.The fix (all in
MeshWeaver.Messaging.Hub)ReportFailurehas no run-level gate any more. The gate predatesPostImplGeneric's forward-through-a-live-parent seam and could only lose answers. The seam decides, and stampsREPLY_REFUSED_SHUTTING_DOWN runLevel=… parent=…on the request's trail where it cannot carry.MessageHub.TryDeliverNackInProcess— the NACK's carrier of last resort: aDeliveryFailurecarryingRequestIdthat no transport can take is handed straight to the in-process hub its requester lives under (host chain climbed to the root, descended, never created), when that hub still admits replies (< DisposeHostedHubs). NACKs only — nothing to reorder — and offered only where the drop happens: the post seam's refusal arm, the two routing drop arms afterIUndeliverableReplySinkdeclined, and (throughReportFailure) the intake gate's tier 2, which used to return without answering because "there is genuinely no route".IsDisposedContaineris a teardown fact in the handler fault chain: answeredShuttingDownin the owner's refusal vocabulary (ShutdownNack.IsAnsweredByOwner), notUnknown.HANDLER_FAULTnames the inner types; every carrier tried or declined is a stage (NACK_DECLINED reason=…,NACKED_THROUGH_PARENT,FAILURE_REPORTED,REPLY_FORWARDED_THROUGH_PARENT,NACK_DELIVERED_IN_PROCESS,REPLY_REFUSED_SHUTTING_DOWN); the verdict reads the last post-seam stage instead ofRESPONSE_POSTEDalone.Test and falsification
HandlerFaultPastDisposeHostedHubsTest(4 cases, 0.5 s): a delivery-pipeline stage detaches the handler exactly as production does and releases it once the fence is read back — targeted teardown (parent live, genuine fault →Unknownthrough the parent) and whole-tree teardown (parent pastDisposeHostedHubs, requester Quiescing on a 5-minute budget;HubDisposingException/ disposed-scope →ShuttingDown, genuine →Unknown; NACK arrives in ~2 ms, the requester leaves Quiescing on the answer).Reverting the production change (tests kept): 4/4 fail — every requester times out unanswered (
TimeoutExceptionafter 36 s), and the whole-tree cases hold the mesh teardown open on the pending callback for 46 s. Restored: 4/4 green.Also green with the fix:
MeshWeaver.Messaging.Hub.Test413/413; the teardown/NACK slice ofMeshWeaver.Graph.Test(62/62 —NackReachesTheWaiterDuringTeardownTest,RefusedReplyReachesTheWaiterTest,UndeliverableReplyShapesTest,LateNackReenqueue*,UpsertAnswersWhenTheOwnerGoesAwayTest, …);LeavingHubAdoptionSweepTest2/2;DocumentationLinkIntegrityTest.Residue, named
A typed reply refused by a closed parent during a whole-tree teardown still drops (the documented
RefusedRepliesDuringTeardownasymmetry): in the same repronodeopsanswerstype/StaleBundleType's compile-state upsert withCreateOrUpdateNodeResponse.Fail, which no carrier takes, so that sibling still pays one 2 s quiesce budget. That is the remaining 2.0 s.Doc:
Doc/Architecture/RefusedRepliesDuringTeardown(new section "The NACK's own carrier of last resort — #4072"). What's New: Fix.Pairs-with: none — no public type or member leaves
src/; the new members areinternal/private.🤖 Generated with Claude Code
https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E