fix(engine): a slow first Chrome start no longer drops a render to screenshot capture (#6420)

The beginFrame probe now tells a timeout from a real rejection.

A timeout relaunches Chrome and probes once more with a longer budget.

An unsupported answer still falls back at once.
This commit is contained in:
Miguel Ángel
2026-10-26 13:49:57 +00:00
committed by GitHub
parent dc56b3d20a
commit f16c23cd99
2 changed files with 164 additions and 26 deletions
@@ -84,6 +84,27 @@ describe("BeginFrame capability probe", () => {
expect(result.supported).toBe(false);
expect(result.detail).toMatch(/returned 0 bytes after \d+ attempts in \d+ms/);
expect(result.timedOut).toBe(true);
});
it("reports frames that are not a PNG as unsupported, not as a slow start", async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
const send = vi.fn().mockResolvedValue({ screenshotData: jpeg.toString("base64") });
const browser = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send,
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
const result = await _probeBeginFrameSupportForTests(browser, 120);
expect(result.supported).toBe(false);
expect(result.timedOut).toBe(false);
});
it("keeps trying a renderer that has not drawn its first surface yet, up to the probe deadline", async () => {
@@ -165,6 +186,30 @@ describe("BeginFrame capability probe", () => {
expect(result.detail).toContain("probe page cleanup timed out");
});
it("marks a probe that ran out of time as timed out, and a rejection as not", async () => {
const stuck = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockReturnValue(new Promise<never>(() => {})),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
const rejecting = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send: vi.fn().mockRejectedValue(new Error("'HeadlessExperimental.enable' wasn't found")),
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
expect((await _probeBeginFrameSupportForTests(stuck, 25)).timedOut).toBe(true);
const rejected = await _probeBeginFrameSupportForTests(rejecting);
expect(rejected.supported).toBe(false);
expect(rejected.timedOut).toBe(false);
});
it("force-kills and disconnects when graceful browser cleanup never resolves", async () => {
const kill = vi.fn();
const disconnect = vi.fn().mockResolvedValue(undefined);
@@ -1687,3 +1732,78 @@ describe("browser pool", () => {
await acquirePromise.catch(() => {});
});
});
describe.runIf(process.platform === "linux")("BeginFrame launch on a slow first start", () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const BEGINFRAME_ARGS = ["--no-sandbox", "--enable-begin-frame-control"];
// chromePath must exist for the launch to request BeginFrame; the mocked launch never runs it.
const BEGINFRAME_CONFIG = { enableBrowserPool: false, chromePath: process.execPath };
const base = {
connected: true,
version: vi.fn().mockResolvedValue("HeadlessChrome/131.0.0.0"),
close: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
process: () => ({ kill: vi.fn(), killed: false }),
};
const healthyPage = () => ({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send: vi
.fn()
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValue({ hasDamage: true, screenshotData: png.toString("base64") }),
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
});
const slowPage = () => ({
goto: vi.fn().mockReturnValue(new Promise<never>(() => {})),
close: vi.fn().mockResolvedValue(undefined),
});
afterEach(async () => {
vi.useRealTimers();
await drainBrowserPool();
_setPuppeteerForTests(undefined);
});
it("probes again on a fresh launch instead of falling back to screenshot capture", async () => {
_resetBrowserPoolForTests();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const pages = [slowPage(), healthyPage()];
const launch = vi.fn().mockImplementation(async () => ({
...base,
newPage: vi.fn().mockResolvedValue(pages.shift()),
}));
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
vi.useFakeTimers();
const pending = acquireBrowser(BEGINFRAME_ARGS, BEGINFRAME_CONFIG);
await vi.advanceTimersByTimeAsync(3000);
const result = await pending;
expect(result.captureMode).toBe("beginframe");
expect(launch).toHaveBeenCalledTimes(2);
});
it("falls back to screenshot capture when the longer probe also fails", async () => {
_resetBrowserPoolForTests();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const launch = vi.fn().mockImplementation(async () => ({
...base,
newPage: vi.fn().mockResolvedValue(slowPage()),
}));
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
vi.useFakeTimers();
const pending = acquireBrowser(BEGINFRAME_ARGS, BEGINFRAME_CONFIG);
await vi.advanceTimersByTimeAsync(12000);
const result = await pending;
expect(result.captureMode).toBe("screenshot");
expect(launch).toHaveBeenCalledTimes(3);
});
});
+44 -26
View File
@@ -346,9 +346,13 @@ interface BeginFrameProbeResult {
supported: boolean;
detail: string;
durationMs: number;
timedOut?: boolean;
}
class BeginFrameProbeTimeout extends Error {}
const BEGINFRAME_PROBE_TIMEOUT_MS = 2000;
const BEGINFRAME_PROBE_RETRY_TIMEOUT_MS = 8000;
const BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS = 250;
async function awaitBeforeDeadline<T>(
@@ -358,22 +362,29 @@ async function awaitBeforeDeadline<T>(
): Promise<T> {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new Error(`beginFrame probe timeout before ${label}`);
throw new BeginFrameProbeTimeout(`beginFrame probe timeout before ${label}`);
}
return rejectAfter(operation, remainingMs, `beginFrame probe timeout during ${label}`);
return rejectAfter(
operation,
remainingMs,
new BeginFrameProbeTimeout(`beginFrame probe timeout during ${label}`),
);
}
async function rejectAfter<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
error: string | Error,
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
timeout = setTimeout(
() => reject(typeof error === "string" ? new Error(error) : error),
timeoutMs,
);
}),
]);
} finally {
@@ -490,7 +501,8 @@ async function probeBeginFrameSupport(
);
}
if (!isPng) {
throw new Error(
const Failure = bytes.length === 0 ? BeginFrameProbeTimeout : Error;
throw new Failure(
`beginFrame screenshot returned ${bytes.length} bytes after ` +
`${attempts} attempts in ${Date.now() - started}ms with signature ` +
`${bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"}`,
@@ -509,6 +521,7 @@ async function probeBeginFrameSupport(
supported: false,
detail: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - started,
timedOut: error instanceof BeginFrameProbeTimeout,
};
}
if (page && !(await settleWithin(page.close(), BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS))) {
@@ -871,17 +884,26 @@ async function launchBrowser(
const env = launchEnv(fingerprint);
let captureMode = fingerprint.requestedCaptureMode;
let browser: Browser | undefined;
try {
browser = await ppt.launch({
const launch = (args: readonly string[]) =>
ppt.launch({
...sigintLaunchOptions(),
headless: true,
args: withHyperframesBrowserMarker(fingerprint.args),
args: withHyperframesBrowserMarker(args),
defaultViewport: null,
executablePath: fingerprint.executablePath,
env,
timeout: fingerprint.browserTimeoutMs,
protocolTimeout: fingerprint.protocolTimeoutMs,
});
const probe = (target: Browser, timeoutMs?: number) =>
probeBeginFrameSupport(target, timeoutMs).catch((error) => ({
supported: true,
detail: `probe harness error ignored: ${error instanceof Error ? error.message : String(error)}`,
durationMs: 0,
timedOut: false,
}));
try {
browser = await launch(fingerprint.args);
const browserVersion = await browser.version().catch(() => "unknown");
const gpuFlags = fingerprint.args.filter(
@@ -892,29 +914,25 @@ async function launchBrowser(
);
if (captureMode === "beginframe") {
const probe = await probeBeginFrameSupport(browser).catch((error) => ({
supported: true,
detail: `probe harness error ignored: ${error instanceof Error ? error.message : String(error)}`,
durationMs: 0,
}));
if (!probe.supported) {
let result = await probe(browser);
if (!result.supported && result.timedOut) {
console.warn(
`[BrowserManager] HeadlessExperimental.beginFrame probe timed out after ${result.durationMs}ms: ` +
`${result.detail}; retrying once with a longer budget.`,
);
await closeBrowserAfterFailedProbe(browser);
browser = await launch(fingerprint.args);
result = await probe(browser, BEGINFRAME_PROBE_RETRY_TIMEOUT_MS);
}
if (!result.supported) {
await closeBrowserAfterFailedProbe(browser);
browser = undefined;
console.warn(
`[BrowserManager] HeadlessExperimental.beginFrame probe failed after ${probe.durationMs}ms: ` +
`${probe.detail}; falling back to screenshot mode.`,
`[BrowserManager] HeadlessExperimental.beginFrame probe failed after ${result.durationMs}ms: ` +
`${result.detail}; falling back to screenshot mode.`,
);
captureMode = "screenshot";
browser = await ppt.launch({
...sigintLaunchOptions(),
headless: true,
args: withHyperframesBrowserMarker(stripBeginFrameFlags([...fingerprint.args])),
defaultViewport: null,
executablePath: fingerprint.executablePath,
env,
timeout: fingerprint.browserTimeoutMs,
protocolTimeout: fingerprint.protocolTimeoutMs,
});
browser = await launch(stripBeginFrameFlags([...fingerprint.args]));
}
}