Skip to content

Commit 8914906

Browse files
committed
Flush stdout/stderr before exiting in async path
Wait for stdout and stderr to drain before calling `process.exit()` in the async code path (`gracefulExit`, `signals`, `beforeExit`). This prevents output truncation when hooks write significant data to stdout/stderr. Fixes #34
1 parent cf7ffcd commit 8914906

8 files changed

Lines changed: 225 additions & 3 deletions

fixtures/closed-stdio.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import process from 'node:process';
2+
import {asyncExitHook, gracefulExit} from '../index.js';
3+
4+
process.stdout.end();
5+
process.stderr.end();
6+
7+
asyncExitHook(() => {}, {wait: 100});
8+
9+
gracefulExit();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import process from 'node:process';
2+
import {asyncExitHook, gracefulExit} from '../index.js';
3+
4+
const lineCount = Number.parseInt(process.argv[2], 10);
5+
6+
asyncExitHook(() => {
7+
for (let i = 0; i < lineCount; i++) {
8+
process.stdout.write(`${'x'.repeat(200)}\n`);
9+
}
10+
}, {wait: 10});
11+
12+
setTimeout(() => {
13+
gracefulExit();
14+
}, 50);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import process from 'node:process';
2+
import {asyncExitHook, gracefulExit} from '../index.js';
3+
4+
const lineCount = Number.parseInt(process.argv[2], 10);
5+
6+
asyncExitHook(() => {
7+
for (let i = 0; i < lineCount; i++) {
8+
process.stdout.write(`${'x'.repeat(200)}\n`);
9+
process.stderr.write(`${'y'.repeat(200)}\n`);
10+
}
11+
}, {wait: 10});
12+
13+
setTimeout(() => {
14+
gracefulExit();
15+
}, 50);

fixtures/flush-stdout-sync.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import process from 'node:process';
2+
import exitHook, {gracefulExit} from '../index.js';
3+
4+
const lineCount = Number.parseInt(process.argv[2], 10);
5+
6+
exitHook(() => {
7+
for (let i = 0; i < lineCount; i++) {
8+
process.stdout.write(`${'x'.repeat(200)}\n`);
9+
}
10+
});
11+
12+
gracefulExit();

fixtures/flush-stdout-timeout.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import process from 'node:process';
2+
import {asyncExitHook, gracefulExit} from '../index.js';
3+
4+
const chunk = 'x'.repeat(1024 * 1024);
5+
6+
asyncExitHook(() => {
7+
for (let chunkIndex = 0; chunkIndex < 5; chunkIndex++) {
8+
const canContinue = process.stdout.write(`${chunk}\n`);
9+
if (!canContinue) {
10+
break;
11+
}
12+
}
13+
}, {wait: 10});
14+
15+
setTimeout(() => {
16+
gracefulExit();
17+
}, 50);

fixtures/flush-stdout.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import process from 'node:process';
2+
import {asyncExitHook, gracefulExit} from '../index.js';
3+
4+
const lineCount = Number.parseInt(process.argv[2], 10);
5+
6+
asyncExitHook(() => {
7+
for (let i = 0; i < lineCount; i++) {
8+
process.stdout.write(`${'x'.repeat(200)}\n`);
9+
}
10+
}, {wait: 2000});
11+
12+
gracefulExit();

index.js

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,41 @@ const callbacks = new Set();
66
let isCalled = false;
77
let isRegistered = false;
88

