From 53657b64ada15aaf0e05ee2095d0441cd078de64 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 7 Jul 2026 00:36:26 +0200 Subject: [PATCH] Add `killDescendants` option to also terminate descendant processes Fixes #96 --- docs/api.md | 15 +++++ docs/termination.md | 21 ++++++- lib/arguments/options.js | 2 + lib/methods/main-async.js | 5 +- lib/methods/main-sync.js | 6 +- lib/terminate/kill-descendants.js | 54 ++++++++++++++++++ test-d/arguments/options.test-d.ts | 7 +++ test/terminate/kill-descendants.js | 90 ++++++++++++++++++++++++++++++ types/arguments/options.d.ts | 13 +++++ 9 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 lib/terminate/kill-descendants.js create mode 100644 test/terminate/kill-descendants.js diff --git a/docs/api.md b/docs/api.md index a26d881f6b..ec8f4b0a84 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`\ diff --git a/docs/termination.md b/docs/termination.md index c4b033afb8..b30d2b0649 100644 --- a/docs/termination.md +++ b/docs/termination.md @@ -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 diff --git a/lib/arguments/options.js b/lib/arguments/options.js index 5e416ffda6..3cbcb6b98f 100644 --- a/lib/arguments/options.js +++ b/lib/arguments/options.js @@ -54,6 +54,7 @@ const addDefaultOptions = ({ encoding = 'utf8', reject = true, cleanup = true, + killDescendants = false, all = false, windowsHide = true, killSignal = 'SIGTERM', @@ -73,6 +74,7 @@ const addDefaultOptions = ({ encoding, reject, cleanup, + killDescendants, all, windowsHide, killSignal, diff --git a/lib/methods/main-async.js b/lib/methods/main-async.js index a6ff9fdff1..1db8448168 100644 --- a/lib/methods/main-async.js +++ b/lib/methods/main-async.js @@ -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'; @@ -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, @@ -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, diff --git a/lib/methods/main-sync.js b/lib/methods/main-sync.js index f294e9f0bd..9ee841ce3c 100644 --- a/lib/methods/main-sync.js +++ b/lib/methods/main-sync.js @@ -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'); } @@ -64,6 +64,10 @@ const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal}) => { throwInvalidSyncOption('detached: true'); } + if (killDescendants) { + throwInvalidSyncOption('killDescendants: true'); + } + if (cancelSignal) { throwInvalidSyncOption('cancelSignal'); } diff --git a/lib/terminate/kill-descendants.js b/lib/terminate/kill-descendants.js new file mode 100644 index 0000000000..7ba4b33e5d --- /dev/null +++ b/lib/terminate/kill-descendants.js @@ -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; +}; diff --git a/test-d/arguments/options.test-d.ts b/test-d/arguments/options.test-d.ts index cd4902ac43..9df1a619f4 100644 --- a/test-d/arguments/options.test-d.ts +++ b/test-d/arguments/options.test-d.ts @@ -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})); diff --git a/test/terminate/kill-descendants.js b/test/terminate/kill-descendants.js new file mode 100644 index 0000000000..0cf1b7a40e --- /dev/null +++ b/test/terminate/kill-descendants.js @@ -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/}); +}); diff --git a/types/arguments/options.d.ts b/types/arguments/options.d.ts index f622179f66..f36353400d 100644 --- a/types/arguments/options.d.ts +++ b/types/arguments/options.d.ts @@ -329,6 +329,19 @@ export type CommonOptions< */ readonly cleanup?: Unless; + /** + 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; + /** Sets the [user identifier](https://en.wikipedia.org/wiki/User_identifier) of the subprocess.