Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 29 additions & 24 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down Expand Up @@ -2363,10 +2360,18 @@ export class ChartView {
return; // context pressure persists; the next visibility pass retries
}
this._ctxRecoveryDelay = 0;
this._contextRestoreCount += 1;
Comment thread
FarhanAliRaza marked this conversation as resolved.
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,
});
Comment thread
FarhanAliRaza marked this conversation as resolved.
}

// Visibility feed for the governor: tracks least-recently-visible order and
Expand Down
8 changes: 4 additions & 4 deletions js/src/53_interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions news/503.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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.
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.
23 changes: 15 additions & 8 deletions spec/design-dossier.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions tests/test_benchmark_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();")
Expand Down
70 changes: 67 additions & 3 deletions tests/test_shared_glhost.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import base64
import json
import math
import re
from pathlib import Path

Expand Down Expand Up @@ -1840,6 +1841,25 @@ 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 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);
return name === "WEBGL_lose_context" ? countRestores(ext) : ext;
};
Comment thread
FarhanAliRaza marked this conversation as resolved.
revived._recoverContext();
await settleOn(
"budget rotation after revival",
Expand All @@ -1848,6 +1868,13 @@ 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,
revivedExtCounted,
stamps: views.map((view) => view.canvas.dataset.xyCtx),
lossCounts: views.map((view) => view._contextLossCount),
restoreCounts: views.map((view) => view._contextRestoreCount),
Expand All @@ -1861,6 +1888,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,
Expand Down Expand Up @@ -1924,10 +1973,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
Expand All @@ -1944,6 +1995,19 @@ 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["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.
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
Expand Down