diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 7734f0a62..ae7bfa21b 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -88,7 +88,22 @@ export function shouldEnableChromiumSandbox(): boolean { * restarts on backoff. */ export async function resolveDisconnectCause(browser: Browser | null): Promise<'clean' | 'crash'> { - const proc = browser?.process(); + // Null browser → 'crash' (defensive default, matches existing contract + // pinned by the unit test "null browser returns crash"). + if (browser === null) return 'crash'; + + // Persistent contexts (headed mode) expose a Browser object via + // BrowserContext.browser(), but Playwright doesn't surface the underlying + // Chromium process through it — `browser.process` is undefined on that + // object. Pre-fix, `browser?.process()` evaluated to `undefined()` and + // threw an unhandled rejection, crashing the bun process and putting gbd + // into a respawn loop. Without process introspection we can't distinguish + // "user pressed Cmd+Q or closed all tabs" from "Chromium crashed". In + // headed mode the user controls the lifecycle, so the right default is + // 'clean': exit 0, gbd does not restart, user re-launches if they want. + if (typeof browser.process !== 'function') return 'clean'; + + const proc = browser.process(); if (proc && proc.exitCode === null && proc.signalCode === null) { await new Promise((resolve) => { const timer = setTimeout(resolve, 1000); diff --git a/browse/test/browser-manager-unit.test.ts b/browse/test/browser-manager-unit.test.ts index 45bebc345..aea3f26ac 100644 --- a/browse/test/browser-manager-unit.test.ts +++ b/browse/test/browser-manager-unit.test.ts @@ -190,6 +190,20 @@ describe('resolveDisconnectCause', () => { const { resolveDisconnectCause } = await import('../src/browser-manager'); expect(await resolveDisconnectCause(null)).toBe('crash'); }); + + // Regression: persistent contexts in headed mode expose a Browser stub + // whose `.process` is undefined (not a function). Pre-fix, calling + // `browser?.process()` on such a value threw an unhandled rejection + // ("browser?.process is not a function"), crashed the bun process, and + // put gbd into a respawn loop on every tab close. The contract for that + // case is: default to 'clean' so gbd exits 0 and the user keeps lifecycle + // control. Live evidence in gbrowser amsterdam-v7's browse-server.log + // before this fix landed. + it('clean: browser without .process method (persistent context)', async () => { + const { resolveDisconnectCause } = await import('../src/browser-manager'); + const fake = {} as never; // truthy, no .process → would have thrown pre-fix + expect(await resolveDisconnectCause(fake)).toBe('clean'); + }); }); // ─── onDisconnect exit-code propagation (regression test) ──────────