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
3 changes: 1 addition & 2 deletions packages/shared-lib-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@
"prettier": "@willbooster/prettier-config",
"dependencies": {
"dotenv": "17.3.1",
"dotenv-expand": "12.0.3",
"tree-kill": "1.2.2"
"dotenv-expand": "12.0.3"
},
"devDependencies": {
"@types/bun": "1.3.9",
Expand Down
3 changes: 3 additions & 0 deletions packages/shared-lib-node/src/errno.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
1 change: 1 addition & 0 deletions packages/shared-lib-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export type { EnvReaderOptions } from './env.js';
export { existsAsync } from './exists.js';
export { calculateHashFromFiles, canSkipSeed, updateHashFromFiles } from './hash.js';
export { spawnAsync } from './spawn.js';
export { treeKill } from './treeKill.js';
32 changes: 32 additions & 0 deletions packages/shared-lib-node/src/processTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export function buildChildrenByParentMap(psOutput: string): Map<number, number[]> {
const childrenByParent = new Map<number, number[]>();
for (const line of psOutput.split('\n')) {
const matched = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
if (!matched) {
continue;
}

const childPid = Number(matched[1]);
const parentPid = Number(matched[2]);
const children = childrenByParent.get(parentPid);
if (children) {
children.push(childPid);
} else {
childrenByParent.set(parentPid, [childPid]);
}
}
return childrenByParent;
}

export function collectDescendantPids(rootPid: number, childrenByParent: Map<number, number[]>): number[] {
const descendants: number[] = [];
const queue = [...(childrenByParent.get(rootPid) ?? [])];
let index = 0;
while (index < queue.length) {
const pid = queue[index] as number;
index += 1;
descendants.push(pid);
queue.push(...(childrenByParent.get(pid) ?? []));
}
return descendants;
}
10 changes: 8 additions & 2 deletions packages/shared-lib-node/src/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} from 'node:child_process';
import { spawn } from 'node:child_process';

import treeKill from 'tree-kill';
import { treeKill } from './treeKill.js';

