The Evicted-Stream Retention
The sync/ Hub Population settled where the hubs come from β one per
SynchronizationStream, two per cross-hub subscription β and left the question that decides
#3432 open: are those streams garbage, and
if so what holds them. This page answers it for one named mechanism, and the evidence is a
controlled in-process experiment rather than a heap dump.
π¨ MEASURED AND RE-SCOPED, 2026-09-10 β read this before acting on the page below. A live census
of a production replica classified all 475 parked streams: 474 are MeshNodeReference streams
holding a lease of 1 (the leases != 0 branch, which is the correct one β released with their
mesh-node cache entry), and exactly 1 is the unleased LayoutAreaReference shape Β§3 predicts is
"the volume case". The parked count was also FLAT (474/475/475) while the replica's sync/ hub
population grew by 120 in eight minutes, so this mechanism is real but is neither the bulk nor the
growth of #3432. The growth was pinned
elsewhere, by experiment β see The Read Path Minted a Hub Per Read. π¨ And
do not apply Β§9's remedy to ReclaimIfUnheld's predicate: the eviction parks a HEALTHY stream
precisely because an undeclared reader may still be attached, so disposing on "no lease was ever
declared" cuts live readers off silently.
The verdict is RETENTION, not a leak. Nothing is created and forgotten. The streams are parked deliberately, by code that says so, and then the one routine that could release them structurally declines to β for a reason that is correct in general and wrong for the majority of call sites.
1. The two lines
Workspace.EvictForPath runs on every change-feed event and parks every cached remote stream
whose owner matches the changed path:
// src/MeshWeaver.Data/Workspace.cs β EvictForPath
_evictedRemoteStreams[removed.Value] = 0;
ReclaimIfUnheld(removed.Value);
and ReclaimIfUnheld opens with:
// src/MeshWeaver.Data/Workspace.cs β ReclaimIfUnheld
if (!_remoteStreamLeases.TryGetValue(stream, out var leases) || leases != 0)
return;
Read the first clause carefully. A stream nobody ever leased has no entry in
_remoteStreamLeases at all, so TryGetValue returns false and the method returns having disposed
nothing. leases != 0 β "a holder is still declared" β and "no holder was ever declared" take the
same branch, and only the first of those is a reason to keep the stream.
The stream is now in _evictedRemoteStreams, an instance ConcurrentDictionary on the singleton
workspace, holding its client sync/ hub and (through the owner's mirror) its owner-side twin. The
next caller misses the cache, builds a fresh stream, and the next change event parks that one the
same way.
π¨ The same file already handles this case correctly two hundred lines away.
DiscardFaultedRemoteStream parks a stream, checks _remoteStreamLeases.ContainsKey explicitly,
and disposes on the spot when no holder was ever declared. Two sites, one situation, opposite
outcomes β the second delegates to a helper that cannot distinguish "held" from "never declared".
2. Why the parking is deliberate β and why that is not the whole story
EvictForPath carries an explicit instruction not to dispose unconditionally:
Do NOT unconditionally dispose the evicted stream β an undeclared reader (e.g. a
MeshDataSourcereduce callback that handed the stream on) may still be attached and needs to keep receiving updates until it drops on its own.
That is sound. An undeclared reader is invisible, so the workspace genuinely cannot know. The
_remoteStreamLeases note (the #1324 fix) states the intended remedy in as many words:
A stream NOBODY leased is never in this registry and keeps the old conservative parking β undeclared holders β¦ are unaffected. Opting a call site in is one line and is what makes its streams reclaimable.
So the design is: leasing is opt-in, and the un-opted-in remainder is knowingly retained. The finding here is that the un-opted-in remainder includes the portal's highest-volume path, and that nothing ever drains it.
3. Nothing else collects them
There is exactly one other drain, Workspace.DetachRemoteStreams, and it is reachable from exactly
one call site β MeshNodeStreamHandle.DetachUpstreams() β which always passes
new MeshNodeReference(). It matches parked streams on Equals(parked.Reference, reference).
a parked stream whose Reference is⦠|
reaped by the mesh-node cache's idle release? |
|---|---|
MeshNodeReference |
yes β if the cache holds an entry for the path and the sweep fires |
LayoutAreaReference, CollectionReference, anything else |
no. Nothing matches it. |
For the second row the only remaining disposal is Workspace.Dispose() β i.e. process exit.
And LayoutAreaReference is the volume case. LayoutExtensions.GetControlStream β the call
every rendered layout area goes through β is
hub.GetWorkspace().GetRemoteStream(address, new LayoutAreaReference(area) { Id = id }): the public
GetRemoteStream, which caches the stream and takes no lease. EvictForPath matches on the
owner alone and ignores the reference, so a change event on that owner parks the layout area's
stream too. When the Blazor circuit later ends, its subscription drops β and the stream itself is
disposed by nobody.
The other unleased openers are MeshOperations (also LayoutAreaReference), and the
MeshDataSource / SyncedQueryDataSource reduce callbacks (MeshNodeReference, so at least
reachable by the idle release).
4. The controlled experiment
test/MeshWeaver.Data.Test/EvictedUnleasedStreamRetentionTest.cs drives the identical sequence twice
β resolve a remote stream for one (owner, reference, identity) triple, fire one owner-path
change-feed event, five times β and counts the live sync/ hub population the way #3432 counts it:
HostedHubsCollection.Hubs filtered to Address.Type == SynchronizationAddress.AddressType.
The two arms differ in exactly one thing: whether a lease is taken.
DIAG unleased: opened=5 closed=0 clientSyncHubs=+5
DIAG leased: opened=5 closed=0 realLeases=5/5 clientSyncHubs=+0 (run 1)
DIAG leased: opened=5 closed=0 realLeases=5/5 clientSyncHubs=+1 (run 2)
Five change events on ONE cache key: the unleased arm ends with five live client sync/ hubs
where the cache's own documented invariant is "one stream per key, never two competing live
subscriptions". The leased arm ends at 0 or 1 β the spread is whether the final stream's
eviction had landed when the count was taken, and 1 is the denominator: the one current mirror for
that key. The unleased arm's 5 is flat growth with the number of change events; the leased arm does
not grow at all.
π¨ The leased arm is the positive control, and it earned its keep. The first version of this test
measured reclamation by counting UnsubscribeRequest at the owner, and the control arm failed β
closed=0 in both arms. The instrument was wrong, not the mechanism: disposal in this teardown
ordering does not deliver an UnsubscribeRequest the owner's rule chain sees, so that counter cannot
distinguish "not reclaimed" from "reclaimed quietly". Had the control been omitted, the unleased
arm's closed=0 would have been reported as proof of a retention it does not actually measure.
realLeases=5/5 is in the output for the same reason β AcquireRemoteStreamUnchecked returns
Disposable.Empty when the stream it resolved is not usable, and a control arm that silently leased
nothing would have looked like "leasing does not help".
5. A second, independent root on the same population
JsonSynchronizationStream registers the stream's owner-protocol subscription twice:
reduced.RegisterForDisposal(observeSubscription);
// Belt-and-suspenders: dispose the subscription when the HUB tears down too (idempotent).
hub.RegisterForDisposal(observeSubscription);
hub here is the outer, long-lived hub, not the stream's own. MessageHub.RegisterForDisposal
adds to a plain Rx CompositeDisposable, and CompositeDisposable.Add never prunes β disposing
a child does not remove it from the composite. So the outer hub accumulates one strong reference per
remote stream it has ever opened, and the closure captures reduced, the stream. For a stream that
is never disposed, that subscription is never disposed either, so the closure stays live and the
stream β with its sync/ hub still attached β is rooted a second time.
Line 575 alone already ties the subscription to the stream's own lifetime. The hub. line adds no
guarantee and one monotonic root.
The same monotonic-composite shape feeds DataExtensions per message (SubscribeRequest,
DataChangeRequest, PatchDataRequest, UpdateUnifiedReferenceRequest), where the same file
elsewhere uses the correct .TakeUntil(hub.DisposalCompleted) + self-disposing
SingleAssignmentDisposable pattern.
6. What was ruled OUT
Both were plausible and both are closed, so nobody needs to re-run them:
- No static collection anywhere in
src/holds a hub or a stream. A full audit found zerostaticfields of typeIMessageHub,MessageHub,ISynchronizationStream,IWorkspaceorIMeshService. Every hub and stream registry is already an instance field on a mesh-scoped singleton. (Unrelated static state does exist and is catalogued in No Static State β none of it can reach a hub.) - No timer roots a hub. Every periodic subscription that could β the per-hub stale-callback
scanner, the sync-stream heartbeat, the
MeshDataSourcepersistence sampler, the kernel idle disconnect, the NodeType debounce β holds aWeakReferenceand self-disposes on the tick after the target is collected. Each carries a comment naming theTimerQueue β PeriodicTimer β MessageHubchain it was written to break.
7. The denominator
_remoteStreamCache is keyed (Owner, Reference, Identity) and its own comment states the
invariant: "one stream per key, never two competing live subscriptions." So:
correct live client streams = |distinct (Owner, Reference, Identity) triples in use|
correct live sync/ hubs = 2 Γ that (client + owner-side twin)
excess = 2 Γ |_evictedRemoteStreams|
Everything in _evictedRemoteStreams is above the denominator by construction β it is out of the
cache, so no future caller can adopt it, and it is not the current stream for any key.
In the experiment the denominator is 1 and the measurement is 1 per change event. For the production figure the denominator is unknown, and that is the honest remaining gap: Β§4 establishes the mechanism and its unboundedness, not that it accounts for all 6 925.
8. π¨ The one measurement that closes #3432 β and it is not the referrer walk
#3432's plan calls for a ClrMD referrer walk, which needs the dump that (measured, 2026-09-06) freezes the replica for 106 s and restarts it. Β§7 makes a far cheaper reading decisive, because the excess is a field on one object:
On the singleton
Workspace, read_evictedRemoteStreams.Countand_remoteStreamCache.Count.
Two field reads on a single instance β no referrer walk, no full-heap traversal, no type histogram.
| reading | means |
|---|---|
_evictedRemoteStreams β« _remoteStreamCache |
this page's mechanism dominates; fix it at the call sites (Β§9) |
_evictedRemoteStreams β 0 |
the retention is elsewhere β Β§5's outer-hub composite becomes the prime suspect |
| both small vs 6 925 | the streams are legitimately live and #3432 is per-hub DI cost, its own third outcome row |
Both counts are also derivable without a dump from existing Debug logging, which already emits
one line per open ("opened remote stream for as "), one per eviction,
and one per reclaim ("disposed superseded remote stream β¦ no declared holder remains"):
opened β reclaimed is the retained count.
9. The fix, and why it is not in this change
The remedy is the one the source already names β opt the unleased call sites into the lease β tying it to the consumer's subscription:
// LayoutExtensions.GetControlStream, in shape
Observable.Create<object?>(observer =>
{
var (stream, lease) = workspace.AcquireRemoteStreamUnchecked<JsonElement, LayoutAreaReference>(
address, new LayoutAreaReference(area) { Id = id });
return new CompositeDisposable(stream.GetControlStream(area).Subscribe(observer), lease);
});
It is safe in the way that matters: ReclaimIfUnheld disposes only a stream that is also evicted,
so a stream still in the cache is never touched, and once every consumer of a given path leases, no
undeclared reader remains to be surprised.
It is nevertheless a separate change, for reasons that are about blast radius rather than effort:
GetControlStream is public and changes the lifetime of every layout area's stream; resolving
the stream inside Observable.Create moves resolution from call time to subscribe time; the
consumers most affected are in-mesh layout areas that no dotnet build and no CI job ever
type-checks; and AcquireRemoteStreamUnchecked is internal to MeshWeaver.Data with no
InternalsVisibleTo for MeshWeaver.Layout. Deleting the redundant hub.RegisterForDisposal of Β§5
is smaller but lands on the same hot path.
Neither belongs in a change whose purpose was to establish the cause.
Related
- The
sync/Hub Population β the 1:1 streamβhub identity and the idle sweep that traffic keeps re-arming - Stream Liveness and the Hub Reference β #3321 step 3, which
reclaimed the
Deadhalf - Portal Heap Is Hubs β the five dumps and the measurement hazard
- Live Mirrors and the Change Feed β why the eviction is unconditional and must stay so
- No Static State β the rule Β§6 checked against