Skip to content

Commit cf078d7

Browse files
Re-acquire governed WebGL contexts on a fresh canvas, never restoreContext() (#503)
* 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. * Add news fragment for the governed-revive fix (#503) * 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. * 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.
1 parent 820666c commit cf078d7

6 files changed

Lines changed: 132 additions & 43 deletions

File tree

‎js/src/50_chartview.ts‎

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,10 +2008,8 @@ export class ChartView {
20082008
}, 0);
20092009
}
20102010
// A governed release whose re-acquire raced ahead of this event deferred
2011-
// its restoreContext() (see _recoverContext). Schedule the retry on the
2012-
// next task rather than calling it here: restoreContext() invoked
2013-
// synchronously inside the webglcontextlost dispatch is also ignored by
2014-
// Chromium — it must run after the loss event fully unwinds.
2011+
// its rebuild (see _recoverContext). Schedule the retry on the next task
2012+
// rather than rebuilding here, after the loss event fully unwinds.
20152013
if (governedRelease && this._ctxRecoverRequested && !this._destroyed && this._ctxVisible) {
20162014
this._ctxRecoverRequested = false;
20172015
setTimeout(() => {
@@ -2211,10 +2209,10 @@ export class ChartView {
22112209
this._ctxSnapshot = null;
22122210
}
22132211

2214-
// Re-acquire on scroll-into-view. Governed releases undo via
2215-
// restoreContext() -> the existing restored handler rebuilds; a real
2216-
// browser eviction cannot be force-restored, so the canvas is swapped for a
2217-
// fresh one and rebuilt from the retained spec + payload.
2212+
// Re-acquire on scroll-into-view / pointer entry. Governed releases and real
2213+
// browser evictions recover the same way: the canvas is swapped for a fresh
2214+
// one and rebuilt from the retained spec + payload (see below for why the
2215+
// released canvas is never restoreContext()ed).
22182216
_recoverContext() {
22192217
if (this._destroyed || !this._glLost) return;
22202218
if (this._glHost) {
@@ -2225,29 +2223,28 @@ export class ChartView {
22252223
return;
22262224
}
22272225
// Governed release, but its webglcontextlost event has not dispatched yet
2228-
// (scrolled back into view in the same task it was released). Chromium
2229-
// drops a restoreContext() issued before the loss event, stranding the
2230-
// context lost forever — so defer; the loss handler re-invokes us once the
2231-
// event lands (and restoreContext is then honored).
2226+
// (scrolled back into view in the same task it was released). The loss
2227+
// handler owns the loss bookkeeping (counter, seq bump, quiesce) and it is
2228+
// bound to the canvas being replaced — rebuilding first would strand that
2229+
// event on a canvas nobody listens to. Defer; the loss handler re-invokes
2230+
// us once the event lands.
22322231
if (this._ctxReleasedExt && this._ctxLostPending) {
22332232
this._ctxRecoverRequested = true;
22342233
return;
22352234
}
22362235
this._ctxRecoveries += 1;
22372236
if (this._ctxReleasedExt) {
2238-
const ext = this._ctxReleasedExt;
2237+
// Never restoreContext() the released canvas. Chromium (observed in 151)
2238+
// can bring that context back fully healthy — draws, readPixels and
2239+
// picking all succeed, the stamp reads "live" — while the compositor
2240+
// presents the canvas's frames as empty for good: a chart released while
2241+
// its tab sat hidden came back blank and no redraw could show it. Only a
2242+
// fresh canvas element presents again, so a governed release re-acquires
2243+
// exactly like a real eviction: cloned canvas + §18/§27 rebuild.
2244+
// Reserve first so the governor sheds an off-screen peer before this
2245+
// frame creates its replacement context.
22392246
this._ctxReleasedExt = null;
2240-
try {
2241-
// Reserve before asking the browser to restore. The restored event is
2242-
// asynchronous, so the pending reservation must count against later
2243-
// recoveries in the same IntersectionObserver delivery.
2244-
XY_CONTEXT_GOVERNOR.reserve(this);
2245-
ext.restoreContext(); // restored event -> full rebuild
2246-
return;
2247-
} catch (_err) {
2248-
XY_CONTEXT_GOVERNOR.cancel(this);
2249-
// Extension refused (context was also evicted for real): fall through.
2250-
}
2247+
XY_CONTEXT_GOVERNOR.reserve(this);
22512248
}
22522249
this._rebuildEvictedContext();
22532250
}
@@ -2363,10 +2360,18 @@ export class ChartView {
23632360
return; // context pressure persists; the next visibility pass retries
23642361
}
23652362
this._ctxRecoveryDelay = 0;
2363+
this._contextRestoreCount += 1;
2364+
this._contextRecoveryError = null;
23662365
this.canvas.dataset.xyCtx = "live";
23672366
XY_CONTEXT_GOVERNOR._announceLive(); // rebuilt on a fresh canvas; peers rebalance
23682367
this._scheduleViewRequest(this.view, { delay: 0 });
23692368
this._dropContextSnapshot();
2369+
// Same contract as the restored handler: a loss is answered by exactly one
2370+
// restore, whichever path (§28 telemetry stays consistent across both).
2371+
this._dispatchChartEvent("context_restored", {
2372+
loss_count: this._contextLossCount,
2373+
restore_count: this._contextRestoreCount,
2374+
});
23702375
}
23712376

23722377
// Visibility feed for the governor: tracks least-recently-visible order and

‎js/src/53_interaction.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Object.assign(ChartView.prototype, {
7070
lassoHandleDrag.moved = true;
7171
lassoHandleDrag.interactionId = ++this._interactionSeq;
7272
}
73-
const rect = c.getBoundingClientRect();
73+
const rect = this.canvas.getBoundingClientRect();
7474
const cssX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
7575
const cssY = Math.max(0, Math.min(rect.height, e.clientY - rect.top));
7676
this._lassoPolygon[lassoHandleDrag.index] = this._dataFromCanvas(cssX, cssY);
@@ -159,11 +159,11 @@ Object.assign(ChartView.prototype, {
159159
}
160160

161161
const dataAt = (clientX, clientY) => {
162-
const r = c.getBoundingClientRect();
162+
const r = this.canvas.getBoundingClientRect();
163163
return this._dataFromCanvas(clientX - r.left, clientY - r.top);
164164
};
165165
const lassoPointAt = (clientX, clientY) => {
166-
const r = c.getBoundingClientRect();
166+
const r = this.canvas.getBoundingClientRect();
167167
const cssX = Math.max(0, Math.min(r.width, clientX - r.left));
168168
const cssY = Math.max(0, Math.min(r.height, clientY - r.top));
169169
return {
@@ -414,7 +414,7 @@ Object.assign(ChartView.prototype, {
414414
if (!this._interactionFlag("wheel_zoom", true)) return;
415415
e.preventDefault();
416416
const f = Math.pow(1.0015, e.deltaY);
417-
const r = c.getBoundingClientRect();
417+
const r = this.canvas.getBoundingClientRect();
418418
const fx = (e.clientX - r.left) / r.width;
419419
const fy = 1 - (e.clientY - r.top) / r.height;
420420
this._queueWheelZoom(f, fx, fy);

‎news/503.bugfix.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Charts no longer come back blank after the shared WebGL context budget
2+
released them while their tab was hidden. A released chart used to revive with
3+
`restoreContext()` on the same canvas, and Chrome can restore that context
4+
fully healthy — draws, `readPixels`, and picking all succeed — while never
5+
presenting the canvas element again, so the data layer stayed invisible under
6+
intact axes until a page reload. Governed releases now re-acquire the way a
7+
real eviction does: on a fresh canvas element, rebuilt from the retained spec
8+
and payload.
9+
Gestures follow the chart onto that fresh canvas: wheel zoom, drag, and hover
10+
read their geometry from the live canvas rather than the one they were first
11+
bound to, which previously sent a zoom after any context rebuild to infinity.

‎spec/design-dossier.md‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -760,14 +760,21 @@ already reports an off-screen iframe's chart as not-intersecting (it clips to th
760760
top-level viewport), so the visibility signal is correct across the frame boundary; the
761761
budget accounting was the only gap.
762762

763-
Two subtleties the implementation must get right. **(1) Restore ordering.** A governed
764-
release is `WEBGL_lose_context.loseContext()`; re-acquire is `restoreContext()`. Chromium
765-
*silently drops* a `restoreContext()` issued before that context's `webglcontextlost`
766-
event has dispatched (or synchronously inside the dispatch), stranding the canvas lost
767-
forever — and a chart scrolled back into view in the same task it was shed hits exactly
768-
that window. Recovery therefore defers until the loss event lands (`_ctxLostPending`)
769-
and retries on a fresh task; a released chart that never re-acquired on scroll-in was the
770-
first symptom. **(2) Incremental shedding.** Frames over budget release *one* off-screen
763+
Two subtleties the implementation must get right. **(1) Re-acquire on a fresh canvas,
764+
after the loss event.** A governed release is `WEBGL_lose_context.loseContext()`; re-acquire
765+
is *never* `restoreContext()` on that canvas. Chromium (observed in 151) can restore the
766+
context fully healthy — draws, `readPixels` and picking all succeed, the stamp reads
767+
`live` — while the compositor presents that canvas element's frames as empty for good: a
768+
chart released by a peer tab while its own tab sat hidden came back blank, and no redraw
769+
(hover, resize, backing-store reset, a second lose/restore cycle) could show it. Only a
770+
fresh canvas element presents again, so a governed release re-acquires exactly like a real
771+
eviction: cloned canvas plus the §18/§27 rebuild (`_rebuildEvictedContext`), reserving
772+
with the governor first so an off-screen peer is shed before the replacement context is
773+
created. The rebuild still waits for the release's `webglcontextlost` event to land
774+
(`_ctxLostPending`, retried on a fresh task): the loss bookkeeping is bound to the canvas
775+
being replaced, and a chart scrolled back into view in the same task it was shed would
776+
otherwise strand that event. Either path answers a loss with exactly one
777+
`context_restored`. **(2) Incremental shedding.** Frames over budget release *one* off-screen
771778
view per event-loop turn, not the whole computed excess: several frames observing the
772779
same over-budget snapshot would each drop the full deficit and collectively over-release,
773780
so each sheds one, announces, and re-evaluates against the fresher count — converging on

‎tests/test_benchmark_environment.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,9 @@ def test_dashboard_benchmark_reports_eviction_and_scroll_telemetry() -> None:
271271

272272

273273
def test_context_governor_reserves_pending_restores() -> None:
274-
"""Concurrent visibility callbacks must count restores before their
275-
asynchronous ``webglcontextrestored`` events acquire the contexts."""
274+
"""A governed re-acquire reserves with the governor before rebuilding on a
275+
fresh canvas (never ``restoreContext()`` on the released one, §18), so an
276+
off-screen peer is shed before the replacement context is created."""
276277
client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8")
277278

278279
assert "view._ctxPendingReservation" in client
@@ -282,8 +283,9 @@ def test_context_governor_reserves_pending_restores() -> None:
282283
assert "WebGL2 unavailable in this browser" not in init_gl
283284
recover = client.index(" _recoverContext() {")
284285
reserve = client.index("XY_CONTEXT_GOVERNOR.reserve(this);", recover)
285-
restore = client.index("ext.restoreContext();", recover)
286-
assert reserve < restore
286+
rebuild = client.index("this._rebuildEvictedContext();", recover)
287+
assert reserve < rebuild
288+
assert ".restoreContext(" not in client[recover:rebuild]
287289

288290
snapshot = client[client.index("_snapshotBeforeRelease()") : recover]
289291
draw = snapshot.index("this._drawNow();")

‎tests/test_shared_glhost.py‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import base64
1111
import json
12+
import math
1213
import re
1314
from pathlib import Path
1415

@@ -1840,6 +1841,25 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No
18401841
const equilibriumSum = lossSum();
18411842
const revived = views.find((view) => view._glLost);
18421843
const revivedIndex = views.indexOf(revived);
1844+
const revivedCanvasBefore = revived.canvas;
1845+
// A governed re-acquire must never restoreContext() the released canvas
1846+
// (Chromium can present it blank for good, §18): count every call.
1847+
let restoreContextCalls = 0;
1848+
const countRestores = (ext) => {
1849+
if (!ext || ext.__xyCounted) return ext;
1850+
const restore = ext.restoreContext.bind(ext);
1851+
ext.restoreContext = () => { restoreContextCalls += 1; restore(); };
1852+
ext.__xyCounted = true;
1853+
return ext;
1854+
};
1855+
// The extension cached at release time is the one a same-canvas
1856+
// restore would use; extensions fetched later go through the prototype.
1857+
const revivedExtCounted = !!countRestores(revived._ctxReleasedExt);
1858+
const getExtension = WebGL2RenderingContext.prototype.getExtension;
1859+
WebGL2RenderingContext.prototype.getExtension = function (name) {
1860+
const ext = getExtension.call(this, name);
1861+
return name === "WEBGL_lose_context" ? countRestores(ext) : ext;
1862+
};
18431863
revived._recoverContext();
18441864
await settleOn(
18451865
"budget rotation after revival",
@@ -1848,6 +1868,13 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No
18481868
const afterRevival = {
18491869
revivedIndex,
18501870
revivedStamp: revived.canvas.dataset.xyCtx,
1871+
revivedFreshCanvas:
1872+
revived.canvas !== revivedCanvasBefore &&
1873+
revived.canvas.isConnected &&
1874+
!revivedCanvasBefore.isConnected &&
1875+
revived.gl.canvas === revived.canvas,
1876+
restoreContextCalls,
1877+
revivedExtCounted,
18511878
stamps: views.map((view) => view.canvas.dataset.xyCtx),
18521879
lossCounts: views.map((view) => view._contextLossCount),
18531880
restoreCounts: views.map((view) => view._contextRestoreCount),
@@ -1861,6 +1888,28 @@ def test_mixed_size_presentation_at_device_pixel_ratio_two(tmp_path: Path) -> No
18611888
},
18621889
zoomedRestoreCount: zoomed._contextRestoreCount,
18631890
};
1891+
// Gestures must follow the view onto its fresh canvas: the interaction
1892+
// handlers move with it, and their geometry must read the live canvas,
1893+
// not the detached one they were bound to (wheel zoom went to
1894+
// +-Infinity off a zero rect after a rebuild).
1895+
const wheelBefore = [...revived._axisRange("x")];
1896+
const revivedRect = revived.canvas.getBoundingClientRect();
1897+
revived.canvas.dispatchEvent(new WheelEvent("wheel", {
1898+
deltaY: -600,
1899+
clientX: revivedRect.left + revivedRect.width / 2,
1900+
clientY: revivedRect.top + revivedRect.height / 2,
1901+
bubbles: true,
1902+
cancelable: true,
1903+
}));
1904+
await settleOn(
1905+
"wheel zoom after revival",
1906+
() => {
1907+
const [x0, x1] = revived._axisRange("x");
1908+
return Number.isFinite(x0) && Number.isFinite(x1) &&
1909+
x1 - x0 < wheelBefore[1] - wheelBefore[0];
1910+
},
1911+
);
1912+
afterRevival.wheelZoom = { before: wheelBefore, after: [...revived._axisRange("x")] };
18641913
document.body.setAttribute("data-xy-governed-fallback-probe", JSON.stringify({
18651914
afterCreation,
18661915
registryHostExists,
@@ -1924,10 +1973,12 @@ def assert_governed_equilibrium(state: dict) -> None:
19241973
assert state["snapshots"] == 2, result
19251974

19261975
creation = result["afterCreation"]
1927-
# Opting out really is per-chart native WebGL: one context per canvas, no
1928-
# shared host anywhere, and every view registered with the governor.
1976+
# Opting out really is per-chart native WebGL: every acquisition is its own
1977+
# context (no shared host anywhere — a governed revival rebuilds on a fresh
1978+
# canvas, so the cascade mints more than the four initial contexts), and
1979+
# every view is registered with the governor.
19291980
assert creation["webgl2Acquisitions"] >= 4, result
1930-
assert creation["uniqueWebgl2Contexts"] == 4, result
1981+
assert creation["uniqueWebgl2Contexts"] == creation["webgl2Acquisitions"], result
19311982
assert creation["hostless"] == [True] * 4, result
19321983
assert creation["governorRegistered"] == [True] * 4, result
19331984
assert creation["glHostMarkers"] == [None] * 4, result
@@ -1944,6 +1995,19 @@ def assert_governed_equilibrium(state: dict) -> None:
19441995
assert_governed_equilibrium(revival)
19451996
assert revival["stamps"][revival["revivedIndex"]] == "live", result
19461997
assert revival["revivedStamp"] == "live", result
1998+
# The released canvas is retired, never restoreContext()ed: Chromium can
1999+
# bring a restored context back healthy yet present that canvas blank for
2000+
# good (a chart released while its tab sat hidden came back empty). Only
2001+
# a fresh canvas element presents again (§18).
2002+
assert revival["revivedFreshCanvas"] is True, result
2003+
assert revival["revivedExtCounted"] is True, result
2004+
assert revival["restoreContextCalls"] == 0, result
2005+
# Wheel zoom on the fresh canvas still zooms about the pointer: finite,
2006+
# narrower than before, inside the previous range.
2007+
before, after = revival["wheelZoom"]["before"], revival["wheelZoom"]["after"]
2008+
assert all(math.isfinite(v) for v in after), result
2009+
assert before[0] <= after[0] < after[1] <= before[1], result
2010+
assert after[1] - after[0] < before[1] - before[0], result
19472011
assert revival["liveCount"] == 2, result
19482012
assert sum(revival["lossCounts"]) > sum(creation["lossCounts"]), result
19492013
assert revival["revivedLit"] > 0, result

0 commit comments

Comments
 (0)