Skip to content

terminal-agent watchdog flashes a console window every tick on Windows — isProcessAlive() spawns tasklist instead of signal-0 #1952

Closed

Description

@sad85520

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:

  1. browse connect (headed), then issue no further commands.
  2. 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.
  3. 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 in src/error-handling.ts
    and in the running dist/server-node.mjs — on Windows the server is
    launched from the bundle, not src/ (resolveNodeServerScript() in
    cli.ts), so a src/-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

  1. sad85520 commented on Jul 14, 2026

    @sad85520
    Author

    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 spawns tasklist per watchdog tick), and the signal-0 patch from the Proposed Fix above still applies cleanly to browse/src/error-handling.ts.

    Post-rebuild verification: grep tasklist browse/dist/server-node.mjs — the only remaining tasklist call is isBrowserRunning() (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 → true path on Windows.

    Windows 11 Pro 10.0.26200 / Bun-compiled browse / gstack v1.60.1.0

  2. time-attack commented on Jul 15, 2026

    @time-attack
    Contributor

    @sad85520 The watchdog still launches tasklist on every tick, and signal-zero behavior is not safely handled in the compiled Windows executable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions