fix(browse): server persists across Claude Code Bash calls

The browse server was dying between Bash tool invocations in Claude Code
because:

1. SIGTERM: The Claude Code sandbox sends SIGTERM to all child processes
   when a Bash command completes. The server received this and called
   shutdown(), deleting the state file and exiting.

2. Parent watchdog: The server polls BROWSE_PARENT_PID every 15s. When
   the parent Bash shell exits (killed by sandbox), the watchdog detected
   it and called shutdown().

Both mechanisms made it impossible to use the browse tool across multiple
Bash calls — every new `$B` invocation started a fresh server with no
cookies, no page state, and no tabs.

Fix:
- SIGTERM handler: log and ignore instead of shutdown. Explicit shutdown
  is still available via the /stop command or SIGINT (Ctrl+C).
- Parent watchdog: log once and continue instead of shutdown. The existing
  idle timeout (30 min) handles eventual cleanup.

The /stop command and SIGINT still work for intentional shutdown. Windows
behavior is unchanged (uses taskkill /F which bypasses signal handlers).

Tested: browse server survives across 5+ separate Bash tool calls in
Claude Code, maintaining cookies, page state, and navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Green
2026-04-14 00:03:09 -04:00
committed by Garry Tan
parent a2ea4472f1
commit efbc910eb5
+14 -3
View File
@@ -759,12 +759,18 @@ const idleCheckInterval = setInterval(() => {
// and self-terminate if it is gone.
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
if (BROWSE_PARENT_PID > 0) {
let parentGone = false;
setInterval(() => {
try {
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
} catch {
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited, shutting down`);
shutdown();
// Parent exited. Don't shutdown — the server should persist across
// Bash calls (e.g. Claude Code sandbox kills the parent shell).
// The idle timeout (30 min) handles eventual cleanup instead.
if (!parentGone) {
parentGone = true;
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
}
}
}, 15_000);
}
@@ -1225,8 +1231,13 @@ async function shutdown() {
}
// Handle signals
process.on('SIGTERM', shutdown);
// SIGINT (Ctrl+C): user intentionally stopping → shutdown
process.on('SIGINT', shutdown);
// SIGTERM: may come from sandbox killing parent → ignore and let idle timeout handle cleanup.
// Explicit shutdown is available via the /stop command or SIGINT.
process.on('SIGTERM', () => {
console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)');
});
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
if (process.platform === 'win32') {