Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,40 @@ IMessageDelivery<SubscribeRequest> delivery
var logger = GetLogger(hub.ServiceProvider);
var request = delivery.Message with { Subscriber = delivery.Sender };

// 🚨 RE-SUBSCRIBE IS A REFRESH, NOT A SECOND SUBSCRIPTION (issue #606).
//
// Resubscribe() re-posts a SubscribeRequest carrying the SAME StreamId — it means
// "re-assert my stream", not "open another one". Building a second reduced stream here
// left the FIRST one live and rendering forever (nothing but an UnsubscribeRequest ever
// disposes a server-side stream, and a resubscribe never sends one). For a
// LayoutAreaReference each of those is a whole LayoutAreaHost + render graph, so every
// change-feed pulse on the owner path permanently added one more render pipeline —
// the unbounded growth that killed the pod.
//
// Serve the refresh off the stream we ALREADY have: re-assert its current state as a
// Full, which the outbound forwarding subscription below (registered when that stream
// was created) delivers to the subscriber — exactly the "fresh snapshot" the caller
// asked for, and exactly what its ExpectResubscribeFull latch is armed to accept.
// A genuinely orphaned mirror (owner recycled ⇒ new workspace ⇒ empty registry) still
// takes the full create path below.
if (workspace is Workspace registry
&& registry.GetClientSubscription(request.Subscriber, request.StreamId, request.Reference)
is ISynchronizationStream<TReduced> alreadyServing)
{
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)
alreadyServing.Update(
_ => new ChangeItem<TReduced>(snapshot.Value, alreadyServing.StreamId, alreadyServing.Hub.Version),
ex => logger.LogWarning(ex,
"Stream {StreamId}: could not re-assert snapshot for resubscribing subscriber {Subscriber}",
alreadyServing.StreamId, request.Subscriber));
// Nothing yet to re-assert (the initial subscribe is still hydrating) — its own
// first Full is already on its way to this subscriber; do NOT build a second stream.
return alreadyServing;
}

