Skip to content

fix(browse): daemonize macOS/Linux server via setsid() so it survives sandboxed-shell harnesses#1612

Open
bharat2913 wants to merge 1 commit into
garrytan:mainfrom
bharat2913:fix/macos-server-daemonize-setsid
Open

fix(browse): daemonize macOS/Linux server via setsid() so it survives sandboxed-shell harnesses#1612
bharat2913 wants to merge 1 commit into
garrytan:mainfrom
bharat2913:fix/macos-server-daemonize-setsid

Conversation

@bharat2913
Copy link
Copy Markdown

@bharat2913 bharat2913 commented May 19, 2026

Summary

On macOS/Linux, startServer() spawns the bun server with Bun.spawn().unref(). unref() only releases the child from Bun's event loop — it does not call setsid(). The spawned server inherits the spawning shell's process session, so when the CLI runs inside a session-managed shell that exits shortly after the CLI returns (Claude Code's per-command Bash sandbox, Conductor, OpenClaw, many CI step runners), the session leader's exit sends SIGHUP to every PID in the session — killing the bun server and its Chromium grandchildren within ~10–15s of a successful connect.

Setting BROWSE_PARENT_PID=0 (already done by connect and the pair-agent path) disables the in-server watchdog but does not save the server here: SIGHUP from session teardown still reaps it.

This PR replaces the macOS/Linux Bun.spawn().unref() with Node's child_process.spawn({ detached: true }), which calls setsid() and makes the server its own session leader (PPID=1, STAT=Ss). Same rationale as the Windows path (PR #191 by @fqueiro) — same root cause, different OS surface.

The proc?.stderr startup-error branch is removed since both platforms now spawn with stdio: 'ignore'; both fall through to the on-disk browse-startup-error.log written by server.ts's start().catch.

Repro (without the fix)

# In any harness that runs each CLI call as a fresh, short-lived Bash session
$B connect                          # succeeds → headed Chrome visible
$B status                           # immediately: healthy, Mode: headed, PID=X
sleep 15
$B status                           # "Headed server running (PID X) but not responding"
ps -p X                             # PID X is gone, state.json gracefully unlinked
                                    # (shutdown() ran → SIGHUP-induced SIGTERM)

Verification (with the fix)

macOS 14 / arm64, in Conductor (Claude Code's per-command Bash sandbox):

T+0s   $B connect      → PID 36649  PPID=1  PGID=36649  SESS=0  STAT=Ss
T+5s   ALIVE  state=yes
T+10s  ALIVE  state=yes
T+15s  ALIVE  state=yes
T+20s  ALIVE  state=yes
T+30s  ALIVE  state=yes
T+40s  ALIVE  state=yes

# Fresh Bash invocation (separate `$B` calls):
$B status   → Mode: headed, PID 36649  (same PID, ELAPSED 00:48)
$B goto https://example.com  → 200
$B status   → Mode: headed, PID 36649  (still alive, URL persisted)
$B snapshot → returns live page text

Pre-fix, the same flow produces Mode: launched (fresh headless server) on the next $B status because the headed server has already been reaped.

Notes

  • Behavior unchanged outside session-managed harnesses (Bun's unref() already detached enough for normal shells, and Node's detached:true is at least as detached on those).
  • No change to the Windows path.
  • Independently verified that os.setsid() via a Python double-fork survives the same Conductor session teardown — so this is a session-attachment issue, not a wholesale "kill all spawned PIDs" sweep. The fix is the standard POSIX daemonization.
  • Bug will hit anyone running gstack inside Claude Code's per-command Bash sandbox or similar session-managed harnesses.

`Bun.spawn().unref()` only releases the child from Bun's event loop —
it does NOT call setsid(). The spawned bun server inherits the spawning
shell's process session. When the CLI runs inside a session-managed shell
that exits shortly after the CLI returns (Claude Code's per-command Bash
sandbox, Conductor, OpenClaw, CI step runners), the session leader's exit
sends SIGHUP to every PID in the session — killing the bun server and
its Chromium grandchildren within seconds of a successful `connect`.

Setting `BROWSE_PARENT_PID=0` (already done by the `connect` command and
pair-agent) disables the parent-process watchdog but does NOT save the
server here: SIGHUP from session teardown still reaps it.

Replace the macOS/Linux `Bun.spawn().unref()` with Node's
`child_process.spawn({ detached: true })`, which calls setsid() and
gives the server its own session leader role (PPID=1, STAT=Ss). This
mirrors the Windows path's rationale (PR garrytan#191 by @fqueiro) — same root
cause, different OS surface.

Verified on macOS in Conductor: pre-fix the server dies ~10–15s after
connect across separate Bash invocations; post-fix the same PID stays
alive (PPID=1, SESS=0, STAT=Ss) and responds to `status`/`goto`/
`snapshot` across many separate shell calls.

The `proc?.stderr` startup-error branch is removed since both platforms
now spawn with `stdio: 'ignore'`; both fall through to the on-disk
`browse-startup-error.log` written by `server.ts`'s start().catch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant