mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user