Repository navigation
Portal managed heap leaks ~215 MB/h through 267 gen2 collections, with ZERO sync/bake events — the cause #2194 is not tracking #3321
Description
Filed at #2194's own instruction — "if no wave occurred, it is (2) and needs its own RCA". #2194 is titled for sync/bake recompile waves; this is the cause that is actually running, and it has zero sync/bake events.
The measurement
memex-cloud, replica …-5nqbz, born 2026-09-03T17:51Z, read from its own runtime watchdog line:
| time (UTC) | managed heap | gen2 collections |
|---|---|---|
| 06:55 | 6 856 MB | 342 |
| 09:47 | 7 981 MB | 410 |
| 12:48 | 8 660 MB | 454 |
| 15:41 | 8 744 MB | 537 |
| 18:36 | 9 371 MB | 575 |
| 21:51 | 10 065 MB | 609 |
+3.2 GB in 14.9 h (~215 MB/h) across 267 full collections, and the post-collection floor never returns to any earlier level. A cache the GC has simply not trimmed does not survive 267 gen2 collections. This is retention.
It is not the #2194 mechanism
Counted over that replica's entire 28-hour life (186 137 lines), with info:-level lines confirmed shipping (2 791 of them) so a zero is a real zero:
Stale-build offer: 0
Stale-build convergence: 0
EnrichWithNodeType: self-heal recompile # 0
BatchBake: 0
KEEPING its load context 0
NOT unloading collectible context 0
Grain … deactivating: reason= 0
No publication, no bake, no recompile, no supersession. Modules:AutoRecycleOnStaleBuild is armed on both namespaces and has never fired because nothing has ever been stale.
It does not track load — it tracks uptime
| replica | age | working set | log lines / 30 min |
|---|---|---|---|
fxbhd |
28 h | 11 443 Mi | 512 — and zero of every hot family |
5nqbz |
28 h | 14 096 Mi | 1 914 |
zn7nf |
15.7 h | 4 134 Mi | 2 476 — the most of any replica |
lgwrk |
8.3 h | 1 384 Mi | 16 |
The near-idle replica holds nearly three times the memory of the busiest one. So "the load is simply heavy" does not explain it, and neither does the unanchored [CrossSchema] fan-out traffic (Doc/Architecture/CrossSchemaFanOutElimination, #2640) — that is a CPU multiplier on this curve, not its source.
The two candidate retainers, and why the first is not sufficient
A — no hub or grain is ever deactivated. deactivating: reason= is 0 across five sampled replicas and 514 687 log lines. Every hub activated since boot is still activated, so every ALC lease taken at bind time (MeshDataSource.cs:1017-1035 — a lifetime lease; NodeAssemblyLoadContext.Dispose defers Unload() while any lease is held) is still held. Nothing superseded is needed for that to accumulate.
B — lazy first-compile, because bundle coverage is failing. From the boot lines:
DynamicTypePreWarmer: ADOPT-ONLY boot complete … adopted=9 uncovered=368 of 377 dynamic NodeType(s); nothing was compiled at boot
368 dynamic NodeType(s) were NOT covered by the prebuilt bundles and will each pay a Roslyn compile on FIRST ACCESS
| replica | booted | adopted / total | compile activities since boot |
|---|---|---|---|
fxbhd |
09-03 17:49 | 8 / 377 | 35 |
5nqbz |
09-03 17:51 | 9 / 377 | 167 |
zn7nf |
09-04 06:06 | 86 / 399 | 1 |
q8v6s |
09-04 07:10 | 96 / 399 | 6 254 |
lgwrk |
09-04 13:34 | 174 / 407 | 0 |
Coverage improves with later boots as bakes land — 8/377 → 86/399 → 174/407 across 09-03 17:49 → 09-04 13:34 — so that lane is moving. But it is not enabled: grep -rn RequirePrebuilt deploy/ returns nothing that sets it, and neither live ConfigMap carries the key, so Modules:RequirePrebuilt is in effect on no mesh. (#2193, the design of record, is closed; the remaining named blocker is Systemorph/MeshWeaver.Plugins#1262.) But B alone does not explain the curve: zn7nf logged 1 compile activity and still reached 4.1 GB, and fxbhd logged 35 and reached 11.4 GB. B is a multiplier (see q8v6s: most compiles, highest CPU, fastest growth); A is the shape that is present on every replica without exception.
That is as far as log evidence can take it, and I want to be explicit that it stops there: A and B are candidates, not a proven retainer. Neither is established as the root cause by this data.
Why it becomes an outage rather than a restart
limits.memory is 16Gi and nothing sets DOTNET_GCHeapHardLimit[Percent] (audited by key name across inline env:, memex-portal-config and every envFrom Secret on both namespaces — no value read), so .NET's container default gives the managed heap a 12 GiB ceiling. The runtime defends that with back-to-back blocking gen2 rather than letting the kubelet restart the container: 0 OOMKills across 7 replicas over 28 h, against GC pause share 0.66 sustained for 2 h 15 on 09-03 with /alive answering Healthy throughout.
Full derivation: Doc/Architecture/WhyAGcBoundPodStaysInRotation (#3319).
What would actually name the retainer
Log evidence cannot distinguish A from B or find a third. A gcdump / heap dump from a fat replica would, in one read — retained-bytes by type immediately says whether the roots are hub state, ALCs, cached streams, or something unlisted.
🚨 That needs an ephemeral debug container on a production pod (kubectl debug --profile=sysadmin), which is a production mutation and a maintainer decision. Two caveats if it is authorised: attach the probe to a healthy, non-draining pod — Doc/Architecture/ImagePairSkew records that kubectl debug silently creates nothing on a pod already carrying a deletionTimestamp, printing its "Targeting container" line while ephemeralContainerStatuses stays empty — and pick a fat replica that is not the one hosting a hub someone is using, since the probe competes for CPU with a process already spending two thirds of its time in GC.
(There is no Doc/Architecture/ProfileALivePortalPod page in this repo at ff8c5f1cc; the profiling recipe ImagePairSkew refers to lives outside it. Worth writing down here if this route is taken.) Nothing in this issue has been mutated. The cheap alternative — read dotnet_gc_* off Prometheus — is not available: the portal emits those metrics (AddRuntimeInstrumentation() is on) but the OTel collector's metrics pipeline exports only file/metrics, a rotating JSON file on the node, so nothing queries them. Routing that pipeline to Prometheus is the durable version of the same read and needs no pod mutation at all.
Suggested order
- Route the OTel metrics pipeline to Prometheus (
deploy/aks/manifests/observability/otel-collector-config.yaml) — it costs no pod restart and turns "read the heap" into a query instead of a dump. - Take one heap dump from a fat replica, or read
dotnet_gc_*once (1) is live, and name the retainer. - Fix that. Candidate A (hubs never deactivating) would be the durable fix and is owned by nothing today; candidate B shrinks on its own as bake coverage improves, and disappears when
Modules:RequirePrebuiltis actually enabled somewhere.
Not this
Raising limits.memory, lowering the GC hard limit, recycling pods on a timer, or deleting pods daily are all band-aids — the pod-deletion stopgap has been applied by hand at least four times and is recorded as a stopgap on #2194. The question is why 267 full collections free nothing, not how to get more headroom.
Refs #2194 · #2640 · #3319 · Systemorph/MeshWeaver.Plugins#1262
Activity
docs(nodetypes): correct 'convergence is the missing piece' with the …
docs: name the portal heap retention root — 8,419 of 9,393 hubs are s…
rbuergi commentedon Sep 4, 2026
The retainer is named: 8,419 of 9,393 hubs are
sync/{clientId}This issue said the log evidence stopped where it stopped — "A and B are candidates, not a proven retainer" — and that a heap dump would name the retainer in one read. Took the dump. It is neither candidate.
Two
--type Heapdumps frommemex-cloud/…-gs6j6(26 h, 6 859 MiB working set) through an ephemeral--profile=sysadmincontainer, 2026-09-04 22:32Z and 22:55Z. Read-only throughout; both dump files deleted from the pod afterwards and their absence verified; the pod wasRunning/RESTARTS 0before and after.What the heap is
🚨 SOS
dumpobjandgcrootsegfault on this process's dumps, so the addresses came out of a 40-line ClrMD walk (MessageHub→Configuration→Address→Segments[0]), built and run inside the same debug container:8,419 of 9,393 — 89.6 % — are
sync/{clientId}. The ~974 remaining node hubs match the 962MessageHubGrainactivations almost exactly, so the grain-hosted working set is not what grows: each node hub carries ~8.6 synchronization sub-hubs. At the ~390 KB a hub retains, that is ≈3.2 GB of the 3.51 GB live heap.Why 9,386 hubs is 3.5 GB and not 300 MB
Every hub owns its own Autofac child
ILifetimeScopeand its ownTypeRegistry. Per hub, from the dump:Autofac…MiddlewareDeclarationAutofac…ExternalComponentRegistrationAutofac…ResolvePipelineBuilderService[],NoOpActivator,Slot[],ServiceRegistrationInfo, …)MeshWeaver.Messaging.Serialization.TypeDefinition(+ itsLazy/Funcfan-out)≈1.58 GB of the 3.51 GB live heap — 45 % — is identical framework metadata held 9,386 times. Nothing in that table is application data.
The retaining root, with the chain
HostedHubsCollection.messageHubs(HostedHubsCollection.cs:29) — no idle sweep, no TTL, no cap, no LRU. The one removal ishub.RegisterForDisposal(h => messageHubs.TryRemove(h.Address, out _))(:203). A grain-hosted hub disposes only inMessageHubGrain.OnDeactivateAsync(:890-892) — whose first line (:864,LogInformation) is thedeactivating: reason=this issue counted at zero.And the two halves are the same objects: each
sync/hub's stream fires aHeartBeatEventevery 45 s (SyncStreamOptions.cs:18→JsonSynchronizationStream.cs:1062→MeshExtensions.cs:537) intoGrainKeepAliveCallback → DelayDeactivation(10 min)(MessageHubGrain.cs:560). A 13× over-provision, and the heartbeat is itself a grain call so it also resets Orleans' own idle clock. The population that fills the heap is the same population that guarantees nothing can ever go idle.All three candidates falsified — by measurement, not argument
dotnet.assembly.count= 451 (6.9 GB pod) / 475 (14.0 GB pod); 450 distinct mapped.dllin/proc/1/maps. The fattest replica carries 24 more assemblies, not thousands.5nqbzat 14.0 GB:gen2 heap size 10,251,252,048withgen2 fragmentation 1,075,128— 0.01 % free. Live, reachable data at the 12 GiB ceiling.That last row answers this issue's central question directly: 267 full collections free nothing because there is nothing to free.
🚨 A tooling trap that would have sent the next reader the wrong way
dotnet-gcdumpon this process stopped 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. It reports no error. Had I trusted it, the conclusion would have been "the live set is small, so this is fragmentation", which is precisely the wrong answer.dumpheap -stat'sTotal … objectsline is the number to trust.What I am deliberately NOT claiming, and the next measurement
I did not fix it, because the disposal code is correct and the dump cannot tell me which way it fails.
SynchronizationStream.Dispose()completes the store, walks its composite synchronously and disposes its hub (SynchronizationStream.cs:2198-2251). So a survivingsync/hub is either:MessageHub.Dispose()postsShutdownRequest(Quiescing)and returns, and the callback that removes the registry entry runs in the ShutDown phase, several action-block turns later, bounded by nothing.A type histogram cannot separate (a) from (b), and they call for opposite fixes. Guessing here is how this issue's predecessor spent a month on the convergence lane.
The next measurement is one small extension of the same ClrMD walk: histogram
MessageHub.RunLevelover the 8,419sync/hubs. A large group at>= ShutDown/Disposingis (b) — a teardown-completion defect, bounded, and the fix is that the registry entry is released on the request rather than the completion. A large group atStartedwith a live stream is (a), and the question moves to a ClrMD root-walk from one such stream (SOSgcrootwill crash the same waydumpobjdid).The uncomfortable third possibility is worth stating: if most are legitimately live, then the portal's working set is simply larger than its ceiling, and the ~390 KB-per-hub DI cost is the whole story rather than a multiplier.
Committed
Doc page
Doc/Architecture/PortalHeapIsHubs— the full measurement, the retaining root with file:line, the three falsifications, the two separated defects, the open question above, and the counter recipe that reproduces the discrimination without a dump (gen2 heap sizevsgen2 fragmentationsettles retention-vs-fragmentation in one read;dotnet.timer.countis a cheap proxy for live hubs — 11,387 at 6.9 GB, 37,796 at 14.0 GB).PR: #3325.
Measured 2026-09-04 against
memex-cloudonmemex-aks-rg/memexaks-cluster, read-only throughout. No cluster object mutated; no secret value read, printed or stored.docs: name the portal heap retention root — 8,419 of 9,393 hubs are s…
rbuergi commentedon Sep 4, 2026
Followed the chain three levels further — the corpse-holder is
SynchronizationStream<T>.HubThe previous comment named the population (8,419 of 9,393 hubs are
sync/) and said the next measurement was aRunLevelhistogram. Ran it, and the two after it. Same replica, three more dumps, all read-only; every dump file deleted from the pod afterwards.1. The population is not homogeneous
Deadis the enum's terminal state — "the hub is fully disposed and inert" (MessageHubRunLevel.cs:22). 1,495sync/hubs completed their entire shutdown and are still reachable. At ~390 KB retained each that is ≈580 MB of pure corpse, and unlike theStartedpopulation it admits no benign reading.2. Who holds them — one pass over all 51,562,911 objects
🚨
HostedHubsCollection.messageHubsis NOT the corpse-holder — it did its job. Exactly one of the 1,496 is still in aConcurrentDictionary<Address, IMessageHub>; theRegisterForDisposalremoval at:203fired for the other 1,495. (This corrects my previous comment, which named the registry as the retaining root. It is where the LIVE hubs sit; it is not what keeps the dead ones alive.)🚨 The corpse-holder is
SynchronizationStream<T>.Hub— 1,485 + 11 = 1,496, one stream per dead hub, exactly. No GC root points at a dead hub directly; every one is alive purely transitively, through the stream that created it.3. And the stream was never told
Only 11 of 8,461 streams are disposed.
SynchronizationStream.Dispose()setsisDisposedbefore it disposes the hub, so a stream readingfalsenever ranDispose(). Put the two together:A
sync/hub is a hosted hub: its parent disposes it duringDisposeHubsReactivewhen the parent goes down — a Blazor circuit ending, aDisposeRequest, a recycle — while the stream that created it is owned by a workspace somewhere else and is never notified. The stream keepsisDisposed == false, keeps a strong reference to the corpse and everything the corpse roots (its AutofacILifetimeScope, itsTypeRegistry), and keeps being handed out as usable.The codebase already knows the creation-time half of this hazard —
SynchronizationStream.csrefuses to hand out a stream whose host is winding down, because "a consumer cannot even detect the corpse:ISynchronizationStreamexposes no liveness/disposal member", and that cost a production NRE insideLayoutAreaHost's constructor. The post-creation half — the hub dying under a stream already handed out — has the same root and is not covered.Note this also explains the zero
deactivating: reason=count without contradiction: these hubs are hosted sub-hubs, disposed by their parent's teardown, neverMessageHubGrainactivations.So: three defects, and I am deliberately not fixing them blind
ISynchronizationStreamhas no liveness member, so nothing can detect it.Startedsync/hubs never releasedWhat is still inference, stated plainly:
Deadwhile the stream is undisposed, so not viaStream.Dispose(). Inferred: the parent. The confirming measurement is a log correlation, not a dump — count[DISPOSE-CONTAINER] … lifetime scope closed(HostedHubsCollection.cs:338, Debug) forsync/addresses over a window against the growth in theDeadpopulation.Startedhubs are garbage at all. Consistent with correct behaviour (8,450 undisposed streams for subscribers really attached) and with streams abandoned withoutDispose(). The discriminator is the same one-pass referrer walk retargeted at their streams.🚨 Fixing (1) alone recovers ~580 MB of ~3.5 GB. Worth doing, and it is the only one I would call ready — but (2) is where the rest is, and it needs the walk above before anyone writes code. Guessing here is how the predecessor issue spent a month on the convergence lane.
Committed
All five measurements are in
Doc/Architecture/PortalHeapIsHubs(#3325, and the follow-up that adds these three). The page also carries two tooling traps paid for here:dotnet-gcdumpsilently truncated at exactly 10,000,000 objects and reported a 568 MB heap (a sixth of the truth), and SOSdumpobj/gcrootsegfault on this process — anything that follows a field must be ClrMD, which works fine inside the debug container since the SDK image has internet.Measured 2026-09-04 22:32–23:14Z against
memex-cloudonmemex-aks-rg/memexaks-cluster, read-only throughout. Specimen podRunning/RESTARTS 0before and after. No cluster object mutated; no secret value read, printed or stored.rbuergi commentedon Sep 4, 2026
Carried from #2194 on close: the alerts that would watch this leak are authored and INERT
Both working-set alerts now exist in
deploy/aks/scripts/values.observability.yaml—PortalReplicaWorkingSetDivergedand, as of #3319,PortalHeapEnteringGcDefenceBand(per namespace, no sibling ratio, thresholded at the GC hard limit). Neither is armed.That file has never been applied to this cluster, so the detector for the curve this issue tracks has been blind since the day it merged, and #3319 changes nothing about that — it improves what would fire, not whether anything is armed.
The action is a
helm upgradeof thelokirelease in nsmonitoring. It touches no portal pod in either namespace — no rollout, no restart. It is a production mutation and therefore an operator decision, which is why it is recorded here rather than done.🚨 The only evidence it worked is
GET /api/v1/rulesreturning a group naming both alerts.git grepis evidence of authorship and never of arming — that conflation is what made #2194 read as instrumented for a month while it was blind.It pairs naturally with step 1 of the suggested order above: routing the OTel metrics pipeline to Prometheus would let an alert read
dotnet_gc_heap_sizeagainst the hard limit directly, instead of inferring it from a kubelet-side working-set number that cannot distinguish managed heap from native memory.rbuergi commentedon Sep 5, 2026
Re-measured 2026-09-05 12:38Z — the curve is unchanged, and the blocker on this issue is gone
The
GRAFANA_PWdependency was never necessary. Loki's API is reachable in-cluster andunauthenticated, so
az aks command invoke+curlreads it directly — no Grafana, no password, nonew secret:
(Grafana itself was publicly exposed at
grafana.systemorph.comon a shared admin password for 95days; that ingress is now removed and both Grafana and Loki sit behind an internal-only ingress on
10.42.0.11, VPN-reachable. Unrelated to the leak, but found while chasing this.)The measurement, on a completely different replica set
Every replica in the original table has been replaced or restarted since. Container uptime measured
from the last start (
lastState.terminated.finishedAtwhere a restart occurred), working set fromkubectl top:fxbhdgs6j6zn7nfq8v6sxrgsxlgwrk5nqbzPearson r(uptime, working set) = 0.875 across all seven, and the mean rate is 210 MB/h
against the 215 MB/h this issue was filed on. Two days, a different replica set, the same number.
That is the strongest form of confirmation available short of a dump: the original measurement was
not an artefact of one pod.
fxbhdat 11 889 Mi is ~99 % of the 12 GiB managed-heap ceiling the issue derives.The "no OOMKill" finding still holds — I checked, because it looked like it had changed
Three replicas now show
restarts=1, which the original table did not. None is an OOMKill:5nqbzCompleted, exit 0lgwrk,xrgsxUnknown, exit 255, both at 2026-09-04T15:01:17Z (same instant — a node-level event, not the app)So the mechanism in
WhyAGcBoundPodStaysInRotationis intact: the runtime defends the ceiling withblocking gen2 rather than letting the kubelet recycle the container. A restart count is not evidence
against it.
What this does and does not settle
It settles that the leak is live, reproducible and uptime-linked — candidate A's shape (nothing
ever deactivates, so every ALC lease is held for the process lifetime) predicts exactly this
correlation, and candidate B does not (
5nqbzhas the highest rate on the shortest uptime).It does not name the retainer. Nothing in the logs can, as this issue already concluded. That
still needs the gcdump from a fat replica —
fxbhdorgs6j6are the candidates, and both arecomfortably above the threshold where retained-bytes-by-type would be unambiguous.
rbuergi commentedon Sep 5, 2026
Sizing defect (1) — and ruling out the fix that looks obvious
Not implementing this today: it is a reference-dropping change on a hot path and deserves a clean
pass with tests. But I measured what it involves, so the next attempt does not re-derive it.
"Just close the lifetime scope" is not the missing piece
MessageHubdeliberately does not dispose its ownServiceProvider— the call is present andcommented out, with the reason recorded beside it:
Closing it is delegated instead:
OwnsServiceProvideris read byHostedHubsCollection.Add,"the only place that both knows the scope exists and can act strictly after this hub is terminally
down". So the scope is closed for these hubs — which is consistent with the earlier finding that
HostedHubsCollectionremoved 1 495 of 1 496. Adding a scope-dispose to the hub's own teardown wouldre-introduce exactly the
ObjectDisposedExceptionthat comment is guarding against, and would notfree the bytes anyway.
What actually retains, therefore
Closing an Autofac scope does not null the fields on an object that outlives it. The retained graph
is
stream → dead hub → its own resolved state(TypeRegistry, and whatever else was resolved ontothe hub at construction). The only way to reclaim it is to drop a reference, which is what makes
this delicate rather than fiddly.
The two places a reference could be dropped, measured
SynchronizationStream.Hubpublic IMessageHub Hub { get; private set; }— settable, so it can be nulled inDispose()Hub.uses in a 2 501-line file against only 4isDisposed || Hub is nullguard sites — so ~28 call sites need auditing, not oneMessageHub.ServiceProvider/.TypeRegistrypublic { get; }— get-onlyhub.ServiceProvider.GetRequiredService<T>()uses everywhere; riskier than the stream sideSo the stream side is the right target, and the work is the ~28 unguarded
Hub.call sites plus atest that reproduces corpse-holding (dispose a hosted
sync/hub under a stream nobody disposed,assert the stream releases it).
One thing that must not be lost
Dispose()setsisDisposed, completes the store, clearssharedReduceCacheand disposesstreamDisposables— but it never clearsHub. So even the streams that are disposed keeptheir hub reachable. Whatever the fix,
Dispose()clearing the field is part of it, not only thehub-death path.
rbuergi commentedon Sep 5, 2026
🚨 Defect (1)'s fix cannot be "drop the
Hubreference" — that was tried, and it cost a production NREI picked this up to implement defect (1) and stopped before writing code, because the file records
that the predecessor of the current constructor did exactly the proposed change and it was
reverted. The reasoning is in
SynchronizationStream.csaround the refusal path, and it is worthquoting rather than paraphrasing:
Re-measured today, because the sizing on this issue is scoped to the wrong file
The sizing above ("32
Hub.uses against 4 guard sites in a 2 501-line file, so ~28 call sites")counts only inside
SynchronizationStream.cs. Measured acrosssrc/:TryGetActiveHubuses outsideSynchronizationStream.cs.Hubdereferences acrosssrc/(conservative regex, excluding that file)ISynchronizationStream.HubdeclarationIMessageHub Hub { get; }— non-nullableInside the file the position is actually good, and better than the sizing suggests: the write paths
already funnel through
TryGetActiveHub(Updatebails viaSignalDisposedToProducer),DeliverMessage,OnNextandRegisterForDisposaleach carryisDisposed || Hub is null, and theFailStartup/OpenGatepair is guarded with a deliberately loud else-branch. OnlyOwnerVersion()and the twoBuild*ChangeItemhelpers readHubunguarded, and all three are reachable only afterUpdate'sguard has passed.
The exposure is entirely outside the file, through a contract that promises non-null — which is
precisely why the in-file discipline did not save the predecessor.
So the prerequisite is the contract, not the field
The blocker both this issue and that comment name is the same sentence:
ISynchronizationStreamexposes no liveness member. Until a consumer can ASK whether the stream is still alive, dropping the
reference converts a memory leak into a null-dereference on the layout-area render path — a strictly
worse failure, and one this repository has already shipped once.
A staged shape that does not re-cause it:
ISynchronizationStream— the internalIStreamLivenessSource(
Source/IsDisposed/IsFaulted) andStreamLiveness.IsUsablealready compute exactly this,including walking the reduce chain; today it is
internaland consumers cannot reach it.Hub-is-null is a state the contract admitsrather than a lie.
Step 1 is a public-surface addition and step 2 is a mechanical sweep with a real cost, so this is a
larger change than the sizing implies. I have not started it; flagging so nobody re-derives the trap.
Also worth correcting
StreamLiveness.IsUsablealready treatscurrent.Hub is not { } hubas dead, so detection issolved and only release is missing — the issue reads as though both were open.
🤖 Filed from Claude Code.
rbuergi commentedon Sep 5, 2026
🚨 Correction: do NOT implement defect (1) as I sized it — that approach was already tried and cost a production NRE
meshweaver-64caught this before writing code. My sizing comment above is wrong in a way that couldhave walked someone into repeating a production incident, so treat this as superseding it.
What I got wrong
I sized the fix as "
Hubis{ get; private set; }so it CAN be nulled; 32Hub.uses against 4guard sites, ~28 call sites to audit". That count is scoped to
SynchronizationStream.csalone,and the exposure is almost entirely outside it.
SynchronizationStream.csrecords the previous attempt, in the constructor's refusal path:So "null the field, audit the call sites" is not an untried idea with a manageable cost. It is the
thing that was tried, and the in-file discipline is precisely what failed to hold — because the
contract promises non-null to ~96 callers who have no way to ask.
Also correcting myself: detection is NOT open
My earlier comment reads as though both detection and release were missing. Only release is.
StreamLiveness.IsUsablealready treatscurrent.Hub is not { } hubas dead, checksRunLevel > StartedandMessageHub { IsDisposing: true }, and walks the reduce chain viaIStreamLivenessSource.Source. The cache already refuses to serve a corpse. What is missing isletting it go.
The prerequisite is the CONTRACT, not the field
The staged shape, which is materially bigger than what I sized:
ISynchronizationStream.IStreamLivenessSource(Source/IsDisposed/IsFaulted) andStreamLiveness.IsUsablealready compute the answer, chain-walk included — theyare merely
internal. Making a consumer able to ask is what the predecessor lacked.Step 1 is a public-surface addition, so it is a maintainer decision, not something to start off
the back of an issue comment. I am not implementing it, and I would not recommend anyone do so
without that call being made explicitly.
My apologies for the earlier sizing — I measured one file and reported it as the cost of the change.
rbuergi commentedon Sep 6, 2026
Step 1 of 3 has landed — #3380 merged
SynchronizationStreamLiveness.IsUsable(stream)/stream.TryGetHub()are now public, so a consumercan ask whether a stream is alive. Behaviour is unchanged; this is purely the contract.
Deliberately kept as an extension, not a member on
ISynchronizationStream—StreamLivenessrecords two reasons the interface is internal and neither has expired: adding a member to a public
interface breaks every downstream implementer (these ship as packages), and
IStreamLivenessSource.Sourceis not a consumer-facing concept (it exists so the reduce chain is walked in exactly one place). Both
new methods delegate to
StreamLiveness.IsUsableand add no logic — that type exists because threehand-copied predicates diverged (#1455), and a wrapper that re-implemented the check would be the fourth.
The guard is reflection-based on purpose:
MeshWeaver.DatagrantsInternalsVisibleTo("MeshWeaver.Data.Test"),so a test that merely called the methods would compile and pass unchanged if the surface reverted to
internal. Falsified rather than assumed — with the class flipped tointernalthe project stillbuilds and the other three tests still pass; only
TheSurfaceIsGenuinelyPublicgoes red.What remains on this issue
TryGetHub(), which can answernull.Hubon disposal — reclaiming the retainedstream → dead hub → resolved stategraph,which is the actual leak this issue is about.
🚨 Doing 3 before 2 is the production NRE, in the order it happened: the predecessor nulled the field
and documented that everything went through
TryGetActiveHub, which was private to one file while ~96sites dereferenced a property the interface declares non-null.
LayoutAreaHost's constructor NRE'dduring a recycle window and the subscriber got a terminal
DeliveryFailure.The design and the ordering are now committed as
Doc/Architecture/StreamLivenessAndTheHubReference,so this does not have to be re-derived from issue comments again.
Leaving this issue OPEN — the leak is not fixed until step 3.
docs(#3321): name the dependent half and why no gate can see it
fix(#3321): the reachability assertion waits for the teardown it meas…
rbuergi commentedon Sep 6, 2026
Step 3 is merged — the stream releases its hub
main(f41f8bda)main(cdfaef28), merged firstClosing manually: the merge queue does not fire
Closeslinks.🚨 The description on this issue was a sixth of the fix
"
SynchronizationStream.Dispose()clearsHub" is how step 3 has been written up throughout. Readagainst the dump this issue already carries, that reclaims 11 of 1 496:
A
sync/hub is a HOSTED hub — the parent disposes it during its own teardown while the stream thatcreated it, owned by a workspace elsewhere, is never told.
Dispose()never runs for those 1 485.So the release happens at both ends through one idempotent
ReleaseHub(): fromDispose(), andfrom a hook the constructor registers on the sub-hub's own disposal. That hook is registered after
streamDisposablesis hooked onto the hub, because a hub's composite disposes in registration orderand the stream's own registrants must still see a bound
Hub.The 25 sites, and the one that changed the design
A fresh sweep found 29 stream-receiver
.Huboccurrences insrc/; four must stay (two ARE thepredicate, one is already
?., one is a liveness check). The other 25 — the 20 named on this issue,plus the three
LayoutAreaHostreads step 2 called "hub-turn confined" (that premise expired withthis change),
Workspace.EvictClientSubscriptions, andWorkspaceExtensions.ApplyChangeswhichstep 2's list missed behind a null-forgiving
!.The 12 patch reducers were the design problem. A reducer's signature is
… → ChangeItem<T>: itmust return a change, so it has no absent value. But all of them are invoked from exactly ONE
place —
ToChangeItem, the only call site ofReduceManager.PatchFunction— andnullis what thatmethod already returns for a stream with no
PatchFunction. So the reducers refuse throughRequireHub()'s TRANSIENTHubDisposingExceptionand the gateway maps that one type to its ownmodelled absence. Not a swallow, and not a
catch (NullReferenceException).🚨 Three things this took that are worth carrying forward
1. PRESENCE is not LIVENESS. The first draft guarded every owner-side site with
TryGetHub()(
IsUsable).DataSource.InitializedisTask.WhenAll(streams.Select(s => s.Hub.Started))— and astream whose initial load FAULTED is not
IsUsable, so it dropped out of the WhenAll,Initializedcompleted successfully, and a hub whose data source had thrown went on answeringrequests.
DataContextFaultedInitBeforeStreamHubBoundTestcaught it: the #2625 wedge, re-introducedby a guard that looked like a tightening of safety. Owner-side guards now use the presence
accessor, which matches the
isDisposed || Hub is nullrefusal their own gate already applies.2. The reachability test was FLAKY and its green was luck.
Hub.Dispose()only starts theteardown, 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 it time — and failed the
moment the test ran alone: 1 failed in isolation against 485 passed in the suite, same binary. It
now awaits
DisposalCompletedbefore collecting.3. No gate could have told anyone about the dependent half. This is shape 7 — a public member
whose BEHAVIOUR changes behind an unchanged signature — and the
Cross-repo pairgate resolvesremoved types and members, of which this diff has zero. A by-hand sweep of all six satellites, on
the receiver type and including in-mesh C# inside
.jsonnode strings, found 11 productiondereferences in MeshWeaver.Plugins's Blazor view layer and zero elsewhere. Every one reads
Stream!.Hub.Something— the!is onStream, not on.Hub, so the NRE moves one levelright past the operator that suppresses the warning and the compiler stays silent. One sits inside a
bare
catch { }, where it would have rendered a wrong chart instead of throwing.Falsification
Measured by disabling only the two
ReleaseHub()call sites — every guard and accessor left inplace, so the experiment isolates exactly what step 3 does:
StreamReleasesItsHubTestTornDownStreamCallSitesTest(step 2's)StreamCacheLivenessTest(Plugins, without theHub is nullarm)TimeoutException, 30 sThe live controls passed both ways in all three — a release that fired unconditionally would satisfy
every negative at once.
What is NOT claimed
This releases the 1 496
Dead-hub corpses — defect (1) on this issue, ~580 MB of a 3.5 GB liveheap. Defect (2) — the 6 925
Startedsync/hubs, ~2.7 GB — is untouched and still open, andthis issue's own analysis says the discriminator for it is a referrer walk that has not been run. So
expect the curve to flatten, not to vanish; the honest next measurement is a fresh dump against a
replica running a build that carries
f41f8bda, compared on theSynchronizationStream total / disposedandRunLevel=Deadcounts this issue already established. If it warrants a follow-up,that is a new issue about defect (2), not a reopening of this one.
Committed
Doc/Architecture/StreamLivenessAndTheHubReferencecarries the whole design — the 11-vs-1 485split, the two-ended release with its registration-order constraint, the 29/4/25 site table with a
reason per group, the measured presence-vs-liveness finding, the dependent-half sweep with per-repo
counts, and the falsification numbers. Plus a What's New entry (
Category: Fix).rbuergi commentedon Sep 6, 2026
Defect (2) is now tracked on its own: #3432.
It carries the measured population (6 925
Startedsync/hubs, ≈2.7 GB of a 3.51 GB live heap,from the same five dumps as this issue), the 1 496 / 11 / 1 485 split that made step 3 need two
hooks rather than one, and — explicitly — the list of things that are not established, so the
next session treats the mechanism as a hypothesis rather than a finding.
It also names the outcome that would falsify it: if the referrer walk finds diffuse referrers
matching live subscribers, this is per-hub COST (≈1.58 GB of duplicated Autofac + type-registry
metadata across 9 386 hubs), not retention — a different issue and a different fix.
🚨 And the thing not to misread: step 3 released ≈580 MB of ≈3.5 GB, so the curve should flatten,
not vanish. A still-rising curve is not step 3 having failed.