Repository navigation
fix: a teardown report and a stalled delete name what they tell you to find (#3712, #1198) - #4316
Conversation
…o find (#3712, #1198) Two failure reports in subsystems that share no code ended by sending the reader off to find a fact the reporting code was already holding. #3712 — the deferred-delivery discard (event 7301) named the message, its sender and the gates it sat behind, then closed with "find why this hub disposed before its deferred work could run". The hub knows: MessageHub records disposeRequestedBy / disposeReason / cascadeOwner one frame earlier, in HandleDispose, added so #3510's "[QUIESCE-START] on a root should name who asked" could be satisfied. But [QUIESCE-START] is Information and the red-log pipeline files Errors, so the one line that BECOMES an issue was the one without the attribution. Measured on Admin/_LogIncident/d2249f800ffc2577: 364 occurrences, 2026-09-08 -> 2026-09-14, 13 pods, not one naming its teardown. MessageHub.DisposalAttribution renders WHO and WHY as one clause; both discard sites and the NACK the stranded sender receives now carry it. The sender is the reader who can see none of this hub's log — often a different process — so the clause travels in the answer as well as the line. The absence of a routed request is itself one of the three renderings ("a direct Dispose()"), because it rules the message path out. MeshOperations.Recycle was the single production poster of a DisposeRequest with no Reason — the one teardown originating OUTSIDE the framework's own lifecycle and therefore the one a log cannot reconstruct. It now states one. #1198 — the shared [DeleteNode] timeout line carries an `unanswered=` field so an operator learns WHICH node stopped the operation. The pre-flight stage fills it; the commit stage never did, so it rendered `-`, which is the SAME rendering the line uses for "there is nothing outstanding". Measured on memex-cloud 2026-09-14T08:45:38Z: `stage=commit partial-deleted=3 unanswered=-` for Hosting/TriageStatus — three removed, the drain stuck on the rest of the subtree, and the field that exists to name them said there were none. The plan and the progress are both live in that closure; the difference is the answer. The commit stage now reports "N of M planned path(s) removed ...; still owed by the plan: <paths>" and fills the data key the shared line reads. The progress count stays. Nothing is downgraded, silenced or widened. Every report keeps its level, its event id and every fact it already carried. Deterministic repro, both directions, on the real mesh: - DeferredDeliveryNackedOnDisposeTest: a routed DisposeRequest that STATES a reason, plus a control arm disposed directly so the attribution is proved to VARY with the cause rather than being a constant. With the two production edits reverted and the test project rebuilt, exactly those two go red on their own assertions and the two pre-existing tests stay green. - DeleteCommitTimeoutNamesWhatIsStuckTest: a storage adapter that serves two deletes and then goes silent. Reverted, the message reads "made no progress for 3s - 0 path(s) removed from storage so far" - the production shape, with no names. Verified: MeshWeaver.Messaging.Hub.Test 445/445, MeshWeaver.Graph.Test 1610/1610, MeshWeaver.Documentation.Test 467/467, Memex.Portal.Shared.Test recycle/install 11/11. Every touched project builds -c Release -warnaserror with 0 Error(s). Written up as Doc/Architecture/AFailureReportAnswersItsOwnInstruction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nly against itself The two subject assertions read the exception message. A message can be internally consistent and still describe a state that never happened, so the test now also reads the backing store: the path the report names as still owed is really still there, and the deepest node — one of the two the adapter served before it went silent — is really gone. That second read is also the honest form of the "progress was PARTIAL" control. The production occurrence carried partial-deleted=3, and a drain that removed NOTHING is a different (already legible) report; asserting it against the store rather than against a substring of the message makes the control independent of how the message happens to be worded. MeshWeaver.Graph.Test filter green on a -c Release -warnaserror build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved findings include a disposal-ownership race, blank-reason handling, attribution documentation mismatches, and incomplete timeout-log coverage.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Improves teardown and recursive-delete diagnostics by attributing discarded work and naming paths still owed during commit timeouts.
Changes:
- Adds disposal attribution to logs and NACKs.
- Reports outstanding delete paths during commit timeouts.
- Adds regression tests and documentation.
File summaries
| File | Summary |
|---|---|
test/MeshWeaver.Messaging.Hub.Test/DeferredDeliveryNackedOnDisposeTest.cs |
Tests disposal attribution and NACK content. |
test/MeshWeaver.Graph.Test/DeleteDrainCompletionTest.cs |
Extends storage behavior for stalled deletes. |
test/MeshWeaver.Graph.Test/DeleteCommitTimeoutNamesWhatIsStuckTest.cs |
Tests commit-timeout diagnostics. |
src/MeshWeaver.Messaging.Hub/MessageService.cs |
Adds attribution to deferred-delivery diagnostics and NACKs. |
src/MeshWeaver.Messaging.Hub/MessageHub.cs |
Formats disposal source and reason. |
src/MeshWeaver.Mesh.Operations/MeshOperations.cs |
Supplies a reason for operator-triggered recycle. |
src/MeshWeaver.Mesh.Contract/MeshExtensions.cs |
Reports paths still owed by stalled deletes. |
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-14-a-teardown-and-a-stalled-delete-now-name-what-they-tell-you-to-find.md |
Documents the user-facing fixes. |
src/MeshWeaver.Documentation/Data/Architecture/AFailureReportAnswersItsOwnInstruction.md |
Documents the diagnostic patterns. |
src/MeshWeaver.Documentation/Data/Architecture.md |
Indexes the architecture documentation. |
Review details
Suppressed comments (3)
src/MeshWeaver.Messaging.Hub/MessageHub.cs:204
- The new attribution table says an owner cascade is reported as
requested by a direct Dispose(), but the implementation takes thecascadeOwnerbranch and emitsrequested by a cascade from its owner <owner>instead. Please align the documented/advertised third shape with the actual cascade-specific output (or change the formatter if the direct-dispose wording is the intended contract), otherwise operators and tests will rely on a shape this code never produces.
internal string DisposalAttribution =>
$"requested by {DisposalRequestedBy}; why: {DisposalReason}";
test/MeshWeaver.Graph.Test/DeleteCommitTimeoutNamesWhatIsStuckTest.cs:115
- This regression test only inspects
reported.Message; it never verifies the[DeleteNode] timeout ... unanswered=log line that the production change is meant to repair. Removing the newException.Data[DeleteUnansweredDataKey]assignment would leave the exception-text assertions green while the logger still rendersunanswered=-, so capture the test logger and assert the stalled path appears in the emitted timeout line while retaining the already-deleted negative control.
var reported = await failure.Should().Within(TestTimeouts.WriteConvergence).Emit(
"the store went silent mid-commit, so the no-progress watchdog must end the operation");
test/MeshWeaver.Messaging.Hub.Test/DeferredDeliveryNackedOnDisposeTest.cs:261
- The control actually calls
gated.Dispose()directly, while owner-cascade disposal is deliberately distinguished bycascadeOwnerand is asserted byQuiesceStartNamesTheAskerTest. Describing an owner's cascade as this direct-dispose control makes the test documentation claim coverage for an output it does not exercise and contradicts the new attribution contract.
/// taken down by a DIRECT <c>Dispose()</c> — host teardown, an owner's cascade, a <c>using</c> —
- Files reviewed: 10/10 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| internal string DisposalAttribution => | ||
| $"requested by {DisposalRequestedBy}; why: {DisposalReason}"; |
There was a problem hiding this comment.
Valid, and fixed — this is the defect this PR is about, pointed the other way: a report that names the WRONG cause is worse than one that names none.
Confirmed reachable rather than theoretical: Dispose() sets IsShuttingDown and only then posts ShutdownRequest(Quiescing), so a DisposeRequest arriving in that window still sees RunLevel=Started, is admitted by RefusesIntake, and its turn runs after the teardown has begun. HandleDispose then overwrote a true a direct Dispose() (no routed DisposeRequest) reading — which is the reading that rules the message path out — with its own. It mattered little while the attribution appeared only on [QUIESCE-START]; it matters now that it rides the Error and the NACK.
Both halves of your suggestion are now in:
- Atomic ownership. New
teardownCauseClaimed+TryClaimTeardownCause()(Interlocked.CompareExchange).NoteCascadeFromhad a first-cause-wins rule over two volatile fields — a rule, not a guarantee, since the two writers run on different threads (action block vs. the disposing owner's thread) — and it now takes the same claim. Exactly one writer ever wins. - Ignore once disposal has begun.
HandleDisposerecords onlyif (startsTheTeardown && TryClaimTeardownCause()).startsTheTeardownis read once and reused for the existingAnnounceRecycle()branch, which asks the same question; two reads of a flag another thread can flip could otherwise disagree.
What I have deliberately not claimed: a genuinely simultaneous pair — a direct Dispose() and a routed request landing within the same instant — is still a race, but one where both really happened and either attribution is true. That is written into the code comment as the honest residue rather than papered over, and it is not an overwrite of an earlier cause.
I could not build a deterministic repro for the interleaving (the reachable window needs a DisposeRequest admitted between IsShuttingDown and RunLevel=Quiescing, and the post-#3647 intake bound closes every seam a test can reach), so I am not claiming a regression test for this arm — only the guard and the reasoning. The sibling arm that is deterministic — a hub torn down with no routed request must report a direct Dispose() — is pinned by ADiscardWithNoRoutedRequest_SaysSO_RatherThanSayingNothing.
| @@ -182,6 +182,27 @@ cascadeOwner is not null | |||
| ? $"the owner's own teardown — {cascadeOrigin ?? DisposeRequest.ReasonNotStated}" | |||
| : disposeReason ?? DisposeRequest.ReasonNotStated; | |||
There was a problem hiding this comment.
Valid, and fixed — and it is exactly the defect in a new costume, which makes it worth a guard rather than just an edit.
DisposeRequest.Reason is free text from the poster, and null was the only value any renderer treated as "not stated". A blank one produced a literal why: with nothing after it — an absent answer rendering as nothing, which reads to the next person as "there was nothing to report". That sentence is the thesis of the page this PR adds.
Normalised at the capture point, as you suggested, rather than at the read:
disposeReason = string.IsNullOrWhiteSpace(request.Message.Reason)
? null
: request.Message.Reason;Capture is the single place because there are two readers — DisposalReason and DisposalOriginForChildren (which propagates the cause down a cascade) — so fixing it at the read would have left the second one to remember, and a cascaded child would still have carried the blank.
Covered alongside the null case by a new test, ATeardownWhoseReasonIsBlank_ReportsItAsUNSTATED_NotAsNothing, which uses whitespace rather than an empty string on purpose: an empty string is the obvious case and a Length > 0 check would pass it, while " " is what a poster writing $"{maybeEmpty} " actually produces. Negative control measured — with the normalisation neutralised and the test project rebuilt, it fails on the real production string:
… The teardown was requested by host/1 (routed DisposeRequest); why: . The address may reactivate …
and the other four tests in the class stay green. It also asserts the WHO half survives, so "the reason came out named" cannot be satisfied by the attribution having been dropped.
| `DisposalAttribution` renders three distinguishable answers, and the absence of a routed request is | ||
| itself one of them: | ||
|
|
||
| | what happened | what the report says | | ||
| |---|---| | ||
| | a routed `DisposeRequest` that stated a reason | `requested by <sender>; why: <the reason>` | | ||
| | a routed `DisposeRequest` with no reason | `requested by <sender>; why: reason not stated by the caller` | | ||
| | no routed request (host teardown, a `using`, an owner cascade) | `requested by a direct Dispose() (no routed DisposeRequest)` | |
There was a problem hiding this comment.
Valid, and the sharpest of the three: a page whose whole subject is "a report must not tell you the wrong thing" was itself telling operators to expect the wrong attribution for a supported teardown path. Fixed.
You are right on the mechanism: DisposalRequestedBy prefers cascadeOwner and renders a cascade from its owner <owner>, and DisposalReason renders the owner's own teardown — <originating cause>. My table folded that into the direct-dispose row, which is precisely the conflation cascadeOwner was added to remove — before it existed, every child in a wave reported a direct Dispose(), indistinguishable from host teardown, and reading the child told you nothing about the root (#3510's wedge one level down).
The table now has five rows, not three, and the two I had missed are both real distinctions:
- the owner's cascade, with its own WHO and its own WHY, carrying the originating cause propagated unchanged down the chain — so a leaf names the event that started it however deep the tree is, rather than only its immediate parent;
- a self-posted
DisposeRequest(a rebind, a self-heal), which is a different reading again from "somebody else asked".
I added a short paragraph under the table saying why each distinction is load-bearing and cannot be inferred from the others, plus a "One cause, claimed once" section describing the atomic claim and the already-begun check from the other two threads — because the table is only true if exactly one writer ever records the cause.
Three findings from the Copilot review, all valid and all the same defect this
PR is about, pointed the other way: a report that names the WRONG cause is worse
than one that names none.
1. THE CAUSE IS OVERWRITABLE. HandleDispose wrote its attribution
unconditionally, so a routed DisposeRequest whose turn ran AFTER a direct
Dispose() had already begun the teardown replaced a TRUE reading ("a direct
Dispose() (no routed DisposeRequest)", which is what rules the message path
out) with its own. Reachable, not theoretical: Dispose() sets IsShuttingDown
and only THEN posts ShutdownRequest(Quiescing), so a DisposeRequest arriving
in that window still sees RunLevel=Started, is admitted by RefusesIntake, and
its turn runs after the teardown has begun.
Two halves, both needed. `teardownCauseClaimed` + TryClaimTeardownCause()
(Interlocked.CompareExchange) makes ownership atomic — NoteCascadeFrom had a
first-cause-wins RULE over two volatile fields, which is a rule and not a
guarantee, since the two writers run on different threads. And HandleDispose
now records only when it is the request that STARTS the teardown; that flag is
read ONCE and reused for the existing AnnounceRecycle() branch, which asks the
same question.
What is left is a genuinely simultaneous pair, where both happened and either
attribution is true. That is written down as the honest residue rather than
papered over. No deterministic repro is claimed for the interleaving — the
reachable window needs a DisposeRequest admitted between IsShuttingDown and
RunLevel=Quiescing, which the post-#3647 intake bound closes to a test.
2. A BLANK REASON RENDERED AS NOTHING. DisposeRequest.Reason is free text and
`null` was the only value any renderer treated as "not stated", so an empty or
whitespace string produced a literal "why: " with nothing after it — an absent
answer that reads as "there was nothing to report", i.e. this page's own
thesis. Normalised at the single CAPTURE point, because there are two readers
(DisposalReason and DisposalOriginForChildren, which propagates the cause down
a cascade) and fixing it at one read would leave a cascaded child blank.
Guarded by ATeardownWhoseReasonIsBlank_ReportsItAsUNSTATED_NotAsNothing, which
uses WHITESPACE rather than an empty string on purpose: an empty string is the
obvious case and a Length > 0 check would pass it. Negative control measured —
neutralised, the NACK reads "...; why: . The address may reactivate..." and
the other four tests in the class stay green.
3. THE DOC TABLE NAMED THE WRONG FORM FOR A CASCADE. DisposalRequestedBy prefers
cascadeOwner and renders "a cascade from its owner X"; my table folded that
into the direct-dispose row — precisely the conflation cascadeOwner was added
to remove. Five rows now, with the self-posted form too, plus why each
distinction cannot be inferred from the others and a section on the claim.
Verified after the change: MeshWeaver.Messaging.Hub.Test 446/446,
MeshWeaver.Graph.Test 1610/1610, MeshWeaver.Documentation.Test 467/467,
Memex.Portal.Shared.Test recycle/install/dispose 11/11. Every touched project
builds -c Release -warnaserror with 0 Error(s).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
ℹ️ Merge-queue steward: no action — removed from the queue with reason |
Two failure reports that sent the reader to find what the code was already holding
Closes #3712. Advances #1198.
Both were measured on live portals this week, one day apart, in subsystems that share no code.
#3712 — "find why this hub disposed"
MessageService.Dispose()files an Error (event 7301) for every delivery a hub accepted and parked behind an init gate and then threw away. The line named the message, the sender and the gates — and ended:The hub knows.
MessageHubrecordsdisposeRequestedBy/disposeReason/cascadeOwnerone frame earlier, inHandleDispose— added precisely so #3510's "[QUIESCE-START]on a root should name who asked" could be satisfied. But[QUIESCE-START]isInformationand the red-log pipeline filesErrors, so the one line that becomes an issue was the one line without the attribution.Measured on
Admin/_LogIncident/d2249f800ffc2577: 364 occurrences, 2026-09-08 → 2026-09-14, 13 pods. Not one says which teardown discarded the work, so a reader given the incident alone cannot tell an operator recycle from a NodeType rebind from an owner's cascade — three different investigations.MessageHub.DisposalAttributionrenders WHO and WHY as one clause. Both discard sites carry it, and so does the NACK the stranded sender receives — that sender is in a different process as often as not and can see none of this hub's log.It varies with the cause, which is what makes it an answer rather than decoration:
DisposeRequestwith a reasonrequested by <sender>; why: <the reason>DisposeRequest, no reasonrequested by <sender>; why: reason not stated by the callerusing, an owner cascade)requested by a direct Dispose() (no routed DisposeRequest)The third rules the message path out — exactly what the production incident needed and could not get.
One poster fixed with it:
MeshOperations.Recycle(the operations / MCPrecycleverb) was the single production poster of aDisposeRequestwith noReason— and it is the one teardown that originates OUTSIDE the framework's own lifecycle, so it is the one whose cause a log cannot reconstruct. It now states one.#1198 — an outstanding-work field that said "none"
The shared line is
[DeleteNode] timeout path=… stage=<stage> partial-deleted=<n> unanswered=<set>.unanswered=exists so an operator learns WHICH node stopped the operation; the pre-flight stage fills it. The commit stage never did, so it rendered-— which is the SAME rendering the line uses for "there is nothing outstanding".Measured on memex-cloud 2026-09-14T08:45:38Z:
Three removed, the drain stuck on the rest of the subtree, and the field that exists to name them asserted the opposite of the truth. Beside it, a count of what SUCCEEDED, which identifies nothing about the failure.
The plan (
collected.ToDelete) and the progress (SnapshotProgress()) are both live in that closure. The difference is the answer:"still owed by the plan", not "remaining": a drain can remove MORE than it planned (#3392), so the set is what the plan still owes and never a claim about what else storage holds. The progress count stays — names are ADDED.
Not a downgrade, not a widened bound
Every report keeps its level, its event id and every fact it already carried. No bound moved, nothing was silenced, no retry or watchdog was added.
Deterministic repro, both directions
DeferredDeliveryNackedOnDisposeTest(+2): a routedDisposeRequestthat STATES a reason, plus a control arm disposed directly so the clause is proved to vary rather than being a constant. With the two production edits reverted and the test project rebuilt, exactly those two go red on their own assertion text and the two pre-existing tests stay green:DeleteCommitTimeoutNamesWhatIsStuckTest(new): a realIStorageAdapterover a realInMemoryStorageAdapterthat serves two deletes and then goes silent. Reverted, the message is the production shape with no names:It carries its own positive controls (it really is the commit stage; the bound really is a no-progress watchdog and not #3392's total-duration cap; the count survives) and a negative control — the two paths already removed must NOT appear in the owed set, so "the plan echoed back under a new label" fails.
Verification
MeshWeaver.Messaging.Hub.TestMeshWeaver.Graph.TestMeshWeaver.Documentation.TestMemex.Portal.Shared.Test(recycle / install / dispose)Every touched project built
-c Release -warnaserror, one project per invocation,0 Error(s).Notes for the reviewer
MessageHub.DisposalAttributionisinternal.c84c6c0550(2026-09-12 09:30Z), which predates [DISPOSE-DISCARD] 76 Error-level lines per plugin-gate shard: $model-probe hubs dispose with a GetDataRequest still deferred behind [DataContextInit,MeshNodeInit] #4178 (3c421609e6) as well as this change — measured withgit merge-base --is-ancestor. Every 2026-09-14 sample on that incident is the self-addressed$model-probeshape [DISPOSE-DISCARD] 76 Error-level lines per plugin-gate shard: $model-probe hubs dispose with a GetDataRequest still deferred behind [DataContextInit,MeshNodeInit] #4178 already demoted to Debug.Written up as
Doc/Architecture/AFailureReportAnswersItsOwnInstruction.Review round — three findings, all valid, all fixed
Copilot found three, and each was this PR's own defect pointed the other way. Answered inline on the threads; summarised here because two of them changed the code.
HandleDisposewrote its attribution unconditionally, so a routedDisposeRequestwhose turn ran after a directDispose()had begun replaced a truea direct Dispose()reading — the one that rules the message path out — with its own. Reachable, not theoretical:Dispose()setsIsShuttingDownand only then postsShutdownRequest(Quiescing), so a request arriving in that window still seesRunLevel=Startedand is admitted. NowteardownCauseClaimed+TryClaimTeardownCause()(Interlocked.CompareExchange) makes ownership atomic againstNoteCascadeFromon another thread, and the handler records only when it is the request that STARTS the teardown. The genuinely simultaneous pair is written down as the honest residue rather than papered over, and no repro is claimed for it.Reasonrendered as nothing.nullwas the only value treated as "not stated", so""or" "produced a literalwhy:— this page's own thesis, in a new costume. Normalised at the single capture point (two readers:DisposalReasonandDisposalOriginForChildren, which propagates down a cascade). New guardATeardownWhoseReasonIsBlank_ReportsItAsUNSTATED_NotAsNothinguses whitespace, not an empty string, because aLength > 0check would pass the latter; negative control measured.DisposalRequestedBypreferscascadeOwner, so a cascaded child rendersa cascade from its owner X— my table folded that into the direct-dispose row, exactly the conflationcascadeOwnerexists to remove. Five rows now, with the self-posted form too, plus why each distinction cannot be inferred from the others.Held correctly rather than raced: the PR had already been admitted to the merge queue when the findings landed, so it was dequeued via GraphQL
dequeuePullRequestfirst (verified by readingrepository.mergeQueue.entries— position 2,AWAITING_CHECKS, gone afterwards), then drafted, then updated.🤖 Generated with Claude Code