diff --git a/lib/terminate/kill-descendants.js b/lib/terminate/kill-descendants.js index 6b6817faec..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 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)); +}); 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;