9+
async function flushStdio() {
10+
const flush = stream => new Promise(resolve => {
11+
if (!stream || !stream.writable || stream.writableEnded || stream.destroyed) {
12+
resolve();
13+
return;
14+
}
15+
16+
const onError = () => {
17+
stream.off('error', onError);
18+
resolve();
19+
};
20+
21+
stream.once('error', onError);
22+
23+
try {
24+
stream.write('', () => {
25+
stream.off('error', onError);
26+
resolve();
27+
});
28+
} catch {
29+
stream.off('error', onError);
30+
resolve();
31+
}
32+
});
33+
34+
const timeout = new Promise(resolve => {
35+
setTimeout(resolve, 1000);
36+
});
37+
38+
await Promise.race([
39+
Promise.all([flush(process.stdout), flush(process.stderr)]),
40+
timeout,
41+
]);
42+
}
43+
944
async function exit(shouldManuallyExit, isSynchronous, signal) {
1045
if (isCalled) {
1146
return;
@@ -56,12 +91,16 @@ async function exit(shouldManuallyExit, isSynchronous, signal) {
5691
}
5792

5893
// Force exit if we exceeded our wait value
59-
const asyncTimer = setTimeout(() => {
60-
done(true);
61-
}, forceAfter);
94+
const asyncTimer = forceAfter > 0
95+
? setTimeout(() => {
96+
done(true);
97+
}, forceAfter)
98+
: undefined;
6299

63100
await Promise.all(promises);
101+
// Let flushStdio handle its own timeout without the force-exit timer.
64102
clearTimeout(asyncTimer);
103+
await flushStdio();
65104
done();
66105
}
67106

test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import process from 'node:process';
2+
import {spawn} from 'node:child_process';
3+
import {once} from 'node:events';
4+
import {setTimeout as delay} from 'node:timers/promises';
25
import test from 'ava';
36
import {execa} from 'execa';
47
import exitHook, {asyncExitHook} from './index.js';
58

9+
const isContinuousIntegration = Boolean(process.env.CI);
10+
611
test('main', async t => {
712
const {stdout, stderr, exitCode} = await execa(process.execPath, ['./fixtures/sync.js']);
813
t.is(stdout, 'foo\nbar');
@@ -155,6 +160,105 @@ test('type enforcing', t => {
155160
});
156161
});
157162

163+
test('flushes stdout before exit with async hook', async t => {
164+
const lineCount = 10_000;
165+
const {stdout, exitCode} = await execa(process.execPath, ['./fixtures/flush-stdout.js', String(lineCount)]);
166+
const lines = stdout.split('\n').filter(Boolean);
167+
t.is(lines.length, lineCount);
168+
t.is(exitCode, 0);
169+
});
170+
171+
test('flushes stdout before exit with short wait and backpressure', async t => {
172+
const lineCount = 20_000;
173+
const childProcess = spawn(process.execPath, ['./fixtures/flush-stdout-short-wait.js', String(lineCount)], {
174+
stdio: ['ignore', 'pipe', 'ignore'],
175+
});
176+
177+
childProcess.stdout.setEncoding('utf8');
178+
179+
let stdout = '';
180+
childProcess.stdout.on('data', data => {
181+
stdout += data;
182+
});
183+
184+
childProcess.stdout.pause();
185+
await delay(200);
186+
childProcess.stdout.resume();
187+
188+
const [exitCode] = await once(childProcess, 'close');
189+
t.is(exitCode, 0);
190+
191+
const lines = stdout.split('\n').filter(Boolean);
192+
t.is(lines.length, lineCount);
193+
});
194+
195+
test('flushes stdout and stderr before exit with short wait and backpressure', async t => {
196+
const lineCount = 10_000;
197+
const childProcess = spawn(process.execPath, ['./fixtures/flush-stdout-stderr-short-wait.js', String(lineCount)], {
198+
stdio: ['ignore', 'pipe', 'pipe'],
199+
});
200+
201+
childProcess.stdout.setEncoding('utf8');
202+
childProcess.stderr.setEncoding('utf8');
203+
204+
let stdout = '';
205+
let stderr = '';
206+
childProcess.stdout.on('data', data => {
207+
stdout += data;
208+
});
209+
childProcess.stderr.on('data', data => {
210+
stderr += data;
211+
});
212+
213+
childProcess.stdout.pause();
214+
childProcess.stderr.pause();
215+
await delay(200);
216+
childProcess.stdout.resume();
217+
childProcess.stderr.resume();
218+
219+
const [exitCode] = await once(childProcess, 'close');
220+
t.is(exitCode, 0);
221+
222+
const stdoutLines = stdout.split('\n').filter(Boolean);
223+
const stderrLines = stderr.split('\n').filter(Boolean);
224+
t.is(stdoutLines.length, lineCount);
225+
t.is(stderrLines.length, lineCount);
226+
});
227+
228+
test('flush timeout prevents hanging when stdout is blocked', async t => {
229+
t.timeout(isContinuousIntegration ? 15_000 : 8000);
230+
231+
const startTime = Date.now();
232+
const childProcess = spawn(process.execPath, ['./fixtures/flush-stdout-timeout.js'], {
233+
stdio: ['ignore', 'pipe', 'ignore'],
234+
});
235+
const closePromise = once(childProcess, 'close');
236+
237+
childProcess.stdout.pause();
238+
await delay(1500);
239+
childProcess.stdout.resume();
240+
241+
const [exitCode] = await closePromise;
242+
const elapsedMilliseconds = Date.now() - startTime;
243+
t.is(exitCode, 0);
244+
t.true(elapsedMilliseconds >= 900);
245+
});
246+
247+
test('flushes stdout before exit with sync hook', async t => {
248+
const lineCount = 10_000;
249+
const {stdout, exitCode} = await execa(process.execPath, ['./fixtures/flush-stdout-sync.js', String(lineCount)]);
250+
const lines = stdout.split('\n').filter(Boolean);
251+
t.is(lines.length, lineCount);
252+
t.is(exitCode, 0);
253+
});
254+
255+
test('does not throw when stdio is closed before flush', async t => {
256+
const {stdout, stderr, exitCode} = await execa(process.execPath, ['./fixtures/closed-stdio.js']);
257+
t.is(stdout, '');
258+
t.is(stderr, '');
259+
t.is(exitCode, 0);
260+
});
261+
158262
const signalTests = [
159263
['SIGINT', 130],
160264
['SIGTERM', 143],

0 commit comments

Comments
 (0)