var fromWorkspace = workspace
.ReduceManager
.ReduceStream<TReduced>(
Expand Down Expand Up @@ -757,6 +791,13 @@ fromWorkspace as ISynchronizationStream<TReduced>
// })
// );

// Record it as THE stream for this (subscriber, StreamId) so a later resubscribe
// refreshes it instead of stacking another one (see the guard at the top). Registered
// AFTER the outbound forwarding subscription exists, so a re-assert can never find a
// stream that has no way to reach the subscriber. Unregisters itself on disposal.
(workspace as Workspace)?.RegisterClientSubscription(
request.Subscriber, request.StreamId, request.Reference, reduced);

return reduced;
}
private static IObservable<TChange?> ToDataChanged<TReduced, TChange>(
Expand Down
83 changes: 83 additions & 0 deletions src/MeshWeaver.Data/Workspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -664,5 +664,88 @@ private void SubscribeToClient<TReduced, TReference>(IMessageDelivery<SubscribeR
this.CreateSynchronizationStream<TReduced, TReference>(delivery);
}

// 🚨 ONE server-side stream per (subscriber, StreamId) — issue #606.
//
// A SubscribeRequest carries the SUBSCRIBER's StreamId, and a resubscribe deliberately
// REUSES it ("refresh MY stream" — JsonSynchronizationStream.Resubscribe). Before this
// registry the owner treated every arrival as a brand-new subscription and built another
// reduced stream — for a LayoutAreaReference that is a whole new LayoutAreaHost + render
// graph (LayoutExtensions' AddWorkspaceReferenceStream factory) — while the previous one
// kept running, kept rendering and kept pushing DataChangedEvents to the same subscriber.
// Nothing ever released it: only an UnsubscribeRequest disposes a server-side stream, and
// a resubscribe never sends one.
//
// That made the accumulation unbounded on the ordinary serving path: the layout-area
// stream's staleness gate is INERT for EntityStore reductions (EntityStore has no
// `long Version`, so `receivedVersion` never advances while `announcedVersion` is
// fabricated as received+1 — see the version gate in JsonSynchronizationStream), so
// EVERY change-feed event on the owner path resubscribes. Measured: one write to a node
// added one more live render pipeline, so a single subsequent write produced 1 → 10 Full
// frames after 8 writes, each leaked host holding its EntityStore, its menu/node-stream
// subscriptions (which pin MeshNodeStreamCache entries so their upstream sync streams can
// never be idle-released) and its whole control tree.
//
// The registry is an INSTANCE field on the workspace, so its lifetime IS the owner hub's
// (no static state, and an owner recycle correctly starts from empty → a genuinely
// orphaned mirror still gets a fresh stream).
private readonly ConcurrentDictionary<(string Subscriber, string StreamId), ClientSubscription>
_clientSubscriptions = new();

// 🚨 A CLASS, never a record: SynchronizationStream is itself a record whose generated
// GetHashCode/Equals recurse into StreamConfiguration (which holds the stream back) — a
// record wrapper around one stack-overflows the process the moment the dictionary hashes
// or compares it. See the _evictedRemoteStreams field note.
private sealed class ClientSubscription(WorkspaceReference reference, ISynchronizationStream stream)
{
public WorkspaceReference Reference { get; } = reference;
public ISynchronizationStream Stream { get; } = stream;
}

/// <summary>
/// Returns the server-side stream already serving <paramref name="streamId"/> for
/// <paramref name="subscriber"/> under the SAME reference, or <c>null</c>. A hit means the
/// arriving SubscribeRequest is a RE-subscribe of a stream this owner still serves, so the
/// caller must re-assert the current snapshot on it instead of building a second one.
/// </summary>
internal ISynchronizationStream? GetClientSubscription(
Address? subscriber, string? streamId, WorkspaceReference reference)
{
if (subscriber is null || string.IsNullOrEmpty(streamId))
return null;
if (!_clientSubscriptions.TryGetValue((subscriber.ToString(), streamId), out var existing))
return null;
if (!Equals(existing.Reference, reference))
return null;
// A stream whose sync hub is past Started is tearing down — its registry entry is
// removed at ShutDown, so this window is short; treat it as absent and let the caller
// build a fresh stream rather than re-asserting into a corpse.
return existing.Stream.Hub is { RunLevel: <= MessageHubRunLevel.Started }
? existing.Stream
: null;
}

/// <summary>
/// Records <paramref name="stream"/> as THE server-side stream for
/// (<paramref name="subscriber"/>, <paramref name="streamId"/>) and unregisters it when the
/// stream is disposed, so the entry can never outlive what it points at.
/// </summary>
internal void RegisterClientSubscription(
Address? subscriber, string? streamId, WorkspaceReference reference, ISynchronizationStream stream)
{
if (subscriber is null || string.IsNullOrEmpty(streamId))
return;
var key = (subscriber.ToString(), streamId);
_clientSubscriptions[key] = new ClientSubscription(reference, stream);
// Pair-exact removal by REFERENCE identity (never value equality — see ClientSubscription):
// a later stream that legitimately took over this key must not be unregistered by an
// earlier stream's teardown.
stream.RegisterForDisposal(System.Reactive.Disposables.Disposable.Create(() =>
{
if (_clientSubscriptions.TryGetValue(key, out var current)
&& ReferenceEquals(current.Stream, stream))
_clientSubscriptions.TryRemove(key, out _);
}));
}


}
4 changes: 3 additions & 1 deletion src/MeshWeaver.GitSync/GitHubSyncSettingsTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack,
// Editable commit + re-import. Prefill from the Space's saved config once it arrives (same
// synced-query empty-first-emission caveat as the repo form).
host.UpdateData(CommitFormId, new Dictionary<string, object?> { ["commit"] = "main" });
host.RegisterForDisposal("gh-commit-prefill", sync.WatchConfig(spacePath)
// ReplaceDisposable (not the appending RegisterForDisposal) — BuildContent runs per render,
// and a synthetic key like this is never reaped by the area teardown (issue #606).
host.ReplaceDisposable("gh-commit-prefill", sync.WatchConfig(spacePath)
.Where(c => c is not null)
.Take(1)
.Subscribe(cfg => host.UpdateData(CommitFormId, new Dictionary<string, object?>
Expand Down
25 changes: 23 additions & 2 deletions src/MeshWeaver.Graph/Configuration/NodeMenuItemsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,29 @@ private static EntityStoreAndUpdates RenderMenus(LayoutAreaHost host, RenderingC
// Default (unnamed) context lands on "$Menu"; named contexts on "$Menu:{context}".
var area = context.Length == 0 ? MenuControl.MenuArea : MenuControl.GetMenuArea(context);
var areaContext = new RenderingContext(area);
host.RegisterForDisposal(
MenuControl.MenuArea,
// 🚨 ReplaceDisposable keyed on the WRITTEN area — never the appending
// RegisterForDisposal, and never one shared key for every context (issue #606).
//
// RenderMenus is a GLOBAL predicate renderer (`WithRenderer(_ => true, …)`), so it runs
// on EVERY area render — and a render re-runs on every emission of the node/permission
// stream the providers themselves are built on. The appending overload therefore stacked
// one MORE live GetMeshNodeStream+permissions subscription per re-render, under the
// constant key "$Menu" which no area teardown ever reaps (DisposeExistingAreas /
// DisposeChildAreas only remove keys that StartsWith(context.Area), and "$Menu" never
// starts with "Overview"/"Edit"/…). Every stacked subscription stayed live for the
// host's whole lifetime, each one holding the node stream + the permission stream (and
// so pinning a MeshNodeStreamCache entry, which blocks the idle release of that path's
// upstream sync stream), and each one an additional writer of the SAME $Menu area.
//
// Keyed PER CONTEXT (not the shared MenuArea constant): each menu context writes its
// OWN area, so one live writer per area is exactly right — two live subscriptions
// writing the same area were racing writers, not a feature. The shared constant would
// make context B's registration dispose context A's. The key is deliberately NOT the
// bare area name either: RenderArea registers the rendered UiControls under the area
// key, and replacing that bucket would dispose the live menu control alongside the
// stale subscription.
host.ReplaceDisposable(
$"menu-subscription:{area}",
items
.DistinctUntilChanged(MenuItemsSequenceComparer.Instance)
.Subscribe(
Expand Down
7 changes: 6 additions & 1 deletion src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1778,7 +1778,12 @@ private static UiControl BuildEditNodeContent(LayoutAreaHost host, MeshNode? nod
// no SetupAutoSave save subscription. The one-way /data projection below keeps the
// derived-label read views (dimension/options/date) correct from the Layout layer.
var boundContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath, bindContent: true);
host.RegisterForDisposal($"editnode-content-projection_{dataId}",
// 🚨 ReplaceDisposable, NEVER RegisterForDisposal — see the identical seam in
// OverviewLayoutArea.BuildPropertyOverview (issue #606). BuildEditNodeContent re-runs on
// every emission of the node/permission CombineLatest, and the appending overload would
// stack one more live node-stream subscription per render under a key no area teardown
// reaps.
host.ReplaceDisposable($"editnode-content-projection_{dataId}",
host.Workspace.GetMeshNodeStream(nodePath)
.Select(n => n?.Content)
.Where(c => c is not null)
Expand Down
12 changes: 11 additions & 1 deletion src/MeshWeaver.Graph/OverviewLayoutArea.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,17 @@ public static UiControl BuildPropertyOverview(LayoutAreaHost host, MeshNode node
// node's Content (node → /data, NEVER /data → node) so those labels stay correct. This is a
// pure read mirror — there is no save loop and no drift: it follows the node, and all WRITES
// still go straight to the node via the node-bound DataContext above.
host.RegisterForDisposal($"overview-content-projection_{dataId}",
// 🚨 ReplaceDisposable, NEVER RegisterForDisposal (issue #606). This method runs INSIDE the
// area's render — once per emission of the node/permission stream — so an APPENDING
// registration adds one more live node-stream subscription per re-render, under a synthetic
// key that no area teardown reaps (DisposeExistingAreas / DisposeChildAreas only remove keys
// that StartsWith(context.Area), and "overview-content-projection_…" never does). Every
// accumulated projection stays live: it fires ANOTHER UpdateData on every subsequent node
// emission, and it pins a MeshNodeStreamCache entry so that path's upstream sync stream can
// never be idle-released. Nothing frees any of it until the whole host dies.
// ReplaceDisposable disposes the previous projection for this key first, so exactly ONE is
// ever live regardless of render count.
host.ReplaceDisposable($"overview-content-projection_{dataId}",
host.Workspace.GetMeshNodeStream(node.Path)
.Select(n => n?.Content)
.Where(c => c is not null)
Expand Down
Loading
Loading