Skip to content

The thing that OPENED the lifetime scope closes it — not the hub inside it - #2438

Merged
rbuergi merged 2 commits into
mainfrom
fix/dispose-hub-service-provider
Aug 28, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/dispose-hub-service-provider

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

A hub built under a parent gets its own Autofac BeginLifetimeScope from CreateServiceProvider, and nothing closed it. The call that would have sat in MessageHub's ShutDown phase, commented out.

Two costs that look unrelated and are one bug:

  • every IDisposable in the scope outlives the hub — Roslyn load contexts, Npgsql connections, native buffers — so its finalizer runs against a graph already torn down, the standard route to a SIGSEGV at process exit;
  • an Autofac parent tracks every child scope until the parent itself dies, so a portal that recycles hubs for hours accumulates one live scope per hub, with everything each holds.

Why the fix is not where the comment was

I put it back there first. It is wrong twice over:

The hub is a singleton IN the scope it would be destroying. Closing it from inside its own ShutDown phase pulls its logger — and the rest of that method's finally — out from under it.

A hub disposed ON ITS OWN never reaches a parent's teardown branch. That is the recycle, and the recycle is the case that actually leaks.

So the close lives on the parent side, in HostedHubsCollection, subscribed to the child's DisposalCompleted: strictly after the child is terminally down, and reached by both routes. Reactive throughout — Take(1), Catch so a faulted disposal still frees the scope (a failed teardown is when the handles are most likely still held), DefaultIfEmpty so a source that completes without emitting is settled from its known-terminal state rather than parked.

Ownership is recorded, not inferred

MessageHubConfiguration.CreateServiceProvider is the only line that knows whether a scope was opened, so it says so. Every available proxy is wrong: parentAddress is not null misses a hub whose parent is a provider and not a hub, and "is it an AutofacServiceProvider" is equally true of the container the host owns and closes itself.

One consequence, handled at the cause

SynchronizationStream's constructor resolved its logger before the gate that refuses on a disposing host. A host that has finished disposing no longer has a container, so that turned a HubDisposingException into Autofac's raw ObjectDisposedException — same family, but naming the container instead of the hub, carrying no address for the caller to retry, and never reaching the ShuttingDown classification. Ordering is the fix; a catch around the resolve would only re-wrap the wrong fact. DeadStreamSafetyTest is what caught it.

Evidence

  • HubDisposesItsServiceProviderTest pins both routes — the recycle and the parent teardown — because they run different code and a close wired onto one would leave the other leaking exactly as before.
  • Both fail with the close removed (verified by removing it, rebuilding, re-running).
  • MeshWeaver.Messaging.Hub.Test: 281/281.

🤖 Generated with Claude Code

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

Ensures each MessageHub disposes the DI container it creates per hub instance, addressing leaks and shutdown instability by disposing the hub’s owned service provider during teardown and adding a regression test for the ownership contract.

Changes:

  • Disposes the hub-owned ServiceProvider during the ShutDown phase of MessageHub teardown.
  • Adds a new test validating that disposing a hub disposes disposable singletons created within its DI container.

Reviewed changes

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

File Description
test/MeshWeaver.Messaging.Hub.Test/HubDisposesItsServiceProviderTest.cs Adds a regression test asserting hub disposal also disposes a disposable singleton resolved from the hub’s container.
src/MeshWeaver.Messaging.Hub/MessageHub.cs Adds DisposeServiceProvider(...) and invokes it during the ShutDown phase before signalling DisposalCompleted.

💡 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 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

481 tests  +7   477 ✅ +7   5m 7s ⏱️ +14s
  6 suites ±0     4 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 13546b1. ± Comparison against base commit 89f8862.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

898 tests  +2   898 ✅ +2   5m 29s ⏱️ -4s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 13546b1. ± Comparison against base commit 89f8862.

This pull request removes 6 and adds 8 tests. Note that renamed tests count towards both.
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_AbsoluteAreaVerb_CarriesAddressAndArea
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Embed_AuthorOptsInToHeader_QueryStrippedFromRawPath
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Embed_ExplicitShowHeaderFalse_HidesAndCleansPath
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Embed_HidesHeaderByDefault_WithCleanRawPath
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Path_EmitsLayoutAreaElement_NotLiteralText
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ SingleAt_RendersHyperlink_NotInlineArea
MeshWeaver.Markdown.Test.MarkdownAtAtOperatorTest ‑ AtAt_AbsoluteAreaVerb_CarriesAddressAndArea
MeshWeaver.Markdown.Test.MarkdownAtAtOperatorTest ‑ AtAt_Embed_AuthorOptsInToHeader_QueryStrippedFromRawPath
MeshWeaver.Markdown.Test.MarkdownAtAtOperatorTest ‑ AtAt_Embed_ExplicitShowHeaderFalse_HidesAndCleansPath
MeshWeaver.Markdown.Test.MarkdownAtAtOperatorTest ‑ AtAt_Embed_HidesHeaderByDefault_WithCleanRawPath
MeshWeaver.Markdown.Test.MarkdownAtAtOperatorTest ‑ AtAt_Path_EmitsLayoutAreaElement_NotLiteralText
MeshWeaver.Markdown.Test.MarkdownAtAtOperatorTest ‑ SingleAt_RendersHyperlink_NotInlineArea
MeshWeaver.PluginCatalog.Test.DefaultInstallCommercialSkipTest ‑ ACommercialPackage_IsSkippedWithReason_NeverRetried_AndInstallsWhenItsTermsChange
MeshWeaver.PluginCatalog.Test.DefaultInstallCommercialSkipTest ‑ TheClassifierIsPure_AndCountsBothCommercialShapes

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 584 tests  +2   1 583 ✅ +2   6m 52s ⏱️ -13s
    8 suites ±0       1 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit 13546b1. ± Comparison against base commit 89f8862.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

