Skip to content

Commit 029356e

Browse files
garrytanclaude
andauthored
v1.42.2.0 fix wave: browse launch hardening (2 bug fixes + headed exit-code wiring) (#1629)
* v1.42.1.1 fix wave: browse launch hardening (2 bug fixes + headed exit-code wiring) Bundles two browse launch-path bug fixes plus the missing exit-code wiring that made the second fix actually work end-to-end. PR #1617 — Chromium sandbox policy at all 3 launch sites - shouldEnableChromiumSandbox() centralizes the Win32 / CI / CONTAINER / root heuristic that previously lived only in the headless launch path. - launch(), launchHeaded() / launchPersistentContext(), and handoff() now share the policy so Playwright stops auto-adding --no-sandbox on every headed launch and the yellow "unsupported command-line flag" infobar disappears on macOS and Linux dev. PR #1626 — clean Cmd+Q stops triggering supervisor respawn - resolveDisconnectCause(browser) reads the underlying Chromium ChildProcess exitCode + signalCode (with a 1s wait for an async exit event) to distinguish clean user-quit from crash. - handleChromiumDisconnect(browser) dispatches the headless launch() disconnect path: clean → exit(0), crash → exit(1). - launchHeaded() disconnect handler resolves cause inline and computes exitCode = 0 (clean) | 2 (crash) before forwarding to onDisconnect. - handoff() disconnect handler uses the same shared helper. Codex-caught propagation fix (this commit, not in either source PR) - BrowserManager.onDisconnect signature widened to accept an exitCode argument. Without this, launchHeaded's locally-computed exit code was dropped before reaching server.ts. - browse/src/server.ts:688 — onDisconnect callback now forwards the resolved code: (code) => activeShutdown?.(code ?? 2). The ?? 2 preserves legacy crash semantics for callers that invoke onDisconnect without an explicit code. Tests - browse/test/browser-manager-unit.test.ts goes from 2 → 17 tests. - 6 new tests pin shouldEnableChromiumSandbox across darwin / linux / win32 / CI / CONTAINER / root. - 7 new tests pin resolveDisconnectCause across already-exited, async-exit, SIGSEGV, SIGKILL, and null-browser. - 2 new tests (this commit) pin the onDisconnect(exitCode) propagation contract including the exact server.ts forwarding callback shape so a refactor that drops the forward fails CI before the user-visible respawn bug returns. Refs PRs #1617, #1626; companion gbrowser PR #23. * chore: bump version v1.42.1.1 → v1.42.2.0 User-requested rebump (claims v1.42.2.0 slot on the queue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b03cd1a commit 029356e

6 files changed

Lines changed: 369 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# Changelog
22

3+
## [1.42.2.0] - 2026-05-20
4+
5+
## **Headed Chromium stops shipping the yellow `--no-sandbox` infobar, and Cmd+Q on the managed window stops triggering the supervisor respawn loop.**
6+
## **Two launch-path bugs land together with the missing exit-code wiring that made the second fix actually take effect end-to-end.**
7+
8+
Two browse-side launch-path fixes bundle into one PATCH wave on top of v1.42.1.0. The yellow `--no-sandbox` infobar that appeared on every headed launch is gone at all three launch sites: `launch()`, `launchHeaded()` / `launchPersistentContext()`, and `handoff()` now share `shouldEnableChromiumSandbox()` so Playwright stops auto-adding `--no-sandbox` when the sandbox is actually wanted. Cmd+Q on the managed Chromium window now exits the browse server with code 0 instead of 2, so process supervisors (gbrowser's `gbd` HealthMonitor) treat it as user intent and skip the restart loop. The exit-code path threads end-to-end: the disconnect handler resolves clean-vs-crash from the underlying ChildProcess, `BrowserManager.onDisconnect` accepts an `exitCode` arg, and `server.ts`'s shutdown callback forwards it (`(code) => activeShutdown?.(code ?? 2)`). A regression test pins the full propagation path so a refactor that drops the forward fails CI before the user-visible respawn bug returns.
9+
10+
### The numbers that matter
11+
12+
Source: `bun test browse/test/browser-manager-unit.test.ts` — 17 tests, all green. The new `BrowserManager.onDisconnect exit-code propagation` describe block pins the signature and the server.ts forwarding callback shape; the existing `shouldEnableChromiumSandbox` and `resolveDisconnectCause` blocks pin platform/env and clean-vs-crash behavior.
13+
14+
| Surface | Before | After |
15+
|---|---|---|
16+
| Headed launch on macOS / Linux dev | Yellow `--no-sandbox` warning infobar on every tab | Infobar gone — all 3 launch sites share `shouldEnableChromiumSandbox()` |
17+
| Linux root / Docker / CI headed launch | Sandbox off (kernel can't engage it), no infobar (already correct) | Same; sandbox correctly off, helper makes the policy explicit |
18+
| Windows headed launch | Sandbox off (GitHub #276 Bun→Node chain) | Same; the policy is preserved by `shouldEnableChromiumSandbox()` returning false |
19+
| Cmd+Q on managed headed Chromium | Server exits **2**; gbrowser's `gbd` HealthMonitor treats as crash; window respawns 1s → 2s → 4s backoff | Server exits **0**; `gbd` reads "user intent", no respawn |
20+
| `SIGKILL` / `SIGSEGV` / OOM on Chromium | Server exits 2 (headed) / 1 (headless + handoff); supervisors restart on backoff | Same; crash-recovery preserved bit-for-bit |
21+
| `BrowserManager.onDisconnect` signature | `(() => void \| Promise<void>) \| null` — caller cannot pass the resolved exit code | `((exitCode?: number) => void \| Promise<void>) \| null` — caller forwards the code through |
22+
| `server.ts` shutdown callback wiring | Hardcoded `activeShutdown?.(2)` ignored any computed exit code | `(code) => activeShutdown?.(code ?? 2)` forwards 0 when computed, falls back to 2 |
23+
24+
### What this means for builders
25+
26+
If you run `browse` headed on macOS or Linux dev, the yellow `--no-sandbox` warning is gone. If you use gbrowser and Cmd+Q the managed window, the window stays closed instead of popping back on exponential backoff. Container, root, and CI environments still get sandbox off (correct, kernel can't engage it there). The exit-code contract for supervisors is now: 0 means user-initiated clean quit, 2 means a real crash. Crash-recovery is preserved across `launch()` (headless, crash → 1), `launchHeaded()` (headed, crash → 2), and `handoff()` (headless→headed re-launch, crash → 1). Pull and your next headed launch is clean.
27+
28+
### Itemized changes
29+
30+
#### Fixed
31+
32+
- `browse/src/browser-manager.ts` — headed `launchPersistentContext()` calls in `launchHeaded()` and `handoff()` now pass `chromiumSandbox`, so Playwright stops auto-adding `--no-sandbox` on every headed launch. Headless `launch()` switches to the same helper for consistency.
33+
- `browse/src/browser-manager.ts` — disconnect handlers in `launch()` (headless), `launchHeaded()` (headed), and `handoff()` (headless→headed re-launch) now resolve `clean` vs `crash` from the underlying Chromium ChildProcess `exitCode` + `signalCode` (with a 1s wait for an asynchronous exit event), and exit with 0 on clean user-quit vs the legacy non-zero code on crash.
34+
- `browse/src/browser-manager.ts` — `BrowserManager.onDisconnect` signature widened to `((exitCode?: number) => void | Promise<void>) | null`, and the headed disconnect handler now passes the resolved `exitCode` through (`this.onDisconnect(exitCode)`). Without this wiring the clean code computed inside `launchHeaded()` was dropped on the floor and the headed server still exited 2.
35+
- `browse/src/server.ts:688` — `onDisconnect` shutdown callback now forwards the resolved exit code (`(code) => activeShutdown?.(code ?? 2)`). The `?? 2` preserves legacy crash semantics for callers that invoke `onDisconnect` without a code.
36+
37+
#### Added
38+
39+
- `browse/src/browser-manager.ts` (new exports) — `shouldEnableChromiumSandbox()` centralizes the Win32 / CI / CONTAINER / root heuristic that previously lived only in the headless path's explicit `--no-sandbox` push; `resolveDisconnectCause(browser)` resolves clean-vs-crash from the Chromium ChildProcess; `handleChromiumDisconnect(browser)` is the dispatcher for the headless `launch()` path.
40+
- `browse/test/browser-manager-unit.test.ts` — 6 tests pinning `shouldEnableChromiumSandbox` across darwin / linux / win32 / CI / CONTAINER / root; 7 tests pinning `resolveDisconnectCause` across already-exited / async-exit / SIGSEGV / SIGKILL / null-browser; 2 tests pinning the new `onDisconnect(exitCode)` propagation contract including the `server.ts` forwarding callback shape. 17 tests total.
41+
342
## [1.42.1.0] - 2026-05-19
443

544
## **Embedder PTY teardown stops clobbering — gbrowser's phoenix overlay survives every shutdown.**

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.42.1.0
1+
1.42.2.0

browse/src/browser-manager.ts

Lines changed: 137 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,76 @@ export function isCustomChromium(): boolean {
4040
return p.includes('GBrowser') || p.includes('gbrowser');
4141
}
4242

43+
/**
44+
* Decide whether Playwright should request Chromium's sandbox.
45+
*
46+
* Returns false on Windows (Bun→Node→Chromium chain breaks the sandbox,
47+
* GitHub #276) and on Linux under root / CI / container (sandbox needs
48+
* unprivileged user namespaces, which are missing for root and typically
49+
* disabled in containers).
50+
*
51+
* When false, Playwright auto-adds --no-sandbox to the launch args — the
52+
* desired behavior in those environments. When true, Playwright does NOT
53+
* add --no-sandbox, which keeps Chromium's "unsupported command-line flag"
54+
* yellow infobar from appearing on every headed launch.
55+
*
56+
* The headless launch path also pushes an explicit '--no-sandbox' into args
57+
* when CI/CONTAINER/root is set; that push is now defensively redundant
58+
* (Playwright will add it anyway when this returns false) and harmless.
59+
*/
60+
export function shouldEnableChromiumSandbox(): boolean {
61+
if (process.platform === 'win32') return false;
62+
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
63+
return !(process.env.CI || process.env.CONTAINER || isRoot);
64+
}
65+
66+
/**
67+
* Resolve why the underlying Chromium ChildProcess is going away.
68+
*
69+
* The 'disconnected' Playwright event fires before the child process emits
70+
* its own 'exit' in most cases, so .exitCode is null at that moment. Wait
71+
* briefly (capped at 1s) for the exit then read .exitCode + .signalCode:
72+
*
73+
* exitCode === 0 && no signal → 'clean' (user Cmd+Q, normal shutdown)
74+
* anything else → 'crash' (signal-kill, SIGSEGV, OOM, non-zero exit)
75+
*
76+
* Process supervisors (gbrowser's gbd HealthMonitor in cmd/gbd/health.go)
77+
* read our exit code to decide whether to restart. The two callers in this
78+
* file ride on top of this: a 'clean' result exits with code 0 (gbd skips
79+
* restart, treats as user-intent); a 'crash' result keeps the existing
80+
* per-path exit semantics (launch→1, launchHeaded→2, handoff→1) and gbd
81+
* restarts on backoff.
82+
*/
83+
export async function resolveDisconnectCause(browser: Browser | null): Promise<'clean' | 'crash'> {
84+
const proc = browser?.process();
85+
if (proc && proc.exitCode === null && proc.signalCode === null) {
86+
await new Promise<void>((resolve) => {
87+
const timer = setTimeout(resolve, 1000);
88+
proc.once('exit', () => {
89+
clearTimeout(timer);
90+
resolve();
91+
});
92+
});
93+
}
94+
return proc?.exitCode === 0 && proc?.signalCode == null ? 'clean' : 'crash';
95+
}
96+
97+
/**
98+
* Headless `launch()` disconnect handler. Exits 0 on clean user-quit, 1 on
99+
* crash. Inlined into the launch() body via a one-line dispatch so
100+
* browser-manager's flow stays grep-friendly.
101+
*/
102+
export async function handleChromiumDisconnect(browser: Browser | null): Promise<void> {
103+
const cause = await resolveDisconnectCause(browser);
104+
if (cause === 'clean') {
105+
console.error('[browse] Chromium closed cleanly (user-initiated quit). Server exiting (0).');
106+
process.exit(0);
107+
}
108+
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting (1).');
109+
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
110+
process.exit(1);
111+
}
112+
43113
export type { RefEntry };
44114

45115
// Re-export TabSession for consumers
@@ -121,7 +191,11 @@ export class BrowserManager {
121191
// (user closed the window). Wired up by server.ts to run full cleanup
122192
// (sidebar-agent, state file, profile locks) before exiting with code 2.
123193
// Returns void or a Promise; rejections are caught and fall back to exit(2).
124-
public onDisconnect: (() => void | Promise<void>) | null = null;
194+
// `exitCode` is the resolved process exit code from the disconnect cause:
195+
// 0 on clean user-initiated quit (e.g., Cmd+Q on headed Chromium), 2 on
196+
// crash/signal-kill. Callers (server.ts) forward it to their shutdown
197+
// pipeline so process supervisors (gbrowser's gbd) read the right signal.
198+
public onDisconnect: ((exitCode?: number) => void | Promise<void>) | null = null;
125199

126200
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
127201

@@ -240,17 +314,25 @@ export class BrowserManager {
240314
headless: useHeadless,
241315
// On Windows, Chromium's sandbox fails when the server is spawned through
242316
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
243-
// browsing user-specified URLs has marginal sandbox benefit.
244-
chromiumSandbox: process.platform !== 'win32',
317+
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
318+
// on Linux root/CI/container, where the sandbox requires unprivileged user
319+
// namespaces that aren't available.
320+
chromiumSandbox: shouldEnableChromiumSandbox(),
245321
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
246322
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
247323
});
248324

249-
// Chromium crash → exit with clear message
325+
// Chromium disconnect → distinguish clean user-quit from crash. Both
326+
// events look identical to Playwright (one 'disconnected' fires), but
327+
// the underlying ChildProcess exit code separates them:
328+
// exitCode === 0 → clean quit (user Cmd+Q on macOS, normal shutdown)
329+
// exitCode !== 0 → crash, signal-kill, or OOM
330+
// Process supervisors (gbrowser's gbd) consume our exit code: code 0
331+
// means "user wanted this, don't restart"; non-zero means "crash, please
332+
// bring me back." Without this distinction every Cmd+Q gets treated as
333+
// a crash and the user-visible window keeps respawning.
250334
this.browser.on('disconnected', () => {
251-
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
252-
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
253-
process.exit(1);
335+
void handleChromiumDisconnect(this.browser);
254336
});
255337

256338
const contextOptions: BrowserContextOptions = {
@@ -415,6 +497,10 @@ export class BrowserManager {
415497

416498
this.context = await chromium.launchPersistentContext(userDataDir, {
417499
headless: false,
500+
// Match the sandbox policy used by launch() above. Without this,
501+
// Playwright auto-adds --no-sandbox on every headed launch and the user
502+
// sees Chromium's "unsupported command-line flag" yellow infobar.
503+
chromiumSandbox: shouldEnableChromiumSandbox(),
418504
args: launchArgs,
419505
viewport: null, // Use browser's default viewport (real window size)
420506
userAgent: this.customUserAgent || customUA,
@@ -542,32 +628,45 @@ export class BrowserManager {
542628
await this.newTab();
543629
}
544630

545-
// Browser disconnect handler — exit code 2 distinguishes from crashes (1).
546-
// Calls onDisconnect() to trigger full shutdown (kill sidebar-agent, save
547-
// session, clean profile locks + state file) before exit. Falls back to
548-
// direct process.exit(2) if no callback is wired up, or if the callback
549-
// throws/rejects — never leave the process running with a dead browser.
631+
// Browser disconnect handler — distinguish user Cmd+Q from real crash.
632+
// Clean exit (Chromium exit code 0) → process.exit(0) so process
633+
// supervisors (gbrowser's gbd) treat it as user intent and skip the
634+
// restart loop. Crash → process.exit(2) preserves the legacy headed
635+
// semantics that's distinct from launch()'s code 1.
636+
// Always calls onDisconnect() first to trigger full shutdown (kill
637+
// sidebar-agent, save session, clean profile locks + state file) so
638+
// crashes don't strand resources either.
550639
if (this.browser) {
551640
this.browser.on('disconnected', () => {
552641
if (this.intentionalDisconnect) return;
553-
console.error('[browse] Real browser disconnected (user closed or crashed).');
554-
console.error('[browse] Run `$B connect` to reconnect.');
555-
if (!this.onDisconnect) {
556-
process.exit(2);
557-
return;
558-
}
559-
try {
560-
const result = this.onDisconnect();
561-
if (result && typeof (result as Promise<void>).catch === 'function') {
562-
(result as Promise<void>).catch((err) => {
563-
console.error('[browse] onDisconnect rejected:', err);
564-
process.exit(2);
565-
});
642+
const browserRef = this.browser;
643+
void (async () => {
644+
const cause = await resolveDisconnectCause(browserRef);
645+
const exitCode = cause === 'clean' ? 0 : 2;
646+
if (cause === 'clean') {
647+
console.error('[browse] Real browser closed cleanly (user-initiated quit). Server exiting (0).');
648+
} else {
649+
console.error('[browse] Real browser disconnected (crash or kill). Server exiting (2).');
650+
console.error('[browse] Run `$B connect` to reconnect.');
566651
}
567-
} catch (err) {
568-
console.error('[browse] onDisconnect threw:', err);
569-
process.exit(2);
570-
}
652+
if (!this.onDisconnect) {
653+
process.exit(exitCode);
654+
return;
655+
}
656+
try {
657+
const result = this.onDisconnect(exitCode);
658+
if (result && typeof (result as Promise<void>).catch === 'function') {
659+
(result as Promise<void>).catch((err) => {
660+
console.error('[browse] onDisconnect rejected:', err);
661+
process.exit(exitCode);
662+
});
663+
}
664+
// onDisconnect is responsible for exit on the success path.
665+
} catch (err) {
666+
console.error('[browse] onDisconnect threw:', err);
667+
process.exit(exitCode);
668+
}
669+
})();
571670
});
572671
}
573672

@@ -1303,6 +1402,10 @@ export class BrowserManager {
13031402

13041403
newContext = await chromium.launchPersistentContext(userDataDir, {
13051404
headless: false,
1405+
// Match the sandbox policy used by launchHeaded() / launch(). The
1406+
// handoff path is the headless→headed re-launch and shares the same
1407+
// anti-detection posture, including no spurious --no-sandbox infobar.
1408+
chromiumSandbox: shouldEnableChromiumSandbox(),
13061409
args: launchArgs,
13071410
viewport: null,
13081411
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
@@ -1332,12 +1435,14 @@ export class BrowserManager {
13321435
await newContext.setExtraHTTPHeaders(this.extraHeaders);
13331436
}
13341437

1335-
// Register crash handler on new browser
1438+
// Register disconnect handler on new browser. Same clean-vs-crash
1439+
// discrimination as launch() / launchHeaded() above so a user-initiated
1440+
// Cmd+Q after a handoff doesn't trigger gbd's restart loop.
13361441
if (this.browser) {
1442+
const browserRef = this.browser;
13371443
this.browser.on('disconnected', () => {
13381444
if (this.intentionalDisconnect) return;
1339-
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
1340-
process.exit(1);
1445+
void handleChromiumDisconnect(browserRef);
13411446
});
13421447
}
13431448

browse/src/server.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -680,8 +680,12 @@ function emitInspectorEvent(event: any): void {
680680
const browserManager = new BrowserManager();
681681
// When the user closes the headed browser window, run full cleanup
682682
// (kill sidebar-agent, save session, remove profile locks, delete state file)
683-
// before exiting with code 2. Exit code 2 distinguishes user-close from crashes (1).
684-
browserManager.onDisconnect = () => activeShutdown?.(2);
683+
// before exiting. Exit code 0 means user-initiated clean quit (Cmd+Q on
684+
// macOS) so process supervisors like gbrowser's gbd skip the restart loop;
685+
// 2 means a real crash that should respawn. The fallback `?? 2` preserves
686+
// legacy crash semantics for any caller that invokes onDisconnect without
687+
// an explicit code.
688+
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
685689
let isShuttingDown = false;
686690

687691
// Test if a port is available by binding and immediately releasing.

0 commit comments

Comments
 (0)