diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index d3a78fd6..e0335d19 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -47,11 +47,34 @@ const UNITLESS_STYLE_PROPS = new Set([ // view ever releases, so pages with few charts behave exactly as before. // Every decision is observable (§28): `data-xy-ctx` on the canvas reads // "live" | "released" | "lost", and views count releases/recoveries. +// +// The browser cap is *process-wide* — shared across every same-origin iframe — +// but the machinery above is per-document, so it sees only its own charts. A +// page that puts each chart in its own iframe (docs sites, SaaS dashboards, +// the FastAPI gallery example) would therefore blow the cap: no per-document +// governor ever releases (each frame is under budget on its own), the browser +// LRU-evicts live charts, and the evicted charts fight to recover and re-evict +// — a scroll-driven "Too many active WebGL contexts" storm. The governor +// closes that gap by sharing one budget across same-origin frames over a +// BroadcastChannel (§18): each frame announces its live-context count, and any +// frame over the shared budget sheds its own *off-screen* views (never a +// visible one — a neighbor loading must not blank a chart the user is looking +// at). Cross-origin frames cannot share a channel and fall back to the +// per-document behavior. Over-counting a crashed frame that never said goodbye +// is safe: it only lowers the effective budget, releasing a few extra +// off-screen contexts that revive on demand — it never evicts or blanks. const XY_CONTEXT_GOVERNOR = { views: new Set(), seq: 1, hiddenReleaseChannel: null, hiddenReleaseQueue: [], + // Cross-frame coordination (initialized lazily on first register()). + frameId: null, + channel: null, + foreign: null, // Map reported by other same-origin frames + _announcedLive: -1, + _crossFrameReady: false, + _rebalanceScheduled: false, budget() { const v = typeof window !== "undefined" ? window.XY_CONTEXT_BUDGET : null; // 12 leaves headroom under Chrome's ~16 so host-page GL (maps, editors) @@ -59,11 +82,13 @@ const XY_CONTEXT_GOVERNOR = { return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; }, register(view) { + this._initCrossFrame(); this.views.add(view); }, unregister(view) { view._ctxPendingReservation = false; this.views.delete(view); + this._announceLive(); }, // Called before a view acquires (or re-acquires) a GL context. Releases // least-recently-visible off-screen views until the requester fits the @@ -103,10 +128,130 @@ const XY_CONTEXT_GOVERNOR = { }, acquired(requester) { requester._ctxPendingReservation = false; + // A context just came live. Shed our own off-screen views if that pushed + // the shared budget over, then tell peer frames the new count so theirs + // can shed too (the newly visible chart stays; off-screen ones give way). + this._rebalance(); + this._announceLive(); }, cancel(requester) { requester._ctxPendingReservation = false; }, + // --- Cross-frame budget sharing over BroadcastChannel (§18) --------------- + // Same-origin frames share one WebGL-context budget so a per-chart-iframe + // page cannot collectively exceed the browser's process-wide cap. Guarded + // and lazy: a lone top-level page opens a channel but never hears a peer, so + // foreignLive() stays 0 and every path below is a no-op — identical to the + // per-document behavior. Cross-origin frames get their own opaque channel + // scope (or none) and likewise fall back. + _initCrossFrame() { + if (this._crossFrameReady) return; + this._crossFrameReady = true; + this.foreign = new Map(); + if (typeof BroadcastChannel === "undefined") return; + try { + this.frameId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + this.channel = new BroadcastChannel("xy-webgl-context-governor"); + this.channel.onmessage = (event) => this._onForeignMessage(event.data); + // Announce arrival so already-open frames re-advertise their counts, and + // drop our contribution from theirs when we go away. + this._post({ t: "hello", id: this.frameId }); + if (typeof window !== "undefined" && window.addEventListener) { + // `pagehide` fires on real unload AND when the document is frozen into + // the back/forward cache; either way peers should stop counting us (a + // frozen frame can't respond to shed requests). `pageshow` with + // persisted=true is a bfcache restore: re-announce so peers add us back + // — without it a restored frame stays absent from the shared budget and + // the page can silently exceed the browser cap. + window.addEventListener("pagehide", () => this._post({ t: "bye", id: this.frameId })); + window.addEventListener("pageshow", (event) => { + if (!event || !event.persisted) return; + // Peers may have come and gone while we were frozen, and a departed + // peer sent its `bye` to a channel we could not hear. Drop the stale + // map and rebuild it from live peers' replies to our `hello` rather + // than counting contexts that no longer exist. + this.foreign.clear(); + this._announcedLive = -1; // force the announcement below to re-send + this._post({ t: "hello", id: this.frameId }); // relearn peers' counts + this._announceLive(true); // and re-advertise ours + }); + } + } catch (_err) { + this.channel = null; // sandboxed context: stay per-document + } + }, + _post(msg) { + try { + if (this.channel) this.channel.postMessage(msg); + } catch (_err) { + /* channel closed mid-teardown */ + } + }, + _onForeignMessage(msg) { + if (!msg || !this.foreign || msg.id === this.frameId) return; + if (msg.t === "live") { + this.foreign.set(msg.id, msg.n | 0); + this._rebalance(); + } else if (msg.t === "hello") { + // A frame joined: re-advertise so it learns our current count. + this._announceLive(true); + } else if (msg.t === "bye") { + this.foreign.delete(msg.id); + } + }, + localLive() { + let n = 0; + for (const view of this.views) { + if (view.gl && !view._glLost && !view._destroyed) n += 1; + } + return n; + }, + foreignLive() { + let n = 0; + if (this.foreign) for (const count of this.foreign.values()) n += count; + return n; + }, + // Broadcast this frame's live-context count when it changes (deduped so a + // burst of releases collapses to one message). `force` re-sends the current + // count in reply to a peer's hello even when it is unchanged. + _announceLive(force) { + if (!this.channel) return; + const n = this.localLive(); + if (!force && n === this._announcedLive) return; + this._announcedLive = n; + this._post({ t: "live", id: this.frameId, n }); + }, + // Shared budget crossed (a peer announced, we acquired, or one of our charts + // scrolled off): release the single least-recently-visible *off-screen* view. + // Visible views are never released here — the shared cap can only be honored + // by dropping off-screen contexts, and blanking a chart the user is looking + // at because a sibling frame loaded is worse than the documented + // >budget-simultaneously-visible limit. + // + // One release per call, not the whole computed excess: several frames all see + // the same over-budget snapshot at once, and if each dropped the full deficit + // they would collectively over-release (N frames each shedding K → N×K gone). + // Shedding one and re-arming on a task lets every frame's release announce and + // be observed before the next round, so the page converges on the budget + // instead of overshooting it. Re-arming (rather than stopping) also means a + // frame that must shed several never under-releases when peers are quiet. + _rebalance() { + if (this.localLive() + this.foreignLive() - this.budget() <= 0) return; + let target = null; + for (const view of this.views) { + if (view.gl && !view._glLost && !view._destroyed && !view._ctxVisible) { + if (!target || (view._ctxSeenSeq || 0) < (target._ctxSeenSeq || 0)) target = view; + } + } + if (!target || !target._releaseContext()) return; + if (this.localLive() + this.foreignLive() - this.budget() > 0 && !this._rebalanceScheduled) { + this._rebalanceScheduled = true; + setTimeout(() => { + this._rebalanceScheduled = false; + this._rebalance(); + }, 0); + } + }, // Releasing a context takes a synchronous framebuffer readback. Queue one // chart per task when a document is hidden so visibilitychange itself stays // cheap and a many-chart page cannot monopolize the event-loop turn. @@ -242,6 +387,11 @@ class ChartView { this._ctxReleasedExt = null; this._ctxReleases = 0; this._ctxRecoveries = 0; + // A governed release's webglcontextlost event is dispatched a task later; + // restoreContext() called before it lands is silently dropped by Chromium, + // so recovery that races ahead is deferred until the loss handler fires. + this._ctxLostPending = false; + this._ctxRecoverRequested = false; this._ctxVisible = xyInitiallyVisible(el); XY_CONTEXT_GOVERNOR.register(this); if (this._ctxVisible) this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; @@ -834,9 +984,14 @@ class ChartView { // that first governed event; only ignore duplicate ungoverned losses. if (this._glLost && !governedRelease) return; this._glLost = true; + this._ctxLostPending = false; // the loss event has now dispatched // Governed releases already stamped "released"; anything else is a // browser-side eviction/driver reset (§28: the difference stays legible). if (!governedRelease) this.canvas.dataset.xyCtx = "lost"; + // Either way a live context just went away; let peer frames know the + // shared budget has room (a governed release already announced; this is + // deduped, and it is what tells peers about a browser-side eviction). + XY_CONTEXT_GOVERNOR._announceLive(); this._contextLossCount += 1; this._contextRecoveryError = null; this.root.dataset.xyContextState = "lost"; @@ -894,6 +1049,17 @@ 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. + if (governedRelease && this._ctxRecoverRequested && !this._destroyed && this._ctxVisible) { + this._ctxRecoverRequested = false; + setTimeout(() => { + if (!this._destroyed && this._glLost && this._ctxVisible) this._recoverContext(); + }, 0); + } }); this._listen(this.canvas, "webglcontextrestored", () => { // A failed recovery replaced the canvas with the error message; a later @@ -947,6 +1113,7 @@ class ChartView { this._ctxRecoveryDelay = 0; this.canvas.dataset.xyCtx = "live"; this.root.dataset.xyContextState = "ready"; + XY_CONTEXT_GOVERNOR._announceLive(); // context recovered; peers rebalance this._scheduleViewRequest(this.view, { delay: 0 }); this._dropContextSnapshot(); // live frame is back; retire the stand-in this._dispatchChartEvent("context_restored", { @@ -969,10 +1136,12 @@ class ChartView { this._ctxReleasedExt = ext; this._ctxReleases += 1; this._glLost = true; // synchronous: the lost *event* arrives as a task + this._ctxLostPending = true; // ...and restoreContext() must wait for it this.canvas.dataset.xyCtx = "released"; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; ext.loseContext(); + XY_CONTEXT_GOVERNOR._announceLive(); // one fewer live context on this frame return true; } @@ -1060,6 +1229,15 @@ class ChartView { // fresh one and rebuilt from the retained spec + payload. _recoverContext() { if (this._destroyed || !this._glLost) 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). + if (this._ctxReleasedExt && this._ctxLostPending) { + this._ctxRecoverRequested = true; + return; + } this._ctxRecoveries += 1; if (this._ctxReleasedExt) { const ext = this._ctxReleasedExt; @@ -1151,6 +1329,7 @@ class ChartView { } this._ctxRecoveryDelay = 0; this.canvas.dataset.xyCtx = "live"; + XY_CONTEXT_GOVERNOR._announceLive(); // rebuilt on a fresh canvas; peers rebalance this._scheduleViewRequest(this.view, { delay: 0 }); this._dropContextSnapshot(); } @@ -1202,6 +1381,11 @@ class ChartView { this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; if (this._glLost && !this._destroyed) this._recoverContext(); if (this._healStaleTheme()) this.draw(); + } else if (!this._destroyed) { + // Now off-screen and releasable: if a sibling frame has pushed the + // shared budget over, give this context back rather than waiting for + // the browser to evict some other frame's visible chart. + XY_CONTEXT_GOVERNOR._rebalance(); } }, { rootMargin: "25% 0px 25% 0px" }, diff --git a/python/xy/static/index.js b/python/xy/static/index.js index ff151808..98415ca5 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -1893,16 +1893,24 @@ views: new Set(), seq: 1, hiddenReleaseChannel: null, hiddenReleaseQueue: [], +frameId: null, +channel: null, +foreign: null, +_announcedLive: -1, +_crossFrameReady: false, +_rebalanceScheduled: false, budget() { const v = typeof window !== "undefined" ? window.XY_CONTEXT_BUDGET : null; return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; }, register(view) { +this._initCrossFrame(); this.views.add(view); }, unregister(view) { view._ctxPendingReservation = false; this.views.delete(view); +this._announceLive(); }, reserve(requester) { const live = []; @@ -1933,10 +1941,90 @@ if (view._releaseContext()) over -= 1; }, acquired(requester) { requester._ctxPendingReservation = false; +this._rebalance(); +this._announceLive(); }, cancel(requester) { requester._ctxPendingReservation = false; }, +_initCrossFrame() { +if (this._crossFrameReady) return; +this._crossFrameReady = true; +this.foreign = new Map(); +if (typeof BroadcastChannel === "undefined") return; +try { +this.frameId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +this.channel = new BroadcastChannel("xy-webgl-context-governor"); +this.channel.onmessage = (event) => this._onForeignMessage(event.data); +this._post({ t: "hello", id: this.frameId }); +if (typeof window !== "undefined" && window.addEventListener) { +window.addEventListener("pagehide", () => this._post({ t: "bye", id: this.frameId })); +window.addEventListener("pageshow", (event) => { +if (!event || !event.persisted) return; +this.foreign.clear(); +this._announcedLive = -1; +this._post({ t: "hello", id: this.frameId }); +this._announceLive(true); +}); +} +} catch (_err) { +this.channel = null; +} +}, +_post(msg) { +try { +if (this.channel) this.channel.postMessage(msg); +} catch (_err) { + +} +}, +_onForeignMessage(msg) { +if (!msg || !this.foreign || msg.id === this.frameId) return; +if (msg.t === "live") { +this.foreign.set(msg.id, msg.n | 0); +this._rebalance(); +} else if (msg.t === "hello") { +this._announceLive(true); +} else if (msg.t === "bye") { +this.foreign.delete(msg.id); +} +}, +localLive() { +let n = 0; +for (const view of this.views) { +if (view.gl && !view._glLost && !view._destroyed) n += 1; +} +return n; +}, +foreignLive() { +let n = 0; +if (this.foreign) for (const count of this.foreign.values()) n += count; +return n; +}, +_announceLive(force) { +if (!this.channel) return; +const n = this.localLive(); +if (!force && n === this._announcedLive) return; +this._announcedLive = n; +this._post({ t: "live", id: this.frameId, n }); +}, +_rebalance() { +if (this.localLive() + this.foreignLive() - this.budget() <= 0) return; +let target = null; +for (const view of this.views) { +if (view.gl && !view._glLost && !view._destroyed && !view._ctxVisible) { +if (!target || (view._ctxSeenSeq || 0) < (target._ctxSeenSeq || 0)) target = view; +} +} +if (!target || !target._releaseContext()) return; +if (this.localLive() + this.foreignLive() - this.budget() > 0 && !this._rebalanceScheduled) { +this._rebalanceScheduled = true; +setTimeout(() => { +this._rebalanceScheduled = false; +this._rebalance(); +}, 0); +} +}, scheduleHiddenReleases() { if (this.hiddenReleaseChannel !== null) return; this.hiddenReleaseQueue = Array.from(this.views); @@ -2044,6 +2132,8 @@ this._glLost = false; this._ctxReleasedExt = null; this._ctxReleases = 0; this._ctxRecoveries = 0; +this._ctxLostPending = false; +this._ctxRecoverRequested = false; this._ctxVisible = xyInitiallyVisible(el); XY_CONTEXT_GOVERNOR.register(this); if (this._ctxVisible) this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; @@ -2538,7 +2628,9 @@ if (this._destroyed) return; const governedRelease = this.canvas.dataset.xyCtx === "released"; if (this._glLost && !governedRelease) return; this._glLost = true; +this._ctxLostPending = false; if (!governedRelease) this.canvas.dataset.xyCtx = "lost"; +XY_CONTEXT_GOVERNOR._announceLive(); this._contextLossCount += 1; this._contextRecoveryError = null; this.root.dataset.xyContextState = "lost"; @@ -2585,6 +2677,12 @@ this._recoverContext(); } }, 0); } +if (governedRelease && this._ctxRecoverRequested && !this._destroyed && this._ctxVisible) { +this._ctxRecoverRequested = false; +setTimeout(() => { +if (!this._destroyed && this._glLost && this._ctxVisible) this._recoverContext(); +}, 0); +} }); this._listen(this.canvas, "webglcontextrestored", () => { if (this._destroyed || this._contextRecoveryError) return; @@ -2626,6 +2724,7 @@ this._contextRecoveryError = null; this._ctxRecoveryDelay = 0; this.canvas.dataset.xyCtx = "live"; this.root.dataset.xyContextState = "ready"; +XY_CONTEXT_GOVERNOR._announceLive(); this._scheduleViewRequest(this.view, { delay: 0 }); this._dropContextSnapshot(); this._dispatchChartEvent("context_restored", { @@ -2642,10 +2741,12 @@ this._snapshotBeforeRelease(); this._ctxReleasedExt = ext; this._ctxReleases += 1; this._glLost = true; +this._ctxLostPending = true; this.canvas.dataset.xyCtx = "released"; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; ext.loseContext(); +XY_CONTEXT_GOVERNOR._announceLive(); return true; } _snapshotBeforeRelease() { @@ -2707,6 +2808,10 @@ this._ctxSnapshot = null; } _recoverContext() { if (this._destroyed || !this._glLost) return; +if (this._ctxReleasedExt && this._ctxLostPending) { +this._ctxRecoverRequested = true; +return; +} this._ctxRecoveries += 1; if (this._ctxReleasedExt) { const ext = this._ctxReleasedExt; @@ -2777,6 +2882,7 @@ return; } this._ctxRecoveryDelay = 0; this.canvas.dataset.xyCtx = "live"; +XY_CONTEXT_GOVERNOR._announceLive(); this._scheduleViewRequest(this.view, { delay: 0 }); this._dropContextSnapshot(); } @@ -2813,6 +2919,8 @@ if (this._ctxVisible) { this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; if (this._glLost && !this._destroyed) this._recoverContext(); if (this._healStaleTheme()) this.draw(); +} else if (!this._destroyed) { +XY_CONTEXT_GOVERNOR._rebalance(); } }, { rootMargin: "25% 0px 25% 0px" }, diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 714686df..02203d84 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -1894,16 +1894,24 @@ views: new Set(), seq: 1, hiddenReleaseChannel: null, hiddenReleaseQueue: [], +frameId: null, +channel: null, +foreign: null, +_announcedLive: -1, +_crossFrameReady: false, +_rebalanceScheduled: false, budget() { const v = typeof window !== "undefined" ? window.XY_CONTEXT_BUDGET : null; return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; }, register(view) { +this._initCrossFrame(); this.views.add(view); }, unregister(view) { view._ctxPendingReservation = false; this.views.delete(view); +this._announceLive(); }, reserve(requester) { const live = []; @@ -1934,10 +1942,90 @@ if (view._releaseContext()) over -= 1; }, acquired(requester) { requester._ctxPendingReservation = false; +this._rebalance(); +this._announceLive(); }, cancel(requester) { requester._ctxPendingReservation = false; }, +_initCrossFrame() { +if (this._crossFrameReady) return; +this._crossFrameReady = true; +this.foreign = new Map(); +if (typeof BroadcastChannel === "undefined") return; +try { +this.frameId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +this.channel = new BroadcastChannel("xy-webgl-context-governor"); +this.channel.onmessage = (event) => this._onForeignMessage(event.data); +this._post({ t: "hello", id: this.frameId }); +if (typeof window !== "undefined" && window.addEventListener) { +window.addEventListener("pagehide", () => this._post({ t: "bye", id: this.frameId })); +window.addEventListener("pageshow", (event) => { +if (!event || !event.persisted) return; +this.foreign.clear(); +this._announcedLive = -1; +this._post({ t: "hello", id: this.frameId }); +this._announceLive(true); +}); +} +} catch (_err) { +this.channel = null; +} +}, +_post(msg) { +try { +if (this.channel) this.channel.postMessage(msg); +} catch (_err) { + +} +}, +_onForeignMessage(msg) { +if (!msg || !this.foreign || msg.id === this.frameId) return; +if (msg.t === "live") { +this.foreign.set(msg.id, msg.n | 0); +this._rebalance(); +} else if (msg.t === "hello") { +this._announceLive(true); +} else if (msg.t === "bye") { +this.foreign.delete(msg.id); +} +}, +localLive() { +let n = 0; +for (const view of this.views) { +if (view.gl && !view._glLost && !view._destroyed) n += 1; +} +return n; +}, +foreignLive() { +let n = 0; +if (this.foreign) for (const count of this.foreign.values()) n += count; +return n; +}, +_announceLive(force) { +if (!this.channel) return; +const n = this.localLive(); +if (!force && n === this._announcedLive) return; +this._announcedLive = n; +this._post({ t: "live", id: this.frameId, n }); +}, +_rebalance() { +if (this.localLive() + this.foreignLive() - this.budget() <= 0) return; +let target = null; +for (const view of this.views) { +if (view.gl && !view._glLost && !view._destroyed && !view._ctxVisible) { +if (!target || (view._ctxSeenSeq || 0) < (target._ctxSeenSeq || 0)) target = view; +} +} +if (!target || !target._releaseContext()) return; +if (this.localLive() + this.foreignLive() - this.budget() > 0 && !this._rebalanceScheduled) { +this._rebalanceScheduled = true; +setTimeout(() => { +this._rebalanceScheduled = false; +this._rebalance(); +}, 0); +} +}, scheduleHiddenReleases() { if (this.hiddenReleaseChannel !== null) return; this.hiddenReleaseQueue = Array.from(this.views); @@ -2045,6 +2133,8 @@ this._glLost = false; this._ctxReleasedExt = null; this._ctxReleases = 0; this._ctxRecoveries = 0; +this._ctxLostPending = false; +this._ctxRecoverRequested = false; this._ctxVisible = xyInitiallyVisible(el); XY_CONTEXT_GOVERNOR.register(this); if (this._ctxVisible) this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; @@ -2539,7 +2629,9 @@ if (this._destroyed) return; const governedRelease = this.canvas.dataset.xyCtx === "released"; if (this._glLost && !governedRelease) return; this._glLost = true; +this._ctxLostPending = false; if (!governedRelease) this.canvas.dataset.xyCtx = "lost"; +XY_CONTEXT_GOVERNOR._announceLive(); this._contextLossCount += 1; this._contextRecoveryError = null; this.root.dataset.xyContextState = "lost"; @@ -2586,6 +2678,12 @@ this._recoverContext(); } }, 0); } +if (governedRelease && this._ctxRecoverRequested && !this._destroyed && this._ctxVisible) { +this._ctxRecoverRequested = false; +setTimeout(() => { +if (!this._destroyed && this._glLost && this._ctxVisible) this._recoverContext(); +}, 0); +} }); this._listen(this.canvas, "webglcontextrestored", () => { if (this._destroyed || this._contextRecoveryError) return; @@ -2627,6 +2725,7 @@ this._contextRecoveryError = null; this._ctxRecoveryDelay = 0; this.canvas.dataset.xyCtx = "live"; this.root.dataset.xyContextState = "ready"; +XY_CONTEXT_GOVERNOR._announceLive(); this._scheduleViewRequest(this.view, { delay: 0 }); this._dropContextSnapshot(); this._dispatchChartEvent("context_restored", { @@ -2643,10 +2742,12 @@ this._snapshotBeforeRelease(); this._ctxReleasedExt = ext; this._ctxReleases += 1; this._glLost = true; +this._ctxLostPending = true; this.canvas.dataset.xyCtx = "released"; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; ext.loseContext(); +XY_CONTEXT_GOVERNOR._announceLive(); return true; } _snapshotBeforeRelease() { @@ -2708,6 +2809,10 @@ this._ctxSnapshot = null; } _recoverContext() { if (this._destroyed || !this._glLost) return; +if (this._ctxReleasedExt && this._ctxLostPending) { +this._ctxRecoverRequested = true; +return; +} this._ctxRecoveries += 1; if (this._ctxReleasedExt) { const ext = this._ctxReleasedExt; @@ -2778,6 +2883,7 @@ return; } this._ctxRecoveryDelay = 0; this.canvas.dataset.xyCtx = "live"; +XY_CONTEXT_GOVERNOR._announceLive(); this._scheduleViewRequest(this.view, { delay: 0 }); this._dropContextSnapshot(); } @@ -2814,6 +2920,8 @@ if (this._ctxVisible) { this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; if (this._glLost && !this._destroyed) this._recoverContext(); if (this._healStaleTheme()) this.draw(); +} else if (!this._destroyed) { +XY_CONTEXT_GOVERNOR._rebalance(); } }, { rootMargin: "25% 0px 25% 0px" }, diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 6c42e507..1bad0c0c 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -653,6 +653,47 @@ including the destroy+rebuild a full-payload republish performs — frees its sl immediately rather than leaving a destroyed context to linger until GC and count against the browser cap. +**The budget is shared across same-origin frames.** Chrome's cap is *process-wide* — +one budget for every iframe in the tab — but a per-document governor sees only its own +charts. A page that renders each chart in its own iframe (docs sites, SaaS dashboards, +and the `examples/fastapi` gallery, which needs iframes to host each standalone +`to_html` document) would otherwise defeat the governor entirely: no frame ever +releases (each is under budget alone), the browser LRU-evicts live charts, and the +evicted charts fight to recover and re-evict — a scroll-driven "Too many active WebGL +contexts" storm. The governor closes this by sharing one budget over a +`BroadcastChannel("xy-webgl-context-governor")`: each frame announces its live-context +count (`{t:"live", id, n}`, with `hello`/`bye` for join/leave), and any frame over the +shared budget sheds its own *off-screen* views — never a visible one, so a sibling +frame loading cannot blank a chart the user is looking at. `IntersectionObserver` +already reports an off-screen iframe's chart as not-intersecting (it clips to the +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 +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 +the budget instead of overshooting it (still safe either way; an off-screen over-release +just revives on demand). + +Coordination is otherwise best-effort and self-healing: `BroadcastChannel` delivery is +asynchronous, so a burst of charts constructed in one synchronous tick across many frames +can briefly overshoot before the first `live` messages arrive (a handful of transient +evictions that recover); a frame frozen into the back/forward cache says `bye` on +`pagehide` and re-announces on `pageshow` (`persisted`) so peers neither count a frozen +frame nor omit a restored one; and a frame that crashes without a `bye` only lowers the +effective budget (a few extra off-screen releases, revived on demand) — it never blanks a +visible chart or evicts. Cross-origin and `sandbox`-without-`allow-same-origin` frames +(e.g. the notebook `_repr_html_` frame) get an isolated channel scope and fall back to +per-document behavior. + **Device/context loss is a first-class event:** all GPU state is derived state, rebuilt from the scene graph + column store on a new context. The visible cost is one reupload flicker, never lost data. The governor depends on this — a governed release is a @@ -743,7 +784,7 @@ Where it must run, and what each environment denies us: | Environment | Denied | Design answer | |---|---|---| | Jupyter / VS Code notebooks | COOP/COEP (no SAB), sometimes strict CSP | transferables path (§8); WASM served same-origin by the extension; no `eval` anywhere | -| Embedded iframes (docs, dashboards-in-SaaS) | COOP/COEP, GPU context quota shared with host | transferables; shared-context renderer (§18) degrades chart count gracefully | +| Embedded iframes (docs, dashboards-in-SaaS) | COOP/COEP, GPU context quota shared with host | transferables; the context governor shares one budget across same-origin iframes over a `BroadcastChannel` so a chart-per-iframe page stays under the process-wide cap (§18) | | Strict-CSP enterprise pages | `wasm-unsafe-eval` may be blocked | documented CSP requirements; **pure-JS fallback build** (same core transpiled level: Tier 0/1 only, capped point counts) so a chart *renders* rather than white-boxes | | Old browsers / no WebGL2 | GPU entirely | same pure-JS + 2D-canvas fallback, capped; loudly reported via the §5 no-silent-caps rule | | Server / CI (native) | no display | headless native path (§8) | diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index f57682cb..0ba59a43 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -405,7 +405,14 @@ Not yet safe: every chart nonblank when visited, recovery p95 ~8 ms, heap sublinear (28 MB at 50 charts) — but a layout keeping more than the budget visible at once can still hit browser-side eviction, so do not claim unbounded - simultaneous live charts. + simultaneous live charts. The browser cap is process-wide (shared across a + tab's iframes), so the governor shares one budget across **same-origin** + frames over a `BroadcastChannel` (§18): a chart-per-iframe page (the + `examples/fastapi` gallery) stays under the cap instead of flooding the + console with "Too many active WebGL contexts". Cross-origin and + `sandbox`-without-`allow-same-origin` frames cannot share the channel and + fall back to per-document budgeting — many such isolated frames in one tab + can still collectively exceed the cap. ## Hardening Backlog diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 0bf3d34d..d7d779f2 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -438,6 +438,75 @@ def test_client_quiesces_and_rebuilds_repeated_context_loss() -> None: assert "ctxpost != 1" in smoke +def test_client_shares_context_budget_across_same_origin_frames() -> None: + # The browser's WebGL-context cap is process-wide (shared across a tab's + # iframes), but the governor is per-document. A chart-per-iframe page (the + # examples/fastapi gallery) would otherwise blow the cap and flood the + # console with "Too many active WebGL contexts". The governor coordinates a + # single shared budget across same-origin frames over a BroadcastChannel + # (§18); these markers guard that machinery against silent removal. + required = ( + 'new BroadcastChannel("xy-webgl-context-governor")', + "_initCrossFrame()", + "_onForeignMessage(", + # The message contract peers rely on: a live-context count keyed by frame. + '{ t: "live", id: this.frameId, n }', + '{ t: "hello", id: this.frameId }', + '{ t: "bye", id: this.frameId }', + # Effective budget = own live contexts + those reported by other frames. + "this.localLive() + this.foreignLive() - this.budget()", + # A bfcache restore must re-advertise, or peers omit the restored frame + # forever and the page silently exceeds the browser cap; and it must + # discard the membership map that may have gone stale while frozen. + 'window.addEventListener("pageshow"', + "event.persisted", + "this.foreign.clear()", + ) + for path, text in CLIENT_FILES: + for marker in required: + assert marker in text, f"{path} lost cross-frame governor marker {marker!r}" + + +def test_governed_recovery_waits_for_loss_event_before_restore() -> None: + # Chromium silently drops WEBGL_lose_context.restoreContext() if it is called + # before that context's webglcontextlost event has dispatched (or during the + # dispatch) — the context is then stranded lost forever. A governed release + # that is scrolled back into view in the same task must therefore defer its + # restore until the loss event lands and then retry on a fresh task. Without + # this, a chart-per-iframe dashboard leaves charts permanently blank on + # scroll-in. Guard the deferral so it cannot regress. + for path, text in CLIENT_FILES: + assert "this._ctxLostPending = true" in text, ( + f"{path}: release no longer marks the loss event pending" + ) + assert "this._ctxLostPending = false" in text, ( + f"{path}: loss handler no longer clears the pending flag" + ) + # _recoverContext defers while the loss event is still pending. + rec = text[text.index("_recoverContext() {") :][:900] + assert "this._ctxReleasedExt && this._ctxLostPending" in rec, ( + f"{path}: _recoverContext must defer restore until the loss event dispatched" + ) + assert "this._ctxRecoverRequested = true" in rec, ( + f"{path}: _recoverContext must record the deferred recovery" + ) + + +def test_cross_frame_rebalance_only_sheds_offscreen_views() -> None: + # Shared-budget shedding must release only OFF-screen views: a sibling frame + # loading a chart must never blank one the user is looking at. The _rebalance + # candidate filter therefore requires `!view._ctxVisible`. (reserve() may + # still release a visible view as a last resort for a dense single-document + # grid, but that is intra-document, not driven by a peer frame.) + for path, text in CLIENT_FILES: + start = text.index("_rebalance() {") # the method definition, not a call site + body = text[start : start + 700] + assert "!view._ctxVisible" in body, ( + f"{path}: _rebalance must only shed off-screen views (missing !view._ctxVisible)" + ) + assert "this.budget()" in body, f"{path}: _rebalance must compare against the budget" + + def test_client_refreshes_and_destroys_density_sample_overlays() -> None: chartview_required = ( "_refreshReductionBadges()",