Skip to content

Commit 1cee524

Browse files
committed
Fix an uncaught error when a file target cannot be opened and the subprocess fails to spawn
1 parent 15c46cd commit 1cee524

4 files changed

Lines changed: 41 additions & 5 deletions

File tree

‎lib/stdio/handle.js‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,15 @@ const addStreamProperties = ({stdioItem, addProperties, direction, options, file
241241
// We need to create those streams before subprocess spawning, in case their creation fails, e.g. when passing an invalid generator as argument.
242242
// Like this, an exception would be thrown, which would prevent spawning a subprocess.
243243
export const cleanupCustomStreams = fileDescriptors => {
244-
for (const {stdioItems} of fileDescriptors) {
245-
for (const {stream} of stdioItems) {
246-
if (stream !== undefined && !isStandardStream(stream)) {
247-
stream.destroy();
248-
}
244+
for (const {stream} of fileDescriptors.flatMap(({stdioItems}) => stdioItems)) {
245+
if (stream === undefined || isStandardStream(stream)) {
246+
continue;
249247
}
248+
249+
// The stream can still error after being destroyed, e.g. when its file cannot be opened.
250+
// That error would be uncaught and crash the current process, although it is irrelevant since execa already fails with its own error.
251+
stream.on('error', () => {});
252+
stream.destroy();
250253
}
251254
};
252255

‎test/fixtures/early-error-file.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env node
2+
import process from 'node:process';
3+
import {execa} from '../../index.js';
4+
import {earlyErrorOptions} from '../helpers/early-error.js';
5+
6+
// The file cannot be opened, since its directory does not exist
7+
const {code} = await execa('empty.js', {...earlyErrorOptions, [process.argv[2]]: {file: '/nonexistent-directory-for-execa/file'}, reject: false});
8+
console.log(code);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env node
2+
import {execa} from '../../index.js';
3+
4+
// The file cannot be opened, since its directory does not exist.
5+
// Its stream is created before the next item is rejected, which throws before spawning.
6+
try {
7+
execa('empty.js', {stdout: [{file: '/nonexistent-directory-for-execa/file'}, ['foo']]});
8+
} catch (error) {
9+
console.log(error.message);
10+
}

‎test/return/early-error.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@ test('child_process.spawn() early errors are propagated', async () => {
9898
await assertRejects(getEarlyErrorSubprocess(), expectedEarlyError);
9999
});
100100

101+
// File targets are opened before spawning, then closed since the subprocess did not spawn.
102+
// Failing to open them must not be an uncaught exception, which would crash the current process.
103+
const testEarlyErrorFile = async optionName => {
104+
const {stdout} = await execa('early-error-file.js', [optionName]);
105+
assert.equal(stdout, expectedEarlyError.code);
106+
};
107+
108+
test('child_process.spawn() early errors do not crash if an output file cannot be opened', () => testEarlyErrorFile('stdout'));
109+
test('child_process.spawn() early errors do not crash if an input file cannot be opened', () => testEarlyErrorFile('stdin'));
110+
111+
test('Invalid stdio options do not crash if an output file cannot be opened', async () => {
112+
const {stdout} = await execa('invalid-stdio-file.js');
113+
assert.equal(stdout, 'The `stdout` option cannot be an iterable.');
114+
});
115+
101116
test('child_process.spawn() early errors are returned', async () => {
102117
const {failed} = await getEarlyErrorSubprocess({reject: false});
103118
assert.equal(failed, true);

0 commit comments

Comments
 (0)