Repository navigation
terminal-agent watchdog flashes a console window every tick on Windows — isProcessAlive() spawns tasklist instead of signal-0 #1952
Description
TL;DR
On Windows, a connected headed browse session blinks a console window
(conhost.exe) on a fixed interval for the entire session — no CLI command, no
user action. The terminal-agent watchdog calls isProcessAlive() once per
setInterval tick to check the agent PID, and on Windows isProcessAlive()
shells out to tasklist, which Windows gives its own console window.
macOS/Linux stay silent because their path is a process.kill(pid, 0) syscall
that spawns nothing. It is purely cosmetic — each window self-closes — but it
blinks on a fixed interval for the whole session with no indication of what it
is, which is confusing and distracting.
Root Cause
isProcessAlive() (browse/src/error-handling.ts) has a Windows-only branch
that spawns tasklist:
export function isProcessAlive(pid: number): boolean {
if (IS_WINDOWS) {
try {
const result = Bun.spawnSync(
['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000 }
);
return result.stdout.toString().includes(`"${pid}"`);
} catch { return false; }
}
try { process.kill(pid, 0); return true; } catch { return false; }
}The terminal-agent watchdog (browse/src/server.ts) registers a setInterval
that fires every AGENT_WATCHDOG_TICK_MS (default 60000) on the normal headed
connect path (ownsTerminalAgent === true) and calls isProcessAlive() on
every tick:
agentWatchdogInterval = setInterval(() => {
if (isShuttingDown) return;
if (agentRespawnGuardTripped) return;
const record = readAgentRecord(stateDir);
if (record && isProcessAlive(record.pid)) return; // ← spawns tasklist on Windows, every tick
// ...respawn dead agent...
}, AGENT_WATCHDOG_TICK_MS);Each tick → Bun.spawnSync(['tasklist', …]) → a console window. The parent
watchdog two functions away already avoids exactly this: it calls
process.kill(BROWSE_PARENT_PID, 0) directly
(// signal 0 = existence check only, no signal sent). Only isProcessAlive()
takes the spawn path.
Evidence
Reproduced on Windows 11, gstack 1.57.6.0:
browse connect(headed), then issue no further commands.- A console window flashes open and immediately closes on a fixed interval,
indefinitely, for as long as the session stays connected. Each one
self-closes — it does not pile up — but the steady periodic blink is the
problem. - The only process-spawning call on the watchdog tick is
isProcessAlive()→
tasklist; macOS shows no window for the same watchdog (its branch is
process.kill(pid, 0)).
Proposed Fix
Use signal-0 on all platforms — what the parent watchdog already does. Node/Bun
implement process.kill(pid, 0) on Windows via OpenProcess: a pure existence
check, no child process, no window.
export function isProcessAlive(pid: number): boolean {
- if (IS_WINDOWS) {
- try {
- const result = Bun.spawnSync(
- ['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
- { stdout: 'pipe', stderr: 'pipe', timeout: 3000 }
- );
- return result.stdout.toString().includes(`"${pid}"`);
- } catch {
- return false;
- }
- }
- try {
- process.kill(pid, 0);
- return true;
- } catch {
- return false;
- }
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (err: any) {
+ // ESRCH → gone; EPERM → exists but not signallable → still alive.
+ return err?.code === 'EPERM';
+ }
}Why EPERM ⇒ alive
process.kill(pid, 0) throws ESRCH when the process is gone (→ false) and
EPERM when it exists but can't be signalled (→ alive, → true). The old Unix
branch collapsed every error to false; unifying on signal-0 corrects that edge
for free.
Verification
Applied the patch and restarted the server:
- Patched
isProcessAlive()to the signal-0 form insrc/error-handling.ts
and in the runningdist/server-node.mjs— on Windows the server is
launched from the bundle, notsrc/(resolveNodeServerScript()in
cli.ts), so asrc/-only edit has no runtime effect until re-bundled. - After restart, the periodic console-window flashes stopped completely —
observed over several minutes on Windows 11. - The watchdog's alive/dead branch is unchanged; signal-0 returns the same
boolean it already keys on, so dead-agent detection and respawn still work.
The same isProcessAlive is also compiled into the browse CLI binary, which
spawns tasklist on every stale-detection call — the one-line fix covers it on
rebuild.
Windows 11. gstack 1.57.6.0. Bun.
Activity
sad85520 commentedon Jul 14, 2026
Verification update — patch still required and still clean at v1.60.1.0
Upgraded v1.57.6.0 → v1.60.1.0 (13 commits, 7 releases): upstream
isProcessAlive()is unchanged (still spawnstasklistper watchdog tick), and the signal-0 patch from the Proposed Fix above still applies cleanly tobrowse/src/error-handling.ts.Post-rebuild verification:
grep tasklist browse/dist/server-node.mjs— the only remainingtasklistcall isisBrowserRunning()(one-off image-name probe at launch, not per-tick). The watchdog path is silent.Data point after ~1 month of daily use on the patched build: no console flashes, no watchdog false positives/negatives observed from the
EPERM → truepath on Windows.Windows 11 Pro 10.0.26200 / Bun-compiled browse / gstack v1.60.1.0
time-attack commentedon Jul 15, 2026
@sad85520 The watchdog still launches tasklist on every tick, and signal-zero behavior is not safely handled in the compiled Windows executable.