Skip to content

Commit b65756e

Browse files
[feat]: add keepAlive param (browserbase#1672)
# why - to give users the freedom to decide whether the browser should be kept open or "alive" when their stagehand instance is killed - to follow best practices by avoiding usage of global signal handlers inside library code # what changed new `keepAlive` option: - added an optional, boolean `keepAlive` parameter in the stagehand constructor params. `keepAlive` defaults to `false` - `keepAlive` on the constructor overrides `browserbaseSessionCreateParams.keepAlive` when both are provided out-of-process shutdown supervisor (`packages/core/lib/v3/shutdown/`): - replaced the old in-process global signal handlers (process.once("SIGINT"), etc.) with a detached supervisor process that watches a lifeline (stdin pipe + IPC channel) to the parent - when `keepAlive` is set to `false`, the supervisor is spawned during `init()` and performs best-effort cleanup if the parent dies unexpectedly: - if `env` is `"LOCAL"` the supervisor kills the Chrome PID (with SIGTERM → SIGKILL escalation) and removes any temp user-data directory - if `env` is `"BROWSERBASE"` & using the stagehand API, the supervisor sends `REQUEST_RELEASE` to the browserbase API - the supervisor also includes PID polling to guard against process reuse (killing a recycled PID - when `keepAlive` is set to `true`, no supervisor is spawned, & the browser is left running - when `disableAPI` is set to `true` (direct WS to Browserbase), no supervisor is spawned since the browser will auto release when the WS is dropped refactored `close()` into helpers: - pulled browser teardown logic into `shutdown/shutdownBrowser.ts` (`shutdownBrowserSession`, `shutdownLocalBrowser`) - pulled stagehand internal state cleanup into `shutdown/shutdownStagehand.ts` (`shutdownStagehandResources`, `finalizeStagehandShutdown`) -`.close()` now skips `apiClient.end()` when `keepAlive: true` local browser launch changes: - updated local launch to pass chrome launcher’s `handleSIGINT` based on `keepAlive` in `packages/core/lib/v3/v3.ts` and `packages/core/lib/v3/launch/local.ts`. this prevents chrome from getting killed on Ctrl+C when `keepAlive: true` - updated local launch so that we `unref()` the chrome child process after launch so Node can exit while chrome stays open (`packages/core/lib/v3/v3.ts`) ### behaviour notes: - setting `keepAlive: true` will keep the browser alive in the following scenarios: - stagehand instance gets killed because of an uncaught error (eg, `unhandledRejection`, `unhandledException`) - stagehand instance gets killed because of a `SIGTERM` or `SIGINT` - stagehand instance gets killed with an explicit call to `stagehand.close()` # test plan - added tests in `keep-alive.spec.ts` that validate expected behaviour across the following scenarios: - `unhandledRejection`/`unhandledException`, - `SIGTERM`/`SIGINT`, - `stagehand.close()` - each scenario is validated in each of the following browser configurations: - using a local browser launched by stagehand - using a browserbase browser, connected directly over the WS - using a browserbase browser + the stagehand API <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a keepAlive option to Stagehand V3 so you control whether the browser/session stays up after stagehand.close(), signals, or uncaught errors. Replaces global process handlers with a per‑instance crash‑only supervisor with a ready handshake and PID polling to avoid race conditions (STG‑1338). - **New Features** - keepAlive?: boolean (default false) on the V3 constructor; overrides browserbaseSessionCreateParams.keepAlive when set. - Local: set handleSIGINT based on keepAlive and unref Chrome when true; when false, a detached supervisor watches a stdin/IPC lifeline and on parent crash/error SIGTERM→SIGKILLs Chrome and removes the temp profile (with PID polling). A shared cleanup helper is used by both close() and the supervisor for consistent local teardown. - Browserbase: propagate keepAlive to session creation; when keepAlive is true, close() skips apiClient.end() so the session stays RUNNING; when false (and API enabled), the supervisor requests REQUEST_RELEASE on crash/error; when disableAPI is true (direct WS), no supervisor is spawned and the session ends when the WS drops. - **Migration** - Set keepAlive: true in the V3 constructor to keep the browser/session open on errors, signals, or stagehand.close(); this overrides any value in browserbaseSessionCreateParams. - When keepAlive is true, close resources yourself when done (Local: send Browser.close; Browserbase: request release/end the session). <sup>Written for commit 3887d8f. Summary will update on new commits. <a href="https://cubic.dev/pr/browserbase/stagehand/pull/1672">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. -->
1 parent f727578 commit b65756e

13 files changed

Lines changed: 1245 additions & 95 deletions

File tree

.changeset/hungry-areas-remain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": minor
3+
---
4+
5+
add boolean keepAlive parameter to allow for configuring whether the browser should be closed when stagehand.close() is called.

packages/core/lib/v3/launch/local.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface LaunchLocalOptions {
88
userDataDir?: string;
99
port?: number;
1010
connectTimeoutMs?: number;
11+
handleSIGINT?: boolean;
1112
}
1213

1314
export async function launchLocalChrome(
@@ -29,6 +30,7 @@ export async function launchLocalChrome(
2930
chromeFlags,
3031
port: opts.port,
3132
userDataDir: opts.userDataDir,
33+
handleSIGINT: opts.handleSIGINT,
3234
});
3335

3436
const ws = await waitForWebSocketDebuggerUrl(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import fs from "node:fs";
2+
3+
/**
4+
* Shared cleanup logic for locally launched Chrome.
5+
*
6+
* Used by both `V3.close()` (normal shutdown) and the supervisor process
7+
* (crash cleanup). The caller provides a `killChrome` callback since the
8+
* kill mechanism differs: chrome-launcher's `chrome.kill()` in-process
9+
* vs raw `process.kill(pid)` from the supervisor.
10+
*/
11+
export async function cleanupLocalBrowser(opts: {
12+
killChrome?: () => Promise<void> | void;
13+
userDataDir?: string;
14+
createdTempProfile?: boolean;
15+
preserveUserDataDir?: boolean;
16+
}): Promise<void> {
17+
if (opts.killChrome) {
18+
try {
19+
await opts.killChrome();
20+
} catch {
21+
// best-effort
22+
}
23+
}
24+
if (
25+
opts.createdTempProfile &&
26+
!opts.preserveUserDataDir &&
27+
opts.userDataDir
28+
) {
29+
try {
30+
fs.rmSync(opts.userDataDir, { recursive: true, force: true });
31+
} catch {
32+
// ignore cleanup errors
33+
}
34+
}
35+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Shutdown supervisor process.
3+
*
4+
* This process watches a lifeline (stdin/IPC). When the parent dies, the
5+
* lifeline closes and the supervisor performs best-effort cleanup:
6+
* - LOCAL: kill Chrome + remove temp profile (when keepAlive is false)
7+
* - STAGEHAND_API: request session release (when keepAlive is false)
8+
*/
9+
10+
import Browserbase from "@browserbasehq/sdk";
11+
import type {
12+
ShutdownSupervisorConfig,
13+
ShutdownSupervisorMessage,
14+
} from "../types/private/shutdown";
15+
import { cleanupLocalBrowser } from "./cleanupLocal";
16+
17+
const SIGKILL_POLL_MS = 500;
18+
const SIGKILL_TIMEOUT_MS = 10_000;
19+
const PID_POLL_INTERVAL_MS = 500;
20+
21+
let armed = false;
22+
let config: ShutdownSupervisorConfig | null = null;
23+
let cleanupPromise: Promise<void> | null = null;
24+
25+
const exit = (code = 0): void => {
26+
try {
27+
process.exit(code);
28+
} catch {
29+
// ignore
30+
}
31+
};
32+
33+
const safeKill = async (pid: number): Promise<void> => {
34+
const isAlive = (): boolean => {
35+
try {
36+
process.kill(pid, 0);
37+
return true;
38+
} catch {
39+
return false;
40+
}
41+
};
42+
43+
if (!isAlive()) return;
44+
try {
45+
process.kill(pid, "SIGTERM");
46+
} catch {
47+
return;
48+
}
49+
50+
const deadline = Date.now() + SIGKILL_TIMEOUT_MS;
51+
while (Date.now() < deadline) {
52+
await new Promise((resolve) => setTimeout(resolve, SIGKILL_POLL_MS));
53+
if (!isAlive()) return;
54+
}
55+
try {
56+
process.kill(pid, "SIGKILL");
57+
} catch {
58+
// best-effort
59+
}
60+
};
61+
62+
let pidGone = false;
63+
let pidPollTimer: NodeJS.Timeout | null = null;
64+
65+
const startPidPolling = (pid: number): void => {
66+
if (pidPollTimer) return;
67+
pidPollTimer = setInterval(() => {
68+
try {
69+
process.kill(pid, 0);
70+
} catch {
71+
pidGone = true;
72+
if (pidPollTimer) {
73+
clearInterval(pidPollTimer);
74+
pidPollTimer = null;
75+
}
76+
}
77+
}, PID_POLL_INTERVAL_MS);
78+
};
79+
80+
const cleanupLocal = async (
81+
cfg: Extract<ShutdownSupervisorConfig, { kind: "LOCAL" }>,
82+
) => {
83+
if (cfg.keepAlive) return;
84+
await cleanupLocalBrowser({
85+
killChrome: cfg.pid && !pidGone ? () => safeKill(cfg.pid) : undefined,
86+
userDataDir: cfg.userDataDir,
87+
createdTempProfile: cfg.createdTempProfile,
88+
preserveUserDataDir: cfg.preserveUserDataDir,
89+
});
90+
};
91+
92+
const cleanupBrowserbase = async (
93+
cfg: Extract<ShutdownSupervisorConfig, { kind: "STAGEHAND_API" }>,
94+
) => {
95+
if (cfg.keepAlive) return;
96+
if (!cfg.apiKey || !cfg.projectId || !cfg.sessionId) return;
97+
try {
98+
const bb = new Browserbase({ apiKey: cfg.apiKey });
99+
await bb.sessions.update(cfg.sessionId, {
100+
status: "REQUEST_RELEASE",
101+
projectId: cfg.projectId,
102+
});
103+
} catch {
104+
// best-effort cleanup
105+
}
106+
};
107+
108+
const runCleanup = (): Promise<void> => {
109+
if (!cleanupPromise) {
110+
cleanupPromise = (async () => {
111+
const cfg = config;
112+
if (!cfg || !armed) return;
113+
armed = false;
114+
if (cfg.kind === "LOCAL") {
115+
await cleanupLocal(cfg);
116+
return;
117+
}
118+
if (cfg.kind === "STAGEHAND_API") {
119+
await cleanupBrowserbase(cfg);
120+
}
121+
})();
122+
}
123+
return cleanupPromise;
124+
};
125+
126+
const onLifelineClosed = () => {
127+
void runCleanup().finally(() => exit(0));
128+
};
129+
130+
const onMessage = (raw: unknown) => {
131+
if (!raw || typeof raw !== "object") return;
132+
const msg = raw as ShutdownSupervisorMessage;
133+
if (msg.type === "config") {
134+
config = msg.config ?? null;
135+
armed = Boolean(config) && config?.keepAlive === false;
136+
if (armed && config?.kind === "LOCAL" && config?.pid) {
137+
startPidPolling(config.pid);
138+
}
139+
try {
140+
process.send?.({ type: "ready" });
141+
} catch {
142+
// ignore IPC failures
143+
}
144+
return;
145+
}
146+
if (msg.type === "exit") {
147+
armed = false;
148+
exit(0);
149+
}
150+
};
151+
152+
// Keep stdin open as a lifeline to the parent process.
153+
try {
154+
process.stdin.resume();
155+
process.stdin.on("end", onLifelineClosed);
156+
process.stdin.on("close", onLifelineClosed);
157+
process.stdin.on("error", onLifelineClosed);
158+
} catch {
159+
// ignore
160+
}
161+
162+
process.on("disconnect", onLifelineClosed);
163+
process.on("message", onMessage);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Parent-side helper for spawning the shutdown supervisor process.
3+
*
4+
* The supervisor runs out-of-process and watches a lifeline pipe. If the parent
5+
* dies, the supervisor performs best-effort cleanup (Chrome kill or Browserbase
6+
* session release) when keepAlive is false.
7+
*/
8+
9+
import fs from "node:fs";
10+
import path from "node:path";
11+
import { spawn } from "node:child_process";
12+
import type {
13+
ShutdownSupervisorConfig,
14+
ShutdownSupervisorHandle,
15+
ShutdownSupervisorMessage,
16+
} from "../types/private/shutdown";
17+
import {
18+
ShutdownSupervisorResolveError,
19+
ShutdownSupervisorSpawnError,
20+
} from "../types/private/shutdownErrors";
21+
22+
const READY_TIMEOUT_MS = 500;
23+
24+
const resolveSupervisorScript = (): {
25+
command: string;
26+
args: string[];
27+
} | null => {
28+
const jsPath = path.resolve(__dirname, "supervisor.js");
29+
if (fs.existsSync(jsPath)) {
30+
return { command: process.execPath, args: [jsPath] };
31+
}
32+
const tsPath = path.resolve(__dirname, "supervisor.ts");
33+
if (fs.existsSync(tsPath)) {
34+
return { command: process.execPath, args: ["--import", "tsx", tsPath] };
35+
}
36+
return null;
37+
};
38+
39+
/**
40+
* Start a supervisor process for crash cleanup. Returns a handle that can
41+
* stop the supervisor during a normal shutdown.
42+
*/
43+
export function startShutdownSupervisor(
44+
config: ShutdownSupervisorConfig,
45+
opts?: { onError?: (error: Error, context: string) => void },
46+
): ShutdownSupervisorHandle | null {
47+
const resolved = resolveSupervisorScript();
48+
if (!resolved) {
49+
opts?.onError?.(
50+
new ShutdownSupervisorResolveError(
51+
"Shutdown supervisor script missing (expected supervisor.js or supervisor.ts next to shutdown/supervisorClient).",
52+
),
53+
"resolve",
54+
);
55+
return null;
56+
}
57+
58+
const child = spawn(resolved.command, resolved.args, {
59+
stdio: ["pipe", "ignore", "ignore", "ipc"],
60+
detached: true,
61+
});
62+
child.on("error", (error) => {
63+
opts?.onError?.(
64+
new ShutdownSupervisorSpawnError(
65+
`Shutdown supervisor failed to start: ${error.message}`,
66+
),
67+
"spawn",
68+
);
69+
});
70+
71+
try {
72+
child.unref();
73+
const stdin = child.stdin as unknown as { unref?: () => void } | null;
74+
stdin?.unref?.();
75+
} catch {
76+
// best-effort: avoid keeping the event loop alive
77+
}
78+
79+
try {
80+
const message: ShutdownSupervisorMessage = { type: "config", config };
81+
child.send?.(message);
82+
} catch {
83+
// ignore IPC failures
84+
}
85+
86+
const ready = new Promise<void>((resolve) => {
87+
let resolved = false;
88+
const done = () => {
89+
if (resolved) return;
90+
resolved = true;
91+
clearTimeout(timer);
92+
child.off("message", onMessage);
93+
resolve();
94+
};
95+
const timer = setTimeout(done, READY_TIMEOUT_MS);
96+
const onMessage = (msg: unknown) => {
97+
const payload = msg as ShutdownSupervisorMessage;
98+
if (payload?.type === "ready") {
99+
done();
100+
}
101+
};
102+
child.on("message", onMessage);
103+
child.on("exit", done);
104+
});
105+
106+
const stop = () => {
107+
try {
108+
const message: ShutdownSupervisorMessage = { type: "exit" };
109+
child.send?.(message);
110+
} catch {
111+
// ignore
112+
}
113+
try {
114+
child.disconnect?.();
115+
} catch {
116+
// ignore
117+
}
118+
};
119+
120+
return { stop, ready };
121+
}

0 commit comments

Comments
 (0)