diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3c9076..c692ac09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ in the README). ## [Unreleased] ### Added +- **Dashboard context governor**: browsers cap live WebGL contexts per page + (~16 in Chrome) and LRU-evict the oldest on overflow, which permanently + blanked the earliest charts of a 20+/50-chart dashboard. The render client + now keeps itself inside a context budget (default 12, + `window.FASTCHARTS_CONTEXT_BUDGET` to override): at budget, the + least-recently-visible off-screen chart releases its own context + (`WEBGL_lose_context`, a controlled loss the existing restore machinery + undoes) and re-acquires when scrolled back into view — including canvas-swap + recovery for real browser evictions. Under the budget nothing releases, so + small pages are unaffected. Every decision is observable: `data-fc-ctx` on + the canvas reads live/released/lost. The dashboard benchmark now + settle-waits each scrolled chart (reporting per-visit recovery latency), + classifies governed releases vs evictions, adds a `governed` health tier, + and reports a `visible_stable_chart_ceiling`. Measured (Chrome/macOS): the + 10/20/50 sweep goes from 16-of-50 permanently blank to 50-of-50 nonblank + when visited, recovery p95 ~8 ms, with 10-chart dashboards byte-identical + in behavior and heap/render times unchanged. - **Stratified sampling in the native core** (ABI v10, `fc_stratified_sample_mask` / `kernels.stratified_sample_mask`). `lod.stratified_sample_keep_mask` — the category-aware mask behind diff --git a/benchmarks/bench_dashboard.py b/benchmarks/bench_dashboard.py index 6c561b35..8becafd2 100644 --- a/benchmarks/bench_dashboard.py +++ b/benchmarks/bench_dashboard.py @@ -132,7 +132,10 @@ def _probe_js() -> str: const state = {lost: false}; view.canvas.addEventListener("webglcontextlost", () => { state.lost = true; - contextEvents.push({id: payload.id, type: "lost", phase, at_ms: performance.now() - t0}); + contextEvents.push({ + id: payload.id, type: "lost", phase, at_ms: performance.now() - t0, + governed: view.canvas.dataset.fcCtx === "released", + }); }); view.canvas.addEventListener("webglcontextrestored", () => { state.lost = false; @@ -180,10 +183,24 @@ def _probe_js() -> str: phase = "scroll"; const scrollStart = performance.now(); const scrollNonblankIds = []; + const scrollRecoveryMs = []; + // Context recovery is asynchronous (IntersectionObserver delivery -> + // restore/rebuild -> draw), so each visit settles until the chart paints + // or a hard deadline passes. The settle time IS the user-visible + // recovery latency for a chart scrolled back into view. for (const slot of slots) { slot.cell.scrollIntoView({block: "center", inline: "center"}); - await new Promise((resolve) => setTimeout(resolve, 0)); - if (nonblankPixels(slot) > 0) scrollNonblankIds.push(slot.id); + const arriveMs = performance.now(); + let lit = nonblankPixels(slot) > 0; + while (!lit && performance.now() - arriveMs < 400) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => setTimeout(resolve, 0)); + lit = nonblankPixels(slot) > 0; + } + if (lit) { + scrollNonblankIds.push(slot.id); + scrollRecoveryMs.push(performance.now() - arriveMs); + } } window.scrollTo(0, 0); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -202,10 +219,19 @@ def _probe_js() -> str: const heapAfter = performance.memory ? performance.memory.usedJSHeapSize : null; const lostEvents = contextEvents.filter((event) => event.type === "lost"); const restoredEvents = contextEvents.filter((event) => event.type === "restored"); + const governedLostEvents = lostEvents.filter((event) => event.governed === true); const uniqueIds = (events) => Array.from(new Set(events.map((event) => event.id))); const currentlyLostIds = slots .filter((slot) => slot.view && contextLost(slot)) .map((slot) => slot.id); + const ctxState = (slot) => + slot.view && slot.view.canvas && slot.view.canvas.dataset + ? slot.view.canvas.dataset.fcCtx || null + : null; + // End-state split (§28): "released" is a governed, recoverable release; + // "lost" is a browser-side eviction the governor could not prevent. + const releasedChartIds = slots.filter((slot) => ctxState(slot) === "released").map((s) => s.id); + const evictedChartIds = slots.filter((slot) => ctxState(slot) === "lost").map((s) => s.id); const createdCharts = slots.length - creationFailureIds.length; const fullyNonblank = createdCharts === slots.length && @@ -213,9 +239,18 @@ def _probe_js() -> str: scrollNonblankIds.length === slots.length && lostEvents.length === 0 && currentlyLostIds.length === 0; + // "governed": above the context budget, but every chart was created, + // every context loss was a governed release, and every chart proved + // nonblank while visited — the dashboard is fully usable, off-screen + // charts just hold no context. + const governedHealth = + !fullyNonblank && + createdCharts === slots.length && + scrollNonblankIds.length === slots.length && + governedLostEvents.length === lostEvents.length; fcReport("FC_DASHBOARD", { status: "ok", - render_status: fullyNonblank ? "complete" : "partial", + render_status: fullyNonblank ? "complete" : governedHealth ? "governed" : "partial", fully_nonblank: fullyNonblank, render_ms: renderMs, navigation_ready_ms: navigationReadyMs, @@ -235,6 +270,10 @@ def _probe_js() -> str: scroll_nonblank_charts: scrollNonblankIds.length, scroll_nonblank_chart_ids: scrollNonblankIds, scroll_blank_chart_ids: complement(scrollNonblankIds), + scroll_recovery_p95_ms: fcPercentile(scrollRecoveryMs, 95), + governed_context_lost_events: governedLostEvents.length, + released_chart_ids: releasedChartIds, + evicted_chart_ids: evictedChartIds, context_lost_events: lostEvents.length, context_restored_events: restoredEvents.length, context_lost_chart_ids: uniqueIds(lostEvents), @@ -305,6 +344,10 @@ def run(*, chart_counts: list[int], chromium: str | None = None) -> dict[str, An "scroll_nonblank_charts", "scroll_nonblank_chart_ids", "scroll_blank_chart_ids", + "scroll_recovery_p95_ms", + "governed_context_lost_events", + "released_chart_ids", + "evicted_chart_ids", "context_lost_events", "context_restored_events", "context_lost_chart_ids", @@ -323,6 +366,16 @@ def run(*, chart_counts: list[int], chromium: str | None = None) -> dict[str, An for row in rows if str(row.get("status", "")).startswith("ok") and row.get("fully_nonblank") is True ] + # A "governed" row is fully usable — every chart was created and proved + # nonblank while visited; off-screen charts hold no context by design — + # so it raises the *visible-stable* ceiling even though it is not + # loss-free. + visible_stable_counts = [ + row["chart_count"] + for row in rows + if str(row.get("status", "")).startswith("ok") + and row.get("render_status") in {"complete", "governed"} + ] return { "schema_version": SCHEMA_VERSION, "kind": "dashboard-browser", @@ -331,6 +384,9 @@ def run(*, chart_counts: list[int], chromium: str | None = None) -> dict[str, An "tracked_categories": categories_for(DASHBOARD_CATEGORY_IDS), "attempted_chart_counts": list(chart_counts), "chart_count_ceiling": max(successful_counts) if successful_counts else None, + "visible_stable_chart_ceiling": ( + max(visible_stable_counts) if visible_stable_counts else None + ), "rows": rows, } @@ -361,22 +417,25 @@ def to_markdown(report: dict[str, Any]) -> str: "Tracked in this run: " + ", ".join(f"`{category['id']}`" for category in report["tracked_categories"]), "", - f"Stable loss-free chart-count ceiling: `{report.get('chart_count_ceiling')}`.", + f"Stable loss-free chart-count ceiling: `{report.get('chart_count_ceiling')}`; " + f"visible-stable ceiling (complete or governed): " + f"`{report.get('visible_stable_chart_ceiling')}`.", "", "## Results", "", - "| scenario | charts | prep | navigation ready | render | scroll pass | redraw submit p95 | active | JS heap | payload | html | initial/scroll nonblank | loss/restore | health |", - "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|", + "| scenario | charts | prep | navigation ready | render | scroll pass | recovery p95 | redraw submit p95 | active | JS heap | payload | html | initial/scroll nonblank | loss(gov)/restore | health |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|", ] for row in report["rows"]: lines.append( - "| {scenario} | {count} | {prep} | {navigation} | {render} | {scroll} | {idle} | {active} | {heap} | {payload} | {html} | {nonblank}/{scroll_nonblank} | {lost}/{restored} | {health} |".format( + "| {scenario} | {count} | {prep} | {navigation} | {render} | {scroll} | {recovery} | {idle} | {active} | {heap} | {payload} | {html} | {nonblank}/{scroll_nonblank} | {lost}({governed})/{restored} | {health} |".format( scenario=row["scenario"], count=row["chart_count"], prep=_fmt_ms(row.get("payload_prep_ms")), navigation=_fmt_ms(row.get("navigation_ready_ms")), render=_fmt_ms(row.get("render_ms")), scroll=_fmt_ms(row.get("scroll_pass_ms")), + recovery=_fmt_ms(row.get("scroll_recovery_p95_ms")), idle=_fmt_ms(row.get("steady_redraw_p95_ms")), active=row.get("steady_redraw_active_charts", "—"), heap=_fmt_bytes(row.get("js_heap_bytes")), @@ -385,6 +444,7 @@ def to_markdown(report: dict[str, Any]) -> str: nonblank=row.get("nonblank_charts", "—"), scroll_nonblank=row.get("scroll_nonblank_charts", "—"), lost=row.get("context_lost_events", "—"), + governed=row.get("governed_context_lost_events", "—"), restored=row.get("context_restored_events", "—"), health=row.get("render_status", row.get("status", "unknown")), ) diff --git a/docs/benchmark.md b/docs/benchmark.md index 8de792e7..101840c8 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -143,7 +143,9 @@ also declares `tooltip_sample_count`, and eligible rows must report that exact away fails verification. `bench_dashboard.py` attempts 10/20/50 mixed dashboards and reports payload prep, navigation readiness, JS heap, redraw-submission p95, per-chart context loss and -restore events, initial/scrolled nonblank IDs, and the largest stable loss-free +restore events (governed releases labeled separately from browser evictions), +initial/scrolled nonblank IDs, per-visit scroll recovery latency, and the +largest stable loss-free count. Partial rows retain their timing and memory metrics. `bench_workflows.py` covers ingestion, streaming/pyramid invalidation, and separate HTML/SVG/native-PNG/Chromium-PNG export rows. All three emit schema-versioned JSON with environment metadata and benchmark category IDs. diff --git a/docs/production-readiness.md b/docs/production-readiness.md index 79e148bf..8d5b8341 100644 --- a/docs/production-readiness.md +++ b/docs/production-readiness.md @@ -360,6 +360,16 @@ Not yet safe: - Cross-browser/perceptual rendering conformance. - Exact-marker interaction for every possible 100M-point zoom path. - Production Reflex state integration as a first-class API. +- More than ~12 charts *simultaneously in view* holding live WebGL contexts. + Browsers cap live contexts per page (~16 in Chrome); the render client's + context governor keeps fastcharts inside a budget (default 12) by having + the least-recently-visible off-screen chart release its context and + re-acquire on scroll-into-view. Measured (`benchmarks/bench_dashboard.py`, + 2026-07-09, Chrome/macOS): 10/20/50-chart dashboards are all fully usable — + 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. ## Hardening Backlog diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index 6fc3d9c8..782c5603 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -29,6 +29,79 @@ const UNITLESS_STYLE_PROPS = new Set([ "stroke-opacity", ]); +// Dashboard context governor (production-readiness: the WebGL-context cap). +// Browsers cap live WebGL contexts per page (~16 in Chrome) and LRU-evict the +// oldest on overflow, permanently blanking the earliest charts of a big +// dashboard. The governor keeps this library inside a budget instead: when a +// view is about to acquire a context and the page is at budget, the +// least-recently-visible *off-screen* view releases its own context via +// WEBGL_lose_context — a controlled loss the existing restore machinery can +// undo — and re-acquires when scrolled back into view. Under the budget no +// view ever releases, so pages with few charts behave exactly as before. +// Every decision is observable (§28): `data-fc-ctx` on the canvas reads +// "live" | "released" | "lost", and views count releases/recoveries. +const FC_CONTEXT_GOVERNOR = { + views: new Set(), + seq: 1, + budget() { + const v = typeof window !== "undefined" ? window.FASTCHARTS_CONTEXT_BUDGET : null; + // 12 leaves headroom under Chrome's ~16 so host-page GL (maps, editors) + // does not push chart contexts into browser-side eviction. + return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; + }, + register(view) { + this.views.add(view); + }, + unregister(view) { + view._ctxPendingReservation = false; + this.views.delete(view); + }, + // Called before a view acquires (or re-acquires) a GL context. Releases + // least-recently-visible off-screen views until the requester fits the + // budget. If every live view is visible, overflow is allowed — the browser + // may LRU-evict, and eviction recovery rebuilds on re-entry. + reserve(requester) { + const live = []; + let pending = 0; + for (const view of this.views) { + if (view !== requester && view.gl && !view._glLost && !view._destroyed) live.push(view); + if (view !== requester && view._ctxPendingReservation && !view._destroyed) pending += 1; + } + const needsReservation = !requester._ctxPendingReservation; + requester._ctxPendingReservation = true; + let over = live.length + pending + (needsReservation ? 1 : 0) - this.budget(); + if (over <= 0) return; + const candidates = live + .filter((view) => !view._ctxVisible) + .sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); + for (const view of candidates) { + if (over <= 0) break; + if (view._releaseContext()) over -= 1; + } + }, + acquired(requester) { + requester._ctxPendingReservation = false; + }, + cancel(requester) { + requester._ctxPendingReservation = false; + }, +}; + +// Initial visibility estimate for the governor: IntersectionObserver entries +// arrive asynchronously, but big dashboards create every chart synchronously — +// the estimate lets reserve() prefer below-the-fold charts immediately. The +// 25% margin matches the observer's rootMargin (recovery hysteresis). +function fcInitiallyVisible(el) { + if (typeof window === "undefined" || !el.getBoundingClientRect) return true; + const rect = el.getBoundingClientRect(); + if (!rect.width && !rect.height) return false; // hidden boot slot: recoverable + const vh = window.innerHeight || 0; + const vw = window.innerWidth || 0; + return ( + rect.bottom > -0.25 * vh && rect.top < 1.25 * vh && rect.right > -0.25 * vw && rect.left < 1.25 * vw + ); +} + class ChartView { constructor(el, spec, buffer, comm) { if (spec.protocol !== PROTOCOL) { @@ -80,12 +153,19 @@ class ChartView { // spec + payload by design (§18/§27). this._payload = buffer; this._glLost = false; + this._ctxReleasedExt = null; + this._ctxReleases = 0; + this._ctxRecoveries = 0; + this._ctxVisible = fcInitiallyVisible(el); + FC_CONTEXT_GOVERNOR.register(this); + if (this._ctxVisible) this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; this._contextLossCount = 0; this._contextRestoreCount = 0; this._contextRecoveryError = null; this._initGl(buffer); this.root.dataset.fcContextState = "ready"; this._initContextLossRecovery(); + this._armContextVisibilityWatch(); this._initInteraction(); this._buildModebar(this.root); // after theme (icon color) + canvas (cursor) @@ -411,8 +491,16 @@ class ChartView { _initContextLossRecovery() { this._listen(this.canvas, "webglcontextlost", (e) => { e.preventDefault(); - if (this._destroyed || this._glLost) return; + if (this._destroyed) return; + const governedRelease = this.canvas.dataset.fcCtx === "released"; + // _releaseContext marks the view lost synchronously before the browser + // dispatches this event. Still run the full quiesce/telemetry path for + // that first governed event; only ignore duplicate ungoverned losses. + if (this._glLost && !governedRelease) return; this._glLost = true; + // Governed releases already stamped "released"; anything else is a + // browser-side eviction/driver reset (§28: the difference stays legible). + if (!governedRelease) this.canvas.dataset.fcCtx = "lost"; this._contextLossCount += 1; this._contextRecoveryError = null; this.root.dataset.fcContextState = "lost"; @@ -472,6 +560,103 @@ class ChartView { }); } + // Governed release: give this view's GL context back to the page on purpose + // (WEBGL_lose_context), keeping total live contexts under the governor's + // budget so the *browser* never LRU-evicts a visible chart. The retained + // spec + payload rebuild everything on re-entry (§18/§27), riding the same + // lost/restored machinery the lifecycle gate already exercises. + _releaseContext() { + if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; + const ext = this.gl.getExtension("WEBGL_lose_context"); + if (!ext) return false; + this._ctxReleasedExt = ext; + this._ctxReleases += 1; + this._glLost = true; // synchronous: the lost *event* arrives as a task + this.canvas.dataset.fcCtx = "released"; + if (this._raf) cancelAnimationFrame(this._raf); + this._raf = null; + ext.loseContext(); + return true; + } + + // 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. + _recoverContext() { + if (this._destroyed || !this._glLost) return; + this._ctxRecoveries += 1; + if (this._ctxReleasedExt) { + const ext = this._ctxReleasedExt; + 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. + FC_CONTEXT_GOVERNOR.reserve(this); + ext.restoreContext(); // restored event -> full rebuild + return; + } catch (_err) { + FC_CONTEXT_GOVERNOR.cancel(this); + // Extension refused (context was also evicted for real): fall through. + } + } + this._rebuildEvictedContext(); + } + + _rebuildEvictedContext() { + // The evicted context object is dead for good and a canvas keeps its + // context forever, so recovery swaps in a fresh canvas (attributes + // cloned, listeners retargeted) and rebuilds — the same §18/§27 rebuild + // the restored path uses. + const fresh = this.canvas.cloneNode(false); + for (const record of this._listeners) { + if (record.target === this.canvas) { + this.canvas.removeEventListener(record.type, record.handler, record.options); + fresh.addEventListener(record.type, record.handler, record.options); + record.target = fresh; + } + } + this.canvas.replaceWith(fresh); + this.canvas = fresh; + this._glLost = false; + this._lutCache.clear(); + this.pickFbo = null; + this.pickTex = null; + try { + this._initGl(this._payload); + } catch (_err) { + this._glLost = true; + this.canvas.dataset.fcCtx = "lost"; + return; // context pressure persists; the next visibility pass retries + } + this._scheduleViewRequest(this.view, { delay: 0 }); + this.draw(); + } + + // Visibility feed for the governor: tracks least-recently-visible order and + // re-acquires a released/evicted context when the chart scrolls back into + // view (25% rootMargin = pre-warm hysteresis; release is demand-driven only, + // so fast scrolling never thrashes contexts). + _armContextVisibilityWatch() { + if (typeof IntersectionObserver === "undefined") { + this._ctxVisible = true; // no observer: never treat as releasable + return; + } + this._ctxIo = new IntersectionObserver( + (entries) => { + const entry = entries[entries.length - 1]; + this._ctxVisible = entry.isIntersecting || entry.intersectionRatio > 0; + if (this._ctxVisible) { + this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; + if (this._glLost && !this._destroyed) this._recoverContext(); + } + }, + { rootMargin: "25% 0px 25% 0px" }, + ); + this._ctxIo.observe(this.root); + } + // Container size changed (fluid mode). Cheap on purpose: data GPU buffers // are untouched — the _map() uniforms absorb the new aspect — and the pick // FBO realloc is deferred to the next actual pick (_renderPick checks dims). @@ -689,14 +874,20 @@ class ChartView { this.chrome.style.width = this.size.w + "px"; this.chrome.style.height = this.size.h + "px"; + // Stay inside the page's context budget before acquiring (governor above): + // at budget, the least-recently-visible off-screen view releases first. + FC_CONTEXT_GOVERNOR.reserve(this); const gl = this.canvas.getContext("webgl2", { antialias: false, premultipliedAlpha: true, alpha: true, }); if (!gl) { + FC_CONTEXT_GOVERNOR.cancel(this); this.root.textContent = "fastcharts: WebGL2 unavailable in this browser."; throw new Error("webgl2 unavailable"); } this.gl = gl; + FC_CONTEXT_GOVERNOR.acquired(this); + this.canvas.dataset.fcCtx = "live"; gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); @@ -2474,6 +2665,9 @@ class ChartView { destroy() { if (this._destroyed) return; this._destroyed = true; + FC_CONTEXT_GOVERNOR.unregister(this); + this._ctxIo?.disconnect(); + this._ctxIo = null; clearTimeout(this._rebinTimer); if (this._rebinWorker) { this._rebinWorker.terminate(); diff --git a/python/fastcharts/static/index.js b/python/fastcharts/static/index.js index 5f678220..e2cf03b8 100644 --- a/python/fastcharts/static/index.js +++ b/python/fastcharts/static/index.js @@ -1341,6 +1341,56 @@ const UNITLESS_STYLE_PROPS = new Set([ "stroke-miterlimit", "stroke-opacity", ]); +const FC_CONTEXT_GOVERNOR = { +views: new Set(), +seq: 1, +budget() { +const v = typeof window !== "undefined" ? window.FASTCHARTS_CONTEXT_BUDGET : null; +return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; +}, +register(view) { +this.views.add(view); +}, +unregister(view) { +view._ctxPendingReservation = false; +this.views.delete(view); +}, +reserve(requester) { +const live = []; +let pending = 0; +for (const view of this.views) { +if (view !== requester && view.gl && !view._glLost && !view._destroyed) live.push(view); +if (view !== requester && view._ctxPendingReservation && !view._destroyed) pending += 1; +} +const needsReservation = !requester._ctxPendingReservation; +requester._ctxPendingReservation = true; +let over = live.length + pending + (needsReservation ? 1 : 0) - this.budget(); +if (over <= 0) return; +const candidates = live +.filter((view) => !view._ctxVisible) +.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); +for (const view of candidates) { +if (over <= 0) break; +if (view._releaseContext()) over -= 1; +} +}, +acquired(requester) { +requester._ctxPendingReservation = false; +}, +cancel(requester) { +requester._ctxPendingReservation = false; +}, +}; +function fcInitiallyVisible(el) { +if (typeof window === "undefined" || !el.getBoundingClientRect) return true; +const rect = el.getBoundingClientRect(); +if (!rect.width && !rect.height) return false; +const vh = window.innerHeight || 0; +const vw = window.innerWidth || 0; +return ( +rect.bottom > -0.25 * vh && rect.top < 1.25 * vh && rect.right > -0.25 * vw && rect.left < 1.25 * vw +); +} class ChartView { constructor(el, spec, buffer, comm) { if (spec.protocol !== PROTOCOL) { @@ -1384,12 +1434,19 @@ this._buildDom(el); this.theme = readTheme(this.root); this._payload = buffer; this._glLost = false; +this._ctxReleasedExt = null; +this._ctxReleases = 0; +this._ctxRecoveries = 0; +this._ctxVisible = fcInitiallyVisible(el); +FC_CONTEXT_GOVERNOR.register(this); +if (this._ctxVisible) this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; this._contextLossCount = 0; this._contextRestoreCount = 0; this._contextRecoveryError = null; this._initGl(buffer); this.root.dataset.fcContextState = "ready"; this._initContextLossRecovery(); +this._armContextVisibilityWatch(); this._initInteraction(); this._buildModebar(this.root); if ((this.fluid || this.fluidH) && typeof ResizeObserver !== "undefined") { @@ -1668,8 +1725,11 @@ this._dprMq = mq; _initContextLossRecovery() { this._listen(this.canvas, "webglcontextlost", (e) => { e.preventDefault(); -if (this._destroyed || this._glLost) return; +if (this._destroyed) return; +const governedRelease = this.canvas.dataset.fcCtx === "released"; +if (this._glLost && !governedRelease) return; this._glLost = true; +if (!governedRelease) this.canvas.dataset.fcCtx = "lost"; this._contextLossCount += 1; this._contextRecoveryError = null; this.root.dataset.fcContextState = "lost"; @@ -1721,6 +1781,78 @@ restore_count: this._contextRestoreCount, }); }); } +_releaseContext() { +if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; +const ext = this.gl.getExtension("WEBGL_lose_context"); +if (!ext) return false; +this._ctxReleasedExt = ext; +this._ctxReleases += 1; +this._glLost = true; +this.canvas.dataset.fcCtx = "released"; +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +ext.loseContext(); +return true; +} +_recoverContext() { +if (this._destroyed || !this._glLost) return; +this._ctxRecoveries += 1; +if (this._ctxReleasedExt) { +const ext = this._ctxReleasedExt; +this._ctxReleasedExt = null; +try { +FC_CONTEXT_GOVERNOR.reserve(this); +ext.restoreContext(); +return; +} catch (_err) { +FC_CONTEXT_GOVERNOR.cancel(this); +} +} +this._rebuildEvictedContext(); +} +_rebuildEvictedContext() { +const fresh = this.canvas.cloneNode(false); +for (const record of this._listeners) { +if (record.target === this.canvas) { +this.canvas.removeEventListener(record.type, record.handler, record.options); +fresh.addEventListener(record.type, record.handler, record.options); +record.target = fresh; +} +} +this.canvas.replaceWith(fresh); +this.canvas = fresh; +this._glLost = false; +this._lutCache.clear(); +this.pickFbo = null; +this.pickTex = null; +try { +this._initGl(this._payload); +} catch (_err) { +this._glLost = true; +this.canvas.dataset.fcCtx = "lost"; +return; +} +this._scheduleViewRequest(this.view, { delay: 0 }); +this.draw(); +} +_armContextVisibilityWatch() { +if (typeof IntersectionObserver === "undefined") { +this._ctxVisible = true; +return; +} +this._ctxIo = new IntersectionObserver( +(entries) => { +const entry = entries[entries.length - 1]; +this._ctxVisible = entry.isIntersecting || entry.intersectionRatio > 0; +if (this._ctxVisible) { +this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; +if (this._glLost && !this._destroyed) this._recoverContext(); +} +}, +{ rootMargin: "25% 0px 25% 0px" }, +); +this._ctxIo.observe(this.root); +} _resize(cssW, cssH) { const w = this.fluid && cssW ? Math.max(120, Math.round(cssW)) : this.size.w; const h = this.fluidH && cssH ? Math.max(120, Math.round(cssH)) : this.size.h; @@ -1906,14 +2038,18 @@ this.chrome.width = this.size.w * dpr; this.chrome.height = this.size.h * dpr; this.chrome.style.width = this.size.w + "px"; this.chrome.style.height = this.size.h + "px"; +FC_CONTEXT_GOVERNOR.reserve(this); const gl = this.canvas.getContext("webgl2", { antialias: false, premultipliedAlpha: true, alpha: true, }); if (!gl) { +FC_CONTEXT_GOVERNOR.cancel(this); this.root.textContent = "fastcharts: WebGL2 unavailable in this browser."; throw new Error("webgl2 unavailable"); } this.gl = gl; +FC_CONTEXT_GOVERNOR.acquired(this); +this.canvas.dataset.fcCtx = "live"; gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); this._progCache = new Map(); @@ -3460,6 +3596,9 @@ this.draw(); destroy() { if (this._destroyed) return; this._destroyed = true; +FC_CONTEXT_GOVERNOR.unregister(this); +this._ctxIo?.disconnect(); +this._ctxIo = null; clearTimeout(this._rebinTimer); if (this._rebinWorker) { this._rebinWorker.terminate(); diff --git a/python/fastcharts/static/standalone.js b/python/fastcharts/static/standalone.js index 793e5b55..5470a957 100644 --- a/python/fastcharts/static/standalone.js +++ b/python/fastcharts/static/standalone.js @@ -1342,6 +1342,56 @@ const UNITLESS_STYLE_PROPS = new Set([ "stroke-miterlimit", "stroke-opacity", ]); +const FC_CONTEXT_GOVERNOR = { +views: new Set(), +seq: 1, +budget() { +const v = typeof window !== "undefined" ? window.FASTCHARTS_CONTEXT_BUDGET : null; +return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; +}, +register(view) { +this.views.add(view); +}, +unregister(view) { +view._ctxPendingReservation = false; +this.views.delete(view); +}, +reserve(requester) { +const live = []; +let pending = 0; +for (const view of this.views) { +if (view !== requester && view.gl && !view._glLost && !view._destroyed) live.push(view); +if (view !== requester && view._ctxPendingReservation && !view._destroyed) pending += 1; +} +const needsReservation = !requester._ctxPendingReservation; +requester._ctxPendingReservation = true; +let over = live.length + pending + (needsReservation ? 1 : 0) - this.budget(); +if (over <= 0) return; +const candidates = live +.filter((view) => !view._ctxVisible) +.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); +for (const view of candidates) { +if (over <= 0) break; +if (view._releaseContext()) over -= 1; +} +}, +acquired(requester) { +requester._ctxPendingReservation = false; +}, +cancel(requester) { +requester._ctxPendingReservation = false; +}, +}; +function fcInitiallyVisible(el) { +if (typeof window === "undefined" || !el.getBoundingClientRect) return true; +const rect = el.getBoundingClientRect(); +if (!rect.width && !rect.height) return false; +const vh = window.innerHeight || 0; +const vw = window.innerWidth || 0; +return ( +rect.bottom > -0.25 * vh && rect.top < 1.25 * vh && rect.right > -0.25 * vw && rect.left < 1.25 * vw +); +} class ChartView { constructor(el, spec, buffer, comm) { if (spec.protocol !== PROTOCOL) { @@ -1385,12 +1435,19 @@ this._buildDom(el); this.theme = readTheme(this.root); this._payload = buffer; this._glLost = false; +this._ctxReleasedExt = null; +this._ctxReleases = 0; +this._ctxRecoveries = 0; +this._ctxVisible = fcInitiallyVisible(el); +FC_CONTEXT_GOVERNOR.register(this); +if (this._ctxVisible) this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; this._contextLossCount = 0; this._contextRestoreCount = 0; this._contextRecoveryError = null; this._initGl(buffer); this.root.dataset.fcContextState = "ready"; this._initContextLossRecovery(); +this._armContextVisibilityWatch(); this._initInteraction(); this._buildModebar(this.root); if ((this.fluid || this.fluidH) && typeof ResizeObserver !== "undefined") { @@ -1669,8 +1726,11 @@ this._dprMq = mq; _initContextLossRecovery() { this._listen(this.canvas, "webglcontextlost", (e) => { e.preventDefault(); -if (this._destroyed || this._glLost) return; +if (this._destroyed) return; +const governedRelease = this.canvas.dataset.fcCtx === "released"; +if (this._glLost && !governedRelease) return; this._glLost = true; +if (!governedRelease) this.canvas.dataset.fcCtx = "lost"; this._contextLossCount += 1; this._contextRecoveryError = null; this.root.dataset.fcContextState = "lost"; @@ -1722,6 +1782,78 @@ restore_count: this._contextRestoreCount, }); }); } +_releaseContext() { +if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; +const ext = this.gl.getExtension("WEBGL_lose_context"); +if (!ext) return false; +this._ctxReleasedExt = ext; +this._ctxReleases += 1; +this._glLost = true; +this.canvas.dataset.fcCtx = "released"; +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +ext.loseContext(); +return true; +} +_recoverContext() { +if (this._destroyed || !this._glLost) return; +this._ctxRecoveries += 1; +if (this._ctxReleasedExt) { +const ext = this._ctxReleasedExt; +this._ctxReleasedExt = null; +try { +FC_CONTEXT_GOVERNOR.reserve(this); +ext.restoreContext(); +return; +} catch (_err) { +FC_CONTEXT_GOVERNOR.cancel(this); +} +} +this._rebuildEvictedContext(); +} +_rebuildEvictedContext() { +const fresh = this.canvas.cloneNode(false); +for (const record of this._listeners) { +if (record.target === this.canvas) { +this.canvas.removeEventListener(record.type, record.handler, record.options); +fresh.addEventListener(record.type, record.handler, record.options); +record.target = fresh; +} +} +this.canvas.replaceWith(fresh); +this.canvas = fresh; +this._glLost = false; +this._lutCache.clear(); +this.pickFbo = null; +this.pickTex = null; +try { +this._initGl(this._payload); +} catch (_err) { +this._glLost = true; +this.canvas.dataset.fcCtx = "lost"; +return; +} +this._scheduleViewRequest(this.view, { delay: 0 }); +this.draw(); +} +_armContextVisibilityWatch() { +if (typeof IntersectionObserver === "undefined") { +this._ctxVisible = true; +return; +} +this._ctxIo = new IntersectionObserver( +(entries) => { +const entry = entries[entries.length - 1]; +this._ctxVisible = entry.isIntersecting || entry.intersectionRatio > 0; +if (this._ctxVisible) { +this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; +if (this._glLost && !this._destroyed) this._recoverContext(); +} +}, +{ rootMargin: "25% 0px 25% 0px" }, +); +this._ctxIo.observe(this.root); +} _resize(cssW, cssH) { const w = this.fluid && cssW ? Math.max(120, Math.round(cssW)) : this.size.w; const h = this.fluidH && cssH ? Math.max(120, Math.round(cssH)) : this.size.h; @@ -1907,14 +2039,18 @@ this.chrome.width = this.size.w * dpr; this.chrome.height = this.size.h * dpr; this.chrome.style.width = this.size.w + "px"; this.chrome.style.height = this.size.h + "px"; +FC_CONTEXT_GOVERNOR.reserve(this); const gl = this.canvas.getContext("webgl2", { antialias: false, premultipliedAlpha: true, alpha: true, }); if (!gl) { +FC_CONTEXT_GOVERNOR.cancel(this); this.root.textContent = "fastcharts: WebGL2 unavailable in this browser."; throw new Error("webgl2 unavailable"); } this.gl = gl; +FC_CONTEXT_GOVERNOR.acquired(this); +this.canvas.dataset.fcCtx = "live"; gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); this._progCache = new Map(); @@ -3461,6 +3597,9 @@ this.draw(); destroy() { if (this._destroyed) return; this._destroyed = true; +FC_CONTEXT_GOVERNOR.unregister(this); +this._ctxIo?.disconnect(); +this._ctxIo = null; clearTimeout(this._rebinTimer); if (this._rebinWorker) { this._rebinWorker.terminate(); diff --git a/scripts/verify_benchmark_report.py b/scripts/verify_benchmark_report.py index 9cf9923a..289ed6bc 100644 --- a/scripts/verify_benchmark_report.py +++ b/scripts/verify_benchmark_report.py @@ -1158,6 +1158,7 @@ def _validate_dashboard_browser(report: dict[str, Any], errors: list[str]) -> No "tracked_categories", "attempted_chart_counts", "chart_count_ceiling", + "visible_stable_chart_ceiling", "rows", }, "report", @@ -1224,6 +1225,10 @@ def _validate_dashboard_browser(report: dict[str, Any], errors: list[str]) -> No "scroll_nonblank_charts", "scroll_nonblank_chart_ids", "scroll_blank_chart_ids", + "scroll_recovery_p95_ms", + "governed_context_lost_events", + "released_chart_ids", + "evicted_chart_ids", "context_lost_events", "context_restored_events", "context_lost_chart_ids", @@ -1240,6 +1245,7 @@ def _validate_dashboard_browser(report: dict[str, Any], errors: list[str]) -> No "payload_prep_ms", "navigation_ready_ms", "scroll_pass_ms", + "scroll_recovery_p95_ms", "steady_redraw_p95_ms", ): _require_nonnegative_number(row, key, path, errors) @@ -1252,6 +1258,7 @@ def _validate_dashboard_browser(report: dict[str, Any], errors: list[str]) -> No "scroll_nonblank_charts", "context_lost_events", "context_restored_events", + "governed_context_lost_events", ): _require_nonnegative_integer(row, key, path, errors) for key in ("js_heap_before_bytes", "js_heap_bytes"): @@ -1280,6 +1287,20 @@ def _validate_dashboard_browser(report: dict[str, Any], errors: list[str]) -> No "report.chart_count_ceiling must be the largest successful chart_count; " f"got {report.get('chart_count_ceiling')!r}, expected {expected_ceiling!r}" ) + visible_counts = { + row.get("chart_count") + for row in rows + if isinstance(row, dict) + and _status_kind(row.get("status")) == "ok" + and row.get("render_status") in {"complete", "governed"} + } + expected_visible = max(visible_counts) if visible_counts else None + if report.get("visible_stable_chart_ceiling") != expected_visible: + errors.append( + "report.visible_stable_chart_ceiling must be the largest complete-or-governed " + f"chart_count; got {report.get('visible_stable_chart_ceiling')!r}, " + f"expected {expected_visible!r}" + ) if not isinstance(expected_ceiling, int) or expected_ceiling < DASHBOARD_MIN_LOSS_FREE_CHARTS: errors.append( "dashboard must render at least " @@ -1339,6 +1360,8 @@ def _validate_dashboard_telemetry(row: dict[str, Any], path: str, errors: list[s "context_lost_chart_ids", "context_restored_chart_ids", "currently_lost_chart_ids", + "released_chart_ids", + "evicted_chart_ids", ) ids = {key: _dashboard_id_list(row, key, path, expected_ids, errors) for key in id_keys} @@ -1416,6 +1439,21 @@ def _validate_dashboard_telemetry(row: dict[str, Any], path: str, errors: list[s if not ids["currently_lost_chart_ids"] <= ids["context_lost_chart_ids"]: errors.append(f"{path}.currently_lost_chart_ids must be a subset of lost chart IDs") + governed_lost = row.get("governed_context_lost_events") + if ( + isinstance(governed_lost, int) + and not isinstance(governed_lost, bool) + and isinstance(row.get("context_lost_events"), int) + and governed_lost > row.get("context_lost_events") + ): + errors.append(f"{path}.governed_context_lost_events must be <= context_lost_events") + # Governed releases and browser evictions partition the still-lost set. + if ( + not (ids["released_chart_ids"] | ids["evicted_chart_ids"]) + <= ids["currently_lost_chart_ids"] | ids["context_lost_chart_ids"] + ): + errors.append(f"{path}.released/evicted chart IDs must come from context-lost charts") + expected_complete = ( row.get("created_charts") == chart_count and row.get("initial_nonblank_charts") == chart_count @@ -1427,7 +1465,18 @@ def _validate_dashboard_telemetry(row: dict[str, Any], path: str, errors: list[s errors.append(f"{path}.fully_nonblank must be a boolean") elif row.get("fully_nonblank") != expected_complete: errors.append(f"{path}.fully_nonblank is inconsistent with dashboard telemetry") - expected_status = "complete" if expected_complete else "partial" + # "governed": every chart created and nonblank while visited, and every + # context loss was a governed (recoverable) release — the dashboard is + # fully usable above the context budget. + expected_governed = ( + not expected_complete + and row.get("created_charts") == chart_count + and row.get("scroll_nonblank_charts") == chart_count + and row.get("governed_context_lost_events") == row.get("context_lost_events") + ) + expected_status = ( + "complete" if expected_complete else "governed" if expected_governed else "partial" + ) if row.get("render_status") != expected_status: errors.append(f"{path}.render_status must be {expected_status!r}") diff --git a/tests/test_benchmark_environment.py b/tests/test_benchmark_environment.py index 5f02d661..59e11ec0 100644 --- a/tests/test_benchmark_environment.py +++ b/tests/test_benchmark_environment.py @@ -169,7 +169,10 @@ def test_dashboard_benchmark_reports_eviction_and_scroll_telemetry() -> None: "context_restored_chart_ids", "initial_nonblank_chart_ids", "scroll_nonblank_chart_ids", - 'render_status: fullyNonblank ? "complete" : "partial"', + "scroll_recovery_p95_ms", + "governed_context_lost_events", + "released_chart_ids", + 'render_status: fullyNonblank ? "complete" : governedHealth ? "governed" : "partial"', ): assert marker in bench assert "blank dashboard chart" not in bench @@ -185,6 +188,18 @@ def test_dashboard_benchmark_reports_eviction_and_scroll_telemetry() -> None: assert first_yield < phase_initial +def test_context_governor_reserves_pending_restores() -> None: + """Concurrent visibility callbacks must count restores before their + asynchronous ``webglcontextrestored`` events acquire the contexts.""" + client = (ROOT / "js" / "src" / "50_chartview.js").read_text(encoding="utf-8") + + assert "view._ctxPendingReservation" in client + recover = client.index("_recoverContext()") + reserve = client.index("FC_CONTEXT_GOVERNOR.reserve(this);", recover) + restore = client.index("ext.restoreContext();", recover) + assert reserve < restore + + def test_benchmark_categories_track_core_hardening_metrics() -> None: medium_scatter_metrics = CATEGORY_BY_ID["medium_direct_scatter"]["metrics"] interaction_metrics = CATEGORY_BY_ID["interaction_smoothness"]["metrics"] diff --git a/tests/test_verify_benchmark_report.py b/tests/test_verify_benchmark_report.py index 2a78e3c3..c9ddad28 100644 --- a/tests/test_verify_benchmark_report.py +++ b/tests/test_verify_benchmark_report.py @@ -458,6 +458,10 @@ def row(count: int) -> dict: "scroll_nonblank_charts": count, "scroll_nonblank_chart_ids": chart_ids, "scroll_blank_chart_ids": [], + "scroll_recovery_p95_ms": 4.0, + "governed_context_lost_events": 0, + "released_chart_ids": [], + "evicted_chart_ids": [], "context_lost_events": 0, "context_restored_events": 0, "context_lost_chart_ids": [], @@ -473,6 +477,7 @@ def row(count: int) -> dict: "tracked_categories": tracked, "attempted_chart_counts": [10, 20, 50], "chart_count_ceiling": 50, + "visible_stable_chart_ceiling": 50, "rows": [row(count) for count in (10, 20, 50)], } @@ -482,7 +487,7 @@ def _set_dashboard_partial(row: dict, nonblank: int = 16) -> None: blank_ids = [f"chart-{i}" for i in range(count - nonblank)] lit_ids = [f"chart-{i}" for i in range(count - nonblank, count)] events = [ - {"id": chart_id, "type": "lost", "phase": "create", "at_ms": 10.0 + i} + {"id": chart_id, "type": "lost", "phase": "create", "at_ms": 10.0 + i, "governed": False} for i, chart_id in enumerate(blank_ids) ] row.update( @@ -497,6 +502,9 @@ def _set_dashboard_partial(row: dict, nonblank: int = 16) -> None: "scroll_nonblank_charts": nonblank, "scroll_nonblank_chart_ids": lit_ids, "scroll_blank_chart_ids": blank_ids, + "governed_context_lost_events": 0, + "released_chart_ids": [], + "evicted_chart_ids": blank_ids, "context_lost_events": len(events), "context_restored_events": 0, "context_lost_chart_ids": blank_ids, @@ -507,6 +515,46 @@ def _set_dashboard_partial(row: dict, nonblank: int = 16) -> None: ) +def _set_dashboard_governed(row: dict, live: int = 12) -> None: + # A governed row: every chart created and nonblank while visited; all + # context losses were governed releases; off-screen charts hold no context. + count = row["chart_count"] + released_ids = [f"chart-{i}" for i in range(count - live)] + lit_now_ids = [f"chart-{i}" for i in range(count - live, count)] + all_ids = [f"chart-{i}" for i in range(count)] + events = [ + {"id": cid, "type": "lost", "phase": "scroll", "at_ms": 40.0 + i, "governed": True} + for i, cid in enumerate(released_ids) + ] + [ + {"id": cid, "type": "restored", "phase": "scroll", "at_ms": 60.0 + i} + for i, cid in enumerate(released_ids) + ] + row.update( + { + "render_status": "governed", + "fully_nonblank": False, + "steady_redraw_active_charts": live, + "nonblank_charts": live, + "initial_nonblank_charts": live, + "initial_nonblank_chart_ids": lit_now_ids, + "initial_blank_chart_ids": released_ids, + "scroll_nonblank_charts": count, + "scroll_nonblank_chart_ids": all_ids, + "scroll_blank_chart_ids": [], + "scroll_recovery_p95_ms": 9.0, + "governed_context_lost_events": len(released_ids), + "released_chart_ids": released_ids, + "evicted_chart_ids": [], + "context_lost_events": len(released_ids), + "context_restored_events": len(released_ids), + "context_lost_chart_ids": released_ids, + "context_restored_chart_ids": released_ids, + "currently_lost_chart_ids": released_ids, + "context_events": events, + } + ) + + def _workflow_native_report() -> dict: categories, tracked = _category_registry( "input_ingestion", "streaming_updates", "static_export" @@ -645,6 +693,20 @@ def test_dashboard_report_accepts_partial_rows_with_context_telemetry(tmp_path: _set_dashboard_partial(payload["rows"][1]) _set_dashboard_partial(payload["rows"][2]) payload["chart_count_ceiling"] = 10 + payload["visible_stable_chart_ceiling"] = 10 + path = _write_report(tmp_path, payload) + + errors = verify_benchmark_report.validate_report(path, kind="dashboard-browser") + + assert errors == [] + + +def test_dashboard_report_accepts_governed_rows(tmp_path: Path) -> None: + payload = _dashboard_browser_report() + _set_dashboard_governed(payload["rows"][1]) + _set_dashboard_governed(payload["rows"][2]) + payload["chart_count_ceiling"] = 10 + payload["visible_stable_chart_ceiling"] = 50 path = _write_report(tmp_path, payload) errors = verify_benchmark_report.validate_report(path, kind="dashboard-browser") @@ -662,6 +724,22 @@ def test_dashboard_report_rejects_broken_ten_chart_smoke(tmp_path: Path) -> None assert any("10-chart smoke row" in error and "fully nonblank" in error for error in errors) +def test_dashboard_report_rejects_mislabeled_governed_row(tmp_path: Path) -> None: + payload = _dashboard_browser_report() + _set_dashboard_governed(payload["rows"][2]) + # An ungoverned eviction must demote the row to "partial". + row = payload["rows"][2] + row["context_events"][0]["governed"] = False + row["governed_context_lost_events"] -= 1 + payload["chart_count_ceiling"] = 10 + payload["visible_stable_chart_ceiling"] = 50 + path = _write_report(tmp_path, payload) + + errors = verify_benchmark_report.validate_report(path, kind="dashboard-browser") + + assert any("render_status must be 'partial'" in error for error in errors) + + def test_dashboard_report_rejects_catastrophic_smoke_timing(tmp_path: Path) -> None: payload = _dashboard_browser_report() payload["rows"][0]["steady_redraw_p95_ms"] = 101.0