Skip to content

fix: a recycle now reaches the subscribers it orphans (#2551) - #2582

Merged
rbuergi merged 1 commit into
mainfrom
fix/overlay-recycle-strands-subscribers
Aug 28, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/overlay-recycle-strands-subscribers

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

A recycle tore the hub down under the exact audience it exists for

NodeTypeEnrichmentHelpers.WithOverlaySelfHeal posts a bare DisposeRequest the moment a
NodeType reaches a usable build, on the stated premise that "the next access re-enriches against
the settled type"
. An already-open subscription is not a next access — and the self-heal's
whole population is the viewer who is currently looking at the compile-progress overlay.

The exact edge

JsonSynchronizationStream emits StreamEndedEvent from the server-side stream's disposal, and
that emission is deliberately suppressed once the owning hub is winding down:

"a hub must speak only for itself, and never while it is dying" — a dying owner reaching up
the hub tree for a last word RESURRECTS the Orleans activation it is retiring
(OrleansGrainTeardownStragglerTest).

It delegates the owning-hub-tearing-down case to two other recoveries. Neither can fire for a
recycle
, and the same file says so 600 lines earlier:

  • the recycle re-arm needs an in-flight SubscribeRequest to be NACKed — an established
    subscription has none;
  • the change-feed latch needs a WRITE to the owner's path — "a recycle IS NOT A WRITE".

So the delegation is to a mechanism that does not cover the case. The subscriber gets no frame,
no completion and no error
, and its mirror serves the last snapshot it ever saw forever. That is
the "burst of work, then total silence" signature in run 33150985489: the compile SUCCEEDED at
07:39:04.9 and the type was usable at 07:39:05.2 — 43 s before the test gave up.

StreamEndedEvent's own docstring already promises this case ("the owning hub tearing down —
deactivation, recycle, restart"). The emitter refuses to send it in exactly that case. The gap
between those two comments is the bug.

Why it is a production risk, not a test nuisance

A framework-identity bump recompiles every dynamic NodeType fleet-wide, so on that deploy every
instance hub takes the cold-compile path, crosses the 5 s overlay grace, is overlaid and is then
recycled — and every user with a page open at that moment is stranded on the progress page until
they reload. #2568 (removing MeshWeaver.AI from FrameworkBuildIdentity.ContentSurfaceAssemblies)
flips exactly that identity, which is why the FutuRe test fails 2/2 there and only rarely on
main, where a warm assembly cache keeps the compile under 5 s.

Only the automatic recycles were affected, which is why this hid for so long:
MeshOperations.Recycle (the MCP tool) publishes a MeshChangeEvent for the path before its
DisposeRequest — that fed the change-feed latch and made the recycle path look healthy — and
RecycleLayoutArea is driven by the page shell, which navigates on its own. The self-heal posts the
dispose alone.

The fix — announce before the teardown, deliver after it

A new RecycleAnnouncement seam (MeshWeaver.Messaging.Contract), hung on a hub with
hub.Set(...) and invoked by MessageHub.HandleDispose on the recycle's own turn:

if (!IsShuttingDown)      // an ancestor's cascade is NOT a recycle
    AnnounceRecycle();
Dispose();

Workspace registers the one real implementation, because the client-subscription registry that
knows who is listening lives there. Three properties, each load-bearing:

Property Why
Announced BEFORE Dispose() The hub is still whole — the registry is intact and Configuration.ParentHub still resolves. Both are gone or unreliable a phase later.
DELIVERED after DisposalCompleted, through the PARENT hub The subscriber answers with ONE bounded re-ask. Delivered earlier it lands on the still-dying instance, is NACKed ShuttingDown and burns a budget meant for a genuinely non-converging owner (the #1360 shape). Delivered by the dying hub itself it is the up-the-tree post that resurrects the activation. The parent outlives the target and speaks to a third party.
Never for an ancestor's cascade There the address is not coming back. A direct Dispose() — how HostedHubsCollection tears its children down — never reaches HandleDispose at all, so a whole-tree teardown stays silent by construction.

No band-aid: no bound was raised, no timer, poll, retry or watchdog was added. DisposalCompleted
is the event; the re-ask it feeds is the pre-existing, bounded MaxRecycleReArms ladder. A recycle
of a hub nobody is subscribed to costs exactly what it did before.

Verification

Deterministic repro, written first and confirmed red on main —
RecycleStrandsLiveSubscriberTest (Hosting.Monolith.Test): a client holds a live layout-area
subscription to a static-NodeType instance whose area renders a per-activation marker; a bare
DisposeRequest recycles it; the SAME subscription must land on the re-activated hub, with no node
write anywhere.

  • before: fails after its full 60 s wait, still holding ACTIVATION_1 — and the log shows the
    hub re-activating on its own 4.8 s in (the heartbeat), with the subscriber never told.
  • after: passes in 659 ms.

RecycleAnnouncementTest (Messaging.Hub.Test) pins the seam: announced exactly once and before
the teardown starts (IsDisposing == false inside the callback), and silent on a direct Dispose().

Local runs (all green):

Project Result
MeshWeaver.Messaging.Hub.Test 302 / 302
MeshWeaver.Data.Test 391 / 391
MeshWeaver.Layout.Test 431 / 431 (incl. SubscribeDuringRecycleTest)
MeshWeaver.FutuRe.Test 59 / 59
MeshWeaver.Hosting.Monolith.Test 742 / 745 (3 pre-existing skips)
MeshWeaver.Documentation.Test 98 / 98
MeshWeaver.Hosting.Orleans.Test 215 / 215

dotnet build … -c Release -warnaserror clean for MeshWeaver.Messaging.Contract,
MeshWeaver.Messaging.Hub, MeshWeaver.Data, MeshWeaver.Graph, MeshWeaver.Layout,
MeshWeaver.Hosting, MeshWeaver.Documentation.

Docs: HubDisposalModel.md gains "A recycle owes its subscribers a goodbye". What's New entry
shipped (Category: Fix).

Closes #2551. Very likely also Systemorph/MeshWeaver.Plugins#1141 (same test, same mechanism — a waiter that never observes a
terminal state reports whatever it last held).

🤖 Generated with Claude Code

…them

`WithOverlaySelfHeal` posts a bare `DisposeRequest` the moment a NodeType reaches a
usable build, on the premise that "the next access re-enriches against the settled
type". An already-open subscription is not a next access — and the self-heal's whole
population is the viewer currently looking at the compile-progress overlay.

`JsonSynchronizationStream`'s `StreamEndedEvent` is deliberately suppressed once the
owning hub is winding down ("a hub must speak only for itself, and never while it is
dying" — a dying owner reaching up the tree resurrects the Orleans activation it is
retiring). It delegates that case to the recycle re-arm and the change-feed latch, and
neither can fire for a recycle: the re-arm needs an in-flight SubscribeRequest to be
NACKed, and the latch needs a WRITE — "a recycle IS NOT A WRITE". So the subscriber got
no frame, no completion and no error, and held its last snapshot forever. On a
framework-identity bump that is every open page in the portal at once, since the bump
recompiles every dynamic NodeType.

The fix is the ordering: announce on the recycle's own turn, while the hub is whole, and
DELIVER after DisposalCompleted through the parent hub, which outlives it. A new
`RecycleAnnouncement` seam is invoked by `MessageHub.HandleDispose` before `Dispose()`
(never for an ancestor's cascade, where the address is not coming back); `Workspace`
implements it over the client-subscription registry that knows who is listening. No bound
raised, no timer, poll, retry or watchdog added — DisposalCompleted is the event and the
re-ask it feeds is the pre-existing bounded ladder.

Deterministic repro `RecycleStrandsLiveSubscriberTest` fails on main after its full 60 s
wait and passes in 659 ms with the fix; `RecycleAnnouncementTest` pins the seam's firing
contract.

Closes #2551

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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 lifecycle gap where a message-routed hub recycle (DisposeRequest) could strand already-established client subscriptions by tearing down the owner without emitting any terminal signal, leaving the subscriber stuck on its last snapshot (e.g., the compile-progress overlay) until manual reload.

Changes:

  • Introduces a RecycleAnnouncement seam and invokes it from MessageHub.HandleDispose (only for true “recycle” cases, not shutdown cascades) before disposal begins.
  • Implements announcement delivery in Workspace by snapshotting live client subscriptions and, after DisposalCompleted, posting StreamEndedEvent via the parent hub to trigger the existing bounded re-ask path.
  • Adds deterministic regression tests and documents the disposal/recycle contract (plus a What’s New entry).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/MeshWeaver.Messaging.Hub.Test/RecycleAnnouncementTest.cs Pins the new recycle-announcement firing contract (once, before teardown; not on direct Dispose()).
test/MeshWeaver.Hosting.Monolith.Test/RecycleStrandsLiveSubscriberTest.cs End-to-end repro asserting a live layout-area subscription re-converges after a bare DisposeRequest recycle.
src/MeshWeaver.Messaging.Hub/MessageHub.cs Announces recycle on routed DisposeRequest (guarded by IsShuttingDown), then disposes.
src/MeshWeaver.Messaging.Contract/RecycleAnnouncement.cs Defines the new seam type hung on hubs to capture the “announce-before-dispose” turn.
src/MeshWeaver.Data/Workspace.cs Registers and implements announcement delivery to orphaned client subscriptions via parent hub after DisposalCompleted.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-28-open-pages-recover-when-their-hub-restarts.md User-facing release note for the recovery behavior.
src/MeshWeaver.Documentation/Data/Architecture/HubDisposalModel.md Documents the recycle “goodbye” requirement and the new delivery model.

💡 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 2)

1 087 tests   895 ✅  5m 51s ⏱️
    6 suites  192 💤
    6 files      0 ❌

Results for commit 0d57089.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

742 tests   738 ✅  6m 44s ⏱️
  8 suites    4 💤
  8 files      0 ❌

Results for commit 0d57089.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

1 317 tests   1 317 ✅  6m 22s ⏱️
    6 suites      0 💤
    6 files        0 ❌

Results for commit 0d57089.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 134 tests   1 134 ✅  16m 28s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit 0d57089.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 612 tests   1 611 ✅  9m 50s ⏱️
    9 suites      1 💤
    9 files        0 ❌

Results for commit 0d57089.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    8 files      8 suites   11m 32s ⏱️
4 442 tests 4 439 ✅ 3 💤 0 ❌
5 052 runs  5 049 ✅ 3 💤 0 ❌

Results for commit 0d57089.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    44 files      44 suites   56m 50s ⏱️
10 332 tests 10 132 ✅ 200 💤 0 ❌
10 944 runs  10 744 ✅ 200 💤 0 ❌

Results for commit 0d57089.

@rbuergi
rbuergi merged commit 27f1ca7 into main Aug 28, 2026
24 checks passed
rbuergi added a commit that referenced this pull request Sep 1, 2026
…vious fix loses writes

#2776 asked one question: what ended the activity node's two sync streams while
the activity was still running. This answers it, records the measurement that
shows the obvious cure is worse than the disease, and makes the diagnostic that
caused the mis-triage state its own age. It changes NO behaviour.

── the answer ──
Its own previous progress write did, at +0.12 s. IMeshChangeFeed is published
after EVERY create/update/delete, and Workspace.EvictForPath drops every cached
remote stream whose owner matches the changed path — including the mirror the
writer itself is using. A write leases that mirror only for its
Observable.Create subscription, so the lease is released moments after its own
change event lands; the evicted stream then has no declared holder and
ReclaimIfUnheld DISPOSES it — UnsubscribeRequest to the owner, both sync/{id}
hubs gone — and the owner then announces StreamEndedEvent to a subscriber that
no longer exists. Measured: six progress writes to one activity node with one
live reader mint 7 client-side and 11 owner-side sync/ hubs, with 5 mirrors
disposed mid-run.

── the two arithmetic traps that made it read as a hub teardown ──
1. The drop line is written FIVE SECONDS after the event it describes:
   RouteStreamMessage holds a StreamMessage whose sync/{id} hub is missing for
   SyncStreamOptions.SyncHubRegistrationGrace (5 s) and logs only on that
   Timeout. In #2776 the streams ended at +0.12 s — confirmed independently by
   the failure's own arithmetic (FAILED seq=3 elapsedMs=31015.7 at 13:28:58.952
   ⇒ EnteredAt 13:28:27.936). The ADVANCE_WITHOUT_HANDOFF beside it is a
   DIFFERENT 5 s bound (QueueAdvanceBound) counting from the same instant. Two
   unrelated five-second bounds produced a coincidence that looked like the
   hosted-hub drain cap.
