fix(cli): capture waits for a mid-read navigation to settle and reads the page again once (#6567)

* fix(cli): capture waits for a mid-read navigation to settle and reads the page again once

A site that redirects or reloads after load destroyed the execution context under a page read, and the capture failed with Execution context was destroyed. The capture page's evaluate now settles the new document and runs the read once more on that error only.

* fix(cli): the capture browser opens every page with reads that ride out a navigation
This commit is contained in:
Miguel Ángel
2026-10-27 16:09:12 +00:00
committed by GitHub
parent d571d77cae
commit 013fe4e18a
5 changed files with 104 additions and 2 deletions
@@ -38,7 +38,11 @@ it.each(["SIGINT", "SIGTERM"] as const)(
child.emit("exit", 0);
browser.emit("disconnected");
});
const browser = Object.assign(new EventEmitter(), { process: () => child, close });
const browser = Object.assign(new EventEmitter(), {
process: () => child,
close,
newPage: vi.fn(),
});
mocks.launch.mockResolvedValue(browser);
mocks.proxyClose.mockResolvedValue(undefined);
const { launchCaptureBrowser } = await import("./launchCaptureBrowser.js");
@@ -28,7 +28,11 @@ beforeEach(() => {
vi.resetAllMocks();
mocks.proxyClose.mockResolvedValue(undefined);
mocks.browserClose.mockResolvedValue(undefined);
mocks.launch.mockResolvedValue({ close: mocks.browserClose, once: mocks.once });
mocks.launch.mockResolvedValue({
close: mocks.browserClose,
once: mocks.once,
newPage: vi.fn(),
});
});
describe("capture browser transport lifecycle", () => {
it("forces proxy for loopback and disables direct UDP routes", async () => {
@@ -101,3 +105,24 @@ describe("capture browser transport lifecycle", () => {
await session.close();
});
});
describe("pages the capture browser opens", () => {
it("ride out a navigation that destroys the execution context mid-read", async () => {
const evaluate = vi
.fn()
.mockRejectedValueOnce(
new Error("Execution context was destroyed, most likely because of a navigation."),
)
.mockResolvedValue("ok");
const rawPage = { evaluate, waitForNetworkIdle: vi.fn(async () => undefined) };
mocks.launch.mockResolvedValue({
close: mocks.browserClose,
once: mocks.once,
newPage: vi.fn(async () => rawPage),
});
const session = await launchCaptureBrowser(options);
const page = await session.browser.newPage();
await expect(page.evaluate(() => "read")).resolves.toBe("ok");
await session.close();
});
});
@@ -8,6 +8,7 @@ import {
registerBrowserSessionCleanup,
} from "../browser/launch.js";
import { captureBrowserArgs } from "./browserLaunchArgs.js";
import { settleAcrossNavigation } from "./navigateForCapture.js";
import { startCaptureProxy } from "./captureProxy.js";
import { removeTreeBestEffortAsync } from "@hyperframes/core/remove-tree";
@@ -66,6 +67,8 @@ export async function launchCaptureBrowser(options: {
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
],
});
const newPage = browser.newPage.bind(browser);
browser.newPage = async () => settleAcrossNavigation(await newPage());
browser.once("disconnected", () => void proxy.close());
return {
browser,
@@ -6,6 +6,7 @@ import {
isNavigationTimeoutError,
navigateForCapture,
networkIdleAttemptTimeoutMs,
settleAcrossNavigation,
waitForPageReady,
NETWORK_IDLE_ATTEMPT_MS,
} from "./navigateForCapture.js";
@@ -416,3 +417,51 @@ describe("gotoWhenSent", () => {
await failed;
});
});
describe("settleAcrossNavigation", () => {
const destroyed = () =>
new Error("Execution context was destroyed, most likely because of a navigation.");
function stubPage(failures: Error[]) {
const waitForNetworkIdle = vi.fn(async () => undefined);
const read = () => "html";
const reads = vi.fn();
const evaluate = vi.fn(async (fn: unknown) => {
if (fn !== read) return false;
reads();
const failure = failures.shift();
if (failure) throw failure;
return "html";
});
const page = { evaluate, waitForNetworkIdle } as unknown as Page;
settleAcrossNavigation(page);
return { page, read, reads, waitForNetworkIdle };
}
it("settles the new document and reads again when a navigation lands mid-read", async () => {
const { page, read, reads, waitForNetworkIdle } = stubPage([destroyed()]);
await expect(page.evaluate(read)).resolves.toBe("html");
expect(reads).toHaveBeenCalledTimes(2);
expect(waitForNetworkIdle).toHaveBeenCalled();
});
it("reads once when nothing navigates", async () => {
const { page, read, reads, waitForNetworkIdle } = stubPage([]);
await expect(page.evaluate(read)).resolves.toBe("html");
expect(reads).toHaveBeenCalledTimes(1);
expect(waitForNetworkIdle).not.toHaveBeenCalled();
});
it("gives up after one more try, so a page that never stops navigating still fails", async () => {
const { page, read, reads } = stubPage([destroyed(), destroyed()]);
await expect(page.evaluate(read)).rejects.toThrow(/Execution context was destroyed/);
expect(reads).toHaveBeenCalledTimes(2);
});
it("does not retry any other error", async () => {
const { page, read, reads, waitForNetworkIdle } = stubPage([new Error("boom")]);
await expect(page.evaluate(read)).rejects.toThrow("boom");
expect(reads).toHaveBeenCalledTimes(1);
expect(waitForNetworkIdle).not.toHaveBeenCalled();
});
});
@@ -126,6 +126,27 @@ export async function waitForPageReady(
if (waitedOutLoad && remaining() > 0) await idle();
}
function isNavigationRaceError(error: unknown): boolean {
return error instanceof Error && /Execution context was destroyed/i.test(error.message);
}
export function settleAcrossNavigation(page: Page): Page {
const read = page.evaluate.bind(page);
page.evaluate = (async (...args: Parameters<Page["evaluate"]>) => {
try {
return await read(...args);
} catch (error) {
if (!isNavigationRaceError(error)) throw error;
await waitForPageReady({
evaluate: (fn) => read(fn),
waitForNetworkIdle: (o) => page.waitForNetworkIdle(o),
});
return read(...args);
}
}) as Page["evaluate"];
return page;
}
const PUPPETEER_LAUNCH_DEFAULT_TIMEOUT_MS = 30_000;
const PAGE_LOAD_BLOCKING_TYPES = new Set(["Document", "Script", "Stylesheet"]);
const NAMED_BLOCKING_REQUESTS = 3;