Skip to content

Commit 06c7a22

Browse files
committed
Add killDescendants option to also terminate descendant processes
Fixes #96
1 parent ade74bf commit 06c7a22

9 files changed

Lines changed: 205 additions & 4 deletions

File tree

docs/api.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,21 @@ Kill the subprocess when the current process exits.
11681168

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

1171+
### options.killDescendants
1172+
1173+
_Type:_ `boolean`\
1174+
_Default:_ `false`
1175+
1176+
When the subprocess is terminated by Execa, also terminate all of its descendant processes, instead of only the subprocess itself.
1177+
1178+
This is useful when the subprocess spawns its own processes, such as when using the [`shell`](#optionsshell) option.
1179+
1180+
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).
1181+
1182+
This is best-effort: descendant processes that create their own process group or session are not terminated.
1183+
1184+
[More info.](termination.md#killing-descendant-processes)
1185+
11711186
### options.uid
11721187

11731188
_Type:_ `number`\

docs/termination.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,26 @@ If the current process exits, the subprocess is automatically [terminated](#defa
192192
- The subprocess is run in the background using the [`detached`](api.md#optionsdetached) option.
193193
- The current process was terminated abruptly, for example, with [`SIGKILL`](#sigkill) as opposed to [`SIGTERM`](#sigterm) or a successful exit.
194194

195-
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.
195+
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.
196+
197+
## Killing descendant processes
198+
199+
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.
200+
201+
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.
202+
203+
```js
204+
// Without `killDescendants`, only the shell is terminated, and `sleep` keeps running
205+
const subprocess = execa({shell: true, killDescendants: true})`sleep 60`;
206+
subprocess.kill();
207+
await subprocess;
208+
```
209+
210+
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.
211+
212+
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.
213+
214+
This option cannot be used with [synchronous methods](execution.md#synchronous-execution).
196215

197216
## Signal termination
198217

lib/arguments/options.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const addDefaultOptions = ({
5454
encoding = 'utf8',
5555
reject = true,
5656
cleanup = true,
57+
killDescendants = false,
5758
all = false,
5859
windowsHide = true,
5960
killSignal = 'SIGTERM',
@@ -73,6 +74,7 @@ const addDefaultOptions = ({
7374
encoding,
7475
reject,
7576
cleanup,
77+
killDescendants,
7678
all,
7779
windowsHide,
7880
killSignal,

lib/methods/main-async.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {handleStdioAsync} from '../stdio/handle-async.js';
1414
import {stripNewline} from '../io/strip-newline.js';
1515
import {pipeOutputAsync} from '../io/output-async.js';
1616
import {subprocessKill} from '../terminate/kill.js';
17+
import {getSpawnOptions, getKillFunction} from '../terminate/kill-descendants.js';
1718
import {cleanupOnExit} from '../terminate/cleanup.js';
1819
import {pipeToSubprocess} from '../pipe/setup.js';
1920
import {makeAllStream} from '../resolve/all-async.js';
@@ -84,7 +85,7 @@ const handleAsyncOptions = ({timeout, signal, ...options}) => {
8485
const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verboseInfo, command, escapedCommand, fileDescriptors}) => {
8586
let subprocess;
8687
try {
87-
subprocess = spawn(...concatenateShell(file, commandArguments, options));
88+
subprocess = spawn(...concatenateShell(file, commandArguments, getSpawnOptions(options)));
8889
} catch (error) {
8990
return handleEarlyError({
9091
error,
@@ -107,7 +108,7 @@ const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verbo
107108
const context = {};
108109
const onInternalError = createDeferred();
109110
const kill = subprocessKill.bind(undefined, {
110-
kill: subprocess.kill.bind(subprocess),
111+
kill: getKillFunction(subprocess, options),
111112
options,
112113
onInternalError,
113114
context,

lib/methods/main-sync.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const handleSyncArguments = (rawFile, rawArguments, rawOptions) => {
5151
const normalizeSyncOptions = options => options.node && !options.ipc ? {...options, ipc: false} : options;
5252

5353
// Options validation logic specific to sync methods
54-
const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal}) => {
54+
const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal, killDescendants}) => {
5555
if (ipcInput) {
5656
throwInvalidSyncOption('ipcInput');
5757
}
@@ -64,6 +64,10 @@ const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal}) => {
6464
throwInvalidSyncOption('detached: true');
6565
}
6666

67+
if (killDescendants) {
68+
throwInvalidSyncOption('killDescendants: true');
69+
}
70+
6771
if (cancelSignal) {
6872
throwInvalidSyncOption('cancelSignal');
6973
}

lib/terminate/kill-descendants.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import process from 'node:process';
2+
import {execFile} from 'node:child_process';
3+
4+
const isWindows = process.platform === 'win32';
5+
6+
// The `killDescendants` option terminates the whole process tree, not just the direct child.
7+
// On Unix, this requires spawning the subprocess in its own process group, so we override the
8+
// `detached` argument passed to `child_process.spawn()`.
9+
// This is kept separate from the user-facing `detached` option, which must keep its own value,
10+
// so the `cleanup` behavior is not affected.
11+
export const getSpawnOptions = options => options.killDescendants && !isWindows
12+
? {...options, detached: true}
13+
: options;
14+
15+
// Returns the low-level function used to send a signal to the subprocess.
16+
// With the `killDescendants` option, the signal is sent to the whole process tree.
17+
export const getKillFunction = (subprocess, {killDescendants}) => {
18+
if (!killDescendants) {
19+
return subprocess.kill.bind(subprocess);
20+
}
21+
22+
const killDescendantsFunction = isWindows ? killDescendantsWindows : killDescendantsUnix;
23+
return killDescendantsFunction.bind(undefined, subprocess);
24+
};
25+
26+
// On Unix, the subprocess is its own process group leader (its PGID equals its PID), since it
27+
// was spawned with `detached: true`. Sending the signal to `-pid` targets the whole group.
28+
const killDescendantsUnix = (subprocess, signal) => {
29+
if (subprocess.pid === undefined) {
30+
return false;
31+
}
32+
33+
try {
34+
return process.kill(-subprocess.pid, signal);
35+
} catch {
36+
// The process group might already be gone, or signaling it might not be permitted, so we
37+
// fall back to the direct child. Like `ChildProcess.kill()`, this returns `false` instead of throwing.
38+
return subprocess.kill(signal);
39+
}
40+
};
41+
42+
// Windows has no process groups. Instead, `taskkill /T` recursively terminates the process tree.
43+
// It must run while the tree is still intact, so it is the only termination performed: killing the
44+
// direct subprocess first would orphan its descendants before `taskkill` could enumerate them.
45+
// Windows does not support signals, so the signal argument is ignored (`/F` terminates the tree).
46+
const killDescendantsWindows = subprocess => {
47+
if (subprocess.pid === undefined) {
48+
return false;
49+
}
50+
51+
// This is best-effort: any error (such as the subprocess having already exited) is ignored.
52+
execFile('taskkill', ['/pid', `${subprocess.pid}`, '/T', '/F'], () => {});
53+
return true;
54+
};

test-d/arguments/options.test-d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,13 @@ expectError(execaSync('unicorns', {detached: true as boolean}));
275275
expectError(await execa('unicorns', {detached: 'true'}));
276276
expectError(execaSync('unicorns', {detached: 'true'}));
277277

278+
await execa('unicorns', {killDescendants: true});
279+
expectError(execaSync('unicorns', {killDescendants: true}));
280+
await execa('unicorns', {killDescendants: true as boolean});
281+
expectError(execaSync('unicorns', {killDescendants: true as boolean}));
282+
expectError(await execa('unicorns', {killDescendants: 'true'}));
283+
expectError(execaSync('unicorns', {killDescendants: 'true'}));
284+
278285
await execa('unicorns', {cancelSignal: AbortSignal.abort()});
279286
expectError(execaSync('unicorns', {cancelSignal: AbortSignal.abort()}));
280287
expectError(await execa('unicorns', {cancelSignal: false}));

test/terminate/kill-descendants.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import process from 'node:process';
2+
import {setTimeout} from 'node:timers/promises';
3+
import test from 'ava';
4+
import isRunning from 'is-running';
5+
import {execa, execaSync} from '../../index.js';
6+
import {setFixtureDirectory} from '../helpers/fixtures-directory.js';
7+
8+
setFixtureDirectory();
9+
10+
const isWindows = process.platform === 'win32';
11+
12+
const pollForSubprocessExit = async pid => {
13+
while (isRunning(pid)) {
14+
// eslint-disable-next-line no-await-in-loop
15+
await setTimeout(100);
16+
}
17+
};
18+
19+
// `ipc-send-pid.js` spawns a descendant process (`forever.js`) and sends back its PID.
20+
const spawnDescendant = async (killDescendants, options) => {
21+
const subprocess = execa('ipc-send-pid.js', ['false', 'false'], {stdio: 'ignore', ipc: true, killDescendants, ...options});
22+
const descendantPid = await subprocess.getOneMessage();
23+
return {subprocess, descendantPid};
24+
};
25+
26+
test('killDescendants terminates descendant processes', async t => {
27+
const {subprocess, descendantPid} = await spawnDescendant(true);
28+
t.true(isRunning(descendantPid));
29+
30+
subprocess.kill();
31+
await t.throwsAsync(subprocess);
32+
t.false(isRunning(subprocess.pid));
33+
34+
await Promise.race([
35+
setTimeout(1e4, undefined, {ref: false}),
36+
pollForSubprocessExit(descendantPid),
37+
]);
38+
t.false(isRunning(descendantPid));
39+
});
40+
41+
test('killDescendants also terminates descendant processes when the subprocess times out', async t => {
42+
const {subprocess, descendantPid} = await spawnDescendant(true, {timeout: 1_000});
43+
t.true(isRunning(descendantPid));
44+
45+
const {isTerminated, timedOut} = await t.throwsAsync(subprocess);
46+
t.true(isTerminated);
47+
t.true(timedOut);
48+
49+
await Promise.race([
50+
setTimeout(1e4, undefined, {ref: false}),
51+
pollForSubprocessExit(descendantPid),
52+
]);
53+
t.false(isRunning(descendantPid));
54+
});
55+
56+
// On Windows, terminating the direct subprocess already terminates its descendants, so this
57+
// only asserts the default Unix behavior of leaving descendants running.
58+
if (!isWindows) {
59+
test('descendant processes are not terminated without killDescendants', async t => {
60+
const {subprocess, descendantPid} = await spawnDescendant(false);
61+
t.true(isRunning(descendantPid));
62+
63+
subprocess.kill();
64+
await t.throwsAsync(subprocess);
65+
t.false(isRunning(subprocess.pid));
66+
67+
t.true(isRunning(descendantPid));
68+
process.kill(descendantPid, 'SIGKILL');
69+
});
70+
71+
test('timeout does not terminate descendants without killDescendants', async t => {
72+
const {subprocess, descendantPid} = await spawnDescendant(false, {timeout: 1_000});
73+
t.true(isRunning(descendantPid));
74+
75+
const {timedOut} = await t.throwsAsync(subprocess);
76+
t.true(timedOut);
77+
t.true(isRunning(descendantPid));
78+
process.kill(descendantPid, 'SIGKILL');
79+
});
80+
}
81+
82+
test('Cannot use "killDescendants" option, sync', t => {
83+
t.throws(() => {
84+
execaSync('empty.js', {killDescendants: true});
85+
}, {message: /The "killDescendants: true" option cannot be used/});
86+
});

types/arguments/options.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,19 @@ export type CommonOptions<
329329
*/
330330
readonly cleanup?: Unless<IsSync, boolean>;
331331

332+
/**
333+
When the subprocess is terminated by Execa, also terminate all of its descendant processes, instead of only the subprocess itself.
334+
335+
This is useful when the subprocess spawns its own processes, such as when using the `shell` option.
336+
337+
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).
338+
339+
This is best-effort: descendant processes that create their own process group or session are not terminated.
340+
341+
@default false
342+
*/
343+
readonly killDescendants?: Unless<IsSync, boolean>;
344+
332345
/**
333346
Sets the [user identifier](https://en.wikipedia.org/wiki/User_identifier) of the subprocess.
334347

0 commit comments

Comments
 (0)