diff --git a/packages/runtime/src/index.test.ts b/packages/runtime/src/index.test.ts index 0b0c708..65dd0c3 100644 --- a/packages/runtime/src/index.test.ts +++ b/packages/runtime/src/index.test.ts @@ -70,9 +70,20 @@ function createMockBrowserTree() { close: contextClose, }; const browserClose = vi.fn().mockResolvedValue(undefined); + const disconnectedHandlers: Array<() => void> = []; const browser = { newContext: vi.fn(async () => context), close: browserClose, + on: vi.fn((event: string, handler: () => void) => { + if (event === "disconnected") { + disconnectedHandlers.push(handler); + } + }), + emitDisconnected: () => { + for (const handler of disconnectedHandlers) { + handler(); + } + }, }; return { browser, @@ -329,4 +340,40 @@ describe("BrowserRuntime", () => { message: "other failure", }); }); + + it("recovers from a dead browser by relaunching on the next createSession", async () => { + const first = createMockBrowserTree(); + const second = createMockBrowserTree(); + first.browser.newContext.mockRejectedValueOnce( + new Error("browser.newContext: Target page, context or browser has been closed"), + ); + mockChromiumLaunch + .mockResolvedValueOnce(first.browser) + .mockResolvedValueOnce(second.browser); + + const rt = new BrowserRuntime({ headless: true }); + const session = await rt.createSession(); + + expect(session.sessionId).toBeTruthy(); + expect(mockChromiumLaunch).toHaveBeenCalledTimes(2); + await rt.shutdown(); + }); + + it("clears sessions when the browser disconnects", async () => { + const { browser } = createMockBrowserTree(); + mockChromiumLaunch.mockResolvedValueOnce(browser); + + const rt = new BrowserRuntime({ headless: true }); + const { sessionId } = await rt.createSession(); + browser.emitDisconnected(); + + await expect( + rt.navigate({ + action: "navigate", + sessionId, + url: "https://example.com", + }), + ).rejects.toMatchObject({ code: "SESSION_NOT_FOUND" }); + await rt.shutdown(); + }); }); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6e1c945..759199b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -33,12 +33,32 @@ type SessionRecord = { export class BrowserRuntime { private browserPromise: Promise | null = null; private sessions = new Map(); + private browserDisconnectHandler: (() => void) | null = null; constructor(private readonly options: BrowserRuntimeOptions = {}) {} + private invalidateBrowserState(): void { + this.browserPromise = null; + this.browserDisconnectHandler = null; + this.sessions.clear(); + } + + private attachBrowserDisconnectHandler(browser: Browser): void { + if (this.browserDisconnectHandler) { + return; + } + this.browserDisconnectHandler = () => { + this.invalidateBrowserState(); + }; + browser.on("disconnected", this.browserDisconnectHandler); + } + private async getBrowser(): Promise { if (!this.browserPromise) { - this.browserPromise = this.launchBrowser(); + this.browserPromise = this.launchBrowser().then((browser) => { + this.attachBrowserDisconnectHandler(browser); + return browser; + }); } try { @@ -49,6 +69,11 @@ export class BrowserRuntime { } } + private isBrowserClosedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("has been closed"); + } + private async launchBrowser() { try { return await chromium.launch({ @@ -70,8 +95,24 @@ export class BrowserRuntime { } async createSession(): Promise { + return this.createSessionWithRetry(false); + } + + private async createSessionWithRetry( + isRetry: boolean, + ): Promise { const browser = await this.getBrowser(); - const context = await browser.newContext(); + let context: BrowserContext; + try { + context = await browser.newContext(); + } catch (error) { + if (!isRetry && this.isBrowserClosedError(error)) { + this.invalidateBrowserState(); + return this.createSessionWithRetry(true); + } + this.invalidateBrowserState(); + throw mapPlaywrightLaunchError(error); + } try { const page = await context.newPage(); @@ -91,6 +132,11 @@ export class BrowserRuntime { }; } catch (error) { await context.close().catch(() => {}); + if (!isRetry && this.isBrowserClosedError(error)) { + this.invalidateBrowserState(); + return this.createSessionWithRetry(true); + } + this.invalidateBrowserState(); throw mapPlaywrightLaunchError(error); } }