Skip to content

Commit

Permalink
Improve stream error handling (#49)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored Aug 31, 2024
1 parent de39403 commit d3a2d2c
Show file tree
Hide file tree
Showing 2 changed files with 60 additions and 15 deletions.
39 changes: 24 additions & 15 deletions spawn.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {once, on} from 'node:events';
import {stripVTControlCharacters} from 'node:util';
import path from 'node:path';
import process from 'node:process';
import {finished} from 'node:stream/promises';
import {fileURLToPath} from 'node:url';
import {lineIterator, combineAsyncIterators} from './iterable.js';
import {getForcedShell, escapeArguments} from './windows.js';
Expand Down Expand Up @@ -124,16 +123,16 @@ const getResult = async (instancePromise, input, start, command) => {
const instance = await instancePromise;
useInput(instance, input);
const result = initResult();
const onExit = once(instance, 'close');
const onStdoutDone = bufferOutput(instance.stdout, result, 'stdout');
const onStderrDone = bufferOutput(instance.stderr, result, 'stderr');
const onClose = once(instance, 'close');
bufferOutput(instance.stdout, result, 'stdout');
bufferOutput(instance.stderr, result, 'stderr');

try {
await Promise.all([onExit, onStdoutDone, onStderrDone]);
await Promise.race([onClose, ...onStreamErrors(instance)]);
checkFailure(command, getErrorOutput(instance));
return getOutput(result, command, start);
} catch (error) {
await Promise.allSettled([onExit, onStdoutDone, onStderrDone]);
await Promise.allSettled([onClose]);
throw getResultError({
error,
result,
Expand All @@ -152,18 +151,28 @@ const useInput = (instance, input) => {

const initResult = () => ({stdout: '', stderr: ''});

const bufferOutput = async (stream, result, streamName) => {
if (!stream) {
return;
const bufferOutput = (stream, result, streamName) => {
if (stream) {
stream.setEncoding('utf8');
stream.on('data', chunk => {
result[streamName] += chunk;
});
}
};

stream.setEncoding('utf8');
stream.on('data', chunk => {
result[streamName] += chunk;
});
await finished(stream, {cleanup: true});
const onStreamErrors = ({stdio}) => stdio.filter(Boolean).map(stream => onStreamError(stream));

const onStreamError = async stream => {
for await (const [error] of on(stream, 'error')) {
if (!IGNORED_CODES.has(error?.code)) {
throw error;
}
}
};

// Ignore errors that are due to closing errors when the subprocesses exit normally, or due to piping
const IGNORED_CODES = new Set(['ERR_STREAM_PREMATURE_CLOSE', 'EPIPE']);

const getResultError = ({error, result, instance, start, command}) => Object.assign(
getErrorInstance(error, command),
getErrorOutput(instance),
Expand Down
36 changes: 36 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,42 @@ if (isLinux) {
});
}

test('Error on stdin', async t => {
const error = new Error(testString);
const promise = nanoSpawn(...nodePrintStdout);
const subprocess = await promise.nodeChildProcess;
subprocess.stdin.destroy(error);
const {exitCode, signalName, message, cause} = await t.throwsAsync(promise);
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.true(message.startsWith('Command failed: node -e'));
t.is(cause, error);
});

test('Error on stdout', async t => {
const error = new Error(testString);
const promise = nanoSpawn(...nodePrintStderr);
const subprocess = await promise.nodeChildProcess;
subprocess.stdout.destroy(error);
const {exitCode, signalName, message, cause} = await t.throwsAsync(promise);
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.true(message.startsWith('Command failed: node -e'));
t.is(cause, error);
});

test('Error on stderr', async t => {
const error = new Error(testString);
const promise = nanoSpawn(...nodePrintStdout);
const subprocess = await promise.nodeChildProcess;
subprocess.stderr.destroy(error);
const {exitCode, signalName, message, cause} = await t.throwsAsync(promise);
t.is(exitCode, undefined);
t.is(signalName, undefined);
t.true(message.startsWith('Command failed: node -e'));
t.is(cause, error);
});

test('promise.stdout can be iterated', async t => {
const promise = nanoSpawn(...nodePrintStdout);
const lines = await arrayFromAsync(promise.stdout);
Expand Down

0 comments on commit d3a2d2c

Please sign in to comment.