Skip to content

fix(browser): bound the agent-browser daemon's life instead of hoping teardown runs (#16367) - #16588

Merged
nwparker merged 5 commits into
mainfrom
nwparker/win-16367-agent-browser-orphans
Aug 26, 2026
Merged

nwparker merged 5 commits into
mainfrom
nwparker/win-16367-agent-browser-orphans

Conversation

@nwparker

@nwparker nwparker commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​531 $\color{#cf222e}{\Huge{\mathbf{−}}}$​18 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​513
Prod 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​255 $\color{#cf222e}{\Huge{\mathbf{−}}}$​26 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​229

ELI5

When Orca opens a browser for an agent, it starts a little background helper program that actually drives the browser. Orca was never able to shut those helpers down reliably — they detached from Orca completely, so if Orca quit, crashed, or was force-killed, the helpers just kept running forever, and two of them had grown to about 25 GB and 7 GB of memory, which filled the machine until everything froze. Now every helper turns itself off after ten minutes of doing nothing, Orca actually waits for them to close when you quit, and on start-up Orca cleans up any leftovers from a previous run.

The one helper that runs a remote user's own browser is deliberately left alone. A review round found this PR was doing the opposite in one place: it closed that helper — and the whole browser hanging off it — every single time Orca started, not just after a crash. That is fixed: Orca now looks for a live tab first and, if it finds one, leaves the browser exactly as the user left it.

What Changed

Original commit (a12a2fd171f) plus three review rounds (294fdeb6d73, c9a065be2e1, a73395ba43c) — 13 files, +864/−51.

1. A self-imposed idle timeout — the only bound that survives SIGKILL. agent-browser-process-environment.ts now sets AGENT_BROWSER_IDLE_TIMEOUT_MS = 10 min on every platform (previously it returned the inherited env untouched on win32 and whenever a caller had already set AGENT_BROWSER_SOCKET_DIR, so those paths got nothing). An operator-set value passes through untouched.

2. The bound is asymmetric, on purpose — and the orcad daemon is now reused, not killed. The first fix round removed the timeout from the orcad external-Chromium daemon (external-chromium-browser-session.ts): that daemon owns a remote user's whole Chromium tree, so idling it out would have closed their live browser and every tab in it.

The same round then contradicted that reasoning: start() issued a close before every open, which kills the daemon and its Chromium tree — and start() runs on every provider start, not only after a crash, so an orca serve restart took the remote user's browser and every tab in it. The justification for that close was borrowed from the pane bridge, which passes --cdp and so genuinely holds a port that dies with its Orca. This session passes only --session and --profile: nothing binds it to the old process, and the daemon owns its Chromium independently, so a survivor is reusable as-is.

Fixed in a73395ba43c: start() probes for an active tab and returns it untouched; only a session name that answers nothing is closed and reopened — which is still the killed-orcad case the stable session name exists to recover. This matters more as orcad becomes the backend the remote host runs on: the browser it manages belongs to a user, not to the process driving it this minute.

3. Quit actually waits. destroyAllSessions() was fire-and-forget and was the only teardown step not in settleTeardownWithinDeadline, so app.quit() was reached long before any daemon closed. It is now a barrier member (index.ts:3545) — and each per-session close runs with an explicit TEARDOWN_CLOSE_TIMEOUT_MS = 5_000 instead of inheriting EXEC_TIMEOUT_MS = 90_000, so the member is individually bounded well inside the 20 s deadline. The loop also walks the union of sessions / pendingSessionCreation / pendingSessionDestruction, so a daemon spawned by a session still being created no longer outlives quit.

4. Headless owner boundary. In orca serve, OffscreenBrowserBackend.closeTab destroyed the page without ever retiring its daemon, and it unregistered the guest first — which would have defeated onTabClosed's webContentsId lookup even if it had been wired up. The backend now takes a getAgentBrowserBridge hook and awaits the new page-id-keyed AgentBrowserBridge.onPageClosed(browserPageId) before unregisterGuest, on both closeTab and the unexpected webContents 'destroyed' event. destroyAll drops each entry before destroying so the destroyed handler cannot queue a second, unawaited retirement outside the quit barrier.

5. Start-up sweep for daemons left by a previous run. closeStaleAgentBrowserSession only ever reset the one name a new tab was about to reuse, so a crash with 10 open tabs left 9 daemons untouched forever. New agent-browser-orphan-sweep.ts uses session list --json + --session X close — no pid-walking, per the argument in windows-pty-job.ts:4-24 — filtered to the orca-tab- prefix and guarded by isSessionLive.

6. Ownership is proven, not assumed. The sweep originally ran whenever AGENT_BROWSER_SOCKET_DIR was merely set, which an inherited value satisfies — two Orca profiles sharing one socket dir would sweep each other's live daemons. createAgentBrowserProcessEnvironment now returns { env, ownsSocketDirectory } and the sweep gates on ownsSocketDirectory. Windows deliberately gets no enumeration at all (named pipes make the directory moot) rather than a machine-wide sweep that could close another process's daemons; it stays bounded by the idle timeout, which needs no ownership proof.

7. Idle-out is no longer silent. The bridge tracks session.lastCommandAt; when the gap reaches the configured idle timeout it resets session.initialized and re-seeds pendingInterceptRestore, so configured network routes are replayed onto the replacement daemon instead of requests silently hitting the real network. Detection is skipped entirely when no idle bound is configured.

8. The sweep has a kill switch (c9a065be2e1). ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 disables it, matching the existing ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention. Of the three new behaviours, the other two were already field-recoverable without a build — the idle bound is an env passthrough an operator can raise (use a large value, not 0), and the quit close is bounded at TEARDOWN_CLOSE_TIMEOUT_MS = 5_000 inside the 20 s teardown deadline. The sweep was the only one whose failure modes (closing a daemon it should not, or spawning one process per stale name on a profile holding hundreds) were revert-only. macOS caveat: a Finder-launched Orca does not see shell env, so both this flag and an AGENT_BROWSER_IDLE_TIMEOUT_MS override need launchctl setenv or a terminal launch.

Cleanups from the same rounds: dropped the dead .catch(() => {}) on sweepOrphanedSessions, collapsed the two identical sweep timeouts into one SWEEP_TIMEOUT_MS, dropped the never-read success?: unknown from SessionListEnvelope, and deleted two tests that only re-exercised settleTeardownWithinDeadline (already pinned in quit-teardown-deadline.test.ts).

Why

agent-browser is a client/daemon CLI. Orca only ever spawns the short-lived client; the client forks a persistent daemon Orca holds no handle on. Reproduced directly against agent-browser 0.27.0: a freshly spawned daemon is PPID 1 immediately, with the whole Chrome tree under it. Per its README the daemon self-terminates only when AGENT_BROWSER_IDLE_TIMEOUT_MS is set — and Orca set it nowhere. So nothing bounded a daemon's life: no idle timeout, no max lifetime, no memory cap, no health check, no reaper.

Evidence for each leak path:

  • Idle bound never set: agent-browser-process-environment.ts returned inheritedEnv untouched on win32 and on the socket-dir-override path.
  • Quit leak: index.ts:3487 (pre-fix) called destroyAllSessions() unawaited while every neighbour was a settleTeardownWithinDeadline member; destroySession spawns a fresh agent-browser --session X close child and waits on it (agent-browser-bridge.ts:2465), so every open tab's daemon survived a normal quit.
  • Headless in-run leak: orca-runtime-browser.ts:2062 takes the offscreen branch and calls only offscreen.closeTab, which never reached the bridge. A long-lived orca serve opening and closing 50 pages a day leaked all 50 daemons; nothing in the original commit closed them.
  • Crash leak: closeStaleAgentBrowserSession only reset the single name a new tab was reusing.

Reported impact (#16367): two orphans at ~25.6 GiB and ~7.2 GiB RSS drove a 64 GiB cgroup to its limit until Orca, Claude, Codex and Chrome all entered uninterruptible D state under headless orca serve.

10 minutes for the idle bound is >6× the bridge's 90 s EXEC_TIMEOUT_MS, so it cannot cut a command, a retry chain, or a normal think-gap between two user commands, while capping an abandoned daemon at minutes instead of days.

Corrections to the earlier analysis

  • The orcad close-before-open was justified from the wrong path, and is gone. It was reasoned about as if this session behaved like the pane bridge, which passes --cdp and therefore really does hold a port that dies with its Orca. The orcad session passes only --session and --profile, so a surviving daemon is not bound to the dead process and is reusable as-is. Closing it unconditionally destroyed a live remote browser on every provider start — the exact outcome exempting this path from the idle bound was meant to prevent.
  • getOffscreenBrowserBackend()?.destroyAll?.() does not have the same bug: it is declared void and is fully synchronous. No barrier entry needed.
  • fix(memory): bound OOM-prone accumulators across Orca #10179's queue bounds are irrelevant here — they bound Orca's own main-process heap, while the 25.6 GiB was daemon RSS.
  • /tmp/orca-ab-* does not grow unboundedly: the key is sha256(userDataPath), one stable dir per profile.
  • HAR capture never auto-stopping remains the prime suspect for where the 25 GiB accumulates inside the daemon, but that is inference from code, not a measurement.

Risk & Trade-offs

Merge risk: Medium-high. The one change that could destroy a remote user's live browser is fixed and pinned by tests, so what remains is an unexercised Windows path and a headline memory number attributed to HAR capture by reading code rather than by measuring the leaking process. (Was High; what moved it: the orcad close-on-every-start bug is fixed in a73395ba43c, and the sweep — the only new behaviour whose failure modes were revert-only — now has the ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 kill switch.)

What could go wrong

  • The orcad daemon is closed on every provider start, taking a remote user's live browser and every tab with it. Fixed and pinned (a73395ba43c). start() now probes for an active tab and returns it untouched; only a name that answers nothing is closed and reopened. Both new tests were verified to fail against the old behaviour — one asserts no close and no open is issued when a session answers, the other that a silent name is still reclaimed.
  • Windows behaves differently from what was tested, because Windows was still not run. Windows deliberately gets the idle timer and no start-up sweep (named pipes make socket-dir ownership unprovable, and a machine-wide sweep could close another process's daemons). That design is defensible, but zero Windows runs happened, so the idle-env change on win32 — a path that previously returned the inherited env untouched — is unexercised. What would surface it: a Windows user's browser tab failing to start, or daemons surviving a crash for the full 10 minutes. This is now the largest single unknown in the PR; the run that would close it is spelled out under Testing.
  • agent-browser's notion of "idle" is not what we think it is, and long sessions get cut. The 10-minute bound is built on the tool's README plus one manual run against 0.27.0. If the daemon measures from process start rather than last command, any session older than 10 minutes gets its daemon killed and respawned. Bounded and self-healing — execAgentBrowser reasserts --cdp on every call, so the session comes back — but a user would see a browser tab reset mid-task. No automated test can pin this without the real binary; the Windows run below would settle it as a side effect.
  • A daemon idles out mid-work and the user loses an in-flight HAR capture, silently. This is not hypothetical — it is a known, accepted gap (see below). Any capture spanning a >10-minute gap between bridge commands is gone, and nothing tells the user. Network intercept routes are replayed onto the replacement daemon, so only HAR is affected. Likelihood: low for interactive use (10 min is >6× the 90 s exec timeout), realistic for a long unattended orca serve. What would surface it: an empty or truncated HAR with no error.
  • Quit hangs longer than before on a wedged daemon. destroyAllSessions() used to be fire-and-forget; quit now waits on it. Each close is capped at 5 s and the whole thing sits inside the existing 20 s teardown barrier, so the worst case is bounded — but quit is measurably slower when a daemon will not close.
  • The start-up sweep closes a daemon it should not have. Two narrow paths remain: a TOCTOU window between isSessionLive() and the close spawn, and a profile with hundreds of stale orca-tab- names producing hundreds of sequential subprocess spawns after launch. Both are low-impact (the session respawns; the sweep is unawaited and off the critical path), and both are now switchable off in the field with ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 rather than needing a revert — but the spawn count is still uncapped when the sweep is on.
  • A wedged-but-alive orcad session is now reused instead of reclaimed. The reuse probe treats "an active tab answers" as proof the daemon is healthy. A daemon that lists tabs but cannot drive them would be reused rather than replaced, where the old code would have blown it away. Judged the right trade: a false "healthy" costs a failed command, a false "dead" cost the user their whole browser.

What a user gets in exchange

The reported failure in #16367 stops happening: two orphaned daemons at ~25.6 GiB and ~7.2 GiB RSS drove a 64 GiB cgroup to its limit until Orca, Claude, Codex and Chrome all went into uninterruptible D state. Every orphan class named in that issue is now bounded at 10 minutes, and three of the four are closed deterministically instead — daemons die on quit, on headless page close, and on the next start-up after a crash. Before this, an orca serve opening and closing 50 pages a day leaked all 50 daemons, forever, with nothing in the codebase capable of reclaiming them. The idle bound is the only one that survives SIGKILL, which matters precisely because a cgroup at its memory limit kills Orca outright and no teardown code runs at all.

Trade-offs accepted

  • The orcad external-Chromium daemon is deliberately left unbounded, and now also left running. It owns a remote user's whole Chromium tree; idling it out — or closing it on start — would close their live browser and every tab in it. It is reclaimed only when its session name answers nothing. This means one daemon class still has no SIGKILL-proof bound, and a genuinely wedged-but-responsive one is not reclaimed.
  • In-flight HAR capture lost to an idle-out fails silently, not loudly. Only the dropped-network-route half of that review finding was fixed. Reconstructing a capture on a fresh daemon has no existing machinery, and hard-erroring would break callers whose capture was fine. Not fixed. Owed as a follow-up.
  • Windows gets no deterministic reclamation at all — only the timer. Chosen over a machine-wide sweep that could kill another process's daemons. Windows is the weakest-covered platform here by design and by testing.
  • fix(browser): retire helpers with page owners #16564's idempotent serve SIGINT/SIGTERM handlers were declined and belong in their own PR — today's process.once means a beforeunload veto on the first signal makes a second SIGTERM a no-op. Real, unfixed, owed as a separate PR.
  • fix(browser): retire helpers with page owners #16564's concurrency-4 close fanout and 35 s supervisor grace were declined in favour of the 5 s per-close bound. Our close set is small; fix(browser): retire helpers with page owners #16564's own body concedes >16 retirements can still blow the 20 s barrier.
  • Env snapshot shape changed on two paths. createAgentBrowserProcessEnvironment now returns a shallow copy on win32 and on the mkdir-failure path, where it previously returned the live process.env reference. The bridge snapshots once in its constructor, so post-construction PATH/DISPLAY mutations are not picked up on those two paths. Both mutators are macOS/Linux start-up steps, so this is likely inert — but the ordering was never verified.
  • The Linux headless orca serve topology from the issue was never run against the built app. The owner-boundary fix that closes that exact leak is covered by unit tests only.

How to back it out

Clean revert, and the riskiest single behaviour can be disabled without one (ORCA_DISABLE_AGENT_BROWSER_SWEEP=1; on macOS via launchctl setenv or a terminal launch, since a Finder-launched Orca does not see shell env). No schema, no migration, no persisted state — the only durable artifact is the per-profile socket directory under /tmp/orca-ab-*, which is keyed on sha256(userDataPath) and was already there before this PR. Reverting restores the previous (leaking) behaviour immediately with no cleanup step.

Linked Issue

Fixes #16367

This closes the reported failure: every class of orphan named in the issue is now bounded (idle timer), and three of the four are also closed deterministically (quit barrier, headless close, start-up sweep). Two known gaps are documented under Review rather than left implied — Windows has no deterministic reclamation, only the 10-minute timer; and an in-flight HAR capture lost to an idle-out is still lost silently.

Visual Proof

N/A — every change is in the main process and in a detached CLI daemon's lifetime (process env, quit teardown, page-close wiring, start-up sweep); nothing renders and no user-facing copy, layout, or interaction changes.

Testing

Real output from the fix rounds (worktree /tmp/fixwt-16588, head a73395ba43c):

$ pnpm tc
> node config/scripts/run-typecheck-projects-in-parallel.mjs
(clean exit, no diagnostics)

$ pnpm test src/main/browser src/main/orcad
 Test Files  171 passed | 1 skipped (172)
      Tests  1530 passed | 1 skipped (1531)
   Duration  18.14s

$ npx vitest run --config config/vitest.config.ts \
    src/main/quit-teardown-agent-browser-daemons.test.ts src/main/quit-teardown-deadline.test.ts
 Test Files  2 passed (2)
      Tests  5 passed (5)

$ npx oxlint <13 changed files>
(no output — clean)

The 1 skip is pre-existing: an integration test that needs a real operator executable.

Non-vacuity — each new test was re-run against the pre-fix source and fails there:

x replays intercept routes after the daemon idles out
x bounds every teardown close well inside the quit barrier
x closes a session still being created when everything is torn down
x does not enumerate with a socket directory Orca inherited
x never bounds the daemon that owns the Chromium tree
x reuses a surviving session instead of closing the user's browser   (a73395ba43c)
x reclaims a session that answers nothing, then opens                (a73395ba43c)

The two a73395ba43c tests are the proof for the orcad fix: the first fails against the previous behaviour because close and open were both issued against a session that already had a live tab; the second pins that a silent name is still reclaimed, so the killed-orcad recovery the stable session name exists for is not lost. The kill switch adds two more (enumerates nothing when disabled, even when Orca owns the socket directory, still sweeps when the flag holds any other value) — new-code coverage rather than regression proofs, since the flag did not exist before.

The original commit's it.each idle-timeout test also fails pre-fix on all three platforms, and the quit-teardown source-shape tests fail pre-fix because index.ts had no agentBrowserShutdown binding and destroyAllSessions was not a barrier member. The sweep, orcad-env and offscreen-retirement tests cover new modules, so they are new-code coverage rather than regression proofs.

Reviewer re-ran independently on a clean checkout of the branch: pnpm tc clean and pnpm test src/main/browser src/main/orcad green.

Manual verification against the real agent-browser 0.27.0 binary (macOS):

  • Daemon is PPID 1 immediately — the reported orphaning reproduced.
  • Idle timeout works: the daemon exited, the Chrome tree died with it, socket sidecars cleaned up.
  • The check that mattered: a daemon attached over --cdp (the bridge's mode) idles out without closing the attached browser, so an Orca tab is never killed by its own daemon expiring.
  • Sweep primitive end-to-end: session list --json → {"data":{"sessions":["orca-tab-sweepprobe"]}}; --session X close killed the PPID-1 orphan.

Recommended before merge — still not done. Launch Orca on Windows with AGENT_BROWSER_IDLE_TIMEOUT_MS=20000, open a browser pane, run a command, idle 30 s, confirm the daemon is gone, then run another command in the same pane and confirm it succeeds with the tab still alive. That single run converts three unknowns at once: that the changed win32 env path actually delivers the variable, that "idle" is measured from last command rather than process start, and that an idled-out daemon does not take the Electron webview with it.

Also not verified end-to-end: the Linux headless orca serve topology from the issue (a long-lived serve opening/closing many pages) was never run against the built app — the headless owner-boundary fix is covered by unit tests only.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Credit

Jinwoo Hong (@Jinwoo-H), #16564 identified the headless owner-boundary gap this PR originally missed entirely, and he was first and right on three separate points:

  1. Owner-boundary retirement in the headless path. In orca serve, OffscreenBrowserBackend.closeTab destroys the page without ever retiring its orca-tab-<pageId> daemon, and it unregisters the guest first, which would defeat onTabClosed's webContentsId lookup even if it were wired up. Our original commit reclaimed that leak only via the 10-minute idle timer. The fix here is inherited from @Jinwoo-H's fix(browser): retire helpers with page owners #16564: the getAgentBrowserBridge hook and awaiting the bridge before unregisterGuest are his design.
  2. A bounded teardown close. He bounded agent-browser close at 5 s rather than letting it inherit the bridge's 90 s exec timeout inside a 20 s quit barrier. Our original commit moved destroyAllSessions into the barrier while leaving the 90 s timeout — a wedged daemon would have hung quit for the whole deadline. TEARDOWN_CLOSE_TIMEOUT_MS = 5_000 is taken from fix(browser): retire helpers with page owners #16564.
  3. destroyAllSessions must cover sessions still being created. The union over sessions / pendingSessionCreation / pendingSessionDestruction is his fix, adopted here.

His judgement that a PPID-1 helper is normal, and that cleanup must key on session identity rather than pid or process name, matches the conclusion our sweep reached independently — he did not need our analysis to get there. The two PRs are complementary rather than competing: #16564 is the deterministic in-run retirement; this one adds the bound that survives a SIGKILL (which #16564 explicitly declines: "deliberately adds no … idle reaper"). A cgroup at its memory limit kills Orca outright and no teardown code of either PR runs.

Fixed this round

  • The orcad close-before-open destroyed a live remote browser on every provider start. Not a crash-only path — start() runs on every provider start. Fixed in a73395ba43c by probing for an active tab and reusing it; only a name that answers nothing is reclaimed. This was the finding that had held the merge risk at High.
  • The sweep had no field remedy short of a revert. Fixed in c9a065be2e1 with ORCA_DISABLE_AGENT_BROWSER_SWEEP=1.

Declined, with reasons

  • fix(browser): retire helpers with page owners #16564's idempotent serve SIGINT/SIGTERM handlers (registerServeSignalHandlers). Adjacent and worth taking separately — today's process.once means a beforeunload veto on the first signal makes a second SIGTERM a no-op. It touches serve process lifecycle rather than the daemon-orphan path, so it belongs in its own PR, not in this diff. Not fixed by this PR.
  • fix(browser): retire helpers with page owners #16564's concurrency-4 close fanout and 35 s supervisor grace. fix(browser): retire helpers with page owners #16564's own body concedes >16 retirements can still exceed the 20 s barrier; the 5 s per-close bound gives the barrier guarantee without the fanout. Our close set is small.
  • Failing loudly when an in-flight HAR capture is lost to an idle-out. Only the dropped-network-route half of that finding was fixed (routes are replayed). Reconstructing an in-flight HAR capture on a fresh daemon has no existing machinery, and hard-erroring would break callers whose capture was fine. A HAR capture that spans a >10-minute idle gap is still lost silently.
  • Keepalive ping / self-heal in resolveTargetPage for the orcad path. Removing the bound from that daemon removes the failure mode outright with strictly less code; a periodic tab list would add unbounded subprocess churn per registered page.
  • Reverting createAgentBrowserProcessEnvironment to a live process.env reference on win32 and the mkdir-failure path. See residual risk 2 below; reverting would reintroduce cross-platform inconsistency for no observed defect.

Residual risk

  1. Idle semantics are not proven. agent-browser's idle definition comes from its README plus one manual run against 0.27.0. If "idle" were measured from daemon start rather than last command, the 10-minute bound would cut long sessions. No automated coverage can pin this without the real binary — the Windows run recommended under Testing would settle it.
  2. Env snapshot shape changed. The function now returns a shallow copy on win32 and on the mkdir-failure path, where it previously returned the live process.env reference. The bridge snapshots it once in its constructor, so post-construction mutations (hydrate-shell-path.ts:395 sets PATH, ensure-virtual-display.ts:129/165 sets DISPLAY) are not picked up on those two paths. Both mutators are macOS/Linux start-up steps and the Linux success path already copied, so this is likely inert — but the ordering was not verified.
  3. Start-up sweep has no cap on list size. A profile with hundreds of stale names means hundreds of sequential agent-browser spawns after launch. Off the main thread and unawaited, so latency-invisible, but unbounded subprocess churn. Disableable with ORCA_DISABLE_AGENT_BROWSER_SWEEP=1; still uncapped when on.
  4. Narrow TOCTOU between isSessionLive() and the close spawn: a tab created in that window could have its brand-new daemon closed. Low impact — execAgentBrowser reasserts --cdp on every invocation, so the session simply respawns.
  5. Windows is the weakest-covered platform here, by design and by testing. No sweep, so a Windows crash leaves per-tab daemons for the full idle window with no deterministic reclamation — and the win32 idle-env path itself has still never been run.
  6. Known trade-off: if a helper daemon idles out while the bridge holds an active HAR capture, that capture is lost on the next command's fresh daemon. Network intercept routes are now replayed (see What Changed Improve terminal font rendering, per-pane zoom, and UI zoom #7); HAR capture is not.
  7. The orcad reuse probe trusts a tab listing as a health check. A daemon that answers tab but cannot drive its Chromium is reused rather than replaced. Deliberate: a false "dead" verdict costs the user their entire browser, a false "healthy" one costs a failed command.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Cross-platform: idle bound applies on all three platforms; sweep is macOS/Linux only, with the reasoning stated above. No new direct child_process use — the sweep goes through runProcess.
  • Kill switch: ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 disables the start-up sweep. On macOS a Finder-launched Orca does not see shell env, so this flag (and any AGENT_BROWSER_IDLE_TIMEOUT_MS override) needs launchctl setenv or a terminal launch. To relax the idle bound rather than remove it, set a large value — not 0.
  • SSH / remote: the orcad external-Chromium path is the remote execution host's browser; it is excluded from the idle bound and, as of a73395ba43c, never closed while it still answers, so a remote user's live browser is not closed by an Orca restart. No wire-format change, so no mixed-version concern.
  • Backwards compatibility: an operator-set AGENT_BROWSER_IDLE_TIMEOUT_MS or AGENT_BROWSER_SOCKET_DIR is preserved untouched.
  • Performance: the point of the change. Quit gains up to 5 s per wedged daemon, bounded inside the existing 20 s barrier.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test pass locally; pnpm build left to CI

`agent-browser` is a client/daemon CLI. Orca only ever spawns the short-lived
client; that client forks a daemon Orca holds no handle on, which reparents to
pid 1 immediately. Nothing in Orca reclaimed it, so a crashed or SIGKILL'd run
left one daemon per browser tab alive forever — two of them at ~25.6 GiB and
~7.2 GiB RSS saturated a 64 GiB cgroup under headless `orca serve`.

Three fixes, in order of how much they cover:

1. Set `AGENT_BROWSER_IDLE_TIMEOUT_MS` on both spawn paths (the bundled-binary
   bridge and the orcad external-Chromium provider). This is the only bound
   that survives every way Orca can die, including SIGKILL, where no teardown
   code ever runs. 10 minutes: >6x the bridge's 90s `EXEC_TIMEOUT_MS`, so it
   can never cut a command, a retry chain, or an ordinary gap between two user
   commands, while capping an abandoned daemon at minutes instead of days.
   Verified against agent-browser 0.27.0: an idle daemon exits and takes its
   Chromium tree and socket sidecar files with it, and a daemon attached over
   `--cdp` (the bridge's case) leaves the attached browser running, so an Orca
   tab is never closed by its daemon idling out.

2. Await `destroyAllSessions()` in the will-quit teardown barrier. It was
   fire-and-forget and the only browser member missing from
   `settleTeardownWithinDeadline`; each session's close is its own
   agent-browser child taking hundreds of ms, so `app.quit()` won.

3. Sweep daemons a previous run left behind, using agent-browser's own
   `session list` / `close` rather than a pid walk (see `windows-pty-job.ts`
   for why walking your own orphans is guesswork). `closeStaleAgentBrowserSession`
   only ever reset the one name a new tab was about to reuse. The sweep runs
   only when `AGENT_BROWSER_SOCKET_DIR` is set, because that private
   per-profile directory is what proves the enumeration can only see this
   Orca profile's daemons; it is never set on Windows, so Windows gets no
   enumeration rather than a machine-wide sweep that could close a daemon Orca
   does not own. Windows stays bounded by the idle timeout, which needs no
   ownership proof. orcad's session name is stable across runs, so it closes
   that one name at start instead — a killed orcad's daemon would otherwise be
   reused while still holding the previous run's Chromium on a dead serve port.

Where the 25 GiB went is inference from code, not a measurement: `captureStart`
sets `activeCapture` and only an explicit `captureStop` ends it, so a HAR
capture in a daemon living for days is unbounded. Not claimed as proven; the
idle bound caps it either way.

Not re-landed: the queue bounds from #10179 (reverted by #10255) bound Orca's
own main-process heap, not the daemon's RSS, so they do not address this report.

Also true but left alone: the 3-strike breaker's `destroySession` is an
unawaited call whose `close` is `catch {}`-swallowed, and it only fires while a
command is in flight — an idle-but-bloated daemon is never noticed. The idle
timeout now bounds that case. `getOffscreenBrowserBackend()?.destroyAll?.()` is
declared `void` and fully synchronous, so unlike `destroyAllSessions` it has no
promise to lose and needs no barrier entry.
… owns

Review follow-ups on the agent-browser orphan fix.

- Never idle-bound the orcad external-Chromium daemon: it owns the user's
  remote browser, so the 10-minute bound closed a live session and every tab
  in it. Per-tab helper daemons keep the bound; the stable session name plus
  the `close` in start() is what reclaims a killed orcad's Chromium tree.
- Retire a page's daemon from the headless offscreen backend, which is the
  only place `orca serve` closes a page and never reached the bridge. Credit
  to @Jinwoo-H (#16564) for identifying this owner-boundary gap.
- Bound the teardown close at 5s so the will-quit barrier member cannot
  inherit the 90s exec timeout, and close sessions still being created.
- Gate the startup sweep on a socket directory Orca derived itself; an
  inherited AGENT_BROWSER_SOCKET_DIR is no proof of per-profile ownership.
- Replay a session's network routes when the daemon idled out between two
  commands, instead of silently serving unstubbed requests.
Of the three behaviours this PR adds, two are already recoverable in the field
without a build: the idle bound is an env passthrough an operator can raise, and
the quit close is bounded by its own timeout inside the teardown deadline. The
startup sweep was the exception — it fires unconditionally, and if it closes a
daemon it should not, or spawns one process per stale name on a profile holding
hundreds, the only remedy was a revert.

ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 turns it off, matching the existing
ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention.

Note for anyone reaching for it on macOS: a Finder-launched Orca does not see
shell env, so it needs launchctl setenv or a terminal launch.
@nwparker

Copy link
Copy Markdown
Contributor Author

De-risking review: one blocking question, one flag added

1. BLOCKING — start() unconditionally closes the remote user's browser

ExternalChromiumBrowserSession.start() (src/main/orcad/external-chromium-browser-session.ts:90-95) now does:

await mkdir(this.profilePath, { recursive: true })
// Why: the session name is stable across runs, so a daemon a killed orcad left behind is
// reused here — still holding the previous run's Chromium on a now-dead serve port (#16367).
await this.stop()
await this.run(['open', 'about:blank'])

stop() runs agent-browser close, which kills the daemon and its whole Chromium tree. The comment justifies the crash-recovery case, but start() is reached from ExternalChromiumBrowserProcess.start() → startProvider() on every orcad provider start, not only after a crash.

This directly contradicts the principle used elsewhere in this same PR: the orcad path is exempted from AGENT_BROWSER_IDLE_TIMEOUT_MS precisely because idling it out "would close their live browser and every tab in it". An unconditional close on the start path reintroduces exactly that, and docs/reference/ssh-execution-boundary.md makes this the execution host's user-visible state.

I did not change it, because whether a healthy previous-run Chromium can be alive at that point depends on the orcad provider lifecycle and I would be guessing. Two acceptable resolutions:

  • Narrow it — close only when a session exists and its recorded serve port is dead.
  • Accept it — state in the PR body that an orca serve restart intentionally closes the remote browser.

Right now it is neither, and no kill switch covers it. Worth settling before merge.

2. Added: ORCA_DISABLE_AGENT_BROWSER_SWEEP (c9a065b)

Of the three new behaviours, two were already recoverable without a build:

  • Idle bound — createAgentBrowserProcessEnvironment only sets AGENT_BROWSER_IDLE_TIMEOUT_MS when the inherited env lacks one, so an operator can raise it and get pre-PR behaviour. (Use a large value, not 0 — "0" passes the .trim() guard and reaches the daemon, and whether it reads as "disabled" or "immediately" is unverified.)
  • Quit close — already bounded at TEARDOWN_CLOSE_TIMEOUT_MS = 5_000 per close inside the 20 s teardown deadline.

The startup sweep was the exception: unconditional, and its failure modes (closing a daemon it should not via the TOCTOU window; one sequential spawn per stale name on a profile with hundreds) were revert-only. Now ORCA_DISABLE_AGENT_BROWSER_SWEEP=1, matching the existing ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention. 171 files / 1532 tests green.

macOS caveat now in the code comment: a Finder-launched Orca does not see shell env, so both this flag and the idle-timeout passthrough need launchctl setenv or a terminal launch.

3. The single Windows run worth doing before merge

Launch Orca on Windows with AGENT_BROWSER_IDLE_TIMEOUT_MS=20000 in its environment — the PR's own passthrough makes this legal and turns an 11-minute wait into ~1 minute:

  1. Open a browser pane, run one browser command.
  2. Confirm agent-browser session list --json (same env) shows orca-tab-<pageId> and the daemon is in the process table.
  3. Idle 30 s. Confirm the daemon is gone.
  4. Run another browser command in the same pane — must succeed, tab must still be alive.

That converts three unknowns at once: the changed win32 env path actually delivers the variable (the specific regression surface, since win32 previously returned inheritedEnv untouched); idle is measured from last command rather than process start; and an idled-out daemon does not take the Electron webview with it — the CDP-attach property so far verified only on macOS.

It does not prove crash recovery on Windows, because Windows deliberately has no sweep. Honest framing is "Windows is idle-timer-only, and this proves the idle timer works there."

On splitting

Recommend against. The idle timeout alone is genuinely separable and is the only SIGKILL-proof bound — but the HIGH grade comes entirely from the unexercised win32 env path and the unproven idle semantics, and both live inside that piece. Splitting concentrates the risk into the split PR while leaving the safe parts behind. Do the Windows run instead.

Revert path confirmed clean: no schema, no migration, no wire change; /tmp/orca-ab-* is keyed on sha256(userDataPath) and pre-dates the PR.

…user's

start() closed the daemon before every open, killing the Chromium tree with it.
That runs on every provider start, not just after a crash — so an `orca serve`
restart took the remote user's browser and every tab in it.

The justification was borrowed from the pane bridge, which passes --cdp and so
really does hold a port that dies with its Orca. This session passes only
--session and --profile: nothing binds it to the old process, and the daemon
owns its Chromium independently. A survivor is reusable as-is.

start() now probes for an active tab first and returns it untouched. Only a name
that answers nothing gets closed and reopened — which is still the killed-orcad
case the stable session name exists to recover.

This matters more as orcad becomes the backend the remote host runs on: the
browser it manages belongs to a user, not to the process that happens to be
driving it this minute. It is also the same principle that already exempts this
path from AGENT_BROWSER_IDLE_TIMEOUT_MS.
@nwparker
nwparker marked this pull request as ready for review August 26, 2026 23:26
@coderabbitai

coderabbitai Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The changes add Agent Browser idle-timeout configuration and socket-directory ownership metadata. The bridge now tracks command activity, restores interception after daemon expiry, reclaims orphaned sessions, and closes sessions during shutdown. External Chromium sessions reuse active tabs and recover unresponsive sessions. Application startup invokes orphan sweeping, and shutdown awaits session cleanup. Tests cover environment propagation, idle handling, orphan filtering, session recovery, teardown timing, and guest-page unregistration.

Merge Risk: 🔵 Low · up to ffe53

Idle browser daemon replacement can currently stop an active HAR capture from recording subsequent traffic, potentially producing incomplete or empty HAR files. The PR is otherwise mergeable, but HAR capture restoration should be addressed or explicitly accepted by the owner.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: bounding the lifetime of agent-browser daemons and improving teardown reliability.
Description check ✅ Passed The description is complete and follows the repository template. It explains the change, rationale, linked issue, testing, risks, platform limitations, visual proof, and checklist status.
Linked Issues check ✅ Passed The changes address issue #16367 by adding idle bounds, quit and page-close cleanup, startup sweeping of stale Orca-owned sessions, and recovery after daemon idle-out. The documented Windows and built…
Out of Scope Changes check ✅ Passed The production and test changes are related to agent-browser daemon lifecycle, orphan cleanup, session ownership, teardown, and safe external Chromium reuse. No unrelated code changes are evident.
Full details: Linked Issues check

Explanation

The changes address issue #16367 by adding idle bounds, quit and page-close cleanup, startup sweeping of stale Orca-owned sessions, and recovery after daemon idle-out. The documented Windows and built-app Linux limitations do not prevent compliance with the Linux-focused issue objectives.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/main/browser/agent-browser-process-environment.ts (1)

7-23: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reduce this comment. Keep the lifetime rationale concise and move operational detail to issue documentation. As per coding guidelines: “DO NOT: be verbose” and “BE CONCISE. 1 LINE if possible.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 617e0dec-44ca-4d33-9cd2-6d39cff593f6

📥 Commits

Reviewing files that changed from the base of the PR and between 9135b6f and ffe5388.

📒 Files selected for processing (12)
  • src/main/browser/agent-browser-bridge-command-transport.test.ts
  • src/main/browser/agent-browser-bridge-session-lifecycle.test.ts
  • src/main/browser/agent-browser-bridge.ts
  • src/main/browser/agent-browser-orphan-sweep.test.ts
  • src/main/browser/agent-browser-orphan-sweep.ts
  • src/main/browser/agent-browser-process-environment.test.ts
  • src/main/browser/agent-browser-process-environment.ts
  • src/main/browser/offscreen-browser-backend-lifecycle.test.ts
  • src/main/index.ts
  • src/main/orcad/external-chromium-browser-session.test.ts
  • src/main/orcad/external-chromium-browser-session.ts
  • src/main/quit-teardown-agent-browser-daemons.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.

Comment on lines +2556 to +2566
private reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void {
if (
this.agentBrowserIdleTimeoutMs === null ||
Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs
) {
return
}
session.initialized = false
if (session.activeInterceptPatterns.length > 0) {
this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- target outline ---'
ast-grep outline src/main/browser/agent-browser-bridge.ts --match 'reinitializeIfDaemonIdledOut' --view expanded
printf '%s\n' '--- target lifecycle references ---'
rg -n -C 10 'reinitializeIfDaemonIdledOut|activeCapture|network har|captureStart|captureStop|pendingInterceptRestore|lastCommandAt' src/main/browser/agent-browser-bridge.ts

Repository: stablyai/orca

Length of output: 16222


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- applicable conventions ---'
cat /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions/src-main-daemon.md
cat /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions/src.md
printf '%s\n' '--- daemon execution and restoration ---'
sed -n '2575,2675p' src/main/browser/agent-browser-bridge.ts
printf '%s\n' '--- capture API and version references ---'
rg -n -C 8 'captureStart|captureStop|network.*har|agent-browser|0\.27\.0' package.json package-lock.json pnpm-lock.yaml yarn.lock src tests 2>/dev/null || true

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- applicable daemon convention ---'
sed -n '1,220p' /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions/src-main-daemon.md
printf '%s\n' '--- bridge execution path ---'
sed -n '2575,2675p' src/main/browser/agent-browser-bridge.ts
printf '%s\n' '--- package manifests containing agent-browser ---'
rg -n -C 3 'agent-browser|0\.27\.0' package.json pnpm-lock.yaml package-lock.json yarn.lock bun.lockb 2>/dev/null || true
printf '%s\n' '--- targeted capture tests ---'
rg -n -C 8 'capture(Start|Stop)|network.*har|activeCapture|idle' src/main/browser --glob '*.{ts,tsx}' 2>/dev/null || true

Repository: stablyai/orca

Length of output: 49853


🌐 Web query:

agent-browser 0.27.0 "network har start" daemon session implementation

💡 Result:

In agent-browser v0.27.0, the network HAR capture functionality is implemented as part of the tool's client-daemon architecture [1][2]. The daemon, which manages the browser state over the Chrome DevTools Protocol (CDP), handles the network events required to generate HAR 1.2 files [1][2]. Network HAR recording is managed via the network har CLI commands, which communicate specific actions to the daemon [1]: 1. network har start [--content ]: This command tells the daemon to begin tracking network traffic [3][4]. It supports configurable content modes: - text (default): Embeds text-based response bodies (JSON, HTML, JS, etc.) up to 2 MB per body [3]. - all: Embeds all response bodies, with binary content base64-encoded [3][4]. - none: Records only metadata, excluding all response bodies [3][4]. 2. network har stop [path]: This command instructs the daemon to stop tracking, finalize the HAR 1.2 data, and save it to the specified path [1]. If no path is provided, the daemon writes the file to a default location (typically ~/.agent-browser/tmp/har/) [1]. Implementation Details: - Daemon Integration: The har_start and har_stop actions are processed by the Rust daemon, which uses CDP events (such as Network.requestWillBeSent and responseReceived) to collect accurate timing, headers, and body data [1]. - Session Persistence: Because the agent-browser uses a persistent daemon-client model, HAR recording remains active across navigation events within a session [1][2]. The har_stop action is explicitly handled to avoid triggering redundant browser launches [1]. - Utility: These HAR files are often used in conjunction with skills like derive-client to reverse-engineer APIs by analyzing the captured request/response data offline [5][6].

Citations:


🌐 Web query:

site:github.com/vercel-labs/agent-browser "har_stop" "har_start" Rust daemon

💡 Result:

The terms har_start and har_stop refer to specific command actions within the Vercel Labs agent-browser project, a tool that uses a Rust-based daemon to orchestrate browser automation [1][2][3]. These commands are used to manage the recording of network traffic in the HAR (HTTP Archive) 1.2 format: 1. har_start: This command initiates the recording of network requests during a browser session [1][2][3]. It sets a flag within the daemon to begin tracking network activity [2][3]. 2. har_stop: This command concludes the recording session and, in current implementations, is designed to save the captured network traffic to a specified file path [1][4]. If no path is provided, it may default to a temporary directory [1]. The daemon infrastructure routes these actions through specific handlers (e.g., handleHarStart and handleHarStop in the codebase) that interact with the underlying browser context, often utilizing Playwright-based browser management [2][5]. While these actions are defined in the project's protocol and CLI interface, functionality has evolved through development cycles, moving from initial stubs to integrated features that support exporting full HAR 1.2 files with request and response metadata [1][5]. You can typically invoke these through the CLI via commands such as agent-browser network har start and agent-browser network har stop [path] [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
tmp=$(mktemp)
curl -fsSL 'https://api.github.com/repos/vercel-labs/agent-browser/git/trees/v0.27.0?recursive=1' > "$tmp"
printf '%s\n' '--- files related to HAR ---'
jq -r '.tree[] | select(.path | test("har|action|daemon|network"; "i")) | .path' "$tmp" | head -100
printf '%s\n' '--- tag metadata ---'
curl -fsSL 'https://api.github.com/repos/vercel-labs/agent-browser/git/ref/tags/v0.27.0' |
  jq '{ref: .ref, type: .object.type, sha: .object.sha}'

Repository: stablyai/orca

Length of output: 595


🏁 Script executed:

#!/bin/bash
set -e
base='https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.27.0'
for file in cli/src/native/network.rs cli/src/native/daemon.rs cli/src/native/actions.rs; do
  printf '\n--- %s ---\n' "$file"
  curl -fsSL "$base/$file" |
    nl -ba |
    rg -n -C 12 'har|HAR|Network\.enable|requestWillBeSent|responseReceived' || true
done

Repository: stablyai/orca

Length of output: 585


🏁 Script executed:

#!/bin/bash
set -e
base='https://raw.githubusercontent.com/vercel-labs/agent-browser/v0.27.0'
for file in cli/src/native/network.rs cli/src/native/daemon.rs cli/src/native/actions.rs; do
  printf '\n--- %s ---\n' "$file"
  curl -fsSL "$base/$file" |
    rg -n -C 12 'har|HAR|Network\.enable|requestWillBeSent|responseReceived' || true
done

Repository: stablyai/orca

Length of output: 50369


Restore HAR capture after idle replacement. When activeCapture is true, the replacement agent-browser v0.27.0 daemon starts with har_recording = false; post-replacement traffic is ignored, and network har stop writes an empty HAR. Restore network har start before processing the next command.

@nwparker
nwparker merged commit 88ecc73 into main Aug 26, 2026
93 of 95 checks passed
@nwparker
nwparker deleted the nwparker/win-16367-agent-browser-orphans branch August 26, 2026 23:49
dallascrilley pushed a commit to dallascrilley/orca that referenced this pull request Aug 27, 2026
… teardown runs (stablyai#16367) (stablyai#16588)

* fix(browser): bound the agent-browser daemon lifetime (stablyai#16367)

`agent-browser` is a client/daemon CLI. Orca only ever spawns the short-lived
client; that client forks a daemon Orca holds no handle on, which reparents to
pid 1 immediately. Nothing in Orca reclaimed it, so a crashed or SIGKILL'd run
left one daemon per browser tab alive forever — two of them at ~25.6 GiB and
~7.2 GiB RSS saturated a 64 GiB cgroup under headless `orca serve`.

Three fixes, in order of how much they cover:

1. Set `AGENT_BROWSER_IDLE_TIMEOUT_MS` on both spawn paths (the bundled-binary
   bridge and the orcad external-Chromium provider). This is the only bound
   that survives every way Orca can die, including SIGKILL, where no teardown
   code ever runs. 10 minutes: >6x the bridge's 90s `EXEC_TIMEOUT_MS`, so it
   can never cut a command, a retry chain, or an ordinary gap between two user
   commands, while capping an abandoned daemon at minutes instead of days.
   Verified against agent-browser 0.27.0: an idle daemon exits and takes its
   Chromium tree and socket sidecar files with it, and a daemon attached over
   `--cdp` (the bridge's case) leaves the attached browser running, so an Orca
   tab is never closed by its daemon idling out.

2. Await `destroyAllSessions()` in the will-quit teardown barrier. It was
   fire-and-forget and the only browser member missing from
   `settleTeardownWithinDeadline`; each session's close is its own
   agent-browser child taking hundreds of ms, so `app.quit()` won.

3. Sweep daemons a previous run left behind, using agent-browser's own
   `session list` / `close` rather than a pid walk (see `windows-pty-job.ts`
   for why walking your own orphans is guesswork). `closeStaleAgentBrowserSession`
   only ever reset the one name a new tab was about to reuse. The sweep runs
   only when `AGENT_BROWSER_SOCKET_DIR` is set, because that private
   per-profile directory is what proves the enumeration can only see this
   Orca profile's daemons; it is never set on Windows, so Windows gets no
   enumeration rather than a machine-wide sweep that could close a daemon Orca
   does not own. Windows stays bounded by the idle timeout, which needs no
   ownership proof. orcad's session name is stable across runs, so it closes
   that one name at start instead — a killed orcad's daemon would otherwise be
   reused while still holding the previous run's Chromium on a dead serve port.

Where the 25 GiB went is inference from code, not a measurement: `captureStart`
sets `activeCapture` and only an explicit `captureStop` ends it, so a HAR
capture in a daemon living for days is unbounded. Not claimed as proven; the
idle bound caps it either way.

Not re-landed: the queue bounds from stablyai#10179 (reverted by stablyai#10255) bound Orca's
own main-process heap, not the daemon's RSS, so they do not address this report.

Also true but left alone: the 3-strike breaker's `destroySession` is an
unawaited call whose `close` is `catch {}`-swallowed, and it only fires while a
command is in flight — an idle-but-bloated daemon is never noticed. The idle
timeout now bounds that case. `getOffscreenBrowserBackend()?.destroyAll?.()` is
declared `void` and fully synchronous, so unlike `destroyAllSessions` it has no
promise to lose and needs no barrier entry.

* fix(browser): scope the daemon idle bound and close every daemon Orca owns

Review follow-ups on the agent-browser orphan fix.

- Never idle-bound the orcad external-Chromium daemon: it owns the user's
  remote browser, so the 10-minute bound closed a live session and every tab
  in it. Per-tab helper daemons keep the bound; the stable session name plus
  the `close` in start() is what reclaims a killed orcad's Chromium tree.
- Retire a page's daemon from the headless offscreen backend, which is the
  only place `orca serve` closes a page and never reached the bridge. Credit
  to @Jinwoo-H (stablyai#16564) for identifying this owner-boundary gap.
- Bound the teardown close at 5s so the will-quit barrier member cannot
  inherit the 90s exec timeout, and close sessions still being created.
- Gate the startup sweep on a socket directory Orca derived itself; an
  inherited AGENT_BROWSER_SOCKET_DIR is no proof of per-profile ownership.
- Replay a session's network routes when the daemon idled out between two
  commands, instead of silently serving unstubbed requests.

* fix(browser): give the orphan sweep a kill switch

Of the three behaviours this PR adds, two are already recoverable in the field
without a build: the idle bound is an env passthrough an operator can raise, and
the quit close is bounded by its own timeout inside the teardown deadline. The
startup sweep was the exception — it fires unconditionally, and if it closes a
daemon it should not, or spawns one process per stale name on a profile holding
hundreds, the only remedy was a revert.

ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 turns it off, matching the existing
ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention.

Note for anyone reaching for it on macOS: a Finder-launched Orca does not see
shell env, so it needs launchctl setenv or a terminal launch.

* fix(orcad): reuse a surviving browser session instead of closing the user's

start() closed the daemon before every open, killing the Chromium tree with it.
That runs on every provider start, not just after a crash — so an `orca serve`
restart took the remote user's browser and every tab in it.

The justification was borrowed from the pane bridge, which passes --cdp and so
really does hold a port that dies with its Orca. This session passes only
--session and --profile: nothing binds it to the old process, and the daemon
owns its Chromium independently. A survivor is reusable as-is.

start() now probes for an active tab first and returns it untouched. Only a name
that answers nothing gets closed and reopened — which is still the killed-orcad
case the stable session name exists to recover.

This matters more as orcad becomes the backend the remote host runs on: the
browser it manages belongs to a user, not to the process that happens to be
driving it this minute. It is also the same principle that already exempts this
path from AGENT_BROWSER_IDLE_TIMEOUT_MS.
rahuljangirworks pushed a commit to rahuljangirworks/veer that referenced this pull request Aug 27, 2026
… teardown runs (stablyai#16367) (stablyai#16588)

* fix(browser): bound the agent-browser daemon lifetime (stablyai#16367)

`agent-browser` is a client/daemon CLI. Orca only ever spawns the short-lived
client; that client forks a daemon Orca holds no handle on, which reparents to
pid 1 immediately. Nothing in Orca reclaimed it, so a crashed or SIGKILL'd run
left one daemon per browser tab alive forever — two of them at ~25.6 GiB and
~7.2 GiB RSS saturated a 64 GiB cgroup under headless `orca serve`.

Three fixes, in order of how much they cover:

1. Set `AGENT_BROWSER_IDLE_TIMEOUT_MS` on both spawn paths (the bundled-binary
   bridge and the orcad external-Chromium provider). This is the only bound
   that survives every way Orca can die, including SIGKILL, where no teardown
   code ever runs. 10 minutes: >6x the bridge's 90s `EXEC_TIMEOUT_MS`, so it
   can never cut a command, a retry chain, or an ordinary gap between two user
   commands, while capping an abandoned daemon at minutes instead of days.
   Verified against agent-browser 0.27.0: an idle daemon exits and takes its
   Chromium tree and socket sidecar files with it, and a daemon attached over
   `--cdp` (the bridge's case) leaves the attached browser running, so an Orca
   tab is never closed by its daemon idling out.

2. Await `destroyAllSessions()` in the will-quit teardown barrier. It was
   fire-and-forget and the only browser member missing from
   `settleTeardownWithinDeadline`; each session's close is its own
   agent-browser child taking hundreds of ms, so `app.quit()` won.

3. Sweep daemons a previous run left behind, using agent-browser's own
   `session list` / `close` rather than a pid walk (see `windows-pty-job.ts`
   for why walking your own orphans is guesswork). `closeStaleAgentBrowserSession`
   only ever reset the one name a new tab was about to reuse. The sweep runs
   only when `AGENT_BROWSER_SOCKET_DIR` is set, because that private
   per-profile directory is what proves the enumeration can only see this
   Orca profile's daemons; it is never set on Windows, so Windows gets no
   enumeration rather than a machine-wide sweep that could close a daemon Orca
   does not own. Windows stays bounded by the idle timeout, which needs no
   ownership proof. orcad's session name is stable across runs, so it closes
   that one name at start instead — a killed orcad's daemon would otherwise be
   reused while still holding the previous run's Chromium on a dead serve port.

Where the 25 GiB went is inference from code, not a measurement: `captureStart`
sets `activeCapture` and only an explicit `captureStop` ends it, so a HAR
capture in a daemon living for days is unbounded. Not claimed as proven; the
idle bound caps it either way.

Not re-landed: the queue bounds from stablyai#10179 (reverted by stablyai#10255) bound Orca's
own main-process heap, not the daemon's RSS, so they do not address this report.

Also true but left alone: the 3-strike breaker's `destroySession` is an
unawaited call whose `close` is `catch {}`-swallowed, and it only fires while a
command is in flight — an idle-but-bloated daemon is never noticed. The idle
timeout now bounds that case. `getOffscreenBrowserBackend()?.destroyAll?.()` is
declared `void` and fully synchronous, so unlike `destroyAllSessions` it has no
promise to lose and needs no barrier entry.

* fix(browser): scope the daemon idle bound and close every daemon Orca owns

Review follow-ups on the agent-browser orphan fix.

- Never idle-bound the orcad external-Chromium daemon: it owns the user's
  remote browser, so the 10-minute bound closed a live session and every tab
  in it. Per-tab helper daemons keep the bound; the stable session name plus
  the `close` in start() is what reclaims a killed orcad's Chromium tree.
- Retire a page's daemon from the headless offscreen backend, which is the
  only place `orca serve` closes a page and never reached the bridge. Credit
  to @Jinwoo-H (stablyai#16564) for identifying this owner-boundary gap.
- Bound the teardown close at 5s so the will-quit barrier member cannot
  inherit the 90s exec timeout, and close sessions still being created.
- Gate the startup sweep on a socket directory Orca derived itself; an
  inherited AGENT_BROWSER_SOCKET_DIR is no proof of per-profile ownership.
- Replay a session's network routes when the daemon idled out between two
  commands, instead of silently serving unstubbed requests.

* fix(browser): give the orphan sweep a kill switch

Of the three behaviours this PR adds, two are already recoverable in the field
without a build: the idle bound is an env passthrough an operator can raise, and
the quit close is bounded by its own timeout inside the teardown deadline. The
startup sweep was the exception — it fires unconditionally, and if it closes a
daemon it should not, or spawns one process per stale name on a profile holding
hundreds, the only remedy was a revert.

ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 turns it off, matching the existing
ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention.

Note for anyone reaching for it on macOS: a Finder-launched Orca does not see
shell env, so it needs launchctl setenv or a terminal launch.

* fix(orcad): reuse a surviving browser session instead of closing the user's

start() closed the daemon before every open, killing the Chromium tree with it.
That runs on every provider start, not just after a crash — so an `orca serve`
restart took the remote user's browser and every tab in it.

The justification was borrowed from the pane bridge, which passes --cdp and so
really does hold a port that dies with its Orca. This session passes only
--session and --profile: nothing binds it to the old process, and the daemon
owns its Chromium independently. A survivor is reusable as-is.

start() now probes for an active tab first and returns it untouched. Only a name
that answers nothing gets closed and reopened — which is still the killed-orcad
case the stable session name exists to recover.

This matters more as orcad becomes the backend the remote host runs on: the
browser it manages belongs to a user, not to the process that happens to be
driving it this minute. It is also the same principle that already exempts this
path from AGENT_BROWSER_IDLE_TIMEOUT_MS.
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 28, 2026
… teardown runs (stablyai#16367) (stablyai#16588)

* fix(browser): bound the agent-browser daemon lifetime (stablyai#16367)

`agent-browser` is a client/daemon CLI. Orca only ever spawns the short-lived
client; that client forks a daemon Orca holds no handle on, which reparents to
pid 1 immediately. Nothing in Orca reclaimed it, so a crashed or SIGKILL'd run
left one daemon per browser tab alive forever — two of them at ~25.6 GiB and
~7.2 GiB RSS saturated a 64 GiB cgroup under headless `orca serve`.

Three fixes, in order of how much they cover:

1. Set `AGENT_BROWSER_IDLE_TIMEOUT_MS` on both spawn paths (the bundled-binary
   bridge and the orcad external-Chromium provider). This is the only bound
   that survives every way Orca can die, including SIGKILL, where no teardown
   code ever runs. 10 minutes: >6x the bridge's 90s `EXEC_TIMEOUT_MS`, so it
   can never cut a command, a retry chain, or an ordinary gap between two user
   commands, while capping an abandoned daemon at minutes instead of days.
   Verified against agent-browser 0.27.0: an idle daemon exits and takes its
   Chromium tree and socket sidecar files with it, and a daemon attached over
   `--cdp` (the bridge's case) leaves the attached browser running, so an Orca
   tab is never closed by its daemon idling out.

2. Await `destroyAllSessions()` in the will-quit teardown barrier. It was
   fire-and-forget and the only browser member missing from
   `settleTeardownWithinDeadline`; each session's close is its own
   agent-browser child taking hundreds of ms, so `app.quit()` won.

3. Sweep daemons a previous run left behind, using agent-browser's own
   `session list` / `close` rather than a pid walk (see `windows-pty-job.ts`
   for why walking your own orphans is guesswork). `closeStaleAgentBrowserSession`
   only ever reset the one name a new tab was about to reuse. The sweep runs
   only when `AGENT_BROWSER_SOCKET_DIR` is set, because that private
   per-profile directory is what proves the enumeration can only see this
   Orca profile's daemons; it is never set on Windows, so Windows gets no
   enumeration rather than a machine-wide sweep that could close a daemon Orca
   does not own. Windows stays bounded by the idle timeout, which needs no
   ownership proof. orcad's session name is stable across runs, so it closes
   that one name at start instead — a killed orcad's daemon would otherwise be
   reused while still holding the previous run's Chromium on a dead serve port.

Where the 25 GiB went is inference from code, not a measurement: `captureStart`
sets `activeCapture` and only an explicit `captureStop` ends it, so a HAR
capture in a daemon living for days is unbounded. Not claimed as proven; the
idle bound caps it either way.

Not re-landed: the queue bounds from stablyai#10179 (reverted by stablyai#10255) bound Orca's
own main-process heap, not the daemon's RSS, so they do not address this report.

Also true but left alone: the 3-strike breaker's `destroySession` is an
unawaited call whose `close` is `catch {}`-swallowed, and it only fires while a
command is in flight — an idle-but-bloated daemon is never noticed. The idle
timeout now bounds that case. `getOffscreenBrowserBackend()?.destroyAll?.()` is
declared `void` and fully synchronous, so unlike `destroyAllSessions` it has no
promise to lose and needs no barrier entry.

* fix(browser): scope the daemon idle bound and close every daemon Orca owns

Review follow-ups on the agent-browser orphan fix.

- Never idle-bound the orcad external-Chromium daemon: it owns the user's
  remote browser, so the 10-minute bound closed a live session and every tab
  in it. Per-tab helper daemons keep the bound; the stable session name plus
  the `close` in start() is what reclaims a killed orcad's Chromium tree.
- Retire a page's daemon from the headless offscreen backend, which is the
  only place `orca serve` closes a page and never reached the bridge. Credit
  to @Jinwoo-H (stablyai#16564) for identifying this owner-boundary gap.
- Bound the teardown close at 5s so the will-quit barrier member cannot
  inherit the 90s exec timeout, and close sessions still being created.
- Gate the startup sweep on a socket directory Orca derived itself; an
  inherited AGENT_BROWSER_SOCKET_DIR is no proof of per-profile ownership.
- Replay a session's network routes when the daemon idled out between two
  commands, instead of silently serving unstubbed requests.

* fix(browser): give the orphan sweep a kill switch

Of the three behaviours this PR adds, two are already recoverable in the field
without a build: the idle bound is an env passthrough an operator can raise, and
the quit close is bounded by its own timeout inside the teardown deadline. The
startup sweep was the exception — it fires unconditionally, and if it closes a
daemon it should not, or spawns one process per stale name on a profile holding
hundreds, the only remedy was a revert.

ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 turns it off, matching the existing
ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention.

Note for anyone reaching for it on macOS: a Finder-launched Orca does not see
shell env, so it needs launchctl setenv or a terminal launch.

* fix(orcad): reuse a surviving browser session instead of closing the user's

start() closed the daemon before every open, killing the Chromium tree with it.
That runs on every provider start, not just after a crash — so an `orca serve`
restart took the remote user's browser and every tab in it.

The justification was borrowed from the pane bridge, which passes --cdp and so
really does hold a port that dies with its Orca. This session passes only
--session and --profile: nothing binds it to the old process, and the daemon
owns its Chromium independently. A survivor is reusable as-is.

start() now probes for an active tab first and returns it untouched. Only a name
that answers nothing gets closed and reopened — which is still the killed-orcad
case the stable session name exists to recover.

This matters more as orcad becomes the backend the remote host runs on: the
browser it manages belongs to a user, not to the process that happens to be
driving it this minute. It is also the same principle that already exempts this
path from AGENT_BROWSER_IDLE_TIMEOUT_MS.

(cherry picked from commit 88ecc73)
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
… teardown runs (stablyai#16367) (stablyai#16588)

* fix(browser): bound the agent-browser daemon lifetime (stablyai#16367)

`agent-browser` is a client/daemon CLI. Orca only ever spawns the short-lived
client; that client forks a daemon Orca holds no handle on, which reparents to
pid 1 immediately. Nothing in Orca reclaimed it, so a crashed or SIGKILL'd run
left one daemon per browser tab alive forever — two of them at ~25.6 GiB and
~7.2 GiB RSS saturated a 64 GiB cgroup under headless `orca serve`.

Three fixes, in order of how much they cover:

1. Set `AGENT_BROWSER_IDLE_TIMEOUT_MS` on both spawn paths (the bundled-binary
   bridge and the orcad external-Chromium provider). This is the only bound
   that survives every way Orca can die, including SIGKILL, where no teardown
   code ever runs. 10 minutes: >6x the bridge's 90s `EXEC_TIMEOUT_MS`, so it
   can never cut a command, a retry chain, or an ordinary gap between two user
   commands, while capping an abandoned daemon at minutes instead of days.
   Verified against agent-browser 0.27.0: an idle daemon exits and takes its
   Chromium tree and socket sidecar files with it, and a daemon attached over
   `--cdp` (the bridge's case) leaves the attached browser running, so an Orca
   tab is never closed by its daemon idling out.

2. Await `destroyAllSessions()` in the will-quit teardown barrier. It was
   fire-and-forget and the only browser member missing from
   `settleTeardownWithinDeadline`; each session's close is its own
   agent-browser child taking hundreds of ms, so `app.quit()` won.

3. Sweep daemons a previous run left behind, using agent-browser's own
   `session list` / `close` rather than a pid walk (see `windows-pty-job.ts`
   for why walking your own orphans is guesswork). `closeStaleAgentBrowserSession`
   only ever reset the one name a new tab was about to reuse. The sweep runs
   only when `AGENT_BROWSER_SOCKET_DIR` is set, because that private
   per-profile directory is what proves the enumeration can only see this
   Orca profile's daemons; it is never set on Windows, so Windows gets no
   enumeration rather than a machine-wide sweep that could close a daemon Orca
   does not own. Windows stays bounded by the idle timeout, which needs no
   ownership proof. orcad's session name is stable across runs, so it closes
   that one name at start instead — a killed orcad's daemon would otherwise be
   reused while still holding the previous run's Chromium on a dead serve port.

Where the 25 GiB went is inference from code, not a measurement: `captureStart`
sets `activeCapture` and only an explicit `captureStop` ends it, so a HAR
capture in a daemon living for days is unbounded. Not claimed as proven; the
idle bound caps it either way.

Not re-landed: the queue bounds from stablyai#10179 (reverted by stablyai#10255) bound Orca's
own main-process heap, not the daemon's RSS, so they do not address this report.

Also true but left alone: the 3-strike breaker's `destroySession` is an
unawaited call whose `close` is `catch {}`-swallowed, and it only fires while a
command is in flight — an idle-but-bloated daemon is never noticed. The idle
timeout now bounds that case. `getOffscreenBrowserBackend()?.destroyAll?.()` is
declared `void` and fully synchronous, so unlike `destroyAllSessions` it has no
promise to lose and needs no barrier entry.

* fix(browser): scope the daemon idle bound and close every daemon Orca owns

Review follow-ups on the agent-browser orphan fix.

- Never idle-bound the orcad external-Chromium daemon: it owns the user's
  remote browser, so the 10-minute bound closed a live session and every tab
  in it. Per-tab helper daemons keep the bound; the stable session name plus
  the `close` in start() is what reclaims a killed orcad's Chromium tree.
- Retire a page's daemon from the headless offscreen backend, which is the
  only place `orca serve` closes a page and never reached the bridge. Credit
  to @Jinwoo-H (stablyai#16564) for identifying this owner-boundary gap.
- Bound the teardown close at 5s so the will-quit barrier member cannot
  inherit the 90s exec timeout, and close sessions still being created.
- Gate the startup sweep on a socket directory Orca derived itself; an
  inherited AGENT_BROWSER_SOCKET_DIR is no proof of per-profile ownership.
- Replay a session's network routes when the daemon idled out between two
  commands, instead of silently serving unstubbed requests.

* fix(browser): give the orphan sweep a kill switch

Of the three behaviours this PR adds, two are already recoverable in the field
without a build: the idle bound is an env passthrough an operator can raise, and
the quit close is bounded by its own timeout inside the teardown deadline. The
startup sweep was the exception — it fires unconditionally, and if it closes a
daemon it should not, or spawns one process per stale name on a profile holding
hundreds, the only remedy was a revert.

ORCA_DISABLE_AGENT_BROWSER_SWEEP=1 turns it off, matching the existing
ORCA_DISABLE_CODEX_TRUST_RPC / ORCA_DISABLE_HTTP2 convention.

Note for anyone reaching for it on macOS: a Finder-launched Orca does not see
shell env, so it needs launchctl setenv or a terminal launch.

* fix(orcad): reuse a surviving browser session instead of closing the user's

start() closed the daemon before every open, killing the Chromium tree with it.
That runs on every provider start, not just after a crash — so an `orca serve`
restart took the remote user's browser and every tab in it.

The justification was borrowed from the pane bridge, which passes --cdp and so
really does hold a port that dies with its Orca. This session passes only
--session and --profile: nothing binds it to the old process, and the daemon
owns its Chromium independently. A survivor is reusable as-is.

start() now probes for an active tab first and returns it untouched. Only a name
that answers nothing gets closed and reopened — which is still the killed-orcad
case the stable session name exists to recover.

This matters more as orcad becomes the backend the remote host runs on: the
browser it manages belongs to a user, not to the process that happens to be
driving it this minute. It is also the same principle that already exempts this
path from AGENT_BROWSER_IDLE_TIMEOUT_MS.
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.

[Bug]: agent-browser processes can consume tens of GB and make headless orca serve unresponsive

1 participant