Skip to content

Client model graph grows on every plot-cell rebuild: two upstream BokehJS leaks #1155

Description

@SimonHeybrock

Every rebuild of a plot cell leaves the previous cell's entire Bokeh figure subtree alive in the browser's model graph. The server-side document is cleaned up correctly; the client is never told to drop the models.

Measurement

Fake backend, committed dummy fixture, one browser session on the Detectors tab. Six stop/start cycles of dummy/area_panel_xy (each generation change replaces the plotter, bumps the layer version and rebuilds the cell), counting the client's model graph after each cycle via Bokeh.documents[0]._all_models:

cycle 0  total  594   Figures 4
cycle 1  total  733   Figures 5
cycle 2  total  872   Figures 6
cycle 3  total 1011   Figures 7
cycle 4  total 1150   Figures 8
cycle 5  total 1289   Figures 9

+139 models and +1 Figure per rebuild, monotonic. Expected steady state for that fixture is 4 figures (3 cells, one with two layers).

The same probe also shows stale stopped freshness pills lingering: after starting the jobs, the client still holds the 7 pill models from the pre-start widgets alongside the 3 new ones. A page reload leaves only the 3.

The server side is fine

PlotGrid._insert_widget_into_grid deletes the target region before assigning, and Panel's GridSpec._get_objects calls old._cleanup(root) for replaced children. Forcing doc.models.recompute() right after each _build_cell and reading len(doc.models._models), the server document does not grow by a cell subtree per rebuild — it creeps by roughly one model per stop/start cycle, which is a much smaller, separate question.

So the server knows the old models are unreachable and the client does not.

Why this matters beyond memory

A detached figure is not inert. The cell's HoloViews hooks are attached to the session's DynamicMap, which the stale figure still belongs to, so the stale figure keeps being rendered and keeps invoking those hooks. That is what broke the autoscale toolbar: CellAutoscaleController installed its CustomAction tools once per controller, and whichever figure rendered first consumed the installation, leaving the figure the user actually sees with an empty toolbar (flaky, and the reason autoscale toggles disappeared after tab switching). Fixed separately by keying installation on the toolbar, but the leak is the underlying cause and will keep producing this class of symptom.

It is also a plausible contributor to long-session slowdown: every job restart adds a full figure to the graph the client recomputes.

Not yet diagnosed

BokehJS's _all_models is reachability-based, so monotonic growth implies the removal never reached the client rather than the client failing to prune. Unverified hypothesis: a structural removal performed inside doc.models.freeze() does not emit the removal half of the PATCH-DOC. Both the poll pass and _BatchedTabs._update_active (plot_grid_tabs.py) wrap structural changes in pn.io.hold() + doc.models.freeze(). Confirming this means reading Bokeh's patch serialisation, and if it holds it is likely an upstream issue rather than ours.

Reproducing

.claude/rules/dashboard-widgets.md ("Driving the dashboard with Playwright") plus scripts/drive_dashboard.py cover the launch/drive setup. The driver clicks .lt-wf-area_panel_xy.lt-tool-player-stop / .lt-tool-player-play per cycle, returns to the Detectors tab, and evaluates the model count in the page.

Related: #1154 (freshness pill blank in an already-open session), found in the same code path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dashboardPanel/Bokeh/HoloViews UI, plotting, frontend configbugperformanceLatency, throughput, CPU or memory costupstreamBlocked on Panel, Bokeh, HoloViews or another dependency

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions