Skip to content

Commit 20b2ee6

Browse files
authored
Fix unhandled rejections and missing data (#658)
1 parent 6cf1c5e commit 20b2ee6

File tree

6 files changed

+56
-23
lines changed

6 files changed

+56
-23
lines changed

index.js

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {fileURLToPath} from 'node:url';
66
import crossSpawn from 'cross-spawn';
77
import stripFinalNewline from 'strip-final-newline';
88
import {npmRunPathEnv} from 'npm-run-path';
9-
import onetime from 'onetime';
109
import {makeError} from './lib/error.js';
1110
import {handleInputAsync, pipeOutputAsync} from './lib/stdio/async.js';
1211
import {handleInputSync, pipeOutputSync} from './lib/stdio/sync.js';
@@ -127,27 +126,27 @@ export function execa(rawFile, rawArgs, rawOptions) {
127126
return dummySpawned;
128127
}
129128

130-
const spawnedPromise = getSpawnedPromise(spawned);
131-
const timedPromise = setupTimeout(spawned, options, spawnedPromise);
132-
const processDone = setExitHandler(spawned, options, timedPromise);
133-
134129
const context = {isCanceled: false};
135130

136131
spawned.kill = spawnedKill.bind(null, spawned.kill.bind(spawned));
137132
spawned.cancel = spawnedCancel.bind(null, spawned, context);
138133

139-
const handlePromiseOnce = onetime(handlePromise.bind(undefined, {spawned, options, context, stdioStreams, command, escapedCommand, processDone}));
140-
141134
pipeOutputAsync(spawned, stdioStreams);
142135

143136
spawned.all = makeAllStream(spawned, options);
144137

145138
addPipeMethods(spawned);
146-
mergePromise(spawned, handlePromiseOnce);
139+
140+
const promise = handlePromise({spawned, options, context, stdioStreams, command, escapedCommand});
141+
mergePromise(spawned, promise);
147142
return spawned;
148143
}
149144

