From d8190e5a5e2ee2e984d39441a8d737065d79d7da Mon Sep 17 00:00:00 2001 From: ehmicky Date: Tue, 4 Jun 2024 00:39:27 +0100 Subject: [PATCH] Add `gracefulCancel` option (#1109) --- docs/api.md | 41 ++++- docs/bash.md | 34 +++- docs/errors.md | 3 +- docs/termination.md | 107 +++++++++++-- index.d.ts | 1 + index.js | 2 + lib/arguments/options.js | 6 +- lib/ipc/graceful.js | 72 +++++++++ lib/ipc/incoming.js | 5 +- lib/ipc/methods.js | 17 +- lib/ipc/send.js | 38 ++++- lib/ipc/validation.js | 45 ++++-- lib/methods/main-async.js | 7 +- lib/methods/main-sync.js | 1 + lib/resolve/wait-subprocess.js | 20 ++- lib/return/message.js | 13 ++ lib/return/result.js | 7 + lib/terminate/cancel.js | 14 +- lib/terminate/graceful.js | 71 +++++++++ lib/terminate/kill.js | 1 - lib/terminate/timeout.js | 2 +- lib/utils/abort-signal.js | 8 + readme.md | 31 +++- test-d/arguments/options.test-d.ts | 9 +- test-d/ipc/get-each.test-d.ts | 5 + test-d/ipc/get-one.test-d.ts | 5 + test-d/ipc/graceful.ts | 8 + test-d/ipc/send.test-d.ts | 5 + test-d/return/result-ipc.ts | 15 ++ test-d/return/result-main.test-d.ts | 4 + test/fixtures/graceful-disconnect.js | 12 ++ test/fixtures/graceful-echo.js | 5 + test/fixtures/graceful-listener.js | 12 ++ test/fixtures/graceful-none.js | 4 + test/fixtures/graceful-print.js | 7 + test/fixtures/graceful-ref.js | 6 + test/fixtures/graceful-send-echo.js | 9 ++ test/fixtures/graceful-send-fast.js | 5 + test/fixtures/graceful-send-print.js | 8 + test/fixtures/graceful-send-string.js | 6 + test/fixtures/graceful-send-twice.js | 8 + test/fixtures/graceful-send.js | 7 + test/fixtures/graceful-twice.js | 8 + test/fixtures/graceful-wait.js | 6 + test/fixtures/ipc-get.js | 4 + test/fixtures/wait-fail.js | 6 + test/helpers/graceful.js | 8 + test/ipc/graceful.js | 216 ++++++++++++++++++++++++++ test/return/result.js | 2 + test/terminate/cancel.js | 52 +++++-- test/terminate/graceful.js | 178 +++++++++++++++++++++ test/terminate/kill-force.js | 3 +- test/terminate/timeout.js | 4 +- types/arguments/options.d.ts | 32 ++-- types/ipc.d.ts | 19 ++- types/methods/main-async.d.ts | 29 ++++ types/return/result.d.ts | 5 + 57 files changed, 1163 insertions(+), 95 deletions(-) create mode 100644 lib/ipc/graceful.js create mode 100644 lib/terminate/graceful.js create mode 100644 lib/utils/abort-signal.js create mode 100644 test-d/ipc/graceful.ts create mode 100755 test/fixtures/graceful-disconnect.js create mode 100755 test/fixtures/graceful-echo.js create mode 100755 test/fixtures/graceful-listener.js create mode 100755 test/fixtures/graceful-none.js create mode 100755 test/fixtures/graceful-print.js create mode 100755 test/fixtures/graceful-ref.js create mode 100755 test/fixtures/graceful-send-echo.js create mode 100755 test/fixtures/graceful-send-fast.js create mode 100755 test/fixtures/graceful-send-print.js create mode 100755 test/fixtures/graceful-send-string.js create mode 100755 test/fixtures/graceful-send-twice.js create mode 100755 test/fixtures/graceful-send.js create mode 100755 test/fixtures/graceful-twice.js create mode 100755 test/fixtures/graceful-wait.js create mode 100755 test/fixtures/ipc-get.js create mode 100755 test/fixtures/wait-fail.js create mode 100644 test/helpers/graceful.js create mode 100644 test/ipc/graceful.js create mode 100644 test/terminate/graceful.js diff --git a/docs/api.md b/docs/api.md index b44de1ac6d..6a82a51c15 100644 --- a/docs/api.md +++ b/docs/api.md @@ -173,6 +173,16 @@ Keep the subprocess alive while `getEachMessage()` is waiting. [More info.](ipc.md#keeping-the-subprocess-alive) +### getCancelSignal() + +_Returns_: [`Promise`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) + +Retrieves the [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) shared by the [`cancelSignal`](#optionscancelsignal) option. + +This can only be called inside a subprocess. This requires the [`gracefulCancel`](#optionsgracefulcancel) option to be `true`. + +[More info.](termination.md#graceful-termination) + ## Return value _TypeScript:_ [`ResultPromise`](typescript.md)\ @@ -651,6 +661,14 @@ Whether the subprocess was canceled using the [`cancelSignal`](#optionscancelsig [More info.](termination.md#canceling) +### error.isGracefullyCanceled + +_Type:_ `boolean` + +Whether the subprocess was canceled using both the [`cancelSignal`](#optionscancelsignal) and the [`gracefulCancel`](#optionsgracefulcancel) options. + +[More info.](termination.md#graceful-termination) + ### error.isMaxBuffer _Type:_ `boolean` @@ -943,6 +961,8 @@ Largest amount of data allowed on [`stdout`](#resultstdout), [`stderr`](#results By default, this applies to both `stdout` and `stderr`, but [different values can also be passed](output.md#stdoutstderr-specific-options). +When reached, [`error.isMaxBuffer`](#errorismaxbuffer) becomes `true`. + [More info.](output.md#big-output) ### options.buffer @@ -959,7 +979,7 @@ By default, this applies to both `stdout` and `stderr`, but [different values ca ### options.ipc _Type:_ `boolean`\ -_Default:_ `true` if either the [`node`](#optionsnode) option or the [`ipcInput`](#optionsipcinput) is set, `false` otherwise +_Default:_ `true` if the [`node`](#optionsnode), [`ipcInput`](#optionsipcinput) or [`gracefulCancel`](#optionsgracefulcancel) option is set, `false` otherwise Enables exchanging messages with the subprocess using [`subprocess.sendMessage(message)`](#subprocesssendmessagemessage-sendmessageoptions), [`subprocess.getOneMessage()`](#subprocessgetonemessagegetonemessageoptions) and [`subprocess.getEachMessage()`](#subprocessgeteachmessage). @@ -1015,7 +1035,7 @@ _Default:_ `0` If `timeout` is greater than `0`, the subprocess will be [terminated](#optionskillsignal) if it runs for longer than that amount of milliseconds. -On timeout, [`result.timedOut`](#errortimedout) becomes `true`. +On timeout, [`error.timedOut`](#errortimedout) becomes `true`. [More info.](termination.md#timeout) @@ -1023,12 +1043,25 @@ On timeout, [`result.timedOut`](#errortimedout) becomes `true`. _Type:_ [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) -You can abort the subprocess using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). +When the `cancelSignal` is [aborted](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort), terminate the subprocess using a `SIGTERM` signal. -When `AbortController.abort()` is called, [`result.isCanceled`](#erroriscanceled) becomes `true`. +When aborted, [`error.isCanceled`](#erroriscanceled) becomes `true`. [More info.](termination.md#canceling) +### options.gracefulCancel + +_Type:_ `boolean`\ +_Default:_: `false` + +When the [`cancelSignal`](#optionscancelsignal) option is [aborted](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort), do not send any [`SIGTERM`](termination.md#canceling). Instead, abort the [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) returned by [`getCancelSignal()`](#getcancelsignal). The subprocess should use it to terminate gracefully. + +The subprocess must be a [Node.js file](#optionsnode). + +When aborted, [`error.isGracefullyCanceled`](#errorisgracefullycanceled) becomes `true`. + +[More info.](termination.md#graceful-termination) + ### options.forceKillAfterDelay _Type:_ `number | false`\ diff --git a/docs/bash.md b/docs/bash.md index bc7e2c4585..930b58b09b 100644 --- a/docs/bash.md +++ b/docs/bash.md @@ -10,7 +10,7 @@ This page describes the differences between [Bash](https://en.wikipedia.org/wiki - [Simple](#simplicity): minimalistic API, no [globals](#global-variables), no [binary](#main-binary), no builtin CLI utilities. - [Cross-platform](#shell): [no shell](shell.md) is used, only JavaScript. - [Secure](#escaping): no shell injection. -- [Featureful](#simplicity): all Execa features are available ([text lines iteration](#iterate-over-output-lines), [advanced piping](#piping-stdout-to-another-command), [simple IPC](#ipc), [passing any input type](#pass-any-input-type), [returning any output type](#return-any-output-type), [transforms](#transforms), [web streams](#web-streams), [convert to Duplex stream](#convert-to-duplex-stream), [cleanup on exit](termination.md#current-process-exit), [forceful termination](termination.md#forceful-termination), and [more](../readme.md#documentation)). +- [Featureful](#simplicity): all Execa features are available ([text lines iteration](#iterate-over-output-lines), [advanced piping](#piping-stdout-to-another-command), [simple IPC](#ipc), [passing any input type](#pass-any-input-type), [returning any output type](#return-any-output-type), [transforms](#transforms), [web streams](#web-streams), [convert to Duplex stream](#convert-to-duplex-stream), [cleanup on exit](termination.md#current-process-exit), [graceful termination](#graceful-termination), [forceful termination](termination.md#forceful-termination), and [more](../readme.md#documentation)). - [Easy to debug](#debugging): [verbose mode](#verbose-mode-single-command), [detailed errors](#detailed-errors), [messages and stack traces](#cancelation), stateless API. - [Performant](#performance) @@ -1042,6 +1042,38 @@ await $({cancelSignal: controller.signal})`node long-script.js`; [More info.](termination.md#canceling) +### Graceful termination + +```sh +# Bash +trap cleanup SIGTERM +``` + +```js +// zx +// This does not work on Windows +process.on('SIGTERM', () => { + // ... +}); +``` + +```js +// Execa - main.js +const controller = new AbortController(); +await $({ + cancelSignal: controller.signal, + gracefulCancel: true, +})`node build.js`; +``` + +```js +// Execa - build.js +import {getCancelSignal} from 'execa'; + +const cancelSignal = await getCancelSignal(); +await fetch('https://example.com', {signal: cancelSignal}); +``` + ### Interleaved output ```sh diff --git a/docs/errors.md b/docs/errors.md index d8f8d1e8f6..deba2ba6ea 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -52,8 +52,9 @@ try { The subprocess can fail for other reasons. Some of them can be detected using a specific boolean property: - [`error.timedOut`](api.md#errortimedout): [`timeout`](termination.md#timeout) option. - [`error.isCanceled`](api.md#erroriscanceled): [`cancelSignal`](termination.md#canceling) option. +- [`error.isGracefullyCanceled`](api.md#errorisgracefullycanceled): `cancelSignal` option, if the [`gracefulCancel`](termination.md#graceful-termination) option is `true`. - [`error.isMaxBuffer`](api.md#errorismaxbuffer): [`maxBuffer`](output.md#big-output) option. -- [`error.isTerminated`](api.md#erroristerminated): [signal termination](termination.md#signal-termination). This includes the [`timeout`](termination.md#timeout) and [`cancelSignal`](termination.md#canceling) options since those terminate the subprocess with a [signal](termination.md#default-signal). However, this does not include the [`maxBuffer`](output.md#big-output) option. +- [`error.isTerminated`](api.md#erroristerminated): [signal termination](termination.md#signal-termination). This includes the [`timeout`](termination.md#timeout) and [`forceKillAfterDelay`](termination.md#forceful-termination) options since those terminate the subprocess with a [signal](termination.md#default-signal). This also includes the [`cancelSignal`](termination.md#canceling) option unless the [`gracefulCancel`](termination.md#graceful-termination) option is `true`. This does not include the [`maxBuffer`](output.md#big-output) option. Otherwise, the subprocess failed because either: - An exception was thrown in a [stream](streams.md) or [transform](transform.md). diff --git a/docs/termination.md b/docs/termination.md index 92bded7e01..8c445cd2e2 100644 --- a/docs/termination.md +++ b/docs/termination.md @@ -8,35 +8,120 @@ ## Alternatives -Terminating a subprocess ends it abruptly. This prevents rolling back the subprocess' operations and leaves them incomplete. When possible, graceful exits should be preferred, such as: -- Letting the subprocess end on its own. -- [Performing cleanup](#sigterm) in termination [signal handlers](https://nodejs.org/api/process.html#process_signal_events). -- [Sending a message](ipc.md) to the subprocess so it aborts its operations and cleans up. +Terminating a subprocess ends it abruptly. This prevents rolling back the subprocess' operations and leaves them incomplete. + +Ideally subprocesses should end on their own. If that's not possible, [graceful termination](#graceful-termination) should be preferred. ## Canceling -The [`cancelSignal`](api.md#optionscancelsignal) option can be used to cancel a subprocess. When [`abortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) is [aborted](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort), a [`SIGTERM` signal](#default-signal) is sent to the subprocess. +The [`cancelSignal`](api.md#optionscancelsignal) option can be used to cancel a subprocess. When it is [aborted](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort), a [`SIGTERM` signal](#default-signal) is sent to the subprocess. ```js -import {execa} from 'execa'; +import {execaNode} from 'execa'; -const abortController = new AbortController(); +const controller = new AbortController(); +const cancelSignal = controller.signal; setTimeout(() => { - abortController.abort(); + controller.abort(); }, 5000); try { - await execa({cancelSignal: abortController.signal})`npm run build`; + await execaNode({cancelSignal})`build.js`; } catch (error) { if (error.isCanceled) { - console.error('Aborted by cancelSignal.'); + console.error('Canceled by cancelSignal.'); + } + + throw error; +} +``` + +## Graceful termination + +### Share a `cancelSignal` + +When the [`gracefulCancel`](api.md#optionsgracefulcancel) option is `true`, the [`cancelSignal`](api.md#optionscancelsignal) option does not send any [`SIGTERM`](#sigterm). Instead, the subprocess calls [`getCancelSignal()`](api.md#getcancelsignal) to retrieve and handle the [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). This allows the subprocess to properly clean up and abort operations. + +This option only works with Node.js files. + +This is cross-platform. If you do not need to support Windows, [signal handlers](#handling-signals) can also be used. + +```js +// main.js +import {execaNode} from 'execa'; + +const controller = new AbortController(); +const cancelSignal = controller.signal; + +setTimeout(() => { + controller.abort(); +}, 5000); + +try { + await execaNode({cancelSignal, gracefulCancel: true})`build.js`; +} catch (error) { + if (error.isGracefullyCanceled) { + console.error('Cancelled gracefully.'); } throw error; } ``` +```js +// build.js +import {getCancelSignal} from 'execa'; + +const cancelSignal = await getCancelSignal(); +``` + +### Abort operations + +The [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) returned by [`getCancelSignal()`](api.md#getcancelsignal) can be passed to most long-running Node.js methods: [`setTimeout()`](https://nodejs.org/api/timers.html#timerspromisessettimeoutdelay-value-options), [`setInterval()`](https://nodejs.org/api/timers.html#timerspromisessetintervaldelay-value-options), [events](https://nodejs.org/api/events.html#eventsonemitter-eventname-options), [streams](https://nodejs.org/api/stream.html#new-streamreadableoptions), [REPL](https://nodejs.org/api/readline.html#rlquestionquery-options), HTTP/TCP [requests](https://nodejs.org/api/http.html#httprequesturl-options-callback) or [servers](https://nodejs.org/api/net.html#serverlistenoptions-callback), [reading](https://nodejs.org/api/fs.html#fspromisesreadfilepath-options) / [writing](https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options) / [watching](https://nodejs.org/api/fs.html#fspromiseswatchfilename-options) files, or spawning another subprocess. + +When aborted, those methods throw the `Error` instance which was passed to [`abortController.abort(error)`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort). Since those methods keep the subprocess alive, aborting them makes the subprocess end on its own. + +```js +import {getCancelSignal} from 'execa'; +import {watch} from 'node:fs/promises'; + +const cancelSignal = await getCancelSignal(); + +try { + for await (const fileChange of watch('./src', {signal: cancelSignal})) { + onFileChange(fileChange); + } +} catch (error) { + if (error.isGracefullyCanceled) { + console.log(error.cause === cancelSignal.reason); // true + } +} +``` + +### Cleanup logic + +For other kinds of operations, the [`abort`](https://nodejs.org/api/globals.html#event-abort) event should be listened to. Although [`cancelSignal.addEventListener('abort')`](https://nodejs.org/api/events.html#eventtargetaddeventlistenertype-listener-options) can be used, [`events.addAbortListener(cancelSignal)`](https://nodejs.org/api/events.html#eventsaddabortlistenersignal-listener) is preferred since it works even if the `cancelSignal` is already aborted. + +### Graceful exit + +We recommend explicitly [stopping](#abort-operations) each pending operation when the subprocess is aborted. This allows it to end on its own. + +```js +import {getCancelSignal} from 'execa'; +import {addAbortListener} from 'node:events'; + +const cancelSignal = await getCancelSignal(); +addAbortListener(cancelSignal, async () => { + await cleanup(); + process.exitCode = 1; +}); +``` + +However, if any operation is still ongoing, the subprocess will keep running. It can be forcefully ended using [`process.exit(exitCode)`](https://nodejs.org/api/process.html#processexitcode) instead of [`process.exitCode`](https://nodejs.org/api/process.html#processexitcode_1). + +If the subprocess is still alive after 5 seconds, it is forcefully terminated with [`SIGKILL`](#sigkill). This can be [configured or disabled](#forceful-termination) using the [`forceKillAfterDelay`](api.md#optionsforcekillafterdelay) option. + ## Timeout If the subprocess lasts longer than the [`timeout`](api.md#optionstimeout) option, a [`SIGTERM` signal](#default-signal) is sent to it. @@ -127,6 +212,8 @@ process.on('SIGTERM', () => { Unfortunately this [usually does not work](https://github.com/ehmicky/cross-platform-node-guide/blob/main/docs/6_networking_ipc/signals.md#cross-platform-signals) on Windows. The only signal that is somewhat cross-platform is [`SIGINT`](#sigint): on Windows, its handler is triggered when the user types `CTRL-C` in the terminal. However `subprocess.kill('SIGINT')` is only handled on Unix. +Execa provides the [`gracefulCancel`](#graceful-termination) option as a cross-platform alternative to signal handlers. + ### Signal name and description When a subprocess was terminated by a signal, [`error.isTerminated`](api.md#erroristerminated) is `true`. diff --git a/index.d.ts b/index.d.ts index 724319faf5..3e77c6b175 100644 --- a/index.d.ts +++ b/index.d.ts @@ -21,5 +21,6 @@ export { sendMessage, getOneMessage, getEachMessage, + getCancelSignal, type Message, } from './types/ipc.js'; diff --git a/index.js b/index.js index a077422e14..11285d9615 100644 --- a/index.js +++ b/index.js @@ -18,9 +18,11 @@ const { sendMessage, getOneMessage, getEachMessage, + getCancelSignal, } = getIpcExport(); export { sendMessage, getOneMessage, getEachMessage, + getCancelSignal, }; diff --git a/lib/arguments/options.js b/lib/arguments/options.js index ff77809729..1b640ac280 100644 --- a/lib/arguments/options.js +++ b/lib/arguments/options.js @@ -5,6 +5,7 @@ import {npmRunPathEnv} from 'npm-run-path'; import {normalizeForceKillAfterDelay} from '../terminate/kill.js'; import {normalizeKillSignal} from '../terminate/signal.js'; import {validateCancelSignal} from '../terminate/cancel.js'; +import {validateGracefulCancel} from '../terminate/graceful.js'; import {validateTimeout} from '../terminate/timeout.js'; import {handleNodeOption} from '../methods/node.js'; import {validateIpcInputOption} from '../ipc/ipc-input.js'; @@ -27,6 +28,7 @@ export const normalizeOptions = (filePath, rawArguments, rawOptions) => { validateEncoding(options); validateIpcInputOption(options); validateCancelSignal(options); + validateGracefulCancel(options); options.shell = normalizeFileUrl(options.shell); options.env = getEnv(options); options.killSignal = normalizeKillSignal(options.killSignal); @@ -53,8 +55,9 @@ const addDefaultOptions = ({ windowsHide = true, killSignal = 'SIGTERM', forceKillAfterDelay = true, + gracefulCancel = false, ipcInput, - ipc = ipcInput !== undefined, + ipc = ipcInput !== undefined || gracefulCancel, serialization = 'advanced', ...options }) => ({ @@ -70,6 +73,7 @@ const addDefaultOptions = ({ windowsHide, killSignal, forceKillAfterDelay, + gracefulCancel, ipcInput, ipc, serialization, diff --git a/lib/ipc/graceful.js b/lib/ipc/graceful.js new file mode 100644 index 0000000000..7931ecacaa --- /dev/null +++ b/lib/ipc/graceful.js @@ -0,0 +1,72 @@ +import {scheduler} from 'node:timers/promises'; +import {sendOneMessage} from './send.js'; +import {getIpcEmitter} from './forward.js'; +import {validateConnection, getAbortDisconnectError, throwOnMissingParent} from './validation.js'; + +// Send an IPC message so the subprocess performs a graceful termination +export const sendAbort = (subprocess, message) => { + const methodName = 'cancelSignal'; + validateConnection(methodName, false, subprocess.connected); + return sendOneMessage({ + anyProcess: subprocess, + methodName, + isSubprocess: false, + wrappedMessage: {type: GRACEFUL_CANCEL_TYPE, message}, + message, + }); +}; + +// When the signal is being used, start listening for incoming messages. +// Unbuffering messages takes one microtask to complete, so this must be async. +export const getCancelSignal = async ({anyProcess, channel, isSubprocess, ipc}) => { + await startIpc({ + anyProcess, + channel, + isSubprocess, + ipc, + }); + return cancelController.signal; +}; + +const startIpc = async ({anyProcess, channel, isSubprocess, ipc}) => { + if (cancelListening) { + return; + } + + cancelListening = true; + + if (!ipc) { + throwOnMissingParent(); + return; + } + + if (channel === null) { + abortOnDisconnect(); + return; + } + + getIpcEmitter(anyProcess, channel, isSubprocess); + await scheduler.yield(); +}; + +let cancelListening = false; + +// Reception of IPC message to perform a graceful termination +export const handleAbort = wrappedMessage => { + if (wrappedMessage?.type !== GRACEFUL_CANCEL_TYPE) { + return false; + } + + cancelController.abort(wrappedMessage.message); + return true; +}; + +const GRACEFUL_CANCEL_TYPE = 'execa:ipc:cancel'; + +// When the current process disconnects early, the subprocess `cancelSignal` is aborted. +// Otherwise, the signal would never be able to be aborted later on. +export const abortOnDisconnect = () => { + cancelController.abort(getAbortDisconnectError()); +}; + +const cancelController = new AbortController(); diff --git a/lib/ipc/incoming.js b/lib/ipc/incoming.js index 6dc0f8fadc..56749f6483 100644 --- a/lib/ipc/incoming.js +++ b/lib/ipc/incoming.js @@ -3,6 +3,7 @@ import {scheduler} from 'node:timers/promises'; import {waitForOutgoingMessages} from './outgoing.js'; import {redoAddedReferences} from './reference.js'; import {handleStrictRequest, handleStrictResponse} from './strict.js'; +import {handleAbort, abortOnDisconnect} from './graceful.js'; // By default, Node.js buffers `message` events. // - Buffering happens when there is a `message` event is emitted but there is no handler. @@ -23,7 +24,7 @@ import {handleStrictRequest, handleStrictResponse} from './strict.js'; // To solve those problems, instead of buffering messages, we debounce them. // The `message` event so it is emitted at most once per macrotask. export const onMessage = async ({anyProcess, channel, isSubprocess, ipcEmitter}, wrappedMessage) => { - if (handleStrictResponse(wrappedMessage)) { + if (handleStrictResponse(wrappedMessage) || handleAbort(wrappedMessage)) { return; } @@ -61,6 +62,8 @@ export const onMessage = async ({anyProcess, channel, isSubprocess, ipcEmitter}, // If the `message` event is currently debounced, the `disconnect` event must wait for it export const onDisconnect = async ({anyProcess, channel, isSubprocess, ipcEmitter, boundOnMessage}) => { + abortOnDisconnect(); + const incomingMessages = INCOMING_MESSAGES.get(anyProcess); while (incomingMessages?.length > 0) { // eslint-disable-next-line no-await-in-loop diff --git a/lib/ipc/methods.js b/lib/ipc/methods.js index 1ccd1d019f..c1963bd864 100644 --- a/lib/ipc/methods.js +++ b/lib/ipc/methods.js @@ -2,6 +2,7 @@ import process from 'node:process'; import {sendMessage} from './send.js'; import {getOneMessage} from './get-one.js'; import {getEachMessage} from './get-each.js'; +import {getCancelSignal} from './graceful.js'; // Add promise-based IPC methods in current process export const addIpcMethods = (subprocess, {ipc}) => { @@ -9,7 +10,21 @@ export const addIpcMethods = (subprocess, {ipc}) => { }; // Get promise-based IPC in the subprocess -export const getIpcExport = () => getIpcMethods(process, true, process.channel !== undefined); +export const getIpcExport = () => { + const anyProcess = process; + const isSubprocess = true; + const ipc = process.channel !== undefined; + + return { + ...getIpcMethods(anyProcess, isSubprocess, ipc), + getCancelSignal: getCancelSignal.bind(undefined, { + anyProcess, + channel: anyProcess.channel, + isSubprocess, + ipc, + }), + }; +}; // Retrieve the `ipc` shared by both the current process and the subprocess const getIpcMethods = (anyProcess, isSubprocess, ipc) => ({ diff --git a/lib/ipc/send.js b/lib/ipc/send.js index 06c3545678..2c885a14d6 100644 --- a/lib/ipc/send.js +++ b/lib/ipc/send.js @@ -13,8 +13,9 @@ import {handleSendStrict, waitForStrictResponse} from './strict.js'; // Users would still need to `await subprocess` after the method is done. // Also, this would prevent `unhandledRejection` event from being emitted, making it silent. export const sendMessage = ({anyProcess, channel, isSubprocess, ipc}, message, {strict = false} = {}) => { + const methodName = 'sendMessage'; validateIpcMethod({ - methodName: 'sendMessage', + methodName, isSubprocess, ipc, isConnected: anyProcess.connected, @@ -23,14 +24,14 @@ export const sendMessage = ({anyProcess, channel, isSubprocess, ipc}, message, { return sendMessageAsync({ anyProcess, channel, + methodName, isSubprocess, message, strict, }); }; -const sendMessageAsync = async ({anyProcess, channel, isSubprocess, message, strict}) => { - const sendMethod = getSendMethod(anyProcess); +const sendMessageAsync = async ({anyProcess, channel, methodName, isSubprocess, message, strict}) => { const wrappedMessage = handleSendStrict({ anyProcess, channel, @@ -39,6 +40,25 @@ const sendMessageAsync = async ({anyProcess, channel, isSubprocess, message, str strict, }); const outgoingMessagesState = startSendMessage(anyProcess, wrappedMessage, strict); + try { + await sendOneMessage({ + anyProcess, + methodName, + isSubprocess, + wrappedMessage, + message, + }); + } catch (error) { + disconnect(anyProcess); + throw error; + } finally { + endSendMessage(outgoingMessagesState); + } +}; + +// Used internally by `cancelSignal` +export const sendOneMessage = async ({anyProcess, methodName, isSubprocess, wrappedMessage, message}) => { + const sendMethod = getSendMethod(anyProcess); try { await Promise.all([ @@ -46,12 +66,14 @@ const sendMessageAsync = async ({anyProcess, channel, isSubprocess, message, str sendMethod(wrappedMessage), ]); } catch (error) { - disconnect(anyProcess); - handleEpipeError(error, isSubprocess); - handleSerializationError(error, isSubprocess, message); + handleEpipeError({error, methodName, isSubprocess}); + handleSerializationError({ + error, + methodName, + isSubprocess, + message, + }); throw error; - } finally { - endSendMessage(outgoingMessagesState); } }; diff --git a/lib/ipc/validation.js b/lib/ipc/validation.js index 9b22c968e8..4b5d7605d6 100644 --- a/lib/ipc/validation.js +++ b/lib/ipc/validation.js @@ -7,63 +7,68 @@ export const validateIpcMethod = ({methodName, isSubprocess, ipc, isConnected}) // Better error message when forgetting to set `ipc: true` and using the IPC methods const validateIpcOption = (methodName, isSubprocess, ipc) => { if (!ipc) { - throw new Error(`${getNamespaceName(isSubprocess)}${methodName}() can only be used if the \`ipc\` option is \`true\`.`); + throw new Error(`${getMethodName(methodName, isSubprocess)} can only be used if the \`ipc\` option is \`true\`.`); } }; // Better error message when one process does not send/receive messages once the other process has disconnected. // This also makes it clear that any buffered messages are lost once either process has disconnected. -const validateConnection = (methodName, isSubprocess, isConnected) => { +// Also when aborting `cancelSignal` after disconnecting the IPC. +export const validateConnection = (methodName, isSubprocess, isConnected) => { if (!isConnected) { - throw new Error(`${getNamespaceName(isSubprocess)}${methodName}() cannot be used: the ${getOtherProcessName(isSubprocess)} has already exited or disconnected.`); + throw new Error(`${getMethodName(methodName, isSubprocess)} cannot be used: the ${getOtherProcessName(isSubprocess)} has already exited or disconnected.`); } }; // When `getOneMessage()` could not complete due to an early disconnection export const throwOnEarlyDisconnect = isSubprocess => { - throw new Error(`${getNamespaceName(isSubprocess)}getOneMessage() could not complete: the ${getOtherProcessName(isSubprocess)} exited or disconnected.`); + throw new Error(`${getMethodName('getOneMessage', isSubprocess)} could not complete: the ${getOtherProcessName(isSubprocess)} exited or disconnected.`); }; // When both processes use `sendMessage()` with `strict` at the same time export const throwOnStrictDeadlockError = isSubprocess => { - throw new Error(`${getNamespaceName(isSubprocess)}sendMessage() failed: the ${getOtherProcessName(isSubprocess)} is sending a message too, instead of listening to incoming messages. + throw new Error(`${getMethodName('sendMessage', isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} is sending a message too, instead of listening to incoming messages. This can be fixed by both sending a message and listening to incoming messages at the same time: const [receivedMessage] = await Promise.all([ - ${getNamespaceName(isSubprocess)}getOneMessage(), - ${getNamespaceName(isSubprocess)}sendMessage(message, {strict: true}), + ${getMethodName('getOneMessage', isSubprocess)}, + ${getMethodName('sendMessage', isSubprocess, 'message, {strict: true}')}, ]);`); }; // When the other process used `strict` but the current process had I/O error calling `sendMessage()` for the response -export const getStrictResponseError = (error, isSubprocess) => new Error(`${getNamespaceName(isSubprocess)}sendMessage() failed when sending an acknowledgment response to the ${getOtherProcessName(isSubprocess)}.`, {cause: error}); +export const getStrictResponseError = (error, isSubprocess) => new Error(`${getMethodName('sendMessage', isSubprocess)} failed when sending an acknowledgment response to the ${getOtherProcessName(isSubprocess)}.`, {cause: error}); // When using `strict` but the other process was not listening for messages export const throwOnMissingStrict = isSubprocess => { - throw new Error(`${getNamespaceName(isSubprocess)}sendMessage() failed: the ${getOtherProcessName(isSubprocess)} is not listening to incoming messages.`); + throw new Error(`${getMethodName('sendMessage', isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} is not listening to incoming messages.`); }; // When using `strict` but the other process disconnected before receiving the message export const throwOnStrictDisconnect = isSubprocess => { - throw new Error(`${getNamespaceName(isSubprocess)}sendMessage() failed: the ${getOtherProcessName(isSubprocess)} exited without listening to incoming messages.`); + throw new Error(`${getMethodName('sendMessage', isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} exited without listening to incoming messages.`); }; -const getNamespaceName = isSubprocess => isSubprocess ? '' : 'subprocess.'; +// When the current process disconnects while the subprocess is listening to `cancelSignal` +export const getAbortDisconnectError = () => new Error(`\`cancelSignal\` aborted: the ${getOtherProcessName(true)} disconnected.`); -const getOtherProcessName = isSubprocess => isSubprocess ? 'parent process' : 'subprocess'; +// When the subprocess uses `cancelSignal` but not the current process +export const throwOnMissingParent = () => { + throw new Error('`getCancelSignal()` cannot be used without setting the `cancelSignal` subprocess option.'); +}; // EPIPE can happen when sending a message to a subprocess that is closing but has not disconnected yet -export const handleEpipeError = (error, isSubprocess) => { +export const handleEpipeError = ({error, methodName, isSubprocess}) => { if (error.code === 'EPIPE') { - throw new Error(`${getNamespaceName(isSubprocess)}sendMessage() cannot be used: the ${getOtherProcessName(isSubprocess)} is disconnecting.`, {cause: error}); + throw new Error(`${getMethodName(methodName, isSubprocess)} cannot be used: the ${getOtherProcessName(isSubprocess)} is disconnecting.`, {cause: error}); } }; // Better error message when sending messages which cannot be serialized. // Works with both `serialization: 'advanced'` and `serialization: 'json'`. -export const handleSerializationError = (error, isSubprocess, message) => { +export const handleSerializationError = ({error, methodName, isSubprocess, message}) => { if (isSerializationError(error)) { - throw new Error(`${getNamespaceName(isSubprocess)}sendMessage()'s argument type is invalid: the message cannot be serialized: ${String(message)}.`, {cause: error}); + throw new Error(`${getMethodName(methodName, isSubprocess)}'s argument type is invalid: the message cannot be serialized: ${String(message)}.`, {cause: error}); } }; @@ -88,6 +93,14 @@ const SERIALIZATION_ERROR_MESSAGES = [ 'call stack size exceeded', ]; +const getMethodName = (methodName, isSubprocess, parameters = '') => methodName === 'cancelSignal' + ? '`cancelSignal`\'s `controller.abort()`' + : `${getNamespaceName(isSubprocess)}${methodName}(${parameters})`; + +const getNamespaceName = isSubprocess => isSubprocess ? '' : 'subprocess.'; + +const getOtherProcessName = isSubprocess => isSubprocess ? 'parent process' : 'subprocess'; + // When any error arises, we disconnect the IPC. // Otherwise, it is likely that one of the processes will stop sending/receiving messages. // This would leave the other process hanging. diff --git a/lib/methods/main-async.js b/lib/methods/main-async.js index 3eaf98c74e..7de9120414 100644 --- a/lib/methods/main-async.js +++ b/lib/methods/main-async.js @@ -102,7 +102,7 @@ const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verbo pipeOutputAsync(subprocess, fileDescriptors, controller); cleanupOnExit(subprocess, options, controller); - const context = {timedOut: false, isCanceled: false}; + const context = {}; const onInternalError = createDeferred(); subprocess.kill = subprocessKill.bind(undefined, { kill: subprocess.kill.bind(subprocess), @@ -175,8 +175,9 @@ const getAsyncResult = ({errorInfo, exitCode, signal, stdio, all, ipcOutput, con error: errorInfo.error, command, escapedCommand, - timedOut: context.timedOut, - isCanceled: context.isCanceled, + timedOut: context.terminationReason === 'timeout', + isCanceled: context.terminationReason === 'cancel' || context.terminationReason === 'gracefulCancel', + isGracefullyCanceled: context.terminationReason === 'gracefulCancel', isMaxBuffer: errorInfo.error instanceof MaxBufferError, isForcefullyTerminated: context.isForcefullyTerminated, exitCode, diff --git a/lib/methods/main-sync.js b/lib/methods/main-sync.js index e91537319d..e068fc840f 100644 --- a/lib/methods/main-sync.js +++ b/lib/methods/main-sync.js @@ -155,6 +155,7 @@ const getSyncResult = ({error, exitCode, signal, timedOut, isMaxBuffer, stdio, a escapedCommand, timedOut, isCanceled: false, + isGracefullyCanceled: false, isMaxBuffer, isForcefullyTerminated: false, exitCode, diff --git a/lib/resolve/wait-subprocess.js b/lib/resolve/wait-subprocess.js index e90e657e22..0c1c6ad97d 100644 --- a/lib/resolve/wait-subprocess.js +++ b/lib/resolve/wait-subprocess.js @@ -2,6 +2,7 @@ import {once} from 'node:events'; import {isStream as isNodeStream} from 'is-stream'; import {throwOnTimeout} from '../terminate/timeout.js'; import {throwOnCancel} from '../terminate/cancel.js'; +import {throwOnGracefulCancel} from '../terminate/graceful.js'; import {isStandardStream} from '../utils/standard-stream.js'; import {TRANSFORM_TYPES} from '../stdio/type.js'; import {getBufferedData} from '../io/contents.js'; @@ -22,6 +23,8 @@ export const waitForSubprocessResult = async ({ lines, timeoutDuration: timeout, cancelSignal, + gracefulCancel, + forceKillAfterDelay, stripFinalNewline, ipc, ipcInput, @@ -89,9 +92,24 @@ export const waitForSubprocessResult = async ({ onInternalError, throwOnSubprocessError(subprocess, controller), ...throwOnTimeout(subprocess, timeout, context, controller), - ...throwOnCancel(subprocess, cancelSignal, context, controller), + ...throwOnCancel({ + subprocess, + cancelSignal, + gracefulCancel, + context, + controller, + }), + ...throwOnGracefulCancel({ + subprocess, + cancelSignal, + gracefulCancel, + forceKillAfterDelay, + context, + controller, + }), ]); } catch (error) { + context.terminationReason ??= 'other'; return Promise.all([ {error}, exitPromise, diff --git a/lib/return/message.js b/lib/return/message.js index be6a0c6267..9a7f22fbe6 100644 --- a/lib/return/message.js +++ b/lib/return/message.js @@ -19,6 +19,7 @@ export const createMessages = ({ escapedCommand, timedOut, isCanceled, + isGracefullyCanceled, isMaxBuffer, isForcefullyTerminated, forceKillAfterDelay, @@ -39,6 +40,7 @@ export const createMessages = ({ signalDescription, exitCode, isCanceled, + isGracefullyCanceled, isForcefullyTerminated, forceKillAfterDelay, killSignal, @@ -70,6 +72,7 @@ const getErrorPrefix = ({ signalDescription, exitCode, isCanceled, + isGracefullyCanceled, isForcefullyTerminated, forceKillAfterDelay, killSignal, @@ -80,6 +83,16 @@ const getErrorPrefix = ({ return `Command timed out after ${timeout} milliseconds${forcefulSuffix}`; } + if (isGracefullyCanceled) { + if (signal === undefined) { + return `Command was gracefully canceled with exit code ${exitCode}`; + } + + return isForcefullyTerminated + ? `Command was gracefully canceled${forcefulSuffix}` + : `Command was gracefully canceled with ${signal} (${signalDescription})`; + } + if (isCanceled) { return `Command was canceled${forcefulSuffix}`; } diff --git a/lib/return/result.js b/lib/return/result.js index 745bed866b..daa73fd90f 100644 --- a/lib/return/result.js +++ b/lib/return/result.js @@ -20,6 +20,7 @@ export const makeSuccessResult = ({ failed: false, timedOut: false, isCanceled: false, + isGracefullyCanceled: false, isTerminated: false, isMaxBuffer: false, isForcefullyTerminated: false, @@ -48,6 +49,7 @@ export const makeEarlyError = ({ startTime, timedOut: false, isCanceled: false, + isGracefullyCanceled: false, isMaxBuffer: false, isForcefullyTerminated: false, stdio: Array.from({length: fileDescriptors.length}), @@ -64,6 +66,7 @@ export const makeError = ({ startTime, timedOut, isCanceled, + isGracefullyCanceled, isMaxBuffer, isForcefullyTerminated, exitCode: rawExitCode, @@ -93,6 +96,7 @@ export const makeError = ({ escapedCommand, timedOut, isCanceled, + isGracefullyCanceled, isMaxBuffer, isForcefullyTerminated, forceKillAfterDelay, @@ -109,6 +113,7 @@ export const makeError = ({ startTime, timedOut, isCanceled, + isGracefullyCanceled, isMaxBuffer, isForcefullyTerminated, exitCode, @@ -131,6 +136,7 @@ const getErrorProperties = ({ startTime, timedOut, isCanceled, + isGracefullyCanceled, isMaxBuffer, isForcefullyTerminated, exitCode, @@ -152,6 +158,7 @@ const getErrorProperties = ({ failed: true, timedOut, isCanceled, + isGracefullyCanceled, isTerminated: signal !== undefined, isMaxBuffer, isForcefullyTerminated, diff --git a/lib/terminate/cancel.js b/lib/terminate/cancel.js index 11dfffd16f..e951186f59 100644 --- a/lib/terminate/cancel.js +++ b/lib/terminate/cancel.js @@ -1,4 +1,4 @@ -import {once} from 'node:events'; +import {onAbortedSignal} from '../utils/abort-signal.js'; // Validate the `cancelSignal` option export const validateCancelSignal = ({cancelSignal}) => { @@ -7,20 +7,14 @@ export const validateCancelSignal = ({cancelSignal}) => { } }; -// Terminate the subprocess when aborting the `cancelSignal` option -export const throwOnCancel = (subprocess, cancelSignal, context, controller) => cancelSignal === undefined +// Terminate the subprocess when aborting the `cancelSignal` option and `gracefulSignal` is `false` +export const throwOnCancel = ({subprocess, cancelSignal, gracefulCancel, context, controller}) => cancelSignal === undefined || gracefulCancel ? [] : [terminateOnCancel(subprocess, cancelSignal, context, controller)]; const terminateOnCancel = async (subprocess, cancelSignal, context, {signal}) => { await onAbortedSignal(cancelSignal, signal); - context.isCanceled = true; + context.terminationReason ??= 'cancel'; subprocess.kill(); throw cancelSignal.reason; }; - -const onAbortedSignal = async (cancelSignal, signal) => { - if (!cancelSignal.aborted) { - await once(cancelSignal, 'abort', {signal}); - } -}; diff --git a/lib/terminate/graceful.js b/lib/terminate/graceful.js new file mode 100644 index 0000000000..df360c5618 --- /dev/null +++ b/lib/terminate/graceful.js @@ -0,0 +1,71 @@ +import {onAbortedSignal} from '../utils/abort-signal.js'; +import {sendAbort} from '../ipc/graceful.js'; +import {killOnTimeout} from './kill.js'; + +// Validate the `gracefulCancel` option +export const validateGracefulCancel = ({gracefulCancel, cancelSignal, ipc, serialization}) => { + if (!gracefulCancel) { + return; + } + + if (cancelSignal === undefined) { + throw new Error('The `cancelSignal` option must be defined when setting the `gracefulCancel` option.'); + } + + if (!ipc) { + throw new Error('The `ipc` option cannot be false when setting the `gracefulCancel` option.'); + } + + if (serialization === 'json') { + throw new Error('The `serialization` option cannot be \'json\' when setting the `gracefulCancel` option.'); + } +}; + +// Send abort reason to the subprocess when aborting the `cancelSignal` option and `gracefulCancel` is `true` +export const throwOnGracefulCancel = ({ + subprocess, + cancelSignal, + gracefulCancel, + forceKillAfterDelay, + context, + controller, +}) => gracefulCancel + ? [sendOnAbort({ + subprocess, + cancelSignal, + forceKillAfterDelay, + context, + controller, + })] + : []; + +const sendOnAbort = async ({subprocess, cancelSignal, forceKillAfterDelay, context, controller: {signal}}) => { + await onAbortedSignal(cancelSignal, signal); + const reason = getReason(cancelSignal); + await sendAbort(subprocess, reason); + killOnTimeout({ + kill: subprocess.kill, + forceKillAfterDelay, + context, + controllerSignal: signal, + }); + context.terminationReason ??= 'gracefulCancel'; + throw cancelSignal.reason; +}; + +// The default `reason` is a DOMException, which is not serializable with V8 +// See https://github.com/nodejs/node/issues/53225 +const getReason = ({reason}) => { + if (!(reason instanceof DOMException)) { + return reason; + } + + const error = new Error(reason.message); + Object.defineProperty(error, 'stack', { + value: reason.stack, + enumerable: false, + configurable: true, + writable: true, + }); + return error; +}; diff --git a/lib/terminate/kill.js b/lib/terminate/kill.js index f9c0fb66a7..7b154367b6 100644 --- a/lib/terminate/kill.js +++ b/lib/terminate/kill.js @@ -91,4 +91,3 @@ export const killOnTimeout = async ({kill, forceKillAfterDelay, context, control } } catch {} }; - diff --git a/lib/terminate/timeout.js b/lib/terminate/timeout.js index 5bed7f914d..d1c19d2439 100644 --- a/lib/terminate/timeout.js +++ b/lib/terminate/timeout.js @@ -15,7 +15,7 @@ export const throwOnTimeout = (subprocess, timeout, context, controller) => time const killAfterTimeout = async (subprocess, timeout, context, {signal}) => { await setTimeout(timeout, undefined, {signal}); - context.timedOut = true; + context.terminationReason ??= 'timeout'; subprocess.kill(); throw new DiscardedError(); }; diff --git a/lib/utils/abort-signal.js b/lib/utils/abort-signal.js new file mode 100644 index 0000000000..e41dd4f4d4 --- /dev/null +++ b/lib/utils/abort-signal.js @@ -0,0 +1,8 @@ +import {once} from 'node:events'; + +// Combines `util.aborted()` and `events.addAbortListener()`: promise-based and cleaned up with a stop signal +export const onAbortedSignal = async (mainSignal, stopSignal) => { + if (!mainSignal.aborted) { + await once(mainSignal, 'abort', {signal: stopSignal}); + } +}; diff --git a/readme.md b/readme.md index 1b8513b213..10f67c2b94 100644 --- a/readme.md +++ b/readme.md @@ -57,7 +57,7 @@ One of the maintainers [@ehmicky](https://github.com/ehmicky) is looking for a r - [Script](#script) interface. - [No escaping](docs/escaping.md) nor quoting needed. No risk of shell injection. - Execute [locally installed binaries](#local-binaries) without `npx`. -- Improved [Windows support](docs/windows.md): [shebangs](docs/windows.md#shebang), [`PATHEXT`](https://ss64.com/nt/path.html#pathext), [and more](https://github.com/moxystudio/node-cross-spawn?tab=readme-ov-file#why). +- Improved [Windows support](docs/windows.md): [shebangs](docs/windows.md#shebang), [`PATHEXT`](https://ss64.com/nt/path.html#pathext), [graceful termination](#graceful-termination), [and more](https://github.com/moxystudio/node-cross-spawn?tab=readme-ov-file#why). - [Detailed errors](#detailed-error) and [verbose mode](#verbose-mode), for [debugging](docs/debugging.md). - [Pipe multiple subprocesses](#pipe-multiple-subprocesses) better than in shells: retrieve [intermediate results](docs/pipe.md#result), use multiple [sources](docs/pipe.md#multiple-sources-1-destination)/[destinations](docs/pipe.md#1-source-multiple-destinations), [unpipe](docs/pipe.md#unpipe). - [Split](#split-into-text-lines) the output into text lines, or [iterate](#iterate-over-text-lines) progressively over them. @@ -187,6 +187,7 @@ console.log(stdout); #### Simple input ```js +const getInputString = () => { /* ... */ }; const {stdout} = await execa({input: getInputString()})`sort`; console.log(stdout); ``` @@ -319,11 +320,39 @@ console.log(ipcOutput[1]); // {kind: 'stop', timestamp: date} // build.js import {sendMessage} from 'execa'; +const runBuild = () => { /* ... */ }; + await sendMessage({kind: 'start', timestamp: new Date()}); await runBuild(); await sendMessage({kind: 'stop', timestamp: new Date()}); ``` +#### Graceful termination + +```js +// main.js +import {execaNode} from 'execa'; + +const controller = new AbortController(); +setTimeout(() => { + controller.abort(); +}, 5000); + +await execaNode({ + cancelSignal: controller.signal, + gracefulCancel: true, +})`build.js`; +``` + +```js +// build.js +import {getCancelSignal} from 'execa'; + +const cancelSignal = await getCancelSignal(); +const url = 'https://example.com/build/info'; +const response = await fetch(url, {signal: cancelSignal}); +``` + ### Debugging #### Detailed error diff --git a/test-d/arguments/options.test-d.ts b/test-d/arguments/options.test-d.ts index 601e569aca..5993d05aeb 100644 --- a/test-d/arguments/options.test-d.ts +++ b/test-d/arguments/options.test-d.ts @@ -215,7 +215,12 @@ expectError(execaSync('unicorns', {detached: true})); expectError(await execa('unicorns', {detached: 'true'})); expectError(execaSync('unicorns', {detached: 'true'})); -await execa('unicorns', {cancelSignal: new AbortController().signal}); -expectError(execaSync('unicorns', {cancelSignal: new AbortController().signal})); +await execa('unicorns', {cancelSignal: AbortSignal.abort()}); +expectError(execaSync('unicorns', {cancelSignal: AbortSignal.abort()})); expectError(await execa('unicorns', {cancelSignal: false})); expectError(execaSync('unicorns', {cancelSignal: false})); + +await execa('unicorns', {gracefulCancel: true, cancelSignal: AbortSignal.abort()}); +expectError(execaSync('unicorns', {gracefulCancel: true, cancelSignal: AbortSignal.abort()})); +expectError(await execa('unicorns', {gracefulCancel: 'true', cancelSignal: AbortSignal.abort()})); +expectError(execaSync('unicorns', {gracefulCancel: 'true', cancelSignal: AbortSignal.abort()})); diff --git a/test-d/ipc/get-each.test-d.ts b/test-d/ipc/get-each.test-d.ts index b30e57839c..c33c9af9cc 100644 --- a/test-d/ipc/get-each.test-d.ts +++ b/test-d/ipc/get-each.test-d.ts @@ -25,15 +25,20 @@ expectError(getEachMessage('')); execa('test', {ipcInput: ''}).getEachMessage(); execa('test', {ipcInput: '' as Message}).getEachMessage(); +execa('test', {gracefulCancel: true, cancelSignal: AbortSignal.abort()}).getEachMessage(); execa('test', {} as Options).getEachMessage?.(); execa('test', {ipc: true as boolean}).getEachMessage?.(); execa('test', {ipcInput: '' as '' | undefined}).getEachMessage?.(); +execa('test', {gracefulCancel: true as boolean | undefined, cancelSignal: AbortSignal.abort()}).getEachMessage?.(); expectType(execa('test').getEachMessage); expectType(execa('test', {}).getEachMessage); expectType(execa('test', {ipc: false}).getEachMessage); expectType(execa('test', {ipcInput: undefined}).getEachMessage); +expectType(execa('test', {gracefulCancel: undefined}).getEachMessage); +expectType(execa('test', {gracefulCancel: false}).getEachMessage); expectType(execa('test', {ipc: false, ipcInput: ''}).getEachMessage); +expectType(execa('test', {ipc: false, gracefulCancel: true, cancelSignal: AbortSignal.abort()}).getEachMessage); subprocess.getEachMessage({reference: true} as const); getEachMessage({reference: true} as const); diff --git a/test-d/ipc/get-one.test-d.ts b/test-d/ipc/get-one.test-d.ts index f629a26eeb..30b42d1b3f 100644 --- a/test-d/ipc/get-one.test-d.ts +++ b/test-d/ipc/get-one.test-d.ts @@ -19,15 +19,20 @@ expectError(await getOneMessage({}, '')); await execa('test', {ipcInput: ''}).getOneMessage(); await execa('test', {ipcInput: '' as Message}).getOneMessage(); +await execa('test', {gracefulCancel: true, cancelSignal: AbortSignal.abort()}).getOneMessage(); await execa('test', {} as Options).getOneMessage?.(); await execa('test', {ipc: true as boolean}).getOneMessage?.(); await execa('test', {ipcInput: '' as '' | undefined}).getOneMessage?.(); +await execa('test', {gracefulCancel: true as boolean | undefined, cancelSignal: AbortSignal.abort()}).getOneMessage?.(); expectType(execa('test').getOneMessage); expectType(execa('test', {}).getOneMessage); expectType(execa('test', {ipc: false}).getOneMessage); expectType(execa('test', {ipcInput: undefined}).getOneMessage); +expectType(execa('test', {gracefulCancel: undefined}).getOneMessage); +expectType(execa('test', {gracefulCancel: false}).getOneMessage); expectType(execa('test', {ipc: false, ipcInput: ''}).getOneMessage); +expectType(execa('test', {ipc: false, gracefulCancel: true, cancelSignal: AbortSignal.abort()}).getOneMessage); await subprocess.getOneMessage({filter: undefined} as const); await subprocess.getOneMessage({filter: (message: Message<'advanced'>) => true} as const); diff --git a/test-d/ipc/graceful.ts b/test-d/ipc/graceful.ts new file mode 100644 index 0000000000..8456a6e1e9 --- /dev/null +++ b/test-d/ipc/graceful.ts @@ -0,0 +1,8 @@ +import {expectType, expectError} from 'tsd'; +import {getCancelSignal, execa} from '../../index.js'; + +expectType>(getCancelSignal()); + +expectError(await getCancelSignal('')); + +expectError(execa('test').getCancelSignal); diff --git a/test-d/ipc/send.test-d.ts b/test-d/ipc/send.test-d.ts index 65c1c44b17..1644d80313 100644 --- a/test-d/ipc/send.test-d.ts +++ b/test-d/ipc/send.test-d.ts @@ -21,15 +21,20 @@ expectError(await sendMessage(Symbol('test'))); await execa('test', {ipcInput: ''}).sendMessage(''); await execa('test', {ipcInput: '' as Message}).sendMessage(''); +await execa('test', {gracefulCancel: true, cancelSignal: AbortSignal.abort()}).sendMessage(''); await execa('test', {} as Options).sendMessage?.(''); await execa('test', {ipc: true as boolean}).sendMessage?.(''); await execa('test', {ipcInput: '' as '' | undefined}).sendMessage?.(''); +await execa('test', {gracefulCancel: true as boolean | undefined, cancelSignal: AbortSignal.abort()}).sendMessage?.(''); expectType(execa('test').sendMessage); expectType(execa('test', {}).sendMessage); expectType(execa('test', {ipc: false}).sendMessage); expectType(execa('test', {ipcInput: undefined}).sendMessage); +expectType(execa('test', {gracefulCancel: undefined}).sendMessage); +expectType(execa('test', {gracefulCancel: false}).sendMessage); expectType(execa('test', {ipc: false, ipcInput: ''}).sendMessage); +expectType(execa('test', {ipc: false, gracefulCancel: true, cancelSignal: AbortSignal.abort()}).sendMessage); await subprocess.sendMessage('', {} as const); await sendMessage('', {} as const); diff --git a/test-d/return/result-ipc.ts b/test-d/return/result-ipc.ts index 79d1a53ce8..1db1a23171 100644 --- a/test-d/return/result-ipc.ts +++ b/test-d/return/result-ipc.ts @@ -28,6 +28,9 @@ expectType>>(inputResult.ipcOutput); const genericInputResult = await execa('unicorns', {ipcInput: '' as Message}); expectType>>(genericInputResult.ipcOutput); +const gracefulResult = await execa('unicorns', {gracefulCancel: true, cancelSignal: AbortSignal.abort()}); +expectType>>(gracefulResult.ipcOutput); + const genericResult = await execa('unicorns', {} as Options); expectType(genericResult.ipcOutput); @@ -37,6 +40,9 @@ expectType> | []>(genericIpc.ipcOutput); const maybeInputResult = await execa('unicorns', {ipcInput: '' as '' | undefined}); expectType> | []>(maybeInputResult.ipcOutput); +const maybeGracefulResult = await execa('unicorns', {gracefulCancel: true as boolean | undefined, cancelSignal: AbortSignal.abort()}); +expectType> | []>(maybeGracefulResult.ipcOutput); + const falseIpcResult = await execa('unicorns', {ipc: false}); expectType<[]>(falseIpcResult.ipcOutput); @@ -52,6 +58,15 @@ expectType<[]>(undefinedInputResult.ipcOutput); const inputNoIpcResult = await execa('unicorns', {ipc: false, ipcInput: ''}); expectType<[]>(inputNoIpcResult.ipcOutput); +const undefinedGracefulResult = await execa('unicorns', {gracefulCancel: undefined}); +expectType<[]>(undefinedGracefulResult.ipcOutput); + +const falseGracefulResult = await execa('unicorns', {gracefulCancel: false}); +expectType<[]>(falseGracefulResult.ipcOutput); + +const gracefulNoIpcResult = await execa('unicorns', {ipc: false, gracefulCancel: true, cancelSignal: AbortSignal.abort()}); +expectType<[]>(gracefulNoIpcResult.ipcOutput); + const noBufferResult = await execa('unicorns', {ipc: true, buffer: false}); expectType<[]>(noBufferResult.ipcOutput); diff --git a/test-d/return/result-main.test-d.ts b/test-d/return/result-main.test-d.ts index 889468d76d..03ef0f6562 100644 --- a/test-d/return/result-main.test-d.ts +++ b/test-d/return/result-main.test-d.ts @@ -27,6 +27,7 @@ expectType(unicornsResult.exitCode); expectType(unicornsResult.failed); expectType(unicornsResult.timedOut); expectType(unicornsResult.isCanceled); +expectType(unicornsResult.isGracefullyCanceled); expectType(unicornsResult.isTerminated); expectType(unicornsResult.isMaxBuffer); expectType(unicornsResult.isForcefullyTerminated); @@ -44,6 +45,7 @@ expectType(unicornsResultSync.exitCode); expectType(unicornsResultSync.failed); expectType(unicornsResultSync.timedOut); expectType(unicornsResultSync.isCanceled); +expectType(unicornsResultSync.isGracefullyCanceled); expectType(unicornsResultSync.isTerminated); expectType(unicornsResultSync.isMaxBuffer); expectType(unicornsResultSync.isForcefullyTerminated); @@ -62,6 +64,7 @@ if (error instanceof ExecaError) { expectType(error.failed); expectType(error.timedOut); expectType(error.isCanceled); + expectType(error.isGracefullyCanceled); expectType(error.isTerminated); expectType(error.isMaxBuffer); expectType(error.isForcefullyTerminated); @@ -85,6 +88,7 @@ if (errorSync instanceof ExecaSyncError) { expectType(errorSync.failed); expectType(errorSync.timedOut); expectType(errorSync.isCanceled); + expectType(errorSync.isGracefullyCanceled); expectType(errorSync.isTerminated); expectType(errorSync.isMaxBuffer); expectType(errorSync.isForcefullyTerminated); diff --git a/test/fixtures/graceful-disconnect.js b/test/fixtures/graceful-disconnect.js new file mode 100755 index 0000000000..f31aebcf49 --- /dev/null +++ b/test/fixtures/graceful-disconnect.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import process from 'node:process'; +import {once} from 'node:events'; +import {getCancelSignal, sendMessage} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +const cancelSignal = await getCancelSignal(); +await onAbortedSignal(cancelSignal); +await Promise.all([ + once(process, 'disconnect'), + sendMessage(cancelSignal.reason), +]); diff --git a/test/fixtures/graceful-echo.js b/test/fixtures/graceful-echo.js new file mode 100755 index 0000000000..9980b9fe32 --- /dev/null +++ b/test/fixtures/graceful-echo.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage, getOneMessage} from 'execa'; + +await getCancelSignal(); +await sendMessage(await getOneMessage()); diff --git a/test/fixtures/graceful-listener.js b/test/fixtures/graceful-listener.js new file mode 100755 index 0000000000..2c5960bf1d --- /dev/null +++ b/test/fixtures/graceful-listener.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage} from 'execa'; + +const id = setTimeout(() => {}, 1e8); +const cancelSignal = await getCancelSignal(); +// eslint-disable-next-line unicorn/prefer-add-event-listener +cancelSignal.onabort = async () => { + await sendMessage(cancelSignal.reason); + clearTimeout(id); +}; + +await sendMessage('.'); diff --git a/test/fixtures/graceful-none.js b/test/fixtures/graceful-none.js new file mode 100755 index 0000000000..e592d215e8 --- /dev/null +++ b/test/fixtures/graceful-none.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import {getCancelSignal} from 'execa'; + +await getCancelSignal(); diff --git a/test/fixtures/graceful-print.js b/test/fixtures/graceful-print.js new file mode 100755 index 0000000000..a86e98e811 --- /dev/null +++ b/test/fixtures/graceful-print.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import {getCancelSignal} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +const cancelSignal = await getCancelSignal(); +await onAbortedSignal(cancelSignal); +console.log(cancelSignal.reason); diff --git a/test/fixtures/graceful-ref.js b/test/fixtures/graceful-ref.js new file mode 100755 index 0000000000..ae28fba5c3 --- /dev/null +++ b/test/fixtures/graceful-ref.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import {once} from 'node:events'; +import {getCancelSignal} from 'execa'; + +const cancelSignal = await getCancelSignal(); +once(cancelSignal, 'abort'); diff --git a/test/fixtures/graceful-send-echo.js b/test/fixtures/graceful-send-echo.js new file mode 100755 index 0000000000..d79e2c52db --- /dev/null +++ b/test/fixtures/graceful-send-echo.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import {getCancelSignal, getOneMessage, sendMessage} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +const message = await getOneMessage(); +const cancelSignal = await getCancelSignal(); +await sendMessage(message); +await onAbortedSignal(cancelSignal); +await sendMessage(cancelSignal.reason); diff --git a/test/fixtures/graceful-send-fast.js b/test/fixtures/graceful-send-fast.js new file mode 100755 index 0000000000..b75712955d --- /dev/null +++ b/test/fixtures/graceful-send-fast.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage} from 'execa'; + +const cancelSignal = await getCancelSignal(); +await sendMessage(cancelSignal.aborted); diff --git a/test/fixtures/graceful-send-print.js b/test/fixtures/graceful-send-print.js new file mode 100755 index 0000000000..77b32c1194 --- /dev/null +++ b/test/fixtures/graceful-send-print.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +const cancelSignal = await getCancelSignal(); +await sendMessage(cancelSignal.aborted); +await onAbortedSignal(cancelSignal); +console.log(cancelSignal.reason); diff --git a/test/fixtures/graceful-send-string.js b/test/fixtures/graceful-send-string.js new file mode 100755 index 0000000000..54458fcf22 --- /dev/null +++ b/test/fixtures/graceful-send-string.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage} from 'execa'; +import {foobarString} from '../helpers/input.js'; + +await getCancelSignal(); +await sendMessage(foobarString); diff --git a/test/fixtures/graceful-send-twice.js b/test/fixtures/graceful-send-twice.js new file mode 100755 index 0000000000..075ae83d7c --- /dev/null +++ b/test/fixtures/graceful-send-twice.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +const cancelSignal = await getCancelSignal(); +await sendMessage(cancelSignal.aborted); +await onAbortedSignal(cancelSignal); +await sendMessage(cancelSignal.reason); diff --git a/test/fixtures/graceful-send.js b/test/fixtures/graceful-send.js new file mode 100755 index 0000000000..adebca9aae --- /dev/null +++ b/test/fixtures/graceful-send.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +const cancelSignal = await getCancelSignal(); +await onAbortedSignal(cancelSignal); +await sendMessage(cancelSignal.reason); diff --git a/test/fixtures/graceful-twice.js b/test/fixtures/graceful-twice.js new file mode 100755 index 0000000000..8cf5ceb0fc --- /dev/null +++ b/test/fixtures/graceful-twice.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import {getCancelSignal, sendMessage} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +await getCancelSignal(); +const cancelSignal = await getCancelSignal(); +await onAbortedSignal(cancelSignal); +await sendMessage(cancelSignal.reason); diff --git a/test/fixtures/graceful-wait.js b/test/fixtures/graceful-wait.js new file mode 100755 index 0000000000..f21c0a8e22 --- /dev/null +++ b/test/fixtures/graceful-wait.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import {getCancelSignal} from 'execa'; +import {onAbortedSignal} from '../helpers/graceful.js'; + +const cancelSignal = await getCancelSignal(); +await onAbortedSignal(cancelSignal); diff --git a/test/fixtures/ipc-get.js b/test/fixtures/ipc-get.js new file mode 100755 index 0000000000..1ec877199c --- /dev/null +++ b/test/fixtures/ipc-get.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import {getOneMessage} from '../../index.js'; + +await getOneMessage(); diff --git a/test/fixtures/wait-fail.js b/test/fixtures/wait-fail.js new file mode 100755 index 0000000000..2fa1e7a3e1 --- /dev/null +++ b/test/fixtures/wait-fail.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import process from 'node:process'; +import {setTimeout} from 'node:timers/promises'; + +await setTimeout(1e3); +process.exitCode = 2; diff --git a/test/helpers/graceful.js b/test/helpers/graceful.js new file mode 100644 index 0000000000..2522ce0093 --- /dev/null +++ b/test/helpers/graceful.js @@ -0,0 +1,8 @@ +import {setTimeout} from 'node:timers/promises'; + +// Combines `util.aborted()` and `events.addAbortListener()`: promise-based and cleaned up with a stop signal +export const onAbortedSignal = async signal => { + try { + await setTimeout(1e8, undefined, {signal}); + } catch {} +}; diff --git a/test/ipc/graceful.js b/test/ipc/graceful.js new file mode 100644 index 0000000000..e4d19ff738 --- /dev/null +++ b/test/ipc/graceful.js @@ -0,0 +1,216 @@ +import {getEventListeners} from 'node:events'; +import {setTimeout} from 'node:timers/promises'; +import test from 'ava'; +import {execa, execaSync} from '../../index.js'; +import {setFixtureDirectory} from '../helpers/fixtures-directory.js'; +import {foobarString} from '../helpers/input.js'; + +setFixtureDirectory(); + +test('Graceful cancelSignal can be already aborted', async t => { + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(execa('graceful-send.js', {cancelSignal: AbortSignal.abort(foobarString), gracefulCancel: true, forceKillAfterDelay: false})); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('Graceful cancelSignal can be aborted', async t => { + const controller = new AbortController(); + const subprocess = execa('graceful-send-twice.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + t.false(await subprocess.getOneMessage()); + controller.abort(foobarString); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [false, foobarString]); +}); + +test('Graceful cancelSignal can be never aborted', async t => { + const controller = new AbortController(); + const subprocess = execa('graceful-send-fast.js', {cancelSignal: controller.signal, gracefulCancel: true}); + t.false(await subprocess.getOneMessage()); + await subprocess; +}); + +test('Graceful cancelSignal can be already aborted but not used', async t => { + const subprocess = execa('ipc-send-get.js', {cancelSignal: AbortSignal.abort(foobarString), gracefulCancel: true, forceKillAfterDelay: false}); + t.is(await subprocess.getOneMessage(), foobarString); + await setTimeout(1e3); + await subprocess.sendMessage('.'); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('Graceful cancelSignal can be aborted but not used', async t => { + const controller = new AbortController(); + const subprocess = execa('ipc-send-get.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + t.is(await subprocess.getOneMessage(), foobarString); + controller.abort(foobarString); + await setTimeout(1e3); + await subprocess.sendMessage(foobarString); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('Graceful cancelSignal can be never aborted nor used', async t => { + const controller = new AbortController(); + const subprocess = execa('empty.js', {cancelSignal: controller.signal, gracefulCancel: true}); + t.is(getEventListeners(controller.signal, 'abort').length, 1); + await subprocess; + t.is(getEventListeners(controller.signal, 'abort').length, 0); +}); + +test('Graceful cancelSignal can be aborted twice', async t => { + const controller = new AbortController(); + const subprocess = execa('graceful-send-twice.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + t.false(await subprocess.getOneMessage()); + controller.abort(foobarString); + controller.abort('.'); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [false, foobarString]); +}); + +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(); + controller.abort(foobarString); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput, originalMessage} = await t.throwsAsync(subprocess); + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, []); + t.is(originalMessage, '`cancelSignal`\'s `controller.abort()` cannot be used: the subprocess has already exited or disconnected.'); +}); + +test('Graceful cancelSignal can disconnect after being manually aborted', async t => { + const controller = new AbortController(); + const subprocess = execa('graceful-disconnect.js', {cancelSignal: controller.signal, gracefulCancel: true}); + controller.abort(foobarString); + t.is(await subprocess.getOneMessage(), foobarString); + subprocess.disconnect(); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [foobarString]); +}); + +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(); + const {isCanceled, isGracefullyCanceled, ipcOutput, stdout} = await subprocess; + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.deepEqual(ipcOutput, [false]); + t.true(stdout.includes('Error: `cancelSignal` aborted: the parent process disconnected.')); +}); + +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(); + const {isCanceled, isGracefullyCanceled, ipcOutput, stdout} = await subprocess; + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.deepEqual(ipcOutput, []); + t.true(stdout.includes('Error: `cancelSignal` aborted: the parent process disconnected.')); +}); + +test('getCancelSignal() fails if no IPC', async t => { + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput, stderr} = await t.throwsAsync(execa('graceful-none.js')); + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 1); + t.deepEqual(ipcOutput, []); + t.true(stderr.includes('Error: `getCancelSignal()` cannot be used without setting the `cancelSignal` subprocess option.')); +}); + +test.serial('getCancelSignal() hangs if cancelSignal without gracefulCancel', async t => { + const controller = new AbortController(); + const {timedOut, isCanceled, isGracefullyCanceled, signal, ipcOutput} = await t.throwsAsync(execa('graceful-wait.js', {ipc: true, cancelSignal: controller.signal, timeout: 1e3})); + t.true(timedOut); + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.is(signal, 'SIGTERM'); + t.deepEqual(ipcOutput, []); +}); + +test('Subprocess cancelSignal does not keep subprocess alive', async t => { + const controller = new AbortController(); + const {ipcOutput} = await execa('graceful-ref.js', {cancelSignal: controller.signal, gracefulCancel: true}); + t.deepEqual(ipcOutput, []); +}); + +test('Subprocess can send a message right away', async t => { + const controller = new AbortController(); + const {ipcOutput} = await execa('graceful-send-string.js', {cancelSignal: controller.signal, gracefulCancel: true}); + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('Subprocess can receive a message right away', async t => { + const controller = new AbortController(); + const {ipcOutput} = await execa('graceful-echo.js', {cancelSignal: controller.signal, gracefulCancel: true, ipcInput: foobarString}); + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('getCancelSignal() can be called twice', async t => { + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(execa('graceful-twice.js', {cancelSignal: AbortSignal.abort(foobarString), gracefulCancel: true, forceKillAfterDelay: false})); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('Graceful cancelSignal can use cancelSignal.onabort', async t => { + const controller = new AbortController(); + const subprocess = execa('graceful-listener.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + t.is(await subprocess.getOneMessage(), '.'); + controller.abort(foobarString); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, ['.', foobarString]); +}); + +test('Graceful cancelSignal abort reason cannot be directly received', async t => { + const subprocess = execa('graceful-send-echo.js', {cancelSignal: AbortSignal.abort(foobarString), gracefulCancel: true, forceKillAfterDelay: false}); + await setTimeout(0); + await subprocess.sendMessage('.'); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, ['.', foobarString]); +}); + +test('error.isGracefullyCanceled is always false with execaSync()', t => { + const {isCanceled, isGracefullyCanceled} = execaSync('empty.js'); + t.false(isCanceled); + t.false(isGracefullyCanceled); +}); diff --git a/test/return/result.js b/test/return/result.js index cbb1bdabc2..30d8d4cfcb 100644 --- a/test/return/result.js +++ b/test/return/result.js @@ -18,6 +18,7 @@ const testSuccessShape = async (t, execaMethod) => { 'failed', 'timedOut', 'isCanceled', + 'isGracefullyCanceled', 'isTerminated', 'isMaxBuffer', 'isForcefullyTerminated', @@ -48,6 +49,7 @@ const testErrorShape = async (t, execaMethod) => { 'failed', 'timedOut', 'isCanceled', + 'isGracefullyCanceled', 'isTerminated', 'isMaxBuffer', 'isForcefullyTerminated', diff --git a/test/terminate/cancel.js b/test/terminate/cancel.js index 7e302d7d61..8ab68deca2 100644 --- a/test/terminate/cancel.js +++ b/test/terminate/cancel.js @@ -18,41 +18,52 @@ test('cancelSignal option cannot be null', testValidCancelSignal, null); test('cancelSignal option cannot be a symbol', testValidCancelSignal, Symbol('test')); test('result.isCanceled is false when abort isn\'t called (success)', async t => { - const {isCanceled} = await execa('noop.js'); + const {isCanceled, isGracefullyCanceled} = await execa('noop.js'); t.false(isCanceled); + t.false(isGracefullyCanceled); }); test('result.isCanceled is false when abort isn\'t called (failure)', async t => { - const {isCanceled} = await t.throwsAsync(execa('fail.js')); + const {isCanceled, isGracefullyCanceled} = await t.throwsAsync(execa('fail.js')); t.false(isCanceled); + t.false(isGracefullyCanceled); }); test('result.isCanceled is false when abort isn\'t called in sync mode (success)', t => { - const {isCanceled} = execaSync('noop.js'); + const {isCanceled, isGracefullyCanceled} = execaSync('noop.js'); t.false(isCanceled); + t.false(isGracefullyCanceled); }); test('result.isCanceled is false when abort isn\'t called in sync mode (failure)', t => { - const {isCanceled} = t.throws(() => { + const {isCanceled, isGracefullyCanceled} = t.throws(() => { execaSync('fail.js'); }); t.false(isCanceled); + t.false(isGracefullyCanceled); }); -test('error.isCanceled is true when abort is used', async t => { +const testCancelSuccess = async (t, options) => { const abortController = new AbortController(); - const subprocess = execa('noop.js', {cancelSignal: abortController.signal}); + const subprocess = execa('noop.js', {cancelSignal: abortController.signal, ...options}); abortController.abort(); - const {isCanceled} = await t.throwsAsync(subprocess); + const {isCanceled, isGracefullyCanceled} = await t.throwsAsync(subprocess); t.true(isCanceled); -}); + t.false(isGracefullyCanceled); +}; + +test('error.isCanceled is true when abort is used', testCancelSuccess, {}); +test('gracefulCancel can be false with cancelSignal', testCancelSuccess, {gracefulCancel: false}); +test('ipc can be false with cancelSignal', testCancelSuccess, {ipc: false}); +test('serialization can be "json" with cancelSignal', testCancelSuccess, {ipc: true, serialization: 'json'}); test('error.isCanceled is false when kill method is used', async t => { const abortController = new AbortController(); const subprocess = execa('noop.js', {cancelSignal: abortController.signal}); subprocess.kill(); - const {isCanceled} = await t.throwsAsync(subprocess); + const {isCanceled, isGracefullyCanceled} = await t.throwsAsync(subprocess); t.false(isCanceled); + t.false(isGracefullyCanceled); }); test('calling abort is considered a signal termination', async t => { @@ -60,16 +71,18 @@ test('calling abort is considered a signal termination', async t => { const subprocess = execa('forever.js', {cancelSignal: abortController.signal}); await once(subprocess, 'spawn'); abortController.abort(); - const {isCanceled, isTerminated, signal} = await t.throwsAsync(subprocess); + const {isCanceled, isGracefullyCanceled, isTerminated, signal} = await t.throwsAsync(subprocess); t.true(isCanceled); + t.false(isGracefullyCanceled); t.true(isTerminated); t.is(signal, 'SIGTERM'); }); test('cancelSignal can already be aborted', async t => { const cancelSignal = AbortSignal.abort(); - const {isCanceled, isTerminated, signal} = await t.throwsAsync(execa('forever.js', {cancelSignal})); + const {isCanceled, isGracefullyCanceled, isTerminated, signal} = await t.throwsAsync(execa('forever.js', {cancelSignal})); t.true(isCanceled); + t.false(isGracefullyCanceled); t.true(isTerminated); t.is(signal, 'SIGTERM'); t.deepEqual(getEventListeners(cancelSignal, 'abort'), []); @@ -83,8 +96,9 @@ test('calling abort does not emit the "error" event', async t => { error = errorArgument; }); abortController.abort(); - const {isCanceled} = await t.throwsAsync(subprocess); + const {isCanceled, isGracefullyCanceled} = await t.throwsAsync(subprocess); t.true(isCanceled); + t.false(isGracefullyCanceled); t.is(error, undefined); }); @@ -93,8 +107,9 @@ test('calling abort cleans up listeners on cancelSignal, called', async t => { const subprocess = execa('forever.js', {cancelSignal: abortController.signal}); t.is(getEventListeners(abortController.signal, 'abort').length, 1); abortController.abort(); - const {isCanceled} = await t.throwsAsync(subprocess); + const {isCanceled, isGracefullyCanceled} = await t.throwsAsync(subprocess); t.true(isCanceled); + t.false(isGracefullyCanceled); t.is(getEventListeners(abortController.signal, 'abort').length, 0); }); @@ -110,8 +125,9 @@ test('calling abort cleans up listeners on cancelSignal, already aborted', async const cancelSignal = AbortSignal.abort(); const subprocess = execa('noop.js', {cancelSignal}); t.is(getEventListeners(cancelSignal, 'abort').length, 0); - const {isCanceled} = await t.throwsAsync(subprocess); + const {isCanceled, isGracefullyCanceled} = await t.throwsAsync(subprocess); t.true(isCanceled); + t.false(isGracefullyCanceled); t.is(getEventListeners(cancelSignal, 'abort').length, 0); }); @@ -164,16 +180,18 @@ test('calling abort twice should show the same behaviour as calling it once', as const subprocess = execa('noop.js', {cancelSignal: abortController.signal}); abortController.abort(); abortController.abort(); - const {isCanceled} = await t.throwsAsync(subprocess); + const {isCanceled, isGracefullyCanceled} = await t.throwsAsync(subprocess); t.true(isCanceled); + t.false(isGracefullyCanceled); }); test('calling abort on a successfully completed subprocess does not make result.isCanceled true', async t => { const abortController = new AbortController(); const subprocess = execa('noop.js', {cancelSignal: abortController.signal}); - const result = await subprocess; + const {isCanceled, isGracefullyCanceled} = await subprocess; abortController.abort(); - t.false(result.isCanceled); + t.false(isCanceled); + t.false(isGracefullyCanceled); }); test('Throws when using the former "signal" option name', t => { diff --git a/test/terminate/graceful.js b/test/terminate/graceful.js new file mode 100644 index 0000000000..6ab4429e84 --- /dev/null +++ b/test/terminate/graceful.js @@ -0,0 +1,178 @@ +import {setTimeout} from 'node:timers/promises'; +import test from 'ava'; +import {execa} from '../../index.js'; +import {setFixtureDirectory} from '../helpers/fixtures-directory.js'; +import {foobarString} from '../helpers/input.js'; +import {mockSendIoError} from '../helpers/ipc.js'; + +setFixtureDirectory(); + +test('cancelSignal cannot be undefined with gracefulCancel', t => { + t.throws(() => { + execa('empty.js', {gracefulCancel: true}); + }, {message: /The `cancelSignal` option must be defined/}); +}); + +test('ipc cannot be false with gracefulCancel', t => { + t.throws(() => { + execa('empty.js', {gracefulCancel: true, cancelSignal: AbortSignal.abort(), ipc: false}); + }, {message: /The `ipc` option cannot be false/}); +}); + +test('serialization cannot be "json" with gracefulCancel', t => { + t.throws(() => { + execa('empty.js', {gracefulCancel: true, cancelSignal: AbortSignal.abort(), serialization: 'json'}); + }, {message: /The `serialization` option cannot be 'json'/}); +}); + +test('Current process can send a message right away', async t => { + const controller = new AbortController(); + const subprocess = execa('ipc-echo.js', {cancelSignal: controller.signal, gracefulCancel: true}); + await subprocess.sendMessage(foobarString); + const {ipcOutput} = await subprocess; + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('Current process can receive a message right away', async t => { + const controller = new AbortController(); + const subprocess = execa('ipc-send.js', {cancelSignal: controller.signal, gracefulCancel: true}); + t.is(await subprocess.getOneMessage(), foobarString); + const {ipcOutput} = await subprocess; + t.deepEqual(ipcOutput, [foobarString]); +}); + +test('Does not disconnect during I/O errors when sending the abort reason', async t => { + const controller = new AbortController(); + const subprocess = execa('ipc-echo.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + const error = mockSendIoError(subprocess); + controller.abort(foobarString); + await setTimeout(0); + t.true(subprocess.connected); + subprocess.kill(); + const {isCanceled, isGracefullyCanceled, signal, ipcOutput, cause} = await t.throwsAsync(subprocess); + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.is(signal, 'SIGTERM'); + t.deepEqual(ipcOutput, []); + t.is(cause, error); +}); + +class AbortError extends Error { + name = 'AbortError'; +} + +test('Abort reason is sent to the subprocess', async t => { + const controller = new AbortController(); + const subprocess = execa('graceful-send.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + const error = new AbortError(foobarString); + controller.abort(error); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, cause, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.is(cause, error); + t.is(ipcOutput[0].message, error.message); + t.is(ipcOutput[0].stack, error.stack); + t.is(ipcOutput[0].name, 'Error'); +}); + +test('Abort default reason is sent to the subprocess', async t => { + const controller = new AbortController(); + const subprocess = execa('graceful-send.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + controller.abort(); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, cause, ipcOutput} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + const {reason} = controller.signal; + t.is(cause.stack, reason.stack); + t.is(ipcOutput[0].message, reason.message); + t.is(ipcOutput[0].stack, reason.stack); +}); + +test('Fail when sending non-serializable abort reason', async t => { + const controller = new AbortController(); + const subprocess = execa('ipc-echo.js', {cancelSignal: controller.signal, gracefulCancel: true, forceKillAfterDelay: false}); + controller.abort(() => {}); + await setTimeout(0); + t.true(subprocess.connected); + await subprocess.sendMessage(foobarString); + const {isCanceled, isGracefullyCanceled, isTerminated, exitCode, cause, ipcOutput} = await t.throwsAsync(subprocess); + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.false(isTerminated); + t.is(exitCode, 0); + t.deepEqual(ipcOutput, [foobarString]); + t.is(cause.message, '`cancelSignal`\'s `controller.abort()`\'s argument type is invalid: the message cannot be serialized: () => {}.'); + t.is(cause.cause.message, '() => {} could not be cloned.'); +}); + +test('timeout does not use graceful cancelSignal', async t => { + const controller = new AbortController(); + const {timedOut, isCanceled, isGracefullyCanceled, isTerminated, signal, exitCode, shortMessage, ipcOutput} = await t.throwsAsync(execa('graceful-send.js', {cancelSignal: controller.signal, gracefulCancel: true, timeout: 1})); + t.true(timedOut); + t.false(isCanceled); + t.false(isGracefullyCanceled); + t.true(isTerminated); + t.is(signal, 'SIGTERM'); + t.is(exitCode, undefined); + t.is(shortMessage, 'Command timed out after 1 milliseconds: graceful-send.js'); + t.deepEqual(ipcOutput, []); +}); + +test('error on graceful cancelSignal on non-0 exit code', async t => { + const {isCanceled, isGracefullyCanceled, isTerminated, isForcefullyTerminated, exitCode, shortMessage} = await t.throwsAsync(execa('wait-fail.js', {cancelSignal: AbortSignal.abort(''), gracefulCancel: true, forceKillAfterDelay: false})); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.false(isTerminated); + t.false(isForcefullyTerminated); + t.is(exitCode, 2); + t.is(shortMessage, 'Command was gracefully canceled with exit code 2: wait-fail.js'); +}); + +test('error on graceful cancelSignal on forceful termination', async t => { + const {isCanceled, isGracefullyCanceled, isTerminated, signal, isForcefullyTerminated, exitCode, shortMessage} = await t.throwsAsync(execa('forever.js', {cancelSignal: AbortSignal.abort(''), gracefulCancel: true, forceKillAfterDelay: 1})); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.true(isTerminated); + t.is(signal, 'SIGKILL'); + t.true(isForcefullyTerminated); + t.is(exitCode, undefined); + t.is(shortMessage, 'Command was gracefully canceled and was forcefully terminated after 1 milliseconds: forever.js'); +}); + +test('error on graceful cancelSignal on non-forceful termination', async t => { + const subprocess = execa('ipc-send-get.js', {cancelSignal: AbortSignal.abort(''), gracefulCancel: true, forceKillAfterDelay: 1e6}); + t.is(await subprocess.getOneMessage(), foobarString); + subprocess.kill(); + const {isCanceled, isGracefullyCanceled, isTerminated, signal, isForcefullyTerminated, exitCode, shortMessage} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.true(isTerminated); + t.is(signal, 'SIGTERM'); + t.false(isForcefullyTerminated); + t.is(exitCode, undefined); + t.is(shortMessage, 'Command was gracefully canceled with SIGTERM (Termination): ipc-send-get.js'); +}); + +test('`forceKillAfterDelay: false` with the "cancelSignal" option when graceful', async t => { + const subprocess = execa('forever.js', {cancelSignal: AbortSignal.abort(''), gracefulCancel: true, forceKillAfterDelay: false}); + await setTimeout(6e3); + subprocess.kill('SIGKILL'); + const {isCanceled, isGracefullyCanceled, isTerminated, signal, isForcefullyTerminated, exitCode, shortMessage} = await t.throwsAsync(subprocess); + t.true(isCanceled); + t.true(isGracefullyCanceled); + t.true(isTerminated); + t.is(signal, 'SIGKILL'); + t.false(isForcefullyTerminated); + t.is(exitCode, undefined); + t.is(shortMessage, 'Command was gracefully canceled with SIGKILL (Forced termination): forever.js'); +}); + +test('subprocess.getCancelSignal() is not defined', async t => { + const subprocess = execa('empty.js', {cancelSignal: AbortSignal.abort(''), gracefulCancel: true}); + t.is(subprocess.getCancelSignal, undefined); + await t.throwsAsync(subprocess); +}); diff --git a/test/terminate/kill-force.js b/test/terminate/kill-force.js index 8344c41cf5..8b4e36748f 100644 --- a/test/terminate/kill-force.js +++ b/test/terminate/kill-force.js @@ -113,10 +113,11 @@ if (isWindows) { const subprocess = spawnNoKillableSimple({cancelSignal: abortController.signal}); await once(subprocess, 'spawn'); abortController.abort(''); - const {isTerminated, signal, isCanceled, isForcefullyTerminated, shortMessage} = await t.throwsAsync(subprocess); + const {isTerminated, signal, isCanceled, isGracefullyCanceled, isForcefullyTerminated, shortMessage} = await t.throwsAsync(subprocess); t.true(isTerminated); t.is(signal, 'SIGKILL'); t.true(isCanceled); + t.false(isGracefullyCanceled); t.true(isForcefullyTerminated); t.is(shortMessage, 'Command was canceled and was forcefully terminated after 1 milliseconds: forever.js'); }); diff --git a/test/terminate/timeout.js b/test/terminate/timeout.js index 72a396e64f..655b5a9115 100644 --- a/test/terminate/timeout.js +++ b/test/terminate/timeout.js @@ -66,11 +66,11 @@ test('timedOut is false if timeout is undefined and exit code is 0 in sync mode' t.false(timedOut); }); -test('timedOut is true if the timeout happened after a different error occurred', async t => { +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); const error = await t.throwsAsync(subprocess); t.is(error.cause, cause); - t.true(error.timedOut); + t.false(error.timedOut); }); diff --git a/types/arguments/options.d.ts b/types/arguments/options.d.ts index e755d74c30..538bb9ceff 100644 --- a/types/arguments/options.d.ts +++ b/types/arguments/options.d.ts @@ -185,6 +185,8 @@ export type CommonOptions = { By default, this applies to both `stdout` and `stderr`, but different values can also be passed. + When reached, `error.isMaxBuffer` becomes `true`. + @default 100_000_000 */ readonly maxBuffer?: FdGenericOption; @@ -203,7 +205,7 @@ export type CommonOptions = { The subprocess must be a Node.js file. - @default `true` if either the `node` option or the `ipcInput` option is set, `false` otherwise + @default `true` if the `node`, `ipcInput` or `gracefulCancel` option is set, `false` otherwise */ readonly ipc?: Unless; @@ -242,32 +244,33 @@ export type CommonOptions = { /** If `timeout` is greater than `0`, the subprocess will be terminated if it runs for longer than that amount of milliseconds. - On timeout, `result.timedOut` becomes `true`. + On timeout, `error.timedOut` becomes `true`. @default 0 */ readonly timeout?: number; /** - You can abort the subprocess using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). + When the `cancelSignal` is [aborted](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort), terminate the subprocess using a `SIGTERM` signal. - When `AbortController.abort()` is called, `result.isCanceled` becomes `true`. + When aborted, `error.isCanceled` becomes `true`. @example ``` - import {execa} from 'execa'; + import {execaNode} from 'execa'; - const abortController = new AbortController(); + const controller = new AbortController(); + const cancelSignal = controller.signal; setTimeout(() => { - abortController.abort(); + controller.abort(); }, 5000); try { - await execa({cancelSignal: abortController.signal})`npm run build`; + await execaNode({cancelSignal})`build.js`; } catch (error) { if (error.isCanceled) { - console.error('Aborted by cancelSignal.'); + console.error('Canceled by cancelSignal.'); } throw error; @@ -276,6 +279,17 @@ export type CommonOptions = { */ readonly cancelSignal?: Unless; + /** + When the `cancelSignal` option is [aborted](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort), do not send any `SIGTERM`. Instead, abort the [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) returned by `getCancelSignal()`. The subprocess should use it to terminate gracefully. + + The subprocess must be a Node.js file. + + When aborted, `error.isGracefullyCanceled` becomes `true`. + + @default false + */ + readonly gracefulCancel?: Unless; + /** If the subprocess is terminated but does not exit, forcefully exit it by sending [`SIGKILL`](https://en.wikipedia.org/wiki/Signal_(IPC)#SIGKILL). diff --git a/types/ipc.d.ts b/types/ipc.d.ts index f572074c70..850684c981 100644 --- a/types/ipc.d.ts +++ b/types/ipc.d.ts @@ -91,7 +91,14 @@ This requires the `ipc` option to be `true`. The type of `message` depends on th */ export function getEachMessage(getEachMessageOptions?: GetEachMessageOptions): AsyncIterableIterator; -// IPC methods in the current process +/** +Retrieves the [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) shared by the `cancelSignal` option. + +This can only be called inside a subprocess. This requires the `gracefulCancel` option to be `true`. +*/ +export function getCancelSignal(): Promise; + +// IPC methods in the subprocess export type IpcMethods< IpcEnabled extends boolean, Serialization extends Options['serialization'], @@ -127,19 +134,23 @@ export type IpcMethods< getEachMessage: undefined; }; -// Whether IPC is enabled, based on the `ipc` and `ipcInput` options +// Whether IPC is enabled, based on the `ipc`, `ipcInput` and `gracefulCancel` options export type HasIpc = HasIpcOption< OptionsType['ipc'], -'ipcInput' extends keyof OptionsType ? OptionsType['ipcInput'] : undefined +'ipcInput' extends keyof OptionsType ? OptionsType['ipcInput'] : undefined, +'gracefulCancel' extends keyof OptionsType ? OptionsType['gracefulCancel'] : undefined >; type HasIpcOption< IpcOption extends Options['ipc'], IpcInputOption extends Options['ipcInput'], + GracefulCancelOption extends Options['gracefulCancel'], > = IpcOption extends true ? true : IpcOption extends false ? false : IpcInputOption extends undefined - ? false + ? GracefulCancelOption extends true + ? true + : false : true; diff --git a/types/methods/main-async.d.ts b/types/methods/main-async.d.ts index 2390e54d9b..5375cb8722 100644 --- a/types/methods/main-async.d.ts +++ b/types/methods/main-async.d.ts @@ -108,6 +108,7 @@ console.log(stdout); @example Simple input ``` +const getInputString = () => { /* ... *\/ }; const {stdout} = await execa({input: getInputString()})`sort`; console.log(stdout); ``` @@ -236,11 +237,39 @@ console.log(ipcOutput[1]); // {kind: 'stop', timestamp: date} // build.js import {sendMessage} from 'execa'; +const runBuild = () => { /* ... *\/ }; + await sendMessage({kind: 'start', timestamp: new Date()}); await runBuild(); await sendMessage({kind: 'stop', timestamp: new Date()}); ``` +@example Graceful termination + +``` +// main.js +import {execaNode} from 'execa'; + +const controller = new AbortController(); +setTimeout(() => { + controller.abort(); +}, 5000); + +await execaNode({ + cancelSignal: controller.signal, + gracefulCancel: true, +})`build.js`; +``` + +``` +// build.js +import {getCancelSignal} from 'execa'; + +const cancelSignal = await getCancelSignal(); +const url = 'https://example.com/build/info'; +const response = await fetch(url, {signal: cancelSignal}); +``` + @example Detailed error ``` diff --git a/types/return/result.d.ts b/types/return/result.d.ts index b9c773afc7..2121f354be 100644 --- a/types/return/result.d.ts +++ b/types/return/result.d.ts @@ -100,6 +100,11 @@ export declare abstract class CommonResult< */ isCanceled: boolean; + /** + Whether the subprocess was canceled using both the `cancelSignal` and the `gracefulCancel` options. + */ + isGracefullyCanceled: boolean; + /** Whether the subprocess failed because its output was larger than the `maxBuffer` option. */