Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,21 @@ Kill the subprocess when the current process exits.

[More info.](termination.md#current-process-exit)

### options.killDescendants

_Type:_ `boolean`\
_Default:_ `false`

When the subprocess is terminated by Execa, also terminate all of its descendant processes, instead of only the subprocess itself.

This is useful when the subprocess spawns its own processes, such as when using the [`shell`](#optionsshell) option.

On Unix, this spawns the subprocess in its own [process group](https://en.wikipedia.org/wiki/Process_group). On Windows, this uses [`taskkill`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill).

This is best-effort: descendant processes that create their own process group or session are not terminated.

[More info.](termination.md#killing-descendant-processes)

### options.uid

_Type:_ `number`\
Expand Down
21 changes: 20 additions & 1 deletion docs/termination.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,26 @@ If the current process exits, the subprocess is automatically [terminated](#defa
- The subprocess is run in the background using the [`detached`](api.md#optionsdetached) option.
- The current process was terminated abruptly, for example, with [`SIGKILL`](#sigkill) as opposed to [`SIGTERM`](#sigterm) or a successful exit.

On Windows, only the subprocess is terminated, not the other processes it might have spawned. To also terminate those, the [`windowsHide: false`](windows.md#console-window) option can be used.
By default, only the subprocess is terminated, not the other processes it might have spawned. The [`killDescendants`](#killing-descendant-processes) option can be used to also terminate those.

## Killing descendant processes

By default, [terminating](#signal-termination) a subprocess only sends a signal to that subprocess, not to any process it might have spawned itself. For example, terminating a subprocess started with the [`shell`](api.md#optionsshell) option only terminates the shell, not the command it is running.

The [`killDescendants`](api.md#optionskilldescendants) option terminates the whole process tree instead: the subprocess and all of its descendants. This applies to every way Execa terminates a subprocess, including [`subprocess.kill()`](#signal-termination), the [`cancelSignal`](#canceling), [`timeout`](#timeout), [`maxBuffer`](output.md#big-output) and [`cleanup`](#current-process-exit) options, and the [`forceKillAfterDelay`](#forceful-termination) escalation.

```js
// Without `killDescendants`, only the shell is terminated, and `sleep` keeps running
const subprocess = execa({shell: true, killDescendants: true})`sleep 60`;
subprocess.kill();
await subprocess;
```

On Unix, this spawns the subprocess in its own [process group](https://en.wikipedia.org/wiki/Process_group), then sends the signal to that group. On Windows, this uses [`taskkill`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill), which terminates the process tree tracked by the OS.

This is best-effort. On Unix, descendant processes that create their own process group or session (for example, daemons calling [`setsid()`](https://man7.org/linux/man-pages/man2/setsid.2.html)) escape termination. On Unix, because the subprocess runs in its own process group, it is also detached from the terminal, so pressing `CTRL-C` no longer forwards [`SIGINT`](#sigint) to it.

This option cannot be used with [synchronous methods](execution.md#synchronous-execution).

## Signal termination

Expand Down
2 changes: 2 additions & 0 deletions lib/arguments/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const addDefaultOptions = ({
encoding = 'utf8',
reject = true,
cleanup = true,
killDescendants = false,
all = false,
windowsHide = true,
killSignal = 'SIGTERM',
Expand All @@ -73,6 +74,7 @@ const addDefaultOptions = ({
encoding,
reject,
cleanup,
killDescendants,
all,
windowsHide,
killSignal,
Expand Down
5 changes: 3 additions & 2 deletions lib/methods/main-async.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {handleStdioAsync} from '../stdio/handle-async.js';
import {stripNewline} from '../io/strip-newline.js';
import {pipeOutputAsync} from '../io/output-async.js';
import {subprocessKill} from '../terminate/kill.js';
import {getSpawnOptions, getKillFunction} from '../terminate/kill-descendants.js';
import {cleanupOnExit} from '../terminate/cleanup.js';
import {pipeToSubprocess} from '../pipe/setup.js';
import {makeAllStream} from '../resolve/all-async.js';
Expand Down Expand Up @@ -84,7 +85,7 @@ const handleAsyncOptions = ({timeout, signal, ...options}) => {
const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verboseInfo, command, escapedCommand, fileDescriptors}) => {
let subprocess;
try {
subprocess = spawn(...concatenateShell(file, commandArguments, options));
subprocess = spawn(...concatenateShell(file, commandArguments, getSpawnOptions(options)));
} catch (error) {
return handleEarlyError({
error,
Expand All @@ -107,7 +108,7 @@ const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verbo
const context = {};
const onInternalError = createDeferred();
const kill = subprocessKill.bind(undefined, {
kill: subprocess.kill.bind(subprocess),
kill: getKillFunction(subprocess, options),
options,
onInternalError,
context,
Expand Down
6 changes: 5 additions & 1 deletion lib/methods/main-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const handleSyncArguments = (rawFile, rawArguments, rawOptions) => {
const normalizeSyncOptions = options => options.node && !options.ipc ? {...options, ipc: false} : options;

// Options validation logic specific to sync methods
const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal}) => {
const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal, killDescendants}) => {
if (ipcInput) {
throwInvalidSyncOption('ipcInput');
}
Expand All @@ -64,6 +64,10 @@ const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal}) => {
throwInvalidSyncOption('detached: true');
}

if (killDescendants) {
throwInvalidSyncOption('killDescendants: true');
}

if (cancelSignal) {
throwInvalidSyncOption('cancelSignal');
}
Expand Down
54 changes: 54 additions & 0 deletions lib/terminate/kill-descendants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import process from 'node:process';
import {execFile} from 'node:child_process';

const isWindows = process.platform === 'win32';

// The `killDescendants` option terminates the whole process tree, not just the direct child.
// On Unix, this requires spawning the subprocess in its own process group, so we override the
// `detached` argument passed to `child_process.spawn()`.
// This is kept separate from the user-facing `detached` option, which must keep its own value,
// so the `cleanup` behavior is not affected.
export const getSpawnOptions = options => options.killDescendants && !isWindows
? {...options, detached: true}
: options;

// Returns the low-level function used to send a signal to the subprocess.
// With the `killDescendants` option, the signal is sent to the whole process tree.
export const getKillFunction = (subprocess, {killDescendants}) => {
if (!killDescendants) {
return subprocess.kill.bind(subprocess);
}

const killDescendantsFunction = isWindows ? killDescendantsWindows : killDescendantsUnix;
return killDescendantsFunction.bind(undefined, subprocess);
};

// On Unix, the subprocess is its own process group leader (its PGID equals its PID), since it
// was spawned with `detached: true`. Sending the signal to `-pid` targets the whole group.
const killDescendantsUnix = (subprocess, signal) => {
if (subprocess.pid === undefined) {
return false;
}

try {
return process.kill(-subprocess.pid, signal);
} catch {
// The process group might already be gone, or signaling it might not be permitted, so we
// fall back to the direct child. Like `ChildProcess.kill()`, this returns `false` instead of throwing.
return subprocess.kill(signal);
}
};

// Windows has no process groups. Instead, `taskkill /T` recursively terminates the process tree.
// It must run while the tree is still intact, so it is the only termination performed: killing the
// direct subprocess first would orphan its descendants before `taskkill` could enumerate them.
// Windows does not support signals, so the signal argument is ignored (`/F` terminates the tree).
const killDescendantsWindows = subprocess => {
if (subprocess.pid === undefined) {
return false;
}

// This is best-effort: any error (such as the subprocess having already exited) is ignored.
execFile('taskkill', ['/pid', `${subprocess.pid}`, '/T', '/F'], () => {});
return true;
};
7 changes: 7 additions & 0 deletions test-d/arguments/options.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@ expectError(execaSync('unicorns', {detached: true as boolean}));
expectError(await execa('unicorns', {detached: 'true'}));
expectError(execaSync('unicorns', {detached: 'true'}));

await execa('unicorns', {killDescendants: true});
expectError(execaSync('unicorns', {killDescendants: true}));
await execa('unicorns', {killDescendants: true as boolean});
expectError(execaSync('unicorns', {killDescendants: true as boolean}));
expectError(await execa('unicorns', {killDescendants: 'true'}));
expectError(execaSync('unicorns', {killDescendants: 'true'}));

await execa('unicorns', {cancelSignal: AbortSignal.abort()});
expectError(execaSync('unicorns', {cancelSignal: AbortSignal.abort()}));
expectError(await execa('unicorns', {cancelSignal: false}));
Expand Down
90 changes: 90 additions & 0 deletions test/terminate/kill-descendants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import process from 'node:process';
import {setTimeout} from 'node:timers/promises';
import test from 'ava';
import isRunning from 'is-running';
import {execa, execaSync} from '../../index.js';
import {setFixtureDirectory} from '../helpers/fixtures-directory.js';

setFixtureDirectory();

const isWindows = process.platform === 'win32';

const pollForSubprocessExit = async pid => {
while (isRunning(pid)) {
// eslint-disable-next-line no-await-in-loop
await setTimeout(100);
}
};

// `ipc-send-pid.js` spawns a descendant process (`forever.js`) and sends back its PID.
const spawnDescendant = async (killDescendants, options) => {
const subprocess = execa('ipc-send-pid.js', ['false', 'false'], {
stdio: 'ignore',
ipc: true,
killDescendants,
...options,
});
const descendantPid = await subprocess.getOneMessage();
return {subprocess, descendantPid};
};

test('killDescendants terminates descendant processes', async t => {
const {subprocess, descendantPid} = await spawnDescendant(true);
t.true(isRunning(descendantPid));

subprocess.kill();
await t.throwsAsync(subprocess);
t.false(isRunning(subprocess.pid));

await Promise.race([
setTimeout(1e4, undefined, {ref: false}),
pollForSubprocessExit(descendantPid),
]);
t.false(isRunning(descendantPid));
});

test('killDescendants also terminates descendant processes when the subprocess times out', async t => {
const {subprocess, descendantPid} = await spawnDescendant(true, {timeout: 1000});
t.true(isRunning(descendantPid));

const {timedOut} = await t.throwsAsync(subprocess);
t.true(timedOut);

await Promise.race([
setTimeout(1e4, undefined, {ref: false}),
pollForSubprocessExit(descendantPid),
]);
t.false(isRunning(descendantPid));
});

// On Windows, terminating the direct subprocess already terminates its descendants, so this
// only asserts the default Unix behavior of leaving descendants running.
if (!isWindows) {
test('descendant processes are not terminated without killDescendants', async t => {
const {subprocess, descendantPid} = await spawnDescendant(false);
t.true(isRunning(descendantPid));

subprocess.kill();
await t.throwsAsync(subprocess);
t.false(isRunning(subprocess.pid));

t.true(isRunning(descendantPid));
process.kill(descendantPid, 'SIGKILL');
});

test('timeout does not terminate descendants without killDescendants', async t => {
const {subprocess, descendantPid} = await spawnDescendant(false, {timeout: 1000});
t.true(isRunning(descendantPid));

const {timedOut} = await t.throwsAsync(subprocess);
t.true(timedOut);
t.true(isRunning(descendantPid));
process.kill(descendantPid, 'SIGKILL');
});
}

test('Cannot use "killDescendants" option, sync', t => {
t.throws(() => {
execaSync('empty.js', {killDescendants: true});
}, {message: /The "killDescendants: true" option cannot be used/});
});
13 changes: 13 additions & 0 deletions types/arguments/options.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,19 @@ export type CommonOptions<
*/
readonly cleanup?: Unless<IsSync, boolean>;

/**
When the subprocess is terminated by Execa, also terminate all of its descendant processes, instead of only the subprocess itself.

This is useful when the subprocess spawns its own processes, such as when using the `shell` option.

On Unix, this spawns the subprocess in its own [process group](https://en.wikipedia.org/wiki/Process_group). On Windows, this uses [`taskkill`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill).

This is best-effort: descendant processes that create their own process group or session are not terminated.

@default false
*/
readonly killDescendants?: Unless<IsSync, boolean>;

/**
Sets the [user identifier](https://en.wikipedia.org/wiki/User_identifier) of the subprocess.

Expand Down
Loading