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
47 changes: 47 additions & 0 deletions packages/runtime/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
50 changes: 48 additions & 2 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,32 @@ type SessionRecord = {
export class BrowserRuntime {
private browserPromise: Promise<Browser> | null = null;
private sessions = new Map<string, SessionRecord>();
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<Browser> {
if (!this.browserPromise) {
this.browserPromise = this.launchBrowser();
this.browserPromise = this.launchBrowser().then((browser) => {
this.attachBrowserDisconnectHandler(browser);
return browser;
});
}

try {
Expand All @@ -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({
Expand All @@ -70,8 +95,24 @@ export class BrowserRuntime {
}

async createSession(): Promise<RuntimeSession> {
return this.createSessionWithRetry(false);
}

private async createSessionWithRetry(
isRetry: boolean,
): Promise<RuntimeSession> {
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();
Expand All @@ -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);
}
}
Expand Down
Loading