fix(terminal-agent): tie agent lifetime to its owning browse server PID

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 crashed or was killed before running normal
shutdown, the agent was adopted by PID 1 and lived forever (#2019).

spawnTerminalAgent now requires an ownerPid and exports it to the agent
as BROWSE_OWNER_PID; all three spawn sites pass the server PID (cli.ts
cold-start, cli.ts supervisor respawn, server.ts watchdog). The agent
polls the owner with signal 0 every 15s (GSTACK_TERMINAL_OWNER_WATCHDOG_MS
to tune) on an unref'd timer and, when the owner disappears, exits
through the SAME cleanup path as an intentional SIGTERM shutdown — now
re-entrancy-guarded and also removing the terminal-internal-token file
alongside the port file and agent record.

Runtime test spawns a real agent tied to a throwaway owner process,
kills the owner, and asserts the agent exits and its discovery files
(terminal-agent-pid, terminal-port) are gone.

Reconciled with the watchdog commit's spawnTerminalAgent contract test
(process-liveness-windows.test.ts now passes ownerPid and pins the
BROWSE_OWNER_PID env forwarding).

Closes #2019.

Contributed by @csarigoz (PR #2530).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 20:20:53 -07:00
co-authored by Claude Fable 5
parent bbaf5068b0
commit 5f6266e012
6 changed files with 104 additions and 2 deletions
@@ -100,12 +100,16 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
const pid = spawnTerminalAgent({
stateFile: path.join(tmpDir, 'state.json'),
serverPort: 12345,
ownerPid: process.pid,
cwd: tmpDir,
scriptPath: script,
});
expect(pid).toBe(4242);
expect(captured).not.toBeNull();
expect(captured.windowsHide).toBe(true);
// Owner-PID lifetime tie (#2019): the agent polls this and exits when
// its owning browse server dies, so it can't be adopted by PID 1.
expect(captured.env.BROWSE_OWNER_PID).toBe(String(process.pid));
// Detached background daemon — must not inherit a terminal either.
expect(captured.stdio).toEqual(['ignore', 'ignore', 'ignore']);
} finally {
@@ -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<boolean> {
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);
});
});