150-
const handlePromise = async ({spawned, options, context, stdioStreams, command, escapedCommand, processDone}) => {
145+
const handlePromise = async ({spawned, options, context, stdioStreams, command, escapedCommand}) => {
146+
const spawnedPromise = getSpawnedPromise(spawned);
147+
const timedPromise = setupTimeout(spawned, options, spawnedPromise);
148+
const processDone = setExitHandler(spawned, options, timedPromise);
149+
151150
const [
152151
{error, exitCode, signal, timedOut},
153152
stdoutResult,

lib/promise.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,7 @@ const descriptors = ['then', 'catch', 'finally'].map(property => [
1111
// The return value is a mixin of `childProcess` and `Promise`
1212
export const mergePromise = (spawned, promise) => {
1313
for (const [property, descriptor] of descriptors) {
14-
// Starting the main `promise` is deferred to avoid consuming streams
15-
const value = typeof promise === 'function'
16-
? (...args) => Reflect.apply(descriptor.value, promise(), args)
17-
: descriptor.value.bind(promise);
18-
14+
const value = descriptor.value.bind(promise);
1915
Reflect.defineProperty(spawned, property, {...descriptor, value});
2016
}
2117
};

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@
5151
"human-signals": "^6.0.0",
5252
"is-stream": "^3.0.0",
5353
"npm-run-path": "^5.2.0",
54-
"onetime": "^7.0.0",
5554
"signal-exit": "^4.1.0",
5655
"strip-final-newline": "^4.0.0"
5756
},

test/fixtures/no-await.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env node
2+
import process from 'node:process';
3+
import {once} from 'node:events';
4+
import {execa} from '../../index.js';
5+
6+
const [options, file, ...args] = process.argv.slice(2);
7+
execa(file, args, JSON.parse(options));
8+
const [error] = await once(process, 'unhandledRejection');
9+
console.log(error.shortMessage);

test/promise.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,11 @@ test('throw in finally bubbles up on error', async t => {
4343
}));
4444
t.is(message, 'called');
4545
});
46+
47+
const testNoAwait = async (t, fixtureName, options, message) => {
48+
const {stdout} = await execa('no-await.js', [JSON.stringify(options), fixtureName]);
49+
t.true(stdout.includes(message));
50+
};
51+
52+
test('Throws if promise is not awaited and process fails', testNoAwait, 'fail.js', {}, 'exit code 2');
53+
test('Throws if promise is not awaited and process times out', testNoAwait, 'forever.js', {timeout: 1}, 'timed out');

test/stream.js

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Buffer} from 'node:buffer';
22
import {exec} from 'node:child_process';
3+
import {once} from 'node:events';
34
import process from 'node:process';
45
import {setTimeout} from 'node:timers/promises';
56
import {promisify} from 'node:util';
@@ -131,12 +132,20 @@ const testNoMaxBuffer = async (t, streamName) => {
131132
test('do not buffer stdout when `buffer` set to `false`', testNoMaxBuffer, 'stdout');
132133
test('do not buffer stderr when `buffer` set to `false`', testNoMaxBuffer, 'stderr');
133134

134-
test('do not buffer when streaming', async t => {
135-
const {stdout} = execa('max-buffer.js', ['stdout', '21'], {maxBuffer: 10});
135+
test('do not buffer when streaming and `buffer` is `false`', async t => {
136+
const {stdout} = execa('max-buffer.js', ['stdout', '21'], {maxBuffer: 10, buffer: false});
136137
const result = await getStream(stdout);
137138
t.is(result, '....................\n');
138139
});
139140

141+
test('buffers when streaming and `buffer` is `true`', async t => {
142+
const childProcess = execa('max-buffer.js', ['stdout', '21'], {maxBuffer: 10});
143+
await Promise.all([
144+
t.throwsAsync(childProcess, {message: /maxBuffer exceeded/}),
145+
t.throwsAsync(getStream(childProcess.stdout), {code: 'ABORT_ERR'}),
146+
]);
147+
});
148+
140149
test('buffer: false > promise resolves', async t => {
141150
await t.notThrowsAsync(execa('noop.js', {buffer: false}));
142151
});
@@ -208,18 +217,31 @@ test.serial('Processes buffer stdout before it is read', async t => {
208217
t.is(stdout, 'foobar');
209218
});
210219

211-
// This test is not the desired behavior, but is the current one.
212-
// I.e. this is mostly meant for documentation and regression testing.
213-
test.serial('Processes might successfully exit before their stdout is read', async t => {
220+
test.serial('Processes buffers stdout right away, on successfully exit', async t => {
214221
const childProcess = execa('noop.js', ['foobar']);
215222
await setTimeout(1e3);
216223
const {stdout} = await childProcess;
217-
t.is(stdout, '');
224+
t.is(stdout, 'foobar');
218225
});
219226

220-
test.serial('Processes might fail before their stdout is read', async t => {
227+
test.serial('Processes buffers stdout right away, on failure', async t => {
221228
const childProcess = execa('noop-fail.js', ['foobar'], {reject: false});
222229
await setTimeout(1e3);
223230
const {stdout} = await childProcess;
224-
t.is(stdout, '');
231+
t.is(stdout, 'foobar');
232+
});
233+
234+
test('Processes buffers stdout right away, even if directly read', async t => {
235+
const childProcess = execa('noop.js', ['foobar']);
236+
const data = await once(childProcess.stdout, 'data');
237+
t.is(data.toString().trim(), 'foobar');
238+
const {stdout} = await childProcess;
239+
t.is(stdout, 'foobar');
240+
});
241+
242+
test('childProcess.stdout|stderr must be read right away', async t => {
243+
const childProcess = execa('noop.js', ['foobar']);
244+
const {stdout} = await childProcess;
245+
t.is(stdout, 'foobar');
246+
t.true(childProcess.stdout.destroyed);
225247
});

0 commit comments

Comments
 (0)