Repository navigation
Stop stacking a LayoutAreaHost per write — the render leak that killed pods (#606) - #916
Conversation
…d pods Fixes #606. memex-cloud ballooned ~1 GB/min while serving. Root cause: Workspace.SubscribeToClient had no registry and no dedup by StreamId, so every arriving SubscribeRequest unconditionally ran ReduceManager.ReduceStream, which for a LayoutAreaReference constructs a NEW LayoutAreaHost. The previous stream for that same StreamId was never disposed — only an UnsubscribeRequest disposes a server-side stream, and a resubscribe never sends one. Each leaked host is held by its own SynchronizationStream on the owner's sync/{ClientId} hub and holds disposablesByArea, whose subscriptions register as SharedView subscribers on MeshNodeStreamCache entries, so the idle sweep can never release those upstream streams either — and it keeps rendering. The trigger: JsonSynchronizationStream resolves the staleness Version BY NAME (GetProperty("Version", typeof(long))). EntityStore has no such property, so receivedVersion stays 0 while announcedVersion is fabricated as receivedVersion + 1 for every version-less event. The gate is therefore permanently open for every layout-area stream, so every change-feed event on the owner path resubscribes with the SAME StreamId and no unsubscribe. Net: one write to any node somebody is viewing = one more permanent LayoutAreaHost. Untyped PluginContent did not create this; it made the pulses continuous, which is why it presented as an untyped-content bug. Controlled A/B: disarming only the change-feed resubscribe took per-write frames from 1,4,5,6,7,8,9,10 to a flat 1 Full + 4 menu patches across all 8 writes. Instrumentation reverted; no A/B code remains. Fix: an INSTANCE registry on Workspace keyed (subscriber, StreamId) — instance so its lifetime is the owner hub; ClientSubscription is a class, not a record, because SynchronizationStream is a record whose generated equality recurses into StreamConfiguration and stack-overflows the process (the trap already documented on _evictedRemoteStreams). A repeat SubscribeRequest for a stream already served under the same reference now re-asserts that stream's state as a Full and returns it instead of building a second host. It deliberately does not dispose the old stream: Dispose tears down the shared sync hub and HostedHubsCollection.GetHub has no disposal check, so dispose-then-recreate races a disposing hub. Three secondary accumulators of the same class — subscriptions created in a per-emission render body under a synthetic key no area teardown reaps — switched to the framework's existing LayoutAreaHost.ReplaceDisposable: NodeMenuItemsExtensions (a GLOBAL predicate renderer appending under the constant key "$Menu" for every context), OverviewLayoutArea, MeshNodeLayoutAreas and GitHubSyncSettingsTab. The host-stacking fix is measured; these four are code-reasoned and suite-verified, not independently measured, because their own DistinctUntilChanged silences the duplicates. Test LayoutAreaResubscribeHostAccumulationTest uses a managed measurement only (no ClrMD, which now skips on macOS per #674): every live host emits its own Full per node change, so Full frames per write == live render pipelines. Fails pre-fix with [1,4,5,6,7,8,9,10,11,12] ("Expected 12 to be less than or equal to 6"); passes post-fix with [1,2,2,2,2,2,2,2,2,2]. Suites on the final tree: Monolith 478 passed, Graph 820, Layout 343, Data 303; re-verified after merging main. Release -warnaserror clean. Closes #606 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses #606 by preventing server-side layout-area resubscribe traffic from creating duplicate long-lived render pipelines (e.g., multiple LayoutAreaHost instances) for the same (subscriber, StreamId), which previously led to unbounded memory growth and pod OOMs under normal serving load.
Changes:
- Add a per-workspace registry keyed by
(subscriber, StreamId)to deduplicate server-side streams and unregister them on disposal. - On owner-side resubscribe, reuse the existing stream and re-assert its current snapshot as a
Fullinstead of creating a second reduced stream/host. - Replace several
RegisterForDisposal-inside-render callsites withReplaceDisposable(...)to prevent stacking subscriptions across re-renders, and add a regression test that countsFullframes per write.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Monolith.Test/LayoutAreaResubscribeHostAccumulationTest.cs | New regression test that detects host stacking by counting Full frames per write. |
| src/MeshWeaver.Data/Workspace.cs | Adds an instance (subscriber, StreamId) → stream registry and lifecycle-unregistration. |
| src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs | Reuses existing streams on resubscribe and re-asserts current state as a Full. |
| src/MeshWeaver.Graph/OverviewLayoutArea.cs | Switches a render-time subscription registration to ReplaceDisposable to avoid accumulation. |
| src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs | Same ReplaceDisposable change for edit-node content projection. |
| src/MeshWeaver.Graph/Configuration/NodeMenuItemsExtensions.cs | Uses ReplaceDisposable with per-area keys to prevent global menu subscription stacking. |
| src/MeshWeaver.GitSync/GitHubSyncSettingsTab.cs | Uses ReplaceDisposable for a render-time “prefill once” subscription to prevent stacking. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| logger.LogDebug( | ||
| "Owner {Owner} already serves stream {StreamId} for {Subscriber} — re-asserting the current snapshot instead of creating a second stream", | ||
| hub.Address, request.StreamId, request.Subscriber); | ||
| if (alreadyServing.Current is { Value: not null } snapshot) |
| using System; | ||
| using System.Collections.Concurrent; | ||
| using System.Linq; |
Test Results (shard 0)672 tests 672 ✅ 4m 58s ⏱️ Results for commit 335e8cb. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 629 tests 1 438 ✅ 5m 38s ⏱️ Results for commit 335e8cb. ♻️ This comment has been updated with latest results. |
Test Results (shard 3) 11 files 11 suites 4m 57s ⏱️ Results for commit 335e8cb. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)1 075 tests 1 070 ✅ 6m 13s ⏱️ Results for commit 335e8cb. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 788 tests 1 786 ✅ 6m 26s ⏱️ Results for commit 335e8cb. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 164 tests 1 164 ✅ 7m 57s ⏱️ Results for commit 335e8cb. ♻️ This comment has been updated with latest results. |
Test Results 65 files 65 suites 36m 13s ⏱️ Results for commit 335e8cb. ♻️ This comment has been updated with latest results. |
|
CI red on a single test that this change does not touch: Merge once the #899 fix lands. Re-running shard 5 would only re-sample the same deadlock. The #899 work is now taking the generator route (releasing the permission-fold gate) rather than the async-fan-out route, which was rejected for breaking three documented synchronous cache-invalidation contracts — see #917's latest comment. 🤖 Generated with Claude Code |
Summary
Fixes #606 — the portal leaked until the pod was killed (memex-cloud ballooned ~1 GB/min while serving).
Root cause: every write stacked another live render pipeline on the owner
Workspace.SubscribeToClient(Workspace.cs:661-665) had no registry and no dedup byStreamId. Every arrivingSubscribeRequestunconditionally ranReduceManager.ReduceStream<TReduced>(...), which for aLayoutAreaReferencereachesLayoutExtensions.cs:114→new LayoutAreaHost(...). The previously-created stream for that sameStreamIdwas never disposed — only anUnsubscribeRequestdisposes a server-side stream, and a resubscribe never sends one.Retaining reference: each leaked
LayoutAreaHostis held by its ownSynchronizationStream<EntityStore>on the owner'ssync/{ClientId}hub, and itself holdsdisposablesByArea(LayoutAreaHost.cs:784) — node-stream + permission subscriptions that register asSharedViewsubscribers onMeshNodeStreamCacheentries, so the idle sweep can never release those upstream sync streams either. It also keeps rendering and keeps pushing frames.The trigger — why it fires on every write:
JsonSynchronizationStream.cs:615resolves the mirror's stalenessVersionby name (GetProperty("Version", typeof(long))).EntityStorehas no such property, soreceivedVersionstays0forever whileannouncedVersionis fabricated asreceivedVersion + 1for every version-less event. The gatereceivedVersion < announcedVersionis therefore permanently open for every layout-area stream, so every change-feed event on the owner path callsResubscribe, re-postingSubscribeRequestwith the same StreamId and no unsubscribe.Net: one write to any node somebody is viewing = one more permanent
LayoutAreaHost+ full render graph. UntypedPluginContentdid not create this; it made the pulses continuous (renders never reach a terminal state), which is why it presented as an untyped-content problem.Controlled A/B: disarming only the change-feed resubscribe took per-write frames from
1,4,5,6,7,8,9,10to a flat1 Full + 4 menu patcheson all 8 writes. Instrumentation reverted; no A/B code remains.Fix
Workspace.cs— instanceConcurrentDictionary<(Subscriber, StreamId), ClientSubscription>with register/lookup. Instance, not static, so lifetime is the owner hub (an owner recycle correctly starts empty; a genuinely orphaned mirror still gets a fresh stream).ClientSubscriptionis a class, not a record:SynchronizationStreamis a record whose generatedGetHashCode/Equalsrecurse intoStreamConfigurationand stack-overflow the process — the same trap already documented on_evictedRemoteStreams. Removal is pair-exact by reference identity.JsonSynchronizationStream.CreateSynchronizationStream— a repeatSubscribeRequestfor a(subscriber, StreamId)already served under the same reference now re-asserts the existing stream's state as aFull(delivered by the already-registered outbound forwarder, and the client'sExpectResubscribeFulllatch is armed for it) and returns that stream instead of building a second host. It deliberately does not dispose the old stream:SynchronizationStream.Disposetears down the sharedsync/{ClientId}hub andHostedHubsCollection.GetHubhas no disposal check, so dispose-then-recreate races a disposing hub.Three secondary accumulators of the same class — subscriptions created inside a per-emission render body, appended under a synthetic key no area teardown reaps (
DisposeExistingAreas/DisposeChildAreasonly remove keys thatStartsWith(context.Area)) — switched to the framework's existingLayoutAreaHost.ReplaceDisposable, documented for exactly this:NodeMenuItemsExtensions.cs:101— the worst: a global predicate renderer (WithRenderer(_ => true, RenderMenus)) appending under the constant key"$Menu"for every context. Now keyedmenu-subscription:{area}(deliberately not the bare area name, sinceLayoutAreaHost.RenderArea:557registers rendered controls under the area key and replacing that bucket would dispose the live menu control).OverviewLayoutArea.cs:52andMeshNodeLayoutAreas.cs:1781— content projections; the comment atMeshNodeLayoutAreas.cs:1749already notes the body "re-runs on every render".GitHubSyncSettingsTab.cs:190— same shape.Honest attribution: the host-stacking fix is measured; the four
ReplaceDisposablechanges are code-reasoned and suite-verified, not independently measured (their accumulated subscriptions are silenced by their ownDistinctUntilChanged, so they leak memory without producing countable wire traffic).Verification
LayoutAreaResubscribeHostAccumulationTest.RepeatedWrites_DoNotStackLayoutAreaHosts_OnTheOwner— managed measurement only, no ClrMD, so it works on macOS where those guards now skip (#674). Every live server-sideLayoutAreaHostemits its ownFullper node change, so Full frames per write == live render pipelines.Full frames per write: [1, 4, 5, 6, 7, 8, 9, 10, 11, 12]→ "Expected 12 to be less than or equal to 6".[1, 2, 2, 2, 2, 2, 2, 2, 2, 2](the 2 = 1 render Full + 1 resubscribe re-assert Full).Suites on the final tree: Hosting.Monolith 478 passed / 0 failed (4 skipped) · Graph 820/820 · Layout 343/343 · Data 303/303.
-c Release -warnaserrorclean on Data, Graph, GitSync and the test project.Residual risks
Current == null, the guard returns it without asserting anything. The dedicated recovery path (ArmOverlaySelfHeal→DisposeRequest→ recycle) still covers this, but it is a real behavioural change worth a reviewer's eye.JsonSynchronizationStream.cs:615). Layout-area streams still resubscribe on every owner-path change — now cheap (a Full re-assert) instead of leaking, but still one extra round-trip per write per viewer. Making the gate evidence-based for version-less reductions is a worthwhile follow-up.RegisterForDisposal-inside-a-render sites were found and deliberately not touched:EditLayoutArea.cs:193,DomainCatalogLayoutArea.cs:50(also mints a freshGuiddata id per render),ThreadLayoutAreas.cs:221and:285,PartitionSyncAdminLayoutArea.cs:286. Widening into the chat path without measurement was more blast radius than could be verified here.MeshNodeStreamCache.ConvertContentJsonElementToTypedandMeshNodeStreamExtensions.EnsureTypedContentreturnnode with { Content = deserialized }over a freshly clonedJsonElement.JsonElementhas noEqualsoverride, soMeshNoderecord equality is always false for untyped content and everyDistinctUntilChangeddegrades to a no-op — including the persistence sampler's. The siblingGetQueryseam already does the right thing. Symmetry looks high-value; it needs a NodeType whose content type is registered only on its per-node hub to repro faithfully.Closes #606
🤖 Generated with Claude Code