mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-27 12:10:00 +02:00
v1.40.0.2 fix(browse): Cmd+Q on managed Chromium stops triggering supervisor respawn
Three browser.on('disconnected') handlers in browse/src/browser-manager.ts
(launch, launchHeaded, handoff) each exited with a non-zero code on every
disconnect, regardless of cause. Process supervisors that consume our exit
code (gbrowser's gbd HealthMonitor in cmd/gbd/health.go) treated user
Cmd+Q identical to a Chromium crash and respawned with exponential
backoff, so the visible browser kept reappearing after the user closed it.
Add resolveDisconnectCause(browser) that reads the underlying ChildProcess
exitCode + signalCode (waiting up to 1s for the exit event if the
disconnected event fired first). Exit code 0 + no signal = clean user
quit; anything else = crash, signal-kill, or OOM.
Wire the resolver into all three disconnect handlers:
- launch() (headless): clean → exit 0, crash → exit 1 (was always 1)
- launchHeaded() (headed): clean → exit 0, crash → exit 2 (was always 2)
onDisconnect() cleanup callback still runs in both cases.
- handoff() (re-launch): same as launch() via the helper.
Preserve the per-path crash codes (1 vs 2) so any supervisor that
differentiated headed vs headless crashes keeps working.
Seven new unit tests in browse-manager-unit.test.ts cover the resolver
across already-exited, signal-killed (SIGSEGV / SIGKILL), async exits,
and null-browser inputs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'bun:test';
|
||||
|
||||
// ─── BrowserManager basic unit tests ─────────────────────────────
|
||||
@@ -90,3 +91,75 @@ describe('shouldEnableChromiumSandbox', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user