Skip to content

fix(preview): relaunch the shared browser after it disconnects (#146)#158

Open
truffle-dev wants to merge 1 commit into
ghostwright:mainfrom
truffle-dev:fix/preview-relaunch-crashed-browser
Open

fix(preview): relaunch the shared browser after it disconnects (#146)#158
truffle-dev wants to merge 1 commit into
ghostwright:mainfrom
truffle-dev:fix/preview-relaunch-crashed-browser

Conversation

@truffle-dev

Copy link
Copy Markdown
Contributor

Closes #146.

The bug

getOrCreateBrowser and getOrCreatePreviewContext in src/ui/preview.ts both return their cached handle unconditionally (if (browser) return browser, if (currentContext) return currentContext). When the shared Chromium process dies out of band — a renderer crash that propagates to the browser process, an OOM kill, or an external process sweep — the Browser disconnects but the module-level reference stays non-null. Every later phantom_preview_page and browser_* call then hands back a dead handle, so the whole tool surface fails with Target/Page crashed or ...has been closed for the rest of the process lifetime, with no recovery short of a restart.

I hit both faces of this while using the browser tools (the ...has been closed mode and the Page crashed mode are the same root cause — a disconnected shared browser that is never re-created); the issue thread has the process-level evidence (orphaned chrome-headless-shell processes from prior sessions, a crashed renderer that is never relaunched).

The fix

Gate both warm-cache returns on liveness:

  • getOrCreateBrowser checks browser.isConnected() and, if false, drops the stale reference so the existing launch path relaunches a fresh process.
  • getOrCreatePreviewContext checks the context's browser via currentContext.browser()?.isConnected() and, if dead, drops the cached context so it rebuilds on the relaunched browser instead of returning a corpse.

Recovery is lazy (on the next call), which matches the module's existing lazy-singleton shape and needs no disconnected-event listener lifecycle to manage. The launch-failure recovery (try/finally clearing browserPromise), the shuttingDown guard, and cookie rotation are all unchanged.

Tests

Two regression tests in preview-correctness.test.ts drive the crash path with a fake browser whose __disconnect() flips isConnected() to false without going through close() (simulating an out-of-band death): one asserts getOrCreateBrowser relaunches, one asserts getOrCreatePreviewContext rebuilds on a fresh browser. The fakes gain isConnected() and context.browser() to cover the new call surface.

bun test src/ui/__tests__/preview-correctness.test.ts   # 10 pass, 0 fail
bun test src/ui/                                         # 358 pass, 10 skip, 0 fail
bunx tsc --noEmit                                        # clean
bunx biome check src/ui/preview.ts src/ui/__tests__/preview-correctness.test.ts  # clean

…wright#146)

getOrCreateBrowser and getOrCreatePreviewContext both returned their
cached handle unconditionally. When the shared Chromium process dies out
of band -- a renderer crash that propagates, an OOM kill, or an external
process sweep -- the Browser disconnects but the module-level reference
stays non-null, so every later phantom_preview_page and browser_* call
hands back a dead handle and fails for the rest of the process lifetime.
That is the ghostwright#146 symptom: "Target/Page crashed" or "...has been closed"
across the whole tool surface with no recovery short of a restart.

Gate both warm-cache returns on liveness. getOrCreateBrowser checks
browser.isConnected() and drops the stale reference so the existing
launch path relaunches a fresh process. getOrCreatePreviewContext checks
the context's browser via currentContext.browser()?.isConnected() and
drops the dead context so it rebuilds on the relaunched browser. The
recovery is lazy (next call), which matches the module's existing
lazy-singleton shape and needs no disconnected-event listener lifecycle.

Two regression tests drive the crash path with a fake browser whose
__disconnect() flips isConnected() to false without going through
close(): one asserts getOrCreateBrowser relaunches, one asserts
getOrCreatePreviewContext rebuilds on a fresh browser. The fakes gain
isConnected() and context.browser() to cover the new call surface.
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.

phantom-browser tool surface goes unresponsive with 'Target page, context or browser has been closed' across all browser_* calls

1 participant