Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/runtime/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function createMockBrowserTree() {
content: mockContent,
evaluate: mockEvaluate,
$$eval: mockEvalAnchors,
on: vi.fn(),
locator: vi.fn(() => ({
first: () => ({
click: mockClick,
Expand Down Expand Up @@ -329,4 +330,63 @@ describe("BrowserRuntime", () => {
message: "other failure",
});
});

it("times out wedged page.content during snapshot instead of hanging", async () => {
const { browser, page } = createMockBrowserTree();
page.content = vi.fn(() => new Promise(() => {}));
mockChromiumLaunch.mockResolvedValueOnce(browser);

const rt = new BrowserRuntime({
headless: true,
pageCommandTimeoutMs: 50,
});
try {
const { sessionId } = await rt.createSession();
await expect(
rt.snapshot({ action: "snapshot", sessionId }),
).rejects.toMatchObject({
code: "COMMAND_FAILED",
message: expect.stringContaining("timed out"),
});
// Subsequent commands fail fast once the session is marked crashed.
await expect(
rt.snapshot({ action: "snapshot", sessionId }),
).rejects.toMatchObject({
code: "COMMAND_FAILED",
message: expect.stringContaining("Page crashed"),
});
} finally {
await rt.shutdown();
}
});

it("marks the session crashed when Playwright emits page crash", async () => {
const { browser, page } = createMockBrowserTree();
const crashHandlers: Array<() => void> = [];
page.on = vi.fn((event: string, handler: () => void) => {
if (event === "crash") {
crashHandlers.push(handler);
}
});
mockChromiumLaunch.mockResolvedValueOnce(browser);

const rt = new BrowserRuntime({ headless: true });
try {
const { sessionId } = await rt.createSession();
expect(crashHandlers.length).toBe(1);
crashHandlers[0]?.();
await expect(
rt.navigate({
action: "navigate",
sessionId,
url: "https://example.com",
}),
).rejects.toMatchObject({
code: "COMMAND_FAILED",
message: expect.stringContaining("Page crashed"),
});
} finally {
await rt.shutdown();
}
});
});
99 changes: 94 additions & 5 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,33 @@ import { extractLandmarks, extractPageLinks } from "./snapshot-helpers.js";

export interface BrowserRuntimeOptions {
headless?: boolean;
/**
* Upper bound for CDP calls that can wedge after a renderer crash
* (Playwright's own timeouts do not always fire when the page process is dead).
*/
pageCommandTimeoutMs?: number;
}

type SessionRecord = {
context: BrowserContext;
page: Page;
pageId: string;
/** Set when Playwright emits `page` crash or a wedged CDP call times out. */
pageCrashed: boolean;
};

/** Default budget for snapshot CDP reads after which we fail instead of hanging. */
export const DEFAULT_PAGE_COMMAND_TIMEOUT_MS = 15_000;

export class BrowserRuntime {
private browserPromise: Promise<Browser> | null = null;
private sessions = new Map<string, SessionRecord>();
private readonly pageCommandTimeoutMs: number;

constructor(private readonly options: BrowserRuntimeOptions = {}) {}
constructor(private readonly options: BrowserRuntimeOptions = {}) {
this.pageCommandTimeoutMs =
options.pageCommandTimeoutMs ?? DEFAULT_PAGE_COMMAND_TIMEOUT_MS;
}

private async getBrowser(): Promise<Browser> {
if (!this.browserPromise) {
Expand Down Expand Up @@ -78,11 +92,16 @@ export class BrowserRuntime {
const sessionId = randomUUID();
const pageId = randomUUID();

this.sessions.set(sessionId, {
const record: SessionRecord = {
context,
page,
pageId,
pageCrashed: false,
};
page.on("crash", () => {
record.pageCrashed = true;
});
this.sessions.set(sessionId, record);

return {
sessionId,
Expand All @@ -97,9 +116,11 @@ export class BrowserRuntime {

async navigate(command: NavigateCommand): Promise<ActionResult> {
const session = this.getSession(command.sessionId);
this.assertPageUsable(session);
try {
await session.page.goto(command.url, { waitUntil: "domcontentloaded" });
} catch (error) {
this.noteCrashFromError(session, error);
throw mapCommandFailure(error);
}

Expand All @@ -112,13 +133,22 @@ export class BrowserRuntime {

async snapshot(command: SnapshotCommand): Promise<SnapshotResult> {
const session = this.getSession(command.sessionId);
this.assertPageUsable(session);
try {
const page = session.page;
const html = await page.content();
/**
* After a renderer crash, Playwright's `page.content({ timeout })` may never
* settle (CDP wedged). Race with a host timer so companion/MCP callers fail.
*/
const html = await this.awaitPageCommand(
session,
page.content(),
"page.content",
);
/** Covers sync throws (some Playwright APIs throw before returning a Promise). */
const safe = async <T>(run: () => Promise<T>): Promise<T | undefined> => {
try {
return await run();
return await this.awaitPageCommand(session, run(), "snapshot-aux");
} catch {
return undefined;
}
Expand Down Expand Up @@ -151,23 +181,26 @@ export class BrowserRuntime {
return {
sessionId: command.sessionId,
url: page.url(),
title: await page.title(),
title: await this.awaitPageCommand(session, page.title(), "page.title"),
htmlSnippet: summarizeHtml(html),
domSummary,
accessibilityTree: accessibilityJson,
links,
landmarks,
};
} catch (error) {
this.noteCrashFromError(session, error);
throw mapCommandFailure(error);
}
}

async click(command: ClickCommand): Promise<ActionResult> {
const session = this.getSession(command.sessionId);
this.assertPageUsable(session);
try {
await session.page.locator(command.selector).first().click();
} catch (error) {
this.noteCrashFromError(session, error);
throw mapCommandFailure(error);
}

Expand All @@ -180,9 +213,11 @@ export class BrowserRuntime {

async type(command: TypeCommand): Promise<ActionResult> {
const session = this.getSession(command.sessionId);
this.assertPageUsable(session);
try {
await session.page.locator(command.selector).first().fill(command.text);
} catch (error) {
this.noteCrashFromError(session, error);
throw mapCommandFailure(error);
}

Expand Down Expand Up @@ -237,6 +272,60 @@ export class BrowserRuntime {

return session;
}

private assertPageUsable(session: SessionRecord): void {
if (session.pageCrashed) {
throw new WebchainRuntimeError(
"COMMAND_FAILED",
"Page crashed; close this session and create a new one.",
);
}
}

private noteCrashFromError(session: SessionRecord, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
if (
message.includes("Page crashed") ||
message.includes("Target crashed") ||
message.includes("page command timed out")
) {
session.pageCrashed = true;
}
}

private awaitPageCommand<T>(
session: SessionRecord,
operation: Promise<T>,
label: string,
): Promise<T> {
if (session.pageCrashed) {
return Promise.reject(
new WebchainRuntimeError(
"COMMAND_FAILED",
"Page crashed; close this session and create a new one.",
),
);
}

let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
session.pageCrashed = true;
reject(
new WebchainRuntimeError(
"COMMAND_FAILED",
`Page command timed out after ${this.pageCommandTimeoutMs}ms (${label}); the page may have crashed.`,
),
);
}, this.pageCommandTimeoutMs);
});

return Promise.race([operation, timeout]).finally(() => {
if (timer !== undefined) {
clearTimeout(timer);
}
});
}
}

export {
Expand Down
61 changes: 61 additions & 0 deletions services/companion/src/local-browser-loop.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,65 @@ describe("local browser loop (integration)", () => {
expect(errBody.code).toBe("SESSION_NOT_FOUND");
expect(errBody.trace.traceId.length).toBeGreaterThan(0);
});

it("fails snapshot quickly after renderer crash instead of hanging", async () => {
const crashRuntime = new BrowserRuntime({
headless: true,
pageCommandTimeoutMs: 3_000,
});
const { app: crashApp } = await createCompanionApp({
runtime: crashRuntime,
localToken: token,
logger: false,
});
try {
const sessionRes = await crashApp.inject({
method: "POST",
url: "/sessions",
headers: { "x-webchain-token": token },
});
expect(sessionRes.statusCode).toBe(200);
const { sessionId } = JSON.parse(sessionRes.body) as {
sessionId: string;
};

const nav = await crashApp.inject({
method: "POST",
url: "/commands",
headers: { "x-webchain-token": token },
payload: {
action: "navigate",
sessionId,
url: "chrome://crash",
},
});
expect(nav.statusCode).toBe(502);

const started = Date.now();
const snap = await crashApp.inject({
method: "POST",
url: "/commands",
headers: { "x-webchain-token": token },
payload: { action: "snapshot", sessionId },
});
const elapsed = Date.now() - started;
expect(snap.statusCode).toBe(502);
const body = JSON.parse(snap.body) as { code?: string; error?: string };
expect(body.code).toBe("COMMAND_FAILED");
expect(body.error ?? "").toMatch(/timed out|crashed/i);
// Must not hang for minutes; host timer is 3s plus small overhead.
expect(elapsed).toBeLessThan(8_000);

const close = await crashApp.inject({
method: "POST",
url: "/commands",
headers: { "x-webchain-token": token },
payload: { action: "closeSession", sessionId },
});
expect(close.statusCode).toBe(200);
} finally {
await crashRuntime.shutdown();
await crashApp.close();
}
});
});
Loading