exitOnSignal(arg_0?: { on?: (signal: string,handler: () => void) => void; exit?: (code: number) => void; }): void
Turns a termination signal into an ordinary exit, so process.on('exit') handlers still run.
Node's DEFAULT handling of these signals terminates the process without running exit listeners.
That is the whole problem: playwright kills the browser it launched from a synchronous
process.on('exit') handler, and the pre-launched Chrome is reaped from another. Killed by a
signal with no listener, neither fires, and the browser outlives the run.
On CI that compounds. A supervisor kills a slow run, its browser survives, the leftover browsers
compete for the machine, the next run is slower, and more get killed — which is what a
Firefox-on-Windows lane looked like when every test took four times its usual duration and the
ones that fell off the end reported page.goto: Timeout 60000ms exceeded.
Exiting rather than cleaning up is deliberate, and is why playwright's own handleSIGTERM stays
off: its handler starts an ASYNC graceful close that can hang, and a process being told to stop
has no time to spend. process.exit runs the synchronous handlers and goes.
The listener stays installed rather than firing once. Removing it restores the default, so a second signal landing while the exit hooks run — a supervisor's retry, a harness's follow-up SIGTERM — would kill the process mid-cleanup, which is the very death this exists to prevent.
Windows gets nothing from this and cannot: child.kill() there is TerminateProcess, which no
handler in this process can intercept. Whoever does the killing has to take the tree down — see
the test runner's own spawnCapture.
import { exitOnSignal } from './exit-on-signal.ts'; const exits: number[] = []; const signals = new Map<string, () => void>(); exitOnSignal({ on: (signal, handler) => void signals.set(signal, handler), exit: (code) => void exits.push(code), }); signals.get('SIGTERM')!(); exits; // [143] — 128 + 15, the conventional code for a SIGTERM death