diff --git a/packages/runtime/src/index.test.ts b/packages/runtime/src/index.test.ts index 0b0c708..32314de 100644 --- a/packages/runtime/src/index.test.ts +++ b/packages/runtime/src/index.test.ts @@ -56,6 +56,7 @@ function createMockBrowserTree() { content: mockContent, evaluate: mockEvaluate, $$eval: mockEvalAnchors, + on: vi.fn(), locator: vi.fn(() => ({ first: () => ({ click: mockClick, @@ -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(); + } + }); }); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6e1c945..2df764f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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 | null = null; private sessions = new Map(); + 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 { if (!this.browserPromise) { @@ -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, @@ -97,9 +116,11 @@ export class BrowserRuntime { async navigate(command: NavigateCommand): Promise { 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); } @@ -112,13 +133,22 @@ export class BrowserRuntime { async snapshot(command: SnapshotCommand): Promise { 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 (run: () => Promise): Promise => { try { - return await run(); + return await this.awaitPageCommand(session, run(), "snapshot-aux"); } catch { return undefined; } @@ -151,7 +181,7 @@ 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, @@ -159,15 +189,18 @@ export class BrowserRuntime { landmarks, }; } catch (error) { + this.noteCrashFromError(session, error); throw mapCommandFailure(error); } } async click(command: ClickCommand): Promise { 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); } @@ -180,9 +213,11 @@ export class BrowserRuntime { async type(command: TypeCommand): Promise { 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); } @@ -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( + session: SessionRecord, + operation: Promise, + label: string, + ): Promise { + if (session.pageCrashed) { + return Promise.reject( + new WebchainRuntimeError( + "COMMAND_FAILED", + "Page crashed; close this session and create a new one.", + ), + ); + } + + let timer: ReturnType | undefined; + const timeout = new Promise((_, 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 { diff --git a/services/companion/src/local-browser-loop.integration.test.ts b/services/companion/src/local-browser-loop.integration.test.ts index 75905b0..1a9fa7b 100644 --- a/services/companion/src/local-browser-loop.integration.test.ts +++ b/services/companion/src/local-browser-loop.integration.test.ts @@ -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(); + } + }); });