Skip to content

Unload node ALCs at the END of teardown, not before the I/O drain (#613) - #967

Merged
rbuergi merged 1 commit into
mainfrom
fix/613-node-alc-unload-teardown-ordering
Aug 8, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/613-node-alc-unload-teardown-ordering

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Fixes the teardown SIGSEGV/SIGABRT in MeshWeaver.FutuRe.Test (#613).

The bug: a teardown phase inversion

Node hubs unloaded their collectible NodeAssemblyLoadContext from a synchronous hub-disposal
callback
(MeshDataSource.cs:879, inside SubscribeToOwnDeletion), which MessageHub.DisposeImpl
runs strictly before SignalDisposalCompleted(). But the pooled layout-render leaves that
execute those compiled types are cancelled and joined only by IoPoolRegistry.DrainAll(), which
every teardown orchestrator runs after DisposalCompleted
(MeshTeardownExtensions.WaitForDisposalAndIoDrainAsync, MonolithMeshTestBase, HubTestBase).

In that window a leaf still inside IoPool.SubscribeThroughPool ran Workspace's
.Cast<T>().ToArray() over a NodeType-compiled T and read its GC thread-static base out of the
just-freed LoaderAllocator → AccessViolationException → SIGABRT (exit 134).

This violated the framework's own written contract: MeshTeardownSignal's XML doc already names
"unloading node ALCs" as one of the things that subscribes to the terminal signal.

The proof — deterministic, no crash needed

This is the durable artifact of the PR. The SIGSEGV is CI/load-dependent and did not reproduce
locally (3/3 clean runs on main), so the bug is pinned as an ordering test instead:

NodeAlcUnloadTeardownOrderingTest decorates ICompilationCacheService to stamp every
UnloadNodeContexts call, parks a real pooled leaf whose cancellation callback stamps from
inside DrainAll (IoPool.Drain → _poolCts.Cancel()), compiles a real NodeType so a genuine
collectible ALC exists, and drives the production teardown phases.

Red on unmodified main — all seven unloads land before the drain even starts:

unload:AccessAssignment | unload:PartitionAccessPolicy | unload:Admin_Partition_type |
unload:type_AlcUnloadOrderProbeStory_Activity_compile-… | unload:type_AlcUnloadOrderProbeStory_Activity_compile-state |
unload:type_AlcUnloadOrderProbeStory | unload:mesh_XFa24gY4G0CdS8xlYb6yew |
drain-all:started | drain-all:returned

Green with this change — the same seven unloads, none skipped, all after the drain returned:

drain-all:started | drain-all:returned |
unload:PartitionAccessPolicy | unload:AccessAssignment | unload:Admin_Partition_type |
unload:type_AlcUnloadOrderProbeStory_Activity_compile-state | unload:type_AlcUnloadOrderProbeStory_Activity_compile-… |
unload:type_AlcUnloadOrderProbeStory | unload:mesh_LogkIA6t9EO_sL0bjG27bQ

The assertion is "no unload may precede DrainAll returning", so it fails on the ordering itself,
not on a timing threshold.

The fix, and why the deferral is CONDITIONAL

"Defer the unload past DrainAll" is right at mesh teardown but cannot be unconditional: in prod
the mesh never tears down, so a node hub disposing on its own (node deleted, hub evicted, recycled)
must still unload immediately — otherwise the ALC leaks for the whole process lifetime, which is
precisely the late-project CI OOM / GC-stall that MeshDataSource.cs:879 was added to fix.

Gated on IMessageHub.IsDisposing of the MESH hub (hub.GetMeshHub()), captured at buildup —
the existing idiom from MonolithRoutingService.cs:73. Capture-at-construction is required because
MessageHubConfiguration.ParentHub re-resolves from a ParentServiceProvider that is routinely
already torn down by the time a disposal callback runs.

Situation Behaviour
Mesh alive (node hub disposes alone) unload immediately — unchanged from today
Mesh tearing down unload on MeshTeardownSignal, which fires only after DrainAll + the async-dispose quiesce
No MeshTeardownSignal registered unload immediately — never leak because no one will signal

A second test, NodeAssemblyContexts_AreUnloaded_Immediately_WhenTheMeshKeepsRunning, pins that
first row. It is not decorative: making the deferral unconditional makes it time out (verified by
temporarily mutating the gate), so the leak-preventing half of the branch cannot silently regress.

NodeTypeAssemblyLeakTest was pinning the buggy ordering — it waited on DisposalCompleted and
asserted zero surviving ALCs, i.e. it required the unload to have happened before DrainAll. Its
wait moves to the terminal teardown signal. What it asserts is unchanged — still zero surviving
DynamicNode_* ALCs, plus a new report.Clean check; only when it checks moved.

Verification

All on a tree merged with current main (b65c831b9), built with CI's flags
(dotnet build -c Release -warnaserror, clean for MeshWeaver.Graph,
MeshWeaver.Hosting.Monolith.Test, MeshWeaver.FutuRe.Test, MeshWeaver.Hosting.Test,
MeshWeaver.Graph.Test):

Suite Result
NodeAlcUnloadTeardownOrderingTest + NodeTypeAssemblyLeakTest 3/3 pass (red on main before the fix)
MeshWeaver.FutuRe.Test (the project #613 was filed against) 59/59 pass, exit 0
MeshWeaver.Hosting.Test (teardown-ordering guards) 129/129 pass
MeshWeaver.Graph.Test ALC-leak + compilation-cache 21/21 pass
Monolith teardown/compile/ALC subset (CompileFinishAndDispose, ClrMdDacUnloadCrash, DisposalPendingSaveFlush, CodeEditRecompile, DynamicTypePreWarmer, …) 20 pass, 1 pre-existing skip

Regression history checked and not re-broken: #635, #715, #774 (the CloseCreation / re-entrant-hub
work) — FutuRe.Test is green with a zero exit code, which is the signal those fixes are measured on.

One caveat, stated honestly: a full MeshWeaver.Hosting.Monolith.Test run was 492 passed / 1
failed — MeshNodeLanguageServiceTest.CheckSpeculative_AddsNuGetReferenceDirective_ResolvesAndCompiles
hung on resolving nuget:Humanizer 2.14.1. That is network, not this change: the test's own comment
notes it relies on the package being warm in the NuGet cache, this worktree is fresh, and the network
on this machine was badly degraded at the time (a 149 MB build-time download was running at ~50 KB/s).
Its teardown reported clean, and it fails inside the test body, nowhere near the disposal path
this PR touches. CI, with a warm cache, is the real check.

Not proven: that the original SIGABRT is gone in CI. It never reproduced locally, so the evidence
here is the ordering contract, not the absence of the crash. If #613 recurs after this merges, the
phase inversion is no longer the explanation.

#618 — related, but needs its own fix

Assessed as asked; this PR does not fix #618 and does not claim to. #618 is a corrupt/evicted
DynamicNode_* assembly poisoning Orleans' ReferencedAssemblyProvider at TestCluster.DeployAsync.
Reasoning:

Refs #613

The FutuRe.Test teardown crash (#613) was a phase inversion, not a leak or a
re-entrant construction: node hubs unloaded their collectible AssemblyLoadContext
from a SYNCHRONOUS hub-disposal callback, which MessageHub.DisposeImpl runs
strictly BEFORE DisposalCompleted. But the pooled layout-render leaves that
execute those compiled types are cancelled and joined only by
IoPoolRegistry.DrainAll(), which every teardown orchestrator runs AFTER
DisposalCompleted. In between, a leaf still inside IoPool.SubscribeThroughPool
read a NodeType-compiled type's GC statics out of the just-freed LoaderAllocator
-> AccessViolationException -> SIGABRT (exit 134).

The deferral cannot be unconditional. In prod the mesh never tears down, so a
node hub disposing on its own (node deleted, hub evicted or recycled) must still
unload immediately, or the ALC leaks for the process lifetime -- which is the
late-project CI OOM that put this hook in SubscribeToOwnDeletion to begin with.
So the callback gates on IMessageHub.IsDisposing of the MESH hub, captured at
buildup (Configuration.ParentHub re-resolves from a ParentServiceProvider that is
routinely already torn down inside a disposal callback):

  - mesh alive          -> unload NOW, exactly as before
  - mesh tearing down   -> hand the unload to MeshTeardownSignal, which fires
                           only after DrainAll + the async-dispose quiesce

That is the role MeshTeardownSignal's own XML doc already assigns it ("unloading
node ALCs ... subscribes here"). With no signal registered we keep the immediate
unload rather than leak.

NodeAlcUnloadTeardownOrderingTest is the proof, and needs no crash: it decorates
ICompilationCacheService to stamp every UnloadNodeContexts call, parks a real
pooled leaf whose cancellation callback stamps from INSIDE DrainAll, and drives
the production teardown phases. On unmodified main all seven unloads land before
drain-all:started; with this change every one lands after drain-all:returned --
same seven unloads, none skipped. A second test pins the other half of the gate:
disposing only the node hub while the mesh keeps running must reclaim
immediately (it times out if the deferral is made unconditional).

NodeTypeAssemblyLeakTest was pinning the buggy ordering by waiting on
DisposalCompleted; its wait moves to the terminal teardown signal. What it
asserts -- zero surviving DynamicNode_* ALCs -- is unchanged.

Refs #613

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 teardown ordering bug where collectible NodeType AssemblyLoadContexts could be unloaded before pooled I/O work is fully drained, causing process aborts (SIGSEGV/SIGABRT) during test/host shutdown.

Changes:

  • Defer node ALC unloading to the mesh’s terminal MeshTeardownSignal when (and only when) the mesh is tearing down; keep immediate unload when a node hub disposes while the mesh remains live.
  • Update NodeTypeAssemblyLeakTest to assert after the terminal teardown signal (and validate a clean teardown report), aligning the test contract with the corrected teardown sequencing.
  • Add a deterministic ordering test (NodeAlcUnloadTeardownOrderingTest) that pins “no unload before IoPoolRegistry.DrainAll() returns”.

Reviewed changes

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

File Description
src/MeshWeaver.Graph/MeshDataSource.cs Gates node ALC unload: immediate for live-mesh node hub disposal; deferred to MeshTeardownSignal during mesh teardown.
test/MeshWeaver.Hosting.Monolith.Test/NodeTypeAssemblyLeakTest.cs Waits for terminal teardown signal and asserts teardown cleanliness before checking for collected dynamic ALCs.
test/MeshWeaver.Hosting.Monolith.Test/NodeAlcUnloadTeardownOrderingTest.cs Adds an ordering regression test to ensure unloads occur only after DrainAll() completes (and still occur immediately in the live-mesh case).
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-clean-shutdown-for-compiled-node-types.md Documents the shutdown crash fix and the corrected teardown sequencing at a user-visible level.

💡 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 8, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

685 tests   685 ✅  4m 46s ⏱️
 10 suites    0 💤
 10 files      0 ❌

Results for commit 33c44e8.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 191 tests   1 191 ✅  5m 55s ⏱️
   11 suites      0 💤
   11 files        0 ❌

Results for commit 33c44e8.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 702 tests  ±0   1 511 ✅ ±0   4m 59s ⏱️ +10s
   11 suites ±0     191 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 33c44e8. ± Comparison against base commit bfea7bd.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 186 tests  ±0   1 179 ✅ ±0   7m 2s ⏱️ +30s
   11 suites ±0       7 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 33c44e8. ± Comparison against base commit bfea7bd.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 2s ⏱️ -16s
1 875 tests ±0  1 769 ✅ ±0  106 💤 ±0  0 ❌ ±0 
1 989 runs  +1  1 883 ✅ +1  106 💤 ±0  0 ❌ ±0 

Results for commit 33c44e8. ± Comparison against base commit bfea7bd.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 843 tests  +6   1 841 ✅ +6   7m 44s ⏱️ -25s
   11 suites ±0       2 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 33c44e8. ± Comparison against base commit bfea7bd.

This pull request removes 94 and adds 100 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ ChildOfAnExecutionHub_WalksUpToThatHub_NotPastItToTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithHandlersButNoExecutionOptIn_StillFallsBackToTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithoutTheOptIn_FallsBackToTheMeshRoot
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ PortalHubThatDeclaredExecution_TargetsItself_NotTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeTypeBakeGateStateTest ‑ AlreadyBrokenTypeThatFailsAgain_DoesNotGate
MeshWeaver.Hosting.Monolith.Test.NodeTypeBakeGateStateTest ‑ CleanRun_CompletesHealthy
MeshWeaver.Hosting.Monolith.Test.NodeTypeBakeGateStateTest ‑ CompileErrorStillGates_EvenAlongsideATimeout
MeshWeaver.Hosting.Monolith.Test.NodeTypeBakeGateStateTest ‑ FreshState_IsNotStarted
MeshWeaver.Hosting.Monolith.Test.NodeTypeBakeGateStateTest ‑ MarkComplete_DoesNotClearARegression
MeshWeaver.Hosting.Monolith.Test.NodeTypeBakeGateStateTest ‑ MarkRunning_MovesToRunning
…
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_FromMcpShapedSessionHubWithoutNodeOperationHandlers
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_OverwritesExistingWhenForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_PreservesContent
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_SkipsExistingWhenNotForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_ThrowsWhenSourceNotFound
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_ToSpaceNamespace
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_WithDescendants
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyRootLevelNode_ToNamespace
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopySingleNode_ToNewNamespace
MeshWeaver.Hosting.Monolith.Test.NodeTypeAssemblyLeakTest ‑ NodeTypeAssemblyContext_IsCollected_AfterMeshDisposal
…

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ±0     65 suites  ±0   35m 30s ⏱️ -34s
8 482 tests +2  8 176 ✅ +2  306 💤 ±0  0 ❌ ±0 
8 596 runs  +3  8 290 ✅ +3  306 💤 ±0  0 ❌ ±0 

Results for commit 33c44e8. ± Comparison against base commit bfea7bd.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants