Repository navigation
fix(Plugins#1605): the next mesh starts only after the previous one's collectible contexts REALLY unloaded - #4042
Conversation
…#11–#16 The zeroed MethodTable word sits in the garbage of a DISPOSED hub (Autofac child registry, STJ polymorphic metadata, TypeRegistry, persistence closures), reachable from no root where measured (#15, #16); at every FutuRe crash 3–7 NodeAssemblyLoadContexts were mid-unload (_state=1). Corrects three claims: alc=1 counts AssemblyLoadContext.All, which drops a context the moment Unload() is called (and CI never forces the GC); #15's 'live, referenced' block is garbage; eliminations 1 and 2 are unsound as written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… collectible contexts REALLY unloaded Unload() only requests an unload; the runtime frees a collectible context over later collections, on the finalizer thread. MeshTeardownSignal fires when teardown has requested them, so the next test's mesh was built while the previous mesh's NodeType contexts were still being torn down — the state every readable FutuRe crash dump (#12-#16) caught: 3-7 NodeAssemblyLoadContexts with _state=Unloading while the next instance ran. - CollectibleContextUnloads (Mesh.Contract, mesh-scoped): records retired contexts by SIGNAL, never by reference; AllCollected completes when every one has been collected, errors when an unload was abandoned (drain fault / Unload() threw), emits synchronously when nothing is pending. - NodeAssemblyLoadContext.RetireInto: a finalizable sentinel referenced only by the context (Unload() suppresses the context's own finalizer); it can only be finalized once the runtime has destroyed the LoaderAllocator and released the context. - CompilationCacheService retires every context it disposes into the tracker. - Test bases (Monolith, Hub): teardown ends with CollectibleUnloadDrain — drive full collections, then observe AllCollected; a fault fails the class, a retention is reported. - Controls: RetiredContextCollectedSignalTest. - Record: DebuggingNativeCrashes + /sigsegv corrected (alc=1, #15 "live referenced", eliminations 1/2/6). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it did not reproduce, and the fix Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n a node context unloads gcroot of a heap dump taken at a FutuRe teardown that could not collect its contexts: the ONLY strong root of both unloading LoaderAllocators is STJ's process-static member-accessor cache (ReflectionEmitCachingMemberAccessor -> CacheEntry -> Action<object,string> -> DynamicMethod -> DynamicResolver -> DynamicScope -> RuntimeTypeHandle -> collectible RuntimeType -> LoaderAllocator), held for ~1 s after last use and evicted by a 200 ms timer. So a context whose types were serialised was really freed when STJ's timer fired — while the NEXT mesh was starting. Same shape as the Autofac precedent (ReflectionCacheEviction): the context's Unloading handler now also calls STJ's published MetadataUpdateHandler.ClearCache hook. Control: ATypeSerializedThroughSystemTextJson_IsCollectedAtTeardown_NotWhenStjsTimerFires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical unload-tracking and teardown-sequencing issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR sequences new mesh startup until collectible contexts from the previous mesh have fully unloaded.
Changes:
- Adds sentinel-based unload tracking and fault propagation.
- Integrates unload draining into test teardown.
- Adds regression tests and updates crash-analysis guidance.
File summaries
| File | Reviewed change |
|---|---|
test/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs |
Adds collectible-unload draining to monolith teardown. |
test/MeshWeaver.Fixture/HubTestBase.cs |
Adds unload draining to hub-test teardown. |
test/MeshWeaver.Fixture/CollectibleUnloadDrain.cs |
Drives collection and reports unload outcomes. |
test/MeshWeaver.Compiler.Pipeline.Test/RetiredContextCollectedSignalTest.cs |
Tests collection, fault, and empty-tracker behavior. |
src/MeshWeaver.Mesh.Contract/Threading/IoPoolServiceCollectionExtensions.cs |
Registers the unload tracker. |
src/MeshWeaver.Mesh.Contract/Threading/CollectibleContextUnloads.cs |
Tracks collectible-context completion and faults. |
src/MeshWeaver.Documentation/Data/Architecture/DebuggingNativeCrashes.md |
Documents crash-dump findings and lifecycle conclusions. |
src/MeshWeaver.Compiler.Pipeline/CompilationCacheService.cs |
Retires contexts and reports unload failures. |
.claude/skills/sigsegv/SKILL.md |
Updates crash-triage guidance. |
Review details
Suppressed comments (5)
src/MeshWeaver.Compiler.Pipeline/CompilationCacheService.cs:895
Catchis upstream of theSubscribecallback, so an exception thrown byCompleteUnload()cannot be routed into this handler. This comment incorrectly says the arm also receivesUnload()exceptions; the separate catch below is the only path that reports those failures, and conflating the two makes future error handling changes unsafe.
{
_logger?.LogError(ex,
"Drain signal for AssemblyLoadContext {ContextName} faulted — KEEPING its load "
+ "context rather than unloading an assembly a scan may still be reading",
src/MeshWeaver.Mesh.Contract/Threading/CollectibleContextUnloads.cs:125
- These public state transitions let any consumer call
Retirement.Collected()immediately (releasingAllCollectedwhile no ALC was collected), or callRetireand never signal it (permanently wedging the wait). The tracker is intended to be driven only by its sentinel; keep the retirement transitions internal/opaque and expose a controlled adapter to the compiler instead of public mutators.
public void Collected()
{
if (Interlocked.Exchange(ref collected, 1) != 0)
return;
test/MeshWeaver.Fixture/CollectibleUnloadDrain.cs:82
- This is the safety-critical fallback that lets teardown continue with an outstanding context, but the added tests cover only collected and faulted unloads. Add a test that deliberately keeps a strong reference, drives the drain, asserts
RetainedContextscontains that context, and verifies the call returns instead of waiting forever; otherwise a regression can either reintroduce the overlap or wedge teardown without a control.
if (unloads.Faults.Count > 0)
return new CollectibleUnloadOutcome(rounds, [], unloads.Faults[0]);
if (unloads.Pending > 0)
return new CollectibleUnloadOutcome(rounds, unloads.PendingContextNames, null);
test/MeshWeaver.Fixture/HubTestBase.cs:225
HubTestBasenever disposes theServiceProvider(its baseDisposeAsync()only tears down test logging), and disposing the mesh does not close the root hub's child scope. ConsequentlyCompilationCacheService.Dispose()is not reached here, so no retired contexts are registered incollectibleUnloadsand this wait returns immediately. The nextHubTestBasemesh can still overlap the previous cache's collectible contexts; close the owning mesh scope before waiting.
await base.DisposeAsync();
Mesh = null!;
var unloadOutcome = await CollectibleUnloadDrain.WaitUntilCollectedAsync(collectibleUnloads);
test/MeshWeaver.Fixture/HubTestBase.cs:226
- By this point
base.DisposeAsync()has already calledFileOutput.Dispose().Loggerwas created with a factory returning that same helper, whoseWriteLineis a no-op after disposal, so thisInformationrecord is silently dropped. TheRetainedoutcome is therefore not reported forHubTestBaseas claimed; write it to the teardown trace (or log before disposing the test output).
var unloadOutcome = await CollectibleUnloadDrain.WaitUntilCollectedAsync(collectibleUnloads);
Logger.LogInformation("[{DisposalId}] Collectible contexts: {Outcome}", disposalId, unloadOutcome);
- Files reviewed: 10/10 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (unloads.Pending > 0) | ||
| return new CollectibleUnloadOutcome(rounds, unloads.PendingContextNames, null); |
There was a problem hiding this comment.
This is deliberate for now, and I have measured it rather than asserting it.
What a retention is. A context that is still referenced after rounds of full collections free nothing will not be freed by waiting. So it is reported, with names, as DISPOSE_ALC_RETAINED, and never waited on. Failing the class on it would make every suite red on a retention defect that is tracked separately (#4017 / #4029), before those holders are removed.
The holders are being removed one by one, by gcroot:
- The first holder was System.Text.Json's static accessor cache. It is now evicted on
Unloading, in b1c8ef0. - That took FutuRe from 84 to 45 retained teardowns over three runs. The next holder is being rooted now.
Once retention reaches zero, escalating Retained to a failure is a one-line change. I'd rather make it on evidence than now.
There was a problem hiding this comment.
Follow-up with the measurements promised above. Every row is three FutuRe runs.
| state | collected | retained |
|---|---|---|
| waiting for "really unloaded" only | 81 | 84 |
+ STJ's accessor cache evicted on Unloading (b1c8ef0) |
93 | 45 |
| + the teardown releases its own mesh before waiting (deef2f8) | 126 | 12 |
The second holder turned out to be the teardown itself. A dump taken inside the drain found the only non-weak root in MonolithMeshTestBase.DisposeAsync's own MoveNext frame: the disposed MessageHub → … → MeshContentTypeRegistry.DiscriminatorClaim → the collectible type. Now the drain yields before it collects, and the test bases clear ServiceProvider first.
What is left:
- A second inside-the-drain dump shows no non-weak root on the remaining contexts.
- Letting the drain run 20 no-progress rounds instead of 3 collected nothing more. No teardown was collected at any round from 3 to 20. So three is the measured cutoff, not a guess.
- The residual (4–9 % of the teardowns that had anything to wait for) is held outside the managed heap, most likely by a native LoaderAllocator-to-LoaderAllocator reference.
So Retained stays a report, for this reason. Failing the class would red suites on a holder that neither this change nor a managed fix can release. The case is named in DISPOSE_ALC_RETAINED on every teardown and handed to the #4017 retention track. Once that residual reaches zero, escalating to a failure is a one-line change.
…scan, shared-mesh drain; record the STJ holder - CollectibleContextUnloads.Retirement: one terminal transition (Collected | Faulted, first wins); a later collection of a KEPT context no longer erases its fault. - NodeAssemblyLoadContext.CompleteUnload: an Unload() fault is logged and reported to the waiter, never rethrown into whichever scan released the last pin. - SharedMeshProvider is IAsyncDisposable and drains the collectible unloads like the per-test path. - DebuggingNativeCrashes + /sigsegv: the gcroot of an unfinished unload (STJ's static accessor cache) and the measured counts after its eviction (93 collected / 45 retained over 3 FutuRe runs). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs Systemorph/MeshWeaver.Plugins#1605
The next mesh now starts only once the previous mesh's collectible NodeType contexts have really unloaded. The wait is a subscription to an observable.
This follows the maintainer's diagnosis (2026-09-11): "disposal being in progress while new instance starting". It also follows his design: "we need to wait (reactively, i.e. observable.Subscribe()) until all is really finished disposing".
What the dumps show
Six crash dumps were read for their managed state. The method is recorded in
Doc/Architecture/DebuggingNativeCrashes, in the entry "the MANAGED view of sightings #11–#16".Contexts mid-unload. In every FutuRe dump that could be read (#12–#16), 3 to 7
NodeAssemblyLoadContexts were_state = Unloadingat the moment of death.DISPOSE_DONE: 5 contexts were Unloading and none were alive. The crashing thread was insideHostedHubsCollection.CreateHub, thenMessageHubConfiguration.Build, then an Autofac resolve.What the zeroed word belongs to. The zeroed MethodTable word sits in objects a hub builds. None is a collectible type, and none is a native wrapper:
ServiceRegistrationInfo,List<IComponentRegistration>);TypeRegistry;Where I measured reachability (#15, #16), the object is garbage: a BFS from every GC root never reaches it. It is left over from a hub that has already been disposed.
Nothing disposing at the instant of death. No managed thread was running disposal code. The finalizer thread was idle in
WaitForFinalizerEvent. The unloads were pending in the GC.#11 is a different branch. It is GitSync, with no collectible context at all, and an interior stale reference: dotnet/runtime#131267's shape.
Why
DISPOSE_DONEwas not "done"AssemblyLoadContext.Unload()only requests an unload. The runtime holds the context through a strong handle and frees it later, on the finalizer thread, over subsequent collections.MeshTeardownSignalfires when teardown has requested those unloads, and the test bases wroteDISPOSE_DONEat that point. So xUnit built the next mesh over contexts that were still being torn down.The fix
CollectibleContextUnloads(new,MeshWeaver.Mesh.Contract, mesh-scoped singleton):AllCollectedcompletes when every context retired before the subscription has been collected.Unload()threw.NodeAssemblyLoadContext.RetireIntoattaches a finalizable sentinel that is referenced only by the context.Unload()suppresses the context's own finalizer, so the sentinel carries the signal instead.CompilationCacheServiceretires every context it disposes (UnloadContext,Dispose) into the tracker. The tracker reaches it as an optional constructor parameter.MonolithMeshTestBaseandHubTestBaseend teardown withCollectibleUnloadDrain.AllCollected.DISPOSE_ALC_RETAINED, and never waited on.It follows "teardown lets work finish, never forced": nothing is cancelled and nothing is unloaded early. Teardown simply does not claim to be finished until the unload has finished.
What held the contexts:
gcroot, and the second fixWaiting was not enough on its own. In three real FutuRe runs, 84 teardowns ended
DISPOSE_ALC_RETAINEDand 81 endedDISPOSE_UNLOADS_COLLECTED. So in about half the fixtures, contexts survived three rounds of full collections.How the holder was found.
RETAINED.gcrooton the two unloading LoaderAllocators. The contexts themselves are always strong-held by the runtime's own handle, so rooting them answers nothing.ReflectionEmitCachingMemberAccessor, the static cache in System.Text.Json.CacheEntry→ emittedAction<object,string>setter →DynamicMethod→DynamicScope→ the collectibleRuntimeType→LoaderAllocator.HNDTYPE_WEAK_INTERIOR_POINTER, which is weak.What this means. STJ shares its emitted accessors for 1 s after last use and evicts them on a 200 ms timer. An unload therefore finished whenever STJ's timer fired, usually while the next mesh was already starting. This is the same shape as the Autofac cache that
ReflectionCacheEvictionalready purges.The second fix.
JsonMemberAccessorCacheEvictionis registered onUnloading. It calls STJ's publishedMetadataUpdateHandler.ClearCache(Type[]?)hook, the one hot reload uses.ATypeSerializedThroughSystemTextJson_IsCollectedAtTeardown_NotWhenStjsTimerFires. It also asserts that the hook exists.COLLECTEDand 45 endedRETAINED.RETAINEDteardown with the eviction active, shows no strong root on any of the three contexts. Every root is a type-10 handle (HNDTYPE_WEAK_INTERIOR_POINTER, weak) pointing at the context's own statics, for example the compiler's<4>__ProfitByLoBmethod-group cache. So nothing held them afterDisposeAsyncreturned.RETAINED, and nothing was collected between round 4 and round 20. Something live held them during the drain.MonolithMeshTestBase.<DisposeAsync>d__87.MoveNext(), the very frame running the drain. The chain runs from the disposedMessageHub→Workspace→MeshDataSource→MeshContentTypeRegistry→DiscriminatorClaim→ the collectibleRuntimeType. The fixture'sServiceProviderfield is a second route to the same graph.ServiceProviderfirst.The progression, measured the same way each time (three FutuRe runs each):
The last 12 are reported and named, never waited on. Two further measurements settle them:
The residual, 4–9 % of the teardowns that had anything to wait for, is held outside the managed heap, most likely by a native LoaderAllocator-to-LoaderAllocator reference or a runtime-internal one. I'm handing it to #4017.
For the #4017 retention work: on a long-lived portal,
MeshContentTypeRegistry's discriminator claims hold superseded NodeType generations'RuntimeTypes for as long as the mesh lives.Controls
RetiredContextCollectedSignalTest, clean Release-warnaserrorbuild: 5 passed, 0 failed. The fullMeshWeaver.Compiler.Pipeline.Testsuite was run before the STJ control was added: 866 passed, 0 failed.MeshWeaver.Documentation.Test: 397 passed.ATypeSerializedThroughSystemTextJson_IsCollectedAtTeardown_NotWhenStjsTimerFiresUnloading += JsonMemberAccessorCacheEviction.EvictForregistrationNextStart_RunsOnlyAfterTheRetiredContextIsCollected_AndTheContextIsCollectedUnload()time (_retirement?.Collected()right afterUnload())AFaultedUnload_ReleasesTheWaiterWithTheFault_NeverHangsItUnloadinghandler that throws releases the waiter with that fault within budgetFaulted(...)armsWithNothingRetired_TheNextStartIsNotDelayed·ACollectedRetirement_LeavesNothingBehind_SoTheNextWaitIsImmediateWhat this does NOT prove, stated plainly
10.0.12) it survived 11,289. The realMeshWeaver.FutuRe.Testsurvived 20 consecutive runs, 1,180 fixtures, on macOS arm64 without the fix. The CI rate is about 1 % of x64 runs, so none of these could be expected to crash. The dumps establish the overlap state at 5 of 5 FutuRe crashes. They do not name the thread that wrote the zero.Coordination with #4017 / #4029
The mirror problem is contexts retained too long. This PR makes retention visible at every teardown (
DISPOSE_ALC_RETAINEDnames the contexts) without failing on it, so it neither reds suites on the retention #4017 is fixing nor hides it.KernelScriptReferences.csis untouched.CompilationCacheService.cschanges are surgical: the retire calls, the sentinel, and the two fault arms. Unload timing is unchanged. Nothing unloads earlier or later; teardown only waits for the unload to finish.What's New: skipped — test-host teardown sequencing and an internal signal, with no user-visible effect.
Pairs-with: none — no public type or member is removed.
CollectibleContextUnloadsis a new public type, and theCompilationCacheServiceconstructor parameter is on aninternalclass.Implementers: none — no member is added to any public interface.
Mirror-sync: none — no i18n catalog key is added or changed.
🤖 Generated with Claude Code