Skip to content

Commit 73b6149

Browse files
committed
feat(cli): graceful termination signals + broken-pipe guard
Install handlers for SIGINT/SIGTERM/SIGHUP that print a one-line explanation (any started run keeps executing server-side; resume with `testsprite test list` or `testsprite test wait <runId>`) and exit with the conventional 128+signum code (SIGINT -> 130). Also guard EPIPE on stdout/stderr so piping to a reader that closes early (`... | head`) exits cleanly instead of dumping a raw `write EPIPE` stack. process and streams are injectable, so both are unit-tested without spawning a subprocess or sending a real signal. Fixes #75
1 parent b9e9601 commit 73b6149

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createProjectCommand } from './commands/project.js';
1212
import { createTestCommand } from './commands/test.js';
1313
import { createUsageCommand } from './commands/usage.js';
1414
import { ApiError, CLIError, RequestTimeoutError } from './lib/errors.js';
15+
import { installBrokenPipeGuard, installSignalHandlers } from './lib/interrupt.js';
1516
import { Output, isOutputMode } from './lib/output.js';
1617
import { maybeInstallProxyAgent } from './lib/proxy.js';
1718
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
@@ -161,6 +162,14 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
161162
}
162163
});
163164

165+
// Clean process lifecycle: a clear message + conventional exit code on SIGINT /
166+
// SIGTERM / SIGHUP (instead of Node's silent abrupt kill) so an interrupted
167+
// `test run --wait` explains the run continues server-side; plus an EPIPE guard
168+
// so piping to a reader that closes early (`| head`) exits cleanly instead of
169+
// dumping a raw `write EPIPE` stack.
170+
installSignalHandlers();
171+
installBrokenPipeGuard();
172+
164173
// Corporate/CI proxies: honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (Node's fetch
165174
// ignores them by default). No-op when no proxy variable is set.
166175
maybeInstallProxyAgent();

src/lib/interrupt.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { EventEmitter } from 'node:events';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import {
4+
SIGINT_EXIT_CODE,
5+
TERMINATION_EXIT_CODES,
6+
formatInterruptMessage,
7+
installBrokenPipeGuard,
8+
installSignalHandlers,
9+
} from './interrupt.js';
10+
11+
describe('formatInterruptMessage', () => {
12+
it('defaults to SIGINT and explains the run continues server-side', () => {
13+
const message = formatInterruptMessage();
14+
expect(message).toContain('Interrupted (SIGINT)');
15+
expect(message).toContain('test wait');
16+
expect(message).toContain('test list');
17+
});
18+
19+
it('names the specific signal when given one', () => {
20+
expect(formatInterruptMessage('SIGTERM')).toContain('Interrupted (SIGTERM)');
21+
expect(formatInterruptMessage('SIGHUP')).toContain('Interrupted (SIGHUP)');
22+
});
23+
});
24+
25+
describe('installSignalHandlers', () => {
26+
it('registers SIGINT, SIGTERM and SIGHUP with the conventional 128+signum exit codes', () => {
27+
const handlers = new Map<string, () => void>();
28+
const stderr: string[] = [];
29+
const exit = vi.fn();
30+
31+
installSignalHandlers({
32+
on: (signal, handler) => handlers.set(signal, handler),
33+
stderr: line => stderr.push(line),
34+
exit,
35+
});
36+
37+
expect([...handlers.keys()].sort()).toEqual(['SIGHUP', 'SIGINT', 'SIGTERM']);
38+
39+
handlers.get('SIGINT')!();
40+
expect(exit).toHaveBeenLastCalledWith(130);
41+
handlers.get('SIGTERM')!();
42+
expect(exit).toHaveBeenLastCalledWith(143);
43+
handlers.get('SIGHUP')!();
44+
expect(exit).toHaveBeenLastCalledWith(129);
45+
46+
// Each handler emits a leading blank line then the explanation.
47+
expect(stderr[0]).toBe('');
48+
expect(stderr.join('\n')).toContain('Interrupted (SIGINT)');
49+
expect(stderr.join('\n')).toContain('Interrupted (SIGTERM)');
50+
expect(stderr.join('\n')).toContain('Interrupted (SIGHUP)');
51+
expect(SIGINT_EXIT_CODE).toBe(130);
52+
expect(TERMINATION_EXIT_CODES.SIGTERM).toBe(143);
53+
expect(TERMINATION_EXIT_CODES.SIGHUP).toBe(129);
54+
});
55+
});
56+
57+
describe('installBrokenPipeGuard', () => {
58+
function makeEpipe(): NodeJS.ErrnoException {
59+
return Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
60+
}
61+
62+
it('exits 0 on stdout EPIPE (clean SIGPIPE-equivalent for `| head`)', () => {
63+
const stdout = new EventEmitter();
64+
const stderr = new EventEmitter();
65+
const exit = vi.fn();
66+
installBrokenPipeGuard({ stdout, stderr, exit });
67+
68+
stdout.emit('error', makeEpipe());
69+
expect(exit).toHaveBeenCalledWith(0);
70+
});
71+
72+
it('re-throws a non-EPIPE stdout error instead of silently swallowing it', () => {
73+
const stdout = new EventEmitter();
74+
const stderr = new EventEmitter();
75+
const exit = vi.fn();
76+
installBrokenPipeGuard({ stdout, stderr, exit });
77+
78+
expect(() =>
79+
stdout.emit('error', Object.assign(new Error('boom'), { code: 'ENOSPC' })),
80+
).toThrow('boom');
81+
expect(exit).not.toHaveBeenCalled();
82+
});
83+
84+
it('swallows stderr EPIPE without exiting or throwing', () => {
85+
const stdout = new EventEmitter();
86+
const stderr = new EventEmitter();
87+
const exit = vi.fn();
88+
installBrokenPipeGuard({ stdout, stderr, exit });
89+
90+
expect(() => stderr.emit('error', makeEpipe())).not.toThrow();
91+
expect(exit).not.toHaveBeenCalled();
92+
});
93+
});

src/lib/interrupt.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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

Comments
 (0)