2. The SENDER rules out a recycle. The recycle announcement is posted by a
   carrier (a dying hub must never speak for itself); the healthy-owner
   announcement is posted by the owner and refuses to fire once RunLevel >
   Started. Both #2776 lines name the activity node — so the owner was Started
   and NOT disposing. #2763/#2582 are eliminated by the trace, not by argument.

── why the obvious fix is NOT taken ──
Giving EvictForPath the liveness gate its two sibling listeners already have
(MeshNodeStreamCache.ResetFailureState: "A healthy live entry is left
untouched"; JsonSynchronizationStream's version-gated Resubscribe: "resubscribing
on it is pure churn") was implemented and measured, and it LOSES WRITES. The
eviction is incidentally what keeps a cross-hub writer's BASE current with
respect to writes the OWNER makes for itself: _pendingSelfWrites carries only
this cache's writes forward, while an owner-side write (an activity's
messageCount, a sealed segment) reaches the mirror only through the async
fan-out. Controlled, same box, DOTNET_PROCESSOR_COUNT=4, on
StaticRepoImportActivityWriteCountTest.AppendCost_DoesNotGrowWithTheLengthOfTheActivity:

  gate ON   FAILED — 2000 messages appended, 1975 recorded (a 25-message batch lost)
  gate OFF  PASSED

