garrytan / garrytan/gstack

Windows: bun-polyfill.cjs spawn/spawnSync drop windowsHide, and the terminal-agent watchdog duplicates live agents (node-path gaps #1952/#2019 won't cover)

Open
#2,151 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.