Skip to content
Open
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
33 changes: 33 additions & 0 deletions fixture-tree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import process from 'node:process';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import {fileURLToPath} from 'node:url';

const [pidsFile, depth, title, ignoreSigterm, isRoot = 'true'] = process.argv.slice(2);

if (title) {
process.title = title;
}

if (ignoreSigterm === 'all' || (ignoreSigterm === 'descendants' && isRoot === 'false')) {
process.on('SIGTERM', () => {});
}

fs.appendFileSync(pidsFile, `${process.pid}\n`);

if (Number(depth) > 0) {
childProcess.spawn(process.execPath, [
fileURLToPath(import.meta.url),
pidsFile,
String(Number(depth) - 1),
'',
ignoreSigterm,
'false',
], {
stdio: 'ignore',
});
} else {
fs.writeFileSync(`${pidsFile}.ready`, '');
}

setInterval(() => {}, 10_000);
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type Options = {
readonly forceAfterTimeout?: number;

/**
Kill all child processes along with the parent process. _(Windows only)_
Kill all child processes along with the parent process.

@default true
*/
Expand Down
111 changes: 101 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import {taskkill} from 'taskkill';
import {execa} from 'execa';
import {portToPid} from 'pid-port';
import pidtree from 'pidtree';
import {processExistsMultiple, filterExistingProcesses} from 'process-exists';
import psList from 'ps-list';

Expand Down Expand Up @@ -196,6 +197,78 @@ const getCurrentProcessParentsPID = processes => {
return pids;
};

const getTreeRootPids = (input, processes, ignoreCase) => {
if (typeof input === 'number') {
return input > 0 ? [input] : [];
}

const normalizedInput = ignoreCase ? input.toLowerCase() : input;

return processes
.filter(process_ => {
const names = [process_.name, process_.cmd?.split(' ')[0]].filter(Boolean);
return names.some(name => (ignoreCase ? name.toLowerCase() : name) === normalizedInput);
})
.map(process_ => process_.pid);
};

const getDescendantPidsForRoots = async (rootPids, protectedPids) => {
const descendantPidLists = await Promise.all(rootPids.map(async rootPid => {
try {
return await pidtree(rootPid);
} catch (error) {
const alive = await filterExistingProcesses([rootPid]);
if (alive.length === 0) {
return [];
}

throw error;
}
}));

// Pidtree returns breadth-first results. Reverse the de-duplicated list so
// grandchildren are signalled before their parents.
return [...new Set(descendantPidLists.flat())]
.filter(pid => !protectedPids.has(pid))
.reverse();
};

const getProcessTreePids = async (input, options) => {
if (process.platform === 'win32' || options.tree === false) {
return {rootPids: [], descendantPids: []};
}

const processes = await psList();
const protectedPids = new Set(getCurrentProcessParentsPID(processes));
const rootPids = getTreeRootPids(input, processes, options.ignoreCase);
const descendantPids = await getDescendantPidsForRoots(rootPids, protectedPids);

return {rootPids, descendantPids};
};

const killIfStillRunning = async (pid, options) => {
try {
await kill(pid, options);
} catch (error) {
const alive = await filterExistingProcesses([pid]);
if (alive.length > 0) {
throw error;
}
}
};

const killDescendants = async (input, options) => {
const {rootPids, descendantPids} = await getProcessTreePids(input, options);

for (const pid of descendantPids) {
await killIfStillRunning(pid, options); // eslint-disable-line no-await-in-loop
}

// Tracking concrete root PIDs matters for name-based kills: once signalled,
// the process name can disappear before the process has actually exited.
return [...descendantPids, ...rootPids];
};

const waitForProcessExit = async (parsedInputsMap, timeout, silent) => {
const endTime = Date.now() + timeout;
let interval = ALIVE_CHECK_MIN_INTERVAL;
Expand Down Expand Up @@ -232,21 +305,30 @@ const killWithLimits = async (input, options) => {
input = await parseInput(input);

if (input === process.pid) {
return;
return [];
}

if (input === 'node' || input === 'node.exe') {
const processes = await psList();
const pids = getCurrentProcessParentsPID(processes);
await Promise.all(processes.map(async ps => {
if ((ps.name === 'node' || ps.name === 'node.exe') && !pids.includes(ps.pid)) {
await kill(ps.pid, options);
}
}));
return;
const targets = processes
.filter(ps => (ps.name === 'node' || ps.name === 'node.exe') && !pids.includes(ps.pid))
.map(ps => ps.pid);
const descendantPids = options.tree === false || process.platform === 'win32'
? []
: await getDescendantPidsForRoots(targets, new Set(pids));

for (const pid of [...descendantPids, ...targets]) {
await killIfStillRunning(pid, options); // eslint-disable-line no-await-in-loop
}

return [...descendantPids, ...targets];
}

const descendantPids = await killDescendants(input, options);
await kill(input, options);

return descendantPids;
};

export default async function fkill(inputs, options = {}) {
Expand All @@ -265,12 +347,16 @@ export default async function fkill(inputs, options = {}) {
const exists = await processExistsMultiple([...parsedInputsMap.values()]);

const errors = [];
const descendantPids = new Set();

const handleKill = async input => {
const parsedInput = parsedInputsMap.get(input);

try {
await killWithLimits(input, options);
const killedDescendantPids = await killWithLimits(input, options);
for (const pid of killedDescendantPids) {
descendantPids.add(pid);
}
} catch (error) {
if (!exists.get(parsedInput)) {
errors.push(`Killing process ${input} failed: Process doesn't exist`);
Expand All @@ -287,14 +373,19 @@ export default async function fkill(inputs, options = {}) {
throw new AggregateError(errors, 'Failed to kill processes');
}

const trackedInputsMap = new Map(parsedInputsMap);
for (const pid of descendantPids) {
trackedInputsMap.set(pid, pid);
}

if (options.forceAfterTimeout !== undefined && !options.force) {
const endTime = Date.now() + options.forceAfterTimeout;
let interval = ALIVE_CHECK_MIN_INTERVAL;
if (interval > options.forceAfterTimeout) {
interval = options.forceAfterTimeout;
}

let alive = [...parsedInputsMap.values()];
let alive = [...trackedInputsMap.values()];

do {
await delay(interval); // eslint-disable-line no-await-in-loop
Expand All @@ -320,6 +411,6 @@ export default async function fkill(inputs, options = {}) {
}

if (options.waitForExit !== undefined && options.waitForExit > 0) {
await waitForProcessExit(parsedInputsMap, options.waitForExit, options.silent);
await waitForProcessExit(trackedInputsMap, options.waitForExit, options.silent);
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"dependencies": {
"execa": "^9.6.0",
"pid-port": "^2.0.0",
"pidtree": "^1.0.0",
"process-exists": "^5.0.0",
"ps-list": "^9.0.0",
"taskkill": "^5.0.0"
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Force kill processes that did not exit within the given number of milliseconds.
Type: `boolean`\
Default: `true`

Kill all child processes along with the parent process. *(Windows only)*
Kill all child processes along with the parent process.

##### ignoreCase

Expand Down
91 changes: 89 additions & 2 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,63 @@ async function noopProcessKilled(pid) {

async function waitForReady(pid) {
const readyFile = path.join(os.tmpdir(), `fkill-ready-${pid}`);
await waitForFile(readyFile, `Process ${pid} did not become ready`);
}

async function waitForFile(filePath, message = 'File was not created') {
const timeout = 2000;
const start = Date.now();
while (!fs.existsSync(readyFile)) {
while (!fs.existsSync(filePath)) {
if (Date.now() - start > timeout) {
throw new Error(`Process ${pid} did not become ready within ${timeout}ms`);
throw new Error(`${message} within ${timeout}ms`);
}

await delay(10); // eslint-disable-line no-await-in-loop
}
}

async function spawnProcessTree({title = '', ignoreSigterm = ''} = {}) {
const pidsFile = path.join(os.tmpdir(), `fkill-tree-${process.pid}-${Date.now()}-${Math.random()}`);
const parent = childProcess.spawn(process.execPath, [
'fixture-tree.js',
pidsFile,
'2',
title,
ignoreSigterm,
], {
stdio: 'ignore',
});

await waitForFile(`${pidsFile}.ready`, `Process tree ${parent.pid} did not become ready`);

const pids = fs.readFileSync(pidsFile, 'utf8')
.trim()
.split('\n')
.map(Number);

assert.strictEqual(pids.length, 3);
assert.strictEqual(pids[0], parent.pid);

return {parent, pids, pidsFile};
}

async function cleanupProcessTree({pids, pidsFile}) {
for (const pid of [...pids].reverse()) {
await fkill(pid, {force: true, tree: false, silent: true}); // eslint-disable-line no-await-in-loop
}

fs.rmSync(pidsFile, {force: true});
fs.rmSync(`${pidsFile}.ready`, {force: true});
}

async function assertProcessesExited(pids) {
await delay(100);

for (const pid of pids) {
assert.strictEqual(await processExists(pid), false); // eslint-disable-line no-await-in-loop
}
}

test('pid', async () => {
const pid = await noopProcess();
await fkill(pid, {force: true});
Expand Down Expand Up @@ -155,6 +201,47 @@ test('kill from port', async () => {
await noopProcessKilled(pid);
});

test('kill process tree by default', async t => {
const tree = await spawnProcessTree();
t.after(async () => cleanupProcessTree(tree));

await fkill(tree.parent.pid, {force: true, waitForExit: 2000});

await assertProcessesExited(tree.pids);
});

test('tree: false only kills the parent process', async t => {
const tree = await spawnProcessTree();
t.after(async () => cleanupProcessTree(tree));

await fkill(tree.parent.pid, {force: true, tree: false, waitForExit: 2000});

assert.strictEqual(await processExists(tree.pids[0]), false);
assert.strictEqual(await processExists(tree.pids[1]), true);
assert.strictEqual(await processExists(tree.pids[2]), true);
});

if (process.platform !== 'win32') {
test('kill process tree by name', async t => {
const title = `fk-tree-${process.pid}`.slice(0, 15);
const tree = await spawnProcessTree({title});
t.after(async () => cleanupProcessTree(tree));

await fkill(title, {force: true, waitForExit: 2000});

await assertProcessesExited(tree.pids);
});

test('forceAfterTimeout kills reparented descendants', async t => {
const tree = await spawnProcessTree({ignoreSigterm: 'descendants'});
t.after(async () => cleanupProcessTree(tree));

await fkill(tree.parent.pid, {forceAfterTimeout: 100, waitForExit: 2000});

await assertProcessesExited(tree.pids);
});
}

// Issue #65: Verify error reporting for port syntax. These tests don't cover the full bug scenario
// (port with process but kill fails) due to portToPid test unreliability, but the fix is sound.
test('error when port is not in use', async () => {
Expand Down