Skip to content

Commit f6de9be

Browse files
committed
Fix subprocesses hanging after exceeding maxBuffer
Closing the output stream does not stop a subprocess that stays alive without writing again, so Execa's promise never settled. The subprocess is now terminated with the `killSignal` option when the limit is exceeded, like with synchronous methods and the other termination options. The error reports the real exit of the subprocess, which might have exited on its own before being terminated.
1 parent e747872 commit f6de9be

8 files changed

Lines changed: 135 additions & 16 deletions

File tree

‎docs/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,7 @@ By default, this applies to both `stdout` and `stderr`, but [different values ca
10351035

10361036
With [synchronous methods](execution.md#synchronous-execution), this cannot be enforced per file descriptor while the subprocess runs, since `spawnSync()` only accepts a single value, which limits the total output of all file descriptors. The `maxBuffer.stdout` value is used for it. The other file descriptors' own values are enforced from the output, once the subprocess ended, and cannot be higher than `maxBuffer.stdout`. Also, with synchronous methods, this is measured in bytes instead of [characters or lines](output.md#big-output), for the same reason.
10371037

1038-
When reached, [`error.isMaxBuffer`](#errorismaxbuffer) becomes `true`.
1038+
When reached, [`error.isMaxBuffer`](#errorismaxbuffer) becomes `true` and the subprocess is terminated with the [`killSignal`](#optionskillsignal) option.
10391039

10401040
[More info.](output.md#big-output)
10411041

‎docs/errors.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The subprocess can fail for other reasons. Some of them can be detected using a
5656
- [`error.isCanceled`](api.md#erroriscanceled): [`cancelSignal`](termination.md#canceling) option.
5757
- [`error.isGracefullyCanceled`](api.md#errorisgracefullycanceled): `cancelSignal` option, if the [`gracefulCancel`](termination.md#graceful-termination) option is `true`.
5858
- [`error.isMaxBuffer`](api.md#errorismaxbuffer): [`maxBuffer`](output.md#big-output) option.
59-
- [`error.isTerminated`](api.md#erroristerminated): [signal termination](termination.md#signal-termination). This includes the [`timeout`](termination.md#timeout) and [`forceKillAfterDelay`](termination.md#forceful-termination) options since those terminate the subprocess with a [signal](termination.md#default-signal). This also includes the [`cancelSignal`](termination.md#canceling) option unless the [`gracefulCancel`](termination.md#graceful-termination) option is `true`. This does not include the [`maxBuffer`](output.md#big-output) option.
59+
- [`error.isTerminated`](api.md#erroristerminated): [signal termination](termination.md#signal-termination). This includes the [`timeout`](termination.md#timeout) and [`forceKillAfterDelay`](termination.md#forceful-termination) options since those terminate the subprocess with a [signal](termination.md#default-signal). This also includes the [`cancelSignal`](termination.md#canceling) option unless the [`gracefulCancel`](termination.md#graceful-termination) option is `true`, and the [`maxBuffer`](output.md#big-output) option, since reaching the limit terminates the subprocess with the [`killSignal`](api.md#optionskillsignal) option, unless the subprocess exits on its own, for example by handling `killSignal`. With [synchronous methods](execution.md#synchronous-execution), the subprocess is only terminated when its total output exceeds `maxBuffer.stdout`.
6060

6161
Otherwise, the subprocess failed because either:
6262
- An exception was thrown in a [stream](streams.md) or [transform](transform.md).

‎docs/output.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ await execa({stdin: 'ignore', stdout: 'ignore', stderr: 'ignore'})`npm run build
171171

172172
To prevent high memory consumption, a maximum output size can be set using the [`maxBuffer`](api.md#optionsmaxbuffer) option. It defaults to 100MB.
173173

174-
When this threshold is hit, the subprocess fails and [`error.isMaxBuffer`](api.md#errorismaxbuffer) becomes `true`. The truncated output is still available using [`error.stdout`](api.md#resultstdout), [`error.stderr`](api.md#resultstderr) and [`error.ipcOutput`](api.md#resultipcoutput).
174+
When this threshold is hit, [`error.isMaxBuffer`](api.md#errorismaxbuffer) becomes `true` and the subprocess is terminated with the [`killSignal`](api.md#optionskillsignal) option. The truncated output is still available using [`error.stdout`](api.md#resultstdout), [`error.stderr`](api.md#resultstderr) and [`error.ipcOutput`](api.md#resultipcoutput).
175175

176176
This is measured:
177177
- By default: in [characters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length).

‎lib/resolve/wait-subprocess.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {once} from 'node:events';
22
import {isStream as isNodeStream} from 'is-stream';
3+
import {MaxBufferError} from 'get-stream';
34
import {throwOnTimeout} from '../terminate/timeout.js';
45
import {throwOnCancel} from '../terminate/cancel.js';
56
import {throwOnGracefulCancel} from '../terminate/graceful.js';
@@ -114,6 +115,11 @@ export const waitForSubprocessResult = async ({
114115
]);
115116
} catch (error) {
116117
context.terminationReason ??= 'other';
118+
// Closing the output stream does not stop a subprocess that stays alive without writing again.
119+
if (error instanceof MaxBufferError) {
120+
kill();
121+
}
122+
117123
return Promise.all([
118124
{error},
119125
exitPromise,

‎test/fixtures/max-buffer-forever.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,15 @@ import {getWriteStream} from '../helpers/fs.js';
44

55
const fdNumber = Number(process.argv[2]);
66
const bytes = '.'.repeat(Number(process.argv[3]));
7+
// The signal is ignored, or makes the subprocess exit with a specific exit code
8+
const [signal, exitCode] = process.argv.slice(4);
9+
if (signal !== undefined) {
10+
process.on(signal, () => {
11+
if (exitCode !== undefined) {
12+
process.exit(Number(exitCode));
13+
}
14+
});
15+
}
16+
717
getWriteStream(fdNumber).write(bytes);
818
setTimeout(() => {}, 1e8);

‎test/io/max-buffer.js‎

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from 'node:assert/strict';
22
import {Buffer} from 'node:buffer';
3+
import {platform} from 'node:process';
34
import {readFile, rm} from 'node:fs/promises';
45
import test from 'node:test';
56
import getStream from 'get-stream';
@@ -17,6 +18,97 @@ setFixtureDirectory();
1718
const maxBufferMessage = {message: /maxBuffer exceeded/};
1819
const maxBufferCodeSync = {code: 'ENOBUFS'};
1920

21+
// Bound the regression even when exceeding maxBuffer does not terminate the subprocess.
22+
const assertMaxBufferRejects = async subprocess => {
23+
const cleanup = setTimeout(() => {
24+
subprocess.kill('SIGKILL');
25+
}, 5000);
26+
try {
27+
const error = await assertRejects(subprocess);
28+
assert.equal(error.isMaxBuffer, true);
29+
return error;
30+
} finally {
31+
clearTimeout(cleanup);
32+
}
33+
};
34+
35+
const assertMaxBufferTermination = async (subprocess, expectedSignal = 'SIGTERM') => {
36+
const error = await assertMaxBufferRejects(subprocess);
37+
assert.equal(error.isTerminated, true);
38+
assert.equal(error.signal, expectedSignal);
39+
assert.equal(error.exitCode, undefined);
40+
assert.equal(error.timedOut, false);
41+
return error;
42+
};
43+
44+
const testAliveMaxBuffer = async (fdNumber, options = {}) => {
45+
const error = await assertMaxBufferTermination(execa('max-buffer-forever.js', [`${fdNumber}`, '100'], {...fullStdio, maxBuffer: 5, ...options}), options.killSignal);
46+
assert.equal(error.stdio[fdNumber], '.....');
47+
};
48+
49+
test('maxBuffer terminates a subprocess that stays alive without more output', () => testAliveMaxBuffer(1));
50+
test('maxBuffer terminates a subprocess that stays alive without more stderr', () => testAliveMaxBuffer(2));
51+
test('maxBuffer terminates a subprocess that stays alive without more stdio[3]', () => testAliveMaxBuffer(3));
52+
test('maxBuffer termination uses killSignal', () => testAliveMaxBuffer(1, {killSignal: 'SIGINT'}));
53+
54+
// Windows terminates immediately instead of delivering catchable signals.
55+
if (platform !== 'win32') {
56+
test('maxBuffer termination forcefully kills a subprocess ignoring killSignal', async () => {
57+
const error = await assertMaxBufferTermination(execa('max-buffer-forever.js', ['1', '100', 'SIGINT'], {
58+
maxBuffer: 5,
59+
killSignal: 'SIGINT',
60+
forceKillAfterDelay: 50,
61+
}), 'SIGKILL');
62+
assert.equal(error.isForcefullyTerminated, true);
63+
assert.equal(error.stdout, '.....');
64+
});
65+
66+
test('maxBuffer termination reports the exit code of a subprocess handling killSignal', async () => {
67+
const error = await assertMaxBufferRejects(execa('max-buffer-forever.js', ['1', '100', 'SIGTERM', '5'], {maxBuffer: 5}));
68+
assert.equal(error.isTerminated, false);
69+
assert.equal(error.signal, undefined);
70+
assert.equal(error.exitCode, 5);
71+
assert.equal(error.stdout, '.....');
72+
});
73+
}
74+
75+
test('maxBuffer termination applies to lines', async () => {
76+
const error = await assertMaxBufferTermination(execa('max-buffer-forever.js', ['1', '100'], {
77+
maxBuffer: 1,
78+
lines: true,
79+
stdout: {
80+
* transform() {
81+
yield 'first\nsecond\n';
82+
},
83+
binary: true,
84+
},
85+
}));
86+
assert.deepEqual(error.stdout, ['first']);
87+
assertErrorMessage(error.shortMessage, {length: 1, unit: 'lines'});
88+
});
89+
90+
test('maxBuffer termination applies to object mode', async () => {
91+
const error = await assertMaxBufferTermination(execa('max-buffer-forever.js', ['1', '100'], {
92+
maxBuffer: 1,
93+
stdout: {
94+
* transform() {
95+
yield {index: 1};
96+
yield {index: 2};
97+
},
98+
binary: true,
99+
objectMode: true,
100+
},
101+
}));
102+
assert.deepEqual(error.stdout, [{index: 1}]);
103+
assertErrorMessage(error.shortMessage, {length: 1, unit: 'objects'});
104+
});
105+
106+
test('maxBuffer termination applies to IPC messages', async () => {
107+
const error = await assertMaxBufferTermination(execa('ipc-send-forever.js', {ipc: true, maxBuffer: {ipc: 0}}));
108+
assert.deepEqual(error.ipcOutput, []);
109+
assert.ok(error.shortMessage.includes('IPC output was larger than 0 messages'));
110+
});
111+
20112
const runMaxBuffer = async (execaMethod, fdNumber, options) => {
21113
const error = execaMethod === execa
22114
? await assertRejects(getMaxBufferSubprocess(execaMethod, fdNumber, options), maxBufferMessage)
@@ -47,20 +139,31 @@ test('maxBuffer does not affect stderr if too high, sync', () => testMaxBufferSu
47139
test('maxBuffer does not affect stdio[*] if too high, sync', () => testMaxBufferSuccess(execaSync, 3, false));
48140
test('maxBuffer does not affect all if too high, sync', () => testMaxBufferSuccess(execaSync, 1, true));
49141

50-
const testGracefulExit = async (fixtureName, expectedExitCode) => {
51-
const {isMaxBuffer, shortMessage, exitCode, signal, stdout} = await assertRejects(
142+
// The subprocess might exit on its own before it is terminated, in which case its real exit is reported
143+
const assertMaxBufferExit = ({exitCode, signal, isTerminated}, expectedExitCode) => {
144+
if (signal === undefined) {
145+
assert.equal(isTerminated, false);
146+
assert.equal(exitCode, expectedExitCode);
147+
} else {
148+
assert.equal(isTerminated, true);
149+
assert.equal(signal, 'SIGTERM');
150+
assert.equal(exitCode, undefined);
151+
}
152+
};
153+
154+
const testMaxBufferExit = async (fixtureName, expectedExitCode) => {
155+
const error = await assertRejects(
52156
execa(fixtureName, ['1', '.'.repeat(maxBuffer + 1)], {maxBuffer}),
53157
maxBufferMessage,
54158
);
55-
assert.equal(isMaxBuffer, true);
56-
assertErrorMessage(shortMessage);
57-
assert.equal(exitCode, expectedExitCode);
58-
assert.equal(signal, undefined);
59-
assert.equal(stdout, getExpectedOutput());
159+
assert.equal(error.isMaxBuffer, true);
160+
assertMaxBufferExit(error, expectedExitCode);
161+
assertErrorMessage(error.shortMessage);
162+
assert.equal(error.stdout, getExpectedOutput());
60163
};
61164

62-
test('maxBuffer terminates stream gracefully, more writes', () => testGracefulExit('noop-repeat.js', 1));
63-
test('maxBuffer terminates stream gracefully, no more writes', () => testGracefulExit('noop-fd.js', 0));
165+
test('maxBuffer terminates subprocess, more writes', () => testMaxBufferExit('noop-repeat.js', 1));
166+
test('maxBuffer terminates subprocess, no more writes', () => testMaxBufferExit('noop-fd.js', 0));
64167

65168
const testGracefulExitSync = fixtureName => {
66169
const {isMaxBuffer, shortMessage, exitCode, signal, stdout} = assertThrows(() => {
@@ -173,6 +276,8 @@ test('maxBuffer.stderr is used when lower than maxBuffer.stdout, sync', () => {
173276
assertErrorMessage(error.shortMessage, {execaMethod: execaSync, fdNumber: 2, length});
174277
assert.equal(error.stdout, getExpectedOutput(maxBuffer + 1));
175278
assert.equal(error.stderr, getExpectedOutput(length));
279+
// Unlike asynchronous methods, the subprocess is not terminated, since it already exited
280+
assert.equal(error.isTerminated, false);
176281
assert.equal(error.signal, undefined);
177282
assert.equal(error.exitCode, 0);
178283
});
@@ -184,8 +289,7 @@ test('maxBuffer.stderr is used when lower than maxBuffer.stdout', async () => {
184289
assertErrorMessage(error.shortMessage, {fdNumber: 2, length});
185290
assert.equal(error.stdout, getExpectedOutput(maxBuffer + 1));
186291
assert.equal(error.stderr, getExpectedOutput(length));
187-
assert.equal(error.signal, undefined);
188-
assert.equal(error.exitCode, 0);
292+
assertMaxBufferExit(error, 0);
189293
});
190294

191295
test('maxBuffer is not hit when under each file descriptor\'s value, sync', () => {

‎test/terminate/kill-force.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ if (isWindows) {
146146
const subprocess = execa('noop-forever.js', ['.'], {...noKillableSimpleOptions, maxBuffer: 1});
147147
const [chunk] = await once(subprocess.stdout, 'data');
148148
assert.equal(chunk.toString(), '.\n');
149-
subprocess.kill();
150149
const {isTerminated, signal, isForcefullyTerminated, shortMessage} = await assertRejects(subprocess);
151150
assert.equal(isTerminated, true);
152151
assert.equal(signal, 'SIGKILL');

‎types/arguments/options.d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export type CommonOptions<
194194
195195
With synchronous methods, this cannot be enforced per file descriptor while the subprocess runs, since `spawnSync()` only accepts a single value, which limits the total output of all file descriptors. The `maxBuffer.stdout` value is used for it. The other file descriptors' own values are enforced from the output, once the subprocess ended, and cannot be higher than `maxBuffer.stdout`. Also, with synchronous methods, this is measured in bytes instead of characters or lines, for the same reason.
196196
197-
When reached, `error.isMaxBuffer` becomes `true`.
197+
When reached, `error.isMaxBuffer` becomes `true` and the subprocess is terminated with the `killSignal` option.
198198
199199
@default 100_000_000
200200
*/

0 commit comments

Comments
 (0)