Skip to content

Commit

Permalink
Add gracefulCancel option (#1109)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Jun 3, 2024
1 parent a5a4d69 commit d8190e5
Show file tree
Hide file tree
Showing 57 changed files with 1,163 additions and 95 deletions.
41 changes: 37 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,16 @@ Keep the subprocess alive while `getEachMessage()` is waiting.

[More info.](ipc.md#keeping-the-subprocess-alive)

### getCancelSignal()

_Returns_: [`Promise<AbortSignal>`](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)\
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand All @@ -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).

Expand Down Expand Up @@ -1015,20 +1035,33 @@ _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)

### options.cancelSignal

_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`\
Expand Down
34 changes: 33 additions & 1 deletion docs/bash.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
107 changes: 97 additions & 10 deletions docs/termination.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ export {
sendMessage,
getOneMessage,
getEachMessage,
getCancelSignal,
type Message,
} from './types/ipc.js';
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ const {
sendMessage,
getOneMessage,
getEachMessage,
getCancelSignal,
} = getIpcExport();
export {
sendMessage,
getOneMessage,
getEachMessage,
getCancelSignal,
};
6 changes: 5 additions & 1 deletion lib/arguments/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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
}) => ({
Expand All @@ -70,6 +73,7 @@ const addDefaultOptions = ({
windowsHide,
killSignal,
forceKillAfterDelay,
gracefulCancel,
ipcInput,
ipc,
serialization,
Expand Down
Loading

0 comments on commit d8190e5

Please sign in to comment.