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
@@ -12,7 +12,10 @@
import { describe, expect, it } from "vitest";
import {
LOCKED_WARMUP_TICKS,
deriveBeginFrameTimelineTicks,
deriveBeginFrameTimeTicks,
driveWarmupTicks,
prepareBeginFrameTimeline,
warmupFrameTimeTicks,
type WarmupTickState,
} from "./frameCapture.js";
@@ -172,3 +175,81 @@ describe("driveWarmupTicks — locked", () => {
expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33);
});
});
describe("deriveBeginFrameTimeTicks", () => {
const warmupIntervalMs = 33;
const state: WarmupTickState = {
running: false,
ticks: LOCKED_WARMUP_TICKS,
};
const expectMonotonicTimeline = (
warmupState: WarmupTickState,
captureIntervalMs: number,
): void => {
const timeline = deriveBeginFrameTimelineTicks(
warmupState,
warmupIntervalMs,
captureIntervalMs,
);
const lastWarmupTick = (warmupState.ticks - 1) * warmupIntervalMs;
expect(timeline.commit).toBeGreaterThan(lastWarmupTick);
expect(timeline.probe).toBeGreaterThan(timeline.commit);
expect(timeline.capture).toBeGreaterThan(timeline.probe);
};
it.each([60, 120, 240, 60_000 / 1001])(
"keeps warmup, commit, probe, and capture monotonic at %ifps",
(fps) => {
expectMonotonicTimeline(state, 1000 / fps);
},
);
it.each([24, 30, 30_000 / 1001, 31, 32])(
"preserves the legacy capture baseline when it is already monotonic at %ifps",
(fps) => {
const captureIntervalMs = 1000 / fps;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
(LOCKED_WARMUP_TICKS + 10) * captureIntervalMs,
);
},
);
it("keeps an unlocked warmup timeline monotonic", () => {
const unlockedState: WarmupTickState = { running: false, ticks: 7 };
expectMonotonicTimeline(unlockedState, 1000 / 60);
});
it("raises the capture baseline only when the warmup clock is ahead", () => {
const captureIntervalMs = 1000 / 60;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs,
);
});
it("raises the baseline just above the safe legacy boundary", () => {
const captureIntervalMs = 1000 / 33;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs,
);
});
it("wires the canonical capture and commit ticks into session initialization", () => {
const session = {
beginFrameIntervalMs: 1000 / 60,
beginFrameTimeTicks: 0,
};
const prepared = prepareBeginFrameTimeline(session, state, warmupIntervalMs);
expect(session.beginFrameTimeTicks).toBe(prepared.timeline.capture);
expect(prepared.commitParams).toEqual({
frameTimeTicks: prepared.timeline.commit,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
});
expect(prepared.timeline.commit).toBeGreaterThan((LOCKED_WARMUP_TICKS - 1) * warmupIntervalMs);
});
});
+106 -9
View File
@@ -632,6 +632,101 @@ export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number)
return state.ticks * intervalMs;
}
const BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS = 10;
const BEGIN_FRAME_COMMIT_LEAD_INTERVALS = 6;
const BEGIN_FRAME_PROBE_LEAD_INTERVALS = 5;
export interface BeginFrameTimelineTicks {
capture: number;
commit: number;
probe: number;
}
export interface PreparedBeginFrameTimeline {
commitParams: {
frameTimeTicks: number;
interval: number;
noDisplayUpdates: false;
};
timeline: BeginFrameTimelineTicks;
}
/**
* Place frame zero after the warmup clock while retaining capture-rate-sized
* headroom for the visual commit and liveness probe ticks that precede it.
*
* The warmup and capture intervals can differ (warmup currently runs at a
* fixed 33ms). Basing both clocks on the capture interval would move time
* backwards whenever the output frame rate is faster than the warmup rate.
*/
export function deriveBeginFrameTimeTicks(
state: WarmupTickState,
warmupIntervalMs: number,
captureIntervalMs: number,
): number {
const legacyCaptureTimeTicks =
(state.ticks + BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS) * captureIntervalMs;
const legacyCommitTimeTicks = deriveBeginFrameCommitTimeTicks(
legacyCaptureTimeTicks,
captureIntervalMs,
);
const lastWarmupTimeTicks = Math.max(0, state.ticks - 1) * warmupIntervalMs;
if (legacyCommitTimeTicks > lastWarmupTimeTicks) return legacyCaptureTimeTicks;
const monotonicCaptureTimeTicks =
warmupFrameTimeTicks(state, warmupIntervalMs) +
BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS * captureIntervalMs;
return monotonicCaptureTimeTicks;
}
function deriveBeginFrameCommitTimeTicks(
captureTimeTicks: number,
captureIntervalMs: number,
): number {
return captureTimeTicks - BEGIN_FRAME_COMMIT_LEAD_INTERVALS * captureIntervalMs;
}
export function deriveBeginFrameProbeTimeTicks(
captureTimeTicks: number,
captureIntervalMs: number,
): number {
return Math.max(0, captureTimeTicks - BEGIN_FRAME_PROBE_LEAD_INTERVALS * captureIntervalMs);
}
export function deriveBeginFrameTimelineTicks(
state: WarmupTickState,
warmupIntervalMs: number,
captureIntervalMs: number,
): BeginFrameTimelineTicks {
const capture = deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs);
return {
capture,
commit: deriveBeginFrameCommitTimeTicks(capture, captureIntervalMs),
probe: deriveBeginFrameProbeTimeTicks(capture, captureIntervalMs),
};
}
export function prepareBeginFrameTimeline(
session: Pick<CaptureSession, "beginFrameIntervalMs" | "beginFrameTimeTicks">,
state: WarmupTickState,
warmupIntervalMs: number,
): PreparedBeginFrameTimeline {
const timeline = deriveBeginFrameTimelineTicks(
state,
warmupIntervalMs,
session.beginFrameIntervalMs,
);
session.beginFrameTimeTicks = timeline.capture;
return {
timeline,
commitParams: {
frameTimeTicks: timeline.commit,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
},
};
}
export async function driveWarmupTicks(
options: WarmupTickOptions,
state: WarmupTickState,
@@ -2277,10 +2372,16 @@ export async function initializeSession(session: CaptureSession): Promise<void>
warmupState.running = false;
await warmupLoopPromise.catch(() => {});
// Set base frame time ticks past warmup range. Locked mode pins to the
// constant so chunk workers on different hosts compute the same baseline.
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
// Preserve the legacy baseline when it is already safe. Otherwise continue
// from the clock actually used by warmup, then reserve capture-rate headroom
// for the commit and probe ticks below. Locked mode still produces an
// identical timeline on every host because its driver ends at exactly
// LOCKED_WARMUP_TICKS.
const preparedBeginFrameTimeline = prepareBeginFrameTimeline(
session,
warmupState,
warmupIntervalMs,
);
// drawElement or transparent-background init — runs after page is fully ready.
// IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame
@@ -2318,11 +2419,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// `-6·interval` the order stays warmup < commit < probe < capture.
await ensureRenderFrameSiblings(page);
const commitCdp = await getCdpSession(page);
await commitCdp.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: session.beginFrameTimeTicks - 6 * session.beginFrameIntervalMs,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
});
await commitCdp.send("HeadlessExperimental.beginFrame", preparedBeginFrameTimeline.commitParams);
session.isInitialized = true;
}