Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/core/platforms/web/WebPlatform.loopError.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>);
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);
});
});
96 changes: 61 additions & 35 deletions src/core/platforms/web/WebPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/main-api/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -841,6 +868,7 @@ export class RendererMain extends EventEmitter {
premultiplyAlphaHonored: settings.premultiplyAlphaHonored,
platform,
maxRetryCount: settings.maxRetryCount ?? 5,
handleLoopError: settings.handleLoopError,
});

// Extract the root node
Expand Down
Loading