Skip to content
121 changes: 120 additions & 1 deletion apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,20 @@ const PNG_HEADER = (() => {
return header.toString("base64");
})();

function makeRenderer() {
return {
/** A page script that never yields; Chromium stops it on `Runtime.terminateExecution`. */
busyScript: null as PromiseWithResolvers<void> | null,
gotoStarted: Promise.withResolvers<void>(),
/** Rejecting these stands in for Playwright's goto timeout while the script runs. */
stalledGotos: [] as Array<(cause: Error) => void>,
};
}

function makeSession(
frame: ReturnType<typeof makeFrame> | undefined,
viewport: { width: number; height: number },
renderer?: ReturnType<typeof makeRenderer>,
) {
const events = new NodeEvents.EventEmitter();
return {
Expand All @@ -106,6 +117,17 @@ function makeSession(
Promise.all(events.listeners(name).map((listener) => listener(...args))),
detach: vi.fn(async () => {}),
send: vi.fn(async (method: string, input?: unknown): Promise<Record<string, unknown>> => {
const busyScript = renderer?.busyScript;
if (method === "Runtime.terminateExecution" && busyScript) {
renderer.busyScript = null;
busyScript.resolve();
return {};
}
if (method === "Runtime.evaluate" && busyScript) {
return busyScript.promise.then(() => {
throw new Error("Execution was terminated");
});
}
if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] };
if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
if (method === "Page.captureScreenshot") {
Expand Down Expand Up @@ -163,6 +185,7 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = "
const nativeFileChooser = vi.fn();
const setChooserFiles = vi.fn(async (_files: unknown) => {});
const sessions: ReturnType<typeof makeSession>[] = [];
const renderer = makeRenderer();
let url = initialUrl;
let viewport: ReturnType<Page["viewportSize"]> = { width: 1280, height: 800 };
const cssViewport = { width: 1920, height: 1080 };
Expand Down Expand Up @@ -199,7 +222,15 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = "
setViewportSize: vi.fn(async (size: typeof viewport) => {
viewport = size;
}),
goto: vi.fn(async (next: string) => {
goto: vi.fn(async (next: string, _options?: { timeout?: number }) => {
const busyScript = renderer.busyScript;
renderer.gotoStarted.resolve();
if (busyScript) {
await new Promise<void>((resolve, reject) => {
renderer.stalledGotos.push(reject);
void busyScript.promise.then(resolve);
});
}
url = next;
events.emit("load");
const pending = initialNavigation;
Expand Down Expand Up @@ -233,13 +264,15 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = "
const context = {
page,
sessions,
renderer,
cssViewport,
newPage: async () => page as unknown as Page,
grantPermissions: vi.fn(async () => {}),
newCDPSession: async (target: unknown) => {
const session = makeSession(
target === page ? undefined : (target as ReturnType<typeof makeFrame>),
cssViewport,
renderer,
);
sessions.push(session);
return session;
Expand Down Expand Up @@ -787,6 +820,30 @@ it.live.each([
).pipe(Effect.provide(layer)),
);

it.live.each([
{ method: "goto" as const, message: { type: "navigate", url: "http://localhost:5173/next" } },
{ method: "goBack" as const, message: { type: "history", delta: -1 } },
{ method: "goForward" as const, message: { type: "history", delta: 1 } },
{ method: "reload" as const, message: { type: "reload" } },
])("a viewer $method leaves a page whose script never yields", ({ method, message }) =>
Effect.scoped(
Effect.gen(function* () {
const { browser, tabId } = yield* ready;
const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
yield* viewer.input({ type: "takeControl" });
const { page, renderer } = contexts[0]!;
renderer.busyScript = Promise.withResolvers<void>();
// A same-site navigation cannot commit until the page's script stops.
const scriptStoppedFirst = Promise.withResolvers<boolean>();
page[method].mockImplementationOnce(async () => {
scriptStoppedFirst.resolve(renderer.busyScript === null);
});
yield* viewer.input(message);
expect(yield* Effect.promise(() => scriptStoppedFirst.promise)).toBe(true);
}),
).pipe(Effect.provide(layer)),
);

// The fake encoder decodes every frame at 1280x800, so 30 fps hits the bitrate floor.
it.live.each([
{ reports: [], frameRate: 30, bitsPerSecond: 2.5e6 },
Expand Down Expand Up @@ -818,6 +875,68 @@ it.live.each([
).pipe(Effect.provide(layer)),
);

it.live.each(["load", "none"] as const)(
"an agent navigates away from a page whose script never yields (readiness $0)",
(readiness) =>
Effect.scoped(
Effect.gen(function* () {
const { broker, tabId } = yield* ready;
yield* broker.invoke({
scope,
tabId,
operation: "navigate",
input: { url: "http://localhost:5173/busy" },
});
const { renderer, sessions } = contexts[0]!;
// On an idle renderer V8 would terminate the next script, which may be the new page's.
expect(
sessions.flatMap((session) => session.send.mock.calls.map(([method]) => method)),
).not.toContain("Runtime.terminateExecution");
renderer.busyScript = Promise.withResolvers<void>();
renderer.gotoStarted = Promise.withResolvers<void>();

const navigation = yield* broker
.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "navigate",
input: { url: "http://localhost:5173/next", readiness, timeoutMs: 200 },
})
.pipe(Effect.exit, Effect.forkScoped);
yield* Effect.promise(() => renderer.gotoStarted.promise);
for (const timeOut of renderer.stalledGotos.splice(0)) {
timeOut(
Object.assign(new Error("page.goto: Timeout 200ms exceeded."), {
name: "TimeoutError",
}),
);
}
const navigated = yield* Fiber.join(navigation);
const waited = yield* broker
.invoke({
scope,
tabId,
operation: "waitFor",
input: { urlIncludes: "/next", timeoutMs: 100 },
})
.pipe(Effect.exit);
const status = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});

expect(status.url).toBe("http://localhost:5173/next");
expect(navigated).toMatchObject({ _tag: "Success" });
// Waiting on the busy script spends the navigation's own budget.
const [, gotoOptions] = contexts[0]!.page.goto.mock.calls.at(-1)!;
expect(gotoOptions?.timeout).toBeLessThan(200);
expect(waited).toMatchObject({ _tag: "Success" });
}),
).pipe(Effect.provide(layer)),
);

it.live("enforces provider ownership and explicit targets when a session has multiple tabs", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
31 changes: 24 additions & 7 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1685,8 +1685,11 @@ const make = Effect.gen(function* () {
readiness: "load" | "domContentLoaded" | "none",
timeout: number,
) => {
const probeStartedAt = performance.now();
await ServerBrowserPage.stopUnresponsiveScript(tab.cdp, timeout);
const navigation = tab.page.goto(url, {
timeout,
// The probe spends the caller's budget; the broker evicts a host that answers late.
timeout: Math.max(1, timeout - (performance.now() - probeStartedAt)),
waitUntil:
readiness === "domContentLoaded"
? "domcontentloaded"
Expand Down Expand Up @@ -2362,7 +2365,10 @@ const make = Effect.gen(function* () {
const request = event.request;
if (request?.clear === "cookies") await tab.cdp.send("Network.clearBrowserCookies");
if (request?.clear === "cache") await tab.cdp.send("Network.clearBrowserCache");
if (request?.hardReload) await tab.cdp.send("Page.reload", { ignoreCache: true });
if (request?.hardReload) {
await ServerBrowserPage.stopUnresponsiveScript(tab.cdp);
await tab.cdp.send("Page.reload", { ignoreCache: true });
}
})
.catch((cause: unknown) =>
runFork(Effect.logWarning("server preview could not apply a tab setting", { cause })),
Expand Down Expand Up @@ -2496,22 +2502,33 @@ const make = Effect.gen(function* () {
case "navigate":
if (typeof message.url === "string") {
const url = normalizePreviewUrl(message.url);
tab.control.trackUntilHandoff(tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS));
tab.control.trackUntilHandoff(
ServerBrowserPage.stopUnresponsiveScript(tab.cdp).then(() =>
tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS),
),
);
}
return;
case "history":
tab.control.trackUntilHandoff(
num(message.delta) < 0
? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS)
: tab.page.goForward(VIEWER_NAVIGATION_OPTIONS),
ServerBrowserPage.stopUnresponsiveScript(tab.cdp).then(() =>
num(message.delta) < 0
? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS)
: tab.page.goForward(VIEWER_NAVIGATION_OPTIONS),
),
);
return;
case "reload":
if (message.ignoreCache === true) {
await ServerBrowserPage.stopUnresponsiveScript(tab.cdp);
await session.send("Page.reload", { ignoreCache: true });
return;
}
tab.control.trackUntilHandoff(tab.page.reload(VIEWER_NAVIGATION_OPTIONS));
tab.control.trackUntilHandoff(
ServerBrowserPage.stopUnresponsiveScript(tab.cdp).then(() =>
tab.page.reload(VIEWER_NAVIGATION_OPTIONS),
),
);
return;
case "probe": {
const x = num(message.x);
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/preview/ServerBrowserPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ const withoutTreeDataPayloads = (tree: string) =>
// The broker waits 15s for the host; a snapshot, retry included, answers well before.
const SNAPSHOT_TIMEOUT_MS = 10_000;
const FRAME_ANSWER_MS = 1_000;
// Far above any frame or ordinary long task, so only a page that is already hung pays it.
const SCRIPT_PROBE_MS = 1_000;

// Include password and CSS-masked text in open shadow roots, normalized like accessible text.
const DOTTED_SCRIPT = `(() => {
Expand Down Expand Up @@ -945,6 +947,25 @@ export const evaluate = async (
return value;
};

/**
* A same-site navigation cannot commit while the page's script never yields. Terminate only after a
* probe goes unanswered: on an idle renderer V8 terminates the next script, possibly the new page's.
*/
export const stopUnresponsiveScript = async (cdp: CDPSession, budgetMs = SCRIPT_PROBE_MS) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<void>((resolve) => {
timer = setTimeout(
() => {
void cdp.send("Runtime.terminateExecution").catch(constVoid);
resolve();
},
Math.min(SCRIPT_PROBE_MS, budgetMs),
);
});
const probe = cdp.send("Runtime.evaluate", { expression: "0" }).then(constVoid, constVoid);
await Promise.race([probe, expired]).finally(() => clearTimeout(timer));
};

export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => {
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
Expand Down
Loading