Skip to content

Commit 5464d43

Browse files
committed
Fix a subprocess which failed to spawn signaling the current process and never ending its streams
1 parent 9842f1a commit 5464d43

3 files changed

Lines changed: 129 additions & 18 deletions

File tree

‎lib/return/early-error.js‎

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {ChildProcess} from 'node:child_process';
2+
import {callbackify} from 'node:util';
23
import {
34
PassThrough,
45
Readable,
@@ -15,6 +16,9 @@ export const handleEarlyError = ({error, command, escapedCommand, fileDescriptor
1516
cleanupCustomStreams(fileDescriptors);
1617

1718
const subprocess = new ChildProcess();
19+
// The subprocess never spawned, so there is no process to signal.
20+
// `ChildProcess.prototype.kill()` would signal the current process group instead, since its process id is `0`.
21+
subprocess.kill = () => false;
1822
const all = createDummyStreams(subprocess, fileDescriptors);
1923

2024
const earlyError = makeEarlyError({
@@ -31,16 +35,7 @@ export const handleEarlyError = ({error, command, escapedCommand, fileDescriptor
3135
subprocess,
3236
promise,
3337
all: options.all ? all : undefined,
34-
convertedStreams: {
35-
readable,
36-
writable,
37-
duplex,
38-
readableStream,
39-
writableStream,
40-
transformStream,
41-
iterable,
42-
[Symbol.asyncIterator]: iterable,
43-
},
38+
convertedStreams: getConvertedStreams(promise),
4439
};
4540
};
4641

@@ -66,12 +61,43 @@ const createDummyStream = () => {
6661
return stream;
6762
};
6863

69-
const readable = () => new Readable({read() {}});
70-
const writable = () => new Writable({write() {}});
71-
const duplex = () => new Duplex({read() {}, write() {}});
72-
const readableStream = () => Readable.toWeb(readable());
73-
const writableStream = () => Writable.toWeb(writable());
74-
const transformStream = () => Duplex.toWeb(duplex());
75-
const iterable = async function * () {};
64+
// The subprocess never started, so those streams have no contents.
65+
// Like when the subprocess did start, they end once its promise resolves, or error with its error, as opposed to hanging forever.
66+
const getConvertedStreams = promise => {
67+
const waitForPromise = async () => {
68+
await promise;
69+
};
70+
71+
const endReadable = function () {
72+
callbackify(waitForPromise)(error => {
73+
if (error) {
74+
this.destroy(error);
75+
} else {
76+
this.push(null);
77+
}
78+
});
79+
};
80+
81+
const endWritable = callbackify(waitForPromise);
82+
const writeWritable = (chunk, encoding, done) => {
83+
endWritable(done);
84+
};
85+
86+
const readable = () => new Readable({read: endReadable});
87+
const writable = () => new Writable({write: writeWritable, final: endWritable});
88+
const duplex = () => new Duplex({read: endReadable, write: writeWritable, final: endWritable});
89+
const iterable = () => readable()[Symbol.asyncIterator]();
90+
91+
return {
92+
readable,
93+
writable,
94+
duplex,
95+
readableStream: () => Readable.toWeb(readable()),
96+
writableStream: () => Writable.toWeb(writable()),
97+
transformStream: () => Duplex.toWeb(duplex()),
98+
iterable,
99+
[Symbol.asyncIterator]: iterable,
100+
};
101+
};
76102

77103
const handleDummyPromise = async (error, verboseInfo, options) => handleResult(error, verboseInfo, options);

‎lib/terminate/kill-descendants.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ export const getSpawnOptions = options => options.killDescendants && !isWindows
1313
? {...options, detached: true}
1414
: options;
1515

16+
/*
17+
`subprocess.pid` is `undefined` when the subprocess failed to spawn, e.g. with `ENOENT`.
18+
`ChildProcess.prototype.kill()` then signals the current process group, since its process id is `0`.
19+
So, like `ChildProcess.prototype.kill()` does for a subprocess which already exited, nothing is signaled and `false` is returned.
20+
*/
21+
1622
// Returns the low-level function used to send a signal to the subprocess.
1723
// With the `killDescendants` option, the signal is sent to the whole process tree.
1824
export const getKillFunction = (subprocess, {killDescendants}) => {
1925
if (!killDescendants) {
20-
return subprocess.kill.bind(subprocess);
26+
const killSubprocess = subprocess.kill.bind(subprocess);
27+
return (...arguments_) => (subprocess.pid === undefined ? false : killSubprocess(...arguments_));
2128
}
2229

2330
const killDescendantsFunction = isWindows ? killDescendantsWindows : killDescendantsUnix;

‎test/return/early-error.js‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {arch} from 'node:os';
22
import process from 'node:process';
3+
import {finished} from 'node:stream/promises';
34
import test from 'ava';
45
import {execa, execaSync, $} from '../../index.js';
56
import {setFixtureDirectory} from '../helpers/fixtures-directory.js';
7+
import {foobarString, foobarUint8Array} from '../helpers/input.js';
68
import {fullStdio} from '../helpers/stdio.js';
79
import {
810
earlyErrorOptions,
@@ -17,6 +19,14 @@ setFixtureDirectory();
1719
const isWindows = process.platform === 'win32';
1820
const ENOENT_REGEXP = isWindows ? /failed with exit code 1/ : /spawn.* ENOENT/;
1921

22+
// A nonexistent command fails asynchronously, so it does not go through the early error path, but the subprocess still has no process to signal
23+
test('kill() does not signal the current process when the command does not exist', async t => {
24+
const subprocess = execa('nonexistent-command-for-execa');
25+
t.is(subprocess.pid, undefined);
26+
t.is(subprocess.kill(), false);
27+
t.is((await t.throwsAsync(subprocess)).code, 'ENOENT');
28+
});
29+
2030
test('execaSync() throws error if ENOENT', t => {
2131
t.throws(() => {
2232
execaSync('foo');
@@ -35,6 +45,26 @@ const testEarlyErrorShape = async (t, reject) => {
3545
test('child_process.spawn() early errors have correct shape', testEarlyErrorShape, true);
3646
test('child_process.spawn() early errors have correct shape - reject false', testEarlyErrorShape, false);
3747

48+
// The subprocess never spawned, so there is no process to signal.
49+
// `ChildProcess.prototype.kill()` would signal the current process group instead, since its process id is `0`.
50+
// The handle is replaced by a stub, so that a regression is caught by an assertion instead of killing this test process.
51+
test('kill() does not signal the current process on early errors', async t => {
52+
const subprocess = getEarlyErrorSubprocess({reject: false});
53+
let isSignaled = false;
54+
subprocess.nodeChildProcess._handle = {
55+
kill() {
56+
isSignaled = true;
57+
return 0;
58+
},
59+
};
60+
61+
t.is(subprocess.kill(), false);
62+
t.false(isSignaled);
63+
const {failed, isCanceled} = await subprocess;
64+
t.true(failed);
65+
t.false(isCanceled);
66+
});
67+
3868
test('child_process.spawn() early errors are propagated', async t => {
3969
await t.throwsAsync(getEarlyErrorSubprocess(), expectedEarlyError);
4070
});
@@ -113,6 +143,54 @@ test('child_process.spawn() early errors can use .readableStream()', testEarlyEr
113143
test('child_process.spawn() early errors can use .writableStream()', testEarlyErrorWebConvertor, 'writableStream');
114144
test('child_process.spawn() early errors can use .transformStream()', testEarlyErrorWebConvertor, 'transformStream');
115145

146+
// The subprocess never started, so those streams have no contents.
147+
// They must end right away, like `subprocess.stdout` and `.iterable()` do, instead of hanging forever.
148+
const testEarlyErrorReadableEnd = async (t, streamMethod) => {
149+
const subprocess = getEarlyErrorSubprocess();
150+
const chunks = await Array.fromAsync(subprocess[streamMethod]());
151+
152+
t.deepEqual(chunks, []);
153+
await t.throwsAsync(subprocess);
154+
};
155+
156+
test('child_process.spawn() early errors end .readable()', testEarlyErrorReadableEnd, 'readable');
157+
test('child_process.spawn() early errors end .duplex()', testEarlyErrorReadableEnd, 'duplex');
158+
test('child_process.spawn() early errors end .readableStream()', testEarlyErrorReadableEnd, 'readableStream');
159+
160+
const testEarlyErrorWritableEnd = async (t, streamMethod) => {
161+
const subprocess = getEarlyErrorSubprocess();
162+
const stream = subprocess[streamMethod]();
163+
stream.end(foobarString);
164+
await finished(stream, {readable: false});
165+
166+
t.false(stream.writable);
167+
await t.throwsAsync(subprocess);
168+
};
169+
170+
test('child_process.spawn() early errors end .writable()', testEarlyErrorWritableEnd, 'writable');
171+
test('child_process.spawn() early errors end .duplex() writable side', testEarlyErrorWritableEnd, 'duplex');
172+
173+
test('child_process.spawn() early errors end .writableStream()', async t => {
174+
const subprocess = getEarlyErrorSubprocess();
175+
const writer = subprocess.writableStream().getWriter();
176+
await writer.write(foobarUint8Array);
177+
await writer.close();
178+
179+
await t.throwsAsync(subprocess);
180+
});
181+
182+
test('child_process.spawn() early errors end .transformStream()', async t => {
183+
const subprocess = getEarlyErrorSubprocess();
184+
const {readable, writable} = subprocess.transformStream();
185+
const writer = writable.getWriter();
186+
await writer.write(foobarUint8Array);
187+
await writer.close();
188+
const chunks = await Array.fromAsync(readable);
189+
190+
t.deepEqual(chunks, []);
191+
await t.throwsAsync(subprocess);
192+
});
193+
116194
const testEarlyErrorIpc = async (t, runIpcMethod) => {
117195
const subprocess = getEarlyErrorSubprocess({ipc: true});
118196
t.throws(() => {

0 commit comments

Comments
 (0)