957 tests  +4   957 ✅ +4   5m 20s ⏱️ +4s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 13546b1. ± Comparison against base commit 89f8862.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

995 tests  +2   995 ✅ +2   6m 24s ⏱️ +8s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit 13546b1. ± Comparison against base commit 89f8862.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

   10 files  ±0     10 suites  ±0   6m 5s ⏱️ -1s
3 755 tests ±0  3 563 ✅ ±0  192 💤 ±0  0 ❌ ±0 
4 383 runs  +3  4 191 ✅ +3  192 💤 ±0  0 ❌ ±0 

Results for commit 13546b1. ± Comparison against base commit 89f8862.

This pull request removes 1 and adds 1 tests. Note that renamed tests count towards both.
MeshWeaver.Layout.Test.MauiViewDataPathTest ‑ ContainerTreeAndLeaves_RenderForTheMauiPack
MeshWeaver.Layout.Test.NativeRendererDataPathTest ‑ ContainerTreeAndLeaves_ResolveThroughTheControlStreamPipeline

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   42 files  ± 0     42 suites  ±0   35m 19s ⏱️ +8s
8 670 tests +17  8 473 ✅ +17  197 💤 ±0  0 ❌ ±0 
9 298 runs  +20  9 101 ✅ +20  197 💤 ±0  0 ❌ ±0 

Results for commit 13546b1. ± Comparison against base commit 89f8862.

