From b7f7431c2e065082eee229a10f5a70add5c0d705 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 30 Jul 2026 04:03:52 +0800 Subject: [PATCH 1/4] Preserve signal zero with descendant termination --- lib/terminate/kill-descendants.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/terminate/kill-descendants.js b/lib/terminate/kill-descendants.js index 6b6817faec..d20db3c0cd 100644 --- a/lib/terminate/kill-descendants.js +++ b/lib/terminate/kill-descendants.js @@ -44,12 +44,17 @@ const killDescendantsUnix = (subprocess, signal) => { // It must run while the tree is still intact, so direct subprocess termination is only used as a // fallback: killing the direct subprocess first would orphan its descendants before `taskkill` could enumerate them. // If `taskkill` is unavailable or fails, the fallback only terminates the direct subprocess. -// `taskkill` ignores the signal (`/F` terminates the tree). +// `taskkill` ignores non-zero signals (`/F` terminates the tree). const killDescendantsWindows = (subprocess, signal) => { if (subprocess.pid === undefined) { return false; } + // Signal 0 is a non-destructive process-existence check. It must not enter the tree-termination path. + if (signal === 0) { + return subprocess.kill(0); + } + const taskkillFile = getTaskkillFile(); if (taskkillFile === undefined) { return subprocess.kill(signal); From 1a1ec851a7bc47e7f0fd0c2c0e88ea25ac316c47 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 30 Jul 2026 04:05:20 +0800 Subject: [PATCH 2/4] Test signal zero with descendant termination --- test/terminate/kill-descendants.js | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/terminate/kill-descendants.js b/test/terminate/kill-descendants.js index fef94c8219..5d23c8391c 100644 --- a/test/terminate/kill-descendants.js +++ b/test/terminate/kill-descendants.js @@ -61,6 +61,24 @@ test('killDescendants also terminates descendant processes when the subprocess t t.false(isRunning(descendantPid)); }); +test('killDescendants preserves signal 0 as a non-destructive liveness check', async t => { + const {subprocess, descendantPid} = await spawnDescendant(true); + + t.true(subprocess.kill(0)); + await setTimeout(500); + t.true(isRunning(subprocess.pid)); + t.true(isRunning(descendantPid)); + + subprocess.kill(); + await t.throwsAsync(subprocess); + + 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) { @@ -132,6 +150,44 @@ test.serial('taskkill is resolved from the Windows directory when available', t t.is(getTaskkillFile(), undefined); }); +test.serial('signal 0 bypasses taskkill when killing descendants on Windows', async t => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + const originalExecFile = childProcess.execFile; + const {SystemRoot, windir} = process.env; + t.teardown(() => { + Object.defineProperty(process, 'platform', platformDescriptor); + childProcess.execFile = originalExecFile; + syncBuiltinESMExports(); + restoreEnvironment('SystemRoot', SystemRoot); + restoreEnvironment('windir', windir); + }); + + Object.defineProperty(process, 'platform', {value: 'win32'}); + process.env.SystemRoot = 'C:\\Windows'; + delete process.env.windir; + + let taskkillCalled = false; + childProcess.execFile = () => { + taskkillCalled = true; + }; + syncBuiltinESMExports(); + + const {getKillFunction} = await import(`../../lib/terminate/kill-descendants.js?signal-zero=${Date.now()}`); + let killedWith; + const subprocess = { + pid: 123, + kill(signal) { + killedWith = signal; + return true; + }, + }; + + const kill = getKillFunction(subprocess, {killDescendants: true}); + t.true(kill(0)); + t.is(killedWith, 0); + t.false(taskkillCalled); +}); + test.serial('taskkill fallback uses direct subprocess kill when Windows directory is unavailable', async t => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const {SystemRoot, windir} = process.env; From 0f9588405ecffc7a024c7df3357e9db7d2f72fb5 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 30 Jul 2026 04:58:02 +0800 Subject: [PATCH 3/4] Handle signal zero before descendant termination --- lib/terminate/kill-descendants.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/terminate/kill-descendants.js b/lib/terminate/kill-descendants.js index d20db3c0cd..20ca25284b 100644 --- a/lib/terminate/kill-descendants.js +++ b/lib/terminate/kill-descendants.js @@ -14,14 +14,16 @@ export const getSpawnOptions = options => options.killDescendants && !isWindows : 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. +// With the `killDescendants` option, non-zero signals are 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); + return signal => signal === 0 + ? subprocess.kill(0) + : killDescendantsFunction(subprocess, signal); }; // On Unix, the subprocess is its own process group leader (its PGID equals its PID), since it @@ -44,17 +46,12 @@ const killDescendantsUnix = (subprocess, signal) => { // It must run while the tree is still intact, so direct subprocess termination is only used as a // fallback: killing the direct subprocess first would orphan its descendants before `taskkill` could enumerate them. // If `taskkill` is unavailable or fails, the fallback only terminates the direct subprocess. -// `taskkill` ignores non-zero signals (`/F` terminates the tree). +// `taskkill` ignores the signal (`/F` terminates the tree). const killDescendantsWindows = (subprocess, signal) => { if (subprocess.pid === undefined) { return false; } - // Signal 0 is a non-destructive process-existence check. It must not enter the tree-termination path. - if (signal === 0) { - return subprocess.kill(0); - } - const taskkillFile = getTaskkillFile(); if (taskkillFile === undefined) { return subprocess.kill(signal); From dc73ffcd1765666f77fb39775af73abec08c5bb5 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 30 Jul 2026 04:58:40 +0800 Subject: [PATCH 4/4] Test signal-zero force-kill isolation --- .../terminate/kill-descendants-signal-zero.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 test/terminate/kill-descendants-signal-zero.js diff --git a/test/terminate/kill-descendants-signal-zero.js b/test/terminate/kill-descendants-signal-zero.js new file mode 100644 index 0000000000..ca494f2e76 --- /dev/null +++ b/test/terminate/kill-descendants-signal-zero.js @@ -0,0 +1,54 @@ +import process from 'node:process'; +import {setTimeout} from 'node:timers/promises'; +import test from 'ava'; +import isRunning from 'is-running'; +import {execa} from '../../index.js'; +import {setFixtureDirectory} from '../helpers/fixtures-directory.js'; + +setFixtureDirectory(); + +const pollForSubprocessExit = async pid => { + while (isRunning(pid)) { + // eslint-disable-next-line no-await-in-loop + await setTimeout(100); + } +}; + +test('signal 0 does not schedule forceful descendant termination', async t => { + const subprocess = execa('ipc-send-pid.js', ['false', 'false'], { + stdio: 'ignore', + ipc: true, + killDescendants: true, + forceKillAfterDelay: 100, + }); + const descendantPid = await subprocess.getOneMessage(); + + t.teardown(() => { + if (isRunning(subprocess.pid)) { + subprocess.kill('SIGKILL'); + } + + if (isRunning(descendantPid)) { + process.kill(descendantPid, 'SIGKILL'); + } + }); + + t.true(subprocess.kill(0)); + const settlement = await Promise.race([ + subprocess.then(() => 'resolved', () => 'rejected'), + setTimeout(500, 'running'), + ]); + + t.is(settlement, 'running'); + t.true(isRunning(subprocess.pid)); + t.true(isRunning(descendantPid)); + + subprocess.kill(); + await t.throwsAsync(subprocess); + + await Promise.race([ + setTimeout(1e4, undefined, {ref: false}), + pollForSubprocessExit(descendantPid), + ]); + t.false(isRunning(descendantPid)); +});