diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index f6b62585d9..52ae859e67 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -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(() => {})), + 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(() => {})), + 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); + }); +}); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index a88ad7be58..04b0f32d63 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -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( @@ -358,22 +362,29 @@ async function awaitBeforeDeadline( ): Promise { 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( operation: Promise, timeoutMs: number, - message: string, + error: string | Error, ): Promise { let timeout: ReturnType | undefined; try { return await Promise.race([ operation, new Promise((_, 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") : ""}`, @@ -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])); } }