Skip to content

v1.43.3.0 fix(browse): resolveDisconnectCause crashes on persistent-context disconnect#1659

Open
garrytan wants to merge 6 commits into
mainfrom
garrytan/fix-for-gbrowser
Open

v1.43.3.0 fix(browse): resolveDisconnectCause crashes on persistent-context disconnect#1659
garrytan wants to merge 6 commits into
mainfrom
garrytan/fix-for-gbrowser

Conversation

@garrytan
Copy link
Copy Markdown
Owner

Summary

browse/src/browser-manager.tsresolveDisconnectCause is called on every Playwright disconnected event. In headed mode the only launcher we use is chromium.launchPersistentContext, which exposes a Browser object whose .process is undefined (Playwright doesn't surface the underlying Chromium process for persistent contexts).

Pre-fix:

```ts
const proc = browser?.process();
```

The optional chain `browser?.process` short-circuits to `undefined` only when `browser` itself is null/undefined. With a truthy browser and a missing method, the expression evaluates to `undefined()` and throws an unhandled rejection. The throw crashes the bun process; downstream supervisors (gbrowser's `gbd`) respawn it; the next tab close hits the same path; loop forever.

Live evidence captured in `gbrowser/amsterdam-v7`'s `~/Library/Logs/GBrowser/workspaces//browse-server.log`:

```
[overlay] Local listener bound on 127.0.0.1:35300 (PID: 19445)
[browse] Tab closed (id=1, remaining=0)
[stderr] [overlay] FATAL unhandled rejection: browser?.process is not a function.
(In 'browser?.process()', 'browser?.process' is undefined)
[browse] Shutting down...
...respawn, same crash, repeat...
```

Fix

Split the null case from the no-callable-process case:

  • `browser === null` → `'crash'` (preserves the existing contract pinned by the `null browser returns crash` test)
  • truthy `browser` without callable `.process` → `'clean'` (persistent contexts; user controls headed-mode lifecycle so the right default is exit 0 / no auto-restart)
  • truthy `browser` with callable `.process` → unchanged exit-code introspection

In headed mode we genuinely can't distinguish "user pressed Cmd+Q or closed all tabs" from "Chromium crashed" because Playwright doesn't expose the underlying Chromium PID through a persistent context. The tradeoff: if Chromium genuinely crashes in headed mode we now exit 0 and don't auto-restart. That's preferable to the respawn loop, and the user can re-launch manually.

Test plan

  • Added regression test `clean: browser without .process method (persistent context)` to `browse/test/browser-manager-unit.test.ts` — passes a truthy object with no `.process`, which would have thrown pre-fix.
  • All 21 tests in `browser-manager-unit.test.ts` pass locally (`bun test browse/test/browser-manager-unit.test.ts`).
  • Verified live: bumped this fix into gbrowser's submodule, restarted `gbd` — overlay now exits cleanly on tab close (`browse_server_clean_exit_no_restart` in gbd log) instead of crash-looping.

🤖 Generated with Claude Code

garrytan and others added 6 commits May 21, 2026 16:17
…indirection

Module-level idleCheckTick, parent watchdog, SIGTERM handler, and
buildFetchHandler's onDisconnect wire all read the module-level
BrowserManager directly. For embedders (gbrowser) that pass their
own instance into buildFetchHandler, the module-level instance
never has launchHeaded() called on it — connectionMode stays
'launched' forever, headed-mode early-returns never fire, and
after 30 min of HTTP idle the server self-terminates out from
under the overlay.

Adds `let activeBrowserManager: BrowserManager` at module scope
(symmetric with the existing `let activeShutdown` pattern).
buildFetchHandler retargets it at cfg.browserManager and CHAINS
cfg.browserManager.onDisconnect to activeShutdown, preserving any
caller-installed handler instead of clobbering it.

Six edit sites in browse/src/server.ts:
- Edit 1 (~705): declare activeBrowserManager
- Edit 2 (~596): extract idleCheckTick + __testInternals__ export
- Edit 3 (~658): parent watchdog reads activeBrowserManager
- Edit 4 (~1387): retarget + chain cfgBrowserManager.onDisconnect
- Edit 5 (verify): line 714 default stays in place
- Edit 6 (~1212): SIGTERM handler reads activeBrowserManager
…rally

Adds 5 behavioral tests to browse/test/server-factory.test.ts under
a new 'idle timer + onDisconnect dual-instance fix' describe block:

- T1 (CRITICAL — REGRESSION): headed embedder does not auto-shutdown
  at idle. Pins the bug this PR fixes.
- T2 (paired defensive): headless still auto-shuts down at idle.
  Catches a future refactor that breaks the inverse case.
- T3 (chain semantics): buildFetchHandler chains
  cfgBrowserManager.onDisconnect, preserving any caller-set handler.
  Uses .rejects.toThrow for the async shutdown path.
- T4 (tunnelActive): tunnel-active blocks idle-shutdown even in
  headless mode.
- T5 (static guard): exactly 3 module-level lifecycle sites use
  activeBrowserManager.getConnectionMode() — idleCheckTick, parent
  watchdog, SIGTERM. Catches refactor-introduced regressions before
  CI.

Reuses existing makeMinimalConfig() + __resetRegistry() patterns
from the factory contract tests. New makeMockBrowserManager() helper.
beforeEach also resets module state via setTunnelActive,
setLastActivity, and resetShutdownState from __testInternals__.

Also deletes the old 'idle check skips in headed mode' string-grep
test from browse/test/sidebar-ux.test.ts at line 1596. That test
would have passed even with the dual-instance bug present
(grepped for "=== 'headed'" + 'return' in the same window).
Behavioral coverage moved to server-factory.test.ts.

Verified: 33/33 tests pass in browse/test/server-factory.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…connect

`browser?.process()` in headed mode reaches a BrowserContext-owned Browser
stub whose `.process` is undefined (not a function). The optional-chain
`browser?.process()` does NOT short-circuit on undefined methods — only
on null/undefined receivers — so it evaluates to `undefined()` and throws
an unhandled rejection. The throw crashes the bun process, gbd respawns
it, the next tab close hits the same path, loop forever.

Reproducer (live in gbrowser amsterdam-v7 right now):

    [overlay] Local listener bound on 127.0.0.1:35300 (PID: 19445)
    [browse] Tab closed (id=1, remaining=0)
    [stderr] [overlay] FATAL unhandled rejection: browser?.process is
             not a function. (In 'browser?.process()', 'browser?.process'
             is undefined)
    [browse] Shutting down...
    ...respawn, same crash, repeat...

Fix: split the null case from the no-process case.
- null browser → 'crash' (preserves the existing contract pinned by the
  "null browser returns crash" test)
- truthy browser without callable .process → 'clean' (persistent contexts
  in headed mode; the user controls the lifecycle so the right default
  is exit 0 / gbd does not restart)
- truthy browser with callable .process → unchanged exit-code introspection

In headed mode we genuinely cannot distinguish "user pressed Cmd+Q or
closed all tabs" from "Chromium crashed" because Playwright doesn't
expose the underlying Chromium PID through a persistent context. The
tradeoff is: if Chromium genuinely crashes in headed mode we now exit 0
and don't auto-restart. That's preferable to the respawn loop, and the
user can re-launch manually if they want.

Test: added "clean: browser without .process method (persistent context)"
which would have caught this bug. All 21 browser-manager-unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot changed the title fix(browse): resolveDisconnectCause crashes on persistent-context disconnect v1.43.3.0 fix(browse): resolveDisconnectCause crashes on persistent-context disconnect May 22, 2026
@github-actions
Copy link
Copy Markdown

E2E Evals: ✅ PASS

8/8 tests passed | $1.40 total cost | 12 parallel runners

Suite Result Status Cost
e2e-browse 2/2 $0.15
e2e-deploy 2/2 $0.31
e2e-qa-workflow 1/1 $0.61
llm-judge 1/1 $0.02
e2e-deploy 2/2 $0.31

12x ubicloud-standard-8 (Docker: pre-baked toolchain + deps) | wall clock ≈ slowest suite

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