diff --git a/packages/integrations/eve/src/session.ts b/packages/integrations/eve/src/session.ts index 142b3ada05..674dca69b9 100644 --- a/packages/integrations/eve/src/session.ts +++ b/packages/integrations/eve/src/session.ts @@ -10,6 +10,8 @@ import { type StagehandBrowser, } from "@browserbasehq/stagehand"; import { + BrowserbaseSessionReleaseError, + releaseBrowserbaseSession, StagehandFacadeTools, stagehandFacadeConfigFromEnv, } from "@browserbasehq/stagehand-integrations/facade"; @@ -18,6 +20,11 @@ type FacadeResources = { browser: StagehandBrowser; stagehand: Stagehand; tools: StagehandFacadeTools; + session?: { + apiKey: string; + baseUrl: string | undefined; + id: string; + }; }; let resources: FacadeResources | undefined; @@ -46,6 +53,17 @@ export function discardFacadeTools(expected: StagehandFacadeTools): void { cleanupPromise = cleanupPromise.then(() => closeResources(stale)); } +async function closeRequestedFacadeTools(expected: StagehandFacadeTools): Promise { + if (resources?.tools !== expected) return; + + const stale = resources; + resources = undefined; + resourcesPromise = undefined; + const closeResult = cleanupPromise.then(() => closeResources(stale, true)); + cleanupPromise = closeResult.catch(() => undefined); + await closeResult; +} + export async function discardFacadeToolsIfUnhealthy(expected: StagehandFacadeTools): Promise { if (resources?.tools !== expected) return; @@ -95,7 +113,13 @@ async function createResources(): Promise { // broken, so reattaching would loop forever (the "no brick" invariant). if (browserbaseSessionId !== suspectSessionId) { const browser = await connectToSession(apiKey, browserbaseSessionId, baseUrl); - if (browser) return attach(browser, config.stagehand); + if (browser) { + return attach(browser, config.stagehand, { + apiKey, + baseUrl, + id: browserbaseSessionId, + }); + } } // Recovery is bounded: release the old keep-alive session best-effort so @@ -120,7 +144,11 @@ async function createResources(): Promise { browserbaseSessionId = browser.sessionId; persistSessionId(browser.sessionId); } - return attach(browser, config.stagehand); + return attach( + browser, + config.stagehand, + browser.sessionId ? { apiKey, baseUrl, id: browser.sessionId } : undefined, + ); } async function connectToSession( @@ -154,21 +182,14 @@ async function releaseSession( apiKey: string, sessionId: string, baseUrl: string | undefined, -): Promise { - // Best-effort raw REST release so the keep-alive session doesn't keep - // billing after we abandon it. Failures are swallowed: the session may - // already be gone, and release must never block the fresh launch. +): Promise { try { - await fetch(`${baseUrl ?? "https://api.browserbase.com"}/v1/sessions/${sessionId}`, { - method: "POST", - headers: { - "x-bb-api-key": apiKey, - "Content-Type": "application/json", - }, - body: JSON.stringify({ status: "REQUEST_RELEASE" }), - }); + await releaseBrowserbaseSession({ apiKey, baseUrl, sessionId }); + return true; } catch { - // Swallow — best-effort only. + // Recovery paths are best-effort; explicit close turns false into a stable + // lifecycle error so the model cannot falsely report successful cleanup. + return false; } } @@ -216,17 +237,64 @@ function sessionFilePath(): string { async function attach( browser: StagehandBrowser, config: StagehandClientCreateConfig, + session?: FacadeResources["session"], ): Promise { + const release = session + ? () => releaseSession(session.apiKey, session.id, session.baseUrl) + : undefined; try { const stagehand = await Stagehand.create({ browser, ...config }); - return { browser, stagehand, tools: new StagehandFacadeTools(stagehand) }; + let tools: StagehandFacadeTools; + tools = new StagehandFacadeTools(stagehand, { + close: () => closeRequestedFacadeTools(tools), + }); + return { browser, stagehand, tools, ...(session ? { session } : {}) }; } catch (error) { - await browser.close().catch(() => undefined); + let browserCloseFailed = false; + await browser.close().catch(() => { + browserCloseFailed = true; + }); + if (browserCloseFailed && release && session) { + const released = await release(); + if (released) { + clearOwnedSessionId(session.id); + } else { + // Keep the persisted ID as the recovery target. The next resource + // creation skips reconnecting to it and retries release first. + suspectSessionId = session.id; + } + } throw error; } } -async function closeResources(stale: FacadeResources): Promise { - await stale.stagehand.close().catch(() => undefined); - await stale.browser.close().catch(() => undefined); +async function closeResources(stale: FacadeResources, explicit = false): Promise { + const cleanupErrors: unknown[] = []; + await stale.stagehand.close().catch((error) => cleanupErrors.push(error)); + await stale.browser.close().catch((error) => cleanupErrors.push(error)); + + if (explicit && stale.session) { + const released = await releaseSession( + stale.session.apiKey, + stale.session.id, + stale.session.baseUrl, + ); + if (!released) { + suspectSessionId = stale.session.id; + cleanupErrors.push(new BrowserbaseSessionReleaseError()); + } else { + clearOwnedSessionId(stale.session.id); + } + } + + if (!explicit || cleanupErrors.length === 0) return; + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + throw new AggregateError(cleanupErrors, "Failed to close the browser session cleanly."); +} + +function clearOwnedSessionId(sessionId: string): void { + if (browserbaseSessionId !== sessionId) return; + browserbaseSessionId = undefined; + suspectSessionId = undefined; + persistSessionId(undefined); } diff --git a/packages/integrations/eve/tests/session-lifecycle.test.ts b/packages/integrations/eve/tests/session-lifecycle.test.ts new file mode 100644 index 0000000000..77043f2b57 --- /dev/null +++ b/packages/integrations/eve/tests/session-lifecycle.test.ts @@ -0,0 +1,139 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + launch: vi.fn(), + connect: vi.fn(), + create: vi.fn(), + release: vi.fn(), +})); + +vi.mock("@browserbasehq/stagehand", () => ({ + browserbase: { launch: mocks.launch, connect: mocks.connect }, + localBrowser: { launch: vi.fn() }, + Stagehand: { create: mocks.create }, +})); + +vi.mock("@browserbasehq/stagehand-integrations/facade", async (importOriginal) => ({ + ...(await importOriginal()), + releaseBrowserbaseSession: mocks.release, + stagehandFacadeConfigFromEnv: () => ({ + browser: { type: "browserbase", launchOptions: { apiKey: "test-api-key" } }, + stagehand: {}, + }), +})); + +describe("Eve facade session lifecycle", () => { + let temporaryDirectory: string; + + beforeEach(() => { + vi.resetModules(); + mocks.launch.mockReset(); + mocks.connect.mockReset(); + mocks.create.mockReset(); + mocks.release.mockReset(); + mocks.release.mockResolvedValue(undefined); + temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "stagehand-eve-session-test-")); + process.env.STAGEHAND_EVE_SESSION_FILE = path.join(temporaryDirectory, "session.json"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.STAGEHAND_EVE_SESSION_FILE; + rmSync(temporaryDirectory, { force: true, recursive: true }); + }); + + it("releases an explicit browser close and creates fresh resources on the next call", async () => { + const first = createResources("session-one"); + const second = createResources("session-two"); + mocks.launch.mockResolvedValueOnce(first.browser).mockResolvedValueOnce(second.browser); + mocks.create.mockResolvedValueOnce(first.stagehand).mockResolvedValueOnce(second.stagehand); + const { getFacadeTools } = await import("../src/session.js"); + + const firstTools = await getFacadeTools(); + await expect(firstTools.run("await browser.close();")).resolves.toBe("closed"); + + expect(first.stagehand.close).toHaveBeenCalledOnce(); + expect(first.browser.close).toHaveBeenCalledOnce(); + expect(mocks.release).toHaveBeenCalledWith({ + apiKey: "test-api-key", + baseUrl: undefined, + sessionId: "session-one", + }); + expect(existsSync(process.env.STAGEHAND_EVE_SESSION_FILE!)).toBe(false); + + const secondTools = await getFacadeTools(); + expect(secondTools).not.toBe(firstTools); + expect(mocks.launch).toHaveBeenCalledTimes(2); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(existsSync(process.env.STAGEHAND_EVE_SESSION_FILE!)).toBe(true); + }); + + it("surfaces a failed release instead of reporting close success", async () => { + const resources = createResources("session-failed-release"); + mocks.launch.mockResolvedValueOnce(resources.browser); + mocks.create.mockResolvedValueOnce(resources.stagehand); + mocks.release.mockRejectedValueOnce(new Error("release failed")); + const { getFacadeTools } = await import("../src/session.js"); + const { BrowserbaseSessionReleaseError } = + await import("@browserbasehq/stagehand-integrations/facade"); + + const tools = await getFacadeTools(); + await expect(tools.run("await browser.close();")).rejects.toBeInstanceOf( + BrowserbaseSessionReleaseError, + ); + }); + + it("surfaces a browser close failure even when Browserbase release succeeds", async () => { + const resources = createResources("session-browser-close-failed"); + resources.browser.close.mockRejectedValueOnce(new Error("browser close failed")); + mocks.launch.mockResolvedValueOnce(resources.browser); + mocks.create.mockResolvedValueOnce(resources.stagehand); + const { getFacadeTools } = await import("../src/session.js"); + + const tools = await getFacadeTools(); + await expect(tools.run("await browser.close();")).rejects.toThrow("browser close failed"); + expect(mocks.release).toHaveBeenCalledOnce(); + }); + + it("releases a persisted session when Stagehand initialization cleanup fails", async () => { + const resources = createResources("session-init-failed"); + resources.browser.close.mockRejectedValueOnce(new Error("browser close failed")); + mocks.launch.mockResolvedValueOnce(resources.browser); + mocks.create.mockRejectedValueOnce(new Error("Stagehand init failed")); + const { getFacadeTools } = await import("../src/session.js"); + + await expect(getFacadeTools()).rejects.toThrow("Stagehand init failed"); + expect(resources.browser.close).toHaveBeenCalledOnce(); + expect(mocks.release).toHaveBeenCalledWith({ + apiKey: "test-api-key", + baseUrl: undefined, + sessionId: "session-init-failed", + }); + expect(existsSync(process.env.STAGEHAND_EVE_SESSION_FILE!)).toBe(false); + }); +}); + +function createResources(sessionId: string) { + const page = { pageId: `page-${sessionId}` }; + const browser = { + closed: false, + sessionId, + context: { pages: vi.fn(async () => [page]) }, + close: vi.fn(async function (this: { closed: boolean }) { + this.closed = true; + }), + }; + const stagehand = { + browser: { context: { activePage: vi.fn(async () => page) } }, + close: vi.fn(async () => undefined), + experimentalBatch: vi.fn(async () => ({ + __stagehandPlaywrightCompat: true, + value: "closed", + closeRequested: true, + })), + }; + return { browser, stagehand }; +}