From 62c71d5ecc241219e7ef5da0de26259bd90f48e3 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:26:45 +0500 Subject: [PATCH 1/4] Re-acquire governed WebGL contexts on a fresh canvas, never restoreContext() A chart released by the cross-tab context governor while its tab sat hidden came back blank after revival: Chromium (observed in 151) restores the context fully healthy - draws, readPixels and picking all succeed and the stamp reads live - while the compositor presents that canvas element's frames as empty for good. No redraw could show it (hover, resize, backing-store reset, a second lose/restore cycle); only a fresh canvas element presents again. Governed releases now recover exactly like a real eviction: reserve with the governor, then the existing fresh-canvas rebuild. The rebuild path gains the restored handler's bookkeeping (restore count, context_restored event) so every loss is still answered by exactly one restore. The loss event deferral stays: the loss bookkeeping is bound to the canvas being replaced. --- js/src/50_chartview.ts | 53 ++++++++++++++++------------- spec/design-dossier.md | 23 ++++++++----- tests/test_benchmark_environment.py | 10 +++--- tests/test_shared_glhost.py | 34 ++++++++++++++++-- 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index e9ed6ee2..2c6df776 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2008,10 +2008,8 @@ export class ChartView { }, 0); } // A governed release whose re-acquire raced ahead of this event deferred - // its restoreContext() (see _recoverContext). Schedule the retry on the - // next task rather than calling it here: restoreContext() invoked - // synchronously inside the webglcontextlost dispatch is also ignored by - // Chromium — it must run after the loss event fully unwinds. + // its rebuild (see _recoverContext). Schedule the retry on the next task + // rather than rebuilding here, after the loss event fully unwinds. if (governedRelease && this._ctxRecoverRequested && !this._destroyed && this._ctxVisible) { this._ctxRecoverRequested = false; setTimeout(() => { @@ -2211,10 +2209,10 @@ export class ChartView { this._ctxSnapshot = null; } - // Re-acquire on scroll-into-view. Governed releases undo via - // restoreContext() -> the existing restored handler rebuilds; a real - // browser eviction cannot be force-restored, so the canvas is swapped for a - // fresh one and rebuilt from the retained spec + payload. + // Re-acquire on scroll-into-view / pointer entry. Governed releases and real + // browser evictions recover the same way: the canvas is swapped for a fresh + // one and rebuilt from the retained spec + payload (see below for why the + // released canvas is never restoreContext()ed). _recoverContext() { if (this._destroyed || !this._glLost) return; if (this._glHost) { @@ -2225,29 +2223,28 @@ export class ChartView { return; } // Governed release, but its webglcontextlost event has not dispatched yet - // (scrolled back into view in the same task it was released). Chromium - // drops a restoreContext() issued before the loss event, stranding the - // context lost forever — so defer; the loss handler re-invokes us once the - // event lands (and restoreContext is then honored). + // (scrolled back into view in the same task it was released). The loss + // handler owns the loss bookkeeping (counter, seq bump, quiesce) and it is + // bound to the canvas being replaced — rebuilding first would strand that + // event on a canvas nobody listens to. Defer; the loss handler re-invokes + // us once the event lands. if (this._ctxReleasedExt && this._ctxLostPending) { this._ctxRecoverRequested = true; return; } this._ctxRecoveries += 1; if (this._ctxReleasedExt) { - const ext = this._ctxReleasedExt; + // Never restoreContext() the released canvas. Chromium (observed in 151) + // can bring that context back fully healthy — draws, readPixels and + // picking all succeed, the stamp reads "live" — while the compositor + // presents the canvas's frames as empty for good: a chart released while + // its tab sat hidden came back blank and no redraw could show it. Only a + // fresh canvas element presents again, so a governed release re-acquires + // exactly like a real eviction: cloned canvas + §18/§27 rebuild. + // Reserve first so the governor sheds an off-screen peer before this + // frame creates its replacement context. this._ctxReleasedExt = null; - try { - // Reserve before asking the browser to restore. The restored event is - // asynchronous, so the pending reservation must count against later - // recoveries in the same IntersectionObserver delivery. - XY_CONTEXT_GOVERNOR.reserve(this); - ext.restoreContext(); // restored event -> full rebuild - return; - } catch (_err) { - XY_CONTEXT_GOVERNOR.cancel(this); - // Extension refused (context was also evicted for real): fall through. - } + XY_CONTEXT_GOVERNOR.reserve(this); } this._rebuildEvictedContext(); } @@ -2363,10 +2360,18 @@ export class ChartView { return; // context pressure persists; the next visibility pass retries } this._ctxRecoveryDelay = 0; + this._contextRestoreCount += 1; + this._contextRecoveryError = null; this.canvas.dataset.xyCtx = "live"; XY_CONTEXT_GOVERNOR._announceLive(); // rebuilt on a fresh canvas; peers rebalance this._scheduleViewRequest(this.view, { delay: 0 }); this._dropContextSnapshot(); + // Same contract as the restored handler: a loss is answered by exactly one + // restore, whichever path (§28 telemetry stays consistent across both). + this._dispatchChartEvent("context_restored", { + loss_count: this._contextLossCount, + restore_count: this._contextRestoreCount, + }); } // Visibility feed for the governor: tracks least-recently-visible order and diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 440f80db..6e006814 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -760,14 +760,21 @@ already reports an off-screen iframe's chart as not-intersecting (it clips to th top-level viewport), so the visibility signal is correct across the frame boundary; the budget accounting was the only gap. -Two subtleties the implementation must get right. **(1) Restore ordering.** A governed -release is `WEBGL_lose_context.loseContext()`; re-acquire is `restoreContext()`. Chromium -*silently drops* a `restoreContext()` issued before that context's `webglcontextlost` -event has dispatched (or synchronously inside the dispatch), stranding the canvas lost -forever — and a chart scrolled back into view in the same task it was shed hits exactly -that window. Recovery therefore defers until the loss event lands (`_ctxLostPending`) -and retries on a fresh task; a released chart that never re-acquired on scroll-in was the -first symptom. **(2) Incremental shedding.** Frames over budget release *one* off-screen +Two subtleties the implementation must get right. **(1) Re-acquire on a fresh canvas, +after the loss event.** A governed release is `WEBGL_lose_context.loseContext()`; re-acquire +is *never* `restoreContext()` on that canvas. Chromium (observed in 151) can restore the +context fully healthy — draws, `readPixels` and picking all succeed, the stamp reads +`live` — while the compositor presents that canvas element's frames as empty for good: a +chart released by a peer tab while its own tab sat hidden came back blank, and no redraw +(hover, resize, backing-store reset, a second lose/restore cycle) could show it. Only a +fresh canvas element presents again, so a governed release re-acquires exactly like a real +eviction: cloned canvas plus the §18/§27 rebuild (`_rebuildEvictedContext`), reserving +with the governor first so an off-screen peer is shed before the replacement context is +created. The rebuild still waits for the release's `webglcontextlost` event to land +(`_ctxLostPending`, retried on a fresh task): the loss bookkeeping is bound to the canvas +being replaced, and a chart scrolled back into view in the same task it was shed would +otherwise strand that event. Either path answers a loss with exactly one +`context_restored`. **(2) Incremental shedding.** Frames over budget release *one* off-screen view per event-loop turn, not the whole computed excess: several frames observing the same over-budget snapshot would each drop the full deficit and collectively over-release, so each sheds one, announces, and re-evaluates against the fresher count — converging on diff --git a/tests/test_benchmark_environment.py b/tests/test_benchmark_environment.py index 45482892..ff03dbe1 100644 --- a/tests/test_benchmark_environment.py +++ b/tests/test_benchmark_environment.py @@ -271,8 +271,9 @@ def test_dashboard_benchmark_reports_eviction_and_scroll_telemetry() -> None: def test_context_governor_reserves_pending_restores() -> None: - """Concurrent visibility callbacks must count restores before their - asynchronous ``webglcontextrestored`` events acquire the contexts.""" + """A governed re-acquire reserves with the governor before rebuilding on a + fresh canvas (never ``restoreContext()`` on the released one, §18), so an + off-screen peer is shed before the replacement context is created.""" client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") assert "view._ctxPendingReservation" in client @@ -282,8 +283,9 @@ def test_context_governor_reserves_pending_restores() -> None: assert "WebGL2 unavailable in this browser" not in init_gl recover = client.index(" _recoverContext() {") reserve = client.index("XY_CONTEXT_GOVERNOR.reserve(this);", recover) - restore = client.index("ext.restoreContext();", recover) - assert reserve < restore + rebuild = client.index("this._rebuildEvictedContext();", recover) + assert reserve < rebuild + assert ".restoreContext(" not in client[recover:rebuild] snapshot = client[client.index("_snapshotBeforeRelease()") : recover] draw = snapshot.index("this._drawNow();") diff --git a/tests/test_shared_glhost.py b/tests/test_shared_glhost.py index 39857fd3..64dc6f82 100644 --- a/tests/test_shared_glhost.py +++ b/tests/test_shared_glhost.py @@ -1840,6 +1840,20 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No const equilibriumSum = lossSum(); const revived = views.find((view) => view._glLost); const revivedIndex = views.indexOf(revived); + const revivedCanvasBefore = revived.canvas; + // A governed re-acquire must never restoreContext() the released canvas + // (Chromium can present it blank for good, §18): count every call. + let restoreContextCalls = 0; + const getExtension = WebGL2RenderingContext.prototype.getExtension; + WebGL2RenderingContext.prototype.getExtension = function (name) { + const ext = getExtension.call(this, name); + if (name === "WEBGL_lose_context" && ext && !ext.__xyCounted) { + const restore = ext.restoreContext.bind(ext); + ext.restoreContext = () => { restoreContextCalls += 1; restore(); }; + ext.__xyCounted = true; + } + return ext; + }; revived._recoverContext(); await settleOn( "budget rotation after revival", @@ -1848,6 +1862,12 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No const afterRevival = { revivedIndex, revivedStamp: revived.canvas.dataset.xyCtx, + revivedFreshCanvas: + revived.canvas !== revivedCanvasBefore && + revived.canvas.isConnected && + !revivedCanvasBefore.isConnected && + revived.gl.canvas === revived.canvas, + restoreContextCalls, stamps: views.map((view) => view.canvas.dataset.xyCtx), lossCounts: views.map((view) => view._contextLossCount), restoreCounts: views.map((view) => view._contextRestoreCount), @@ -1924,10 +1944,12 @@ def assert_governed_equilibrium(state: dict) -> None: assert state["snapshots"] == 2, result creation = result["afterCreation"] - # Opting out really is per-chart native WebGL: one context per canvas, no - # shared host anywhere, and every view registered with the governor. + # Opting out really is per-chart native WebGL: every acquisition is its own + # context (no shared host anywhere — a governed revival rebuilds on a fresh + # canvas, so the cascade mints more than the four initial contexts), and + # every view is registered with the governor. assert creation["webgl2Acquisitions"] >= 4, result - assert creation["uniqueWebgl2Contexts"] == 4, result + assert creation["uniqueWebgl2Contexts"] == creation["webgl2Acquisitions"], result assert creation["hostless"] == [True] * 4, result assert creation["governorRegistered"] == [True] * 4, result assert creation["glHostMarkers"] == [None] * 4, result @@ -1944,6 +1966,12 @@ def assert_governed_equilibrium(state: dict) -> None: assert_governed_equilibrium(revival) assert revival["stamps"][revival["revivedIndex"]] == "live", result assert revival["revivedStamp"] == "live", result + # The released canvas is retired, never restoreContext()ed: Chromium can + # bring a restored context back healthy yet present that canvas blank for + # good (a chart released while its tab sat hidden came back empty). Only + # a fresh canvas element presents again (§18). + assert revival["revivedFreshCanvas"] is True, result + assert revival["restoreContextCalls"] == 0, result assert revival["liveCount"] == 2, result assert sum(revival["lossCounts"]) > sum(creation["lossCounts"]), result assert revival["revivedLit"] > 0, result From 33cb7a6f8a437c5ffb5fb0fdda571cc928e4b60f Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:34:26 +0500 Subject: [PATCH 2/4] Add news fragment for the governed-revive fix (#503) --- news/503.bugfix.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 news/503.bugfix.md diff --git a/news/503.bugfix.md b/news/503.bugfix.md new file mode 100644 index 00000000..ace56f68 --- /dev/null +++ b/news/503.bugfix.md @@ -0,0 +1,8 @@ +Charts no longer come back blank after the shared WebGL context budget +released them while their tab was hidden. A released chart used to revive with +`restoreContext()` on the same canvas, and Chrome can restore that context +fully healthy — draws, `readPixels`, and picking all succeed — while never +presenting the canvas element again, so the data layer stayed invisible under +intact axes until a page reload. Governed releases now re-acquire the way a +real eviction does: on a fresh canvas element, rebuilt from the retained spec +and payload. From e43c09f19e872a3bf18ab83cd12a717bf39f0b05 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:42:07 +0500 Subject: [PATCH 3/4] Read gesture geometry from the live canvas after a context rebuild _initInteraction captured the canvas element once; after a fresh-canvas rebuild the handlers moved to the new canvas but still measured the detached one, so wheel zoom (and drag/hover math) worked off a zero rect and went to infinity. Governed revivals now rebuild on a fresh canvas on every release, which made this visible on the first scroll after a chart came back. The governed-native test now zooms the revived chart with a wheel event and requires a finite, narrower range. --- js/src/53_interaction.ts | 8 ++++---- news/503.bugfix.md | 3 +++ tests/test_shared_glhost.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/js/src/53_interaction.ts b/js/src/53_interaction.ts index 39f96b19..01c450fa 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -70,7 +70,7 @@ Object.assign(ChartView.prototype, { lassoHandleDrag.moved = true; lassoHandleDrag.interactionId = ++this._interactionSeq; } - const rect = c.getBoundingClientRect(); + const rect = this.canvas.getBoundingClientRect(); const cssX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); const cssY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); this._lassoPolygon[lassoHandleDrag.index] = this._dataFromCanvas(cssX, cssY); @@ -159,11 +159,11 @@ Object.assign(ChartView.prototype, { } const dataAt = (clientX, clientY) => { - const r = c.getBoundingClientRect(); + const r = this.canvas.getBoundingClientRect(); return this._dataFromCanvas(clientX - r.left, clientY - r.top); }; const lassoPointAt = (clientX, clientY) => { - const r = c.getBoundingClientRect(); + const r = this.canvas.getBoundingClientRect(); const cssX = Math.max(0, Math.min(r.width, clientX - r.left)); const cssY = Math.max(0, Math.min(r.height, clientY - r.top)); return { @@ -414,7 +414,7 @@ Object.assign(ChartView.prototype, { if (!this._interactionFlag("wheel_zoom", true)) return; e.preventDefault(); const f = Math.pow(1.0015, e.deltaY); - const r = c.getBoundingClientRect(); + const r = this.canvas.getBoundingClientRect(); const fx = (e.clientX - r.left) / r.width; const fy = 1 - (e.clientY - r.top) / r.height; this._queueWheelZoom(f, fx, fy); diff --git a/news/503.bugfix.md b/news/503.bugfix.md index ace56f68..bbd83577 100644 --- a/news/503.bugfix.md +++ b/news/503.bugfix.md @@ -6,3 +6,6 @@ presenting the canvas element again, so the data layer stayed invisible under intact axes until a page reload. Governed releases now re-acquire the way a real eviction does: on a fresh canvas element, rebuilt from the retained spec and payload. +Gestures follow the chart onto that fresh canvas: wheel zoom, drag, and hover +read their geometry from the live canvas rather than the one they were first +bound to, which previously sent a zoom after any context rebuild to infinity. diff --git a/tests/test_shared_glhost.py b/tests/test_shared_glhost.py index 64dc6f82..1e478b97 100644 --- a/tests/test_shared_glhost.py +++ b/tests/test_shared_glhost.py @@ -9,6 +9,7 @@ import base64 import json +import math import re from pathlib import Path @@ -1881,6 +1882,28 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No }, zoomedRestoreCount: zoomed._contextRestoreCount, }; + // Gestures must follow the view onto its fresh canvas: the interaction + // handlers move with it, and their geometry must read the live canvas, + // not the detached one they were bound to (wheel zoom went to + // +-Infinity off a zero rect after a rebuild). + const wheelBefore = [...revived._axisRange("x")]; + const revivedRect = revived.canvas.getBoundingClientRect(); + revived.canvas.dispatchEvent(new WheelEvent("wheel", { + deltaY: -600, + clientX: revivedRect.left + revivedRect.width / 2, + clientY: revivedRect.top + revivedRect.height / 2, + bubbles: true, + cancelable: true, + })); + await settleOn( + "wheel zoom after revival", + () => { + const [x0, x1] = revived._axisRange("x"); + return Number.isFinite(x0) && Number.isFinite(x1) && + x1 - x0 < wheelBefore[1] - wheelBefore[0]; + }, + ); + afterRevival.wheelZoom = { before: wheelBefore, after: [...revived._axisRange("x")] }; document.body.setAttribute("data-xy-governed-fallback-probe", JSON.stringify({ afterCreation, registryHostExists, @@ -1972,6 +1995,12 @@ def assert_governed_equilibrium(state: dict) -> None: # a fresh canvas element presents again (§18). assert revival["revivedFreshCanvas"] is True, result assert revival["restoreContextCalls"] == 0, result + # Wheel zoom on the fresh canvas still zooms about the pointer: finite, + # narrower than before, inside the previous range. + before, after = revival["wheelZoom"]["before"], revival["wheelZoom"]["after"] + assert all(math.isfinite(v) for v in after), result + assert before[0] <= after[0] < after[1] <= before[1], result + assert after[1] - after[0] < before[1] - before[0], result assert revival["liveCount"] == 2, result assert sum(revival["lossCounts"]) > sum(creation["lossCounts"]), result assert revival["revivedLit"] > 0, result From 5d11d9adb51b36007a90b3531f6b576cd5a759b3 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:45:51 +0500 Subject: [PATCH 4/4] Count restoreContext() on the extension cached at release time The governed-native test's spy wrapped only extensions fetched after it was installed; the released view's _ctxReleasedExt predates it, so a same-canvas restore would have gone uncounted. --- tests/test_shared_glhost.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_shared_glhost.py b/tests/test_shared_glhost.py index 1e478b97..8452f918 100644 --- a/tests/test_shared_glhost.py +++ b/tests/test_shared_glhost.py @@ -1845,15 +1845,20 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No // A governed re-acquire must never restoreContext() the released canvas // (Chromium can present it blank for good, §18): count every call. let restoreContextCalls = 0; + const countRestores = (ext) => { + if (!ext || ext.__xyCounted) return ext; + const restore = ext.restoreContext.bind(ext); + ext.restoreContext = () => { restoreContextCalls += 1; restore(); }; + ext.__xyCounted = true; + return ext; + }; + // The extension cached at release time is the one a same-canvas + // restore would use; extensions fetched later go through the prototype. + const revivedExtCounted = !!countRestores(revived._ctxReleasedExt); const getExtension = WebGL2RenderingContext.prototype.getExtension; WebGL2RenderingContext.prototype.getExtension = function (name) { const ext = getExtension.call(this, name); - if (name === "WEBGL_lose_context" && ext && !ext.__xyCounted) { - const restore = ext.restoreContext.bind(ext); - ext.restoreContext = () => { restoreContextCalls += 1; restore(); }; - ext.__xyCounted = true; - } - return ext; + return name === "WEBGL_lose_context" ? countRestores(ext) : ext; }; revived._recoverContext(); await settleOn( @@ -1869,6 +1874,7 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No !revivedCanvasBefore.isConnected && revived.gl.canvas === revived.canvas, restoreContextCalls, + revivedExtCounted, stamps: views.map((view) => view.canvas.dataset.xyCtx), lossCounts: views.map((view) => view._contextLossCount), restoreCounts: views.map((view) => view._contextRestoreCount), @@ -1994,6 +2000,7 @@ def assert_governed_equilibrium(state: dict) -> None: # good (a chart released while its tab sat hidden came back empty). Only # a fresh canvas element presents again (§18). assert revival["revivedFreshCanvas"] is True, result + assert revival["revivedExtCounted"] is True, result assert revival["restoreContextCalls"] == 0, result # Wheel zoom on the fresh canvas still zooms about the pointer: finite, # narrower than before, inside the previous range.