Skip to content

6,925 Started sync/ hubs hold ~2.7 GB of a 3.5 GB live heap — population MEASURED, cause NOT established (split from #3321) #3432

Description

@rbuergi

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:

TOTAL MessageHub: 9398
    6925  sync  RunLevel=1Started      ← THIS ISSUE
    1495  sync  RunLevel=6Dead         ⎫ #3321 step 3
     974  <node-or-other>  RunLevel=1Started
       4  sync  RunLevel=0Starting

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
Dead sync/ 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:

  1. dumpheap -stat's Total … objects line (🚨 not dotnet-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).
  2. The ClrMD RunLevel histogram over MessageHub, reproducing the table above.
  3. SynchronizationStream total / disposed.
  4. 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 gcroot segfault 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.

Related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions