Skip to content
Merged
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
37 changes: 31 additions & 6 deletions lib/terminate/kill-descendants.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import process from 'node:process';
import {execFile} from 'node:child_process';
import path from 'node:path/win32';

const isWindows = process.platform === 'win32';

Expand Down Expand Up @@ -40,15 +41,39 @@ const killDescendantsUnix = (subprocess, 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 => {
// 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).
const killDescendantsWindows = (subprocess, signal) => {
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'], () => {});
const taskkillFile = getTaskkillFile();
if (taskkillFile === undefined) {
return subprocess.kill(signal);
}

// This is best-effort: if `taskkill` fails, still try the direct subprocess.
execFile(taskkillFile, ['/pid', `${subprocess.pid}`, '/T', '/F'], error => {
if (error) {
subprocess.kill(signal);
}
});
return true;
};

export const getTaskkillFile = () => {
const windowsDirectory = [process.env.SystemRoot, process.env.windir]
.find(directory => directory && isWindowsDriveAbsolutePath(directory));

return windowsDirectory === undefined
? undefined
: path.join(windowsDirectory, 'System32', 'taskkill.exe');
};

const isWindowsDriveAbsolutePath = directory => {
const {root} = path.parse(directory);
return /^[a-z]:[/\\]/i.test(root);
};
125 changes: 125 additions & 0 deletions test/terminate/kill-descendants.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import childProcess from 'node:child_process';
import {syncBuiltinESMExports} from 'node:module';
import path from 'node:path/win32';
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 {getTaskkillFile} from '../../lib/terminate/kill-descendants.js';
import {setFixtureDirectory} from '../helpers/fixtures-directory.js';

setFixtureDirectory();
Expand Down Expand Up @@ -88,3 +92,124 @@ test('Cannot use "killDescendants" option, sync', t => {
execaSync('empty.js', {killDescendants: true});
}, {message: /The "killDescendants: true" option cannot be used/});
});

test.serial('taskkill is resolved from the Windows directory when available', t => {
const {SystemRoot, windir} = process.env;
t.teardown(() => {
restoreEnvironment('SystemRoot', SystemRoot);
restoreEnvironment('windir', windir);
});

process.env.SystemRoot = 'C:\\Windows';
process.env.windir = 'D:\\Windows';
t.is(getTaskkillFile(), path.join('C:\\Windows', 'System32', 'taskkill.exe'));

process.env.SystemRoot = 'C:/Windows';
t.is(getTaskkillFile(), path.join('C:/Windows', 'System32', 'taskkill.exe'));

process.env.SystemRoot = 'Windows';
t.is(getTaskkillFile(), path.join('D:\\Windows', 'System32', 'taskkill.exe'));

process.env.SystemRoot = '\\Windows';
t.is(getTaskkillFile(), path.join('D:\\Windows', 'System32', 'taskkill.exe'));

delete process.env.SystemRoot;
t.is(getTaskkillFile(), path.join('D:\\Windows', 'System32', 'taskkill.exe'));

process.env.windir = 'Windows';
t.is(getTaskkillFile(), undefined);

process.env.windir = '\\Windows';
t.is(getTaskkillFile(), undefined);

process.env.windir = '\\\\server\\share\\Windows';
t.is(getTaskkillFile(), undefined);

process.env.windir = '';
t.is(getTaskkillFile(), undefined);

delete process.env.windir;
t.is(getTaskkillFile(), undefined);
});

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;
t.teardown(() => {
Object.defineProperty(process, 'platform', platformDescriptor);
restoreEnvironment('SystemRoot', SystemRoot);
restoreEnvironment('windir', windir);
});

Object.defineProperty(process, 'platform', {value: 'win32'});
process.env.SystemRoot = 'Windows';
delete process.env.windir;

const {getKillFunction} = await import(`../../lib/terminate/kill-descendants.js?taskkill-fallback=${Date.now()}`);
let killedWith;
const subprocess = {
pid: 123,
kill(signal) {
killedWith = signal;
return true;
},
};

const kill = getKillFunction(subprocess, {killDescendants: true});
t.true(kill('SIGTERM'));
t.is(killedWith, 'SIGTERM');
});

test.serial('taskkill fallback uses direct subprocess kill when taskkill cannot be spawned', 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:\\MissingWindows';
delete process.env.windir;

const taskkillFailure = Promise.withResolvers();
childProcess.execFile = (file, arguments_, callback) => {
t.is(file, path.join('C:\\MissingWindows', 'System32', 'taskkill.exe'));
t.deepEqual(arguments_, ['/pid', '123', '/T', '/F']);
queueMicrotask(() => {
callback(new Error('spawn failed'));
taskkillFailure.resolve();
});
};

syncBuiltinESMExports();

const {getKillFunction} = await import(`../../lib/terminate/kill-descendants.js?taskkill-spawn-fallback=${Date.now()}`);
let killedWith;
const subprocess = {
pid: 123,
kill(signal) {
killedWith = signal;
return true;
},
};

const kill = getKillFunction(subprocess, {killDescendants: true});
t.true(kill('SIGTERM'));
t.is(killedWith, undefined);

await taskkillFailure.promise;
t.is(killedWith, 'SIGTERM');
});

const restoreEnvironment = (name, value) => {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
};
Loading