Repository navigation
Report the HANDLER side when a callback outlives the quiescing budget - #1011
Conversation
Refs #981. Every capture of the pending-callback teardown failure has been caller-only: the hub names the request type, its target and its age, and nothing about what happened to the delivery at the receiving end. Two occurrences (2738ms and 6153ms against a 2s budget, both a CreateNodeRequest addressed to the mesh hub itself, every queue empty) therefore left the mechanism unnamed. RequestFateLedger records, per AWAITED request id, the stages the delivery actually reached — posted, received, routed, deferred, dropped, handler entered/exited with a state, and whether a response was ever posted for that correlation. One ledger per hub tree (the root creates it, hosted hubs inherit their parent's), so the hub that registered the callback and the hub that received the delivery write to the same trail. It holds only ids with a live Observe callback and drops each the moment it resolves, so it is bounded by the in-flight set; the hot path is one dictionary lookup that short-circuits on an empty ledger and formats nothing when it misses. The trail is folded into QuiescingTimeoutDetail, which MonolithMeshTestBase already writes to /tmp/meshweaver-test-trace.log (the one diagnostic CI keeps). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #981. Three places report a pending callback and all three were caller-only: - [QUIESCE-TIMEOUT] (teardown budget) — already enriched in the previous commit. - [STALE-CALLBACK] — the scanner that fires while the mesh is still SERVING. This is the one that matters for #981's first hard constraint: the callback predates disposal by ~3.1s on a fully live mesh, so teardown only DETECTS it. Dial MESHWEAVER_STALE_CALLBACK_MS down on a repro run and this line names the handler side before disposal is involved at all. - GetDisposalDiagnostics — what the test base writes on a DISPOSE_TIMEOUT, i.e. the case where the hub never reached its quiescing timeout and so has no QuiescingTimeoutDetail to read. Also documents how to read the trail (and what it cannot see) in DebuggingMessageFlow.md, next to the manual Trace-log reconstruction it replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…chain Refs #981. The open question is binary — does the chain that owes the reply complete SLOWLY, or terminate WITHOUT responding? A trail that only says "no reply yet" reproduces exactly that ambiguity, so three things close it: - HANDLER_COMPLETED_WITHOUT_DELIVERY — a rule chain that completes without emitting is otherwise silent in every diagnostic and reads identically to one still running. - hub.NoteRequestStage(requestId, stage) — the seam a HANDLER uses to report on work it detached from the rule chain. The canonical mesh handlers return Processed() immediately and owe their reply from a composed observable, so the pipeline's own HANDLER_EXIT proves nothing about whether a reply is coming. HandleCreateNodeRequest — the handler named in every #981 capture — now records CREATE_CHAIN_SUBSCRIBED / _EMITTED / _ERROR / _COMPLETED_EMPTY and the post-creation handler arms. Its outer Subscribe had NO onCompleted arm, and two save paths filter with .Where(n => n is not null), so an empty completion posts nothing and the caller waits forever. The new arm RECORDS that and deliberately does NOT post — which response is correct depends on WHY the chain went empty, and that is unknown until a capture names the branch. Instrumentation, not a guessed fix. - A verdict line per trail, so the report states which failure shape it is instead of leaving the reader to infer it. Two verdicts are pinned by tests, including the discriminator that matters most: a reply posted but lost on the way home must not read like a handler that never replied (one says chase the response delivery, the other says chase the handler). Also corrects HandleCreateNodeRequest's doc-comment, which claimed the async work was wrapped in Observable.FromAsync. It is not, and that pattern is now forbidden outside IoPool — a stale doc advertising a banned pattern is one copy away from becoming true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #981. A head-only stage cap was actively harmful: HANDLER_EXIT, RESPONSE_POSTED, *_COMPLETED_EMPTY and faults are always the LAST stages, and a cross-hub request already spends ~12 stages on routing hops before a handler is entered at all. The cap would have silently truncated exactly the evidence the verdict reads. Keep the first 10 and a sliding last 8; suppress the middle and say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds handler-side instrumentation to pending-callback / quiescing-timeout diagnostics so #981 captures can distinguish “never delivered”, “no handler matched”, “handler ran but didn’t reply”, and “reply was posted but lost”.
Changes:
- Introduces
RequestFateLedger, a hub-tree-scoped in-flight ledger that records request/response delivery stages and emits a single “verdict” per pending callback. - Threads fate-stage recording through
MessageService/MessageHubpost, routing, deferral, handler-enter/exit, and response-arrival paths; exposeshub.NoteRequestStage(...)for handlers with detached work. - Adds end-to-end tests pinning the enriched quiescing-timeout summary (including cross-hub and misrouted-reply discriminators) and documents how to read the new trail.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Messaging.Hub.Test/QuiescingTimeoutNamesHandlerSideTest.cs | New tests that pin the enriched quiescing-timeout output and key fate/verdict distinctions. |
| src/MeshWeaver.Messaging.Hub/RequestFateLedger.cs | New in-flight ledger that records handler-side request fate stages and renders a capped trail + verdict. |
| src/MeshWeaver.Messaging.Hub/MessageService.cs | Records fate stages across intake, routing, deferral/drop, handler execution, and correlated response posting. |
| src/MeshWeaver.Messaging.Hub/MessageHubExtensions.cs | Adds NoteRequestStage extension for handlers to record detached-chain terminal stages. |
| src/MeshWeaver.Messaging.Hub/MessageHub.cs | Hooks fate rendering into [STALE-CALLBACK], quiesce-timeout, and disposal diagnostics; tracks/untracks with response-subject lifecycle. |
| src/MeshWeaver.Mesh.Contract/MeshExtensions.cs | Instruments HandleCreateNodeRequest with NoteRequestStage stages (subscribe/emitted/error/completed-empty) and updates the related doc comment. |
| src/MeshWeaver.Documentation/Data/Architecture/DebuggingMessageFlow.md | Documents the new handler-side fate trail and how to interpret the verdicts/stage omissions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Refs #981. A local trace holds 30 DISPOSE_QUIESCE_LEAK records and 26 of them say "0 pending callback(s) after 2.00s: <none>" — the poll reported not-drained, and by the time the verdict was rendered every callback had already resolved inside the timeout hand-off. Nothing leaked; the detector raced its own budget. But the report is indistinguishable at a glance from #981's, and "<none>" reads as a diagnostic that failed to collect its evidence, so an investigator hunts a leak that was never there. Three classes produce it locally (TodoDataChangeWorkflowTest, TodoGraphIntegrationTest, TodoViewsTest). Say what actually happened instead. Behaviour is unchanged — the hub still flags QuiescingTimedOut and the class still fails; only the sentence changes, so a grep over the trace can separate the two shapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #981. Addresses the review thread on RequestFateLedger. The docs said Render() produced one line; it emitted a newline before the verdict. I made the CODE match the DOC rather than the reverse, because single-line is a requirement here, not a formatting preference: these records are read out of /tmp/meshweaver-test-trace.log with grep — a shared, append-only file that is the one artifact surviving a killed CI run. A newline before the verdict splits one record in two, so grep <messageId> returns the stages and HIDES the conclusion, handing the reader evidence without the answer. That is exactly the failure this instrumentation exists to remove. Long lines lose to records that grep out whole. Pinned by a test. Also closes a gap the first CI capture exposed in my own diagnostic. A trail with zero stages rendered as "posted but no RECEIVED stage was recorded" — which ASSERTS a post that was never observed, and could equally have meant the callback was registered after the delivery had already traversed the pipeline (the Observe(delivery) overload registers post-hoc). Track now seeds the trail with an AWAITING stage, so a trail can never be empty and "AWAITING and nothing else" is a positive statement: nothing was ever posted under this correlation. Added the matching verdicts, including one for a post-routing state=Failed — which is what the first real capture actually showed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #981. The first local repro of DynamicGraphIntegrationTest produced a trail carrying ONLY the registration stage, which the ledger read as 'nothing was ever posted under this correlation'. That reading was not safe: the same trail results when the callback is registered AFTER its delivery was posted (the Observe(delivery) overload registers post-hoc, so earlier stages were missed), and it also results when Post THROWS between GetOrAddResponseSubject and the dispatch — which leaks the pending entry with no delivery ever reaching the pipeline. Three stages now separate those cases: - POST_THREW — the Post between registration and dispatch faulted. Records the exception and RETHROWS; no behaviour change, and the leaked entry is left exactly as it was so the defect stays visible rather than being papered over. - REGISTERED_AFTER_POST — marks the Observe(delivery) overload, whose trail cannot describe anything before registration. - The bare-registration verdict now says the gap is in the CALLER between Observe and Post, and names what to look for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/MeshWeaver into worktree-agent-a16a12f403445e4e8
Attribution verdict for
|
| Evidence | Result |
|---|---|
main CI run 31314508984 (7c432729, none of my code) |
Same failure: FutuReAnalysisTest … 1 pending callback(s) after 2.00s: CreateNodeRequest@mesh/<self>(4564ms) — and no handler-side block, because main lacks the instrumentation |
main's other red run today (31314807609) |
the What's New frontmatter test, hotfixed by #1018 — unrelated |
clean origin/main worktree, this test class, locally |
30/30 passed (one sample) |
| this branch, same class, locally | 1/30 failed — same test as CI, same trail |
this branch, same class, after adding the POST_THREW / AWAITING stages |
30/30 passed |
So: the defect class is demonstrably live on main with an identical signature. But my branch reproduced this member once locally and main did not, and the branch then passed on the next run — i.e. an intermittent race, sampled once per configuration. That is not enough to claim my diff is innocent, and per the no-re-running rule I have not gone fishing for statistics.
The honest position: the ledger cannot create a pending callback — it only observes — but it does add per-delivery work on the message hot path (one dictionary lookup per stage site, plus a timestamp, an interpolation and an ImmutableList.Add for awaited ids). A teardown race decided at the 2 s boundary can plausibly be perturbed by that. I am not claiming otherwise.
What the instrumentation gained from this failure
The mesh hub's own trail came back with zero stages, rendered as posted but no RECEIVED stage was recorded — which asserts a post nothing had observed. That was a real defect in my own diagnostic, and this capture is what exposed it. Fixed:
Track()now seeds the trail with anAWAITING …stage, so a trail can never be empty. Locally the same failure now reads:
AWAITING CreateNodeRequest→mesh/upwDICZ… ⇒ the hub registered a callback and NO delivery carrying this id ever entered the pipeline … the gap is in the CALLER between Observe and Post, not in any handler.POST_THREW— records (and rethrows) a throw betweenGetOrAddResponseSubjectand the dispatch, which leaks the pending entry with no delivery ever reaching the pipeline.REGISTERED_AFTER_POST— marks theObserve(delivery)overload, whose trail cannot describe anything before registration. Without it, "registered post-hoc" and "never posted" read identically.
Three mutually exclusive causes now have three distinct stages where there was previously one silence.
Recommendation
Do not merge on my say-so. Two things are worth a maintainer's call:
- Whether the post-routing
Failed-state drop inNotifyAsyncis the Callbacks still pending past the teardown quiescing budget (FutuRe EuropeRe, Cession, PandasExplorer) — a CreateNodeRequest the mesh hub never answers #981 root cause — the trail says it is for the child-hub half. That is a behaviour change (postingDeliveryFailurefor not-on-target routing failures can cascade), so it belongs in its own PR with its own tests, not in an instrumentation change. - Whether the timing sensitivity above is acceptable. If it is not, the ledger can be gated behind an env var — at the cost of the PR Three read-after-write flakes, three unrelated defects (#978) #997 failure mode (a diagnostic that is configured off exactly where you need it).
… ledger PR #1011 (RequestFateLedger) landed while this was in flight. The conflict is in NotifyAsync's routing-loop arm, where both changes touch the same return. Both are kept: the ledger records ROUTING_LOOP, then the failure is reported instead of dropped. The new reporting seam also records its own stages — ROUTING_FAILED_REPORTED (with the verdict) and ROUTING_FAILED_ALREADY_NACKED — so a capture says outright whether the router answered the sender, rather than ending at 'ROUTED onTarget=False state=Failed' with nothing after it.
Refs #981.
This is instrumentation, not a fix. It does not change the quiescing budget, add a retry, or post a response from anywhere. #981 stays open.
The gap it closes
Every capture of "callbacks still pending past the teardown quiescing budget" has been caller-only: the hub names the request type, its target and its age, and nothing about what happened to the delivery at the receiving end. Two occurrences — 2 738 ms and 6 153 ms against a 2 s budget, both a
CreateNodeRequestaddressed to the mesh hub itself, every queue empty — therefore left the mechanism unnamed. Further members have since landed in other projects, one on a docs-only PR with zero C#.RequestFateLedgerrecords, per awaited request id, the stages the delivery actually reached, and folds the trail into every place a pending callback is already reported.What a capture now says
Each trail ends in a verdict naming which of the mutually exclusive shapes it is: never delivered / delivered-but-parked / no handler matched / handler ran and never replied / chain completed without producing a reply / reply posted and lost. The stages stay above it as the evidence.
Answering the binary question
The specific ambiguity to remove was: does the chain that owes the reply complete slowly, or terminate without responding? Three parts close it:
HANDLER_COMPLETED_WITHOUT_DELIVERY— a rule chain that completes without emitting is otherwise silent in every diagnostic and reads exactly like one still running.hub.NoteRequestStage(requestId, stage)— the seam a HANDLER uses to report on work it detached from the rule chain. The canonical mesh handlers returnrequest.Processed()immediately and owe their reply from a composed observable, so the pipeline's ownHANDLER_EXITproves nothing about whether a reply is coming.HandleCreateNodeRequest— the handler named in every capture — now recordsCREATE_CHAIN_SUBSCRIBED / _EMITTED / _ERROR / _COMPLETED_EMPTYplus the post-creation handler arms.While instrumenting (2) I found that the create handler's outer
Subscribehas noonCompletedarm, and both save paths filter with.Where(n => n is not null). An empty completion there posts nothing and the caller waits forever — the #981 signature exactly. The new arm records it and deliberately does not post: which response is correct depends on why the chain went empty, and that is unknown until a capture names the branch. Recording is instrumentation; posting would be a guessed fix. Flagging it here as a code-reading observation, not a proven cause.Cost and bounds
Observecallback, and are dropped the moment one resolves, is cancelled, or its subscription is disposed — so the ledger is bounded by the in-flight set, needs no eviction policy, and needs noClear()for test isolation.ConcurrentDictionarylookup that short-circuits on an empty ledger. Stage strings are formatted only after a lookup HITS, so an un-awaited delivery allocates nothing.PendingCallbackLogCap.Reaching CI
The trail rides the channel that already works:
QuiescingTimeoutDetail→GetQuiescingTimeoutSummary()→MonolithMeshTestBase'sDISPOSE_QUIESCE_LEAKphase trace →/tmp/meshweaver-test-trace.log, whichdotnet-test.ymlcopies withif: always(). All four reported #981 members derive fromMonolithMeshTestBase.Two checks against known traps:
TestPhaseTracewrites throughTestTraceLog.Append, notAppendFault, soFaultRecordBudget(Bound the trace log's fault RATE, not its lifetime total — a spent budget silenced the wedge #994) does not apply and no[FAULT-BUDGET]marker can swallow these records.appsettings.jsonfailure).TestTraceLogwrites to a fixed path and reads no config, so there is no per-project file that could win locally and lose on CI.The trail is also rendered on two other paths:
[STALE-CALLBACK](fires while the mesh is still live — relevant because #981's callbacks go unanswered ~3.1 s beforeDispose()is invoked; dialMESHWEAVER_STALE_CALLBACK_MSdown on a repro run) andGetDisposalDiagnostics(theDISPOSE_TIMEOUTcase, where noQuiescingTimeoutDetailexists yet).Also in this PR
HandleCreateNodeRequest's doc-comment claimed its async work was wrapped inObservable.FromAsync. It is not, and that pattern is forbidden outsideIoPool— a stale doc advertising a banned pattern is one copy away from becoming true. Corrected, with the reason.Verification
dotnet build MeshWeaver.slnx -c Release -warnaserror— 0 warnings, 0 errors.MeshWeaver.Messaging.Hub.Test— 156/156, including three new tests that pin the trail and two verdicts end-to-end: a self-addressed request handled but unanswered (the Callbacks still pending past the teardown quiescing budget (FutuRe EuropeRe, Cession, PandasExplorer) — a CreateNodeRequest the mesh hub never answers #981 shape), the same across two hubs (which is what proves the ledger is tree-scoped), and a reply posted to the wrong target, which must not read like a handler that never replied.MeshWeaver.Graph.Test— 850/850 (exercises the instrumented create path).DocumentationLinkIntegrityTest— green.No What's New entry: internal test/diagnostic infrastructure, nothing user-facing.
🤖 Generated with Claude Code