Skip to content
Open
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
108 changes: 88 additions & 20 deletions packages/integrations/eve/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type StagehandBrowser,
} from "@browserbasehq/stagehand";
import {
BrowserbaseSessionReleaseError,
releaseBrowserbaseSession,
StagehandFacadeTools,
stagehandFacadeConfigFromEnv,
} from "@browserbasehq/stagehand-integrations/facade";
Expand All @@ -18,6 +20,11 @@ type FacadeResources = {
browser: StagehandBrowser;
stagehand: Stagehand;
tools: StagehandFacadeTools;
session?: {
apiKey: string;
baseUrl: string | undefined;
id: string;
};
};

let resources: FacadeResources | undefined;
Expand Down Expand Up @@ -46,6 +53,17 @@ export function discardFacadeTools(expected: StagehandFacadeTools): void {
cleanupPromise = cleanupPromise.then(() => closeResources(stale));
}

async function closeRequestedFacadeTools(expected: StagehandFacadeTools): Promise<void> {
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<void> {
if (resources?.tools !== expected) return;

Expand Down Expand Up @@ -95,7 +113,13 @@ async function createResources(): Promise<FacadeResources> {
// 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
Expand All @@ -120,7 +144,11 @@ async function createResources(): Promise<FacadeResources> {
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(
Expand Down Expand Up @@ -154,21 +182,14 @@ async function releaseSession(
apiKey: string,
sessionId: string,
baseUrl: string | undefined,
): Promise<void> {
// 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<boolean> {
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;
}
}

Expand Down Expand Up @@ -216,17 +237,64 @@ function sessionFilePath(): string {
async function attach(
browser: StagehandBrowser,
config: StagehandClientCreateConfig,
session?: FacadeResources["session"],
): Promise<FacadeResources> {
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 } : {}) };
Comment thread
shrey150 marked this conversation as resolved.
} 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<void> {
await stale.stagehand.close().catch(() => undefined);
await stale.browser.close().catch(() => undefined);
async function closeResources(stale: FacadeResources, explicit = false): Promise<void> {
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);
}
139 changes: 139 additions & 0 deletions packages/integrations/eve/tests/session-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@browserbasehq/stagehand-integrations/facade")>()),
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();
Comment thread
shrey150 marked this conversation as resolved.
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 };
}
Loading