Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/terminate/kill-descendants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions test/terminate/kill-descendants-signal-zero.js
Original file line number Diff line number Diff line change
@@ -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));
});
56 changes: 56 additions & 0 deletions test/terminate/kill-descendants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down