Repository navigation
Bug: ViewHierarchyAssembler.QueryParticipantsAsync subscribes but only snapshots — hierarchy is static and leaks subscriptions #108
Description
Summary
ViewHierarchyAssembler.QueryParticipantsAsync (ViewHierarchyAssembler.cs line 145) calls entityBroker.SubscribeQueryAsync to find relationship participants, then immediately takes a snapshot of subscription.Results.ToList() and discards the subscription reference. The hierarchy is therefore static (it does not update when relationships change) and the SubscribedQuery is leaked into the broker (see #106 — the broker holds a WeakReference that keeps the subscription alive until GC).
The existing TODO comment on line 142 documents this:
This method creates a subscription but only takes a snapshot, making hierarchy static. To make hierarchies dynamic: maintain all subscriptions, observe Results.CollectionChanged, rebuild hierarchy when any subscription changes, dispose all on view disposal.
Root cause
// ViewHierarchyAssembler.cs line 182–183
var subscription = await this.entityBroker.SubscribeQueryAsync(query, cancellationToken);
return subscription.Results.ToList(); // snapshot only; subscription goes out of scopeViewHierarchyAssembler is stateless and creates a new instance per AssembleAsync call. It has no mechanism to:
- Retain the
SubscribedQueryreferences across calls. - Observe
Results.CollectionChangedto detect when relationships are added/removed. - Dispose subscriptions when the view is torn down.
Impact
- Stale hierarchy: adding or removing a relationship that would change a task''s parent or membership does not update the displayed hierarchy until the view is manually refreshed.
- Subscription leak: every
AssembleAsynccall creates oneSubscribedQueryper traversal rule per root entity. These accumulate in the broker (Bug: EntityBroker subscriptions leak — SubscribedGet/SubscribedQuery have no disposal API, abandoned subscriptions accumulate and fire real network requests #106) until GC collects them.
Fix
Make ViewHierarchyAssembler stateful and observable:
-
Retain subscriptions: store all
SubscribedQueryinstances created during an assembly pass in a list owned by the caller (ViewPopulationViewModelfrom Bug: view entities double-populate when navigating away and back (concurrent ApplySelectedViewAsync) #104, or a dedicatedViewHierarchyState). -
Observe changes: subscribe to
Results.CollectionChangedon each retainedSubscribedQuery; on any change, trigger a re-assembly (debounced to avoid churn). -
Dispose on view change: when the view is replaced (e.g., via the
ViewPopulationViewModelswap from Bug: view entities double-populate when navigating away and back (concurrent ApplySelectedViewAsync) #104), dispose all retained subscriptions. SinceSubscribedGet/SubscribedQueryrely on GC for broker cleanup (Bug: EntityBroker subscriptions leak — SubscribedGet/SubscribedQuery have no disposal API, abandoned subscriptions accumulate and fire real network requests #106), "dispose" means null out the strong references so GC can collect them promptly. -
Alternatively: use
GetEntitiesAsync(non-subscribing) for the one-shot snapshot path, and add a separateSubscribeAsyncpath that the caller uses when it wants live updates. This avoids creating subscriptions that are immediately snapshotted.
Files
Phantom.Workspaces/ViewHierarchyAssembler.cs—QueryParticipantsAsync(lines 145–184)Phantom.Workspaces/ViewModels/MainWindowViewModel.cs—AddSubViewEntitiesWithHierarchyAsync(call site)
Activity
JoshuaRowePhantom commentedon Jun 26, 2026
Note that it doesn't need to dispose subscriptions.
JoshuaRowePhantom commentedon Jun 26, 2026
The right fix is #1 with #2.