Skip to content

Cell rebuilt while its grid is visible leaves the old plot subscribed to the layer pipe #1224

Description

@SimonHeybrock

What happens

When PlotGridTabs._poll_for_plot_updates rebuilds a cell whose grid is the session's visible tab, the replaced CellWidget's plot stays subscribed to the layer's hv.streams.Pipe. From then on every SessionLayer.update_pipe drives two renders of that layer per frame instead of one, for the rest of the session, and a further rebuild adds another.

CellWidget.dispose() (dashboard/widgets/cell.py:310) only disposes the autoscale controller; nothing severs the pn.pane.HoloViews it built around the layer's DynamicMap (cell.py:651). PlotGrid.insert_widget_at drops the previous occupant from _occupied_cells and writes the new widget into the GridSpec slot (plot_grid.py:467), which evidently does not run Panel's pane cleanup for the displaced object.

The leak is invisible while the grid is hidden, because a widget built for a hidden tab is never rendered and so registers no subscriber. That is why it does not show up on a normal page load: it needs a rebuild — a job restart, a plotter swap, a title change — landing while the grid is on screen.

Evidence

Dummy fixture, one session viewing the Detectors grid (6 layers, 20 pipe subscribers once settled), a second session parked on Workflows stopping one workflow to force a rebuild of a single one-layer cell in the viewed grid:

subscribers on the viewing session's pipes
settled, before the bump 20
after one cell (1 layer) rebuilt while visible 22
after two cells (4 layers) rebuilt while visible 32

Reproduces on main and on the branch of #1220 alike — it is independent of the hidden-grid rebuild gate. #1220 briefly made every reveal trigger it (40 subscribers instead of 20 on a three-cell grid, and the poll pass for that grid went from ~50 ms to ~100 ms, permanently); that was fixed there by not rebuilding the cell a second time, which sidesteps this bug rather than fixing it.

Why it matters

The doubled work is the expensive kind: ~85 % of a poll pass is SessionLayer.update_pipe (see #1198), so a leaked plot costs roughly what the live one costs, on the shared IOLoop, at the data rate. It accumulates monotonically over a session's lifetime — a control room tab left open across a shift collects one extra rendering per restart of every workflow it is watching — and nothing sweeps it, since the subscriber keeps the discarded pane alive.

Where to look

The sever belongs with the widget that created the pane: CellWidget.dispose() should tear down its HoloViews panes (Panel exposes _cleanup on the pane for exactly this), and PlotGridTabs._build_cell already calls dispose() on the replacement path, so the fix would cover both rebuild and removal. Worth checking at the same time whether PlotGrid.remove_widget_at leaves anything attached.

Two checks for a fix, both cheap to instrument in the poll pass: the subscriber count sum(len(sl.components.pipe.subscribers) for sl in self._session_layers.values() if sl.components) must not grow across rebuilds, and the count of hv.DynamicMap._execute_callback calls per pass must stay flat.

How I measured

Fake-backend dashboard (--transport fake) seeded from the committed dummy fixture, driven with two Playwright sessions via scripts/drive_dashboard.py; a sitecustomize.py on PYTHONPATH wrapped PlotGridTabs._poll_for_plot_updates to emit, per pass, the pipe-subscriber total, the number of hv.DynamicMap._execute_callback calls on the session thread, and the pass wall/CPU time. Happy to hand over the harness.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions