Skip to content

fix(hub): a hosted hub at ShutDown is retired and replaced, never handed out (#5136) - #5199

Merged
rbuergi merged 2 commits into
mainfrom
fix/5136-dying-hub-not-handed-out
Sep 22, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/5136-dying-hub-not-handed-out

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Root R2 of Doc/Architecture/DisposedScopeAndDyingHubs — the one of the five with no fix and a fix that can be written today (status table: #3593 (comment)).

Closes #5136.

The root

HostedHubsCollection.GetHubWithOutcome answered a registry hit as Available, unconditionally. A hosted hub leaves that registry only when its own removal registrant runs — several statements into the ShutDown phase, after the RunLevel flip, CancelCallbacks and the reactive dispose actions — while from the flip on it can serve nothing: intake refuses every delivery and a direct Observe(...) faults synchronously with ObjectDisposedException. A teardown that wedges anywhere in between (a registered cleanup blocking the turn — #4883's shape) never reaches the removal, so the window was unbounded. Measured: one portal/nodeops-… hub handed to four webhook deliveries over 55 minutes on a pod serving everything else, each a 500.

The fix — retire-and-replace, once every removal is value-matched

The page named this as the self-healing answer and gated it on three removals becoming value-matched so a predecessor's teardown cannot erase its successor. Two had landed (#5191 for the registry, #5159 for the local route); the third — the pod-hub cluster claim — had not.

  • GetHubWithOutcome: an Always lookup that finds a registered hub at RunLevel >= ShutDown retires it (value-matched TryRemove(KeyValuePair), so a successor a concurrent lookup registered is untouched) and falls through to the ordinary single-flight creation. The corpse moves to a reference-keyed retiring set the collection still OWNS: Hubs enumerates it, the stall detector sees it, and DisposeHubsReactive joins on it — its Autofac scope is a child of the owner's, and an owner that finished ahead of it would close that scope under a registrant walk still running (R1's straggler class, manufactured locally). It leaves on its own DisposalCompleted. One Warning line, [HOSTED-RETIRE], names the address and phase; the ordinary path never reaches it.
  • The bound is ShutDown, not IsDisposing, deliberately. Below ShutDown the hub is still draining accepted work and the intake's transient ShuttingDown NACK is the documented answer (HubDisposalModel, the creation window); a successor minted there would put two activations on one address with accepted writes still in flight on the first. At ShutDown nothing accepted remains. Never probes — the router's — still find the corpse and are refused with that NACK, right for a message and wrong for a caller holding the reference. While the collection itself is disposing no successor can be minted, so the corpse stays the honest (attributable) answer — refusing with null is still ruled out for the 25 null-forgiving call sites the thread measured.
  • Removal 3, grain-side, no contract change: PodHubGrain.Detach leaves the claim alone when the silo still holds a live local route for the address. RegisterStream's disposal removes its route before it releases the claim, so a live route at that point can only be a successor's; the grain is not [Reentrant], so the read is ordered against the successor's Attach and every Deliver. Honouring a stale release would have stamped the terminal Released tombstone on a live hub — ten minutes of refusals with the one verdict the owner-side eviction acts on, and the pin cut from indefinite.
  • Two more key-only removals found in the sweep, both gone: MonolithRoutingService.UnregisterStream(address), armed on every route-created hub on top of the hub's own value-matched registration and running in the same registrant walk (would have taken the successor's address dark on the Monolith), and the podHubClaimSettled test seam.

Deliberately left where it is: the wedge itself (#4883). This lets callers past a wedged hub and is the first line to name it by address; it does not free the turn.

Controls — both sides, measured

Run through the xUnit v3 host directly (dotnet test --filter runs nothing on this SDK); on each side the hub dll's mtime was read after the source's.

test fixed control
AHubAtShutDownIsNotHandedOutTest (Messaging.Hub.Test) — parks the ShutDown turn inside a reactive dispose action, asserts the corpse is still registered, then measures the lookup and the owner's join total 2, failed 0 (12.5 s; the join case spends its Quick negative window) unconditional registry hit restored: total 2, failed 2, both "Did not expect value to refer to the same instance" — the lookup handed back the corpse
PodHubStaleReleaseTest (Hosting.Orleans.Test, real two-silo cluster) — a stale release must leave a live successor served and pinned at MaxValue; a release with no live route must still tombstone total 2, failed 0 route check removed from Detach: total 2, failed 1 — the successor's delivery threw PodHubNotHereException … was RELEASED by its owner; the honest-release case passed on both sides, as it must

Whole MeshWeaver.Messaging.Hub.Test project: total 501, failed 0 (215.8 s). Neighbouring pod-hub / cross-silo / silo-shutdown suites: total 53, failed 0. Guards (*Guard incl. HandWovenGateRatchetGuard, NoLiteralNulInSourceGuard, TestTimeoutLiteralRatchetGuard, the bridge guards) + DocumentationLinkIntegrityTest: total 347, failed 0, each named guard Passed on a MeshWeaver.Documentation.dll rebuilt after both page edits.

Build verification

dotnet build -c Release -warnaserror, one project per invocation, each 0 Warning(s) 0 Error(s): MeshWeaver.Messaging.Hub, Connection.Orleans, Hosting.Orleans, Hosting.Monolith, Hosting, Hosting.AspNetCore, Documentation, and the Messaging.Hub.Test, Hosting.Orleans.Test, Documentation.Test projects.

Docs

Doc/Architecture/DisposedScopeAndDyingHubs — R2 rewritten from "root identified, not fixed" to the fix, the ShutDown bound and why, the three value-matched removals, and what stays on #4883; the settled "Add unreferenced" lead is recorded as settled. Doc/Architecture/HubDisposalModel — a third creation-window section for the hub at ShutDown that is still registered.

Deploy

Framework code, no node content — nothing to recycle; a roll carries it. Pairs-with: none — no public type or member leaves src/ (UnregisterStream was private).

🤖 Generated with Claude Code

…ded out (#5136)

Root R2 of Doc/Architecture/DisposedScopeAndDyingHubs. GetHubWithOutcome answered a
registry hit as Available unconditionally, while a hub at RunLevel >= ShutDown can serve
nothing and leaves the registry only when its own removal registrant runs, several
statements into that phase; a wedged teardown never got there, so one portal/nodeops hub
was handed to four callers over 55 minutes on a serving pod.

An Always lookup now retires such a hub (value-matched, so a concurrently-registered
successor is untouched), mints a successor through the ordinary single-flight creation,
and keeps the corpse in the collection's disposal join until its own DisposalCompleted -
its scope is a child of the owner's. The bound is ShutDown, not IsDisposing: below it the
hub is still draining accepted work and the intake's transient NACK is the documented
answer; a successor there would overlap accepted writes.

Retire-and-replace needs every removal a teardown makes to be value-matched. The third
and last one lands here: PodHubGrain.Detach leaves the claim alone when the silo still
holds a live local route for the address, because RegisterStream's disposal removes its
route before it releases, so that route can only be a successor's. Two more key-only
removals found in the sweep are gone: MonolithRoutingService.UnregisterStream(address)
(armed on every route-created hub in the same registrant walk) and the podHubClaimSettled
seam.

Controls: AHubAtShutDownIsNotHandedOutTest parks the ShutDown turn inside a reactive
dispose action and asserts the corpse is still registered - total 2 / failed 0 fixed,
total 2 / failed 2 with the unconditional return restored. PodHubStaleReleaseTest on the
real two-silo cluster - total 2 / failed 0 fixed, total 2 / failed 1 with the route check
removed (the successor refused as RELEASED). Whole Messaging.Hub.Test 501/0, pod-hub
suites 53/0, guards + link integrity 347/0.

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

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files  ±0    1 suites  ±0   2m 49s ⏱️ -1s
320 tests +2  320 ✅ +2  0 💤 ±0  0 ❌ ±0 
324 runs  +2  324 ✅ +2  0 💤 ±0  0 ❌ ±0 

Results for commit 9a08eaf. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

533 tests   - 1 057   533 ✅  - 1 057   1m 8s ⏱️ - 2m 4s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 9a08eaf. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

429 tests   - 14   429 ✅  - 14   49s ⏱️ +2s
  2 suites  -  1     0 💤 ± 0 
  2 files    -  1     0 ❌ ± 0 

Results for commit 9a08eaf. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

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.

Copilot review overview

🟡 Changes recommended

Retirement has unresolved disposal and lookup races, and test cleanup requires hardening.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 2 Medium severity

Open (3)
What changed in this PR

This PR fixes hosted-hub reuse during shutdown by retiring hubs, creating successors, and making cleanup successor-safe.

Changes:

  • Added retire-and-replace lifecycle handling.
  • Hardened route and claim cleanup.
  • Added regression tests and updated documentation.
File Summary
test/​MeshWeaver.Messaging.Hub.Test/​AHubAtShutDownIsNotHandedOutTest.cs Tests shutdown retirement and disposal joining.
test/​MeshWeaver.Hosting.Orleans.Test/​PodHubStaleReleaseTest.cs Tests stale and valid pod releases.
src/​MeshWeaver.Messaging.Hub/​HostedHubsCollection.cs Retires shutdown hubs and joins teardown.
src/​MeshWeaver.Hosting.Orleans/​PodHubGrain.cs Prevents stale releases from affecting successors.
src/​MeshWeaver.Hosting.Monolith/​MonolithRoutingService.cs Removes unsafe key-only cleanup.
src/​MeshWeaver.Documentation/​Data/​Architecture/​HubDisposalModel.md Documents the shutdown creation window.
src/​MeshWeaver.Documentation/​Data/​Architecture/​DisposedScopeAndDyingHubs.md Records the R2 fix and lifecycle details.
src/​MeshWeaver.Connection.Orleans/​OrleansRoutingService.cs Makes claim cleanup value-matched.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +342 to +345
if (!messageHubs.TryRemove(new KeyValuePair<Address, IMessageHub>(address, corpse)))
return;

retiring[corpse] = Unit.Default;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real, and fixed in the next push — thank you. The gap was remove-then-add: a lookup that had already taken the corpse out of messageHubs but not yet put it into retiring left it in neither of the owner's two snapshots.

The correction is an ordering, not a lock. RetireCorpse now goes into retiring first, out of messageHubs second (TryAdd, then the value-matched TryRemove), and DisposeHubsReactive keeps snapshotting the registry first, retiring second. With those two orders opposed, the corpse is in at least one snapshot at every instant: a registry snapshot taken before the removal still holds it; one taken after was taken after the add that preceded that removal, so the retiring snapshot that follows holds it. Both dictionaries publish under their own locks, so the add is visible to any thread that already observed the removal. A hub caught in both snapshots is joined once, as retired (ImmutableHashSet by reference), and the late-construction leg now diffs against the registry snapshot rather than the live-legs subset so a hub retired between the snapshots is not re-disposed as "late". The retirement that races the IsDisposing check itself (check passes, owner flips, then retire) is covered by the same argument — the corpse was in the registry snapshot — and its would-be successor is refused by the creation Lazy's own IsDisposing re-check, exactly as before.

Both orders are documented at the two sites as load-bearing.

Comment on lines +342 to +345
if (!messageHubs.TryRemove(new KeyValuePair<Address, IMessageHub>(address, corpse)))
return;

retiring[corpse] = Unit.Default;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Considered, and deliberately not done — the window you describe is the ordinary recycle window, not a new one.

Today, without this change, every recycle produces exactly the sequence corpse → (removal registrant fires) → Absent → (next Always lookup) → successor, and the Absent stretch is unbounded: a recycled node nobody re-activates stays absent until something asks for it. A Never probe in that stretch answers Absent now, and is answered Absent on main today. Retirement moves the removal a few statements earlier — from the registrant walk to the retiring lookup — and the successor's construction that follows is the same single-flight Build that any Always creation runs. So the Never-miss answer during the successor's Build is the answer the platform has always given between a removal and the next activation; nothing here converts a transient refusal into a terminal one that was not already terminal in that window on the ordinary path.

An address-indexed retired lookup would make Never probes deliver into a hub at ShutDown for the duration of the successor's Build — intake refuses, a ShuttingDown NACK goes back through the parent — which is a different established answer for a window that is milliseconds long and already exists. Two answers for one state is more to reason about than one, so the lookup keeps the single semantics: Never sees the registry, and the registry holds one live hub per address.

The sync/… addresses RouteStreamMessage probes are per stream instance and never re-created under a predecessor's address, so the retire path is not reachable for them at all.

var ct = TestContext.Current.CancellationToken;

// 1. The predecessor claims the address.
var predecessor = routes.RegisterStream(address, (delivery, _) => Observable.Return(delivery));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in the next push — both registrations are using var now (line 64 and line 120), with the explicit early Dispose() kept; RegisterStream's disposable is Disposable.Create, so the second call is a no-op. The comment on the first one says why: the fixture is shared across the class, and a registration a failed assertion left behind would keep its local route and its pod-hub claim alive into the next test.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   1      2 suites   - 1   6m 14s ⏱️ +8s
2 550 tests +594  2 550 ✅ +594  0 💤 ±0  0 ❌ ±0 
2 551 runs  +594  2 551 ✅ +594  0 💤 ±0  0 ❌ ±0 

Results for commit 9a08eaf. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 558 tests  +832   1 558 ✅ +1 024   8m 39s ⏱️ + 2m 46s
    2 suites  -   1       0 💤  -   192 
    2 files    -   1       0 ❌ ±    0 

Results for commit 9a08eaf. ± Comparison against base commit bc7fe79.

♻️ This comment has been updated with latest results.

…stry

RetireCorpse adds to retiring BEFORE the value-matched TryRemove from the registry, and
DisposeHubsReactive snapshots the registry BEFORE retiring, so a retirement racing the
owner's teardown lands in at least one snapshot whatever the interleaving; a hub in both
is joined once, as retired, and the late-construction leg diffs against the registry
snapshot. The two fixture registrations in PodHubStaleReleaseTest are using-scoped so a
failed assertion cannot leave a route and a claim behind in the shared cluster.

Messaging.Hub.Test 501/0 and PodHubStaleReleaseTest 2/0 on the rebuilt dlls.

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

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    5 files  ±  0      5 suites  ±0   14m 30s ⏱️ - 1m 1s
3 319 tests  - 636  3 319 ✅  - 634  0 💤  - 2  0 ❌ ±0 
3 323 runs   - 636  3 323 ✅  - 634  0 💤  - 2  0 ❌ ±0 

Results for commit 9a08eaf. ± Comparison against base commit bc7fe79.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   13 files   -   4     13 suites   - 4   34m 11s ⏱️ -10s
8 709 tests  - 279  8 709 ✅  - 85  0 💤  - 194  0 ❌ ±0 
8 718 runs   - 279  8 718 ✅  - 85  0 💤  - 194  0 ❌ ±0 

Results for commit 9a08eaf. ± Comparison against base commit bc7fe79.

@rbuergi
rbuergi added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 9f3868f Sep 22, 2026
37 checks passed
@rbuergi
rbuergi deleted the fix/5136-dying-hub-not-handed-out branch October 10, 2026 13:10
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.

Webhook inbox delivery fails with unhandled ObjectDisposedException when portal MessageHub is shutting down

2 participants