fix: headed browser no longer auto-shuts down after 15 seconds

The parent-process watchdog in server.ts polls the spawning CLI's PID
every 15s and self-terminates if it is gone. The connect command in
cli.ts exits with process.exit(0) immediately after launching the server,
so the watchdog would reliably kill the headed browser within ~15s.

This contradicted the idle timer's own design: server.ts:745 explicitly
skips headed mode because "the user is looking at the browser. Never
auto-die." The watchdog had no such exemption.

Two-layer fix:
1. CLI layer: connect handler always sets BROWSE_PARENT_PID=0 (was only
   pass-through for pair-agent subprocesses). The user owns the headed
   browser lifecycle; cleanup happens via browser disconnect event or
   $B disconnect.
2. CLI layer: startServer() honors caller's BROWSE_PARENT_PID=0 in the
   headless spawn path too. Lets CI, non-interactive shells, and Claude
   Code Bash calls opt into persistent servers across short-lived CLI
   invocations.
3. Server layer: defense-in-depth. Watchdog now also skips when
   BROWSE_HEADED=1, so even if a future launcher forgets PID=0, headed
   browsers won't die. Adds log lines when the watchdog is disabled
   so lifecycle debugging is easier.

Four community contributors diagnosed variants of this bug independently.
Thanks for the clear analyses and reproductions.

Closes #1020 (rocke2020)
Closes #1018 (sanghyuk-seo-nexcube)
Closes #1012 (rodbland2021)
Closes #986 (jbetala7)
Closes #1006
Closes #943

Co-Authored-By: rocke2020 <noreply@github.com>
Co-Authored-By: sanghyuk-seo-nexcube <noreply@github.com>
Co-Authored-By: rodbland2021 <noreply@github.com>
Co-Authored-By: jbetala7 <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-16 13:07:13 -07:00
parent b805aa0113
commit 4b48f8116f
2 changed files with 26 additions and 8 deletions
+13 -7
View File
@@ -210,12 +210,18 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
let proc: any = null;
// Allow the caller to opt out of the parent-process watchdog by setting
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
// shells, and short-lived Bash invocations that need the server to outlive
// the spawning CLI. Defaults to the current process PID (watchdog active).
const parentPid = process.env.BROWSE_PARENT_PID === '0' ? '0' : String(process.pid);
if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
// Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows —
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
// with { detached: true } instead, which is the gold standard for Windows
// process independence. Credit: PR #191 by @fqueiro.
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...(extraEnv || {}) });
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) });
const launcherCode =
`const{spawn}=require('child_process');` +
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
@@ -226,7 +232,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
// macOS/Linux: Bun.spawn + unref works correctly
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...extraEnv },
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
});
proc.unref();
}
@@ -826,12 +832,12 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
BROWSE_HEADED: '1',
BROWSE_PORT: '34567',
BROWSE_SIDEBAR_CHAT: '1',
// Disable parent-process watchdog: the user controls the headed browser
// window lifecycle. The CLI exits immediately after connect, so watching
// it would kill the server ~15s later. Cleanup happens via browser
// disconnect event or $B disconnect.
BROWSE_PARENT_PID: '0',
};
// If parent explicitly set BROWSE_PARENT_PID=0 (pair-agent disabling
// self-termination), pass it through so startServer doesn't override it.
if (process.env.BROWSE_PARENT_PID === '0') {
serverEnv.BROWSE_PARENT_PID = '0';
}
const newState = await startServer(serverEnv);
// Print connected status
+13 -1
View File
@@ -757,8 +757,16 @@ const idleCheckInterval = setInterval(() => {
// server can become an orphan — keeping chrome-headless-shell alive and
// causing console-window flicker on Windows. Poll the parent PID every 15s
// and self-terminate if it is gone.
//
// Headed mode (BROWSE_HEADED=1 or BROWSE_PARENT_PID=0): The user controls
// the browser window lifecycle. The CLI exits immediately after connect,
// so the watchdog would kill the server prematurely. Disabled in both cases
// as defense-in-depth — the CLI sets PID=0 for headed mode, and the server
// also checks BROWSE_HEADED in case a future launcher forgets.
// Cleanup happens via browser disconnect event or $B disconnect.
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
if (BROWSE_PARENT_PID > 0) {
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
setInterval(() => {
try {
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
@@ -767,6 +775,10 @@ if (BROWSE_PARENT_PID > 0) {
shutdown();
}
}, 15_000);
} else if (IS_HEADED_WATCHDOG) {
console.log('[browse] Parent-process watchdog disabled (headed mode)');
} else if (BROWSE_PARENT_PID === 0) {
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
}
// ─── Command Sets (from commands.ts — single source of truth) ───