diff --git a/packages/shared-lib-node/package.json b/packages/shared-lib-node/package.json index 9de86470..da896206 100644 --- a/packages/shared-lib-node/package.json +++ b/packages/shared-lib-node/package.json @@ -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", diff --git a/packages/shared-lib-node/src/errno.ts b/packages/shared-lib-node/src/errno.ts new file mode 100644 index 00000000..12008715 --- /dev/null +++ b/packages/shared-lib-node/src/errno.ts @@ -0,0 +1,3 @@ +export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} diff --git a/packages/shared-lib-node/src/index.ts b/packages/shared-lib-node/src/index.ts index 19110a4a..0b388c05 100644 --- a/packages/shared-lib-node/src/index.ts +++ b/packages/shared-lib-node/src/index.ts @@ -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'; diff --git a/packages/shared-lib-node/src/processTree.ts b/packages/shared-lib-node/src/processTree.ts new file mode 100644 index 00000000..1340ce65 --- /dev/null +++ b/packages/shared-lib-node/src/processTree.ts @@ -0,0 +1,32 @@ +export function buildChildrenByParentMap(psOutput: string): Map { + const childrenByParent = new Map(); + 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[] { + 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; +} diff --git a/packages/shared-lib-node/src/spawn.ts b/packages/shared-lib-node/src/spawn.ts index 01257c40..c88fe009 100644 --- a/packages/shared-lib-node/src/spawn.ts +++ b/packages/shared-lib-node/src/spawn.ts @@ -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 @@ -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); diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts new file mode 100644 index 00000000..5cebcd9d --- /dev/null +++ b/packages/shared-lib-node/src/treeKill.ts @@ -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; + } +} diff --git a/packages/shared-lib-node/test/processUtils.test.ts b/packages/shared-lib-node/test/processUtils.test.ts new file mode 100644 index 00000000..b371e2d2 --- /dev/null +++ b/packages/shared-lib-node/test/processUtils.test.ts @@ -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 { + 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}`); +} diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts new file mode 100644 index 00000000..69fcc06a --- /dev/null +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -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; + +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 { + 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 { + await new Promise((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); + }); +} diff --git a/packages/wb/package.json b/packages/wb/package.json index be35f003..619af341 100644 --- a/packages/wb/package.json +++ b/packages/wb/package.json @@ -37,7 +37,6 @@ "globby": "16.1.1", "kill-port": "2.0.1", "minimal-promise-pool": "6.0.1", - "tree-kill": "1.2.2", "yargs": "18.0.0" }, "devDependencies": { diff --git a/packages/wb/src/commands/treeKill.ts b/packages/wb/src/commands/treeKill.ts new file mode 100644 index 00000000..566f4079 --- /dev/null +++ b/packages/wb/src/commands/treeKill.ts @@ -0,0 +1,40 @@ +import { constants } from 'node:os'; + +import { treeKill } from '@willbooster/shared-lib-node/src'; +import type { Argv, CommandModule } from 'yargs'; + +interface TreeKillCommandArgs { + pid: number; + signal: string; +} + +const builder = (yargs: Argv): Argv => + yargs + .positional('pid', { + description: 'The process ID to kill.', + type: 'number', + demandOption: true, + }) + .option('signal', { + description: 'Signal to send to the process tree.', + type: 'string', + default: 'SIGTERM', + }) as Argv; + +export const treeKillCommand: CommandModule = { + command: 'tree-kill [signal]', + describe: 'Kill the given process and all descendants', + builder, + handler(argv) { + try { + const signal = argv.signal as NodeJS.Signals; + if (!(signal in constants.signals)) { + throw new Error(`Invalid signal: ${signal}`); + } + treeKill(argv.pid, signal); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }, +}; diff --git a/packages/wb/src/index.ts b/packages/wb/src/index.ts index a33c02b8..09364c3c 100644 --- a/packages/wb/src/index.ts +++ b/packages/wb/src/index.ts @@ -1,8 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { removeNpmAndYarnEnvironmentVariables } from '@willbooster/shared-lib-node/src'; -import treeKill from 'tree-kill'; +import { removeNpmAndYarnEnvironmentVariables, treeKill } from '@willbooster/shared-lib-node/src'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; @@ -16,6 +15,7 @@ import { setupCommand } from './commands/setup.js'; import { startCommand } from './commands/start.js'; import { testCommand } from './commands/test.js'; import { testOnCiCommand } from './commands/testOnCi.js'; +import { treeKillCommand } from './commands/treeKill.js'; import { tcCommand, typeCheckCommand } from './commands/typecheck.js'; import { sharedOptionsBuilder } from './sharedOptionsBuilder.js'; @@ -41,6 +41,7 @@ await yargs(hideBin(process.argv)) .command(startCommand) .command(testCommand) .command(testOnCiCommand) + .command(treeKillCommand) .command(typeCheckCommand) .command(tcCommand) .demandCommand() @@ -59,9 +60,17 @@ function getVersion(): string { return packageJson.version; } +let shuttingDown = false; for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) { process.on(signal, () => { - treeKill(process.pid); + if (shuttingDown) return; + + shuttingDown = true; + try { + treeKill(process.pid); + } catch (error) { + console.warn(`Failed to treeKill(${process.pid}) during shutdown:`, error); + } process.exit(); }); } diff --git a/packages/wb/test/treeKill.test.ts b/packages/wb/test/treeKill.test.ts new file mode 100644 index 00000000..69afd4f2 --- /dev/null +++ b/packages/wb/test/treeKill.test.ts @@ -0,0 +1,51 @@ +import { spawn } from 'node:child_process'; + +import { describe, expect, it } from 'vitest'; +import type { ArgumentsCamelCase } from 'yargs'; + +import { isProcessRunning, waitForProcessStopped } from '../../../test/processUtils.js'; +import { treeKillCommand } from '../src/commands/treeKill.js'; + +describe('tree-kill command', () => { + it('kills target process', async () => { + const proc = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + const { pid } = proc; + expect(pid).toBeDefined(); + if (!pid) { + throw new Error('proc.pid is undefined'); + } + + expect(isProcessRunning(pid)).toBe(true); + + runTreeKillHandler(pid, 'SIGTERM'); + + await waitForProcessStopped(pid, 10_000); + }); + + it('kills target process with custom signal', async () => { + const proc = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + const { pid } = proc; + expect(pid).toBeDefined(); + if (!pid) { + throw new Error('proc.pid is undefined'); + } + + runTreeKillHandler(pid, 'SIGKILL'); + + await waitForProcessStopped(pid, 10_000); + }); +}); + +function runTreeKillHandler(pid: number, signal: NodeJS.Signals): void { + const argv = { + _: [], + $0: 'wb', + pid, + signal, + } as ArgumentsCamelCase<{ pid: number; signal: string }>; + void treeKillCommand.handler(argv); +} diff --git a/test/processUtils.ts b/test/processUtils.ts new file mode 100644 index 00000000..d05a70b1 --- /dev/null +++ b/test/processUtils.ts @@ -0,0 +1,51 @@ +import { spawnSync } from 'node:child_process'; + +import { isErrnoException } from '../packages/shared-lib-node/src/errno.js'; +import { buildChildrenByParentMap, collectDescendantPids } from '../packages/shared-lib-node/src/processTree.js'; + +export function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (isErrnoException(error) && error.code === 'ESRCH') { + return false; + } + throw error; + } +} + +export async function waitForProcessStopped(pid: number, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (!isProcessRunning(pid)) { + return; + } + await wait(100); + } + throw new Error(`Timed out while waiting process ${pid} to stop`); +} + +export async function wait(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function listDescendantPids(rootPid: number): number[] { + const result = spawnSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8' }); + const childrenByParent = buildChildrenByParentMap(result.stdout); + return collectDescendantPids(rootPid, childrenByParent); +} + +export function createTreeScript(depth: number): string { + let code = 'setInterval(() => {}, 1000);'; + for (let i = 0; i < depth; i++) { + code = [ + "const { spawn } = require('node:child_process');", + `spawn(process.execPath, ['-e', ${JSON.stringify(code)}], { stdio: 'ignore' });`, + 'setInterval(() => {}, 1000);', + ].join(''); + } + return code; +} diff --git a/yarn.lock b/yarn.lock index fe764ba1..57064888 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6887,7 +6887,6 @@ __metadata: prettier: "npm:3.8.1" prettier-plugin-java: "npm:2.8.1" sort-package-json: "npm:3.6.1" - tree-kill: "npm:1.2.2" typescript: "npm:5.9.3" typescript-eslint: "npm:8.56.1" vitest: "npm:4.0.18" @@ -7010,7 +7009,6 @@ __metadata: prettier: "npm:3.8.1" prettier-plugin-java: "npm:2.8.1" sort-package-json: "npm:3.6.1" - tree-kill: "npm:1.2.2" type-fest: "npm:5.4.4" typescript: "npm:5.9.3" typescript-eslint: "npm:8.56.1"