Skip to content

Bug: ViewHierarchyAssembler.QueryParticipantsAsync subscribes but only snapshots — hierarchy is static and leaks subscriptions #108

Open

Description

@JoshuaRowePhantom

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 scope

ViewHierarchyAssembler is stateless and creates a new instance per AssembleAsync call. It has no mechanism to:

  1. Retain the SubscribedQuery references across calls.
  2. Observe Results.CollectionChanged to detect when relationships are added/removed.
  3. Dispose subscriptions when the view is torn down.

Impact

Fix

Make ViewHierarchyAssembler stateful and observable:

  1. Retain subscriptions: store all SubscribedQuery instances created during an assembly pass in a list owned by the caller (ViewPopulationViewModel from Bug: view entities double-populate when navigating away and back (concurrent ApplySelectedViewAsync) #104, or a dedicated ViewHierarchyState).

  2. Observe changes: subscribe to Results.CollectionChanged on each retained SubscribedQuery; on any change, trigger a re-assembly (debounced to avoid churn).

  3. Dispose on view change: when the view is replaced (e.g., via the ViewPopulationViewModel swap from Bug: view entities double-populate when navigating away and back (concurrent ApplySelectedViewAsync) #104), dispose all retained subscriptions. Since SubscribedGet/SubscribedQuery rely 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.

  4. Alternatively: use GetEntitiesAsync (non-subscribing) for the one-shot snapshot path, and add a separate SubscribeAsync path 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

  1. JoshuaRowePhantom commented on Jun 26, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Note that it doesn't need to dispose subscriptions.

  2. JoshuaRowePhantom commented on Jun 26, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    The right fix is #1 with #2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions