Repository navigation
Bug: EntityBroker subscriptions leak — SubscribedGet/SubscribedQuery have no disposal API, abandoned subscriptions accumulate and fire real network requests #106
Description
Symptom
In a production session, the entity broker accumulates 211+ active get requests firing on every entity update. Each request is a real DAL/network round-trip, causing quadratic load as the session continues.
Root cause
SubscribedGet and SubscribedQuery implement no IDisposable interface and have no explicit deregistration API. The broker stores them as WeakReference<T> in internal lists:
// EntityBroker.cs lines 17–19
private readonly List<WeakReference<SubscribedGet>> subscribedGets = new();
private readonly List<WeakReference<SubscribedQuery>> subscribedQueries = new();The only cleanup path is GC-based compaction during RefreshSubscribedGetsAsync: entries whose WeakReference.TryGetTarget returns false are dropped. Until the GC collects the object, every abandoned subscription fires RefreshAsync() → a real DataAccessLayer.GetAsync() call on every broker update cycle.
In a long-running production session with low memory pressure, the GC rarely runs eagerly enough to collect abandoned subscriptions, so they accumulate until memory runs low or the session is restarted.
Confirmed leak sites
1. View population — MainWindowViewModel.ApplySelectedViewAsync (lines 743–810)
// Lines 746–747 — .Clear() does NOT dispose
this.selectedViewSubViewSubscriptions.Clear();
this.selectedViewSubViewQuerySubscriptions.Clear();
// Lines 841–905 — new subscriptions created every view switch
var subscribedGet = await this.EntityBroker.SubscribeGetAsync(getRequest);
this.selectedViewSubViewSubscriptions.Add(subscribedGet);
var subscribedQuery = await this.EntityBroker.SubscribeQueryAsync(effectiveQuery);
this.selectedViewSubViewQuerySubscriptions.Add(subscribedQuery);
var noteSubscription = await this.EntityBroker.SubscribeGetAsync(…);
this.selectedViewSubViewSubscriptions.Add(noteSubscription);Each view switch creates 2–3 new subscriptions and drops the previous ones into GC limbo. After ~70 view navigations the broker holds 140–210+ abandoned subscriptions. MainWindowViewModel.DisposeAsync never touches these lists.
Trigger sites for ApplySelectedViewAsync: ShowHiddenItems setter, SelectedTopLevelView setter, OnEntityBrokerChanged, OnInterestCatalogChanged, OnEntityTypeCatalogChanged — each fires the method concurrently with no cancellation (see also #104).
2. Entity browser tab close — EntityBrowserWorkspaceTabViewModel
// Class declaration — no IDisposable
public sealed class EntityBrowserWorkspaceTabViewModel : WorkspaceTabViewModelFields that hold live subscriptions:
rootSubscribedGet: SubscribedGet(line 20) — created on construction, never disposedsubscribedGetsByPath: Dictionary<string, SubscribedGet>(line 22) — one entry per expanded tree node
WorkspaceTabViewModel base class has no disposal interface. DisposeWorkspaceTab in MainWindowViewModel (lines 1748–1759) checks for IAsyncDisposable/IDisposable and falls through silently for EntityBrowserWorkspaceTabViewModel. When the entity browser tab is closed, all its subscriptions are abandoned.
3. Duplicate entity browser open — OpenEntityBrowserTabAsync (lines 1762–1793)
var subscribedGet = await this.EntityBroker.SubscribeGetAsync(…); // always created
var entityBrowserTab = new EntityBrowserWorkspaceTabViewModel(this.EntityBroker, subscribedGet);
await this.OpenTabAsync(entityBrowserTab); // if tab already open, disposes new tabOpenTabAsync detects a duplicate and calls DisposeWorkspaceTab(entityBrowserTab) on the just-created instance — which does nothing (no IDisposable). The subscribedGet lives until both the transient entityBrowserTab and the subscribedGet it holds are GC'd.
Fix: check for an existing entity browser tab before calling SubscribeGetAsync.
4. ViewHierarchyAssembler.cs line 182
var subscription = await this.entityBroker.SubscribeQueryAsync(query, cancellationToken);
return subscription.Results.ToList();Results are read immediately and subscription goes out of scope. It is a transient per-call leak — each call to ViewHierarchyAssembler leaves one SubscribedQuery in the broker until GC runs. If GetEntitiesAsync (non-subscribing) meets the need here, replace it.
Fix direction
1. Add IDisposable to SubscribedGet and SubscribedQuery
Expose an internal Unregister callback on EntityBroker:
public sealed class SubscribedGet : IDisposable
{
private readonly Action _unregister;
internal SubscribedGet(Action unregister, …) { _unregister = unregister; }
public void Dispose() => _unregister();
}EntityBroker.SubscribeGetAsync passes a lambda that removes the WeakReference from the list (under the existing gate lock). Same pattern for SubscribedQuery.
2. Dispose subscriptions before clearing lists in ApplySelectedViewAsync
foreach (var s in this.selectedViewSubViewSubscriptions) s.Dispose();
foreach (var s in this.selectedViewSubViewQuerySubscriptions) s.Dispose();
this.selectedViewSubViewSubscriptions.Clear();
this.selectedViewSubViewQuerySubscriptions.Clear();This will be superseded by the ViewPopulationViewModel refactor (#104), whose DisposeAsync will do this cleanup automatically.
3. Implement IAsyncDisposable on EntityBrowserWorkspaceTabViewModel
public ValueTask DisposeAsync()
{
this.rootSubscribedGet.Dispose();
foreach (var s in this.subscribedGetsByPath.Values) s.Dispose();
this.subscribedGetsByPath.Clear();
return ValueTask.CompletedTask;
}4. Fix early-exit in OpenEntityBrowserTabAsync
Check for an existing tab before calling SubscribeGetAsync:
if (this.FindExistingTab("entity-browser-tab") is { } existing)
{
this.ActivateTab(existing);
return;
}
var subscribedGet = await this.EntityBroker.SubscribeGetAsync(…);
…5. Replace SubscribeQueryAsync with GetEntitiesAsync in ViewHierarchyAssembler
If live updates are not needed at that call site, use the non-subscribing path to avoid adding broker entries at all.
Files
| File | Issue |
|---|---|
Phantom.Workspaces.Data.Core/EntityBroker.cs |
SubscribedGet/SubscribedQuery lack IDisposable; no explicit deregistration |
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs |
Lines 746–747 clear without dispose; DisposeAsync misses subscription lists |
Phantom.Workspaces/ViewModels/EntityBrowserWorkspaceTabViewModel.cs |
No IDisposable; subscriptions leak on tab close |
Phantom.Workspaces/ViewModels/ViewHierarchyAssembler.cs |
Line 182 creates transient SubscribedQuery and immediately drops reference |
Activity
JoshuaRowePhantom commentedon Jun 26, 2026
Missing GC-cleanup tests for
SubscribedGetandSubscribedQueryEntityBrokerTests.csalready has aRefreshAsync_SkipsCollectedSubscriptionstest (line 96) that proves collectedSubscribedEntityViewModelentries are dropped from the broker. The required pattern ([NoInlining]helper,ForceGarbageCollection,WeakReferenceliveness check) is already in place:There are no equivalent tests for
SubscribedGetorSubscribedQuery. The following tests must be added:RefreshAsync_SkipsCollectedSubscribedGetRefreshAsync_SkipsCollectedSubscribedQuerySame shape, using
SubscribeQueryAsyncandActiveSubscribedQueryCount.Expose internal count properties for testability
To make these tests precise (not just "does not throw"),
EntityBrokerneeds observable count properties. Add toEntityBroker.cs:These let tests assert compaction actually happened rather than only verifying "no exception".
Why
[NoInlining]is essentialWithout
[NoInlining]on the helper that creates the subscription, the JIT may keep the localsubvariable alive in a register across theGC.Collect()call in the caller, causingWeakReference.TryGetTargetto still returntrueand the test to give a false negative. The existingCreateCollectedEntityAsyncalready uses this pattern correctly.Note on the
IDisposablefix interactionOnce
IDisposableis added toSubscribedGet/SubscribedQuery(as described in the main bug), the preferred test becomes:Both the GC-path test and the explicit-dispose test should be present: the GC test documents the fallback safety net, the dispose test documents the expected fast path.
JoshuaRowePhantom commentedon Jun 26, 2026
I don't think Dispose is the way to go. I think just cleaning up collected subscriptions ought to be fine. If necessary, current users can null-out their references to subscriptions. I'd like duplicate subscriptions to be able to be reused, so disposing of them negates that.
JoshuaRowePhantom commentedon Jun 26, 2026
Understood — removing the
IDisposabledirection. Updated approach:No
IDisposableonSubscribedGet/SubscribedQuery. GC-based cleanup viaWeakReferenceremains the only removal mechanism. The fix at each leak site is to null out (or stop holding) the strong reference as soon as the subscription is no longer needed, so GC can collect it promptly:ApplySelectedViewAsync: instead of.Clear()on the subscription lists (which drops list references but the list itself is a GC root keeping objects alive), null out / replace individual field references or letViewPopulationViewModel(Bug: view entities double-populate when navigating away and back (concurrent ApplySelectedViewAsync) #104) go out of scope entirely when replaced.EntityBrowserWorkspaceTabViewModeltab close: setrootSubscribedGet = nulland clearsubscribedGetsByPathso no strong references remain.OpenEntityBrowserTabAsync: early-exit before callingSubscribeGetAsyncif a live tab already exists.Deduplication: if the same
GetRequestis re-requested and the existingSubscribedGetis still alive (viaWeakReference.TryGetTarget), return the existing instance rather than creating a new one. This prevents accumulation when callers re-subscribe to the same view repeatedly. Requires a stable key forGetRequest(equality or a canonical string) to look up the existing weak ref.Test plan stays the same but drops the explicit-dispose test. Only the GC-path tests remain:
RefreshAsync_SkipsCollectedSubscribedGetRefreshAsync_SkipsCollectedSubscribedQueryBoth using the
[NoInlining]+ForceGarbageCollection+WeakReferenceliveness pattern, assertingActiveSubscribedGetCount == 0after collection andRefreshAsync.JoshuaRowePhantom commentedon Jun 26, 2026
I don't think we need null-out subscription lists. Clearing out the list removes the references.
JoshuaRowePhantom commentedon Jun 26, 2026
EntityBrowserWorkspaceTabViewModel tab close - doesn't this eventually free up the EntityBrowse4rWorkspaceTabViewModel, too?
JoshuaRowePhantom commentedon Jun 26, 2026
The key for GetRequest, etc., can just be the serialized json content of the request.
JoshuaRowePhantom commentedon Jun 26, 2026
Fixed in commit 17e147d. Changes:
Fix #106: deduplicate SubscribedGet/Query with WeakReference dictiona…
JoshuaRowePhantom commentedon Jun 30, 2026
Verification passed
Checked: Commit 17e147d on the \eatures\ branch, files:
Result: Implementation found, all verification criteria satisfied, tests present.
What was verified
No disabled/skipped tests, no unresolved TODOs, no timing-dependent synchronisation found.