From 53d1f14bc74cc50ce21bfec7ea7b261656d5063e Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sat, 27 Jun 2026 23:56:33 -0400 Subject: [PATCH] refactor(loop): extract per-frame work into tick() so try/catch stays in a thin shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render-loop keep-alive guard added in #115 wraps the whole frame body in a try/catch inside the rAF callback. Some optimizing compilers — notably Chrome 38's Crankshaft, our language floor — refuse to optimize any function that contains a try/catch, so the entire hot per-frame function was being de-optimized on the embedded targets we care about. Split startLoop into two closures: - tick(currentTime): all per-frame work — context-lost check, frame limiting, updateAnimations, the idle path, and the active drawFrame/flushFrameEvents path. This is where every client callback (the only code that can actually throw) and every hot stage.* call runs. No try/catch, so it stays fully optimizable. - runLoop(currentTime): a near-empty shell that is just `try { tick(t) } catch`. It carries the only try/catch, so it's the only function de-optimized on Crankshaft — and it does almost nothing. Behavior is unchanged. The `scheduled` double-schedule guard is promoted to a closure variable, reset at the top of runLoop before the try and set by tick at each scheduling point, so the catch still reschedules only when tick had not already queued the next frame. Context-lost and frame-limit paths now run inside tick but execute no client code and cannot throw, so the catch never fires for them. Co-Authored-By: Claude Opus 4.8 --- src/core/platforms/web/WebPlatform.ts | 115 ++++++++++++++------------ 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/src/core/platforms/web/WebPlatform.ts b/src/core/platforms/web/WebPlatform.ts index 874666a..5dbf6c1 100644 --- a/src/core/platforms/web/WebPlatform.ts +++ b/src/core/platforms/web/WebPlatform.ts @@ -29,9 +29,24 @@ export class WebPlatform extends Platform { override startLoop(stage: Stage): void { let isIdle = false; let lastFrameTime = 0; + // Set by `tick` once it has queued the next frame; read by `runLoop`'s catch + // so a throw after scheduling never double-schedules (which would compound + // into runaway frames if it threw every frame). Reset at the top of each frame. + let scheduled = false; const buffer = 4; - const runLoop = (currentTime: number = 0) => { + const requestLoop = () => requestAnimationFrame(runLoop); + + // All per-frame work lives in `tick`, deliberately split out of `runLoop`. + // `runLoop` carries the try/catch that keeps the loop alive, and some + // optimizing compilers — notably Chrome 38's Crankshaft, our language floor — + // refuse to optimize any function that contains a try/catch. Keeping that + // guard in a near-empty shell confines the de-opt to the shell: every hot + // operation (the `stage.*` calls below, run every frame) stays in fully + // optimizable functions. `tick` is also where all client-supplied code runs + // (synchronous event subscribers, the events drained by `flushFrameEvents`, + // and animation steps) — i.e. everything that can actually throw. + const tick = (currentTime: number) => { // The GL context is lost and the engine does not rebuild it in place. // Stop the loop entirely (no reschedule) so we issue no GL calls against // a dead context. Recovery is via app reload (see the `contextLost` event). @@ -55,6 +70,7 @@ export class WebPlatform extends Platform { } else { requestAnimationFrame(runLoop); } + scheduled = true; return; } @@ -64,65 +80,62 @@ export class WebPlatform extends Platform { lastFrameTime = currentTime; } - // 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(); + stage.updateFrameTime(); + const hasActiveAnimations = stage.updateAnimations(); - // 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 (!stage.hasSceneUpdates()) { + // We still need to calculate the fps else it looks like the app is frozen + stage.calculateFps(); - 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; - } + // 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 (stage.txMemManager.checkCleanup() === true) { - stage.txMemManager.cleanup(); + 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; + } - stage.flushFrameEvents(); - return; + if (stage.txMemManager.checkCleanup() === true) { + stage.txMemManager.cleanup(); } - isIdle = false; - stage.drawFrame(hasActiveAnimations); stage.flushFrameEvents(); + return; + } - // Schedule next frame - requestAnimationFrame(runLoop); - scheduled = true; + isIdle = false; + stage.drawFrame(hasActiveAnimations); + stage.flushFrameEvents(); + + // Schedule next frame + requestAnimationFrame(runLoop); + scheduled = true; + }; + + const runLoop = (currentTime: number = 0) => { + // `tick` runs client-supplied code every frame; a throw there would + // otherwise propagate out of the rAF callback and permanently stop the + // render loop — the whole app freezes until reload. Guard it 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 unless + // `tick` already queued the next frame before throwing. + scheduled = false; + try { + tick(currentTime); } 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); @@ -133,8 +146,6 @@ export class WebPlatform extends Platform { } }; - const requestLoop = () => requestAnimationFrame(runLoop); - requestAnimationFrame(runLoop); }