From e0bb9c15cb5e7697a18b4e3e4d015cd14eb8673d Mon Sep 17 00:00:00 2001 From: Cagri Sarigoz Date: Wed, 12 Aug 2026 13:56:16 +0300 Subject: [PATCH] fix(browse): stop terminal agent when owner exits --- browse/src/cli.ts | 2 + browse/src/server.ts | 1 + browse/src/terminal-agent-control.ts | 3 + browse/src/terminal-agent.ts | 25 +++++++ .../terminal-agent-owner-watchdog.test.ts | 68 +++++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 browse/test/terminal-agent-owner-watchdog.test.ts diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 59327b7923..13ce325b7f 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -1130,6 +1130,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: const newPid = spawnTerminalAgent({ stateFile: config.stateFile, serverPort: newState.port, + ownerPid: newState.pid, cwd: config.projectDir, }); if (newPid) { @@ -1222,6 +1223,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: spawnTerminalAgent({ stateFile: config.stateFile, serverPort: respawned.port, + ownerPid: respawned.pid, cwd: config.projectDir, }); } catch (err: any) { diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acce..17d3177ba0 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1551,6 +1551,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { const pid = spawnTerminalAgent({ stateFile: cfg.config.stateFile, serverPort: cfg.browsePort, + ownerPid: process.pid, cwd: cfg.config.projectDir, }); if (pid) { diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index 094ba668fa..c474ead961 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -54,6 +54,8 @@ export function resolveTerminalAgentScript(searchHints: { metaDir?: string; exec export function spawnTerminalAgent(opts: { stateFile: string; serverPort: number; + /** PID of the browse server that owns this agent. */ + ownerPid: number; cwd?: string; /** Optional extra env vars to add to the agent's process env. */ extraEnv?: Record; @@ -74,6 +76,7 @@ export function spawnTerminalAgent(opts: { ...process.env, BROWSE_STATE_FILE: opts.stateFile, BROWSE_SERVER_PORT: String(opts.serverPort), + BROWSE_OWNER_PID: String(opts.ownerPid), ...(opts.extraEnv || {}), }, stdio: ['ignore', 'ignore', 'ignore'], diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 2e39d99e40..92d5611a29 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -30,6 +30,11 @@ import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control'; const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json'); const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port'); const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10); +const BROWSE_OWNER_PID = parseInt(process.env.BROWSE_OWNER_PID || '0', 10); +const OWNER_WATCHDOG_MS = parseInt( + process.env.GSTACK_TERMINAL_OWNER_WATCHDOG_MS || '15000', + 10, +); const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn /** @@ -987,13 +992,33 @@ function main() { console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid} gen=${CURRENT_GEN}`); // Cleanup port file + agent record on exit. + let cleaningUp = false; const cleanup = () => { + if (cleaningUp) return; + cleaningUp = true; safeUnlink(PORT_FILE); + safeUnlink(INTERNAL_TOKEN_FILE); clearAgentRecord(dir); process.exit(0); }; process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); + + // The terminal agent is intentionally detached so it survives the short-lived + // CLI launcher, but its real owner is the persistent browse server. If that + // server crashes or is killed before running normal shutdown, the agent would + // otherwise be adopted by PID 1 and live forever. Poll the server PID and use + // the same cleanup path as an intentional shutdown when it disappears. + if (BROWSE_OWNER_PID > 0) { + const ownerWatchdog = setInterval(() => { + try { + process.kill(BROWSE_OWNER_PID, 0); + } catch { + cleanup(); + } + }, OWNER_WATCHDOG_MS); + (ownerWatchdog as any)?.unref?.(); + } } // Export the internal token so cli.ts can pass the SAME value to the parent diff --git a/browse/test/terminal-agent-owner-watchdog.test.ts b/browse/test/terminal-agent-owner-watchdog.test.ts new file mode 100644 index 0000000000..5348a221f1 --- /dev/null +++ b/browse/test/terminal-agent-owner-watchdog.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts'); +const spawned: any[] = []; +const tempDirs: string[] = []; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await Bun.sleep(25); + } + return predicate(); +} + +afterEach(() => { + for (const proc of spawned.splice(0)) { + try { proc.kill?.('SIGKILL'); } catch {} + } + for (const dir of tempDirs.splice(0)) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} + } +}); + +describe('terminal-agent owner lifecycle', () => { + test('exits after its owning browse server process exits', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-owner-')); + tempDirs.push(stateDir); + const stateFile = path.join(stateDir, 'browse.json'); + fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-token' })); + + const owner = Bun.spawn(['sleep', '30'], { stdio: ['ignore', 'ignore', 'ignore'] }); + spawned.push(owner); + const agent = Bun.spawn(['bun', 'run', AGENT_SCRIPT], { + env: { + ...process.env, + BROWSE_STATE_FILE: stateFile, + BROWSE_SERVER_PORT: '0', + BROWSE_OWNER_PID: String(owner.pid), + GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25', + }, + stdio: ['ignore', 'ignore', 'ignore'], + }); + spawned.push(agent); + + expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-agent-pid')))).toBe(true); + expect(isAlive(agent.pid)).toBe(true); + + owner.kill('SIGTERM'); + await owner.exited; + + expect(await waitFor(() => !isAlive(agent.pid))).toBe(true); + expect(fs.existsSync(path.join(stateDir, 'terminal-agent-pid'))).toBe(false); + expect(fs.existsSync(path.join(stateDir, 'terminal-port'))).toBe(false); + }); +});