Skip to content

fix(browse): keep headed daemon alive when parent shell dies (handoff dies ~15s in) - #2405

Open
davidani-davi wants to merge 1 commit into
garrytan:mainfrom
davidani-davi:fix/handoff-parent-watchdog
Open

fix(browse): keep headed daemon alive when parent shell dies (handoff dies ~15s in)#2405
davidani-davi wants to merge 1 commit into
garrytan:mainfrom
davidani-davi:fix/handoff-parent-watchdog

Conversation

@davidani-davi

Copy link
Copy Markdown

Summary

$B handoff opens a visible browser for the user to take over — CAPTCHA, MFA, login. Under Claude Code that window is destroyed ~15 seconds later, every time, usually while the user is still typing their password.

Root cause is the parent-process watchdog in browse/src/server.ts. It polls the spawning shell every 15s and, on parent death, shuts the daemon down whenever connectionMode is 'headed'. Claude Code's Bash tool kills the parent shell after every tool invocation, so the parent is always dead within seconds.

handoff flips an already-running headless daemon to headed at runtime. That daemon was spawned with a real BROWSE_PARENT_PID belonging to a long-dead shell, so the next tick tears down the browser.

Why the headed branch was never load-bearing

Its stated purpose is avoiding orphan daemons after /pair-agent and /open-gstack-browser. But those paths launch with BROWSE_HEADED=1 + BROWSE_PARENT_PID=0, which skips watchdog registration entirely at the outer gate:

if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) { ... }

So the branch never fired for the case it was written for, and only ever fired for handoff.

This is also a re-run of #994, which inverted "kill on parent death" for headless mode for exactly this reason. The headed branch was left behind, and handoff's runtime transition walks straight into it.

Change

  • Headed mode now stays alive on parent death, matching the BROWSE_HEADED=1 launch path. Cleanup is unchanged: handleChromiumDisconnect exits the daemon when the user closes the window, or $B disconnect.
  • Tunnel mode still shuts down — a remote peer has no window to close, so parent death is its only cleanup signal.
  • Extracted the watchdog body into parentWatchdogTick() behind __testInternals__, mirroring the existing idleCheckTick seam, so the branches can be driven deterministically instead of waiting on a 15s interval.

Verification

Reproduced end-to-end before the fix:

T+0   daemon 28145 alive
T+2   handoff -> daemon alive
T+20  daemon GONE, chromium GONE

After:

T+5   daemon ALIVE
T+25  daemon ALIVE
T+45  daemon ALIVE, chromium ALIVE, session URL intact

Four new tests in browse/test/server-factory.test.ts covering headed / headless / tunnel / live-parent. The headed test fails on main (Received number of calls: 1) and passes here.

browse suite: 0 failures across two full runs. Repo-root bun test shows 6 failures in gbrain-detect-install and user-slug-fallback (status 127 under restricted PATH) — reproduced identically on a clean origin/main worktree, so they are pre-existing and environmental, unrelated to this change.

Found while using /browse for real work, per the CONTRIBUTING workflow.

🤖 Generated with Claude Code

The parent-process watchdog polls the spawning shell every 15s and, on
parent death, shut the daemon down whenever connectionMode was 'headed'.
Claude Code's Bash tool kills the parent shell after every tool
invocation, so the parent is always dead within seconds.

`handoff` flips an already-running headless daemon to headed at runtime.
That daemon was spawned with a real BROWSE_PARENT_PID belonging to a
long-dead shell, so the next tick tore down the browser ~15s after every
handoff — including while the user was being asked to log in.

The branch never protected its stated case: `$B connect` / /pair-agent
launch with BROWSE_HEADED=1 + BROWSE_PARENT_PID=0, which skips watchdog
registration entirely. garrytan#994 already inverted this for headless mode for
exactly the same reason; the headed branch was left behind.

Headed mode now stays alive on parent death, matching the BROWSE_HEADED=1
launch path. Cleanup is unchanged: handleChromiumDisconnect exits the
daemon when the window closes, or `$B disconnect`. Tunnel mode still
shuts down — a remote peer has no window to close, so parent death is
its only cleanup signal.

Extract the watchdog body into parentWatchdogTick() exposed via
__testInternals__, mirroring the existing idleCheckTick seam, so the
branches can be driven deterministically instead of waiting on a 15s
interval.

Verified end-to-end: before, daemon + Chromium dead at T+20; after, both
alive past T+45 with session URL intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Jul 30, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

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