/**
* Return type for spawnAsync function, based on SpawnSyncReturns but without output and error properties
Expand Down Expand Up @@ -107,7 +107,13 @@ export async function spawnAsync(
if (options?.verbose) {
console.info(`treeKill(${proc.pid})`);
}
treeKill(proc.pid);
try {
treeKill(proc.pid);
} catch (error) {
if (options?.verbose) {
console.warn(`Failed to treeKill(${proc.pid})`, error);
}
}
};
if (options?.killOnExit) {
process.on('beforeExit', stopProcess);
Expand Down
133 changes: 133 additions & 0 deletions packages/shared-lib-node/src/treeKill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { execFileSync } from 'node:child_process';

import { isErrnoException } from './errno.js';
import { buildChildrenByParentMap, collectDescendantPids as collectDescendantPidsFromMap } from './processTree.js';

export function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void {
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`Invalid pid: ${pid}`);
}

if (process.platform === 'win32') {
killTreeOnWindows(pid);
return;
}

const descendants = collectDescendantPids(pid);
const targetPids = toChildrenFirstPids(pid, descendants);
for (const targetPid of targetPids) {
killIfNeeded(targetPid, signal);
}
}

function killIfNeeded(pid: number, signal: NodeJS.Signals): void {
try {
process.kill(pid, signal);
} catch (error) {
if (isNoSuchProcessError(error)) {
return;
}
throw error;
}
}

function killTreeOnWindows(pid: number): void {
try {
runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], {
maxBuffer: 1024 * 1024,
timeout: 2000,
});
} catch (error) {
if (isNoSuchProcessError(error)) {
return;
}
throw error;
}
}

function collectDescendantPids(rootPid: number): number[] {
const { stdout } = runCommand(
'ps',
['-Ao', 'pid=,ppid='],
// Keep command bounded so watch-mode kill loops cannot hang this path.
{ maxBuffer: 1024 * 1024, timeout: 2000 }
);
const childrenByParent = buildChildrenByParentMap(stdout);
return collectDescendantPidsFromMap(rootPid, childrenByParent);
}

function toChildrenFirstPids(pid: number, descendants: readonly number[]): number[] {
const targetPids = descendants.toReversed();
targetPids.push(pid);
return targetPids;
}

function runCommand(
command: string,
args: readonly string[],
options?: { timeout: number; maxBuffer: number }
): { stdout: string; stderr: string } {
try {
const stdout = execFileSync(command, [...args], {
encoding: 'utf8',
maxBuffer: options?.maxBuffer,
timeout: options?.timeout,
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, stderr: '' };
} catch (error) {
const stderr = extractStderr(error);
throw new CommandExecutionError(command, args, stderr, toExitCode(error));
}
}

function isNoSuchProcessError(error: unknown): boolean {
if (isErrnoException(error) && error.code === 'ESRCH') {
return true;
}

if (error instanceof CommandExecutionError) {
return /no such process|not found|not recognized|there is no running instance/i.test(error.stderr);
}
return false;
}

function toExitCode(error: unknown): number | string | undefined {
if (isErrnoException(error)) {
return error.code;
}
if (typeof error === 'object' && error !== null && 'status' in error) {
const status = (error as { status: unknown }).status;
if (typeof status === 'number') {
return status;
}
}
return undefined;
}

function extractStderr(error: unknown): string {
if (typeof error !== 'object' || error === null || !('stderr' in error)) {
return '';
}

const stderr = (error as Record<'stderr', unknown>).stderr;
if (typeof stderr === 'string') {
return stderr;
}
if (Buffer.isBuffer(stderr)) {
return stderr.toString('utf8');
}
return '';
}

class CommandExecutionError extends Error {
readonly stderr: string;
readonly code: number | string | undefined;

constructor(command: string, args: readonly string[], stderr: string, code: number | string | undefined) {
super(`Command failed: ${command} ${args.join(' ')}`);
this.name = 'CommandExecutionError';
this.stderr = stderr;
this.code = code;
}
}
50 changes: 50 additions & 0 deletions packages/shared-lib-node/test/processUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { spawn } from 'node:child_process';

import { describe, expect, it } from 'vitest';

import {
createTreeScript,
isProcessRunning,
listDescendantPids,
wait,
waitForProcessStopped,
} from '../../../test/processUtils.js';
import { treeKill } from '../src/treeKill.js';

describe('processUtils', () => {
it('finds descendants for a running process tree', async () => {
const parent = spawn(process.execPath, ['-e', createTreeScript(2)], { stdio: ['ignore', 'ignore', 'ignore'] });
const { pid: parentPid } = parent;
expect(parentPid).toBeDefined();
if (!parentPid) {
throw new Error('parent.pid is undefined');
}

const descendants = await waitForDescendants(parentPid, 2, 10_000);
expect(descendants.length).toBeGreaterThanOrEqual(2);

treeKill(parentPid, 'SIGKILL');
await waitForProcessStopped(parentPid, 10_000);
for (const pid of descendants) {
await waitForProcessStopped(pid, 10_000);
}
}, 30_000);

it('returns empty descendants and false running status for unknown pid', () => {
const unknownPid = 999_999_999;
expect(listDescendantPids(unknownPid)).toStrictEqual([]);
expect(isProcessRunning(unknownPid)).toBe(false);
});
});

async function waitForDescendants(parentPid: number, minimumCount: number, timeoutMs: number): Promise<number[]> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const descendants = listDescendantPids(parentPid);
if (descendants.length >= minimumCount) {
return descendants;
}
await wait(100);
}
throw new Error(`Timed out while waiting descendants of ${parentPid}`);
}
137 changes: 137 additions & 0 deletions packages/shared-lib-node/test/treeKill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { ChildProcessByStdio } from 'node:child_process';
import { spawn } from 'node:child_process';
import type { Readable } from 'node:stream';

import { describe, expect, it } from 'vitest';

import {
createTreeScript,
isProcessRunning,
listDescendantPids,
wait,
waitForProcessStopped,
} from '../../../test/processUtils.js';
import { treeKill } from '../src/treeKill.js';

type ChildProcessWithPipeOut = ChildProcessByStdio<null, Readable, Readable>;

describe('treeKill', () => {
it('kills parent and descendant processes', async () => {
const parent = spawnProcessTree(1);
const { pid: parentPid } = parent;
expect(parentPid).toBeDefined();
if (!parentPid) {
throw new Error('parent.pid is undefined');
}

const descendantPids = await waitForDescendantPidsCount(parentPid, 1, 10_000);
expect(isProcessRunning(parentPid)).toBe(true);
for (const pid of descendantPids) {
expect(isProcessRunning(pid)).toBe(true);
}

treeKill(parentPid);

await Promise.all([
waitForProcessStopped(parentPid, 10_000),
...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)),
waitForClose(parent, 10_000),
]);
}, 30_000);

it('kills deep process trees', async () => {
const parent = spawnProcessTree(2);
const { pid: parentPid } = parent;
expect(parentPid).toBeDefined();
if (!parentPid) {
throw new Error('parent.pid is undefined');
}

const descendantPids = await waitForDescendantPidsCount(parentPid, 2, 10_000);
treeKill(parentPid);

await Promise.all([
waitForProcessStopped(parentPid, 10_000),
...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)),
waitForClose(parent, 10_000),
]);
}, 30_000);

it('kills process trees with custom signal', async () => {
const parent = spawnProcessTree(1);
const { pid: parentPid } = parent;
expect(parentPid).toBeDefined();
if (!parentPid) {
throw new Error('parent.pid is undefined');
}

const descendantPids = await waitForDescendantPidsCount(parentPid, 1, 10_000);
treeKill(parentPid, 'SIGKILL');

await Promise.all([
waitForProcessStopped(parentPid, 10_000),
...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)),
waitForClose(parent, 10_000),
]);
}, 30_000);

it('kills repeatedly in rapid succession', async () => {
for (let i = 0; i < 3; i++) {
const parent = spawnProcessTree(2);
const { pid: parentPid } = parent;
expect(parentPid).toBeDefined();
if (!parentPid) {
throw new Error('parent.pid is undefined');
}

const descendantPids = await waitForDescendantPidsCount(parentPid, 2, 10_000);
treeKill(parentPid);

await Promise.all([
waitForProcessStopped(parentPid, 10_000),
...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)),
waitForClose(parent, 10_000),
]);
}
}, 60_000);

it('does not throw when process is already gone', () => {
expect(() => {
treeKill(999_999_999);
}).not.toThrow();
});
});

function spawnProcessTree(depth: number): ChildProcessWithPipeOut {
return spawn(process.execPath, ['-e', createTreeScript(depth)], { stdio: ['ignore', 'pipe', 'pipe'] });
}

async function waitForDescendantPidsCount(
parentPid: number,
minimumCount: number,
timeoutMs: number
): Promise<number[]> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const descendants = listDescendantPids(parentPid);
if (descendants.length >= minimumCount) {
return descendants;
}
await wait(100);
}
throw new Error(`Timed out while waiting descendant processes for ${parentPid}`);
}

async function waitForClose(proc: ChildProcessWithPipeOut, timeoutMs: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
proc.removeListener('close', onClose);
reject(new Error('Timed out while waiting process close event'));
}, timeoutMs);
const onClose = (): void => {
clearTimeout(timer);
resolve();
};
proc.once('close', onClose);
});
}
Loading