This pull request removes 7 and adds 24 tests. Note that renamed tests count towards both.
MeshWeaver.Layout.Test.MauiViewDataPathTest ‑ ContainerTreeAndLeaves_RenderForTheMauiPack
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_AbsoluteAreaVerb_CarriesAddressAndArea
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Embed_AuthorOptsInToHeader_QueryStrippedFromRawPath
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Embed_ExplicitShowHeaderFalse_HidesAndCleansPath
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Embed_HidesHeaderByDefault_WithCleanRawPath
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ AtAt_Path_EmitsLayoutAreaElement_NotLiteralText
MeshWeaver.Markdown.Test.MauiMarkdownAtAtOperatorTest ‑ SingleAt_RendersHyperlink_NotInlineArea
MeshWeaver.Documentation.Test.LocalOverlayDefaultsToDevLoginTest ‑ DevAdminUsersIsSetAlongsideIt
MeshWeaver.Documentation.Test.LocalOverlayDefaultsToDevLoginTest ‑ DevLoginIsOnByDefault
MeshWeaver.Documentation.Test.LocalOverlayDefaultsToDevLoginTest ‑ EntraIsCommentedOut(key: \"Authentication__Microsoft__ClientId\")
MeshWeaver.Documentation.Test.LocalOverlayDefaultsToDevLoginTest ‑ EntraIsCommentedOut(key: \"Authentication__Microsoft__ClientSecret\")
MeshWeaver.Documentation.Test.LocalOverlayDefaultsToDevLoginTest ‑ EntraIsCommentedOut(key: \"Authentication__Microsoft__TenantId\")
MeshWeaver.Documentation.Test.LocalOverlayDefaultsToDevLoginTest ‑ EntraIsCommentedOut(key: \"Authentication__Provider\")
MeshWeaver.Documentation.Test.LocalOverlayDefaultsToDevLoginTest ‑ EntraRemainsDocumentedAsAnOptIn
MeshWeaver.Hosting.Monolith.Test.SelfTypedDeclarationDurableRepairTest ‑ FossilSelfTypedDeclarationRows_AreRetypedAtStartup
MeshWeaver.Hosting.Monolith.Test.SelfTypedDeclarationDurableRepairTest ‑ TheUserDirectory_NoLongerReturnsTheFossilDeclaration
MeshWeaver.Hosting.Monolith.Test.SelfTypedDeclarationRepairRetainsNothingTest ‑ StartAsync_IsOneShot_andTheSecondCallIsAnInertNoOp
…

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

All 8 red checks are own-diff — the container is disposed one whole teardown phase too early

3 shards actually failed (1, 2, 5); the other 5 red checks are their reporters plus the consolidator. 9 failing tests, none pre-existing. The diff is 2 files / +150 lines and introduces exactly one behavioural change — DisposeServiceProvider(...) in HandleShutdownCore's ShutDown phase — and every failure message names LifetimeScope … has already been disposed. Nothing else in the tree disposes hub containers.

Reproduced deterministically on this branch, locally, in isolation:

DeadStreamSafetyTest.Ctor_OnDisposingHost_Refuses_WithATransientHubDisposalError [FAIL]
  Expected a HubDisposingException …, but found ObjectDisposedException:
  Instances cannot be resolved and nested lifetimes cannot be created from this
  LifetimeScope as it (or one of its parent scopes) has already been disposed.

Root cause: placement

MeshTeardownExtensions already states the contract this change has to satisfy — teardown is two halves, and DisposalCompleted covers only the first:

Anything that must not run before teardown truly ends — scope disposal, node-ALC unload, the next test's mesh — subscribes to that signal (MeshTeardownSignal), never to IMessageHub.DisposalCompleted alone.

The PR disposes the scope inside the ShutDown phase, i.e. before SignalDisposalCompleted() even fires — strictly earlier than the earliest point the contract already forbids. That same file predicts the symptom verbatim: "a late continuation resolves a service from the already-disposed Autofac scope and throws ObjectDisposedException ('LifetimeScope … has already been disposed')".

What that costs, in four families

  1. The transient-error contract breaks (production regression, not a test artifact). SynchronizationStream's ctor resolves ILogger from Host.ServiceProvider (SynchronizationStream.cs:1053) before the frozen-hosted-hub gate that throws HubDisposingException (:1103). With a dead container Autofac wins, so the failure loses its HubAddress and its ErrorType.ShuttingDown classification — reinstating exactly the terminal-vs-retry regression Answer deferred deliveries when a hub disposes (portal hang on recycle-during-activation) #672 fixed ("A page that subscribed during a recycle got 'this failed forever' instead of 'ask again'"). This generalises: 922 hub-qualified ServiceProvider.Get* sites in src/ become unclassified terminal faults during any hub recycle, and node-hub recycles are routine.
  2. The delete pipeline resolves DI from the hub whose node it just deleted. MeshExtensions.RunPostDeletionHandlersObs (:3579) calls hub.ServiceProvider.GetServices<INodePostDeletionHandler>() at step 5 — after the subtree, including that hub's own node, is gone. 5 of the 9 failures (DeletionTests, ThreadCreationTest, NodeTypeCompileParkTest, SpaceDeletionPartitionDropTests, UserPartitionDeletionCascadeTests).
  3. Test-base teardown resolves the mesh-scoped drain services from an already-dead mesh, in tests that dispose the mesh themselves (NodeAlcUnloadTeardownOrderingTest, ActivityOutlivesDisposeTest).
  4. A teardown wedge — parent and hosted hub both parked at DisposeHostedHubs, Disposal=Pending, queues empty (UserHomeLegacyPathResolutionTest). Load-dependent; did not reproduce locally.

Why I stopped rather than pushing a fix

The leak this PR targets is real — hosted-hub child scopes do accumulate under a long-lived parent, and Autofac tracks every one until the parent dies. But making hub containers mortal is a system-wide invariant change: getting it green means changing the delete pipeline, stream construction, the test base and hosted-hub teardown ordering, and then auditing those 922 resolution sites — because a timing-dependent invariant that passes one CI run is not thereby safe. Chasing each ObjectDisposedException as it surfaces would be whack-a-mole, not a root fix.

That is a different change from "a hub never disposed its own DI container", so it needs your scope call. The two shapes I'd weigh:

  • Sequence it: first land the two genuine latent defects (1) and (2) — both are correct fixes on their own merits and small — then make containers mortal on top.
  • Relocate it: dispose each hub's scope on the MeshTeardownSignal (the hook explicitly named for scope disposal). Correct by construction, but it only fires at mesh shutdown, so it does not fix the recycled-node-hub leak — which is the leak worth fixing.

@rbuergi
rbuergi marked this pull request as draft August 26, 2026 22:14
auto-merge was automatically disabled August 26, 2026 22:14

Pull request was converted to draft

@rbuergi

rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Converting to draft — the placement is wrong, and the tests are right

Two independent tests caught this, and they say the same thing from different angles. Control on clean main: 6/6 pass, so both are mine.

1 — unconditional disposal broke NodeAlcUnloadTeardownOrderingTest:

ObjectDisposedException: … LifetimeScope … already disposed
  at MonolithMeshTestBase.DisposeAsync()

The teardown orchestration resolves IoPoolRegistry / MeshTeardownSignal from that container after DisposalCompleted, to cancel-and-join pooled I/O before node ALCs unload.

2 — narrowed to hubs that own a child scope still breaks DeadStreamSafetyTest:

Expected a HubDisposingException …, but found ObjectDisposedException:
  … LifetimeScope … already disposed

A disposing hub must refuse with the domain-meaningful HubDisposingException. With its container gone, Autofac's error arrives first and the refusal consumers rely on is replaced by a DI failure.

The conclusion

The container must outlive the hub's own disposal, because the hub's disposal semantics are expressed through code that still resolves from it. HandleShutdownCore is the wrong place at any narrowing — which is why the original line was commented out. I read that as an oversight; it was not.

What still stands

The leak is real: CreateServiceProvider gives every hosted hub its own BeginLifetimeScope, Autofac's parent tracks child scopes until the parent dies, and nothing closes them. That is unbounded growth on a portal that recycles hubs for hours.

The right owner is HostedHubsCollection — the parent disposes a child's scope after that child reaches Dead, i.e. after the child's own teardown has fully settled — not inside MessageHub.

HubDisposesItsServiceProviderTest is worth keeping as the statement of the invariant, but it belongs against that design, not this one.

@rbuergi rbuergi changed the title fix: a hub never disposed its own DI container The thing that OPENED the lifetime scope closes it — not the hub inside it Aug 27, 2026
@rbuergi
rbuergi marked this pull request as ready for review August 27, 2026 04:43
@rbuergi
rbuergi enabled auto-merge August 27, 2026 04:43
@rbuergi
rbuergi disabled auto-merge August 27, 2026 04:57
@rbuergi
rbuergi marked this pull request as draft August 27, 2026 04:58
@rbuergi

rbuergi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

CI found two real consumers that resolve from a hub's container after its disposal has completed. Both are regressions from this PR, not flakes, and I am putting it back to draft rather than re-running until it goes green.

What failed

OrleansGrainTeardownStragglerTest.GrainLifetimeCallbacks_AfterActivationIsInvalid_AreGracefulNoOps_NotThrows

System.ObjectDisposedException : Instances cannot be resolved and nested lifetimes cannot be
created from this LifetimeScope as it (or one of its parent scopes) has already been disposed.

The test name is the contract: those callbacks must be graceful no-ops. They were, only because there had never been a closed scope for them to meet.

SpaceDeletionPartitionDropTests / UserPartitionDeletionCascadeTests

Delete failed for 'pgdropc0b8d98f1c/_Access/Roland_Access': Unexpected error: Instances cannot be resolved…
Delete failed for 'usrdel8205cf531b/_UserActivity/act1': Unexpected error: Instances cannot be resolved…

Not a straggler — a real operation failing. MeshExtensions' delete cascade calls hostedHub?.Dispose() and the work continues against that hub's provider.

What this means for the change

The ownership claim still holds: the thing that opened the scope should close it, nothing did, and the two costs (finalizers against a torn-down graph; one live scope per recycled hub) are real. What CI shows is that closing the scope is a behaviour change with a blast radius, because several paths legitimately use a hub's provider after DisposalCompleted — and I found two by running the suite once, which is not a reason to believe there are only two.

So this needs the consumers fixed first, and each on its own merits:

  • the grain lifetime callbacks should honour the contract their own test names — tolerate a closed scope and no-op;
  • the delete cascade disposing a hub and then continuing to use it is an ordering bug in its own right, visible now only because the scope outlived the hub.

What I deliberately did NOT do

Add a grace period before closing the scope. A timer would make both failures disappear and would leave a race that fires rarely and never reproduces — the shape this codebase keeps paying for. The leak is measured in hours of hub recycling; the fix should be ordering, not a delay.

Local state for whoever picks this up: MeshWeaver.Data.Test 390/390 and MeshWeaver.Messaging.Hub.Test 281/281 on this branch, and both ownership tests fail with the close removed (verified by removing it).

@rbuergi

rbuergi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Correcting my previous comment — I attributed the Orleans failure to the wrong cause. The accurate reading, with stacks:

1. A process-poisoning FATAL in MeshWeaver.Threading.Test

Not a test failure — the assembly dies at startup:

[FATAL ERROR] System.ObjectDisposedException
  Instances cannot be resolved and nested lifetimes cannot be created from this LifetimeScope…
  at ServiceProviderServiceExtensions.GetService[T](IServiceProvider)
  MeshExtensions.cs(3469): at MeshWeaver.Mesh.MeshExtensions.RunPostCreationHandlersObs(IMessageHub, MeshNode, string, ILogger)
  MeshExtensions.cs(924):  at MeshExtensions.<>c__DisplayClass15_0.<HandleCreateNodeRequest>b__3(ValueTuple`2)
  at System.Reactive.AnonymousSafeObserver`1.OnNext …

RunPostCreationHandlersObs resolves three services — IStorageAdapter, IMeshChangeFeed, INodePostCreationHandler — from hub.ServiceProvider, on a reactive continuation of a create. When that hub has gone down, the throw escapes into an unobserved Rx chain and xUnit v3 escalates it to a Catastrophic failure that takes the whole assembly with it.

That escalation shape is documented in this repo already, in OrleansGrainTeardownStragglerTest: "faults a ThreadPool task nobody observes, and xUnit v3 escalates the UnobservedTaskException to a Catastrophic failure that poisons the NEXT test class." Same mechanism, new cause.

2. The Orleans test times out — it does NOT throw

I said the grain lifetime callbacks were throwing. They are not; TryDelayDeactivation / TryDeactivateOnIdle already catch the invalid-activation case correctly. The actual failure is at line 98:

System.TimeoutException : The operation has timed out.
  OrleansGrainTeardownStragglerTest.cs(98)

which is the poll waiting for the activation to leave the silo catalog after hub disposal:

.Where(stats => stats.All(s => !string.Equals(s.GrainId.ToString(), grainId, …)))
.FirstAsync().Timeout(TimeSpan.FromSeconds(30))

So the grain never deactivated within 30 s. Mechanism not yet established — but "the hub's container is gone before something the deactivation needs" is the obvious candidate and the same family as #1.

3. The delete cascade — same file

Delete failed for '…/_Access/Roland_Access': Instances cannot be resolved…, reached through MeshExtensions' delete path, which calls hostedHub?.Dispose() and continues.

The shape

Three sites, two of them in MeshExtensions' node CRUD, all the same pattern: a reactive continuation resolves from a hub's provider after that hub is down. Today that works only because nothing closes the scope. That is not a small blast radius, and the failure mode is the worst kind — an unobserved throw that kills a whole test assembly rather than failing one case.

The rule those sites need is one this codebase already states, one layer over: a dead activation is a graceful terminal — log and no-op, never throw. Applying it to a hub whose container is closed is the principled fix, and it is its own piece of work, not a rider on this PR.

Staying draft.

@rbuergi

rbuergi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Checked this PR against the #2346 fix I have in flight (#2482) — and it does not unblock you. Saying so explicitly, because "another Orleans flake on shard 3" is exactly the inference that keeps mis-attributing this cluster.

Run 33040002238 failed three tests, none of them mine:

shard test tracked as
3 OrleansGrainTeardownStragglerTest.GrainLifetimeCallbacks_AfterActivationIsInvalid_AreGracefulNoOps_NotThrows #2301 — "activation never leaves the catalog"; reproduced locally in BOTH arms of #2393's A/B, so it is independent of the memory fix too
4 MeshWeaver.Hosting.PostgreSql.Test.SpaceDeletionPartitionDropTests.DeletingSpace_DropsWholePartition_AndSameIdCanBeRecreated not in #2346's roster
4 MeshWeaver.Hosting.PostgreSql.Test.UserPartitionDeletionCascadeTests.DeletingUserPartitionRoot_UnderSystem_DropsWholePartition not in #2346's roster

#2482 fixes one specific mechanism — a cross-hub write whose leaf is silently refused because the per-path queue released its slot on the optimistic emit instead of on the owner's verdict. Its signature is a wrong value at a terminal state (Status = Completed with Text still on the placeholder), and the tell in a log is [MergeGuard] refused stale/reordered cross-hub write. None of the three above has that shape: the shard-3 one is an activation/teardown lifetime assertion, and the two shard-4 ones are partition-drop tests in a different assembly on a different backend.

Worth noting the two PostgreSql ones failed together — a pair in the same area is more often one real cause than two flakes, and neither appears in #2346. If the pair recurs it probably deserves its own issue rather than being folded into the cluster.

@rbuergi

rbuergi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Re-synced onto #2505, and the new diagnostics named it on first contact — this failure is OWN-DIFF

I re-synced this branch after #2505 (the Orleans/test diagnostics fix) landed. The run now fails shard 2, a shard it had not failed before, and — unlike every previous attribution on this PR — the failure is legible without a matched local control:

Run tests (shard 2)
  MeshWeaver.Hosting.Monolith.Test.DeleteTimeoutStageDiagnosticsTest
    .CascadeLeg_GivesUpOnItsOwnNestedBudget_NamingItsStageAndItsProgress [FAIL]

  Expected "Unexpected error: Delete failed for 'TestData/commitstall/healthy':
            Unexpected error: Instances cannot be resolved and nested lifetimes
            cannot be created…"

"Instances cannot be resolved and nested lifetimes cannot be created" is Autofac's ObjectDisposedException from a disposed LifetimeScope. This PR disposes hub containers. So this is own-diff, and it is the exact consequence the earlier analysis on this PR predicted:

SynchronizationStream's ctor resolves ILogger from Host.ServiceProvider before the gate that throws HubDisposingException … generalises to 922 hub-qualified ServiceProvider.Get* sites in src/.

A delete now resolves DI from a hub whose container this PR has disposed. That is family 1 and 2 of the four the analysis listed, reaching a test that names the stage it died in.

#2505 earning itself immediately

Also in that job:

crash="MASKED: exit=$rc, but the trx records only $trxfails failing test(s) — that does not
       explain the exit code, so the host crashed AFTER streaming results."
HOST_CRASHED

Under the previous reporting a host that died after streaming results was indistinguishable from a pass — the Content.Test trap, where a summary line reported a pass over 3 tests while the process had crashed. Its first real firing is on this PR, hours after it shipped.

What this means for the PR

The earlier report on #2438 stopped short of merging and asked for a scope call, on the grounds that making hub containers mortal is a system-wide invariant change touching the delete pipeline, stream construction, the test base and hosted-hub teardown, with 922 resolution sites to audit. This failure is direct evidence for that position, not against it: the first shard to exercise the delete path under the new diagnostics found a disposed-scope resolution immediately.

The two options remain as stated — sequence it (land the delete-pipeline and stream-construction fixes first, each correct on its own merits, then make containers mortal on top), or relocate the disposal to the MeshTeardownSignal (correct by construction, but fires only at mesh shutdown and so does not fix the recycled-node-hub leak that motivates this PR).

Not merging, and not re-running. Recording the attribution because it is now measured rather than argued.

@rbuergi

rbuergi commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

A second front, and it strengthens the same conclusion

Shard 1 on the same run also failed:

Run tests (shard 1)
  MeshWeaver.Hosting.Monolith.Test.SpaceOverviewBodyTest.SpaceOverview_RendersContentBody_NotWelcomeTemplate [FAIL]
  HOST_CRASHED

So the run now carries two independent Monolith tests on two different shards, plus a HOST_CRASHED marker on each:

shard test shape
2 DeleteTimeoutStageDiagnosticsTest.CascadeLeg_GivesUpOnItsOwnNestedBudget_… disposed LifetimeScope during a delete
1 SpaceOverviewBodyTest.SpaceOverview_RendersContentBody_NotWelcomeTemplate a render producing the wrong body

Both are MeshWeaver.Hosting.Monolith.Test; neither appears on #2346's roster; and a render resolving from a disposed container is the second of the four consequence families this PR's own analysis named — SynchronizationStream's ctor resolving ILogger from Host.ServiceProvider before the HubDisposingException gate. A render that falls back to the welcome template instead of the content body is what "the resolution failed and something degraded" looks like from the outside.

I am not claiming the two share one root by measurement — that would need the same discrimination I did for #2475. What I can say is that two tests, on two shards, in the paths this diff touches, failing on a branch whose only behavioural change is disposing hub containers, is not a coincidence worth explaining away.

What is now different, and worth noting for its own sake

Both shards also produced HOST_CRASHED. Before #2505 (merged ~2 hours ago) a host that died after streaming results was indistinguishable from a pass — that is the Content.Test trap, where a summary line reported a pass over 3 tests while the process had crashed. This PR is the first thing to exercise the new marker in anger, and it has fired twice.

That matters beyond this PR: the previous "green" runs of branches touching hub disposal may have been masking host crashes. Anyone reading this PR's history should treat pre-#2505 green runs on disposal-adjacent work as weaker evidence than they appear.

Still not merging, still not re-running.

@rbuergi
rbuergi force-pushed the fix/dispose-hub-service-provider branch from d9f2a91 to a552094 Compare August 28, 2026 10:52
…de it

A hub that owns its DI lifetime scope must not be the thing that closes it: closing the scope
disposes the hub that lives in it, so the hub would be tearing down the container it is still
running inside. HostedHubsCollection opened the scope, so it closes it — strictly after the hub
is terminally down, driven off DisposalCompleted.

That signal covers every terminal shape, which is the point:
  - a clean shutdown signals it at the end of the ShutDown phase;
  - a FAULTED disposal reaches it through .Catch(_ => Return(Unit)) — a teardown that failed is
    when the handles are most likely still held, so it must free the scope too;
  - a source completing WITHOUT emitting is settled by .DefaultIfEmpty rather than parked;
  - and the watchdog's out-of-band ForceTeardownAfterWatchdog signals it as well, so a hub torn
    down after a disposal deadlock closes its scope through the same single path.

REBUILT ONTO CURRENT MAIN. Core's history was rewritten on 2026-08-28, and this branch predates
it — so the two share no merge base and `git merge origin/main` produced 5996 files / 889k
insertions plus an add/add conflict, none of which is this change. The branch is therefore
replayed as the PR's actual 6-file diff on top of today's main rather than merged; the content is
unchanged (`gh pr diff` applied clean).

Verified with CI's flags: MeshWeaver.Messaging.Hub, MeshWeaver.Data and both touched test
projects build Release -warnaserror clean; HubDisposesItsServiceProviderTest 2/2 and
DeadStreamSafetyTest 6/6 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi force-pushed the fix/dispose-hub-service-provider branch from a552094 to 184a1fc Compare August 28, 2026 13:54
@rbuergi

rbuergi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Rebuilt onto current main — the branch predated the history rewrite

Force-pushed (--force-with-lease). The change itself is unmodified; only its base moved.

Why it had to be replayed rather than merged. Core's history was rewritten on 2026-08-28 and this branch predates it, so the two share no merge base. git merge origin/main here produced 5996 files / 889k insertions plus an add/add conflict in tools/MeshWeaver.PluginTester/TreeBake.cs — none of it this change. Completing that merge would have put ~889k lines of unrelated history into the PR and made the diff unreviewable; git log origin/main..HEAD --no-merges counted 9221 commits, which is the no-merge-base signature rather than a real divergence.

So the PR's actual diff (gh pr diff — 6 files, 419 lines) was applied to a fresh branch cut from today's main. It applied clean, which is the useful evidence that nothing behavioural was reconciled by hand.

Verified with CI's flags, one project per invocation:

  • MeshWeaver.Messaging.Hub, MeshWeaver.Data, MeshWeaver.Messaging.Hub.Test, MeshWeaver.Data.Test — all -c Release -warnaserror, Build succeeded
  • HubDisposesItsServiceProviderTest 2/2
  • DeadStreamSafetyTest 6/6

The prior red CI was measured against the stale base and is superseded by this run.

Also answered the open review thread: the watchdog teardown does not leak the scope. ForceTeardownAfterWatchdog calls SignalDisposalCompleted(), which pushes onto the disposalCompleted ReplaySubject(1) behind DisposalCompleted, which is exactly what HostedHubsCollection.CloseScopeWhenDisposed subscribed to at Add time. Same single path as a clean shutdown, and the ReplaySubject removes any subscribe-after-signal race.

@rbuergi

rbuergi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

🚨 This is a real regression in the PR, not a flake — reproduced locally and attributed by a control run

I chased this the way I'd chase someone else's red, and it does not come out in this PR's favour.

The measurement

Same machine, same command, same 749-test assembly, DOTNET_PROCESSOR_COUNT=4:

branch result
origin/main @ bf1ae3a24 746 passed, 0 failed (9 m 31 s)
this branch 745 passed, 1 failed (9 m 48 s)

The only variable is this diff.

The failure

CI (shard 2) and my local run failed different tests in the same family, with the identical error:

RecursiveDeleteAuthoritativeTest.Write_UnderSubtreeBeingDeleted_IsRefused_AndNodeDoesNotSurvive   (CI)
RecursiveDeleteRouterTrafficTest.RecursiveDelete_Authorized_MakesTheRouterNeitherEndOfAnyDelivery (local)

  Delete failed for 'TestData/…': Unexpected error: Instances cannot be resolved and nested
  lifetimes cannot be created from this LifetimeScope as it (or one of its parent scopes)
  has already been disposed.

Two distinct tests, two environments, one error class — and that error class is precisely what this PR introduces, since its entire subject is who closes hub lifetime scopes.

It only reproduces with the FULL assembly

Worth recording so nobody repeats the cheap checks:

  • RecursiveDeleteAuthoritativeTest alone, 15× under PROCESSOR_COUNT=4 → 15/15 pass
  • --filter ~RecursiveDelete (7 tests) → pass
  • full 749-test assembly → fails

An isolated run discriminates nothing here: this is a cross-mesh scope race, so the interference between test classes IS the trigger.

Mechanism — and one hypothesis I killed

The error names a parent scope, and closing a child scope never disposes a parent. So something resolves from a scope whose ANCESTOR already closed.

I first suspected OwnsServiceProvider was wrongly true for a shared scope. It is not — ServiceProviderExtensions.SetupModules always calls BeginLifetimeScope when parent != null, so the scope really is fresh and owned, and OwnsServiceProvider = ParentServiceProvider is not null is correct. That hypothesis is dead; recording it so it is not re-run.

What fits the evidence: a recursive delete posts DeleteNodeRequest to new Address(path) per leaf, which activates a per-node hub. When an ancestor hub is concurrently disposing — because its own node is being deleted in the same cascade — that activation resolves from a closing scope. Before this PR the scope was never closed, so the resolution still succeeded; the change converts a leak into a live failure. That is not an argument for the leak, but it is why the cascade never had to order itself before.

Two candidate fix shapes, neither obviously right

  1. Refuse the activation cleanly. Creating a hub under a disposing ancestor should answer the routing NACK the framework already has (HubDisposingException / "is shutting down"), which the delete cascade can treat as absent-and-therefore-deleted — instead of a raw Autofac ObjectDisposedException surfacing as Delete failed.
  2. Order the cascade. Ensure no leaf activation is in flight for a subtree whose ancestor has begun disposal.

(1) is smaller and matches how the codebase already classifies disposal races; (2) addresses the ordering directly but is a bigger change to the delete path.

Not merging this. The leak fix is right and the review thread about the watchdog is answered, but shipping it as-is breaks recursive delete.

…node's scope is gone after it

Closing the regression this PR introduced, found by running the full assembly on both branches.

Making HostedHubsCollection actually CLOSE a hub's lifetime scope turned a leak into a live
failure in recursive delete:

    Delete failed for 'TestData/rtspace/keep': Instances cannot be resolved and nested lifetimes
    cannot be created from this LifetimeScope as it (or one of its parent scopes) has already
    been disposed.

The stack names it exactly — not hub activation (CreateHub catches and returns null, so it cannot
produce this), but step 5:

    at Autofac…AutofacServiceProvider.GetRequiredService(Type)
    at MeshExtensions.RunPostDeletionHandlersObs(...)        MeshExtensions.cs:3629
    at MeshExtensions.<HandleDeleteNodeRequest>b__20(...)    MeshExtensions.cs:2574

RunPostDeletionHandlersObs resolved INodePostDeletionHandler from hub.ServiceProvider AFTER the
subtree was deleted. That hub is the deleted root's own; its scope is now closed, so Autofac
throws at RESOLUTION — outside the per-handler .Catch, so it escapes as a failed delete even
though step 5's whole contract is that a handler failure cannot un-delete anything and stays a
Warning.

The handlers are now resolved while the scope is alive, before the cascade, and the list is
passed in. Deliberately NOT a guard around the resolution: these handlers are the side effects
that drop a partition's backing store when a partition-owning Space root is deleted, so skipping
them would trade a visible failure for a leaked database. A handler that itself resolves from the
dead scope still throws INSIDE Handle(), where the existing .Catch degrades it to the Warning the
contract promises.

Measured, same machine, same command, full 749-test assembly, DOTNET_PROCESSOR_COUNT=4:

    main @ bf1ae3a        746 passed, 0 failed   (9m31s)
    this branch, before     745 passed, 1 failed   (9m48s)
    this branch, after      746 passed, 0 failed   (9m43s)

An isolated run proves nothing here — RecursiveDeleteAuthoritativeTest alone passed 15/15 and the
7-test ~RecursiveDelete filter passed; only the full assembly reproduces it, because the trigger
is cross-mesh scope teardown between test classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi marked this pull request as ready for review August 28, 2026 15:02
@rbuergi

rbuergi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixed at the root, and the before/after is a clean three-way

Found the actual throw site — my earlier mechanism in this thread was wrong and I want that on the record rather than quietly replaced. I said the cascade's hub activation resolved from a closing scope. It cannot: HostedHubsCollection.CreateHub wraps construction in catch (Exception) { return null; }, so an ObjectDisposedException there becomes a null, never this error.

The stack from the local repro names it:

at Autofac…AutofacServiceProvider.GetRequiredService(Type)
at MeshExtensions.RunPostDeletionHandlersObs(...)        MeshExtensions.cs:3629
at MeshExtensions.<HandleDeleteNodeRequest>b__20(...)    MeshExtensions.cs:2574

It is step 5. RunPostDeletionHandlersObs resolved INodePostDeletionHandler from hub.ServiceProvider after the subtree was deleted — and that hub is the deleted root's own, whose scope this PR now closes. The throw lands at resolution, outside the per-handler .Catch, so it escaped as Delete failed even though step 5's stated contract is that a handler failure cannot un-delete anything and stays a Warning.

The fix, and the option I rejected

Handlers are now resolved before the cascade, while the scope is alive, and the list is passed in.

I deliberately did not guard the resolution, which is the one-line alternative. These handlers are the side effects that drop a partition's backing store when a partition-owning Space root is deleted — guarding would swap a visible failure for a silently leaked database. A handler that itself resolves from the dead scope still throws inside Handle(), where the existing .Catch degrades it to exactly the Warning the contract promises.

Measurement

Same machine, same command, full 749-test assembly, DOTNET_PROCESSOR_COUNT=4:

result
main @ bf1ae3a24 746 passed, 0 failed (9 m 31 s)
this branch, before 745 passed, 1 failed (9 m 48 s)
this branch, after 746 passed, 0 failed (9 m 43 s)

The isolated runs stay worthless as evidence here and I am not citing them as such: RecursiveDeleteAuthoritativeTest alone passed 15/15 before the fix, and the 7-test ~RecursiveDelete filter passed too. Only the full assembly reproduces it, because the trigger is cross-mesh scope teardown between test classes.

Out of draft. MeshWeaver.Mesh.Contract and the test project build Release -warnaserror clean.

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.

2 participants