and on CI the gated build turned 80 append calls into 99 writes with 44
OWNER_NACK_REENQUEUE / "MergeGuard: refused stale/reordered cross-hub write to
'messageCount'". The O(1) append pin (6a48f9f, d1a239a) was right and is not
touched. EvictForPath now carries a comment saying the unconditional eviction is
deliberate, and why.

── what DOES change ──
LogStreamMessageDrop states how long the message was HELD, so its timestamp can
never again be read as the time the stream ended. That is the whole behavioural
diff; the rest is a doc page and comments.

── the real fix, for whoever takes it ──
Make the writer's base version-aware (MeshChangeEvent already carries Version)
so a write waits for the mirror to reach the announced version instead of
throwing the mirror away to force a fresh snapshot. That is a change to the
write path, not to EvictForPath.

── not claimed ──
#2776's visible failure was a write with no verdict at 31 s. That silence has a
separate, already-fixed cause (#2882 / #2890, 4d23117 — the write registered
its response subject after posting; the trail could only say
REGISTERED_AFTER_POST, which is exactly what that trace says).

Verified: Release -warnaserror 0/0 for Graph.Test and Documentation.Test (which
transitively build MeshWeaver.Data, Hosting, Mesh.Contract, Graph, Layout,
Messaging.Hub, Documentation). Graph.Test 1086/1086 and Documentation.Test
166/166 at DOTNET_PROCESSOR_COUNT=4; earlier full runs on this tree also had
Data.Test 419/419 and Hosting.Test 274/274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa
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.

A recycled hub strands its live subscribers — StreamEndedEvent is suppressed and neither named recovery covers a recycle

2 participants