Repository navigation
fix(#2778): the OwnerDisposing NACK is handed to the waiter, not posted and hoped for - #2868
Conversation
…ed and hoped for
RegisterOwnerDisposingNack delivered the NACK as `parent.Post(...)` guarded by
`parent.RunLevel < DisposeHostedHubs`. With the levels ordered Starting,
Started, Quiescing, DisposeHostedHubs, … that guard is open for the first three
and shut for the rest — so the one moment a whole BATCH of owner hubs goes down
with patches in flight is the one moment none of their NACKs is delivered. The
caller then burns the full 31 s WriteVerdictBound for an answer the owner had
already minted and discarded.
The guard justified itself with "during a whole-mesh teardown the parent is past
that mark too, the post is skipped, and NOBODY IS WAITING". That is not
something code can verify, and it is false exactly when it matters.
🚨 The post was never the seam this NACK was designed to arrive on. A verdict
landing after UpdateRemote's ~2 s bounded wait has NO pending Observe callback,
so posting it is only a way of reaching LatePatchResponseRegistry the long way
round — routed to the caller, into the cache hub's PatchDataResponse handler,
and finally Dispatch. That handler's own comment already names this NACK as what
it exists for. Within one mesh the registry is a singleton both hubs share, so
the owner can hand the verdict over directly.
`Dispatch` also RETURNS whether a caller was armed, so "nobody is waiting" stops
being an assumption and becomes a checked fact.
- ILatePatchVerdictSink, new in MeshWeaver.Data.Contract. The owner side lives in
MeshWeaver.Data, which cannot reference MeshWeaver.Mesh.Contract — that closes
the cycle Data → Mesh.Contract → Layout → Data. PatchDataResponse already lives
in Data.Contract, so the verb belongs there too.
- LatePatchResponseRegistry implements it (its existing Dispatch already has the
signature); registered as a FACTORY over the concrete singleton, never a second
AddSingleton — two instances would mean dispatching into a registry nobody
armed, which reads exactly like "nobody was waiting".
- The parent post stays, unchanged and unguarded-differently, as the only route
to a caller in ANOTHER process, which this registry cannot see.
🚨 COST — the reason the obvious fix was reverted. Walking the parent chain to
the first ancestor that can still post is correct about the caller and was
measured at ~10x on teardown (Content.Test 29 s → 176 s). This shape is a
dictionary lookup that misses, so the common teardown pays nothing. Measured on
one box, one variable, same 166 tests, DOTNET_PROCESSOR_COUNT=4:
baseline (main-equivalent worktree) 24 s 166/166
this branch 25 s 166/166
the rejected ancestor walk 176 s (prior session)
NackReachesTheWaiterDuringTeardownTest disposes the MESH — not just the node hub
— because disposing only the owner leaves the parent at Started, where the old
guard was OPEN: OwnerDisposalNackTest and LateNackReenqueueTest both pass with
the defect fully present, which is why it survived.
🚨 It asserts the ARMED WATCH is consumed, not that the caller's callback runs.
The first draft asserted the caller's terminal and failed WITH the fix: a mesh
teardown takes the caller's own subscription down too, so OnError lands on a
disposed observer and is a silent no-op. That is a property of the scenario, not
of the framework, so asserting it would have been asserting something no
implementation could satisfy. The registry entry is armed while a caller waits
and removed by Dispatch, so it returning to zero IS "the verdict reached the
waiter" — the step that used to be skipped. The bound is well under the 30 s
watch bound, because an entry that merely EXPIRES also ends at zero.
Mutation-proved: with the direct hand-off removed the new test fails on a
timeout. All 9 sibling NACK / late-watch tests stay green — and LateNackReenqueue
now exercises the new path, since its caller is armed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a teardown-edge-case in the cross-hub write pipeline where an owner-minted OwnerDisposing NACK could be dropped exactly during DisposeHostedHubs, causing callers to wait out the full write verdict budget in silence. It introduces an in-mesh “late verdict sink” seam so the owner can hand the verdict directly to the already-armed late-watch registry (when both hubs are in the same process), while preserving the existing parent-post path for cross-process delivery.
Changes:
- Add
ILatePatchVerdictSink(inMeshWeaver.Data.Contract) and implement it onLatePatchResponseRegistryso late verdicts can be dispatched directly to armed waiters. - Update
RegisterOwnerDisposingNackto attempt direct dispatch first and only fall back to the existing parent-post path when no in-mesh waiter is armed. - Add a regression test that disposes the mesh (not only the node hub) to hit the
DisposeHostedHubswindow, plus a “What’s New” fix entry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Monolith.Test/NackReachesTheWaiterDuringTeardownTest.cs | New regression test proving the armed late-watch is consumed during mesh teardown. |
| src/MeshWeaver.Mesh.Contract/LatePatchWriteWatch.cs | LatePatchResponseRegistry now implements ILatePatchVerdictSink (direct dispatch seam). |
| src/MeshWeaver.Hosting/Persistence/PersistenceExtensions.cs | Registers ILatePatchVerdictSink as a factory over the existing LatePatchResponseRegistry singleton. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-31-a-write-is-answered-even-when-its-owner-goes-down.md | Adds a user-facing “Fix” entry describing the resolved hang-on-teardown behavior. |
| src/MeshWeaver.Data/DataExtensions.cs | Owner-disposal NACK path now tries direct verdict dispatch before attempting parent-post. |
| src/MeshWeaver.Data.Contract/ILatePatchVerdictSink.cs | New contract interface enabling MeshWeaver.Data to reach the late-watch registry without adding a reference cycle. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The red is attributed, not assumed — the changed path never ran in the failing testShard 2 failed on So I checked whether the modified path executed at all. The path this PR changes produced no output in that test. What it actually isThe hang's own window: Burst, then total silence to the deadline — Note the direction: this PR makes more owner verdicts reach their waiter, so if it moves this family at all it moves it the other way. I have posted the fuller finding on #2865 — this instance is a better witness than the ones investigated so far, because a method-timeout kill preserves the hung test's transcript while a host-cap kill destroys it. Corroboration from an unrelated PR#2867 went red in the same window on a different test and a different shard ( Re-running the failed jobs on that attribution, not to see whether it passes. |
…is branch Evidence: scaffold test/MeshWeaver.Graph.Test/_TempDiagTest.cs + DIAG-4023 fate stages on this branch. Runs (Release, DOTNET_PROCESSOR_COUNT=2): run1 200/200 pass (no stress); run2 1/400 FAIL (18-proc CPU stress); run4 (v2 journey) 1/400 FAIL; run5 (v3) 0/400. 1. #2778's fix (#2868, direct ILatePatchVerdictSink.Dispatch in RegisterOwnerDisposingNack) NEVER failed: every iteration where the ShutDown registrant claimed the ack gate shows dispatched=True (41/200, 42/400). 2. The TEST moved off that path. Core #3291 (2026-09-04, no forced teardown) made the parked merge turn release on owner.IsShuttingDown, which flips at the FIRST instant of Mesh.Dispose() (CloseCreation cascade) while the owner is still Started. The merge then COMMITS and the live PATCH_ACK ok claims the once-only gate; the registrant stands down (claimed=False). Routes that consumed the watch (run2): 347 live ack via 97a0284 route-up sink; 42 registrant dispatch; 10 live ack via #3196 refused-post fallthrough; 1 FAIL = live ack on the ORDINARY route (owner->mesh->cache queues, parent still < DisposeHostedHubs). 3. a9249a4 (2026-09-08) asserts at owner RunLevel==Dead on the premise "DisposeImpl runs the registrant before Dead". True for the registrant and for the sink (synchronous on the owner's turns); FALSE for the ordinary route: the ack has left the owner and sits in mesh/cache queues. "Owner Dead" no longer implies "answer delivered". 4. Two failure shapes (MonolithMeshTestBase DisposeAsync 'Mesh.Disposal completed in' = time REMAINING after the assertion): B run4 i=220 = 12ms (reply queued at cache depth 3, delivered right after the assertion instant: test-premise race). A run2 i=21 = 1849ms, CI 34513634943 = 1949ms, CI 34581527040 = 1948ms (passing max 69-107ms): answer held for the cache hub's whole 2s QuiesceTimeout. Shape A mechanism NOT YET NAMED; v3 snapshots the hub tree at Dead to name it. 5. Falsified: cache queue depth (i=399 passed at depth 3, mesh Started, handled +38ms); DataChangedEvent push holding the cache pump (handled on the sync sub-hub, synchronous); thread-pool starvation (all monolith hubs use TaskScheduler.Default, owner went Started->Dead in ~80ms same iteration); ShuttingDown subscribers posting to cache (only ActivityControlPlane, which just Stop()s). 6. Registrant-ran elimination HOLDS (DIAG_NACK_REGISTRANT runLevel=ShutDown in every failing trail) but proves nothing about a NACK: the gate was claimed by a SUCCESS ack; no OwnerDisposing NACK is minted. 7. The assertion's "burns the whole 31s" is wrong for this scenario: the cache's quiesce force-cancels at ~2s (CancelCallbacks -> caller onError -> lateRegistry.Complete) — a committed write reported as HubDisposedBeforeResponse.
Closes #2778.
The owner mints an explicit "this patch was NOT applied — safe to retry" NACK when it goes down mid-write. Delivering it is the entire point of minting it. It was delivered like this:
With the levels ordered
Starting, Started, Quiescing, DisposeHostedHubs, …, that guard is open for the first three and shut for the rest — so the one moment a whole batch of owner hubs goes down with patches in flight is the one moment none of their NACKs is delivered. The caller then burns the full 31 sWriteVerdictBoundwaiting for an answer that had already been written and thrown away.The guard justified itself with "during a whole-mesh teardown the parent is past that mark too, the post is skipped, and nobody is waiting." That is not something the code could verify, and it is false exactly when it matters.
The post was never the seam this NACK was designed to arrive on
A verdict that lands after
UpdateRemote's ~2 s bounded wait has no pendingObservecallback left. So posting it is only a way of reachingLatePatchResponseRegistrythe long way round — routed to the caller, into the cache hub'sPatchDataResponsehandler, and finallyDispatch. That handler's own comment already names this NACK as the thing it exists for:Within one mesh the registry is a singleton both hubs already share, so the owner can hand the verdict over directly: same waiter, no hub woken, no message routed. And
Dispatchreturns whether a caller was armed — so "nobody is waiting" stops being an assumption and becomes a checked fact.ILatePatchVerdictSink, new inMeshWeaver.Data.Contract. The owner side lives inMeshWeaver.Data, which cannot referenceMeshWeaver.Mesh.Contract— that closes the cycleData → Mesh.Contract → Layout → Data.PatchDataResponsealready lives inData.Contract, so the verb belongs there too.LatePatchResponseRegistryimplements it — its existingDispatchalready has the signature. Registered as a factory over the concrete singleton, never a secondAddSingleton: two instances would mean the owner dispatching into a registry nobody armed, which reads exactly like "nobody was waiting" and would restore the bug in a form no test could see.Cost — why the obvious fix was reverted, and why this one is affordable
A previous attempt walked the parent chain to the first ancestor that could still post. It is correct about the caller and was measured at ~10× on teardown, so it was reverted. This shape is a dictionary lookup that misses, so the common teardown — nobody waiting — pays nothing.
Measured on one box, one variable at a time, same 166 tests,
DOTNET_PROCESSOR_COUNT=4:The test, and two things it taught me
NackReachesTheWaiterDuringTeardownTestdisposes the mesh, not just the node hub. Disposing only the owner leaves the parent atStarted, where the old guard was open — which is whyOwnerDisposalNackTestandLateNackReenqueueTestboth pass with the defect fully present, and why it survived this long.🚨 It asserts the armed watch is consumed, not that the caller's callback runs. My first draft asserted the caller's terminal and failed with the fix in place. The reason is sound: a mesh teardown takes the caller's own subscription down along with the owner, so
observer.OnErrorlands on a disposed subscription and is a silent no-op. That is a property of the scenario, not of the framework — asserting it would have meant asserting something no implementation could satisfy. The registry entry is armed while a caller waits and is removed byDispatch, so it returning to zero is "the verdict reached the waiter". The bound is well under the 30 s watch bound, because an entry that merely expires also ends at zero.The failing draft is also what proved the fix works end to end:
LATE_NACK_REENQUEUE … code=OwnerDisposingappears in its log, which the pre-fix code cannot produce at all.Mutation-proved: with the direct hand-off removed, the new test fails on a timeout. All 9 sibling NACK / late-watch tests stay green — and
LateNackReenqueueTestnow exercises the new path, since its caller is armed.MeshWeaver.Data.Contract,MeshWeaver.Data,MeshWeaver.Mesh.Contract,MeshWeaver.Hosting,MeshWeaver.Hosting.Monolith.Test,MeshWeaver.Content.Test— each-c Release -warnaserror,0 Warning(s) 0 Error(s).One What's New entry (
Category: Fix).🤖 Generated with Claude Code