Skip to content

Init faulting on a disposed DI scope is shutdown, not a FAILED hub (#2444) - #2604

Merged
rbuergi merged 2 commits into
mainfrom
fix/2444-init-scope-race
Aug 28, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/2444-init-scope-race

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Closes #2444 (incident aaf0ffe1d2556dc6).

Root cause

HandleInitialize's recognized-shutdown branch keys on IsShuttingDown — own disposalStarted OR the ancestor-Dispose CloseCreation cascade. An out-of-band scope teardown never trips either: when the host's root container is disposed (a Host.StartAsync abort, pod shutdown) or an ancestor Autofac scope disposes this hub's child scope, no hub Dispose() has run yet — Autofac's Disposable.Dispose flips the scope's disposed flag before its disposer reaches the tracked hub instance. A BuildupAction resolving inside that window (prod: MeshDataSourceExtensions.SubscribeToOwnDeletion resolving the workspace on mesh/… hubs) throws ObjectDisposedException while IsShuttingDown is still false → misclassified as init failure → permanent FAILED state + Error-level log for routine teardown. The 2026-08-26 occurrences line up to the second with #2443's Host.StartAsync aborts on the same pod.

Fix — at the one seam every BuildupAction shares

The catch now also recognizes fault chain carries ObjectDisposedException AND the hub's own scope no longer resolves (probed by resolving the hub's own already-materialized IMessageHub registration — cheap on a live scope, throws ODE on a dead one) as a shutdown outcome: Debug log, no FAILED residue, gate opened so the imminent disposal flows. A disposed scope is proof the hub is going away — the hub instance is a tracked component of that same scope, so its Dispose() is already queued in the very disposer that killed the scope; no zombie is possible. An ODE from an unrelated disposed dependency (scope alive) still faults the init, and InitializationErrorSurfacedTest keeps pinning that side.

Not a per-call-site try/catch in MeshDataSource (that would be N band-aids for one classification defect), and no retry/re-resolve loop.

Relation to draft #2438 (scope ownership): complementary, not overlapping — #2438 has the parent close the child's scope strictly after DisposalCompleted, when no init can be in flight; this PR fixes the classification when a scope dies out-of-band mid-init.

Deterministic repro

ScopeDisposedDuringInitializationTest: a sentinel IDisposable registered in the hub's own scope and resolved by the parked BuildupAction (created after the hub ⇒ reverse-creation-order disposal runs it inside the incident window: scope flag set, hub Dispose not yet run); its disposal wakes the BuildupAction synchronously on the disposing thread, whose resolve then throws exactly the incident's ODE with IsShuttingDown == false. No sleeps, no races.

  • RED without the fix (verified by temporarily reverting the condition): InitializationError records the ODE — the exact incident.
  • GREEN with the fix, and the companion DisposeDuringInitializationTest / InitializationErrorSurfacedTest stay green.

Verification

  • dotnet build -c Release -warnaserror (--no-incremental for the final src build): MeshWeaver.Messaging.Hub + test project, 0 warnings / 0 errors.
  • Full MeshWeaver.Messaging.Hub.Test suite, Release: 306/306 passed (1 m 18 s, fresh .trx).
  • What's New entry included (Category: Fix); WhatsNewEntryIntegrityTest run against a rebuilt DLL verified to embed the new entry: passed.

🤖 Generated with Claude Code

