diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a362233a..faa9e355 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,14 @@ jobs: CHROME=$(node -e "console.log(require('playwright').chromium.executablePath())") .venv/bin/python scripts/interaction_stress_smoke.py "$CHROME" + - name: Browser dashboard reliability smoke (Chromium) + run: | + CHROME=$(node -e "console.log(require('playwright').chromium.executablePath())") + .venv/bin/python benchmarks/bench_dashboard.py \ + --chart-counts 10,20,50 --chromium "$CHROME" --json dashboard-smoke.json + .venv/bin/python scripts/verify_benchmark_report.py \ + dashboard-smoke.json --kind dashboard-browser + - name: Benchmark smoke (1e5/1e6 — §12 harness runs every phase) run: .venv/bin/python scripts/bench.py --sizes 1e5,1e6 diff --git a/CHANGELOG.md b/CHANGELOG.md index b357dbc2..fd0fd20c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,13 @@ in the README). - `LICENSE` (Apache-2.0), `CHANGELOG.md`, `SECURITY.md`, root `CONTRIBUTING.md`. ### Changed +- **Rendering hardening:** context loss now quiesces draw/animation/re-bin work, + invalidates pre-loss replies, retains streamed canonical payloads, reports + recovery state, and rebuilds without throwing an unhandled event error. The + dependency-free browser smoke forces three pixel-identical recovery cycles + and verifies interaction afterward. CI now hard-gates a loss-free 10-chart + dashboard, pins interaction/visual budget ceilings in the verifier, and + fails timing regressions beyond 4x while retaining the 2x advisory band. - **Native PNG export compression** dropped from zlib level 9 to level 6: a 1M-point line export goes from ~298 ms to ~64 ms (reference hardware) for ~2.65% larger output. Regression tests pin the level for both truecolor diff --git a/docs/design/benchmark-methodology.md b/docs/design/benchmark-methodology.md index 6c6da20c..ce349aa6 100644 --- a/docs/design/benchmark-methodology.md +++ b/docs/design/benchmark-methodology.md @@ -122,6 +122,8 @@ reported). prep, navigation readiness, JS heap, redraw-submission p95, per-chart context loss/restoration events, and the stable loss-free chart-count ceiling. Partial dashboards remain successful measurement rows rather than losing their metrics. + CI hard-gates the 10-chart row as loss-free/nonblank and applies deliberately + loose catastrophic budgets to its render, scroll, and redraw timings. 8. `install_import`: lower-bound distribution size plus opt-in fresh-venv total site-packages, transitive distribution count, install time, and cold import. 9. `public_workflows`: `benchmarks/bench_workflows.py` tracks ingestion shapes, @@ -138,3 +140,8 @@ reported). `dashboard_20` scenario. 4. Reference-hardware runbook (`benchmarks/README`): exact pins + one-command repro; publish both tiers on the next README refresh. + +Timing regression policy is two-level: movement beyond 2x is advisory on shared +runners, while movement beyond 4x is a hard failure. Interaction and visual +budgets are capped in the report verifier so a benchmark change cannot silently +make its own gate easier. diff --git a/docs/design/renderer-architecture.md b/docs/design/renderer-architecture.md index 2524f033..1f1ba04d 100644 --- a/docs/design/renderer-architecture.md +++ b/docs/design/renderer-architecture.md @@ -47,11 +47,15 @@ Ordered by how much each compounds as kinds multiply. adopt when a second state-toggling pass lands (e.g. scissored panels or an additive-blend mark). - **R4 — No GL context-loss handling.** ✅ **Done.** - `_initContextLossRecovery` listens for loss (preventDefault, halt RAF) and - restore (drop dead handles, re-run `_initGl` from the retained - screen-bounded payload, re-fire the view request to re-sync live tiers). - Smoke probe forces `WEBGL_lose_context` loss/restore and asserts the - rebuilt frame hashes pixel-identical (`ctxloss` flag). + `_initContextLossRecovery` listens for loss, prevents default eviction + handling, quiesces draw/animation/re-bin work, and increments the request + sequence so pre-loss kernel/worker replies cannot mutate the rebuilt state. + Streaming appends still replace the retained canonical payload while the + context is down. Restore drops dead handles, re-runs `_initGl` from that + payload, and re-fires the view request to re-sync live tiers; a failed + restore remains explicitly failed instead of throwing from the event + handler. The dependency-free smoke forces three `WEBGL_lose_context` + cycles, checks pixel-identical frames after each, and zooms after recovery. - **R5 — Shader source conventions are informal.** ✅ **Done.** `build.mjs` lints every shader at build time: `#version 300 es` first line, every FS declares `precision highp float;`, every VS references a `u_*map` uniform diff --git a/examples/demo.ipynb b/examples/demo.ipynb index 2a5171c0..9c55b9ab 100644 --- a/examples/demo.ipynb +++ b/examples/demo.ipynb @@ -240,10 +240,14 @@ " doc = re.sub(\n", " r'',\n", " '',\n", - " doc, count=1, flags=re.S)\n", + " \"'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com data: blob:; img-src * data:;\\\">\",\n", + " doc,\n", + " count=1,\n", + " flags=re.S,\n", + " )\n", " return doc.replace(\"\", '', 1)\n", "\n", + "\n", "def styled(chart, custom_css=None, tailwind=False, height=420):\n", " \"\"\"Render a chart's self-contained HTML (optionally +Tailwind) inside an iframe.\"\"\"\n", " with tempfile.NamedTemporaryFile(\"w+\", suffix=\".html\", delete=True) as f:\n", @@ -256,15 +260,21 @@ " 'srcdoc=\"' + _html.escape(doc, quote=True) + '\">'\n", " )\n", "\n", + "\n", "# shared data for the styling variations\n", "sx = rng.normal(0, 1, 4000)\n", "sy = sx * 0.5 + rng.normal(0, 0.6, 4000)\n", "sd = sx**2 + sy**2 # continuous -> colormap\n", + "\n", + "\n", "def base_scatter(title, cmap):\n", " return fc.scatter_chart(\n", " fc.scatter(x=sx, y=sy, color=sd, colormap=cmap, opacity=0.75, size=5),\n", - " fc.x_axis(label=\"x\"), fc.y_axis(label=\"y\"),\n", - " title=title, width=\"100%\", height=360,\n", + " fc.x_axis(label=\"x\"),\n", + " fc.y_axis(label=\"y\"),\n", + " title=title,\n", + " width=\"100%\",\n", + " height=360,\n", " )" ] }, @@ -311,18 +321,35 @@ "tt = np.linspace(0, 12, 240)\n", "wave = np.sin(tt) + 0.12 * np.cumsum(rng.normal(0, 0.1, 240))\n", "area = fc.area_chart(\n", - " fc.area(x=tt, y=wave, color=\"#3b82f6\", curve=\"smooth\",\n", - " fill=\"linear-gradient(currentColor, transparent)\", line_width=2),\n", + " fc.area(\n", + " x=tt,\n", + " y=wave,\n", + " color=\"#3b82f6\",\n", + " curve=\"smooth\",\n", + " fill=\"linear-gradient(currentColor, transparent)\",\n", + " line_width=2,\n", + " ),\n", " fc.line(x=tt, y=wave + 2.4, color=\"#ec4899\", dash=\"dashed\", width=2, curve=\"smooth\"),\n", - " fc.x_axis(label=\"t\"), fc.y_axis(label=\"value\"),\n", - " title=\"gradient area + dashed line (mark props)\", width=\"100%\", height=340,\n", + " fc.x_axis(label=\"t\"),\n", + " fc.y_axis(label=\"value\"),\n", + " title=\"gradient area + dashed line (mark props)\",\n", + " width=\"100%\",\n", + " height=340,\n", ")\n", "bars = fc.bar_chart(\n", - " fc.bar(x=[\"Q1\", \"Q2\", \"Q3\", \"Q4\", \"Q5\", \"Q6\"], y=[5, 9, 4, 7, 6, 8],\n", - " corner_radius=(10, 0), stroke=\"#1e293b\", stroke_width=1.25,\n", - " fill=\"linear-gradient(to top, #6366f1, #a5b4fc)\"),\n", - " fc.x_axis(label=\"quarter\"), fc.y_axis(label=\"value\"),\n", - " title=\"rounded, stroked, gradient bars\", width=\"100%\", height=340,\n", + " fc.bar(\n", + " x=[\"Q1\", \"Q2\", \"Q3\", \"Q4\", \"Q5\", \"Q6\"],\n", + " y=[5, 9, 4, 7, 6, 8],\n", + " corner_radius=(10, 0),\n", + " stroke=\"#1e293b\",\n", + " stroke_width=1.25,\n", + " fill=\"linear-gradient(to top, #6366f1, #a5b4fc)\",\n", + " ),\n", + " fc.x_axis(label=\"quarter\"),\n", + " fc.y_axis(label=\"value\"),\n", + " title=\"rounded, stroked, gradient bars\",\n", + " width=\"100%\",\n", + " height=340,\n", ")\n", "_css = \".fastcharts{--chart-text:#334155;font-family:ui-sans-serif,system-ui}\"\n", "display(styled(area, custom_css=_css, height=320))\n", @@ -339,9 +366,11 @@ "# Variation 3 — Tailwind utilities on chrome slots (class_names) + root class_name.\n", "tw = fc.scatter_chart(\n", " fc.scatter(x=sx, y=sy, color=sd, colormap=\"viridis\", opacity=0.8, size=5),\n", - " fc.x_axis(label=\"x\"), fc.y_axis(label=\"y\"),\n", + " fc.x_axis(label=\"x\"),\n", + " fc.y_axis(label=\"y\"),\n", " title=\"Tailwind-styled chrome\",\n", - " width=\"100%\", height=360,\n", + " width=\"100%\",\n", + " height=360,\n", " class_name=\"bg-slate-900 rounded-2xl p-3 ring-1 ring-indigo-500/30\",\n", " class_names={\n", " \"title\": \"text-indigo-300 font-semibold tracking-wide\",\n", @@ -353,9 +382,12 @@ ")\n", "# tailwind=True injects the Play CDN for this standalone demo (needs network);\n", "# a Reflex/Tailwind host already has Tailwind, so class_names apply with no CDN.\n", - "styled(tw, tailwind=True,\n", - " custom_css=\".fastcharts{--chart-text:#cbd5e1;--chart-grid:rgba(148,163,184,.18);--chart-bg:transparent}\",\n", - " height=380)" + "styled(\n", + " tw,\n", + " tailwind=True,\n", + " custom_css=\".fastcharts{--chart-text:#cbd5e1;--chart-grid:rgba(148,163,184,.18);--chart-bg:transparent}\",\n", + " height=380,\n", + ")" ] }, { @@ -395,11 +427,20 @@ "mobile = desktop * (0.40 + 0.20 * rng.random(n))\n", "\n", "analytics = fc.area_chart(\n", - " fc.area(x=days, y=desktop, name=\"Desktop\", color=\"#880808\",\n", - " line_width=1.5, line_opacity=1,\n", - " fill={\"gradient\": \"linear-gradient(to bottom, #6E260E, #FF0000)\", \"space\": \"plot\"}),\n", - " fc.x_axis(label=None), fc.y_axis(label=None),\n", - " width=\"100%\", height=320, padding=[18, 20, 46, 20],\n", + " fc.area(\n", + " x=days,\n", + " y=desktop,\n", + " name=\"Desktop\",\n", + " color=\"#880808\",\n", + " line_width=1.5,\n", + " line_opacity=1,\n", + " fill={\"gradient\": \"linear-gradient(to bottom, #6E260E, #FF0000)\", \"space\": \"plot\"},\n", + " ),\n", + " fc.x_axis(label=None),\n", + " fc.y_axis(label=None),\n", + " width=\"100%\",\n", + " height=320,\n", + " padding=[18, 20, 46, 20],\n", ")\n", "analytics_css = \"\"\"\n", ".fastcharts{ --chart-bg:#ffffff; --chart-text:#94a3b8; --chart-grid:rgba(15,23,42,.045);\n", diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index b3e2d930..6fc3d9c8 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -80,7 +80,11 @@ class ChartView { // spec + payload by design (§18/§27). this._payload = buffer; this._glLost = false; + this._contextLossCount = 0; + this._contextRestoreCount = 0; + this._contextRecoveryError = null; this._initGl(buffer); + this.root.dataset.fcContextState = "ready"; this._initContextLossRecovery(); this._initInteraction(); this._buildModebar(this.root); // after theme (icon color) + canvas (cursor) @@ -407,13 +411,35 @@ class ChartView { _initContextLossRecovery() { this._listen(this.canvas, "webglcontextlost", (e) => { e.preventDefault(); + if (this._destroyed || this._glLost) return; this._glLost = true; + this._contextLossCount += 1; + this._contextRecoveryError = null; + this.root.dataset.fcContextState = "lost"; + // Quiesce every source of deferred GPU work, not only the draw RAF. + // Incrementing seq makes pre-loss kernel/worker replies stale, so they + // cannot populate the newly restored context with an old view. + this.seq += 1; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; + if (this._wheelZoomRaf) cancelAnimationFrame(this._wheelZoomRaf); + this._wheelZoomRaf = null; + this._pendingWheelZoom = null; + this._cancelViewAnimation(); + clearTimeout(this._viewTimer); + this._viewTimer = null; + clearTimeout(this._rebinTimer); + this._rebinTimer = null; + this._viewRequestBurstStart = null; + this._dispatchChartEvent("context_lost", { + loss_count: this._contextLossCount, + }); }); this._listen(this.canvas, "webglcontextrestored", () => { - if (this._destroyed) return; - this._glLost = false; + // A failed recovery replaced the canvas with the error message; a later + // restore firing on the detached canvas must not resurrect GL state the + // user can no longer see. + if (this._destroyed || this._contextRecoveryError) return; // Old handles died with the context — drop them without delete calls. this._lutCache.clear(); this.pickFbo = null; @@ -421,11 +447,28 @@ class ChartView { try { this._initGl(this._payload); } catch (err) { + this._glLost = true; + this._contextRecoveryError = err; + this.root.dataset.fcContextState = "failed"; + try { this._destroyGlResources(); } catch (_cleanupErr) {} + this.gl = null; + this._dispatchChartEvent("context_restore_failed", { + loss_count: this._contextLossCount, + message: err instanceof Error ? err.message : String(err), + }); this.root.textContent = "fastcharts: WebGL2 context could not be restored."; - throw err; + return; } + this._glLost = false; + this._contextRestoreCount += 1; + this._contextRecoveryError = null; + this.root.dataset.fcContextState = "ready"; this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); + this._dispatchChartEvent("context_restored", { + loss_count: this._contextLossCount, + restore_count: this._contextRestoreCount, + }); }); } @@ -1308,7 +1351,7 @@ class ChartView { } draw() { - if (this._destroyed) return; + if (this._destroyed || this._glLost || !this.gl) return; if (this._raf) return; this._raf = requestAnimationFrame(() => { this._raf = null; diff --git a/js/src/54_kernel.js b/js/src/54_kernel.js index 6cdd43a3..adc07966 100644 --- a/js/src/54_kernel.js +++ b/js/src/54_kernel.js @@ -5,7 +5,7 @@ Object.assign(ChartView.prototype, { _scheduleViewRequest(viewOverride = this.view, opts = {}) { - if (this._destroyed) return; + if (this._destroyed || this._glLost) return; if (!this.comm) { // Kernel-less (standalone HTML): density traces refine via the bundled // re-bin worker instead of a kernel round-trip. @@ -74,7 +74,7 @@ Object.assign(ChartView.prototype, { // request path, then the retained §28 sample re-bins in the bundled worker — // off the main thread — and applies like a density_update. _scheduleSampleRebin(viewOverride = this.view, opts = {}) { - if (this._destroyed || this._sampleRebinDisabled) return; + if (this._destroyed || this._glLost || this._sampleRebinDisabled) return; const targets = (this.gpuTraces || []).filter( (g) => g.tier === "density" && g.sampleOverlay && g.sampleOverlay._cpu ); @@ -144,7 +144,7 @@ Object.assign(ChartView.prototype, { }, _onRebinResult(msg) { - if (this._destroyed || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; + if (this._destroyed || this._glLost || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; const g = this.gpuTraces.find((t) => t.trace.id === msg.trace && t.tier === "density"); if (!g) return; const grid = new Float32Array(msg.grid); @@ -201,14 +201,6 @@ Object.assign(ChartView.prototype, { this.spec = spec; this.axes = this._normalizeAxes(spec); this._payload = blob; - const texSeen = new Set(); - for (const id of msg.affected || []) { - const i = this.gpuTraces.findIndex((g) => g.trace.id === id); - const ts = spec.traces.find((t) => t.id === id); - if (i < 0 || !ts) continue; - this._destroyTraceResources(this.gpuTraces[i], texSeen); - this.gpuTraces[i] = this._buildTrace(blob, ts); - } this.view0 = { x0: spec.x_axis.range[0], x1: spec.x_axis.range[1], y0: spec.y_axis.range[0], y1: spec.y_axis.range[1], @@ -219,6 +211,19 @@ Object.assign(ChartView.prototype, { const w = this.view.x1 - this.view.x0; this.view = { ...this.view, x1: this.view0.x1, x0: this.view0.x1 - w }; } + // Append payloads are canonical state, so retain them even while the + // context is lost. The restore path rebuilds every affected GPU object + // from this latest payload; attempting partial uploads to a dead context + // would only create handles that must immediately be discarded. + if (this._glLost || !this.gl) return; + const texSeen = new Set(); + for (const id of msg.affected || []) { + const i = this.gpuTraces.findIndex((g) => g.trace.id === id); + const ts = spec.traces.find((t) => t.id === id); + if (i < 0 || !ts) continue; + this._destroyTraceResources(this.gpuTraces[i], texSeen); + this.gpuTraces[i] = this._buildTrace(blob, ts); + } this._pickable = this.gpuTraces.some( (g) => markOf(g.trace.kind).pointPick && (g.tier !== "density" || g.drill)); if (this._pickable && !this.pickFbo) this._initPickTarget(); @@ -229,6 +234,7 @@ Object.assign(ChartView.prototype, { _onKernelMsg(msg, buffers) { if (this._destroyed) return; if (!msg) return; + if (this._glLost && msg.type !== "append" && msg.type !== "pick_result") return; if (msg.type === "tier_update") { if (msg.seq !== this.seq) return; for (const upd of msg.traces) { diff --git a/python/fastcharts/static/index.js b/python/fastcharts/static/index.js index 306bf1b7..5f678220 100644 --- a/python/fastcharts/static/index.js +++ b/python/fastcharts/static/index.js @@ -1384,7 +1384,11 @@ this._buildDom(el); this.theme = readTheme(this.root); this._payload = buffer; this._glLost = false; +this._contextLossCount = 0; +this._contextRestoreCount = 0; +this._contextRecoveryError = null; this._initGl(buffer); +this.root.dataset.fcContextState = "ready"; this._initContextLossRecovery(); this._initInteraction(); this._buildModebar(this.root); @@ -1664,24 +1668,57 @@ this._dprMq = mq; _initContextLossRecovery() { this._listen(this.canvas, "webglcontextlost", (e) => { e.preventDefault(); +if (this._destroyed || this._glLost) return; this._glLost = true; +this._contextLossCount += 1; +this._contextRecoveryError = null; +this.root.dataset.fcContextState = "lost"; +this.seq += 1; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; +if (this._wheelZoomRaf) cancelAnimationFrame(this._wheelZoomRaf); +this._wheelZoomRaf = null; +this._pendingWheelZoom = null; +this._cancelViewAnimation(); +clearTimeout(this._viewTimer); +this._viewTimer = null; +clearTimeout(this._rebinTimer); +this._rebinTimer = null; +this._viewRequestBurstStart = null; +this._dispatchChartEvent("context_lost", { +loss_count: this._contextLossCount, +}); }); this._listen(this.canvas, "webglcontextrestored", () => { -if (this._destroyed) return; -this._glLost = false; +if (this._destroyed || this._contextRecoveryError) return; this._lutCache.clear(); this.pickFbo = null; this.pickTex = null; try { this._initGl(this._payload); } catch (err) { +this._glLost = true; +this._contextRecoveryError = err; +this.root.dataset.fcContextState = "failed"; +try { this._destroyGlResources(); } catch (_cleanupErr) {} +this.gl = null; +this._dispatchChartEvent("context_restore_failed", { +loss_count: this._contextLossCount, +message: err instanceof Error ? err.message : String(err), +}); this.root.textContent = "fastcharts: WebGL2 context could not be restored."; -throw err; +return; } +this._glLost = false; +this._contextRestoreCount += 1; +this._contextRecoveryError = null; +this.root.dataset.fcContextState = "ready"; this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dispatchChartEvent("context_restored", { +loss_count: this._contextLossCount, +restore_count: this._contextRestoreCount, +}); }); } _resize(cssW, cssH) { @@ -2436,7 +2473,7 @@ gl.uniform2f(u(`${prefix}meta`), meta && Number.isFinite(meta.offset) ? meta.off gl.uniform1i(u(`${prefix}mode`), this._axisMode(axisId)); } draw() { -if (this._destroyed) return; +if (this._destroyed || this._glLost || !this.gl) return; if (this._raf) return; this._raf = requestAnimationFrame(() => { this._raf = null; @@ -4398,7 +4435,7 @@ return svg(""); }); Object.assign(ChartView.prototype, { _scheduleViewRequest(viewOverride = this.view, opts = {}) { -if (this._destroyed) return; +if (this._destroyed || this._glLost) return; if (!this.comm) { this._scheduleSampleRebin(viewOverride, opts); return; @@ -4461,7 +4498,7 @@ this._viewTimer = setTimeout(send, delay); return seq; }, _scheduleSampleRebin(viewOverride = this.view, opts = {}) { -if (this._destroyed || this._sampleRebinDisabled) return; +if (this._destroyed || this._glLost || this._sampleRebinDisabled) return; const targets = (this.gpuTraces || []).filter( (g) => g.tier === "density" && g.sampleOverlay && g.sampleOverlay._cpu ); @@ -4526,7 +4563,7 @@ h: Math.max(16, Math.min(2048, Math.round(this.plot.h))), }); }, _onRebinResult(msg) { -if (this._destroyed || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; +if (this._destroyed || this._glLost || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; const g = this.gpuTraces.find((t) => t.trace.id === msg.trace && t.tier === "density"); if (!g) return; const grid = new Float32Array(msg.grid); @@ -4564,14 +4601,6 @@ const pinnedRight = !atHome && Math.abs(this.view.x1 - this.view0.x1) <= ex; this.spec = spec; this.axes = this._normalizeAxes(spec); this._payload = blob; -const texSeen = new Set(); -for (const id of msg.affected || []) { -const i = this.gpuTraces.findIndex((g) => g.trace.id === id); -const ts = spec.traces.find((t) => t.id === id); -if (i < 0 || !ts) continue; -this._destroyTraceResources(this.gpuTraces[i], texSeen); -this.gpuTraces[i] = this._buildTrace(blob, ts); -} this.view0 = { x0: spec.x_axis.range[0], x1: spec.x_axis.range[1], y0: spec.y_axis.range[0], y1: spec.y_axis.range[1], @@ -4582,6 +4611,15 @@ this.view = { ...this.view0 }; const w = this.view.x1 - this.view.x0; this.view = { ...this.view, x1: this.view0.x1, x0: this.view0.x1 - w }; } +if (this._glLost || !this.gl) return; +const texSeen = new Set(); +for (const id of msg.affected || []) { +const i = this.gpuTraces.findIndex((g) => g.trace.id === id); +const ts = spec.traces.find((t) => t.id === id); +if (i < 0 || !ts) continue; +this._destroyTraceResources(this.gpuTraces[i], texSeen); +this.gpuTraces[i] = this._buildTrace(blob, ts); +} this._pickable = this.gpuTraces.some( (g) => markOf(g.trace.kind).pointPick && (g.tier !== "density" || g.drill)); if (this._pickable && !this.pickFbo) this._initPickTarget(); @@ -4591,6 +4629,7 @@ this.draw(); _onKernelMsg(msg, buffers) { if (this._destroyed) return; if (!msg) return; +if (this._glLost && msg.type !== "append" && msg.type !== "pick_result") return; if (msg.type === "tier_update") { if (msg.seq !== this.seq) return; for (const upd of msg.traces) { diff --git a/python/fastcharts/static/standalone.js b/python/fastcharts/static/standalone.js index 8dc3b4fe..793e5b55 100644 --- a/python/fastcharts/static/standalone.js +++ b/python/fastcharts/static/standalone.js @@ -1385,7 +1385,11 @@ this._buildDom(el); this.theme = readTheme(this.root); this._payload = buffer; this._glLost = false; +this._contextLossCount = 0; +this._contextRestoreCount = 0; +this._contextRecoveryError = null; this._initGl(buffer); +this.root.dataset.fcContextState = "ready"; this._initContextLossRecovery(); this._initInteraction(); this._buildModebar(this.root); @@ -1665,24 +1669,57 @@ this._dprMq = mq; _initContextLossRecovery() { this._listen(this.canvas, "webglcontextlost", (e) => { e.preventDefault(); +if (this._destroyed || this._glLost) return; this._glLost = true; +this._contextLossCount += 1; +this._contextRecoveryError = null; +this.root.dataset.fcContextState = "lost"; +this.seq += 1; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; +if (this._wheelZoomRaf) cancelAnimationFrame(this._wheelZoomRaf); +this._wheelZoomRaf = null; +this._pendingWheelZoom = null; +this._cancelViewAnimation(); +clearTimeout(this._viewTimer); +this._viewTimer = null; +clearTimeout(this._rebinTimer); +this._rebinTimer = null; +this._viewRequestBurstStart = null; +this._dispatchChartEvent("context_lost", { +loss_count: this._contextLossCount, +}); }); this._listen(this.canvas, "webglcontextrestored", () => { -if (this._destroyed) return; -this._glLost = false; +if (this._destroyed || this._contextRecoveryError) return; this._lutCache.clear(); this.pickFbo = null; this.pickTex = null; try { this._initGl(this._payload); } catch (err) { +this._glLost = true; +this._contextRecoveryError = err; +this.root.dataset.fcContextState = "failed"; +try { this._destroyGlResources(); } catch (_cleanupErr) {} +this.gl = null; +this._dispatchChartEvent("context_restore_failed", { +loss_count: this._contextLossCount, +message: err instanceof Error ? err.message : String(err), +}); this.root.textContent = "fastcharts: WebGL2 context could not be restored."; -throw err; +return; } +this._glLost = false; +this._contextRestoreCount += 1; +this._contextRecoveryError = null; +this.root.dataset.fcContextState = "ready"; this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dispatchChartEvent("context_restored", { +loss_count: this._contextLossCount, +restore_count: this._contextRestoreCount, +}); }); } _resize(cssW, cssH) { @@ -2437,7 +2474,7 @@ gl.uniform2f(u(`${prefix}meta`), meta && Number.isFinite(meta.offset) ? meta.off gl.uniform1i(u(`${prefix}mode`), this._axisMode(axisId)); } draw() { -if (this._destroyed) return; +if (this._destroyed || this._glLost || !this.gl) return; if (this._raf) return; this._raf = requestAnimationFrame(() => { this._raf = null; @@ -4399,7 +4436,7 @@ return svg(""); }); Object.assign(ChartView.prototype, { _scheduleViewRequest(viewOverride = this.view, opts = {}) { -if (this._destroyed) return; +if (this._destroyed || this._glLost) return; if (!this.comm) { this._scheduleSampleRebin(viewOverride, opts); return; @@ -4462,7 +4499,7 @@ this._viewTimer = setTimeout(send, delay); return seq; }, _scheduleSampleRebin(viewOverride = this.view, opts = {}) { -if (this._destroyed || this._sampleRebinDisabled) return; +if (this._destroyed || this._glLost || this._sampleRebinDisabled) return; const targets = (this.gpuTraces || []).filter( (g) => g.tier === "density" && g.sampleOverlay && g.sampleOverlay._cpu ); @@ -4527,7 +4564,7 @@ h: Math.max(16, Math.min(2048, Math.round(this.plot.h))), }); }, _onRebinResult(msg) { -if (this._destroyed || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; +if (this._destroyed || this._glLost || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; const g = this.gpuTraces.find((t) => t.trace.id === msg.trace && t.tier === "density"); if (!g) return; const grid = new Float32Array(msg.grid); @@ -4565,14 +4602,6 @@ const pinnedRight = !atHome && Math.abs(this.view.x1 - this.view0.x1) <= ex; this.spec = spec; this.axes = this._normalizeAxes(spec); this._payload = blob; -const texSeen = new Set(); -for (const id of msg.affected || []) { -const i = this.gpuTraces.findIndex((g) => g.trace.id === id); -const ts = spec.traces.find((t) => t.id === id); -if (i < 0 || !ts) continue; -this._destroyTraceResources(this.gpuTraces[i], texSeen); -this.gpuTraces[i] = this._buildTrace(blob, ts); -} this.view0 = { x0: spec.x_axis.range[0], x1: spec.x_axis.range[1], y0: spec.y_axis.range[0], y1: spec.y_axis.range[1], @@ -4583,6 +4612,15 @@ this.view = { ...this.view0 }; const w = this.view.x1 - this.view.x0; this.view = { ...this.view, x1: this.view0.x1, x0: this.view0.x1 - w }; } +if (this._glLost || !this.gl) return; +const texSeen = new Set(); +for (const id of msg.affected || []) { +const i = this.gpuTraces.findIndex((g) => g.trace.id === id); +const ts = spec.traces.find((t) => t.id === id); +if (i < 0 || !ts) continue; +this._destroyTraceResources(this.gpuTraces[i], texSeen); +this.gpuTraces[i] = this._buildTrace(blob, ts); +} this._pickable = this.gpuTraces.some( (g) => markOf(g.trace.kind).pointPick && (g.tier !== "density" || g.drill)); if (this._pickable && !this.pickFbo) this._initPickTarget(); @@ -4592,6 +4630,7 @@ this.draw(); _onKernelMsg(msg, buffers) { if (this._destroyed) return; if (!msg) return; +if (this._glLost && msg.type !== "append" && msg.type !== "pick_result") return; if (msg.type === "tier_update") { if (msg.seq !== this.seq) return; for (const upd of msg.traces) { diff --git a/scripts/check_regressions.py b/scripts/check_regressions.py index b03b4e02..d36fc837 100644 --- a/scripts/check_regressions.py +++ b/scripts/check_regressions.py @@ -8,10 +8,10 @@ functions of N and the grid — byte-identical on every machine. Gated **hard** with a hair of tolerance; a regression here is always a real one (the screen-bounded-payload invariant broke), so CI fails. -- **Timing** (kernel Mpt/s, prep ms): vary wildly across shared runners. Gated - **advisory** with a 2x band — reported and annotated, but never fails the - build on noise. A >2x drop (e.g. someone deleted the parallel path) still - gets surfaced loudly. +- **Timing** (kernel Mpt/s, prep ms): vary wildly across shared runners. A 2x + move is advisory; a 4x move is a hard catastrophic-regression gate. This + leaves room for shared-runner noise while still catching deleted fast paths, + accidental quadratic work, and other changes too large to wave away. The baseline stores only *measured values*; the gate policy lives here (classified by metric-id suffix) so re-blessing is a values-only diff. @@ -90,6 +90,19 @@ def regressed(cmp: str, base, cur, tol: float) -> bool: return False +def catastrophic_timing_regression(metric_id: str, base, cur) -> bool: + """True only for timing movement too large to attribute to CI noise.""" + if base in (None, 0) or not isinstance(base, (int, float)): + return False + if not isinstance(cur, (int, float)): + return False + if "_mpts_s." in metric_id: + return cur < base * 0.25 + if "_ms." in metric_id: + return cur > base * 4.0 + return False + + def _fmt(v) -> str: if isinstance(v, float): return f"{v:,.2f}" @@ -143,8 +156,10 @@ def main() -> None: cmp, gate, tol = policy(mid) b, c = base[mid], current[mid] if regressed(cmp, b, c, tol): - (hard if gate == "hard" else advisory).append((mid, b, c)) - rows.append((mid, _fmt(b), _fmt(c), "REGRESS" if gate == "hard" else "warn")) + catastrophic = catastrophic_timing_regression(mid, b, c) + is_hard = gate == "hard" or catastrophic + (hard if is_hard else advisory).append((mid, b, c)) + rows.append((mid, _fmt(b), _fmt(c), "REGRESS" if is_hard else "warn")) else: rows.append((mid, _fmt(b), _fmt(c), "ok")) @@ -173,7 +188,7 @@ def main() -> None: Path(args.emit_md).write_text("\n".join(lines) + "\n", encoding="utf-8") if hard: - print(f"\n{len(hard)} HARD regression(s) — deterministic metric moved the wrong way:") + print(f"\n{len(hard)} HARD regression(s):") for mid, b, c in hard: print(f" ✗ {mid}: baseline {_fmt(b)} -> current {_fmt(c)}") raise SystemExit(1) diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index c5cfb5d6..aa32a120 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -906,42 +906,91 @@ def main() -> None: cleanup(); const unsub=(offCalled===1 && holder3.querySelector(".fastcharts")===null)?1:0; holder3.remove(); - // R4: GL context loss must be survivable — preventDefault + rebuild from - // the retained payload on restore, ending pixel-identical to pre-loss. - const holder4=document.createElement("div"); - document.body.appendChild(holder4); - const v4=fastcharts.renderStandalone(holder4,spec,bytes.buffer); - v4._densityNormAnim=null; - v4._drawNow(); - const ctxHashBefore=pixhash(v4); - const loseExt=v4.gl.getExtension("WEBGL_lose_context"); - let lostSeen=0; - if (loseExt) loseExt.loseContext(); - setTimeout(()=>{{try{{ - lostSeen=(v4._glLost===true)?1:0; - if (loseExt) loseExt.restoreContext(); - setTimeout(()=>{{try{{ - v4._densityNormAnim=null; + (async()=>{{try{{ + // R4: force repeated loss/restore cycles. Each cycle queues draw, + // animation, and re-bin work first so the loss handler must quiesce + // every deferred GPU path and invalidate pre-loss replies. + const holder4=document.createElement("div"); + document.body.appendChild(holder4); + const v4=fastcharts.renderStandalone(holder4,spec,bytes.buffer); + // Compare settled frames, not the constructor's intentionally + // transitional first density/sample handoff. + await new Promise((resolve)=>setTimeout(resolve,180)); + for(const g of v4.gpuTraces) g._densityNormAnim=null; + v4._drawNow(); + const ctxHashBefore=pixhash(v4); + let rootLost=0,rootRestored=0; + v4.root.addEventListener("fastcharts:context_lost",()=>rootLost++); + v4.root.addEventListener("fastcharts:context_restored",()=>rootRestored++); + const waitFor=(predicate,label)=>new Promise((resolve,reject)=>{{ + const deadline=performance.now()+1500; + const poll=()=>{{ + if(predicate()){{resolve();return;}} + if(performance.now()>=deadline){{reject(new Error(`timeout waiting for ${{label}}`));return;}} + setTimeout(poll,20); + }}; + poll(); + }}); + const litcount=(view)=>{{ + const gl=view.gl,w=gl.drawingBufferWidth,h=gl.drawingBufferHeight; + const px=new Uint8Array(w*h*4);gl.readPixels(0,0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,px); + let n=0;for(let i=0;i{{}}); + v4._viewAnim={{target:{{...v4.view}},last:performance.now(),tau:36}}; + v4._animRaf=requestAnimationFrame(()=>{{}}); + v4._scheduleViewRequest(v4.view,{{delay:10000}}); + const seqBeforeLoss=v4.seq; + ext.loseContext(); + await waitFor(()=>v4._glLost===true,`loss ${{cycle+1}}`); + v4.draw(); + if(v4._raf!==null || v4._wheelZoomRaf!==null || v4._animRaf!==null + || v4._viewTimer!==null || v4._rebinTimer!==null + || v4.seq<=seqBeforeLoss || v4.root.dataset.fcContextState!=="lost") ctxquiet=0; + ext.restoreContext(); + await waitFor(()=>v4._glLost===false && v4.root.dataset.fcContextState==="ready", + `restore ${{cycle+1}}`); + for(const g of v4.gpuTraces) g._densityNormAnim=null; v4._drawNow(); - const ctxloss=(loseExt && lostSeen===1 && v4._glLost===false - && pixhash(v4)===ctxHashBefore)?1:0; - v4.destroy(); holder4.remove(); - // R7: a pure devicePixelRatio change (browser zoom) must re-derive - // backing stores even though the CSS size never changed. - const holder5=document.createElement("div"); - document.body.appendChild(holder5); - const v5=fastcharts.renderStandalone(holder5,spec,bytes.buffer); - const dpr0=v5.dpr; - Object.defineProperty(window,"devicePixelRatio",{{value:dpr0*2,configurable:true}}); - v5._onDprChange(); - const dprw=(v5.dpr===dpr0*2 && v5.canvas.width===v5.plot.w*v5.dpr - && v5.chrome.width===v5.size.w*v5.dpr - && String(v5._dprMq.media).indexOf(`${{dpr0*2}}dppx`)>=0)?1:0; - Object.defineProperty(window,"devicePixelRatio",{{value:dpr0,configurable:true}}); - v5.destroy(); holder5.remove(); - document.title=`${{base}} fluid=${{fluid0}} grew=${{grew}} pick2=${{pick2}} destroyed=${{destroyed}} unsub=${{unsub}} ctxloss=${{ctxloss}} dprw=${{dprw}}`; - }}catch(e){{document.title="FC_ERROR "+(e.stack||e.message)}}}},250); - }}catch(e){{document.title="FC_ERROR "+(e.stack||e.message)}}}},150); + const restoredHash=pixhash(v4); + ctxhashes.push(restoredHash); + if(restoredHash!==ctxHashBefore) ctxpixels=0; + ctxcycles++; + }} + const savedView={{...v4.view}}; + const span0=savedView.x1-savedView.x0; + v4._zoomAt(0.9,0.5,0.5,false); + v4._drawNow(); + const ctxpost=((v4.view.x1-v4.view.x0)0)?1:0; + v4._setView(savedView,{{animate:false,request:false}}); + v4._drawNow(); + const ctxloss=(ctxcycles===3 && ctxquiet===1 && ctxpixels===1 && ctxpost===1 + && rootLost===3 && rootRestored===3 && v4._contextLossCount===3 + && v4._contextRestoreCount===3 && pixhash(v4)===ctxHashBefore)?1:0; + v4.destroy(); holder4.remove(); + // R7: a pure devicePixelRatio change (browser zoom) must re-derive + // backing stores even though the CSS size never changed. + const holder5=document.createElement("div"); + document.body.appendChild(holder5); + const v5=fastcharts.renderStandalone(holder5,spec,bytes.buffer); + const dpr0=v5.dpr; + Object.defineProperty(window,"devicePixelRatio",{{value:dpr0*2,configurable:true}}); + v5._onDprChange(); + const dprw=(v5.dpr===dpr0*2 && v5.canvas.width===v5.plot.w*v5.dpr + && v5.chrome.width===v5.size.w*v5.dpr + && String(v5._dprMq.media).indexOf(`${{dpr0*2}}dppx`)>=0)?1:0; + Object.defineProperty(window,"devicePixelRatio",{{value:dpr0,configurable:true}}); + v5.destroy(); holder5.remove(); + document.title=`${{base}} fluid=${{fluid0}} grew=${{grew}} pick2=${{pick2}} destroyed=${{destroyed}} unsub=${{unsub}} ctxloss=${{ctxloss}} ctxcycles=${{ctxcycles}} ctxquiet=${{ctxquiet}} ctxpixels=${{ctxpixels}} ctxhashes=${{ctxhashes.join(",")}} ctxpost=${{ctxpost}} ctxevents=${{rootLost}}/${{rootRestored}} ctxcounts=${{v4._contextLossCount}}/${{v4._contextRestoreCount}} dprw=${{dprw}}`; + }}catch(e){{document.title="FC_ERROR "+(e.stack||e.message)}}}})(); }}catch(e){{document.title="FC_ERROR "+(e.stack||e.message)}}}},250); }}catch(e){{document.title="FC_ERROR "+(e.stack||e.message)}}}},200); }}catch(e){{document.title="FC_ERROR "+(e.stack||e.message)}} @@ -1023,6 +1072,9 @@ def main() -> None: malformed = int(re.search(r"malformed=(\d+)", title).group(1)) pixdet = int(re.search(r"pixdet=(\d+)", title).group(1)) ctxloss = int(re.search(r"ctxloss=(\d+)", title).group(1)) + ctxcycles = int(re.search(r"ctxcycles=(\d+)", title).group(1)) + ctxquiet = int(re.search(r"ctxquiet=(\d+)", title).group(1)) + ctxpost = int(re.search(r"ctxpost=(\d+)", title).group(1)) dprw = int(re.search(r"dprw=(\d+)", title).group(1)) bar_base = int(re.search(r"barBase=(\d+)", title).group(1)) hist_base = int(re.search(r"histBase=(\d+)", title).group(1)) @@ -1136,7 +1188,13 @@ def main() -> None: if dprw != 1: raise SystemExit("devicePixelRatio change did not re-derive backing stores") if ctxloss != 1: - raise SystemExit("GL context loss/restore did not rebuild pixel-identically") + raise SystemExit("GL context recovery did not quiesce and rebuild pixel-identically") + if ctxcycles != 3: + raise SystemExit(f"GL context recovery completed only {ctxcycles}/3 forced cycles") + if ctxquiet != 1: + raise SystemExit("GL context loss left deferred GPU work or stale replies active") + if ctxpost != 1: + raise SystemExit("chart was not interactive and nonblank after context restoration") if pixdet != 1: raise SystemExit("pixel determinism failed (render path must be RNG/time-free)") if qwire != 1: diff --git a/scripts/verify_benchmark_report.py b/scripts/verify_benchmark_report.py index 664446dc..9cf9923a 100644 --- a/scripts/verify_benchmark_report.py +++ b/scripts/verify_benchmark_report.py @@ -39,7 +39,19 @@ "box_zoom_p95_ms", "brush_select_p95_ms", ) +INTERACTION_BUDGET_LIMITS_MS = { + "wheel_zoom_p95_ms": 600.0, + "pan_p95_ms": 300.0, + "crosshair_p95_ms": 300.0, + "hover_p95_ms": 350.0, + "box_zoom_p95_ms": 300.0, + "brush_select_p95_ms": 200.0, +} INTERACTION_VISUAL_BUDGET_KEYS = ("max_frame_color_delta", "min_interaction_lit_pixels") +INTERACTION_VISUAL_BUDGET_LIMITS = { + "max_frame_color_delta": 0.85, + "min_interaction_lit_pixels": 64.0, +} INTERACTION_REQUIRED_SCENARIOS = ( "direct_scatter_interaction", "density_scatter_interaction", @@ -62,6 +74,13 @@ "export_png_native_decimated_line", } DASHBOARD_REQUIRED_COUNTS = {10, 20, 50} +DASHBOARD_MIN_LOSS_FREE_CHARTS = 10 +DASHBOARD_SMOKE_BUDGETS_MS = { + "render_ms": 5_000.0, + "ms_per_chart": 500.0, + "scroll_pass_ms": 5_000.0, + "steady_redraw_p95_ms": 100.0, +} def _is_number(value: Any) -> bool: @@ -1083,6 +1102,12 @@ def _validate_interaction_budget_block( errors.append(f"report.interaction_budgets_ms.{key} must be > 0") else: valid[key] = float(value) + limit = INTERACTION_BUDGET_LIMITS_MS[key] + if value > limit: + errors.append( + f"report.interaction_budgets_ms.{key} may not exceed the gate limit " + f"{limit:g} ms" + ) return valid @@ -1110,6 +1135,17 @@ def _validate_interaction_visual_budget_block( errors.append(f"report.interaction_visual_budgets.{key} must be > 0") else: valid[key] = float(value) + limit = INTERACTION_VISUAL_BUDGET_LIMITS[key] + if key.startswith("max_") and value > limit: + errors.append( + f"report.interaction_visual_budgets.{key} may not exceed the gate limit " + f"{limit:g}" + ) + elif key.startswith("min_") and value < limit: + errors.append( + f"report.interaction_visual_budgets.{key} may not be below the gate floor " + f"{limit:g}" + ) return valid @@ -1244,6 +1280,30 @@ 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}" ) + if not isinstance(expected_ceiling, int) or expected_ceiling < DASHBOARD_MIN_LOSS_FREE_CHARTS: + errors.append( + "dashboard must render at least " + f"{DASHBOARD_MIN_LOSS_FREE_CHARTS} charts without loss or blank frames" + ) + smoke_rows = [ + row + for row in rows + if isinstance(row, dict) and row.get("chart_count") == DASHBOARD_MIN_LOSS_FREE_CHARTS + ] + if len(smoke_rows) == 1: + smoke = smoke_rows[0] + if _status_kind(smoke.get("status")) != "ok" or smoke.get("fully_nonblank") is not True: + errors.append( + f"dashboard {DASHBOARD_MIN_LOSS_FREE_CHARTS}-chart smoke row must be " + "loss-free and fully nonblank" + ) + for metric, limit in DASHBOARD_SMOKE_BUDGETS_MS.items(): + value = smoke.get(metric) + if _is_number(value) and value > limit: + errors.append( + f"dashboard {DASHBOARD_MIN_LOSS_FREE_CHARTS}-chart {metric} " + f"{value:.3g} ms exceeds hard smoke budget {limit:.3g} ms" + ) def _dashboard_id_list( diff --git a/scripts/verify_ci_workflow.py b/scripts/verify_ci_workflow.py index c49afd25..65beea3b 100644 --- a/scripts/verify_ci_workflow.py +++ b/scripts/verify_ci_workflow.py @@ -131,9 +131,13 @@ def validate_ci_workflow(path: Path = DEFAULT_CI_WORKFLOW) -> list[str]: "Browser lifecycle smoke", "Browser visual regression smoke", "Browser interaction stress smoke", + "Browser dashboard reliability smoke", "scripts/reflex_lifecycle_smoke.py", "scripts/visual_regression_smoke.py", "scripts/interaction_stress_smoke.py", + "benchmarks/bench_dashboard.py", + "--chart-counts 10,20,50", + "dashboard-smoke.json --kind dashboard-browser", "--sizes 1e5,1e6,1e7 --production --json scatter.json", "scripts/bench_native.py --sizes 1e6,1e7 --json kernel.json", "scripts/verify_benchmark_report.py scatter.json --kind scatter-native", diff --git a/scripts/visual_regression_smoke.py b/scripts/visual_regression_smoke.py index 53c95baf..319a0dd4 100644 --- a/scripts/visual_regression_smoke.py +++ b/scripts/visual_regression_smoke.py @@ -234,7 +234,12 @@ def _assert_layout_regions( # non-white threshold high, but only require enough dark pixels to prove # the axis chrome did not disappear. "x-axis": (700, 250 if asset else 40), - "y-axis": (500, 200), + # Current Chromium's lighter glyph antialiasing leaves some generated + # axes below 100 dark pixels while retaining >500 non-white chrome + # pixels. Keep assets strict and use the same cross-version dark floor + # as the generated x-axis so the gate catches disappearance, not font + # rasterizer choice. + "y-axis": (500, 200 if asset else 40), } for label, stats in regions.items(): min_non_white, min_dark = minimums[label] diff --git a/tests/test_check_regressions.py b/tests/test_check_regressions.py index b784b49e..f38297b1 100644 --- a/tests/test_check_regressions.py +++ b/tests/test_check_regressions.py @@ -107,3 +107,47 @@ def test_missing_baseline_metric_is_a_hard_failure(tmp_path: Path, monkeypatch) with pytest.raises(SystemExit): check_regressions.main() + + +def test_catastrophic_timing_regression_is_a_hard_failure( + tmp_path: Path, + monkeypatch, +) -> None: + baseline = tmp_path / "baseline.json" + baseline.write_text( + json.dumps({"metrics": {"kernel.encode_mpts_s.1000000": 1_000.0}}), + encoding="utf-8", + ) + kernel = tmp_path / "kernel.json" + kernel.write_text( + json.dumps({"rows": [{"n": 1_000_000, "encode_mpts_s": 200.0}]}), + encoding="utf-8", + ) + monkeypatch.setattr(check_regressions, "BASELINE", baseline) + monkeypatch.setattr(sys, "argv", ["check_regressions.py", "--kernel", str(kernel)]) + + with pytest.raises(SystemExit): + check_regressions.main() + + +def test_large_but_noncatastrophic_timing_regression_stays_advisory( + tmp_path: Path, + monkeypatch, + capsys: pytest.CaptureFixture[str], +) -> None: + baseline = tmp_path / "baseline.json" + baseline.write_text( + json.dumps({"metrics": {"kernel.encode_mpts_s.1000000": 1_000.0}}), + encoding="utf-8", + ) + kernel = tmp_path / "kernel.json" + kernel.write_text( + json.dumps({"rows": [{"n": 1_000_000, "encode_mpts_s": 400.0}]}), + encoding="utf-8", + ) + monkeypatch.setattr(check_regressions, "BASELINE", baseline) + monkeypatch.setattr(sys, "argv", ["check_regressions.py", "--kernel", str(kernel)]) + + check_regressions.main() + + assert "advisory timing regression" in capsys.readouterr().out diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 3ee9e09e..9250cb77 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -300,6 +300,31 @@ def test_client_hardens_responsive_visibility_recovery() -> None: assert marker in text, f"{path} no longer exposes responsive marker {marker!r}" +def test_client_quiesces_and_rebuilds_repeated_context_loss() -> None: + required = ( + 'this.root.dataset.fcContextState = "lost";', + 'this.root.dataset.fcContextState = "ready";', + "this._contextLossCount += 1;", + "this._contextRestoreCount += 1;", + 'this._dispatchChartEvent("context_lost"', + 'this._dispatchChartEvent("context_restored"', + "clearTimeout(this._viewTimer);", + "clearTimeout(this._rebinTimer);", + "if (this._destroyed || this._glLost || !this.gl) return;", + "if (this._destroyed || this._contextRecoveryError) return;", + 'if (this._glLost && msg.type !== "append" && msg.type !== "pick_result") return;', + ) + + for path, text in CLIENT_FILES: + for marker in required: + assert marker in text, f"{path} lost context-recovery marker {marker!r}" + + smoke = (ROOT / "scripts" / "render_smoke_nonumpy.py").read_text(encoding="utf-8") + assert "for(let cycle=0;cycle<3;cycle++)" in smoke + assert "ctxcycles != 3" in smoke + assert "ctxpost != 1" in smoke + + def test_client_refreshes_and_destroys_density_sample_overlays() -> None: chartview_required = ( "_refreshReductionBadges()", diff --git a/tests/test_verify_benchmark_report.py b/tests/test_verify_benchmark_report.py index 12aa05b3..2a78e3c3 100644 --- a/tests/test_verify_benchmark_report.py +++ b/tests/test_verify_benchmark_report.py @@ -652,6 +652,26 @@ def test_dashboard_report_accepts_partial_rows_with_context_telemetry(tmp_path: assert errors == [] +def test_dashboard_report_rejects_broken_ten_chart_smoke(tmp_path: Path) -> None: + payload = _dashboard_browser_report() + _set_dashboard_partial(payload["rows"][0], nonblank=8) + path = _write_report(tmp_path, payload) + + errors = verify_benchmark_report.validate_report(path, kind="dashboard-browser") + + assert any("10-chart smoke row" in error and "fully nonblank" 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 + path = _write_report(tmp_path, payload) + + errors = verify_benchmark_report.validate_report(path, kind="dashboard-browser") + + assert any("steady_redraw_p95_ms" in error and "hard smoke budget" in error for error in errors) + + def test_workflow_report_rejects_missing_required_scenario(tmp_path: Path) -> None: payload = _workflow_native_report() payload["rows"] = payload["rows"][1:] @@ -935,6 +955,20 @@ def test_verify_benchmark_report_rejects_interaction_budget_regression( assert any("hover_p95_ms" in error and "exceeds budget" in error for error in errors) +def test_verify_benchmark_report_rejects_relaxed_interaction_gate( + tmp_path: Path, +) -> None: + payload = _interaction_browser_report() + payload["interaction_budgets_ms"]["wheel_zoom_p95_ms"] = 601.0 + payload["interaction_visual_budgets"]["min_interaction_lit_pixels"] = 32 + path = _write_report(tmp_path, payload) + + errors = verify_benchmark_report.validate_report(path, kind="interaction-browser") + + assert any("wheel_zoom_p95_ms" in error and "gate limit" in error for error in errors) + assert any("min_interaction_lit_pixels" in error and "gate floor" in error for error in errors) + + def test_verify_benchmark_report_rejects_interaction_repetition_mismatch( tmp_path: Path, ) -> None: diff --git a/tests/test_verify_ci_workflow.py b/tests/test_verify_ci_workflow.py index 106e6e81..516c4e47 100644 --- a/tests/test_verify_ci_workflow.py +++ b/tests/test_verify_ci_workflow.py @@ -217,6 +217,25 @@ def test_ci_workflow_rejects_missing_interaction_stress_smoke(tmp_path: Path) -> assert any("test job" in error and "interaction_stress_smoke" in error for error in errors) +def test_ci_workflow_rejects_missing_dashboard_reliability_smoke(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + block = ( + " - name: Browser dashboard reliability smoke (Chromium)\n" + " run: |\n" + " CHROME=$(node -e \"console.log(require('playwright').chromium.executablePath())\")\n" + " .venv/bin/python benchmarks/bench_dashboard.py \\\n" + ' --chart-counts 10,20,50 --chromium "$CHROME" --json dashboard-smoke.json\n' + " .venv/bin/python scripts/verify_benchmark_report.py \\\n" + " dashboard-smoke.json --kind dashboard-browser\n\n" + ) + path = tmp_path / "ci.yml" + path.write_text(workflow.replace(block, ""), encoding="utf-8") + + errors = verify_ci_workflow.validate_workflow(path) + + assert any("test job" in error and "dashboard reliability" in error for error in errors) + + def test_ci_workflow_rejects_missing_reflex_lifecycle_smoke(tmp_path: Path) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") path = tmp_path / "ci.yml"