fix(engine): keep BeginFrame time monotonic across frame rates (#3011)

Fixes #3012

## What

Fixes `HeadlessExperimental.beginFrame` hanging at 60fps and other high frame rates.

## Why

Warmup always advances Chrome in 33ms steps, but the first capture timestamp was calculated from the output fps.

At 60fps, the last warmup tick is 1947ms, while the next commit tick used to jump back to about 1067ms. Chrome sees time moving backwards and `beginFrame` can stall.

## How

Keep the old timestamps when they are already safe. If the commit tick would go backwards, move the capture baseline past warmup first.

The init commit and both producer probes now use the same timestamp helpers, so they cannot calculate different values.

## Test plan

- [x] Added tests for 24, 30, 31, 32, 33, 60, 120, 240, and 59.94fps
- [x] Added coverage for the actual session baseline, commit parameters, and both producer probe paths
- [x] Engine tests: 1383 passed, 3 skipped
- [x] Producer unit tests passed
- [x] Engine and producer typechecks and builds passed
- [ ] Manual render test
This commit is contained in:
Varo
2026-08-04 19:22:33 -07:00
committed by GitHub
parent bce2140ff2
commit b390b71bde
6 changed files with 221 additions and 14 deletions
@@ -51,6 +51,7 @@ import {
createCaptureSession,
createFrameLookupTable,
createVideoFrameInjector,
deriveBeginFrameProbeTimeTicks,
type EngineConfig,
type ExtractedFrames,
type FrameLookupTable,
@@ -316,7 +317,10 @@ export async function beginFrameSessionNeedsScreenshotFallback(
Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS) > 0
? Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS)
: 30_000;
const probeTick = Math.max(0, session.beginFrameTimeTicks - 5 * session.beginFrameIntervalMs);
const probeTick = deriveBeginFrameProbeTimeTicks(
session.beginFrameTimeTicks,
session.beginFrameIntervalMs,
);
return !(await probe(session.page, timeoutMs, probeTick, session.beginFrameIntervalMs));
}
@@ -46,6 +46,12 @@ let createSessionFailUntilAttempt = 0;
let createSessionError: Error | null = null;
let closeCaptureSessionCallCount = 0;
let probeBeginFrameAlive = true;
const beginFrameProbeCalls: Array<{
page: unknown;
timeoutMs: number;
frameTimeTicks: number;
intervalMs: number;
}> = [];
const createdSessions: MockSession[] = [];
const closedSessions: MockSession[] = [];
const durationProbeSessions: MockSession[] = [];
@@ -59,6 +65,7 @@ function resetRetryMocks() {
createSessionError = null;
closeCaptureSessionCallCount = 0;
probeBeginFrameAlive = true;
beginFrameProbeCalls.length = 0;
createdSessions.length = 0;
closedSessions.length = 0;
durationProbeSessions.length = 0;
@@ -137,7 +144,15 @@ mock.module("@hyperframes/engine", () => ({
closeCaptureSessionCallCount++;
closedSessions.push(session);
},
probeBeginFrameLiveness: async () => probeBeginFrameAlive,
probeBeginFrameLiveness: async (
page: unknown,
timeoutMs: number,
frameTimeTicks: number,
intervalMs: number,
) => {
beginFrameProbeCalls.push({ page, timeoutMs, frameTimeTicks, intervalMs });
return probeBeginFrameAlive;
},
// Mirror of the real engine classifier. Canonical tests + pattern list
// live in frameCapture-transientErrors.test.ts — update both if patterns change.
isTransientBrowserError: (error: unknown) => {
@@ -678,6 +693,14 @@ describe("runProbeStage — transient browser error retry (#1687)", () => {
expect(createSessionCallCount).toBe(2);
expect(closedSessions).toEqual([createdSessions[0]]);
expect(capturedCfgs[1]).toMatchObject({ forceScreenshot: true });
expect(beginFrameProbeCalls).toEqual([
{
page: createdSessions[0]?.page,
timeoutMs: 30_000,
frameTimeTicks: 95,
intervalMs: 1,
},
]);
expect(durationProbeSessions).toEqual([createdSessions[1]]);
expect(result.probeSession).toBe(createdSessions[1]);
expect(result.beginFrameStalled).toBe(true);
@@ -36,6 +36,7 @@ import {
type EngineConfig,
closeCaptureSession,
createCaptureSession,
deriveBeginFrameProbeTimeTicks,
getCompositionDuration,
initializeSession,
isTransientBrowserError,
@@ -388,9 +389,9 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
const livenessStart = Date.now();
// Tick inside the post-warmup cushion: warmup < probe < first capture
// keeps the session's BeginFrame frameTimeTicks monotonic.
const probeTick = Math.max(
0,
probeSession.beginFrameTimeTicks - 5 * probeSession.beginFrameIntervalMs,
const probeTick = deriveBeginFrameProbeTimeTicks(
probeSession.beginFrameTimeTicks,
probeSession.beginFrameIntervalMs,
);
const alive = await probeBeginFrameLiveness(
probeSession.page,