Skip to content

fix(webgl2): evict and rebuild GL handles on real context restore (B-09, #275)#285

Merged
Exoridus merged 1 commit into
mainfrom
fix/webgl2-context-restore
Jul 11, 2026
Merged

fix(webgl2): evict and rebuild GL handles on real context restore (B-09, #275)#285
Exoridus merged 1 commit into
mainfrom
fix/webgl2-context-restore

Conversation

@Exoridus

Copy link
Copy Markdown
Owner

Summary

Fixes #275 (audit finding B-09, P2). On a genuine WebGL2 context loss (mobile tab-switch, driver TDR) the backend only flipped a flag and called restoreContext(); the cached GL handles were never invalidated. After a real restore every handle is dead, so the next frame drew with dangling handles → blank canvas or an INVALID_OPERATION storm. The pre-existing synthetic signal-only tests passed because they never exercised the rebuild path.

Root cause (two independent bugs)

  1. The context could never be restored. The webglcontextlost handler never called event.preventDefault(), so per the WebGL spec the browser never scheduled a webglcontextrestored event — a real loss was permanent. It also called restoreContext() synchronously inside the lost handler (Chromium silently ignores that) and via a fresh getExtension() result. Empirically verified against headless Chromium + Firefox: restoreContext() only drives a restore when invoked on the live extension instance obtained before the loss, on a later task.
  2. Restore evicted almost nothing. _onContextRestored invalidated only the retained instruction-set bundles; _textureStates, _renderTargetStates, the shared transform texture, renderer buffers/VAOs/shader programs, and the compositors all kept dead handles.

Approach

Mirrors the WebGPU backend's _teardownDeviceState (the audit rated that path production-grade and recommended the WebGL2 side copy it):

  • _onContextLost now cancels the default action so the browser can restore, and schedules the extension-driven restore on a fresh task using the WEBGL_lose_context instance cached in the constructor (pre-loss).
  • _onContextRestored delegates to a new _reinitializeDeviceState() that:
    • re-enables EXT_color_buffer_float (extension enablement does not survive a loss),
    • evicts every managed texture / render-target handle (frees the resource accountant, detaches destroy listeners),
    • drops the shared transform texture + upload bookkeeping,
    • disconnects and reconnects all renderers (rebuilds buffers / VAOs / shader programs, resets the VAO appliedVersion cache),
    • disconnects the mask / backdrop-blend compositors and stencil clipper (lazy reconnect on next use),
    • invalidates the retained bundles and drops in-flight captures,
    • resets the cached GL bind state, re-applies the GL global state, re-binds the root target + default blend mode,
    • drains benign teardown INVALID_OPERATIONs so the app's own getError() starts clean.
  • destroy() cancels any pending restore and a _destroyed guard stops a late timer callback.

Pre-1.0 clean break — no shims.

Test evidence

  • Browser lane (test/rendering/browser/webgl2-context-restore.test.ts): drives a real WEBGL_lose_context lose/restore cycle and asserts the scene renders identical pixels afterwards, including a two-cycle case and a texture created after the losses. Passes on Chromium and Firefox.
  • Node lane (test/perf/rendering/context-restore-webgl2.test.ts, 5 tests): pins the invalidation bookkeeping against the recording fake context — preventDefault on loss, full cache eviction, fresh GL handle identity after restore for the same Texture, draw-call resumption, and GPU-memory accounting release/re-accrual.

Full verification run locally: pnpm typecheck, eslint, prettier --check, pnpm docs:api:check, full WebGL2 Chromium browser suite (213 passed), Firefox context-restore, rendering-perf Node lane (66 passed), and the existing jsdom webgl2-backend suite (9 passed).

Note: the retained instruction-set bundles (Slice 3) already hooked their own generation-bump into the restore path; that behavior is preserved and folded into the new teardown.

https://claude.ai/code/session_01RHB7cLgoonJHLQg9ScdXby

…09, #275)

On a genuine WebGL2 context loss (mobile tab-switch, driver TDR) the backend
only flipped a flag and called restoreContext(); the cached GL handles
(_textureStates, _renderTargetStates, the shared transform texture, renderer
buffers/VAOs/shader programs, compositors, retained bundles) were never
invalidated. After a real restore every one of those handles is dead, so the
next frame drew with dangling handles -> blank canvas or an INVALID_OPERATION
storm. Synthetic signal-only tests passed because they never exercised the
rebuild path.

Root cause (two independent bugs):
- The `webglcontextlost` handler never called `event.preventDefault()`, so the
  browser never scheduled a `webglcontextrestored` event — a real loss was
  permanent. It also called `restoreContext()` synchronously inside the lost
  handler, which Chromium ignores, and via a fresh `getExtension()` result;
  `restoreContext()` only works on the extension instance that is live from
  before the loss.
- The `webglcontextrestored` handler evicted nothing beyond the retained
  bundles, leaving all other caches pointing at dead handles.

Approach (mirrors WebGPU's `_teardownDeviceState`, rated production-grade in
the audit):
- `_onContextLost` now cancels the default action so the browser can restore,
  and schedules the extension-driven restore on a fresh task using the
  extension instance cached in the constructor (pre-loss).
- `_onContextRestored` delegates to a new `_reinitializeDeviceState()` that
  re-enables EXT_color_buffer_float, evicts every managed texture/render-target
  handle, drops the transform texture, disconnects+reconnects all renderers
  (rebuilding buffers/VAOs/programs and resetting the VAO appliedVersion),
  disconnects the compositors/stencil clipper, invalidates the retained
  bundles, resets the cached GL bind state, re-applies the GL global state, and
  drains benign teardown errors so the app's own getError() starts clean.
- destroy() now cancels any pending restore and guards against a late callback.

Tests:
- Browser lane (webgl2-context-restore.test.ts): drives a REAL WEBGL_lose_context
  lose/restore cycle and asserts the scene renders identical pixels afterwards
  (plus a two-cycle + fresh-texture-after-loss case). Green on Chromium and
  Firefox.
- Node lane (context-restore-webgl2.test.ts): pins the invalidation bookkeeping
  against the recording fake context — preventDefault on loss, cache eviction,
  fresh handle identity after restore, draw-call resumption, and GPU-memory
  accounting release/re-accrual.

Fixes #275

Claude-Session: https://claude.ai/code/session_01RHB7cLgoonJHLQg9ScdXby
@Exoridus Exoridus enabled auto-merge (squash) July 11, 2026 22:00
@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 15.76kB (0.06%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
exo-esm-esm 1.77MB 2.36kB (0.13%) ⬆️
exo-esm-modules-esm 3.24MB 8.11kB (0.25%) ⬆️
exo-iife-min-Exo-iife 1.78MB 2.37kB (0.13%) ⬆️
exo-full-iife-Exo-iife 2.38MB 2.92kB (0.12%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: exo-esm-modules-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
rendering/webgl2/WebGl2Backend.js 7.22kB 75.58kB 10.56% ⚠️
rendering/webgl2/WebGl2Backend.d.ts 897 bytes 18.14kB 5.2% ⚠️

Files in rendering/webgl2/WebGl2Backend.js:

  • ./src/rendering/webgl2/WebGl2Backend.ts → Total Size: 73.93kB
view changes for bundle: exo-iife-min-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.iife.min.js 1.47kB 758.91kB 0.19%
rendering/webgl2/WebGl2Backend.d.ts 897 bytes 18.14kB 5.2% ⚠️

Files in exo.iife.min.js:

  • ./src/rendering/webgl2/WebGl2Backend.ts → Total Size: 73.99kB
view changes for bundle: exo-esm-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.esm.js 1.46kB 752.28kB 0.19%
rendering/webgl2/WebGl2Backend.d.ts 897 bytes 18.14kB 5.2% ⚠️

Files in exo.esm.js:

  • ./src/rendering/webgl2/WebGl2Backend.ts → Total Size: 73.93kB
view changes for bundle: exo-full-iife-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.full.iife.js 2.92kB 2.38MB 0.12%

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.63265% with 9 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/rendering/webgl2/WebGl2Backend.ts 81.63% 8 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@Exoridus Exoridus merged commit 937c0d2 into main Jul 11, 2026
16 checks passed
@Exoridus Exoridus deleted the fix/webgl2-context-restore branch July 11, 2026 22:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebGL2: evict cached GL handles on real context restore (B-09)

1 participant