From da17a205f1d6892e595089132f11789699ced732 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 25 Jun 2026 13:18:20 -0500 Subject: [PATCH 1/2] fix(webgl): release throwaway context in premultiply-alpha probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detectPremultiplyAlphaHonored` creates a temporary WebGL context to test whether `createImageBitmap(..., { premultiplyAlpha: 'premultiply' })` is honored, but it only deleted the texture/framebuffer — the context itself was left dangling until GC. This probe runs in a microtask *after* the main render context is created, so a leaked context here is the newest one on the page. On embedded TV browsers (Apollo/Sunrise, Chrome 38+) the live-context budget is tiny; the lingering probe pushes the page over the limit and the browser evicts the *oldest* context — the live render context — after which every `createTexture()` returns null and the engine spams "Could not create WebGL Texture" each frame. Release the probe context immediately via `WEBGL_lose_context` on every exit path instead of waiting for GC. The lose event fires only on the throwaway canvas, so it does not trip the main renderer's `webglcontextlost` handler. Only active when `premultiplyAlphaHonored: 'auto'` is set (default is `true`, no probe). Co-Authored-By: Claude Opus 4.8 --- src/core/lib/validateImageBitmap.test.ts | 15 +++++++++++++++ src/core/lib/validateImageBitmap.ts | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/src/core/lib/validateImageBitmap.test.ts b/src/core/lib/validateImageBitmap.test.ts index bee51ba..bc014d5 100644 --- a/src/core/lib/validateImageBitmap.test.ts +++ b/src/core/lib/validateImageBitmap.test.ts @@ -44,6 +44,10 @@ function createFakeGl(readbackRed: number, framebufferComplete = true) { ), deleteFramebuffer: vi.fn(), deleteTexture: vi.fn(), + // The probe releases its throwaway context via WEBGL_lose_context once done. + getExtension: vi.fn((name: string) => + name === 'WEBGL_lose_context' ? { loseContext: vi.fn() } : null, + ), }; } @@ -116,4 +120,15 @@ describe('detectPremultiplyAlphaHonored', () => { false, ); }); + + it('releases the throwaway context so it does not leak a GL slot', async () => { + const lose = { loseContext: vi.fn() }; + const gl = createFakeGl(128); + gl.getExtension = vi.fn((name: string) => + name === 'WEBGL_lose_context' ? lose : null, + ); + await detectPremultiplyAlphaHonored(createPlatform(gl)); + expect(gl.getExtension).toHaveBeenCalledWith('WEBGL_lose_context'); + expect(lose.loseContext).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/core/lib/validateImageBitmap.ts b/src/core/lib/validateImageBitmap.ts index 8c4abcf..5fab8c6 100644 --- a/src/core/lib/validateImageBitmap.ts +++ b/src/core/lib/validateImageBitmap.ts @@ -172,5 +172,13 @@ export async function detectPremultiplyAlphaHonored( gl.deleteTexture(tex); bitmap.close?.(); + // Release this throwaway context immediately. Embedded TV browsers cap the + // number of live WebGL contexts very low; since this probe runs AFTER the + // main render context is created, a leaked context here is the newest one + // and its lingering presence can push the page over the limit, evicting the + // OLDEST context (the live render context) — which then fails every + // createTexture. Don't wait for GC to reclaim the canvas; drop it now. + gl.getExtension('WEBGL_lose_context')?.loseContext(); + return result; } From a3afcaf8d9fb2d32ebab36798ee99fe3c5172884 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 25 Jun 2026 13:32:01 -0500 Subject: [PATCH 2/2] feat(loop): add handleLoopError escape hatch; never crash the render loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render loop runs client-supplied code every frame — synchronous event subscribers (frameTick, idle, the queued events drained by flushFrameEvents) and animation steps. A throw in any of these would propagate out of the requestAnimationFrame callback and permanently stop the loop, freezing the whole app until reload. Wrap the frame body in try/catch and always keep the loop alive. Errors are routed to a handleLoopError(error) setting that defaults to a no-op, so by default a bad frame is swallowed and the loop never crashes. Apps can override the handler to log/report (and optionally recover); the loop keeps running after it returns. A `scheduled` flag prevents double-scheduling: the idle path queues its next tick before running the throwing client code, so the catch must not also queue a frame — otherwise a handler that throws every idle frame would compound into runaway frames. Co-Authored-By: Claude Opus 4.8 --- .../web/WebPlatform.loopError.test.ts | 145 ++++++++++++++++++ src/core/platforms/web/WebPlatform.ts | 96 +++++++----- src/main-api/Renderer.ts | 28 ++++ 3 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 src/core/platforms/web/WebPlatform.loopError.test.ts diff --git a/src/core/platforms/web/WebPlatform.loopError.test.ts b/src/core/platforms/web/WebPlatform.loopError.test.ts new file mode 100644 index 0000000..673f5d4 --- /dev/null +++ b/src/core/platforms/web/WebPlatform.loopError.test.ts @@ -0,0 +1,145 @@ +/** + * Tests the `handleLoopError` escape hatch: a synchronous throw inside a frame + * (event subscriber, animation step, draw) must never propagate out of the + * `requestAnimationFrame` callback and freeze the loop. By default the error is + * swallowed and the loop keeps running; a registered handler is invoked with the + * error and the loop still survives. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { WebPlatform } from './WebPlatform.js'; +import type { Stage } from '../../Stage.js'; + +interface MakeStageOptions { + idle?: boolean; + handleLoopError?: (error: unknown) => void; + throwIn?: 'drawFrame' | 'emitIdle'; + error?: unknown; +} + +function makeStage(opts: MakeStageOptions = {}) { + const idle = opts.idle ?? false; + const error = opts.error ?? new Error('frame boom'); + + const drawFrame = vi.fn(() => { + if (opts.throwIn === 'drawFrame') { + throw error; + } + }); + const emit = vi.fn(() => { + if (opts.throwIn === 'emitIdle') { + throw error; + } + }); + + const stage = { + isContextLost: false, + targetFrameTime: 0, + updateFrameTime: vi.fn(), + updateAnimations: vi.fn(() => false), + hasSceneUpdates: vi.fn(() => !idle), + calculateFps: vi.fn(), + drawFrame, + flushFrameEvents: vi.fn(), + shManager: { cleanup: vi.fn() }, + cleanupTextRenderers: vi.fn(), + eventBus: { emit }, + txMemManager: { + checkCleanup: vi.fn(() => false), + cleanup: vi.fn(), + handleOutOfMemory: vi.fn(), + }, + renderer: { checkForOutOfMemory: vi.fn(() => false) }, + options: { handleLoopError: opts.handleLoopError }, + } as unknown as Stage; + + return { stage, drawFrame, emit }; +} + +describe('WebPlatform render loop — handleLoopError escape hatch', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Stubs raf/setTimeout, starts the loop, and returns a `runFrame` that drives + // exactly one frame plus the raf/setTimeout call counts so far. + function harness(stage: Stage) { + let capturedLoop: ((t?: number) => void) | null = null; + const raf = vi.fn((cb: (t?: number) => void) => { + capturedLoop = cb; + return 1; + }); + const timeout = vi.fn(() => 1 as unknown as ReturnType); + vi.stubGlobal('requestAnimationFrame', raf); + vi.stubGlobal('setTimeout', timeout); + + new WebPlatform().startLoop(stage); + + return { + runFrame: () => capturedLoop!(0), + rafCount: () => raf.mock.calls.length, + timeoutCount: () => timeout.mock.calls.length, + }; + } + + it('routes an active-frame error to the handler and keeps the loop alive', () => { + const error = new Error('draw boom'); + const handleLoopError = vi.fn(); + const { stage } = makeStage({ + throwIn: 'drawFrame', + error, + handleLoopError, + }); + + const h = harness(stage); + expect(h.rafCount()).toBe(1); // initial schedule from startLoop + + expect(() => h.runFrame()).not.toThrow(); + + expect(handleLoopError).toHaveBeenCalledTimes(1); + expect(handleLoopError).toHaveBeenCalledWith(error); + expect(h.rafCount()).toBe(2); // rescheduled the next frame + }); + + it('swallows the error and keeps the loop alive when no handler is registered', () => { + const error = new Error('draw boom'); + const { stage } = makeStage({ throwIn: 'drawFrame', error }); + + const h = harness(stage); + + // Default behaviour: the error is swallowed, never propagated, and the loop + // reschedules so the app does not freeze. + expect(() => h.runFrame()).not.toThrow(); + expect(h.rafCount()).toBe(2); // rescheduled despite the error + }); + + it('does not double-schedule when an idle-path error follows the idle reschedule', () => { + const handleLoopError = vi.fn(); + const { stage } = makeStage({ + idle: true, + throwIn: 'emitIdle', + handleLoopError, + }); + + const h = harness(stage); + + expect(() => h.runFrame()).not.toThrow(); + + expect(handleLoopError).toHaveBeenCalledTimes(1); + // The idle path already queued the next tick via setTimeout before the throw, + // so the catch must NOT queue an extra rAF. + expect(h.timeoutCount()).toBe(1); + expect(h.rafCount()).toBe(1); + }); + + it('does not touch the handler on a clean frame and reschedules normally', () => { + const handleLoopError = vi.fn(); + const { stage, drawFrame } = makeStage({ handleLoopError }); + + const h = harness(stage); + h.runFrame(); + + expect(handleLoopError).not.toHaveBeenCalled(); + expect(drawFrame).toHaveBeenCalledTimes(1); + expect(h.rafCount()).toBe(2); + }); +}); diff --git a/src/core/platforms/web/WebPlatform.ts b/src/core/platforms/web/WebPlatform.ts index 15176fc..874666a 100644 --- a/src/core/platforms/web/WebPlatform.ts +++ b/src/core/platforms/web/WebPlatform.ts @@ -64,47 +64,73 @@ export class WebPlatform extends Platform { lastFrameTime = currentTime; } - stage.updateFrameTime(); - const hasActiveAnimations = stage.updateAnimations(); - - if (!stage.hasSceneUpdates()) { - // We still need to calculate the fps else it looks like the app is frozen - stage.calculateFps(); - - // We use 15ms instead of 16.6ms to provide a safety buffer. - // This ensures we wake up slightly before the next frame to check for updates, - // preventing us from missing a frame due to timer variances. - setTimeout(requestLoop, Math.max(targetFrameTime, 15)); - - if (isIdle === false) { - // The render burst has settled. Probe for a GPU out-of-memory now - // rather than every frame: GL errors accumulate and persist until - // drained, so a single check here still catches any OOM raised during - // the active frames, without paying the getError() CPU/GPU sync on - // every frame. Queues the `outOfMemory` event, flushed below. - if (stage.renderer.checkForOutOfMemory() === true) { - stage.txMemManager.handleOutOfMemory(); + // From here on the frame runs client-supplied code: synchronous event + // subscribers (`frameTick`, `idle`, and the queued events drained by + // `flushFrameEvents`) and animation steps. A throw in any of these would + // otherwise propagate out of the rAF callback and permanently stop the + // render loop — the whole app freezes until reload. Guard the body and + // always keep the loop alive: hand the error to `handleLoopError` (a + // no-op by default; the app can override it to log/report) and reschedule. + // `scheduled` tracks whether the next tick was already queued before the + // throw so we never double-schedule (which would compound into runaway + // frames if it threw every frame). + let scheduled = false; + try { + stage.updateFrameTime(); + const hasActiveAnimations = stage.updateAnimations(); + + if (!stage.hasSceneUpdates()) { + // We still need to calculate the fps else it looks like the app is frozen + stage.calculateFps(); + + // We use 15ms instead of 16.6ms to provide a safety buffer. + // This ensures we wake up slightly before the next frame to check for updates, + // preventing us from missing a frame due to timer variances. + setTimeout(requestLoop, Math.max(targetFrameTime, 15)); + scheduled = true; + + if (isIdle === false) { + // The render burst has settled. Probe for a GPU out-of-memory now + // rather than every frame: GL errors accumulate and persist until + // drained, so a single check here still catches any OOM raised during + // the active frames, without paying the getError() CPU/GPU sync on + // every frame. Queues the `outOfMemory` event, flushed below. + if (stage.renderer.checkForOutOfMemory() === true) { + stage.txMemManager.handleOutOfMemory(); + } + stage.shManager.cleanup(); + stage.cleanupTextRenderers(); + stage.eventBus.emit('idle'); + isIdle = true; + } + + if (stage.txMemManager.checkCleanup() === true) { + stage.txMemManager.cleanup(); } - stage.shManager.cleanup(); - stage.cleanupTextRenderers(); - stage.eventBus.emit('idle'); - isIdle = true; - } - if (stage.txMemManager.checkCleanup() === true) { - stage.txMemManager.cleanup(); + stage.flushFrameEvents(); + return; } + isIdle = false; + stage.drawFrame(hasActiveAnimations); stage.flushFrameEvents(); - return; - } - - isIdle = false; - stage.drawFrame(hasActiveAnimations); - stage.flushFrameEvents(); - // Schedule next frame - requestAnimationFrame(runLoop); + // Schedule next frame + requestAnimationFrame(runLoop); + scheduled = true; + } catch (error: unknown) { + // Report the error (default handler is a no-op), then keep the loop + // alive — a single bad frame must never freeze the app. Skip the + // reschedule if this frame already queued the next tick before throwing. + const handleLoopError = stage.options.handleLoopError; + if (handleLoopError !== undefined) { + handleLoopError(error); + } + if (scheduled === false) { + requestAnimationFrame(runLoop); + } + } }; const requestLoop = () => requestAnimationFrame(runLoop); diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index be28cbd..aa54f7b 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -23,6 +23,9 @@ import type { import { WebPlatform } from '../core/platforms/web/WebPlatform.js'; import { Platform } from '../core/platforms/Platform.js'; +/** Shared default `handleLoopError` — swallows the error, keeping the loop alive. */ +const noop = (): void => {}; + /** * FPS Update Event Data * @@ -661,6 +664,27 @@ export type RendererMainSettings = RendererRuntimeSettings & { * */ maxRetryCount?: number; + + /** + * Render loop error handler — an escape hatch for `requestAnimationFrame` crashes + * + * @remarks + * The render loop runs client-supplied code every frame: synchronous event + * subscribers (`frameTick`, `idle`, `fpsUpdate`, etc.) and animation steps. If + * any of these throw, the exception would otherwise propagate out of the + * `requestAnimationFrame` callback and permanently stop the loop — freezing + * the entire app until reload. + * + * The loop catches such errors and never lets them stop it. By default the + * error is swallowed (the default handler is a no-op), so a single bad frame + * can't freeze the app. Provide your own handler to log/report the crash (and + * optionally recover); the loop still keeps running after it returns. + * + * @param error The error thrown during the frame + * + * @defaultValue a no-op (errors are swallowed; the loop keeps running) + */ + handleLoopError?: (error: unknown) => void; }; /** @@ -777,6 +801,9 @@ export class RendererMain extends EventEmitter { : settings.premultiplyAlphaHonored, platform: settings.platform || null, maxRetryCount: settings.maxRetryCount ?? 5, + // Default to a no-op so a thrown frame is swallowed and the render loop + // never crashes; apps can override to log/report. + handleLoopError: settings.handleLoopError ?? noop, }; const { @@ -841,6 +868,7 @@ export class RendererMain extends EventEmitter { premultiplyAlphaHonored: settings.premultiplyAlphaHonored, platform, maxRetryCount: settings.maxRetryCount ?? 5, + handleLoopError: settings.handleLoopError, }); // Extract the root node