mirror of https://github.com/garrytan/gstack.git
test(browse): unit coverage for the close() SIGKILL fallback
The wedge fix (capture the Chromium child before the close race, SIGKILL on timeout) shipped without a test of the branch it added — the coverage audit flagged it as the diff's one regression-gap. The 5s race window becomes an injectable closeRaceMs field, and four unit tests pin: SIGKILL on hang, no SIGKILL on clean close, no SIGKILL on an already-exited child, SIGKILL on a rejecting close.
This commit is contained in:
parent
7b4b70babd
commit
ec154bcb83
|
|
@ -797,6 +797,12 @@ export class BrowserManager {
|
||||||
this.consecutiveFailures = 0;
|
this.consecutiveFailures = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How long close() waits for a graceful shutdown before falling back to
|
||||||
|
// SIGKILL (launched mode) or abandoning the context close (headed mode).
|
||||||
|
// A field, not a literal, so the SIGKILL fallback is unit-testable without
|
||||||
|
// a 5-second wait.
|
||||||
|
private closeRaceMs = 5000;
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
// unref'd race timer: without unref, every successful close still pins
|
// unref'd race timer: without unref, every successful close still pins
|
||||||
// the caller's event loop for the full window.
|
// the caller's event loop for the full window.
|
||||||
|
|
@ -811,7 +817,7 @@ export class BrowserManager {
|
||||||
if (this.browser) this.browser.removeAllListeners('disconnected');
|
if (this.browser) this.browser.removeAllListeners('disconnected');
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.context ? this.context.close() : Promise.resolve(),
|
this.context ? this.context.close() : Promise.resolve(),
|
||||||
raceTimeout(5000),
|
raceTimeout(this.closeRaceMs),
|
||||||
]).catch(() => {});
|
]).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
// Launched mode: close the browser we spawned.
|
// Launched mode: close the browser we spawned.
|
||||||
|
|
@ -824,7 +830,7 @@ export class BrowserManager {
|
||||||
const child = this.browser.process?.();
|
const child = this.browser.process?.();
|
||||||
const closed = await Promise.race([
|
const closed = await Promise.race([
|
||||||
this.browser.close().then(() => true as const),
|
this.browser.close().then(() => true as const),
|
||||||
raceTimeout(5000),
|
raceTimeout(this.closeRaceMs),
|
||||||
]).catch(() => false as const);
|
]).catch(() => false as const);
|
||||||
if (closed === false && child && child.exitCode === null && !child.killed) {
|
if (closed === false && child && child.exitCode === null && !child.killed) {
|
||||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||||
|
|
|
||||||
|
|
@ -303,3 +303,69 @@ describe('stealth injected on every context-creation path', () => {
|
||||||
expect(sites.length).toBeGreaterThanOrEqual(2);
|
expect(sites.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('close() launched-mode SIGKILL fallback', () => {
|
||||||
|
// The wedge this guards against: browser.close() hangs, the race times
|
||||||
|
// out, and pre-fix code nulled this.browser — ABANDONING a live Chromium
|
||||||
|
// whose sockets pinned the caller's event loop forever. The child must be
|
||||||
|
// captured before the race and SIGKILLed when graceful close loses.
|
||||||
|
type FakeChild = { exitCode: number | null; killed: boolean; kill: (sig: string) => void };
|
||||||
|
const makeCloseFakes = (closeBehavior: () => Promise<void>, child?: Partial<FakeChild>) => {
|
||||||
|
const kills: string[] = [];
|
||||||
|
const fakeChild: FakeChild = {
|
||||||
|
exitCode: null,
|
||||||
|
killed: false,
|
||||||
|
kill: (sig: string) => { kills.push(sig); },
|
||||||
|
...child,
|
||||||
|
};
|
||||||
|
const fakeBrowser = {
|
||||||
|
removeAllListeners: () => fakeBrowser,
|
||||||
|
process: () => fakeChild,
|
||||||
|
close: closeBehavior,
|
||||||
|
};
|
||||||
|
return { kills, fakeBrowser };
|
||||||
|
};
|
||||||
|
|
||||||
|
const managerWith = async (fakeBrowser: unknown) => {
|
||||||
|
const { BrowserManager } = await import('../src/browser-manager');
|
||||||
|
const bm = new BrowserManager();
|
||||||
|
const raw = bm as unknown as { browser: unknown; connectionMode: string; closeRaceMs: number };
|
||||||
|
raw.browser = fakeBrowser;
|
||||||
|
raw.connectionMode = 'launched';
|
||||||
|
raw.closeRaceMs = 20;
|
||||||
|
return { bm, raw };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('SIGKILLs a live child when graceful close exceeds the race window', async () => {
|
||||||
|
const { kills, fakeBrowser } = makeCloseFakes(() => new Promise<void>(() => {}));
|
||||||
|
const { bm, raw } = await managerWith(fakeBrowser);
|
||||||
|
await bm.close();
|
||||||
|
expect(kills).toEqual(['SIGKILL']);
|
||||||
|
expect(raw.browser).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not SIGKILL when graceful close finishes in time', async () => {
|
||||||
|
const { kills, fakeBrowser } = makeCloseFakes(async () => {});
|
||||||
|
const { bm, raw } = await managerWith(fakeBrowser);
|
||||||
|
await bm.close();
|
||||||
|
expect(kills).toEqual([]);
|
||||||
|
expect(raw.browser).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not SIGKILL a child that already exited', async () => {
|
||||||
|
const { kills, fakeBrowser } = makeCloseFakes(
|
||||||
|
() => new Promise<void>(() => {}),
|
||||||
|
{ exitCode: 0 },
|
||||||
|
);
|
||||||
|
const { bm } = await managerWith(fakeBrowser);
|
||||||
|
await bm.close();
|
||||||
|
expect(kills).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a rejecting close() and still SIGKILLs the live child', async () => {
|
||||||
|
const { kills, fakeBrowser } = makeCloseFakes(() => Promise.reject(new Error('target closed')));
|
||||||
|
const { bm } = await managerWith(fakeBrowser);
|
||||||
|
await bm.close();
|
||||||
|
expect(kills).toEqual(['SIGKILL']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue