From b3b46263325d530ab5f486ff858ba87853f8f5cb Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 6 Jul 2026 17:44:51 +0200 Subject: [PATCH] Add `nodeChildProcess` to subprocess promises and make the subprocess a normal promise Fixes #413 --- docs/api.md | 14 ++++++- docs/execution.md | 4 +- lib/convert/duplex.js | 2 +- lib/convert/readable.js | 16 ++++++- lib/convert/writable.js | 25 +++++++++-- lib/ipc/forward.js | 2 +- lib/ipc/get-each.js | 7 ++-- lib/ipc/ipc-input.js | 10 ++++- lib/ipc/methods.js | 7 ++-- lib/ipc/outgoing.js | 12 ++++-- lib/methods/main-async.js | 58 ++++++++++++++++++++++---- lib/methods/promise.js | 17 +------- lib/resolve/all-async.js | 10 ++--- lib/resolve/wait-subprocess.js | 10 +++-- lib/return/early-error.js | 29 ++++++++----- lib/terminate/cancel.js | 8 ++-- lib/terminate/cleanup.js | 4 +- lib/terminate/graceful.js | 6 ++- lib/terminate/timeout.js | 8 ++-- test-d/subprocess/subprocess.test-d.ts | 32 +++++++++++++- test/convert/writable.js | 6 ++- test/fixtures/ipc-send-pid.js | 15 ++++++- test/helpers/ipc.js | 3 +- test/ipc/forward.js | 6 +-- test/ipc/get-each.js | 20 ++++----- test/ipc/get-one.js | 14 +++---- test/ipc/graceful.js | 8 ++-- test/ipc/outgoing.js | 4 +- test/ipc/reference.js | 10 ++--- test/ipc/send.js | 4 +- test/ipc/strict.js | 2 +- test/ipc/validation.js | 2 +- test/methods/main-async.js | 36 ++++++++++++++++ test/methods/promise.js | 6 +-- test/pipe/sequence.js | 4 +- test/resolve/wait-subprocess.js | 4 +- test/return/early-error.js | 38 ++++++++++++++++- test/return/final-error.js | 8 ++-- test/return/result.js | 2 +- test/terminate/cancel.js | 4 +- test/terminate/cleanup.js | 19 +++++++++ test/terminate/graceful.js | 4 +- test/terminate/kill-error.js | 2 +- test/terminate/kill-force.js | 6 +-- test/terminate/kill-signal.js | 16 +++---- test/terminate/timeout.js | 2 +- types/subprocess/subprocess.d.ts | 18 +++++--- 47 files changed, 389 insertions(+), 155 deletions(-) diff --git a/docs/api.md b/docs/api.md index a8ef473d83..a26d881f6b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -211,7 +211,7 @@ This can only be called inside a subprocess. This requires the [`gracefulCancel` ## Return value _TypeScript:_ [`ResultPromise`](typescript.md)\ -_Type:_ `Promise | Subprocess` +_Type:_ `Subprocess` The return value of all [asynchronous methods](#methods) is both: - the [subprocess](#subprocess). @@ -223,7 +223,9 @@ The return value of all [asynchronous methods](#methods) is both: _TypeScript:_ [`Subprocess`](typescript.md) -[`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess) with the following methods and properties. +Promise with the following Execa-specific methods and properties. + +The underlying Node.js [`ChildProcess`](https://nodejs.org/api/child_process.html#class-childprocess) instance is available as [`subprocess.nodeChildProcess`](#subprocessnodechildprocess). This is an escape hatch for Node.js-specific APIs that Execa does not document, such as `.on()`, `.send()`, `.disconnect()`, `.ref()` and `.unref()`. ### subprocess\[Symbol.asyncIterator\]() @@ -335,6 +337,14 @@ This is `undefined` if the subprocess failed to spawn. [More info.](termination.md#inter-process-termination) +### subprocess.nodeChildProcess + +_Type:_ [`ChildProcess`](https://nodejs.org/api/child_process.html#class-childprocess) + +Underlying Node.js child process instance. + +This is intended as an escape hatch for Node.js-specific APIs that Execa does not document. + ### subprocess.sendMessage(message, sendMessageOptions) `message`: [`Message`](ipc.md#message-type)\ diff --git a/docs/execution.md b/docs/execution.md index 9339f83f74..1efdc96172 100644 --- a/docs/execution.md +++ b/docs/execution.md @@ -127,7 +127,7 @@ await timedExeca`npm run test`; ### Subprocess -The subprocess is returned as soon as it is spawned. It is a [`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess) with [additional methods and properties](api.md#subprocess). +The subprocess is returned as soon as it is spawned. It is a `Promise` with [Execa-specific methods and properties](api.md#subprocess). The underlying Node.js [`ChildProcess`](https://nodejs.org/api/child_process.html#class-childprocess) is available as [`subprocess.nodeChildProcess`](api.md#subprocessnodechildprocess). ```js const subprocess = execa`npm run build`; @@ -136,7 +136,7 @@ console.log(subprocess.pid); ### Result -The subprocess is also a `Promise` that resolves with the [`result`](api.md#result). +The subprocess resolves with the [`result`](api.md#result). ```js const {stdout} = await execa`npm run build`; diff --git a/lib/convert/duplex.js b/lib/convert/duplex.js index ecfcf9eefd..e3461e69f1 100644 --- a/lib/convert/duplex.js +++ b/lib/convert/duplex.js @@ -52,7 +52,7 @@ export const createDuplex = ({subprocess, concurrentStreams, encoding}, {from, t subprocess, subprocessStdin, }); - onStdinFinished(subprocessStdin, duplex, subprocessStdout); + onStdinFinished(subprocessStdin, duplex, subprocessStdout, subprocess); return duplex; }; diff --git a/lib/convert/readable.js b/lib/convert/readable.js index cefd663cb9..2d11cea155 100644 --- a/lib/convert/readable.js +++ b/lib/convert/readable.js @@ -96,10 +96,24 @@ export const onStdoutFinished = async ({subprocessStdout, onStdoutDataDone, read } } catch (error) { await safeWaitForSubprocessStdin(subprocessStdin); - destroyOtherReadable(readable, error); + destroyOtherReadable(readable, await getPrematureCloseError(subprocess, error)); } }; +const getPrematureCloseError = async (subprocess, error) => { + if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + return error; + } + + try { + await subprocess; + } catch (subprocessError) { + return subprocessError; + } + + return error; +}; + // When `readable` aborts/errors, do the same on `subprocess.stdout` export const onReadableDestroy = async ({subprocessStdout, subprocess, waitReadableDestroy}, error) => { if (!await waitForConcurrentStreams(waitReadableDestroy, subprocess)) { diff --git a/lib/convert/writable.js b/lib/convert/writable.js index 2dc1c5402c..a0bb1588e4 100644 --- a/lib/convert/writable.js +++ b/lib/convert/writable.js @@ -1,6 +1,7 @@ import {Writable} from 'node:stream'; import {callbackify} from 'node:util'; import {getToStream} from '../arguments/fd-options.js'; +import {isStreamAbort} from '../resolve/wait-stream.js'; import {addConcurrentStream, waitForConcurrentStreams} from './concurrent.js'; import { safeWaitForSubprocessStdout, @@ -23,7 +24,7 @@ export const createWritable = ({subprocess, concurrentStreams}, {to} = {}) => { highWaterMark: subprocessStdin.writableHighWaterMark, objectMode: subprocessStdin.writableObjectMode, }); - onStdinFinished(subprocessStdin, writable); + onStdinFinished(subprocessStdin, writable, undefined, subprocess); return writable; }; @@ -66,18 +67,36 @@ const onWritableFinal = async (subprocessStdin, subprocess, waitWritableFinal) = }; // When `subprocess.stdin` ends/aborts/errors, do the same on `writable`. -export const onStdinFinished = async (subprocessStdin, writable, subprocessStdout) => { +export const onStdinFinished = async (subprocessStdin, writable, subprocessStdout, subprocess) => { try { await waitForSubprocessStdin(subprocessStdin); + await subprocess; if (writable.writable) { writable.end(); } } catch (error) { await safeWaitForSubprocessStdout(subprocessStdout); - destroyOtherWritable(writable, error); + destroyOtherWritable(writable, await getSubprocessError(subprocess, error)); } }; +const getSubprocessError = async (subprocess, error) => { + if (!shouldUseSubprocessError(error)) { + return error; + } + + try { + await subprocess; + } catch (subprocessError) { + return subprocessError; + } + + return error; +}; + +const shouldUseSubprocessError = error => error === undefined + || isStreamAbort(error); + // When `writable` aborts/errors, do the same on `subprocess.stdin` export const onWritableDestroy = async ({subprocessStdin, subprocess, waitWritableFinal, waitWritableDestroy}, error) => { await waitForConcurrentStreams(waitWritableFinal, subprocess); diff --git a/lib/ipc/forward.js b/lib/ipc/forward.js index b380b44908..512c6e3175 100644 --- a/lib/ipc/forward.js +++ b/lib/ipc/forward.js @@ -51,6 +51,6 @@ const forwardEvents = ({ipcEmitter, anyProcess, channel, isSubprocess}) => { export const isConnected = anyProcess => { const ipcEmitter = IPC_EMITTERS.get(anyProcess); return ipcEmitter === undefined - ? anyProcess.channel !== null + ? anyProcess.channel !== undefined && anyProcess.channel !== null : ipcEmitter.connected; }; diff --git a/lib/ipc/get-each.js b/lib/ipc/get-each.js index ffcd6138b1..edf0c109f5 100644 --- a/lib/ipc/get-each.js +++ b/lib/ipc/get-each.js @@ -20,7 +20,7 @@ export const getEachMessage = (subprocessInfo, options = {}) => { }; // Same but used internally -export const loopOnMessages = ({anyProcess, channel, isSubprocess, ipc, shouldAwait, reference, signal}) => { +export const loopOnMessages = ({anyProcess, waitProcess = anyProcess, channel, isSubprocess, ipc, shouldAwait, reference, signal}) => { validateIpcMethod({ methodName: 'getEachMessage', isSubprocess, @@ -42,6 +42,7 @@ export const loopOnMessages = ({anyProcess, channel, isSubprocess, ipc, shouldAw }); return iterateOnMessages({ anyProcess, + waitProcess, channel, ipcEmitter, isSubprocess, @@ -83,7 +84,7 @@ const abortOnStrictError = async ({ipcEmitter, isSubprocess, controller, state}) } catch {} }; -const iterateOnMessages = async function * ({anyProcess, channel, ipcEmitter, isSubprocess, shouldAwait, controller, state, reference}) { +const iterateOnMessages = async function * ({anyProcess, waitProcess, channel, ipcEmitter, isSubprocess, shouldAwait, controller, state, reference}) { try { for await (const [message] of on(ipcEmitter, 'message', {signal: controller.signal})) { throwIfStrictError(state); @@ -100,7 +101,7 @@ const iterateOnMessages = async function * ({anyProcess, channel, ipcEmitter, is } if (shouldAwait) { - await anyProcess; + await waitProcess; } } }; diff --git a/lib/ipc/ipc-input.js b/lib/ipc/ipc-input.js index 908f2ace1c..926d392132 100644 --- a/lib/ipc/ipc-input.js +++ b/lib/ipc/ipc-input.js @@ -1,4 +1,5 @@ import {serialize} from 'node:v8'; +import {sendMessage} from './send.js'; // Validate the `ipcInput` option export const validateIpcInputOption = ({ipcInput, ipc, serialization}) => { @@ -35,10 +36,15 @@ const validateIpcInput = { }; // When the `ipcInput` option is set, it is sent as an initial IPC message to the subprocess -export const sendIpcInput = async (subprocess, ipcInput) => { +export const sendIpcInput = async (subprocess, ipcInput, ipc) => { if (ipcInput === undefined) { return; } - await subprocess.sendMessage(ipcInput); + await sendMessage({ + anyProcess: subprocess, + channel: subprocess.channel, + isSubprocess: false, + ipc, + }, ipcInput); }; diff --git a/lib/ipc/methods.js b/lib/ipc/methods.js index c1963bd864..284d85778f 100644 --- a/lib/ipc/methods.js +++ b/lib/ipc/methods.js @@ -5,8 +5,8 @@ import {getEachMessage} from './get-each.js'; import {getCancelSignal} from './graceful.js'; // Add promise-based IPC methods in current process -export const addIpcMethods = (subprocess, {ipc}) => { - Object.assign(subprocess, getIpcMethods(subprocess, false, ipc)); +export const addIpcMethods = (target, subprocess, {ipc}) => { + Object.assign(target, getIpcMethods(subprocess, false, ipc, target)); }; // Get promise-based IPC in the subprocess @@ -27,7 +27,7 @@ export const getIpcExport = () => { }; // Retrieve the `ipc` shared by both the current process and the subprocess -const getIpcMethods = (anyProcess, isSubprocess, ipc) => ({ +const getIpcMethods = (anyProcess, isSubprocess, ipc, waitProcess = anyProcess) => ({ sendMessage: sendMessage.bind(undefined, { anyProcess, channel: anyProcess.channel, @@ -45,5 +45,6 @@ const getIpcMethods = (anyProcess, isSubprocess, ipc) => ({ channel: anyProcess.channel, isSubprocess, ipc, + waitProcess, }), }); diff --git a/lib/ipc/outgoing.js b/lib/ipc/outgoing.js index 904f67dd73..76c658bea1 100644 --- a/lib/ipc/outgoing.js +++ b/lib/ipc/outgoing.js @@ -1,6 +1,5 @@ import {createDeferred} from '../utils/deferred.js'; import {getFdSpecificValue} from '../arguments/specific.js'; -import {SUBPROCESS_OPTIONS} from '../arguments/fd-options.js'; import {validateStrictDeadlock} from './strict.js'; // When `sendMessage()` is ongoing, any `message` being received waits before being emitted. @@ -35,13 +34,20 @@ export const waitForOutgoingMessages = async (anyProcess, ipcEmitter, wrappedMes }; const OUTGOING_MESSAGES = new WeakMap(); +const IPC_SUBPROCESS_OPTIONS = new WeakMap(); + +export const setIpcSubprocessOptions = (subprocess, options) => { + IPC_SUBPROCESS_OPTIONS.set(subprocess, options); +}; // Whether any `message` listener is setup export const hasMessageListeners = (anyProcess, ipcEmitter) => ipcEmitter.listenerCount('message') > getMinListenerCount(anyProcess); // When `buffer` is `false`, we set up a `message` listener that should be ignored. // That listener is only meant to intercept `strict` acknowledgement responses. -const getMinListenerCount = anyProcess => SUBPROCESS_OPTIONS.has(anyProcess) - && !getFdSpecificValue(SUBPROCESS_OPTIONS.get(anyProcess).options.buffer, 'ipc') +const getMinListenerCount = anyProcess => getOptions(anyProcess) !== undefined + && !getFdSpecificValue(getOptions(anyProcess).buffer, 'ipc') ? 1 : 0; + +const getOptions = anyProcess => IPC_SUBPROCESS_OPTIONS.get(anyProcess); diff --git a/lib/methods/main-async.js b/lib/methods/main-async.js index bcfa83ac80..a6ff9fdff1 100644 --- a/lib/methods/main-async.js +++ b/lib/methods/main-async.js @@ -6,6 +6,7 @@ import {normalizeOptions} from '../arguments/options.js'; import {SUBPROCESS_OPTIONS} from '../arguments/fd-options.js'; import {concatenateShell} from '../arguments/shell.js'; import {addIpcMethods} from '../ipc/methods.js'; +import {setIpcSubprocessOptions} from '../ipc/outgoing.js'; import {makeError, makeSuccessResult} from '../return/result.js'; import {handleResult} from '../return/reject.js'; import {handleEarlyError} from '../return/early-error.js'; @@ -24,7 +25,7 @@ import {mergePromise} from './promise.js'; // Main shared logic for all async methods: `execa()`, `$`, `execaNode()` export const execaCoreAsync = (rawFile, rawArguments, rawOptions, createNested) => { const {file, commandArguments, command, escapedCommand, startTime, verboseInfo, options, fileDescriptors} = handleAsyncArguments(rawFile, rawArguments, rawOptions); - const {subprocess, promise} = spawnSubprocessAsync({ + const {subprocess: nodeChildProcess, promise, kill, all, convertedStreams} = spawnSubprocessAsync({ file, commandArguments, options, @@ -34,13 +35,20 @@ export const execaCoreAsync = (rawFile, rawArguments, rawOptions, createNested) escapedCommand, fileDescriptors, }); + const subprocess = getSubprocessPromise({ + promise, + nodeChildProcess, + kill, + all, + convertedStreams, + options, + }); subprocess.pipe = pipeToSubprocess.bind(undefined, { source: subprocess, sourcePromise: promise, boundOptions: {}, createNested, }); - mergePromise(subprocess, promise); SUBPROCESS_OPTIONS.set(subprocess, {options, fileDescriptors}); return subprocess; }; @@ -94,23 +102,24 @@ const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verbo const originalStreams = [...subprocess.stdio]; pipeOutputAsync(subprocess, fileDescriptors, controller); - cleanupOnExit(subprocess, options, controller); + setIpcSubprocessOptions(subprocess, options); const context = {}; const onInternalError = createDeferred(); - subprocess.kill = subprocessKill.bind(undefined, { + const kill = subprocessKill.bind(undefined, { kill: subprocess.kill.bind(subprocess), options, onInternalError, context, controller, }); - subprocess.all = makeAllStream(subprocess, options); - addConvertedStreams(subprocess, options); - addIpcMethods(subprocess, options); + cleanupOnExit(kill, options, controller); + const all = makeAllStream(subprocess, options); const promise = handlePromise({ subprocess, + kill, + all, options, startTime, verboseInfo, @@ -122,11 +131,40 @@ const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verbo onInternalError, controller, }); - return {subprocess, promise}; + return { + subprocess, + promise, + kill, + all, + }; }; +const getSubprocessPromise = ({promise, nodeChildProcess, kill, all, convertedStreams, options}) => { + const subprocess = mergePromise(promise, getSubprocessProperties(nodeChildProcess, all)); + subprocess.kill = kill ?? subprocess.kill; + if (convertedStreams === undefined) { + addConvertedStreams(subprocess, options); + } else { + Object.assign(subprocess, convertedStreams); + } + + addIpcMethods(subprocess, nodeChildProcess, options); + return subprocess; +}; + +const getSubprocessProperties = (nodeChildProcess, all) => ({ + nodeChildProcess, + pid: nodeChildProcess.pid, + stdin: nodeChildProcess.stdin, + stdout: nodeChildProcess.stdout, + stderr: nodeChildProcess.stderr, + stdio: nodeChildProcess.stdio, + all, + kill: nodeChildProcess.kill.bind(nodeChildProcess), +}); + // Asynchronous logic, as opposed to the previous logic which can be run synchronously, i.e. can be returned to user right away -const handlePromise = async ({subprocess, options, startTime, verboseInfo, fileDescriptors, originalStreams, command, escapedCommand, context, onInternalError, controller}) => { +const handlePromise = async ({subprocess, kill, all: allStream, options, startTime, verboseInfo, fileDescriptors, originalStreams, command, escapedCommand, context, onInternalError, controller}) => { const [ errorInfo, [exitCode, signal], @@ -135,6 +173,8 @@ const handlePromise = async ({subprocess, options, startTime, verboseInfo, fileD ipcOutput, ] = await waitForSubprocessResult({ subprocess, + kill, + all: allStream, options, context, verboseInfo, diff --git a/lib/methods/promise.js b/lib/methods/promise.js index 705692b4be..2e52adddd8 100644 --- a/lib/methods/promise.js +++ b/lib/methods/promise.js @@ -1,15 +1,2 @@ -// The return value is a mixin of `subprocess` and `Promise` -export const mergePromise = (subprocess, promise) => { - for (const [property, descriptor] of descriptors) { - const value = descriptor.value.bind(promise); - Reflect.defineProperty(subprocess, property, {...descriptor, value}); - } -}; - -// eslint-disable-next-line unicorn/prefer-top-level-await -const nativePromisePrototype = (async () => {})().constructor.prototype; - -const descriptors = ['then', 'catch', 'finally'].map(property => [ - property, - Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property), -]); +// The return value is a native Promise with Execa-specific properties. +export const mergePromise = (promise, properties) => Object.assign(promise, properties); diff --git a/lib/resolve/all-async.js b/lib/resolve/all-async.js index f0a5abcd3a..07be892ea5 100644 --- a/lib/resolve/all-async.js +++ b/lib/resolve/all-async.js @@ -7,19 +7,19 @@ export const makeAllStream = ({stdout, stderr}, {all}) => all && (stdout || stde : undefined; // Read the contents of `subprocess.all` and|or wait for its completion -export const waitForAllStream = ({subprocess, encoding, buffer, maxBuffer, lines, stripFinalNewline, verboseInfo, streamInfo}) => waitForSubprocessStream({ - ...getAllStream(subprocess, buffer), +export const waitForAllStream = ({subprocess, all, encoding, buffer, maxBuffer, lines, stripFinalNewline, verboseInfo, streamInfo}) => waitForSubprocessStream({ + ...getAllStream(subprocess, all, buffer), fdNumber: 'all', encoding, maxBuffer: maxBuffer[1] + maxBuffer[2], lines: lines[1] || lines[2], - allMixed: getAllMixed(subprocess), + allMixed: getAllMixed(subprocess, all), stripFinalNewline, verboseInfo, streamInfo, }); -const getAllStream = ({stdout, stderr, all}, [, bufferStdout, bufferStderr]) => { +const getAllStream = ({stdout, stderr}, all, [, bufferStdout, bufferStderr]) => { const buffer = bufferStdout || bufferStderr; if (!buffer) { return {stream: all, buffer}; @@ -40,7 +40,7 @@ const getAllStream = ({stdout, stderr, all}, [, bufferStdout, bufferStderr]) => // - `getStreamAsArray()` for the chunks in objectMode, to return as an array without changing each chunk // - `getStreamAsArrayBuffer()` or `getStream()` for the chunks not in objectMode, to convert them from Buffers to string or Uint8Array // We do this by emulating the Buffer -> string|Uint8Array conversion performed by `get-stream` with our own, which is identical. -const getAllMixed = ({all, stdout, stderr}) => all +const getAllMixed = ({stdout, stderr}, all) => all && stdout && stderr && stdout.readableObjectMode !== stderr.readableObjectMode; diff --git a/lib/resolve/wait-subprocess.js b/lib/resolve/wait-subprocess.js index 0c1c6ad97d..fc9189aa39 100644 --- a/lib/resolve/wait-subprocess.js +++ b/lib/resolve/wait-subprocess.js @@ -16,6 +16,8 @@ import {waitForStream} from './wait-stream.js'; // Retrieve result of subprocess: exit code, signal, error, streams (stdout/stderr/all) export const waitForSubprocessResult = async ({ subprocess, + kill, + all, options: { encoding, buffer, @@ -57,6 +59,7 @@ export const waitForSubprocessResult = async ({ }); const allPromise = waitForAllStream({ subprocess, + all, encoding, buffer, maxBuffer, @@ -85,15 +88,15 @@ export const waitForSubprocessResult = async ({ Promise.all(stdioPromises), allPromise, ipcOutputPromise, - sendIpcInput(subprocess, ipcInput), + sendIpcInput(subprocess, ipcInput, ipc), ...originalPromises, ...customStreamsEndPromises, ]), onInternalError, throwOnSubprocessError(subprocess, controller), - ...throwOnTimeout(subprocess, timeout, context, controller), + ...throwOnTimeout(kill, timeout, context, controller), ...throwOnCancel({ - subprocess, + kill, cancelSignal, gracefulCancel, context, @@ -101,6 +104,7 @@ export const waitForSubprocessResult = async ({ }), ...throwOnGracefulCancel({ subprocess, + kill, cancelSignal, gracefulCancel, forceKillAfterDelay, diff --git a/lib/return/early-error.js b/lib/return/early-error.js index a52bbc250f..6c9032b9f8 100644 --- a/lib/return/early-error.js +++ b/lib/return/early-error.js @@ -15,15 +15,7 @@ export const handleEarlyError = ({error, command, escapedCommand, fileDescriptor cleanupCustomStreams(fileDescriptors); const subprocess = new ChildProcess(); - createDummyStreams(subprocess, fileDescriptors); - Object.assign(subprocess, { - readable, - writable, - duplex, - readableStream, - writableStream, - transformStream, - }); + const all = createDummyStreams(subprocess, fileDescriptors); const earlyError = makeEarlyError({ error, @@ -35,7 +27,21 @@ export const handleEarlyError = ({error, command, escapedCommand, fileDescriptor isSync: false, }); const promise = handleDummyPromise(earlyError, verboseInfo, options); - return {subprocess, promise}; + return { + subprocess, + promise, + all: options.all ? all : undefined, + convertedStreams: { + readable, + writable, + duplex, + readableStream, + writableStream, + transformStream, + iterable, + [Symbol.asyncIterator]: iterable, + }, + }; }; const createDummyStreams = (subprocess, fileDescriptors) => { @@ -49,9 +55,9 @@ const createDummyStreams = (subprocess, fileDescriptors) => { stdin, stdout, stderr, - all, stdio, }); + return all; }; const createDummyStream = () => { @@ -66,5 +72,6 @@ const duplex = () => new Duplex({read() {}, write() {}}); const readableStream = () => Readable.toWeb(readable()); const writableStream = () => Writable.toWeb(writable()); const transformStream = () => Duplex.toWeb(duplex()); +const iterable = async function * () {}; const handleDummyPromise = async (error, verboseInfo, options) => handleResult(error, verboseInfo, options); diff --git a/lib/terminate/cancel.js b/lib/terminate/cancel.js index e951186f59..ed5bd5c323 100644 --- a/lib/terminate/cancel.js +++ b/lib/terminate/cancel.js @@ -8,13 +8,13 @@ export const validateCancelSignal = ({cancelSignal}) => { }; // Terminate the subprocess when aborting the `cancelSignal` option and `gracefulSignal` is `false` -export const throwOnCancel = ({subprocess, cancelSignal, gracefulCancel, context, controller}) => cancelSignal === undefined || gracefulCancel +export const throwOnCancel = ({kill, cancelSignal, gracefulCancel, context, controller}) => cancelSignal === undefined || gracefulCancel ? [] - : [terminateOnCancel(subprocess, cancelSignal, context, controller)]; + : [terminateOnCancel(kill, cancelSignal, context, controller)]; -const terminateOnCancel = async (subprocess, cancelSignal, context, {signal}) => { +const terminateOnCancel = async (kill, cancelSignal, context, {signal}) => { await onAbortedSignal(cancelSignal, signal); context.terminationReason ??= 'cancel'; - subprocess.kill(); + kill(); throw cancelSignal.reason; }; diff --git a/lib/terminate/cleanup.js b/lib/terminate/cleanup.js index 5e98788d67..d0b9723452 100644 --- a/lib/terminate/cleanup.js +++ b/lib/terminate/cleanup.js @@ -2,13 +2,13 @@ import {addAbortListener} from 'node:events'; import {onExit} from 'signal-exit'; // If the `cleanup` option is used, call `subprocess.kill()` when the parent process exits -export const cleanupOnExit = (subprocess, {cleanup, detached}, {signal}) => { +export const cleanupOnExit = (kill, {cleanup, detached}, {signal}) => { if (!cleanup || detached) { return; } const removeExitHandler = onExit(() => { - subprocess.kill(); + kill(); }); addAbortListener(signal, () => { removeExitHandler(); diff --git a/lib/terminate/graceful.js b/lib/terminate/graceful.js index df360c5618..e63d7f723c 100644 --- a/lib/terminate/graceful.js +++ b/lib/terminate/graceful.js @@ -24,6 +24,7 @@ export const validateGracefulCancel = ({gracefulCancel, cancelSignal, ipc, seria // Send abort reason to the subprocess when aborting the `cancelSignal` option and `gracefulCancel` is `true` export const throwOnGracefulCancel = ({ subprocess, + kill, cancelSignal, gracefulCancel, forceKillAfterDelay, @@ -32,6 +33,7 @@ export const throwOnGracefulCancel = ({ }) => gracefulCancel ? [sendOnAbort({ subprocess, + kill, cancelSignal, forceKillAfterDelay, context, @@ -39,12 +41,12 @@ export const throwOnGracefulCancel = ({ })] : []; -const sendOnAbort = async ({subprocess, cancelSignal, forceKillAfterDelay, context, controller: {signal}}) => { +const sendOnAbort = async ({subprocess, kill, cancelSignal, forceKillAfterDelay, context, controller: {signal}}) => { await onAbortedSignal(cancelSignal, signal); const reason = getReason(cancelSignal); await sendAbort(subprocess, reason); killOnTimeout({ - kill: subprocess.kill, + kill, forceKillAfterDelay, context, controllerSignal: signal, diff --git a/lib/terminate/timeout.js b/lib/terminate/timeout.js index d1c19d2439..080d3d98a1 100644 --- a/lib/terminate/timeout.js +++ b/lib/terminate/timeout.js @@ -9,13 +9,13 @@ export const validateTimeout = ({timeout}) => { }; // Fails when the `timeout` option is exceeded -export const throwOnTimeout = (subprocess, timeout, context, controller) => timeout === 0 || timeout === undefined +export const throwOnTimeout = (kill, timeout, context, controller) => timeout === 0 || timeout === undefined ? [] - : [killAfterTimeout(subprocess, timeout, context, controller)]; + : [killAfterTimeout(kill, timeout, context, controller)]; -const killAfterTimeout = async (subprocess, timeout, context, {signal}) => { +const killAfterTimeout = async (kill, timeout, context, {signal}) => { await setTimeout(timeout, undefined, {signal}); context.terminationReason ??= 'timeout'; - subprocess.kill(); + kill(); throw new DiscardedError(); }; diff --git a/test-d/subprocess/subprocess.test-d.ts b/test-d/subprocess/subprocess.test-d.ts index 76e4e1ea90..fa370e5fba 100644 --- a/test-d/subprocess/subprocess.test-d.ts +++ b/test-d/subprocess/subprocess.test-d.ts @@ -1,10 +1,38 @@ +import type {ChildProcess} from 'node:child_process'; import {expectType, expectError, expectAssignable} from 'tsd'; -import {execa, type Subprocess} from '../../index.js'; +import {execa, type Result, type Subprocess} from '../../index.js'; const subprocess = execa('unicorns'); expectAssignable(subprocess); +expectType>(await subprocess); expectType(subprocess.pid); +expectType(subprocess.nodeChildProcess); +subprocess.nodeChildProcess.on('exit', () => undefined); +subprocess.nodeChildProcess.once('exit', () => undefined); +subprocess.nodeChildProcess.ref(); +subprocess.nodeChildProcess.unref(); +subprocess.nodeChildProcess.send('message'); +subprocess.nodeChildProcess.disconnect(); +expectType(subprocess.nodeChildProcess.connected); +expectType(subprocess.nodeChildProcess.exitCode); +expectType(subprocess.nodeChildProcess.signalCode); +expectType(subprocess.nodeChildProcess.killed); +expectType(subprocess.nodeChildProcess.spawnargs); +expectType(subprocess.nodeChildProcess.spawnfile); +expectError(subprocess.on('exit', () => undefined)); +expectError(subprocess.once('exit', () => undefined)); +expectError(subprocess.ref()); +expectError(subprocess.unref()); +expectError(subprocess.send('message')); +expectError(subprocess.disconnect()); +expectError(subprocess.connected); +expectError(subprocess.channel); +expectError(subprocess.exitCode); +expectError(subprocess.signalCode); +expectError(subprocess.killed); +expectError(subprocess.spawnargs); +expectError(subprocess.spawnfile); expectType(subprocess.kill()); subprocess.kill('SIGKILL'); @@ -27,4 +55,4 @@ expectError(subprocess.kill('SIGKILL', {})); expectError(subprocess.kill(null, new Error('test'))); const ipcSubprocess = execa('unicorns', {ipc: true}); -expectAssignable(subprocess); +expectAssignable(ipcSubprocess); diff --git a/test/convert/writable.js b/test/convert/writable.js index 3ea1847528..3965eb5596 100644 --- a/test/convert/writable.js +++ b/test/convert/writable.js @@ -181,11 +181,13 @@ const testStdinAbort = async (t, methodName) => { const error = await t.throwsAsync(finishedStream(stream)); t.like(error, prematureClose); - assertProcessNormalExit(t, error); assertWritableAborted(t, subprocess.stdin); t.true(subprocess.stdout.readableEnded); t.true(subprocess.stderr.readableEnded); - await assertSubprocessError(t, subprocess, error); + const subprocessError = await t.throwsAsync(subprocess); + t.like(subprocessError, prematureClose); + t.like(subprocessError.cause, prematureClose); + assertProcessNormalExit(t, subprocessError); }; test('subprocess.stdin abort -> .writable() error + subprocess fail', testStdinAbort, 'writable'); diff --git a/test/fixtures/ipc-send-pid.js b/test/fixtures/ipc-send-pid.js index fa9448ba1b..bb98b2a4ed 100755 --- a/test/fixtures/ipc-send-pid.js +++ b/test/fixtures/ipc-send-pid.js @@ -4,6 +4,19 @@ import {execa, sendMessage} from '../../index.js'; const cleanup = process.argv[2] === 'true'; const detached = process.argv[3] === 'true'; -const subprocess = execa('forever.js', {cleanup, detached}); +const file = process.argv[4] === 'no-killable' ? 'no-killable.js' : 'forever.js'; +const options = { + cleanup, + detached, + ...(file === 'no-killable.js' && { + ipc: true, + killSignal: 'SIGKILL', + }), +}; +const subprocess = execa(file, options); +if (options.ipc) { + await subprocess.getOneMessage(); +} + await sendMessage(subprocess.pid); await subprocess; diff --git a/test/helpers/ipc.js b/test/helpers/ipc.js index 7d52ac4d05..0d25000919 100644 --- a/test/helpers/ipc.js +++ b/test/helpers/ipc.js @@ -32,7 +32,8 @@ export const alwaysPass = () => true; // So we mock them. export const mockSendIoError = anyProcess => { const error = new Error(foobarString); - anyProcess.send = () => { + const target = anyProcess.nodeChildProcess ?? anyProcess; + target.send = () => { throw error; }; diff --git a/test/ipc/forward.js b/test/ipc/forward.js index 1d5119239c..559b6942df 100644 --- a/test/ipc/forward.js +++ b/test/ipc/forward.js @@ -11,7 +11,7 @@ const testParentErrorOne = async (t, filter, buffer) => { const promise = subprocess.getOneMessage({filter}); const cause = new Error(foobarString); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); t.is(await promise, foobarString); const error = await t.throwsAsync(subprocess); @@ -49,7 +49,7 @@ const testParentErrorEach = async (t, buffer) => { const promise = iterateAllMessages(subprocess); const cause = new Error(foobarString); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); const error = await t.throwsAsync(subprocess); t.is(error, await t.throwsAsync(promise)); @@ -83,7 +83,7 @@ test('"error" event does not interrupt result.ipcOutput', async t => { const subprocess = execa('ipc-echo-twice.js', {ipcInput: foobarString}); const cause = new Error(foobarString); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); t.is(await subprocess.getOneMessage(), foobarString); await subprocess.sendMessage(foobarString); t.is(await subprocess.getOneMessage(), foobarString); diff --git a/test/ipc/get-each.js b/test/ipc/get-each.js index 773ef6ee41..454a22115c 100644 --- a/test/ipc/get-each.js +++ b/test/ipc/get-each.js @@ -143,7 +143,7 @@ test('Disconnecting in the current process stops exports.getEachMessage()', asyn const subprocess = execa('ipc-iterate-print.js', {ipc: true}); t.is(await subprocess.getOneMessage(), foobarString); await subprocess.sendMessage('.'); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {stdout} = await subprocess; t.is(stdout, '.'); @@ -172,25 +172,25 @@ test('Exiting the subprocess stops subprocess.getEachMessage()', async t => { const testCleanupListeners = async (t, buffer) => { const subprocess = execa('ipc-send.js', {ipc: true, buffer}); - t.is(subprocess.listenerCount('message'), 1); - t.is(subprocess.listenerCount('disconnect'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('message'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('disconnect'), 1); const promise = iterateAllMessages(subprocess); - t.is(subprocess.listenerCount('message'), 1); - t.is(subprocess.listenerCount('disconnect'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('message'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('disconnect'), 1); t.deepEqual(await promise, [foobarString]); - t.is(subprocess.listenerCount('message'), 0); - t.is(subprocess.listenerCount('disconnect'), 0); + t.is(subprocess.nodeChildProcess.listenerCount('message'), 0); + t.is(subprocess.nodeChildProcess.listenerCount('disconnect'), 0); }; test('Cleans up subprocess.getEachMessage() listeners, buffer false', testCleanupListeners, false); test('Cleans up subprocess.getEachMessage() listeners, buffer true', testCleanupListeners, true); const sendContinuousMessages = async subprocess => { - while (subprocess.connected) { + while (subprocess.nodeChildProcess.connected) { for (let index = 0; index < 10; index += 1) { - subprocess.emit('message', foobarString); + subprocess.nodeChildProcess.emit('message', foobarString); } // eslint-disable-next-line no-await-in-loop @@ -202,7 +202,7 @@ test.serial('Handles buffered messages when disconnecting', async t => { const subprocess = execa('ipc-send-fail.js', {ipc: true, buffer: false}); const promise = subprocess.getOneMessage(); - subprocess.emit('message', foobarString); + subprocess.nodeChildProcess.emit('message', foobarString); t.is(await promise, foobarString); sendContinuousMessages(subprocess); diff --git a/test/ipc/get-one.js b/test/ipc/get-one.js index dfad7e7ccd..89225467c0 100644 --- a/test/ipc/get-one.js +++ b/test/ipc/get-one.js @@ -79,18 +79,18 @@ test('subprocess.getOneMessage() can be called twice at the same time, buffer tr const testCleanupListeners = async (t, buffer, filter) => { const subprocess = execa('ipc-send.js', {ipc: true, buffer}); - t.is(subprocess.listenerCount('message'), 1); - t.is(subprocess.listenerCount('disconnect'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('message'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('disconnect'), 1); const promise = subprocess.getOneMessage({filter}); - t.is(subprocess.listenerCount('message'), 1); - t.is(subprocess.listenerCount('disconnect'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('message'), 1); + t.is(subprocess.nodeChildProcess.listenerCount('disconnect'), 1); t.is(await promise, foobarString); await subprocess; - t.is(subprocess.listenerCount('message'), 0); - t.is(subprocess.listenerCount('disconnect'), 0); + t.is(subprocess.nodeChildProcess.listenerCount('message'), 0); + t.is(subprocess.nodeChildProcess.listenerCount('disconnect'), 0); }; test('Cleans up subprocess.getOneMessage() listeners, buffer false', testCleanupListeners, false, undefined); @@ -103,7 +103,7 @@ const testParentDisconnect = async (t, buffer, filter) => { await subprocess.sendMessage(foobarString); t.is(await subprocess.getOneMessage(), foobarString); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {exitCode, isTerminated, message} = await t.throwsAsync(subprocess); t.is(exitCode, 1); diff --git a/test/ipc/graceful.js b/test/ipc/graceful.js index e4d19ff738..9c76165b84 100644 --- a/test/ipc/graceful.js +++ b/test/ipc/graceful.js @@ -89,7 +89,7 @@ test('Graceful cancelSignal can be aborted twice', async t => { test('Graceful cancelSignal cannot be manually aborted after disconnection', async t => { const controller = new AbortController(); const subprocess = execa('empty.js', {cancelSignal: controller.signal, gracefulCancel: true}); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); controller.abort(foobarString); const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput, originalMessage} = await t.throwsAsync(subprocess); t.false(isCanceled); @@ -105,7 +105,7 @@ test('Graceful cancelSignal can disconnect after being manually aborted', async const subprocess = execa('graceful-disconnect.js', {cancelSignal: controller.signal, gracefulCancel: true}); controller.abort(foobarString); t.is(await subprocess.getOneMessage(), foobarString); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); t.true(isCanceled); t.true(isGracefullyCanceled); @@ -118,7 +118,7 @@ test('Graceful cancelSignal is automatically aborted on disconnection', async t const controller = new AbortController(); const subprocess = execa('graceful-send-print.js', {cancelSignal: controller.signal, gracefulCancel: true}); t.false(await subprocess.getOneMessage()); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {isCanceled, isGracefullyCanceled, ipcOutput, stdout} = await subprocess; t.false(isCanceled); t.false(isGracefullyCanceled); @@ -129,7 +129,7 @@ test('Graceful cancelSignal is automatically aborted on disconnection', async t test('getCancelSignal() aborts if already disconnected', async t => { const controller = new AbortController(); const subprocess = execa('graceful-print.js', {cancelSignal: controller.signal, gracefulCancel: true}); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {isCanceled, isGracefullyCanceled, ipcOutput, stdout} = await subprocess; t.false(isCanceled); t.false(isGracefullyCanceled); diff --git a/test/ipc/outgoing.js b/test/ipc/outgoing.js index d4c0fae33f..0610d59aa0 100644 --- a/test/ipc/outgoing.js +++ b/test/ipc/outgoing.js @@ -17,7 +17,7 @@ const testSendHoldParent = async (t, getMessage, buffer, filter) => { await Promise.all([ ...messages.map(message => subprocess.sendMessage(message)), subprocess.sendMessage(foobarString), - subprocess.emit('message', '.'), + subprocess.nodeChildProcess.emit('message', '.'), ]); t.is(await getMessage(subprocess, {filter}), '.'); @@ -52,7 +52,7 @@ const testSendHoldParentSerial = async (t, getMessage, buffer, filter) => { t.is(await subprocess.getOneMessage({filter}), 0); const promise = subprocess.sendMessage(1); - subprocess.emit('message', '.'); + subprocess.nodeChildProcess.emit('message', '.'); await promise; const messages = Array.from({length: PARALLEL_COUNT}, (_, index) => index + 2); diff --git a/test/ipc/reference.js b/test/ipc/reference.js index 01969a591c..8541c41ef1 100644 --- a/test/ipc/reference.js +++ b/test/ipc/reference.js @@ -80,7 +80,7 @@ test('process.once("message") keeps the subprocess alive, after getOneMessage()' test('process.once("disconnect") keeps the subprocess alive', async t => { const subprocess = execa('ipc-once-disconnect.js', {ipc: true}); t.is(await subprocess.getOneMessage(), '.'); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {ipcOutput, stdout} = await subprocess; t.deepEqual(ipcOutput, ['.']); @@ -91,7 +91,7 @@ test('process.once("disconnect") keeps the subprocess alive, after sendMessage() const subprocess = execa('ipc-once-disconnect-send.js', {ipc: true}); t.is(await subprocess.getOneMessage(), '.'); t.is(await subprocess.getOneMessage(), '.'); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {ipcOutput, stdout} = await subprocess; t.deepEqual(ipcOutput, ['.', '.']); @@ -102,7 +102,7 @@ test('process.once("disconnect") does not keep the subprocess alive, after getOn const subprocess = execa('ipc-once-disconnect-get.js', {ipc: true}); await subprocess.sendMessage('.'); t.is(await subprocess.getOneMessage(), '.'); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {ipcOutput, stdout} = await subprocess; t.deepEqual(ipcOutput, ['.']); @@ -111,8 +111,8 @@ test('process.once("disconnect") does not keep the subprocess alive, after getOn test('Can call subprocess.disconnect() right away', async t => { const subprocess = execa('ipc-send.js', {ipc: true}); - subprocess.disconnect(); - t.is(subprocess.channel, null); + subprocess.nodeChildProcess.disconnect(); + t.is(subprocess.nodeChildProcess.channel, null); await t.throwsAsync(subprocess.getOneMessage(), { message: /subprocess.getOneMessage\(\) could not complete/, diff --git a/test/ipc/send.js b/test/ipc/send.js index f1bc212761..028bb503cf 100644 --- a/test/ipc/send.js +++ b/test/ipc/send.js @@ -50,7 +50,7 @@ const BIG_PAYLOAD_SIZE = '.'.repeat(1e6); test('Handles backpressure', async t => { const subprocess = execa('ipc-iterate.js', {ipc: true}); await subprocess.sendMessage(BIG_PAYLOAD_SIZE); - t.true(subprocess.send(foobarString)); + t.true(subprocess.nodeChildProcess.send(foobarString)); t.is(await subprocess.getOneMessage(), BIG_PAYLOAD_SIZE); const {ipcOutput} = await subprocess; t.deepEqual(ipcOutput, [BIG_PAYLOAD_SIZE]); @@ -124,7 +124,7 @@ test('Does not hold message events on I/O errors', async t => { const subprocess = execa('ipc-echo.js', {ipc: true}); const error = mockSendIoError(subprocess); const promise = subprocess.sendMessage('.'); - subprocess.emit('message', '.'); + subprocess.nodeChildProcess.emit('message', '.'); t.is(await t.throwsAsync(promise), error); const {exitCode, isTerminated, message, ipcOutput} = await t.throwsAsync(subprocess); diff --git a/test/ipc/strict.js b/test/ipc/strict.js index 42b3312e3d..0f78a39e02 100644 --- a/test/ipc/strict.js +++ b/test/ipc/strict.js @@ -113,7 +113,7 @@ test('subprocess.sendMessage() "strict" fails if the subprocess uses once()', as test('exports.sendMessage() "strict" fails if the current process uses once() and buffer false', async t => { const subprocess = execa('ipc-send-strict.js', {ipc: true, buffer: {ipc: false}}); - const [message] = await once(subprocess, 'message'); + const [message] = await once(subprocess.nodeChildProcess, 'message'); t.deepEqual(message, { id: 0n, type: 'execa:ipc:request', diff --git a/test/ipc/validation.js b/test/ipc/validation.js index 3d76c8fb91..1724765730 100644 --- a/test/ipc/validation.js +++ b/test/ipc/validation.js @@ -44,7 +44,7 @@ test('subprocess.getEachMessage() after disconnection', testPostDisconnection, ' const testPostDisconnectionSubprocess = async (t, methodName) => { const subprocess = execa('ipc-disconnect.js', [methodName], {ipc: true}); - subprocess.disconnect(); + subprocess.nodeChildProcess.disconnect(); const {message} = await t.throwsAsync(subprocess); t.true(message.includes(`${methodName}() cannot be used`)); }; diff --git a/test/methods/main-async.js b/test/methods/main-async.js index 0496cc9aeb..318d2b768c 100644 --- a/test/methods/main-async.js +++ b/test/methods/main-async.js @@ -1,4 +1,5 @@ import process from 'node:process'; +import {ChildProcess} from 'node:child_process'; import test from 'ava'; import {execa} from '../../index.js'; import {setFixtureDirectory, FIXTURES_DIRECTORY} from '../helpers/fixtures-directory.js'; @@ -41,3 +42,38 @@ test('execa() returns a promise with pid', async t => { t.is(typeof subprocess.pid, 'number'); await subprocess; }); + +test('execa() returns a promise with nodeChildProcess', async t => { + const subprocess = execa('noop.js', ['foo']); + t.true(subprocess instanceof Promise); + t.false(subprocess instanceof ChildProcess); + t.true(subprocess.nodeChildProcess instanceof ChildProcess); + t.is(subprocess.pid, subprocess.nodeChildProcess.pid); + t.is(subprocess.stdout, subprocess.nodeChildProcess.stdout); + t.is(subprocess.on, undefined); + t.is(subprocess.once, undefined); + t.is(subprocess.send, undefined); + t.is(subprocess.ref, undefined); + t.is(subprocess.unref, undefined); + t.is(subprocess.disconnect, undefined); + t.is(subprocess.channel, undefined); + t.is(subprocess.connected, undefined); + t.is(subprocess.exitCode, undefined); + t.is(subprocess.signalCode, undefined); + t.is(subprocess.killed, undefined); + t.is(subprocess.spawnargs, undefined); + t.is(subprocess.spawnfile, undefined); + t.is(subprocess[Symbol.dispose], undefined); + await subprocess; +}); + +test('nodeChildProcess does not include Execa-specific APIs', async t => { + const subprocess = execa('noop.js', ['foo'], {all: true}); + t.false(Object.hasOwn(subprocess.nodeChildProcess, 'all')); + t.is(subprocess.nodeChildProcess.readable, undefined); + t.is(subprocess.nodeChildProcess.writable, undefined); + t.is(subprocess.nodeChildProcess.duplex, undefined); + t.is(subprocess.nodeChildProcess.iterable, undefined); + t.is(subprocess.nodeChildProcess[Symbol.asyncIterator], undefined); + await subprocess; +}); diff --git a/test/methods/promise.js b/test/methods/promise.js index 389eb20015..e5ea306a05 100644 --- a/test/methods/promise.js +++ b/test/methods/promise.js @@ -4,11 +4,9 @@ import {setFixtureDirectory} from '../helpers/fixtures-directory.js'; setFixtureDirectory(); -test('promise methods are not enumerable', t => { +test('subprocess properties are enumerable', t => { const descriptors = Object.getOwnPropertyDescriptors(execa('noop.js')); - t.false(descriptors.then.enumerable); - t.false(descriptors.catch.enumerable); - t.false(descriptors.finally.enumerable); + t.true(descriptors.nodeChildProcess.enumerable); }); test('finally function is executed on success', async t => { diff --git a/test/pipe/sequence.js b/test/pipe/sequence.js index 1151a905c1..8835a7a039 100644 --- a/test/pipe/sequence.js +++ b/test/pipe/sequence.js @@ -285,9 +285,9 @@ test('Simultaneous error on source and destination', async t => { const pipePromise = source.pipe(destination); const sourceCause = new Error(foobarString); - source.emit('error', sourceCause); + source.nodeChildProcess.emit('error', sourceCause); const destinationCause = new Error('other'); - destination.emit('error', destinationCause); + destination.nodeChildProcess.emit('error', destinationCause); t.is(await t.throwsAsync(pipePromise), await t.throwsAsync(destination)); t.like(await t.throwsAsync(source), {cause: {originalMessage: sourceCause.originalMessage}}); diff --git a/test/resolve/wait-subprocess.js b/test/resolve/wait-subprocess.js index 7d4a8b3e17..909b095021 100644 --- a/test/resolve/wait-subprocess.js +++ b/test/resolve/wait-subprocess.js @@ -19,9 +19,9 @@ test('stdio[*] is undefined if ignored - sync', testIgnore, 3, execaSync); const testSubprocessEventsCleanup = async (t, fixtureName) => { const subprocess = execa(fixtureName, {reject: false}); - t.deepEqual(subprocess.eventNames().map(String).sort(), ['error', 'exit', 'spawn']); + t.deepEqual(subprocess.nodeChildProcess.eventNames().map(String).sort(), ['error', 'exit', 'spawn']); await subprocess; - t.deepEqual(subprocess.eventNames(), []); + t.deepEqual(subprocess.nodeChildProcess.eventNames(), []); }; test('subprocess listeners are cleaned up on success', testSubprocessEventsCleanup, 'empty.js'); diff --git a/test/return/early-error.js b/test/return/early-error.js index 29d3271a73..ec8e089869 100644 --- a/test/return/early-error.js +++ b/test/return/early-error.js @@ -27,8 +27,8 @@ const testEarlyErrorShape = async (t, reject) => { const subprocess = getEarlyErrorSubprocess({reject}); t.notThrows(() => { subprocess.catch(() => {}); - subprocess.unref(); - subprocess.on('error', () => {}); + subprocess.nodeChildProcess.unref(); + subprocess.nodeChildProcess.on('error', () => {}); }); }; @@ -113,6 +113,40 @@ test('child_process.spawn() early errors can use .readableStream()', testEarlyEr test('child_process.spawn() early errors can use .writableStream()', testEarlyErrorWebConvertor, 'writableStream'); test('child_process.spawn() early errors can use .transformStream()', testEarlyErrorWebConvertor, 'transformStream'); +const testEarlyErrorIpc = async (t, runIpcMethod) => { + const subprocess = getEarlyErrorSubprocess({ipc: true}); + t.throws(() => { + runIpcMethod(subprocess); + }, {message: /cannot be used: the subprocess has already exited or disconnected/}); + await t.throwsAsync(subprocess, expectedEarlyError); +}; + +test('child_process.spawn() early errors can use .sendMessage()', testEarlyErrorIpc, subprocess => subprocess.sendMessage('.')); +test('child_process.spawn() early errors can use .getOneMessage()', testEarlyErrorIpc, subprocess => subprocess.getOneMessage()); +test('child_process.spawn() early errors can use .getEachMessage()', testEarlyErrorIpc, subprocess => subprocess.getEachMessage()); + +test('child_process.spawn() early errors can use .iterable()', async t => { + const subprocess = getEarlyErrorSubprocess(); + const lines = []; + for await (const line of subprocess.iterable()) { + lines.push(line); + } + + t.deepEqual(lines, []); + await t.throwsAsync(subprocess); +}); + +test('child_process.spawn() early errors can use Symbol.asyncIterator', async t => { + const subprocess = getEarlyErrorSubprocess(); + const lines = []; + for await (const line of subprocess) { + lines.push(line); + } + + t.deepEqual(lines, []); + await t.throwsAsync(subprocess); +}); + const testEarlyErrorStream = async (t, getStreamProperty, options) => { const subprocess = getEarlyErrorSubprocess(options); const stream = getStreamProperty(subprocess); diff --git a/test/return/final-error.js b/test/return/final-error.js index f622089c3c..248acba378 100644 --- a/test/return/final-error.js +++ b/test/return/final-error.js @@ -13,7 +13,7 @@ setFixtureDirectory(); const testUnusualError = async (t, error, expectedOriginalMessage = String(error)) => { const subprocess = execa('empty.js'); - subprocess.emit('error', error); + subprocess.nodeChildProcess.emit('error', error); const {originalMessage, shortMessage, message} = await t.throwsAsync(subprocess); t.is(originalMessage, expectedOriginalMessage === '' ? undefined : expectedOriginalMessage); t.true(shortMessage.includes(expectedOriginalMessage)); @@ -34,13 +34,13 @@ test('error instance can be undefined', testUnusualError, undefined, 'undefined' test('error instance can be a plain object', async t => { const subprocess = execa('empty.js'); - subprocess.emit('error', {message: foobarString}); + subprocess.nodeChildProcess.emit('error', {message: foobarString}); await t.throwsAsync(subprocess, {message: new RegExp(foobarString)}); }); const runAndFail = (t, fixtureName, argument, error) => { const subprocess = execa(fixtureName, [argument]); - subprocess.emit('error', error); + subprocess.nodeChildProcess.emit('error', error); return t.throwsAsync(subprocess); }; @@ -91,7 +91,7 @@ test('error.cause is not set if error.timedOut', async t => { test('error.cause is set on error event', async t => { const subprocess = execa('empty.js'); const error = new Error(foobarString); - subprocess.emit('error', error); + subprocess.nodeChildProcess.emit('error', error); const {cause} = await t.throwsAsync(subprocess); t.is(cause, error); }); diff --git a/test/return/result.js b/test/return/result.js index 78b338a8a9..32f1d5e024 100644 --- a/test/return/result.js +++ b/test/return/result.js @@ -114,7 +114,7 @@ test('result.isTerminated is false if not killed', async t => { test('result.isTerminated is false if not killed and subprocess.kill() was called', async t => { const subprocess = execa('noop.js'); subprocess.kill(0); - t.true(subprocess.killed); + t.true(subprocess.nodeChildProcess.killed); const {isTerminated} = await subprocess; t.false(isTerminated); }); diff --git a/test/terminate/cancel.js b/test/terminate/cancel.js index 8ab68deca2..a8224f2105 100644 --- a/test/terminate/cancel.js +++ b/test/terminate/cancel.js @@ -69,7 +69,7 @@ test('error.isCanceled is false when kill method is used', async t => { test('calling abort is considered a signal termination', async t => { const abortController = new AbortController(); const subprocess = execa('forever.js', {cancelSignal: abortController.signal}); - await once(subprocess, 'spawn'); + await once(subprocess.nodeChildProcess, 'spawn'); abortController.abort(); const {isCanceled, isGracefullyCanceled, isTerminated, signal} = await t.throwsAsync(subprocess); t.true(isCanceled); @@ -92,7 +92,7 @@ test('calling abort does not emit the "error" event', async t => { const abortController = new AbortController(); const subprocess = execa('forever.js', {cancelSignal: abortController.signal}); let error; - subprocess.once('error', errorArgument => { + subprocess.nodeChildProcess.once('error', errorArgument => { error = errorArgument; }); abortController.abort(); diff --git a/test/terminate/cleanup.js b/test/terminate/cleanup.js index 613902ed25..087d81d225 100644 --- a/test/terminate/cleanup.js +++ b/test/terminate/cleanup.js @@ -73,6 +73,25 @@ test('spawnAndKill detached SIGKILL', spawnAndKill, ['SIGKILL', false, true, fal test('spawnAndKill cleanup detached SIGTERM', spawnAndKill, ['SIGTERM', true, true, false]); test('spawnAndKill cleanup detached SIGKILL', spawnAndKill, ['SIGKILL', true, true, false]); +if (!isWindows) { + test('spawnAndKill cleanup uses killSignal', async t => { + const subprocess = execa('ipc-send-pid.js', ['true', 'false', 'no-killable'], {stdio: 'ignore', ipc: true}); + + const pid = await subprocess.getOneMessage(); + t.true(Number.isInteger(pid)); + t.true(isRunning(pid)); + + process.kill(subprocess.pid, 'SIGTERM'); + + await t.throwsAsync(subprocess); + await Promise.race([ + setTimeout(1e4, undefined, {ref: false}), + pollForSubprocessExit(pid), + ]); + t.false(isRunning(pid)); + }); +} + // See #128 test('removes exit handler on exit', async t => { // @todo this relies on `signal-exit` internals diff --git a/test/terminate/graceful.js b/test/terminate/graceful.js index 6ab4429e84..600bc200e2 100644 --- a/test/terminate/graceful.js +++ b/test/terminate/graceful.js @@ -47,7 +47,7 @@ test('Does not disconnect during I/O errors when sending the abort reason', asyn const error = mockSendIoError(subprocess); controller.abort(foobarString); await setTimeout(0); - t.true(subprocess.connected); + t.true(subprocess.nodeChildProcess.connected); subprocess.kill(); const {isCanceled, isGracefullyCanceled, signal, ipcOutput, cause} = await t.throwsAsync(subprocess); t.false(isCanceled); @@ -97,7 +97,7 @@ test('Fail when sending non-serializable abort reason', async t => { const subprocess = execa('ipc-echo.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); controller.abort(() => {}); await setTimeout(0); - t.true(subprocess.connected); + t.true(subprocess.nodeChildProcess.connected); await subprocess.sendMessage(foobarString); const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, cause, ipcOutput} = await t.throwsAsync(subprocess); t.false(isCanceled); diff --git a/test/terminate/kill-error.js b/test/terminate/kill-error.js index 6bd7bbd936..e69fbe4c0a 100644 --- a/test/terminate/kill-error.js +++ b/test/terminate/kill-error.js @@ -78,6 +78,6 @@ test('.kill(error) does not emit the "error" event', async t => { const subprocess = execa('forever.js'); const cause = new Error('test'); subprocess.kill(cause); - const error = await Promise.race([t.throwsAsync(subprocess), once(subprocess, 'error')]); + const error = await Promise.race([t.throwsAsync(subprocess), once(subprocess.nodeChildProcess, 'error')]); t.is(error.cause, cause); }); diff --git a/test/terminate/kill-force.js b/test/terminate/kill-force.js index 961e048e4e..ea41c7d0bb 100644 --- a/test/terminate/kill-force.js +++ b/test/terminate/kill-force.js @@ -111,7 +111,7 @@ if (isWindows) { test('`forceKillAfterDelay` works with the "cancelSignal" option', async t => { const abortController = new AbortController(); const subprocess = spawnNoKillableSimple({cancelSignal: abortController.signal}); - await once(subprocess, 'spawn'); + await once(subprocess.nodeChildProcess, 'spawn'); abortController.abort(''); const {isTerminated, signal, isCanceled, isGracefullyCanceled, isForcefullyTerminated, shortMessage} = await t.throwsAsync(subprocess); t.true(isTerminated); @@ -145,10 +145,10 @@ if (isWindows) { test('`forceKillAfterDelay` works with the "error" event', async t => { const subprocess = spawnNoKillableSimple(); - await once(subprocess, 'spawn'); + await once(subprocess.nodeChildProcess, 'spawn'); const error = new Error(foobarString); error.code = 'ECODE'; - subprocess.emit('error', error); + subprocess.nodeChildProcess.emit('error', error); subprocess.kill(); const {isTerminated, signal, isForcefullyTerminated, shortMessage, originalMessage, cause} = await t.throwsAsync(subprocess); t.true(isTerminated); diff --git a/test/terminate/kill-signal.js b/test/terminate/kill-signal.js index 26c500d2e5..42d7f26638 100644 --- a/test/terminate/kill-signal.js +++ b/test/terminate/kill-signal.js @@ -89,7 +89,7 @@ test('Cannot call .kill(true, error)', testInvalidKillArgument, true, new Error( test('subprocess errors are handled before spawn', async t => { const subprocess = execa('forever.js'); const cause = new Error('test'); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); subprocess.kill(); const error = await t.throwsAsync(subprocess); t.is(error.cause, cause); @@ -100,9 +100,9 @@ test('subprocess errors are handled before spawn', async t => { test('subprocess errors are handled after spawn', async t => { const subprocess = execa('forever.js'); - await once(subprocess, 'spawn'); + await once(subprocess.nodeChildProcess, 'spawn'); const cause = new Error('test'); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); subprocess.kill(); const error = await t.throwsAsync(subprocess); t.is(error.cause, cause); @@ -114,12 +114,12 @@ test('subprocess errors are handled after spawn', async t => { test('subprocess double errors are handled after spawn', async t => { const abortController = new AbortController(); const subprocess = execa('forever.js', {cancelSignal: abortController.signal}); - await once(subprocess, 'spawn'); + await once(subprocess.nodeChildProcess, 'spawn'); const cause = new Error('test'); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); await setImmediate(); abortController.abort(); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); const error = await t.throwsAsync(subprocess); t.is(error.cause, cause); t.is(error.exitCode, undefined); @@ -129,9 +129,9 @@ test('subprocess double errors are handled after spawn', async t => { test('subprocess errors use killSignal', async t => { const subprocess = execa('forever.js', {killSignal: 'SIGINT'}); - await once(subprocess, 'spawn'); + await once(subprocess.nodeChildProcess, 'spawn'); const cause = new Error('test'); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); subprocess.kill(); const error = await t.throwsAsync(subprocess); t.is(error.cause, cause); diff --git a/test/terminate/timeout.js b/test/terminate/timeout.js index 655b5a9115..ef7706fc59 100644 --- a/test/terminate/timeout.js +++ b/test/terminate/timeout.js @@ -69,7 +69,7 @@ test('timedOut is false if timeout is undefined and exit code is 0 in sync mode' test('timedOut is false if the timeout happened after a different error occurred', async t => { const subprocess = execa('forever.js', {timeout: 1e3}); const cause = new Error('test'); - subprocess.emit('error', cause); + subprocess.nodeChildProcess.emit('error', cause); const error = await t.throwsAsync(subprocess); t.is(error.cause, cause); t.false(error.timedOut); diff --git a/types/subprocess/subprocess.d.ts b/types/subprocess/subprocess.d.ts index 5df10d43a3..2910d4df9e 100644 --- a/types/subprocess/subprocess.d.ts +++ b/types/subprocess/subprocess.d.ts @@ -121,22 +121,28 @@ type ExecaCustomSubprocess = Converts the subprocess to a [`{readable, writable}`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream) pair of web streams. */ transformStream(duplexOptions?: DuplexOptions): ReadableWritablePair; + + /** + Underlying Node.js [`ChildProcess`](https://nodejs.org/api/child_process.html#class-childprocess) instance. + + This is an escape hatch for Node.js-specific APIs not documented by Execa, such as `.on()`, `.send()`, `.disconnect()`, `.ref()` or `.unref()`. + */ + nodeChildProcess: ChildProcess; }; /** -[`child_process` instance](https://nodejs.org/api/child_process.html#child_process_class_childprocess) with additional methods and properties. +Subprocess with Execa-specific methods and properties. It is also a `Promise` either resolving with its successful `result`, or rejecting with its `error`. */ export type Subprocess = - & Omit> - & ExecaCustomSubprocess; + & ExecaCustomSubprocess + & Promise>; /** The return value of all asynchronous methods is both: -- the subprocess. +- the subprocess with Execa-specific methods and properties. - a `Promise` either resolving with its successful `result`, or rejecting with its `error`. */ export type ResultPromise = - & Subprocess - & Promise>; + Subprocess; export {};