From ef17a5c807ba446316cdaf46cd7836cb0c977307 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 17 Apr 2026 06:01:27 +0800 Subject: [PATCH] fix(browse): gate #994 SIGTERM-ignore to normal mode only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #994 made browse persist across Claude Code Bash calls by ignoring SIGTERM and parent-PID death, relying on the 30-min idle timeout for eventual cleanup. Codex outside-voice review caught that the idle timeout doesn't apply in two modes: headed mode (/open-gstack-browser) and tunnel mode (/pair-agent). Both early-return from idleCheckInterval. Combined with #994's ignore-SIGTERM, those sessions would leak forever after the user disconnects — a real resource leak on shared machines where multiple /pair-agent sessions come and go. Fix: gate SIGTERM-ignore and parent-PID-watchdog-ignore to normal (headless) mode only. Headed + tunnel modes respect both signals and shutdown cleanly. Idle timeout behavior unchanged. Also documents the deliberate contract change for future contributors — don't re-add global SIGTERM shutdown thinking it's missing; it's intentionally scoped. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/server.ts | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/browse/src/server.ts b/browse/src/server.ts index 47575d1a..25fe25b3 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -764,10 +764,19 @@ if (BROWSE_PARENT_PID > 0) { try { process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent } catch { - // 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) { + // Parent exited. Behavior depends on mode: + // - Normal (headless) mode: stay alive. Claude Code's Bash tool kills the + // parent shell between invocations, so the server must survive. The + // idle timeout (30 min) handles eventual cleanup. + // - Headed / tunnel mode: the idle timeout DOESN'T apply (see idleCheckInterval + // above — both modes early-return). If we ignored parent death here too, + // orphan daemons would accumulate forever after /pair-agent or + // /open-gstack-browser sessions end. Shutdown instead. + const headed = browserManager.getConnectionMode() === 'headed'; + if (headed || tunnelActive) { + console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`); + shutdown(); + } else if (!parentGone) { parentGone = true; console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`); } @@ -1233,10 +1242,21 @@ async function shutdown() { // Handle signals // 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. +// SIGTERM behavior depends on mode: +// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the +// parent shell exits between tool invocations. Ignoring it keeps the server +// alive across $B calls. Idle timeout (30 min) handles eventual cleanup. +// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect +// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly +// without waiting forever. Ctrl+C and /stop still work either way. process.on('SIGTERM', () => { - console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)'); + const headed = browserManager.getConnectionMode() === 'headed'; + if (headed || tunnelActive) { + console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`); + shutdown(); + } else { + 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.