Skip to content

Commit 946f5b3

Browse files
authored
feat(cli): graceful termination signals + broken-pipe guard (#185)
* 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 * fix(interrupt): flush the signal message synchronously before exit A signal handler calls process.exit() immediately after writing the interrupt hint. When stderr is a pipe, an async process.stderr.write() may not flush before the process terminates, so the hint could be lost. The default stderr writer now uses fs.writeSync (best-effort, guarded against EPIPE) so the hint is reliably emitted. Added a test mocking fs.writeSync to assert the synchronous write on the default path.
1 parent edc31c8 commit 946f5b3

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

src/index.ts

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

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

src/lib/interrupt.test.ts

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

src/lib/interrupt.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
import { writeSync } from 'node:fs';
23+
24+
/**
25+
* Termination signals handled, mapped to their conventional `128 + signum`
26+
* exit code. sourceRef: POSIX signal numbers (SIGHUP=1, SIGINT=2, SIGTERM=15).
27+
*/
28+
export const TERMINATION_EXIT_CODES = {
29+
SIGINT: 130, // 128 + 2
30+
SIGTERM: 143, // 128 + 15
31+
SIGHUP: 129, // 128 + 1
32+
} as const;
33+
34+
export type TerminationSignal = keyof typeof TERMINATION_EXIT_CODES;
35+
36+
/** Back-compat alias: SIGINT's conventional exit code. */
37+
export const SIGINT_EXIT_CODE = TERMINATION_EXIT_CODES.SIGINT;
38+
39+
export function formatInterruptMessage(signal: TerminationSignal = 'SIGINT'): string {
40+
return (
41+
`Interrupted (${signal}). Any run already started keeps executing on the server; ` +
42+
'check it with `testsprite test list` or `testsprite test wait <runId>`.'
43+
);
44+
}
45+
46+
export interface InterruptDeps {
47+
/** Signal registrar. Defaults to `process.on`. */
48+
on?: (signal: TerminationSignal, handler: () => void) => void;
49+
/** Line-oriented stderr writer (appends a newline). */
50+
stderr?: (line: string) => void;
51+
/** Process exit. Defaults to `process.exit`. */
52+
exit?: (code: number) => void;
53+
}
54+
55+
/**
56+
* Register handlers for SIGINT, SIGTERM and SIGHUP. Idempotent enough for a
57+
* single top-level call in `index.ts`; not designed to be installed twice.
58+
*/
59+
export function installSignalHandlers(deps: InterruptDeps = {}): void {
60+
const on =
61+
deps.on ??
62+
((signal: TerminationSignal, handler: () => void) => {
63+
process.on(signal, handler);
64+
});
65+
const stderr =
66+
deps.stderr ??
67+
((line: string) => {
68+
// A signal handler calls process.exit() right after writing, which can
69+
// truncate an async process.stderr.write() when stderr is a pipe. Write
70+
// synchronously so the interrupt hint is flushed before the process exits.
71+
try {
72+
writeSync(process.stderr.fd, `${line}\n`);
73+
} catch {
74+
// Best-effort: if stderr is already gone (EPIPE), still exit cleanly.
75+
}
76+
});
77+
const exit = deps.exit ?? ((code: number) => process.exit(code));
78+
79+
for (const signal of Object.keys(TERMINATION_EXIT_CODES) as TerminationSignal[]) {
80+
on(signal, () => {
81+
// Blank line first so the message starts on its own row rather than
82+
// trailing the progress ticker's in-place line.
83+
stderr('');
84+
stderr(formatInterruptMessage(signal));
85+
exit(TERMINATION_EXIT_CODES[signal]);
86+
});
87+
}
88+
}
89+
90+
export interface BrokenPipeDeps {
91+
/** stdout stream. Defaults to `process.stdout`. */
92+
stdout?: NodeJS.EventEmitter;
93+
/** stderr stream. Defaults to `process.stderr`. */
94+
stderr?: NodeJS.EventEmitter;
95+
/** Process exit. Defaults to `process.exit`. */
96+
exit?: (code: number) => void;
97+
}
98+
99+
/**
100+
* Guard against `EPIPE` on stdout/stderr so piping to a reader that closes
101+
* early (`testsprite ... | head`) exits cleanly instead of crashing with an
102+
* unhandled `write EPIPE` stack. Only `EPIPE` is swallowed; any other stream
103+
* error is left to surface normally.
104+
*/
105+
export function installBrokenPipeGuard(deps: BrokenPipeDeps = {}): void {
106+
const stdout = deps.stdout ?? process.stdout;
107+
const stderr = deps.stderr ?? process.stderr;
108+
const exit = deps.exit ?? ((code: number) => process.exit(code));
109+
110+
stdout.on('error', (error: NodeJS.ErrnoException) => {
111+
// Reader went away (`| head`, `| less` then q): exit cleanly like SIGPIPE
112+
// rather than dumping an unhandled `write EPIPE` stack. Any other stdout
113+
// error is a genuine, actionable failure, so re-throw it (Node's default).
114+
if (error.code === 'EPIPE') {
115+
exit(0);
116+
return;
117+
}
118+
throw error;
119+
});
120+
stderr.on('error', (error: NodeJS.ErrnoException) => {
121+
// stderr closed: nothing can be reported over it, so swallow EPIPE. Any
122+
// other error re-throws so a genuine failure is not silently hidden.
123+
if (error.code === 'EPIPE') return;
124+
throw error;
125+
});
126+
}

0 commit comments

Comments
 (0)