fix(engine): disable browser pool for parallel capture workers (#1087)

* fix(engine): disable browser pool for parallel capture in BeginFrame mode

BeginFrame's compositor is process-global — when multiple pages in the
same Chrome instance drive HeadlessExperimental.beginFrame concurrently,
they race the compositor and crash with "Protocol error: Target closed".

Only disable the pool when BeginFrame mode would actually be active
(Linux + headless-shell + not forceScreenshot). Screenshot mode
(macOS/Windows) is unaffected and keeps the pool for memory efficiency.

Also extracts the frame capture loop into captureFrameRange to reduce
function complexity in executeWorkerTask.

* fix(engine): include supersampling in BeginFrame-mode predicate

Match the full capture-mode predicate from createCaptureSession:
DPR > 1 (supersampling) forces screenshot mode, which is pool-safe.
Without this check, supersampled parallel renders on Linux would
unnecessarily launch separate browsers.
This commit is contained in:
Miguel Ángel
2026-05-26 20:24:21 -04:00
committed by GitHub
parent c53552876e
commit a9482ed801
@@ -25,6 +25,7 @@ import {
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { assertSwiftShader } from "../utils/assertSwiftShader.js";
import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
import { resolveHeadlessShellPath } from "./browserManager.js";
export interface WorkerTask {
workerId: number;
@@ -191,6 +192,33 @@ export function shouldVerifyWorkerGpu(workerId: number, config?: Partial<EngineC
return config?.browserGpuMode === "software" && workerId === 0;
}
async function captureFrameRange(
session: CaptureSession,
task: WorkerTask,
captureOptions: CaptureOptions,
signal: AbortSignal | undefined,
onFrameCaptured: ((workerId: number, frameIndex: number) => void) | undefined,
onFrameBuffer: ((frameIndex: number, buffer: Buffer) => Promise<void>) | undefined,
): Promise<number> {
let framesCaptured = 0;
const outputOffset = task.outputFrameOffset ?? 0;
for (let i = task.startFrame; i < task.endFrame; i++) {
if (signal?.aborted) throw new Error("Parallel worker cancelled");
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
const fileFrameIdx = i - outputOffset;
if (onFrameBuffer) {
const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
await onFrameBuffer(i, buffer);
} else {
await captureFrame(session, fileFrameIdx, time);
}
framesCaptured++;
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
}
return framesCaptured;
}
async function executeWorkerTask(
task: WorkerTask,
serverUrl: string,
@@ -200,6 +228,7 @@ async function executeWorkerTask(
onFrameCaptured?: (workerId: number, frameIndex: number) => void,
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
config?: Partial<EngineConfig>,
parallel?: boolean,
): Promise<WorkerResult> {
const startTime = Date.now();
let framesCaptured = 0;
@@ -209,58 +238,43 @@ async function executeWorkerTask(
let session: CaptureSession | null = null;
let perf: CapturePerfSummary | undefined;
// BeginFrame's compositor is process-global — multiple pages driving
// beginFrame in the same browser race it and crash with "Target closed".
// Only disable the pool when BeginFrame mode would actually be active.
// Must match the predicate in createCaptureSession (frameCapture.ts):
// Linux + headless-shell + !forceScreenshot + !supersampling.
const supersampling = (captureOptions.deviceScaleFactor ?? 1) > 1;
const needsSeparateBrowsers =
parallel &&
process.platform === "linux" &&
!config?.forceScreenshot &&
!supersampling &&
resolveHeadlessShellPath(config) !== undefined;
const workerConfig: Partial<EngineConfig> | undefined = needsSeparateBrowsers
? { ...config, enableBrowserPool: false }
: config;
try {
session = await createCaptureSession(
serverUrl,
task.outputDir,
captureOptions,
createBeforeCaptureHook(),
config,
workerConfig,
);
// Per-worker SwiftShader assertion, gated to worker 0 only.
// When `browserGpuMode: "software"` is declared, the chunk's GL backend
// must be verified as SwiftShader before the first frame — a host that
// falls back to a hardware GL backend (or silently fails to load
// SwiftShader) would otherwise produce non-deterministic pixels and
// break the distributed byte-identical-retry contract. Running this
// probe on every worker means N concurrent navigations to a WebGL
// probe page per chunk; with `chunkWorkerCount=6` × 3 chunks, that's
// 18 simultaneous CDP page-loads, which inflated c=3 worst-case wall
// by ~24s vs c=6/c=8 on the texture-launch bench. Workers in the same
// chunk share the same Chrome binary, flags, and OS/driver state, so
// worker 0's success is representative — gate it there and skip the
// rest. See `heygen-com/hyperframes#955` for the bench data and the
// pre-warmup probe interaction (which `renderChunk` already skips
// when `chunkWorkerCount > 1`).
if (shouldVerifyWorkerGpu(task.workerId, config)) {
// Worker-0-only SwiftShader assertion — see `shouldVerifyWorkerGpu` and #955.
if (shouldVerifyWorkerGpu(task.workerId, workerConfig)) {
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
}
await initializeSession(session);
const outputOffset = task.outputFrameOffset ?? 0;
for (let i = task.startFrame; i < task.endFrame; i++) {
if (signal?.aborted) {
throw new Error("Parallel worker cancelled");
}
// captureOptions.fps is an Fps rational; collapse to decimal for the
// frame-index → time math. The 1-in-1001 ULP loss for NTSC is invisible
// at our scales (frame count tops out at single-digit thousands).
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
const fileFrameIdx = i - outputOffset;
if (onFrameBuffer) {
// The streaming-encode callback receives the absolute index `i`
// (not `fileFrameIdx`) so the encoder sequences frames against the
// composition's timeline.
const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
await onFrameBuffer(i, buffer);
} else {
await captureFrame(session, fileFrameIdx, time);
}
framesCaptured++;
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
}
framesCaptured = await captureFrameRange(
session,
task,
captureOptions,
signal,
onFrameCaptured,
onFrameBuffer,
);
perf = getCapturePerfSummary(session);
return {
@@ -318,6 +332,7 @@ export async function executeParallelCapture(
}
};
const parallel = tasks.length > 1;
const results = await Promise.all(
tasks.map((task) =>
executeWorkerTask(
@@ -329,6 +344,7 @@ export async function executeParallelCapture(
onFrameCaptured,
onFrameBuffer,
config,
parallel,
),
),
);