During hub initialization, a BuildupAction resolving from the hub's
ServiceProvider could observe ObjectDisposedException ('this LifetimeScope
(or one of its parent scopes) has already been disposed') and
HandleInitialize's .Catch put the hub in the permanent FAILED state, with
an Error-level 'initialization failed' log — for a hub that was in fact
being torn down (mesh/... data-source hubs, memex/memex-cloud pods,
2026-08-23..26, incident aaf0ffe1d2556dc6).

Root cause: the existing recognized-shutdown branch keys on IsShuttingDown
(own disposalStarted OR the ancestor-Dispose CloseCreation cascade). An
OUT-OF-BAND scope teardown — the host's root container disposed on a
Host.StartAsync abort or pod shutdown, or an ancestor Autofac scope
disposing this hub's child scope — never runs any hub Dispose first:
Autofac's Disposable.Dispose flips the scope's disposed flag BEFORE its
disposer reaches the tracked hub instance, so a BuildupAction resolving
inside that window sees a dead scope while IsShuttingDown is still false.
The 2026-08-26 occurrences line up exactly with the host-start aborts of
issue #2443: mesh hubs were mid-init when Host.StartAsync failed and the
root container was torn down.

Fix at the single seam every BuildupAction shares: HandleInitialize's
catch now also recognizes 'the fault chain carries ObjectDisposedException
AND the hub's own scope no longer resolves' (probed against the hub's
already-materialized IMessageHub registration) as a shutdown outcome —
Debug log, no FAILED residue, gate opened so the imminent disposal flows.
A disposed scope is proof the hub is going away: the hub instance is a
tracked component of that same scope, so its Dispose is already queued in
the very disposer that killed the scope. An ObjectDisposedException from
an UNRELATED disposed dependency (scope alive) still faults the init.

Deterministic repro: ScopeDisposedDuringInitializationTest disposes the
hub's own scope out-of-band while a BuildupAction is parked, using a
sentinel disposable created after the hub (reverse-creation-order disposal
runs it inside the exact window: scope flag set, hub Dispose not yet run).
RED before the fix (InitializationError records the ODE), GREEN after.
Full MeshWeaver.Messaging.Hub.Test suite: 306/306 passed (Release).

Closes #2444

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 28, 2026 12:00

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

Improves hub-initialization failure classification so an ObjectDisposedException caused by out-of-band DI scope teardown during initialization is treated as a normal shutdown outcome (no FAILED residue, no error-level “init failed” log), with a deterministic regression test and a What’s New entry.

Changes:

  • Extend MessageHub.HandleInitialize shutdown recognition to include disposed-scope teardown detection.
  • Add ScopeDisposedDuringInitializationTest to deterministically reproduce and pin the incident window.
  • Add a What’s New entry documenting the user-visible effect (no stuck FAILED hubs when startup races shutdown).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
test/MeshWeaver.Messaging.Hub.Test/ScopeDisposedDuringInitializationTest.cs Adds a deterministic repro covering scope-disposed-during-init classification (#2444).
src/MeshWeaver.Messaging.Hub/MessageHub.cs Updates init .Catch classification to treat disposed-scope ObjectDisposedException as shutdown.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-28-hub-init-scope-teardown.md Documents the fix as a user-visible reliability improvement (Category: Fix).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

474 tests  ±0   470 ✅ ±0   4m 57s ⏱️ +42s
  6 suites ±0     4 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit d509358. ± Comparison against base commit ba3b336.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

896 tests  ±0   896 ✅ ±0   5m 19s ⏱️ -14s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit d509358. ± Comparison against base commit ba3b336.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

953 tests  +7   953 ✅ +7   5m 4s ⏱️ -12s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit d509358. ± Comparison against base commit ba3b336.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

   10 files  ± 0     10 suites  ±0   6m 4s ⏱️ +4s
3 751 tests + 7  3 559 ✅ + 7  192 💤 ±0  0 ❌ ±0 
4 375 runs  +13  4 183 ✅ +13  192 💤 ±0  0 ❌ ±0 

Results for commit d509358. ± Comparison against base commit ba3b336.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 582 tests  +9   1 581 ✅ +9   6m 55s ⏱️ -7s
    8 suites ±0       1 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit d509358. ± Comparison against base commit ba3b336.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

993 tests  +8   993 ✅ +8   6m 37s ⏱️ +21s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit d509358. ± Comparison against base commit ba3b336.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   42 files  ± 0     42 suites  ±0   34m 59s ⏱️ +33s
8 649 tests +31  8 452 ✅ +31  197 💤 ±0  0 ❌ ±0 
9 273 runs  +37  9 076 ✅ +37  197 💤 ±0  0 ❌ ±0 

Results for commit d509358. ± Comparison against base commit ba3b336.

♻️ This comment has been updated with latest results.

From review on #2604: IsTerminatedByScopeTeardown walked only InnerException, so an
ObjectDisposedException carried by an AggregateException at any index but 0 was invisible to it.
Reactive pipelines produce exactly that — a Merge that saw a real error alongside a teardown
hands back an aggregate whose ordering nobody controls — so a shutdown would have been
classified as an init FAILURE some of the time and as shutdown the rest, from one code path.

HubDisposingException.IsHubDisposal already had a tree walk. Rather than copy a second one, both
now share ExceptionChain — they ask different questions about the same graph, and a walker fixed
in one place had already left the other wrong.

🚨 Extracting it exposed a defect in the ORIGINAL shape, which the shared version would have
inherited unchanged. A for-loop over InnerException that ALSO recurses into InnerExceptions
re-walks the same chain once per position, because AggregateException.InnerException IS
InnerExceptions[0]. On nested aggregates that is exponential: 200 of them pinned a core at 100%
CPU indefinitely — found by the new pathological-graph test on its first run, which is why it now
asserts a WALL-CLOCK bound rather than mere termination. A walker that eventually returns the
right answer is still a defect, and a depth cap could never have fixed it: the blow-up is in
breadth of re-walking, not depth.

The traversal is now iterative over an explicit stack with a reference-identity visited set and a
256-node budget — linear in the graph, bounded regardless of shape, and terminating on a CYCLIC
graph, which the recursive version could not do at all. Exception graphs are caller-supplied data;
this is the difference between a classification and a hang.

8 ExceptionChain tests (17 ms, against a core pinned indefinitely), plus
HubDisposalFailureClassification, ScopeDisposedDuringInitialization and
DeletedAddressNackClassification green.

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

Hub init BuildupAction resolves the workspace from a disposed Autofac LifetimeScope, permanently failing the hub

2 participants