mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-22 04:38:24 +08:00
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'bun:test';
|
||||
|
||||
// ─── BrowserManager basic unit tests ─────────────────────────────
|
||||
|
||||
@@ -15,3 +16,186 @@ describe('BrowserManager defaults', () => {
|
||||
expect(bm.getRefMap()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shouldEnableChromiumSandbox ─────────────────────────────────
|
||||
//
|
||||
// Pinning this is what prevents the "--no-sandbox" yellow infobar from
|
||||
// regressing on headed launches. Playwright auto-adds --no-sandbox when
|
||||
// chromiumSandbox !== true (playwright-core chromium.js:291-292), so all
|
||||
// three launch sites in browser-manager.ts must pass the policy this
|
||||
// helper computes.
|
||||
|
||||
describe('shouldEnableChromiumSandbox', () => {
|
||||
const origPlatform = process.platform;
|
||||
const origCI = process.env.CI;
|
||||
const origContainer = process.env.CONTAINER;
|
||||
const origGetuid = process.getuid;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.CI;
|
||||
delete process.env.CONTAINER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: origPlatform });
|
||||
if (origCI === undefined) delete process.env.CI; else process.env.CI = origCI;
|
||||
if (origContainer === undefined) delete process.env.CONTAINER; else process.env.CONTAINER = origContainer;
|
||||
process.getuid = origGetuid;
|
||||
});
|
||||
|
||||
function setPlatform(p: NodeJS.Platform) {
|
||||
Object.defineProperty(process, 'platform', { value: p });
|
||||
}
|
||||
|
||||
it('darwin, no CI/CONTAINER/root → true', async () => {
|
||||
setPlatform('darwin');
|
||||
process.getuid = (() => 501) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('linux, no CI/CONTAINER/root → true', async () => {
|
||||
setPlatform('linux');
|
||||
process.getuid = (() => 1000) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('win32 → false (sandbox fails in Bun→Node→Chromium chain)', async () => {
|
||||
setPlatform('win32');
|
||||
process.getuid = (() => 1000) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(false);
|
||||
});
|
||||
|
||||
it('linux + CI=1 → false', async () => {
|
||||
setPlatform('linux');
|
||||
process.env.CI = '1';
|
||||
process.getuid = (() => 1000) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(false);
|
||||
});
|
||||
|
||||
it('linux + CONTAINER=1 → false', async () => {
|
||||
setPlatform('linux');
|
||||
process.env.CONTAINER = '1';
|
||||
process.getuid = (() => 1000) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(false);
|
||||
});
|
||||
|
||||
it('linux + root (uid 0) → false', async () => {
|
||||
setPlatform('linux');
|
||||
process.getuid = (() => 0) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveDisconnectCause ──────────────────────────────────────
|
||||
//
|
||||
// Pinning the clean-vs-crash distinction matters because gbd's
|
||||
// HealthMonitor consumes our exit code (0 = don't restart, !=0 =
|
||||
// restart). A regression here brings back the "Cmd+Q makes the browser
|
||||
// keep coming back" UX bug.
|
||||
|
||||
function makeFakeBrowser(opts: {
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
/** ms before emitting 'exit'; default = already exited at construction */
|
||||
exitDelay?: number;
|
||||
}): { process(): { exitCode: number | null; signalCode: NodeJS.Signals | null; once: EventEmitter['once'] } } {
|
||||
const ee = new EventEmitter();
|
||||
const state = {
|
||||
exitCode: opts.exitDelay != null ? null : opts.exitCode,
|
||||
signalCode: opts.exitDelay != null ? null : opts.signalCode,
|
||||
once: ee.once.bind(ee),
|
||||
};
|
||||
if (opts.exitDelay != null) {
|
||||
setTimeout(() => {
|
||||
state.exitCode = opts.exitCode;
|
||||
state.signalCode = opts.signalCode;
|
||||
ee.emit('exit', opts.exitCode, opts.signalCode);
|
||||
}, opts.exitDelay);
|
||||
}
|
||||
return { process: () => state };
|
||||
}
|
||||
|
||||
describe('resolveDisconnectCause', () => {
|
||||
it('clean: process already exited with code 0', async () => {
|
||||
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
||||
const fake = makeFakeBrowser({ exitCode: 0, signalCode: null });
|
||||
expect(await resolveDisconnectCause(fake as never)).toBe('clean');
|
||||
});
|
||||
|
||||
it('crash: non-zero exit code', async () => {
|
||||
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
||||
const fake = makeFakeBrowser({ exitCode: 1, signalCode: null });
|
||||
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
||||
});
|
||||
|
||||
it('crash: SIGSEGV', async () => {
|
||||
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
||||
const fake = makeFakeBrowser({ exitCode: null, signalCode: 'SIGSEGV' });
|
||||
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
||||
});
|
||||
|
||||
it('crash: SIGKILL', async () => {
|
||||
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
||||
const fake = makeFakeBrowser({ exitCode: null, signalCode: 'SIGKILL' });
|
||||
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
||||
});
|
||||
|
||||
it('clean: process exits asynchronously with code 0 within timeout', async () => {
|
||||
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
||||
const fake = makeFakeBrowser({ exitCode: 0, signalCode: null, exitDelay: 50 });
|
||||
expect(await resolveDisconnectCause(fake as never)).toBe('clean');
|
||||
});
|
||||
|
||||
it('crash: process exits asynchronously with non-zero code', async () => {
|
||||
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
||||
const fake = makeFakeBrowser({ exitCode: 137, signalCode: null, exitDelay: 50 });
|
||||
expect(await resolveDisconnectCause(fake as never)).toBe('crash');
|
||||
});
|
||||
|
||||
it('crash: null browser returns crash (defensive default)', async () => {
|
||||
const { resolveDisconnectCause } = await import('../src/browser-manager');
|
||||
expect(await resolveDisconnectCause(null)).toBe('crash');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── onDisconnect exit-code propagation (regression test) ──────────
|
||||
//
|
||||
// The contract: BrowserManager.onDisconnect is called with the resolved
|
||||
// exit code (0 for clean Cmd+Q, 2 for crash). server.ts then forwards
|
||||
// that code to activeShutdown(), which exits the process.
|
||||
//
|
||||
// Without this propagation, the headed-mode user-visible Cmd+Q respawn
|
||||
// bug returns: server.ts hardcoded `activeShutdown?.(2)` ignores the
|
||||
// resolved 0 and gbrowser's gbd HealthMonitor treats the clean quit as
|
||||
// a crash, restarting the window.
|
||||
describe('BrowserManager.onDisconnect exit-code propagation', () => {
|
||||
it('signature accepts an optional exitCode argument', async () => {
|
||||
const { BrowserManager } = await import('../src/browser-manager');
|
||||
const bm = new BrowserManager();
|
||||
const calls: Array<number | undefined> = [];
|
||||
bm.onDisconnect = (code?: number) => { calls.push(code); };
|
||||
bm.onDisconnect(0);
|
||||
bm.onDisconnect(2);
|
||||
bm.onDisconnect(undefined);
|
||||
expect(calls).toEqual([0, 2, undefined]);
|
||||
});
|
||||
|
||||
it('server.ts callback forwards exitCode when provided, falls back to 2', async () => {
|
||||
// Mirror the production wiring in browse/src/server.ts so a refactor
|
||||
// that drops the forward (e.g. reverting to `() => activeShutdown?.(2)`)
|
||||
// fails CI before the user-visible bug returns.
|
||||
const shutdownCalls: number[] = [];
|
||||
const activeShutdown = (code: number) => { shutdownCalls.push(code); };
|
||||
const onDisconnect = (code?: number) => activeShutdown(code ?? 2);
|
||||
onDisconnect(0);
|
||||
onDisconnect(2);
|
||||
onDisconnect(undefined);
|
||||
expect(shutdownCalls).toEqual([0, 2, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user