Skip to content

fix(#2778): the OwnerDisposing NACK is handed to the waiter, not posted and hoped for - #2868

Merged
rbuergi merged 1 commit into
mainfrom
fix/2778-nack-reaches-the-waiter-directly
Aug 31, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/2778-nack-reaches-the-waiter-directly

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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:

var parent = hub.Configuration.ParentHub;
if (parent is not null && parent.RunLevel < MessageHubRunLevel.DisposeHostedHubs)
    parent.Post(resp, o => o.ResponseFor(request));

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 waiting 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 pending Observe callback left. 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 the thing it exists for:

"the ONLY place a post-optimistic-emit verdict (above all the OwnerDisposing disposal NACK) can still be observed"

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 Dispatch 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 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.
  • The parent post stays, unchanged, as the only route to a caller in another process, which this registry cannot 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:

variant duration tests
baseline (main-equivalent worktree, measured now) 24 s 166/166
this branch 25 s 166/166
the rejected ancestor walk 176 s (prior session)

The test, and two things it taught me

NackReachesTheWaiterDuringTeardownTest disposes the mesh, not just the node hub. Disposing only the owner leaves the parent at Started, where the old guard was open — which is why OwnerDisposalNackTest and LateNackReenqueueTest both 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.OnError lands 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 by Dispatch, 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=OwnerDisposing appears 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 LateNackReenqueueTest now 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

…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>
Copilot AI lite review requested due to automatic review settings August 31, 2026 02:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (in MeshWeaver.Data.Contract) and implement it on LatePatchResponseRegistry so late verdicts can be dispatched directly to armed waiters.
  • Update RegisterOwnerDisposingNack to 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 DisposeHostedHubs window, 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.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

792 tests  ±0   788 ✅ ±0   4m 27s ⏱️ +16s
  7 suites ±0     4 💤 ±0 
  7 files   ±0     0 ❌ ±0 

Results for commit bf6e3a8. ± Comparison against base commit 5751e0e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

978 tests  +27   978 ✅ +27   4m 19s ⏱️ - 1m 14s
  7 suites ± 0     0 💤 ± 0 
  7 files   ± 0     0 ❌ ± 0 

Results for commit bf6e3a8. ± Comparison against base commit 5751e0e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 090 tests  ±0   1 090 ✅ ±0   5m 19s ⏱️ -13s
    7 suites ±0       0 💤 ±0 
    7 files   ±0       0 ❌ ±0 

Results for commit bf6e3a8. ± Comparison against base commit 5751e0e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    8 files  ±0      8 suites  ±0   5m 58s ⏱️ -1s
1 536 tests +1  1 535 ✅ +1  1 💤 ±0  0 ❌ ±0 
2 229 runs  +1  2 228 ✅ +1  1 💤 ±0  0 ❌ ±0 

Results for commit bf6e3a8. ± Comparison against base commit 5751e0e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 137 tests   - 49   1 137 ✅  - 49   8m 7s ⏱️ +30s
    7 suites ± 0       0 💤 ± 0 
    7 files   ± 0       0 ❌ ± 0 

Results for commit bf6e3a8. ± Comparison against base commit 5751e0e.

@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 048 tests  +23   2 856 ✅ +23   6m 21s ⏱️ +29s
    8 suites ± 0     192 💤 ± 0 
    8 files   ± 0       0 ❌ ± 0 

Results for commit bf6e3a8. ± Comparison against base commit 5751e0e.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   44 files  ±0     44 suites  ±0   34m 33s ⏱️ -12s
8 581 tests +2  8 384 ✅ +2  197 💤 ±0  0 ❌ ±0 
9 274 runs  +2  9 077 ✅ +2  197 💤 ±0  0 ❌ ±0 

Results for commit bf6e3a8. ± Comparison against base commit 5751e0e.

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

The red is attributed, not assumed — the changed path never ran in the failing test

Shard 2 failed on LogonActionFrameworkTest.An_every_logon_action_runs_on_every_logon. That test lives in MeshWeaver.Graph.Test, which does depend on all four assemblies this PR touches, so "unrelated" was not something I could claim by inspection. There is even a plausible mechanism: this change makes the late-watch callback run inline on the owner's disposal thread rather than on the cache hub's action block, and that callback does real work.

So I checked whether the modified path executed at all.

LATE_NACK / OwnerDisposing / LATE_ACK lines in the failing test's own output:   0
the same tags elsewhere in shard 2:                                           30

The path this PR changes produced no output in that test. RegisterOwnerDisposingNack runs only when an owner hub disposes with a patch in flight; nothing in that test entered it. A change cannot hang a test through a branch the test never takes.

What it actually is

The hang's own window:

02:46:36.206  TEST START
02:46:36.292  Logon action test.failing failed for logonuser   ← the test's own deliberate failure
02:46:41.286  Dropping StreamEndedEvent for stream _FIj1jTenEynt12CpiSiJA
02:46:41.298  [UpdateQueue] ADVANCE_WITHOUT_HANDOFF path=logonuser
02:46:41.300  Dropping StreamEndedEvent for stream P3XyG-XfKkKUCVWDB_Yu6w
              ── 25 s of complete silence ──
02:47:06.218  TEST FAILED: The operation has timed out

Burst, then total silence to the deadline — /debug's signature for a dropped reactive emission rather than a lock. ADVANCE_WITHOUT_HANDOFF is the queue side of that same absence: it advanced because the owner never acknowledged. This is the ambient family of #2865 / #2776 / #2778, whose signature predates tonight (it is recorded in #2776 and Plugins#993).

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 (FutuReAnalysisTest, the tracked flake Systemorph/MeshWeaver.Plugins#1141). Its diff is PluginCatalog plus two CI YAML files and cannot reach the mesh write path or a chart render. Two unrelated diffs failing simultaneously on two unrelated tests is the signature of an ambient population, not of either change.

Re-running the failed jobs on that attribution, not to see whether it passes.

@rbuergi
rbuergi added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 973a895 Aug 31, 2026
39 of 41 checks passed
rbuergi added a commit that referenced this pull request Sep 11, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OwnerDisposing NACK is dropped exactly when the parent is disposing hosted hubs — the caller hangs for its full budget

2 participants