refactor(engine): clamp warmupTicks to fixed iteration count, gated

Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (warmupTicks row) and §17.2 (gating
table).

The BeginFrame warmup loop in `initializeSession` is driven by wall-clock
during page load — different hosts accumulate different tick counts before
page-readiness completes. That shifts `session.beginFrameTimeTicks` and
yields non-byte-identical captures on distributed workers.

This change adds `lockWarmupTicks: boolean` (default false) to
`CaptureOptions`. When false, behavior is unchanged. When true, the loop
runs exactly `LOCKED_WARMUP_TICKS = 60` iterations regardless of page-load
wall clock, and `session.beginFrameTimeTicks` is computed from the
constant — pinning the baseline across hosts.

Refactoring:

  - Extract `driveWarmupTicks(options, state)` as a pure helper. Tests
    drive it with a stub `tick` callback and an injected `sleep`, so the
    iteration-count contract is unit-testable without real Chrome.
  - `initializeSession`'s warmup body is now a thin adapter that calls
    `driveWarmupTicks` with a CDP-backed tick.

Producer regression baselines remain byte-identical: the in-process
renderer never passes `lockWarmupTicks: true`. Phase 3 distributed
primitives will flip it true when launching chunk workers.

11 new unit tests at packages/engine/src/services/
frameCapture-warmupTicks.test.ts pin both branches (unlocked drifts with
simulated load time; locked produces identical counts).

This is part of a stack of 10 PRs; this is PR 3 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-13 04:36:11 +00:00
co-authored by Claude Opus 4.7
parent d8486a7c4d
commit d54ad9ca19
3 changed files with 309 additions and 24 deletions
+121 -24
View File
@@ -78,6 +78,81 @@ export interface CaptureSession {
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
/**
* Fixed warmup-loop iteration count used when `CaptureOptions.lockWarmupTicks`
* is `true`. Picked to roughly match the median tick count observed by the
* unlocked wall-clock loop during a typical 2s page load at 30fps — so
* `beginFrameTimeTicks` lands in a similar range regardless of host speed.
*/
export const LOCKED_WARMUP_TICKS = 60;
/**
* Internal driver for the BeginFrame warmup loop.
*
* - Unlocked: exits as soon as `state.running` flips to `false`. Tick count
* varies with wall-clock page-load time.
* - Locked: ignores `state.running` entirely and exits once it has driven
* exactly `LOCKED_WARMUP_TICKS` iterations. Caller awaits this promise
* after page-readiness so `session.beginFrameTimeTicks` is identical
* across hosts.
* - `tick` errors are swallowed (Chrome's `beginFrame` is best-effort
* during page load — the page hasn't installed CDP listeners yet). When
* `tick` throws, the iteration count does NOT advance.
*
* `intervalMs` is the BeginFrame interval (≈33ms at 30fps).
*
* `frameTimeTicks` is derived as `ticks * intervalMs` and exposed via
* {@link warmupFrameTimeTicks} — not stored on the state, to keep `ticks`
* the single source of truth.
*/
export interface WarmupTickState {
running: boolean;
ticks: number;
}
export interface WarmupTickOptions {
intervalMs: number;
lockWarmupTicks: boolean;
tick: (frameTimeTicks: number, intervalMs: number) => Promise<void>;
/** Injectable so tests can advance "time" without real setTimeout. */
sleep?: (ms: number) => Promise<void>;
}
const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Derive the current simulated frame time from a warmup state. Single source
* of truth so tests and callers stay in sync.
*/
export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number): number {
return state.ticks * intervalMs;
}
export async function driveWarmupTicks(
options: WarmupTickOptions,
state: WarmupTickState,
): Promise<void> {
const sleep = options.sleep ?? realSleep;
while (true) {
if (options.lockWarmupTicks) {
// Locked mode exits on the iteration count, ignoring `state.running` —
// the caller flips `running=false` after page-readiness but we keep
// ticking until LOCKED_WARMUP_TICKS so the count is host-independent.
if (state.ticks >= LOCKED_WARMUP_TICKS) return;
} else {
// Unlocked mode is wall-clock-bounded.
if (!state.running) return;
}
try {
await options.tick(state.ticks * options.intervalMs, options.intervalMs);
state.ticks += 1;
} catch {
// Page not ready yet; keep spinning.
}
await sleep(options.intervalMs);
}
}
async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -447,38 +522,53 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// In BeginFrame mode, Chrome's event loop is paused until we issue frames.
// Start a warmup loop to drive rAF/setTimeout callbacks during page load.
let warmupRunning = true;
let warmupTicks = 0;
let warmupFrameTime = 0;
//
// The unlocked path runs while `warmupState.running` stays true — wall-
// clock-bounded. The locked path (`options.lockWarmupTicks`) additionally
// exits at exactly `LOCKED_WARMUP_TICKS` iterations so `beginFrameTimeTicks`
// is deterministic across hosts with different page-load latencies.
const warmupIntervalMs = 33; // ~30fps
const warmupState: WarmupTickState = {
running: true,
ticks: 0,
};
const lockWarmupTicks = session.options.lockWarmupTicks === true;
let warmupClient: import("puppeteer-core").CDPSession | null = null;
const warmupLoop = async () => {
const acquireWarmupClient = async (): Promise<void> => {
try {
warmupClient = await getCdpSession(page);
await warmupClient.send("HeadlessExperimental.enable");
} catch {
/* page not ready yet */
}
};
while (warmupRunning) {
if (warmupClient) {
try {
const warmupLoopPromise = (async () => {
await acquireWarmupClient();
await driveWarmupTicks(
{
intervalMs: warmupIntervalMs,
lockWarmupTicks,
tick: async (frameTimeTicks, interval) => {
if (!warmupClient) {
// No CDP yet — let driveWarmupTicks count the tick anyway so the
// locked iteration count is reached deterministically. Throwing
// would skip the ticks++ increment, leaking host-load variance
// back into the count.
return;
}
await warmupClient.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: warmupFrameTime,
interval: warmupIntervalMs,
frameTimeTicks,
interval,
noDisplayUpdates: true,
});
warmupFrameTime += warmupIntervalMs;
warmupTicks++;
} catch {
/* ignore warmup errors */
}
}
await new Promise((r) => setTimeout(r, warmupIntervalMs));
}
};
warmupLoop().catch(() => {});
},
},
warmupState,
);
})();
warmupLoopPromise.catch(() => {});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
@@ -497,7 +587,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
);
if (!pageReady) {
warmupRunning = false;
warmupState.running = false;
throw new Error(
`[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
);
@@ -521,11 +611,18 @@ export async function initializeSession(session: CaptureSession): Promise<void>
await page.evaluate(`document.fonts?.ready`);
await waitForOptionalTailwindReady(page, pageReadyTimeout);
// Stop warmup
warmupRunning = false;
// Stop warmup. Unlocked mode exits on this flag; locked mode keeps ticking
// until LOCKED_WARMUP_TICKS, so we await its promise to ensure the count is
// exact before deriving the baseline.
warmupState.running = false;
if (lockWarmupTicks) {
await warmupLoopPromise.catch(() => {});
}
// Set base frame time ticks past warmup range
session.beginFrameTimeTicks = (warmupTicks + 10) * session.beginFrameIntervalMs;
// 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;
// For PNG captures, inject the transparent-background override + stylesheet
// (see the screenshot-mode branch above for the rationale). BeginFrame mode