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.
What happens
When
PlotGridTabs._poll_for_plot_updatesrebuilds a cell whose grid is the session's visible tab, the replacedCellWidget's plot stays subscribed to the layer'shv.streams.Pipe. From then on everySessionLayer.update_pipedrives 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 thepn.pane.HoloViewsit built around the layer's DynamicMap (cell.py:651).PlotGrid.insert_widget_atdrops the previous occupant from_occupied_cellsand writes the new widget into theGridSpecslot (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:
Reproduces on
mainand 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_cleanupon the pane for exactly this), andPlotGridTabs._build_cellalready callsdispose()on the replacement path, so the fix would cover both rebuild and removal. Worth checking at the same time whetherPlotGrid.remove_widget_atleaves 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 ofhv.DynamicMap._execute_callbackcalls per pass must stay flat.How I measured
Fake-backend dashboard (
--transport fake) seeded from the committed dummy fixture, driven with two Playwright sessions viascripts/drive_dashboard.py; asitecustomize.pyonPYTHONPATHwrappedPlotGridTabs._poll_for_plot_updatesto emit, per pass, the pipe-subscriber total, the number ofhv.DynamicMap._execute_callbackcalls on the session thread, and the pass wall/CPU time. Happy to hand over the harness.