Skip to content

Commit d5341d6

Browse files
committed
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 73b6149 commit d5341d6

2 files changed

Lines changed: 40 additions & 1 deletion

File tree

src/lib/interrupt.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { EventEmitter } from 'node:events';
2+
import { writeSync } from 'node:fs';
23
import { describe, expect, it, vi } from 'vitest';
34
import {
45
SIGINT_EXIT_CODE,
@@ -8,6 +9,13 @@ import {
89
installSignalHandlers,
910
} from './interrupt.js';
1011

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+
1119
describe('formatInterruptMessage', () => {
1220
it('defaults to SIGINT and explains the run continues server-side', () => {
1321
const message = formatInterruptMessage();
@@ -52,6 +60,24 @@ describe('installSignalHandlers', () => {
5260
expect(TERMINATION_EXIT_CODES.SIGTERM).toBe(143);
5361
expect(TERMINATION_EXIT_CODES.SIGHUP).toBe(129);
5462
});
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+
});
5581
});
5682

5783
describe('installBrokenPipeGuard', () => {

src/lib/interrupt.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* without spawning a subprocess or sending a real signal.
2020
*/
2121

22+
import { writeSync } from 'node:fs';
23+
2224
/**
2325
* Termination signals handled, mapped to their conventional `128 + signum`
2426
* exit code. sourceRef: POSIX signal numbers (SIGHUP=1, SIGINT=2, SIGTERM=15).
@@ -60,7 +62,18 @@ export function installSignalHandlers(deps: InterruptDeps = {}): void {
6062
((signal: TerminationSignal, handler: () => void) => {
6163
process.on(signal, handler);
6264
});
63-
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
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+
});
6477
const exit = deps.exit ?? ((code: number) => process.exit(code));
6578

6679
for (const signal of Object.keys(TERMINATION_EXIT_CODES) as TerminationSignal[]) {

0 commit comments

Comments
 (0)