You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split out of #3321, which is CLOSED. Its step 3 released the dead hubs; this is the remainder,
and it is the larger half by bytes. Filing it so the finding does not live only in a closed issue's
comments.
What was measured, and where it came from
Five --type Heap dumps of memex-cloud/…-gs6j6 (26 h uptime, 6 859 MiB working set,
~210 MB/h growth, 267 gen2 collections freeing nothing), 2026-09-04 22:32–23:14Z, read-only
throughout, dump files deleted from the pod afterwards. The full walk is in Doc/Architecture/PortalHeapIsHubs (#3325).
A ClrMD RunLevel histogram over all 9 398 MessageHub instances:
At the ~390 KB a hub retains (its own Autofac ILifetimeScope + its own TypeRegistry; ~173 KB of
that is framework metadata duplicated per hub), the Started population is ≈2.7 GB of a 3.51 GB
live heap. The Dead population #3321 addressed was ≈580 MB.
For the record, the split #3321's step 3 actually turned on:
count
Deadsync/ hubs held by a stream
1 496
…under a stream that had itself been disposed
11
…under a stream nobody ever disposed
1 485
SynchronizationStream total / disposed
8 461 / 11
What step 3 fixed, and why it needed two hooks rather than one
The framing everywhere — including this issue's parent — was "SynchronizationStream.Dispose()
clears Hub". Against the dump that is 11 of 1 496.
A sync/ hub is a HOSTED hub. Its parent disposes it during the parent's own teardown — a Blazor
circuit ending, a DisposeRequest, a recycle — while the stream that created it is owned by a
workspace somewhere else and is never told. The stream therefore keeps isDisposed == false,
keeps a strong reference to the corpse, and keeps being handed out as usable. Dispose() never runs
for those 1 485, so clearing the field there reclaims nothing.
So the release happens at both ends, through one idempotent ReleaseHub():
end
where
covers
the stream is disposed
Dispose(), after Hub.Dispose()
the 11
the hub dies underneath it
syncHub.RegisterForDisposal(_ => ReleaseHub()), from the constructor
the 1 485
Completed in #3427 (core, f41f8bda) and Systemorph/MeshWeaver.Plugins#1405 (cdfaef28, merged
first — it guards 11 Blazor-view dereferences that would otherwise have NRE'd).
🚨 Expect the curve to FLATTEN, not to vanish. ≈580 MB of ≈3.5 GB was released; the ≈2.7 GB this
issue is about was not touched. Nobody should read a flattening as a fix, and nobody should read a
still-rising curve as step 3 having failed.
🚨 What is NOT established — treat everything below as hypothesis
This issue is a measured population plus an unproven cause. The population is solid; the
mechanism is not, and #3321's own history is the argument for not guessing: two separate sessions
proposed fixes for the Dead half from a type histogram alone, and both were wrong until a
referrer walk named the actual holder.
Not established:
Whether these 6 925 are garbage at all. 8 450 undisposed streams is consistent with
subscribers that really are attached — a portal with many open Blazor circuits, each holding live
layout-area streams, is supposed to look like this. It is equally consistent with streams
abandoned without Dispose(). A type histogram cannot separate those two, and they call for
opposite fixes: one is a leak, the other is the working set being genuinely larger than the
ceiling.
What holds them. Unknown. For the Dead half the answer turned out to be exactly one
referrer (SynchronizationStream<T>.Hub, 1 496 of 1 496). Nothing establishes that the Started
half has a single holder, or the same one.
Whether the ~390 KB-per-hub cost is the real story. ≈1.58 GB of the 3.51 GB live heap — 45 % —
is identical framework metadata (Autofac MiddlewareDeclaration / ExternalComponentRegistration
/ ResolvePipelineBuilder, plus TypeDefinition and its Lazy/Func fan-out) held 9 386 times.
If most of the 6 925 are legitimately live, then there is no leak here at all and the issue is
per-hub COST, which is a different fix (sharing the registration metadata across sibling hubs)
and probably a different issue.
Whether step 3 changed this number. Not re-measured. The hub-death hook releases a reference
from an undisposed stream, which could plausibly move some hubs out of the Started bucket
earlier — or change nothing here at all.
The next measurement, and what would falsify the theory
One dump, against a replica running a build that carries f41f8bda, compared on the counters #3321 already established so the comparison is like-for-like:
dumpheap -stat's Total … objects line (🚨 notdotnet-gcdump, which silently truncated
at exactly 10 000 000 objects and reported a 568 MB heap — one sixth of the truth — with a type
table skewed to whatever it reached first).
The ClrMD RunLevel histogram over MessageHub, reproducing the table above.
SynchronizationStream total / disposed.
The discriminator this issue actually turns on: the same one-pass referrer walk that named
the Dead half's holder, retargeted at the streams behind the Started hubs. 🚨 SOS dumpobj
and gcrootsegfault on this process — anything that follows a field must be ClrMD, built
and run inside the debug container.
What each outcome means:
result
reading
Started count fell materially and SynchronizationStream disposed rose
step 3's hub-death hook reaches further than claimed; re-scope this issue to what is left
Started count unchanged, referrer walk finds ONE dominant holder
a leak with a named retainer — the #3321 shape again, and actionable
Started count unchanged, referrers are diffuse and match live subscribers (circuits, open layout areas)
the current "leak" framing is FALSIFIED. This is per-hub COST, not retention; close this and open one about sharing DI/type-registry metadata across sibling hubs
Started count ROSE
step 3 moved hubs between buckets rather than releasing them — a regression to investigate before anything else
The third row is the one worth stating plainly, because it is the outcome that would make this
issue wrong: if most of the 6 925 are legitimately live, the portal's working set is simply larger
than its ceiling and the ~390 KB-per-hub DI cost is the whole story.
Doc/Architecture/PortalHeapIsHubs (docs: name the portal heap retention root — 8,419 of 9,393 hubs are sync/ stream hubs (#3321) #3325) — the five measurements, the retaining root with
file:line, the three falsified candidates, and the counter recipe that discriminates
retention-vs-fragmentation without a dump (gen2 heap size vs gen2 fragmentation in one
read; dotnet.timer.count as a cheap proxy for live hubs — 11 387 at 6.9 GB, 37 796 at 14.0 GB)
Doc/Architecture/StreamLivenessAndTheHubReference — step 3's design, including the 11-vs-1 485
split and why it needed two hooks
Split out of #3321, which is CLOSED. Its step 3 released the dead hubs; this is the remainder,
and it is the larger half by bytes. Filing it so the finding does not live only in a closed issue's
comments.
What was measured, and where it came from
Five
--type Heapdumps ofmemex-cloud/…-gs6j6(26 h uptime, 6 859 MiB working set,~210 MB/h growth, 267 gen2 collections freeing nothing), 2026-09-04 22:32–23:14Z, read-only
throughout, dump files deleted from the pod afterwards. The full walk is in
Doc/Architecture/PortalHeapIsHubs(#3325).A ClrMD
RunLevelhistogram over all 9 398MessageHubinstances:At the ~390 KB a hub retains (its own Autofac
ILifetimeScope+ its ownTypeRegistry; ~173 KB ofthat is framework metadata duplicated per hub), the
Startedpopulation is ≈2.7 GB of a 3.51 GBlive heap. The
Deadpopulation #3321 addressed was ≈580 MB.For the record, the split #3321's step 3 actually turned on:
Deadsync/hubs held by a streamSynchronizationStreamtotal / disposedWhat step 3 fixed, and why it needed two hooks rather than one
The framing everywhere — including this issue's parent — was "
SynchronizationStream.Dispose()clears
Hub". Against the dump that is 11 of 1 496.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. The stream therefore keeps
isDisposed == false,keeps a strong reference to the corpse, and keeps being handed out as usable.
Dispose()never runsfor those 1 485, so clearing the field there reclaims nothing.
So the release happens at both ends, through one idempotent
ReleaseHub():Dispose(), afterHub.Dispose()syncHub.RegisterForDisposal(_ => ReleaseHub()), from the constructorCompleted in #3427 (core,
f41f8bda) and Systemorph/MeshWeaver.Plugins#1405 (cdfaef28, mergedfirst — it guards 11 Blazor-view dereferences that would otherwise have NRE'd).
🚨 Expect the curve to FLATTEN, not to vanish. ≈580 MB of ≈3.5 GB was released; the ≈2.7 GB this
issue is about was not touched. Nobody should read a flattening as a fix, and nobody should read a
still-rising curve as step 3 having failed.
🚨 What is NOT established — treat everything below as hypothesis
This issue is a measured population plus an unproven cause. The population is solid; the
mechanism is not, and #3321's own history is the argument for not guessing: two separate sessions
proposed fixes for the
Deadhalf from a type histogram alone, and both were wrong until areferrer walk named the actual holder.
Not established:
subscribers that really are attached — a portal with many open Blazor circuits, each holding live
layout-area streams, is supposed to look like this. It is equally consistent with streams
abandoned without
Dispose(). A type histogram cannot separate those two, and they call foropposite fixes: one is a leak, the other is the working set being genuinely larger than the
ceiling.
Deadhalf the answer turned out to be exactly onereferrer (
SynchronizationStream<T>.Hub, 1 496 of 1 496). Nothing establishes that theStartedhalf has a single holder, or the same one.
is identical framework metadata (Autofac
MiddlewareDeclaration/ExternalComponentRegistration/
ResolvePipelineBuilder, plusTypeDefinitionand itsLazy/Funcfan-out) held 9 386 times.If most of the 6 925 are legitimately live, then there is no leak here at all and the issue is
per-hub COST, which is a different fix (sharing the registration metadata across sibling hubs)
and probably a different issue.
from an undisposed stream, which could plausibly move some hubs out of the
Startedbucketearlier — or change nothing here at all.
The next measurement, and what would falsify the theory
One dump, against a replica running a build that carries
f41f8bda, compared on the counters#3321 already established so the comparison is like-for-like:
dumpheap -stat'sTotal … objectsline (🚨 notdotnet-gcdump, which silently truncatedat exactly 10 000 000 objects and reported a 568 MB heap — one sixth of the truth — with a type
table skewed to whatever it reached first).
RunLevelhistogram overMessageHub, reproducing the table above.SynchronizationStream total / disposed.the
Deadhalf's holder, retargeted at the streams behind theStartedhubs. 🚨 SOSdumpobjand
gcrootsegfault on this process — anything that follows a field must be ClrMD, builtand run inside the debug container.
What each outcome means:
Startedcount fell materially andSynchronizationStream disposedroseStartedcount unchanged, referrer walk finds ONE dominant holderStartedcount unchanged, referrers are diffuse and match live subscribers (circuits, open layout areas)Startedcount ROSEThe third row is the one worth stating plainly, because it is the outcome that would make this
issue wrong: if most of the 6 925 are legitimately live, the portal's working set is simply larger
than its ceiling and the ~390 KB-per-hub DI cost is the whole story.
Related
Deadhalf, releasedDoc/Architecture/PortalHeapIsHubs(docs: name the portal heap retention root — 8,419 of 9,393 hubs are sync/ stream hubs (#3321) #3325) — the five measurements, the retaining root withfile:line, the three falsified candidates, and the counter recipe that discriminates
retention-vs-fragmentation without a dump (
gen2 heap sizevsgen2 fragmentationin oneread;
dotnet.timer.countas a cheap proxy for live hubs — 11 387 at 6.9 GB, 37 796 at 14.0 GB)Doc/Architecture/StreamLivenessAndTheHubReference— step 3's design, including the 11-vs-1 485split and why it needed two hooks