Repository navigation
fix(#3321): step 3 — a disposed stream releases its hub - #3427
Conversation
A SynchronizationStream held its sync sub-hub for the process's whole life. A production heap dump named it as the sole retainer of 1 496 MessageHubs at RunLevel=Dead — ~390 KB apiece, ~580 MB of a 3.5 GB live heap, and the reason 267 gen2 collections freed nothing. The release happens at BOTH ends, through one idempotent ReleaseHub(): Dispose() (11 of the 1 496), and a hook on the sub-hub's own disposal for the far more common case where the PARENT tears it down while nobody ever disposes the stream (the other 1 485). The constructor registers that hook AFTER streamDisposables is hooked onto the hub, so the stream's own registrants still see a bound Hub. Nulling the field was tried once before and cost a production NRE in LayoutAreaHost's constructor that reached the subscriber as a TERMINAL DeliveryFailure. What makes it safe now is steps 1 (#3380) and 2 (#3386) plus the 25 sites resolved here: - 12 patch reducers -> RequireHub(), whose TRANSIENT HubDisposingException their single gateway (ToChangeItem) absorbs into the `null` its own signature already models - 8 owner-side write paths -> HubIfHeld(), then the no-op each already models (skip the emission / return null from the Update transform) - 2 handler registrations -> don't register - Initialized, ApplyChanges, EvictClientSubscriptions -> one each PRESENCE is not LIVENESS: guarding DataSource.Initialized with TryGetHub() (IsUsable) excluded a FAULTED stream from its WhenAll, so a hub whose data source had thrown reported successful initialization — DataContextFaultedInitBeforeStreamHubBoundTest caught it. Owner-side guards therefore use HubIfHeld()/RequireHub(), which match the `isDisposed || Hub is null` refusal their own gate already applies. Every read inside SynchronizationStream now resolves the field once into a local rather than re-reading it after its own guard. Falsified by disabling only the two ReleaseHub() call sites: 4 failed / 1 passed in both new-and-existing suites, 5 passed each with the release in place; the live controls passed both ways. Doc/Architecture/StreamLivenessAndTheHubReference carries the design; What's New carries the user-facing entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pull request was converted to draft
🚨 HELD AS DRAFT — the dependent half has to land firstA read-only sweep of every satellite checkout on this machine for What the sweep found
The four content satellites are clean structurally, not just by token match: The 11 that would NREAll in
🚨 The That is precisely the incident this issue exists to avoid, relocated one repo over — an NRE in a Also flagged, not an NRE but a silent test regression: OrderPlugins first, core second. Un-drafting this once the Plugins counterpart is merged.
|
There was a problem hiding this comment.
🟢 Approval recommended
The changes are cohesive, well-justified, and include targeted tests/docs; the only noted issue is a minor doc-comment mismatch in a new test helper.
Pull request overview
This PR completes step 3/3 of #3321 by ensuring SynchronizationStream releases its hosted sync-hub reference both when the stream is disposed and when the hosted hub is torn down underneath an undisposed stream, allowing the dead hub’s resolved graph to be garbage collected.
Changes:
- Add an idempotent “release hub” mechanism in
SynchronizationStream(dispose-path + hub-disposal hook) and adjust internal reads to avoid check-then-act races by resolving the hub once into a local. - Introduce/propagate “presence vs liveness” accessors (
HubIfHeld()/RequireHub()) and migrate owner-side.Hubdereferences to use them or to no-op/skip work safely. - Add/adjust tests and documentation/What’s New to lock in the new contract and verify the hub becomes unreachable.
File summaries
| File | Description |
|---|---|
| test/MeshWeaver.Layout.Test/TornDownStreamCallSitesTest.cs | Inverts the assertion to pin the new “disposed stream releases hub” contract. |
| test/MeshWeaver.Data.Test/StreamReleasesItsHubTest.cs | New test suite asserting hub release/collectability and presence-vs-liveness behavior. |
| test/MeshWeaver.Data.Test/StreamLivenessIsConsumerReachableTest.cs | Updates the step-3 contract assertion for Hub after disposal. |
| src/MeshWeaver.Layout/Composition/LayoutAreaHost.cs | Uses HubIfHeld() inside update lambdas and returns null (no-op) when hub was released. |
| src/MeshWeaver.Graph/MeshDataSource.cs | Switches patch reducer to a single RequireHub() read to avoid NRE and enable transient refusal. |
| src/MeshWeaver.Data/WorkspaceStreams.cs | Guards subscription callback with HubIfHeld() and uses the resolved hub once for version stamping. |
| src/MeshWeaver.Data/WorkspaceExtensions.cs | Uses RequireHub().Version to stamp changes where an absent value isn’t modeled. |
| src/MeshWeaver.Data/Workspace.cs | Avoids skipping disposal by using a pure null check before disposing a stream hub. |
| src/MeshWeaver.Data/VirtualDataSource.cs | Adds HubIfHeld() guard inside update transform and no-ops when the hub reference is released. |
| src/MeshWeaver.Data/SynchronizationStreamLiveness.cs | Adds the presence accessors (HubIfHeld, RequireHub) alongside consumer liveness (TryGetHub). |
| src/MeshWeaver.Data/StandardReducers.cs | Threads a single resolved hub through reducers and helper methods to prevent concurrent-release NREs. |
| src/MeshWeaver.Data/Serialization/SynchronizationStream.cs | Implements ReleaseHub(), hooks it into hub disposal + Dispose(), and resolves hub once inside guarded paths. |
| src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs | Resolves reducedHub once; skips owner-protocol registrations if the hub was already released; maps HubDisposingException to modelled null. |
| src/MeshWeaver.Data/Persistence/PartitionedHubDataSource.cs | Mirrors the subscription-callback guard pattern with HubIfHeld() and reuses resolved hub for stamping. |
| src/MeshWeaver.Data/ISynchronizationStream.cs | Updates the contract documentation for Hub to reflect the step-3 reality and the safe access patterns. |
| src/MeshWeaver.Data/GenericUnpartitionedDataSource.cs | Updates Initialized to await only hubs still held (presence), avoiding the prior liveness/policy bug. |
| src/MeshWeaver.Documentation/Data/Architecture/StreamLivenessAndTheHubReference.md | Documents what step 3 changed, the two-ended release, and the presence-vs-liveness distinction. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-06-a-closed-page-gives-its-memory-back.md | Adds a user-facing What’s New entry describing the reclaimed memory behavior. |
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// Forces the collection the reachability assertion reads. Two passes with a finalizer drain | ||
| /// between them: the first may only queue the object for finalization, and a hub holds | ||
| /// finalizable state. |
The sweep that found it: every satellite checkout, on the RECEIVER type, including in-mesh C# inside .json node strings. 11 production dereferences, all in MeshWeaver.Plugins's Blazor view layer; the four content satellites clean structurally (ISynchronizationStream in zero files in four of them). Records the shape-7 point explicitly — a public member whose BEHAVIOUR changes behind an unchanged signature is invisible to the Cross-repo pair gate by construction — and the landing order it forces: the dependent first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ures Two CI reds on the first push, both in the new test file. 1. TestTimeoutLiteralRatchetGuard: the two `.Timeout(30.Seconds())` literals pushed the count to 387 over a 386 baseline. Converted to TestTimeouts.Convergence rather than re-seeding, which the guard names as the defect rather than the fix. It passed locally because main has moved since this branch point — a local ratchet pass attests to the MERGE BASE, never to the merge result. 2. ADisposedStream_ReleasesItsHub_SoTheHubBecomesCollectable was FLAKY, and its green was luck. Hub.Dispose() only STARTS the teardown — it posts a ShutdownRequest and returns — so on return the hub is still rooted by its own in-flight shutdown. Collecting immediately passed in a full-suite run, where surrounding tests happened to give the teardown time, and failed the moment the test ran alone. Measured, not inferred: 1 failed in isolation against 485 passed in the suite, on the same binary. It now awaits the hub's own DisposalCompleted before collecting — a real condition, not a sleep — so the only thing that can still hold the hub is a reference somebody kept, which is exactly what the assertion is about. The helper returns that observable rather than the hub: it wraps a subject FIELD, and a subject does not reference its owner, so holding it cannot defeat the test. Stable 4/4 isolated plus the whole file, and it still discriminates — 4 failed / 2 passed with the release disabled, 6 passed with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hold released — the dependent half mergedSystemorph/MeshWeaver.Plugins#1405 is merged into that repo's Its CI also surfaced one thing worth recording here, because it is the shape that repeats: every red on that PR cascaded from a single stale Marking ready. Auto-arm will enqueue this on its own. |
PortalHeapIsHubs says of its five dumps that "the dump file was deleted from the pod afterwards; no cluster object was mutated". True, and it reads as "no impact". Measured 2026-09-06 on memex-cloud/...-gqqg7 (8 h, 6 609 MiB, ci.7693): `dotnet-dump collect --type Heap` froze the process for 106 s, the kubelet's liveness budget is 90 s (/alive, 15 s x 6), and the container was restarted mid-dump. The freeze is not write-bound, so it does not shrink with a faster disk: dd oflag=direct on the same node measured 169 MB/s, putting a 5.5 GB dump file at ~33 s of the 106; the rest is createdump walking ~50 M objects with every thread suspended. So the freeze scales with the very thing that makes this heap interesting, and that gives the constraint governing every remaining measurement on this page: the population only exists on a replica fat enough that dumping it restarts the replica. A freshly restarted portal was back to 2 977 MiB in five minutes, so ~3 GiB is the floor, not the accumulation. Also records why the referrer walk on the Started population is blocked today: #3427 merged as f41f8bd and the earliest image carrying it is ci.7906, while both memex and memex-cloud still run rc9.ci.7693 after the 09-06 rollback — so a dump now measures the pre-step-3 population and cannot answer "did the Started count move". And writes down the walk recipe verified against Microsoft.Diagnostics.Runtime 4.0.732401 (pass the DAC explicitly; compile locally first, because a typo now costs a replica; kubectl cp the analyzer; the sidecar dies with the target, so print as you go), plus the fields the open questions need: Address.Host, so a Started sub-hub under a Dead parent separates from one under a live parent, and the ReplaySubject observer count that answers whether anyone is listening. No cause is claimed. #3432 stays open: population measured, cause not established. Refs #3432 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PortalHeapIsHubs says of its five dumps that "the dump file was deleted from the pod afterwards; no cluster object was mutated". True, and it reads as "no impact". Measured 2026-09-06 on memex-cloud/...-gqqg7 (8 h, 6 609 MiB, ci.7693): `dotnet-dump collect --type Heap` froze the process for 106 s, the kubelet's liveness budget is 90 s (/alive, 15 s x 6), and the container was restarted mid-dump. The freeze is not write-bound, so it does not shrink with a faster disk: dd oflag=direct on the same node measured 169 MB/s, putting a 5.5 GB dump file at ~33 s of the 106; the rest is createdump walking ~50 M objects with every thread suspended. So the freeze scales with the very thing that makes this heap interesting, and that gives the constraint governing every remaining measurement on this page: the population only exists on a replica fat enough that dumping it restarts the replica. A freshly restarted portal was back to 2 977 MiB in five minutes, so ~3 GiB is the floor, not the accumulation. Also records why the referrer walk on the Started population is blocked today: #3427 merged as f41f8bd and the earliest image carrying it is ci.7906, while both memex and memex-cloud still run rc9.ci.7693 after the 09-06 rollback — so a dump now measures the pre-step-3 population and cannot answer "did the Started count move". And writes down the walk recipe verified against Microsoft.Diagnostics.Runtime 4.0.732401 (pass the DAC explicitly; compile locally first, because a typo now costs a replica; kubectl cp the analyzer; the sidecar dies with the target, so print as you go), plus the fields the open questions need: Address.Host, so a Started sub-hub under a Dead parent separates from one under a live parent, and the ReplaySubject observer count that answers whether anyone is listening. No cause is claimed. #3432 stays open: population measured, cause not established. Refs #3432 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 3 of 3 for #3321 — the step that actually reclaims the leak. Steps 1 (#3380, make liveness
askable) and 2 (#3386, migrate the dereference sites) landed first, in that order, because doing
this one before them is the production NRE this issue is about.
Closes #3321.🚨
Dispose()clearing the field would have reclaimed 11 of 1 496The step is usually described as "
SynchronizationStream.Dispose()clearsHub". Read against theheap dump on the issue, that description is a sixth of the fix:
A
sync/hub is a HOSTED hub: its parent disposes it during the parent's own teardown — a Blazorcircuit ending, a
DisposeRequest, a recycle — while the stream that created it is owned by aworkspace somewhere else and is never told.
Dispose()never runs for those 1 485.So the release happens at both ends, through one private
ReleaseHub()(idempotent, CAS'd,callable from any thread; it disposes nothing — it only drops a reference):
Dispose(), afterHub.Dispose()syncHub.RegisterForDisposal(_ => ReleaseHub()), from the constructorThe constructor's hook is registered after
RegisterForDisposal(resyncSubscription), and thatordering is load-bearing: that call is what hooks
streamDisposablesonto the hub, and a hub'scomposite disposes in registration order — so the stream's own registrants run first and still see a
bound
Hub, and the release runs after them. A hub already past accepting registrants disposes thehook inline, so the constructor re-checks and refuses with
HubDisposingExceptionexactly as itssyncHub is nullbranch does.No new state enters the liveness vocabulary:
StreamLiveness.IsUsablehas always treatedcurrent.Hub is not { } hubas dead, so a released stream reports itself unusable through thepredicate every cache already consults.
The site classification — 29 found, 4 untouched, 25 resolved
A fresh sweep of
src/for every.Hubwhose receiver resolves to anISynchronizationStreamfound 29 occurrences on 27 lines. Four stay untouched and must: two ARE the predicate
(
StreamLiveness.IsUsable's chain walk,TryGetHub's own read), one is alreadystream?.Hub?.…,one is
Workspace's cache probe, which is itself a liveness check.The other 25 are this step's work — the 20 step 2 named, plus five it did not:
LayoutAreaHostreads step 2 called "hub-turn confined". That rested on a hubexecuting a turn is by construction alive — a statement about the HUB. Step 3 makes the FIELD
independently clearable, so a queued
UpdateStreamRequestcan now run after the release. Thepremise expired with the change that needed it.
Workspace.EvictClientSubscriptions— step 2 placed it in "already answerable" for the rightreason (an
IsUsableguard there would SKIP a disposal), but a null check is still needed.WorkspaceExtensions.ApplyChanges— step 2's list missed it (stream!.Hub.Version, behind anull-forgiving
!).StandardReducers(10),MeshDataSource.PatchMeshNode(2)RequireHub(), absorbed by their single gatewaySubscribecallback —WorkspaceStreams(2),PartitionedHubDataSource(2)HubIfHeld()once at the top; skip the emissionstream.Update(…)lambda —LayoutAreaHost(3),VirtualDataSource(1)HubIfHeld(), thenreturn null—Update's documented no-opJsonSynchronizationStream'sreduced.Hub.RegisterHubIfHeld()once; don't registerGenericUnpartitionedDataSource.InitializedWorkspaceExtensions.ApplyChangesRequireHub()— same shape as a reducerWorkspace.EvictClientSubscriptionsHub is { }— deliberately not a liveness checkThe reducers were the design problem, and their single gateway is the answer. A reducer's
signature is
… → ChangeItem<T>: it MUST return a change, so it has no absent value to hand back.But every patch reducer in the codebase is invoked from exactly ONE place —
JsonSynchronizationStream.ToChangeItem, the only call site ofReduceManager.PatchFunction— andnullis whatToChangeItemalready returns for a stream with noPatchFunctionregistered. Sothe reducers read the hub once through
RequireHub(), which refuses with the TRANSIENTHubDisposingException(anObjectDisposedException→ErrorType.ShuttingDown, the "ask again"answer, never the terminal
DeliveryFailurethe predecessor's raw NRE produced), andToChangeItemcatches only that type and answers
null— which both callers already handle(
BuildChangeItemfalls through to its type-registry path;UpdateStreamfalls back todeserializing through
Host.JsonSerializerOptions). Not a swallow, and not acatch (NullReferenceException): every other exception a reducer throws still propagates.🚨 PRESENCE is not LIVENESS — and the wrong one is a behaviour change, measured
The first draft wrote every owner-side guard as
TryGetHub(), on the reasoning that step 2's rulewas "migrate onto
TryGetHub()". That is wrong, and one existing test proved it before the changeleft the branch.
DataSource.InitializedisTask.WhenAll(streams.Select(s => s.Hub.Started)). Guarded withTryGetHub(), a stream whose initial load faulted is excluded — because it is notIsUsable,which is exactly what
IsFaultedis for. So the WhenAll no longer held the task carrying the fault,Initializedcompleted successfully, and a hub whose data source had thrown went on answeringrequests as though nothing had happened.
DataContextFaultedInitBeforeStreamHubBoundTestwent redon precisely that — the #2625 wedge, re-introduced by a guard that looked like a tightening of
safety.
So there are two accessors for two questions:
TryGetHub()(IsUsable)HubIfHeld()/RequireHub()The rule for an owner-side guard is: match the guard the site's own gate already applies. Each of
them ends in
stream.OnNextorstream.Update, whose refusal isisDisposed || Hub is null, so apresence check refuses in exactly the cases the write would have been dropped anyway and never one
more.
TryGetHub()there is a silent policy change smuggled in under a null-safety fix — the shapestep 2 already declined at
Workspace's cache probe.RequireHub()is written in terms ofHubIfHeld(), so there is one definition of presence and one of liveness, never a fourthhand-copied predicate (#1455).
And every read inside the stream now resolves the field once
Step 2 excluded
SynchronizationStream's own bareHubreads as "already guarded". They were —against a field that could not change. Once the field can be nulled, a guard followed by a re-read
is check-then-act across threads, so every read below a guard now uses the local the guard resolved:
OwnerVersion(hub),BuildChangeItem(hub, …),BuildFullChangeItem(hub, …), and the locals inRegisterForDisposal/DeliverMessage/OnNext/OnError.One log level moved for a reason, not for debugging:
OnError's deliberately LOUD "the stream has noHub bound"
LogError(#2625) gained a second, benign way to be reached — the hub was releasedbecause it died — and that arm logs Debug instead. The original arm, for a stream whose hub was never
bound, is unchanged.
Tests, and the measurement that says they can fail
test/MeshWeaver.Data.Test/StreamReleasesItsHubTest.cs, 6 cases. The flagship assertion is aWeakReference, notHub is null: "the field reads null" is a statement about one field;"the hub is unreachable" is the statement this issue is about, and it is the only one that goes
red on the defect for the right reason. The hub is created and dropped inside a non-inlined helper
so no live local on the test's frame can root it. It passes.
Falsified by disabling only the two
ReleaseHub()call sites — everything else,RequireHubandevery guard included, left in place, so the experiment isolates exactly what step 3 does — and
re-running both suites unchanged:
StreamReleasesItsHubTest(MeshWeaver.Data.Test)TornDownStreamCallSitesTest(MeshWeaver.Layout.Test)(Measured before the sixth case was added.) The live control passed in both runs of both suites — a
release that fired unconditionally, or a guard that answered "dead" always, would satisfy every
negative at once, and the controls are what rule that out. The four pre-change failures name four
different facts:
Hubnot null afterDispose(),Hubnot null after the hub died underneath,RequireHubnot throwing, andToChangeItemstill deriving a change off the corpse.A full
src/revert does not compile the tests, becauseRequireHubis itself part of step 3 —which is why the falsification reverts the release rather than the whole change.
Two existing assertions INVERT here, deliberately kept rather than deleted, because they are the
lines that pin which contract is in force:
TornDownStreamCallSitesTestandStreamLivenessIsConsumerReachableTestboth assertedstream.Hub.Should().NotBeNull()after disposal — "the non-nullable contract still holds, which isprecisely why a consumer needed a separate way to ask". Step 3 is what makes that stop holding.
Verification
Local,
-c Release -warnaserror, one project per invocation:MeshWeaver.Data.TestMeshWeaver.Layout.TestMeshWeaver.Graph.TestMeshWeaver.Documentation.TestWork products conserved
Doc/Architecture/StreamLivenessAndTheHubReferencegains "What step 3 did" — the 11-vs-1 485 split,the two-ended release with the registration-order constraint, the 29/4/25 site table with the reason
per group, the measured PRESENCE-vs-LIVENESS finding, and the falsification numbers. Plus a What's
New entry (
Category: Fix).i18n
No new user-visible strings.
HubDisposingException's message is existing framework text.Cross-repo
Pairs-with: Systemorph/MeshWeaver.Plugins#1405🚨 Not because this removes public surface — it removes none (zero public types, zero public
members; the gate does not fire). This is shape 7:
ISynchronizationStream.Hubis a publicmember of a public interface, and step 3 changes what it ANSWERS behind an unchanged signature,
which no surface detector can see by construction.
A by-hand sweep of every satellite checkout, on the RECEIVER type and including in-mesh
.jsonnodes, found the blast radius is one repo and one cluster: 11 production dereferences in
MeshWeaver.Plugins's Blazor view layer, all on the inheritedISynchronizationStream<JsonElement>? Stream. The!inStream!.Hub.Somethingis onStream,not on
.Hub, so the compiler stays silent; one of them sits inside a barecatch { }, so it wouldhave been silent at runtime too. The four content satellites are clean structurally
(
ISynchronizationStreamappears in zero files in four of them).The dependent landed FIRST, and this PR was held as a draft until it did — its guards are no-ops
on a live stream, so they are safe against the old core, while core's release is not safe against an
unguarded dependent. The full table is in the doc page under
"The dependent half — and why no gate could have told you".