|
| 1 | +/** |
| 2 | + * Process lifecycle hardening: graceful termination signals and broken-pipe. |
| 3 | + * |
| 4 | + * Termination signals: without a handler, Node terminates the process abruptly |
| 5 | + * with no output, so a user (Ctrl+C), a CI runner or `docker stop` (SIGTERM), or |
| 6 | + * a closed terminal/SSH session (SIGHUP) that interrupts a long |
| 7 | + * `test run --wait` is left unsure whether the run was cancelled or is still |
| 8 | + * executing server-side (it is: the CLI only polls; the run lives on the |
| 9 | + * backend). The handler prints a one-line explanation plus how to resume, then |
| 10 | + * exits with the conventional `128 + signal` code. |
| 11 | + * |
| 12 | + * Broken pipe: when output is piped to a reader that closes early |
| 13 | + * (`testsprite ... | head`), the kernel raises `EPIPE` on the next stdout write. |
| 14 | + * Node turns an `'error'` with no listener into an uncaughtException and dumps a |
| 15 | + * raw `write EPIPE` stack (exit 1). The guard swallows it and exits 0, the |
| 16 | + * conventional SIGPIPE-equivalent result for "the reader went away". |
| 17 | + * |
| 18 | + * `process` and the streams are injectable so the wiring is unit-testable |
| 19 | + * without spawning a subprocess or sending a real signal. |
| 20 | + */ |
| 21 | + |
| 22 | +/** |
| 23 | + * Termination signals handled, mapped to their conventional `128 + signum` |
| 24 | + * exit code. sourceRef: POSIX signal numbers (SIGHUP=1, SIGINT=2, SIGTERM=15). |
| 25 | + */ |
| 26 | +export const TERMINATION_EXIT_CODES = { |
| 27 | + SIGINT: 130, // 128 + 2 |
| 28 | + SIGTERM: 143, // 128 + 15 |
| 29 | + SIGHUP: 129, // 128 + 1 |
| 30 | +} as const; |
| 31 | + |
| 32 | +export type TerminationSignal = keyof typeof TERMINATION_EXIT_CODES; |
| 33 | + |
| 34 | +/** Back-compat alias: SIGINT's conventional exit code. */ |
| 35 | +export const SIGINT_EXIT_CODE = TERMINATION_EXIT_CODES.SIGINT; |
| 36 | + |
| 37 | +export function formatInterruptMessage(signal: TerminationSignal = 'SIGINT'): string { |
| 38 | + return ( |
| 39 | + `Interrupted (${signal}). Any run already started keeps executing on the server; ` + |
| 40 | + 'check it with `testsprite test list` or `testsprite test wait <runId>`.' |
| 41 | + ); |
| 42 | +} |
| 43 | + |
| 44 | +export interface InterruptDeps { |
| 45 | + /** Signal registrar. Defaults to `process.on`. */ |
| 46 | + on?: (signal: TerminationSignal, handler: () => void) => void; |
| 47 | + /** Line-oriented stderr writer (appends a newline). */ |
| 48 | + stderr?: (line: string) => void; |
| 49 | + /** Process exit. Defaults to `process.exit`. */ |
| 50 | + exit?: (code: number) => void; |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * Register handlers for SIGINT, SIGTERM and SIGHUP. Idempotent enough for a |
| 55 | + * single top-level call in `index.ts`; not designed to be installed twice. |
| 56 | + */ |
| 57 | +export function installSignalHandlers(deps: InterruptDeps = {}): void { |
| 58 | + const on = |
| 59 | + deps.on ?? |
| 60 | + ((signal: TerminationSignal, handler: () => void) => { |
| 61 | + process.on(signal, handler); |
| 62 | + }); |
| 63 | + const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); |
| 64 | + const exit = deps.exit ?? ((code: number) => process.exit(code)); |
| 65 | + |
| 66 | + for (const signal of Object.keys(TERMINATION_EXIT_CODES) as TerminationSignal[]) { |
| 67 | + on(signal, () => { |
| 68 | + // Blank line first so the message starts on its own row rather than |
| 69 | + // trailing the progress ticker's in-place line. |
| 70 | + stderr(''); |
| 71 | + stderr(formatInterruptMessage(signal)); |
| 72 | + exit(TERMINATION_EXIT_CODES[signal]); |
| 73 | + }); |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +export interface BrokenPipeDeps { |
| 78 | + /** stdout stream. Defaults to `process.stdout`. */ |
| 79 | + stdout?: NodeJS.EventEmitter; |
| 80 | + /** stderr stream. Defaults to `process.stderr`. */ |
| 81 | + stderr?: NodeJS.EventEmitter; |
| 82 | + /** Process exit. Defaults to `process.exit`. */ |
| 83 | + exit?: (code: number) => void; |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Guard against `EPIPE` on stdout/stderr so piping to a reader that closes |
| 88 | + * early (`testsprite ... | head`) exits cleanly instead of crashing with an |
| 89 | + * unhandled `write EPIPE` stack. Only `EPIPE` is swallowed; any other stream |
| 90 | + * error is left to surface normally. |
| 91 | + */ |
| 92 | +export function installBrokenPipeGuard(deps: BrokenPipeDeps = {}): void { |
| 93 | + const stdout = deps.stdout ?? process.stdout; |
| 94 | + const stderr = deps.stderr ?? process.stderr; |
| 95 | + const exit = deps.exit ?? ((code: number) => process.exit(code)); |
| 96 | + |
| 97 | + stdout.on('error', (error: NodeJS.ErrnoException) => { |
| 98 | + // Reader went away (`| head`, `| less` then q): exit cleanly like SIGPIPE |
| 99 | + // rather than dumping an unhandled `write EPIPE` stack. Any other stdout |
| 100 | + // error is a genuine, actionable failure, so re-throw it (Node's default). |
| 101 | + if (error.code === 'EPIPE') { |
| 102 | + exit(0); |
| 103 | + return; |
| 104 | + } |
| 105 | + throw error; |
| 106 | + }); |
| 107 | + stderr.on('error', (error: NodeJS.ErrnoException) => { |
| 108 | + // stderr closed: nothing can be reported over it, so swallow EPIPE. Any |
| 109 | + // other error re-throws so a genuine failure is not silently hidden. |
| 110 | + if (error.code === 'EPIPE') return; |
| 111 | + throw error; |
| 112 | + }); |
| 113 | +} |
0 commit comments