Windows: bun-polyfill.cjs spawn/spawnSync drop windowsHide, and the terminal-agent watchdog duplicates live agents (node-path gaps #1952/#2019 won't cover)
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
Description
## TL;DR
On Windows the browse server always runs under **Node.js** (`dist/server-node.mjs` + `dist/bun-polyfill.cjs` — `cli.ts` hard-fails without the bundle because "the Bun path is known broken"). Two node-path defects that the open Windows-flash issues don't cover:
1. **`browse/src/bun-polyfill.cjs` `spawn()`/`spawnSync()` drop `windowsHide` (and every other option) on the floor.** They forward only `stdio`/`env`/`cwd`(/`timeout`). Node's `child_process` default is `windowsHide: false`, so every process the console-less daemon spawns gets a visible console window. Critically, this means the fix proposed in #2019 (add `windowsHide: true` to the `Bun.spawn` call in `terminal-agent-control.ts:71`) **will be silently swallowed on Windows**, because on Windows that call never reaches Bun — it reaches this polyfill, which won't forward the option.
2. **The v1.44 terminal-agent watchdog respawns around a live agent and never reaps it (split-brain), observed live.** I caught the daemon spawning a second `bun run terminal-agent.ts` exactly one watchdog tick (67s) after the first, with **both agents alive simultaneously** minutes later. Each respawn = a fresh visible `bun.exe` console window (defect 1), which is how it surfaced: my report was literally "bun.exe windows keep flashing open and closing".
## Live incident timeline (Windows 11, gstack v1.58.4.0)
A `/qa`-style browse session against a local app left this state:
```
17:07:07 node …\browse\dist\server-node.mjs starts (PID 6048, parent already exited)
17:09:24 bun.exe spawned: bun run …\browse\src\terminal-agent.ts (PID 100448)
17:10:31 bun.exe spawned: bun run …\browse\src\terminal-agent.ts (PID 111648) ← +67s ≈ one AGENT_WATCHDOG_TICK_MS
17:11+ BOTH bun agents alive concurrently (verified via Win32_Process); PID 100448 was never killed
17:10:32 \.gstack\claude-available.json written (agent 2 boot) — matches terminal-agent.ts main() step 1
```
The 17:10:31 respawn means the watchdog tick evaluated `record && isProcessAlive(record.pid)` as false while agent 1 (100448) was demonstrably alive. Then in `spawnTerminalAgent` (`terminal-agent-control.ts:63-68`) the prior-kill is gated on the **same** false signal — `killAgentByRecord` → `isProcessAlive` → false → returns without signalling — so the live agent survives as an orphan, its record is wiped by `clearAgentRecord`, and the duplicate registers in its place. That is exactly the split-brain the v1.44 comment in `server.ts` says the identity-based design exists to prevent ("prevents respawning around a slow-but-alive agent").
Standalone verification: `bun run terminal-agent.ts` with a fresh `BROWSE_STATE_FILE` on this same machine boots correctly and writes `terminal-port`, `terminal-agent-pid`, and `terminal-internal-token` within ~1s — the agent itself is fine on Windows; the supervision path is what breaks.
## Root causes
### 1. Polyfill spawn surface (`browse/src/bun-polyfill.cjs`)
```js
spawn(cmd, options = {}) {
const [command, ...args] = cmd;
const stdio = options.stdio || ['pipe', 'pipe', 'pipe'];
const proc = spawn(command, args, { stdio, env: options.env, cwd: options.cwd }); // no windowsHide, no detached
...
},
spawnSync(cmd, options = {}) {
...
const result = spawnSync(command, args, { stdio: [...], timeout: options.timeout, env: options.env, cwd: options.cwd }); // same
```
Every daemon-side spawn on Windows goes through this: the watchdog's `bun run terminal-agent.ts` respawn, `tasklist` liveness probes (#1952), `git rev-parse`, chrome `--version` probing. Each one pops a console window from a daemon that has no console of its own.
### 2. False-dead probes funnel into destructive respawn
Two paths make a live agent read as dead, and both end in "clear its record + spawn a duplicate + skip the kill":
- **Probe timeout → false.** The polyfill's `spawnSync` passes `timeout: 3000`. Node's `spawnSync` does **not throw** on timeout — it returns `result.error = ETIMEDOUT` with `status: null` — and the polyfill returns `stdout || Buffer.from('')`, so `isProcessAlive`'s `.includes()` check quietly returns false. `tasklist` on a loaded Windows box (this one had ~40 node processes, dev servers, and eval runs going) can exceed 3s.
- **Slow registration.** `writeAgentRecord` runs late in `terminal-agent.ts` `main()` (after `Bun.serve` binds, after two `writeSecureFile` calls that each shell out to `icacls` on Windows — more hidden-console subprocesses). Any boot slower than the gap to the next tick reads as "no record → dead".
Compounding it: the crash-loop guard (`RESPAWN_GUARD_MAX = 3` within `RESPAWN_GUARD_WINDOW_MS = 60_000`) can never trip, because the tick interval is itself 60s — at most ~1 respawn ever lands inside the window. The cycle repeats for the daemon's whole lifetime.
## Why this is distinct from the open issues
- #1952 covers the `tasklist` console flash per tick and proposes signal-0 — correct, and it removes that one spawn. It doesn't cover the polyfill option-dropping, so every *other* `Bun.spawn`/`Bun.spawnSync` on the node path keeps flashing.
- #2019 covers orphan accumulation across *crashed sessions* and proposes `windowsHide: true` at the Bun-runtime call site. This issue is (a) duplication by the watchdog *within a single healthy session*, and (b) the fact that #2019's fix as proposed won't take effect on Windows because `bun-polyfill.cjs` doesn't forward the option.
- #1784 / #1989 are the Bun-runtime and vendored-playwright spawn surfaces respectively; this is the third surface (node polyfill).
## Proposed fixes
1. **`bun-polyfill.cjs`**: add `windowsHide: true` unconditionally (both `spawn` and `spawnSync`), and forward `detached` — the daemon's children are all headless by design, so there's no downside. This is a prerequisite for #2019's call-site fix to work on Windows.
2. **`isProcessAlive`**: unify on `process.kill(pid, 0)` with `EPERM ⇒ alive` per #1952 — kills the probe subprocess, the 3s-timeout false-dead path, and the per-tick flash in one line.
3. **Watchdog/`spawnTerminalAgent`**: don't gate the prior-kill on the same probe that just returned false — `safeKill` already swallows `ESRCH`, so signalling a genuinely-dead PID is harmless, while skipping a live one creates split-brain. And widen `RESPAWN_GUARD_WINDOW_MS` to ≥ 3 ticks so the guard can actually trip.
Happy to send a PR for (1) and (3) if useful — (2) presumably lands with #1952.
## Env
- OS: Windows 11 Pro 10.0.26200
- gstack: v1.58.4.0 local install (polyfill unchanged on main @ v1.58.5.0)
- Bun: 1.3.14, Node: 22.x (`server-node.mjs` path)
- Host: Claude Code
Contributor guide
Research direction
Start with browse/src/bun-polyfill.cjs, then follow isProcessAlive and spawnTerminalAgent in terminal-agent-control.ts, with terminal-agent.ts and server.ts for the watchdog lifecycle. Reproduce the Windows Node path and inspect the existing spawn and liveness behavior. Done means headless child processes retain the intended options and a live terminal agent is not duplicated or orphaned during watchdog checks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bun, javascript, node.js, typescript
- Domain
- backend, devtools, operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100