Skip to content

Commit 9c5c71c

Browse files
committed
Fix killDescendants signaling unrelated processes once Execa is done with the subprocess
1 parent 91d9ded commit 9c5c71c

7 files changed

Lines changed: 192 additions & 48 deletions

File tree

‎docs/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1183,7 +1183,7 @@ This is useful when the subprocess spawns its own processes, such as when using
11831183

11841184
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).
11851185

1186-
This is best-effort: descendant processes that create their own process group or session are not terminated.
1186+
This is best-effort: descendant processes that create their own process group or session are not terminated. Also, descendants are not terminated once Execa is done with the subprocess, or on Windows once the subprocess exited, since its PID might then belong to an unrelated process.
11871187

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

‎docs/termination.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ await subprocess;
209209

210210
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.
211211

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.
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. Finally, the OS can re-assign the subprocess' PID to an unrelated process once it exited. So, descendants are not signaled anymore once Execa is done with the subprocess, i.e. once its promise has settled. On Windows, this applies as soon as the subprocess exited, which also prevents the [`forceKillAfterDelay`](#forceful-termination) escalation from terminating its descendants.
213213

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

‎lib/methods/main-async.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verbo
109109
const context = {};
110110
const onInternalError = createDeferred();
111111
const kill = subprocessKill.bind(undefined, {
112-
kill: getKillFunction(subprocess, options),
112+
kill: getKillFunction(subprocess, options, controller),
113113
options,
114114
onInternalError,
115115
context,

‎lib/terminate/kill-descendants.js‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,30 @@ So, like `ChildProcess.prototype.kill()` does for a subprocess which already exi
2121

2222
// Returns the low-level function used to send a signal to the subprocess.
2323
// With the `killDescendants` option, the signal is sent to the whole process tree.
24-
export const getKillFunction = (subprocess, {killDescendants}) => {
24+
export const getKillFunction = (subprocess, {killDescendants}, {signal: controllerSignal}) => {
2525
if (!killDescendants) {
2626
const killSubprocess = subprocess.kill.bind(subprocess);
2727
return (...arguments_) => (subprocess.pid === undefined ? false : killSubprocess(...arguments_));
2828
}
2929

3030
const killDescendantsFunction = isWindows ? killDescendantsWindows : killDescendantsUnix;
31-
return killDescendantsFunction.bind(undefined, subprocess);
31+
return killDescendantsFunction.bind(undefined, subprocess, controllerSignal);
3232
};
3333

34+
/*
35+
Unlike `subprocess.kill()`, signaling a process group or a process tree uses the PID directly, without going through the subprocess' handle. The OS can re-assign that PID once the tree is gone, and signaling it then would terminate an unrelated process, and on Windows its own descendants too.
36+
On Unix, a PID stays reserved as long as it is the process group ID of a running process, so it can only be re-assigned once every descendant exited too. While Execa is still waiting on the subprocess, the signal is still useful, e.g. for the `forceKillAfterDelay` escalation, which happens after the subprocess exited but while a descendant might still be running. Once Execa is done with the subprocess, no internal logic sends signals anymore, so only `subprocess.kill()` can. Like `ChildProcess.kill()`, it then becomes a noop returning `false`. This narrows the window during which a re-assigned PID could be signaled, without removing it, e.g. when Execa is still waiting on a slow transform after the whole process group exited.
37+
On Windows, there are no process groups: a PID is only reserved while a handle to the process is open, which Node.js closes once the subprocess exits. `taskkill /T` also cannot find the descendants of a process which exited. So the process tree is never signaled once the subprocess exited.
38+
*/
39+
const isAbandoned = (subprocess, controllerSignal) => subprocess.pid === undefined
40+
|| (hasExited(subprocess) && (isWindows || controllerSignal.aborted));
41+
42+
const hasExited = ({exitCode, signalCode}) => exitCode !== null || signalCode !== null;
43+
3444
// On Unix, the subprocess is its own process group leader (its PGID equals its PID), since it
3545
// was spawned with `detached: true`. Sending the signal to `-pid` targets the whole group.
36-
const killDescendantsUnix = (subprocess, signal) => {
37-
if (subprocess.pid === undefined) {
46+
const killDescendantsUnix = (subprocess, controllerSignal, signal) => {
47+
if (isAbandoned(subprocess, controllerSignal)) {
3848
return false;
3949
}
4050

@@ -52,8 +62,8 @@ const killDescendantsUnix = (subprocess, signal) => {
5262
// fallback: killing the direct subprocess first would orphan its descendants before `taskkill` could enumerate them.
5363
// If `taskkill` is unavailable or fails, the fallback only terminates the direct subprocess.
5464
// `taskkill` ignores the signal (`/F` terminates the tree).
55-
const killDescendantsWindows = (subprocess, signal) => {
56-
if (subprocess.pid === undefined) {
65+
const killDescendantsWindows = (subprocess, controllerSignal, signal) => {
66+
if (isAbandoned(subprocess, controllerSignal)) {
5767
return false;
5868
}
5969

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
import {execa, sendMessage} from '../../index.js';
3+
4+
// Spawns a descendant which ignores `SIGTERM` and keeps this process' `stdout` open.
5+
// This process is not awaiting it, so it exits on `SIGTERM` while that descendant keeps running.
6+
const subprocess = execa('no-killable.js', {
7+
ipc: true,
8+
killSignal: 'SIGKILL',
9+
stdout: 'inherit',
10+
cleanup: false,
11+
});
12+
await subprocess.getOneMessage();
13+
await sendMessage(subprocess.pid);
14+
setTimeout(() => {}, 1e8);

‎test/terminate/kill-descendants.js‎

Lines changed: 158 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,80 @@ if (!isWindows) {
8787
});
8888
}
8989

90+
/*
91+
The `forceKillAfterDelay` escalation happens after the subprocess exited, since that is when its termination has been noticed.
92+
A descendant which ignores `SIGTERM` is still running then, so the escalation must still reach it.
93+
Otherwise, since that descendant keeps the subprocess' `stdout` open, awaiting the subprocess would never settle.
94+
*/
95+
test('killDescendants escalates to descendants which outlive the subprocess', async t => {
96+
const subprocess = execa('ipc-send-pid-stubborn.js', {
97+
ipc: true,
98+
killDescendants: true,
99+
forceKillAfterDelay: 1000,
100+
});
101+
const descendantPid = await subprocess.getOneMessage();
102+
t.true(isRunning(descendantPid));
103+
104+
subprocess.kill();
105+
const settled = await Promise.race([
106+
t.throwsAsync(subprocess).then(() => 'settled'),
107+
setTimeout(1e4, 'pending', {ref: false}),
108+
]);
109+
t.is(settled, 'settled');
110+
t.false(isRunning(descendantPid));
111+
});
112+
113+
if (!isWindows) {
114+
// A PID can be re-assigned to an unrelated process once Execa is done with the subprocess, so its process group must not be signaled anymore
115+
const testAbandonedProcessGroup = test.macro(async (t, getSubprocess) => {
116+
const subprocess = getSubprocess();
117+
await t.throwsAsync(subprocess);
118+
119+
const originalKill = process.kill;
120+
t.teardown(() => {
121+
process.kill = originalKill;
122+
});
123+
124+
const signaledProcesses = [];
125+
process.kill = (pid, signal) => {
126+
signaledProcesses.push([pid, signal]);
127+
return true;
128+
};
129+
130+
t.false(subprocess.kill('SIGKILL'));
131+
t.deepEqual(signaledProcesses, []);
132+
});
133+
134+
test.serial('killDescendants does not signal the process group once Execa is done with the subprocess', testAbandonedProcessGroup, () => execa('fail.js', {killDescendants: true}));
135+
136+
test.serial('killDescendants does not signal the process group once Execa is done with a terminated subprocess', testAbandonedProcessGroup, () => {
137+
const subprocess = execa('forever.js', {killDescendants: true});
138+
subprocess.kill('SIGTERM');
139+
return subprocess;
140+
});
141+
142+
// While Execa is still waiting on the subprocess, its process group is still its own
143+
test.serial('killDescendants signals the process group while Execa is not done with the subprocess', async t => {
144+
const subprocess = execa('forever.js', {killDescendants: true});
145+
146+
const originalKill = process.kill;
147+
t.teardown(() => {
148+
process.kill = originalKill;
149+
});
150+
151+
const signaledProcesses = [];
152+
process.kill = (pid, signal) => {
153+
signaledProcesses.push([pid, signal]);
154+
return originalKill(pid, signal);
155+
};
156+
157+
t.true(subprocess.kill('SIGTERM'));
158+
t.deepEqual(signaledProcesses, [[-subprocess.pid, 'SIGTERM']]);
159+
process.kill = originalKill;
160+
await t.throwsAsync(subprocess);
161+
});
162+
}
163+
90164
test('Cannot use "killDescendants" option, sync', t => {
91165
t.throws(() => {
92166
execaSync('empty.js', {killDescendants: true});
@@ -132,78 +206,124 @@ test.serial('taskkill is resolved from the Windows directory when available', t
132206
t.is(getTaskkillFile(), undefined);
133207
});
134208

135-
test.serial('taskkill fallback uses direct subprocess kill when Windows directory is unavailable', async t => {
209+
/*
210+
The `taskkill` logic only runs on Windows, so the platform, the Windows directory and `taskkill` itself are faked to test it on any OS.
211+
`lib/terminate/kill-descendants.js` reads `process.platform` when it is loaded, so it must be re-imported once the platform has been faked.
212+
*/
213+
let fakeWindowsCount = 0;
214+
215+
const fakeWindows = async (t, systemRoot, execFile = () => {}) => {
136216
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
217+
const originalExecFile = childProcess.execFile;
137218
const {SystemRoot, windir} = process.env;
138219
t.teardown(() => {
139220
Object.defineProperty(process, 'platform', platformDescriptor);
221+
childProcess.execFile = originalExecFile;
222+
syncBuiltinESMExports();
140223
restoreEnvironment('SystemRoot', SystemRoot);
141224
restoreEnvironment('windir', windir);
142225
});
143226

144227
Object.defineProperty(process, 'platform', {value: 'win32'});
145-
process.env.SystemRoot = 'Windows';
228+
process.env.SystemRoot = systemRoot;
146229
delete process.env.windir;
230+
childProcess.execFile = execFile;
231+
syncBuiltinESMExports();
147232

148-
const {getKillFunction} = await import(`../../lib/terminate/kill-descendants.js?taskkill-fallback=${Date.now()}`);
149-
let killedWith;
233+
fakeWindowsCount += 1;
234+
const {getKillFunction} = await import(`../../lib/terminate/kill-descendants.js?fake-windows=${fakeWindowsCount}`);
235+
return getKillFunction;
236+
};
237+
238+
// Mimics the `ChildProcess` properties used to send the signal and to detect whether the subprocess is still running
239+
const createFakeSubprocess = ({exitCode = null, signalCode = null} = {}) => {
150240
const subprocess = {
151241
pid: 123,
242+
exitCode,
243+
signalCode,
152244
kill(signal) {
153-
killedWith = signal;
245+
subprocess.killedWith = signal;
154246
return true;
155247
},
156248
};
249+
return subprocess;
250+
};
157251

158-
const kill = getKillFunction(subprocess, {killDescendants: true});
252+
const abortedController = () => {
253+
const controller = new AbortController();
254+
controller.abort();
255+
return controller;
256+
};
257+
258+
test.serial('taskkill fallback uses direct subprocess kill when Windows directory is unavailable', async t => {
259+
const getKillFunction = await fakeWindows(t, 'Windows');
260+
const subprocess = createFakeSubprocess();
261+
262+
const kill = getKillFunction(subprocess, {killDescendants: true}, new AbortController());
159263
t.true(kill('SIGTERM'));
160-
t.is(killedWith, 'SIGTERM');
264+
t.is(subprocess.killedWith, 'SIGTERM');
161265
});
162266

163267
test.serial('taskkill fallback uses direct subprocess kill when taskkill cannot be spawned', async t => {
164-
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
165-
const originalExecFile = childProcess.execFile;
166-
const {SystemRoot, windir} = process.env;
167-
t.teardown(() => {
168-
Object.defineProperty(process, 'platform', platformDescriptor);
169-
childProcess.execFile = originalExecFile;
170-
syncBuiltinESMExports();
171-
restoreEnvironment('SystemRoot', SystemRoot);
172-
restoreEnvironment('windir', windir);
173-
});
174-
175-
Object.defineProperty(process, 'platform', {value: 'win32'});
176-
process.env.SystemRoot = 'C:\\MissingWindows';
177-
delete process.env.windir;
178-
179268
const taskkillFailure = Promise.withResolvers();
180-
childProcess.execFile = (file, arguments_, callback) => {
269+
const getKillFunction = await fakeWindows(t, 'C:\\MissingWindows', (file, arguments_, callback) => {
181270
t.is(file, path.join('C:\\MissingWindows', 'System32', 'taskkill.exe'));
182271
t.deepEqual(arguments_, ['/pid', '123', '/T', '/F']);
183272
queueMicrotask(() => {
184273
callback(new Error('spawn failed'));
185274
taskkillFailure.resolve();
186275
});
187-
};
276+
});
277+
const subprocess = createFakeSubprocess();
188278

189-
syncBuiltinESMExports();
279+
const kill = getKillFunction(subprocess, {killDescendants: true}, new AbortController());
280+
t.true(kill('SIGTERM'));
281+
t.is(subprocess.killedWith, undefined);
190282

191-
const {getKillFunction} = await import(`../../lib/terminate/kill-descendants.js?taskkill-spawn-fallback=${Date.now()}`);
192-
let killedWith;
193-
const subprocess = {
194-
pid: 123,
195-
kill(signal) {
196-
killedWith = signal;
197-
return true;
198-
},
199-
};
283+
await taskkillFailure.promise;
284+
t.is(subprocess.killedWith, 'SIGTERM');
285+
});
200286

201-
const kill = getKillFunction(subprocess, {killDescendants: true});
287+
// Once Execa is done with the subprocess, its PID can be re-assigned by the OS, so its process tree must not be terminated anymore
288+
test.serial('taskkill is not spawned once Execa is done with the subprocess', async t => {
289+
let isTaskkillSpawned = false;
290+
const getKillFunction = await fakeWindows(t, 'C:\\Windows', () => {
291+
isTaskkillSpawned = true;
292+
});
293+
const subprocess = createFakeSubprocess({exitCode: 0});
294+
295+
const kill = getKillFunction(subprocess, {killDescendants: true}, abortedController());
296+
t.false(kill('SIGTERM'));
297+
t.false(isTaskkillSpawned);
298+
t.is(subprocess.killedWith, undefined);
299+
});
300+
301+
// While Execa is still waiting on the subprocess, its process tree is still its own, even after it exited
302+
test.serial('taskkill is still spawned after the subprocess exited', async t => {
303+
let taskkillArguments;
304+
const getKillFunction = await fakeWindows(t, 'C:\\Windows', (file, arguments_) => {
305+
taskkillArguments = arguments_;
306+
});
307+
const subprocess = createFakeSubprocess({exitCode: 0});
308+
309+
const kill = getKillFunction(subprocess, {killDescendants: true}, new AbortController());
202310
t.true(kill('SIGTERM'));
203-
t.is(killedWith, undefined);
311+
t.deepEqual(taskkillArguments, ['/pid', '123', '/T', '/F']);
312+
});
204313

205-
await taskkillFailure.promise;
206-
t.is(killedWith, 'SIGTERM');
314+
// A subprocess which never spawned has no PID to signal
315+
test.serial('taskkill is not spawned when the subprocess has no PID', async t => {
316+
let isTaskkillSpawned = false;
317+
const getKillFunction = await fakeWindows(t, 'C:\\Windows', () => {
318+
isTaskkillSpawned = true;
319+
});
320+
const subprocess = createFakeSubprocess();
321+
subprocess.pid = undefined;
322+
323+
const kill = getKillFunction(subprocess, {killDescendants: true}, new AbortController());
324+
t.false(kill('SIGTERM'));
325+
t.false(isTaskkillSpawned);
326+
t.is(subprocess.killedWith, undefined);
207327
});
208328

209329
const restoreEnvironment = (name, value) => {

‎types/arguments/options.d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ export type CommonOptions<
340340
341341
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).
342342
343-
This is best-effort: descendant processes that create their own process group or session are not terminated.
343+
This is best-effort: descendant processes that create their own process group or session are not terminated. Also, descendants are not terminated once Execa is done with the subprocess, or on Windows once the subprocess exited, since its PID might then belong to an unrelated process.
344344
345345
@default false
346346
*/

0 commit comments

Comments
 (0)