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>
Three-bug chain behind the Windows terminal-agent leak (console window
strobing every 60s, one orphaned agent per watchdog tick until the box
ran out of committable memory):
1. isProcessAlive shelled out to `tasklist /FI "PID eq <pid>"` on Windows
with a 3s timeout. A Bun.spawnSync that hits its timeout still RETURNS
with partial stdout, so the `.includes()` PID match read a LIVE agent
as dead — killAgentByRecord skipped the kill, the watchdog respawned
around the survivor, and every orphan slowed the next tasklist enough
to produce the next false negative. Now: `process.kill(pid, 0)` on
every platform (Node and Bun both map signal 0 to an OpenProcess
existence check on Windows), with EPERM counted as alive. No
subprocess, no timeout, no console window.
2. The respawn circuit-breaker was mathematically unreachable — verified
in this tree: RESPAWN_GUARD_WINDOW_MS was a fixed 60_000 against a
60_000ms default tick, and each tick pushes at most one respawn
timestamp, so three pushes span ~120s and can never coexist inside a
60s window (eviction is strict `>`, and setInterval drift plus
per-tick work always ages the prior entry past the boundary). The
guard could not fire at the default tick rate and a steady
one-per-tick leak ran unbounded. The window now scales with the tick:
max(60_000, tick * (RESPAWN_GUARD_MAX + 2)), so "3 crashes in quick
succession → stop" holds at any tick value.
3. The tasklist probe popped a visible console per tick (no windowsHide).
Removing the shell-out kills that site; the agent-spawn site itself
already passes windowsHide: true (landed with the bun-polyfill
windowsHide commit — PR #2414's terminal-agent-control.ts hunk is
reconciled there rather than duplicated).
New browse/test/process-liveness-windows.test.ts pins all three: no
subprocess from the probe, a static tripwire against reintroducing
`tasklist` + `PID eq` liveness checks in src/, the spawnTerminalAgent
windowsHide + stdio contract, and the window-derived-from-tick
arithmetic. terminal-agent-watchdog.test.ts test 4 now pins the
window/tick relationship instead of the fixed literal that let this
ship. Also converts `new URL(import.meta.url).pathname` to
`import.meta.path` across the static-grep tests it touches — the
pathname form yields /C:/... on Windows and breaks path.resolve.
Contributed by @SYKhayyat (PR #2414).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>