From 501bed8ea87109ed84b6c55e2d5b6e12e5cc0a1a Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 19:06:42 +0900 Subject: [PATCH 01/18] feat: replace tree-kill with shared implementation Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/package.json | 3 +- packages/shared-lib-node/src/index.ts | 1 + packages/shared-lib-node/src/spawn.ts | 6 +- packages/shared-lib-node/src/treeKill.ts | 131 +++++++++++++++++ .../shared-lib-node/test/treeKill.test.ts | 135 ++++++++++++++++++ packages/wb/package.json | 1 - packages/wb/src/commands/treeKill.ts | 20 +++ packages/wb/src/index.ts | 7 +- packages/wb/test/treeKill.test.ts | 134 +++++++++++++++++ yarn.lock | 2 - 10 files changed, 430 insertions(+), 10 deletions(-) create mode 100644 packages/shared-lib-node/src/treeKill.ts create mode 100644 packages/shared-lib-node/test/treeKill.test.ts create mode 100644 packages/wb/src/commands/treeKill.ts create mode 100644 packages/wb/test/treeKill.test.ts 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/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/spawn.ts b/packages/shared-lib-node/src/spawn.ts index 01257c40..8db7aefc 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,9 @@ export async function spawnAsync( if (options?.verbose) { console.info(`treeKill(${proc.pid})`); } - treeKill(proc.pid); + void treeKill(proc.pid).catch(() => { + // do nothing + }); }; 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..c047faea --- /dev/null +++ b/packages/shared-lib-node/src/treeKill.ts @@ -0,0 +1,131 @@ +import { spawn } from 'node:child_process'; + +export async function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): Promise { + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error(`Invalid pid: ${pid}`); + } + + if (process.platform === 'win32') { + await killTreeOnWindows(pid); + return; + } + + const descendants = await collectDescendantPids(pid); + const targetPids = [...descendants, pid].toReversed(); + 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; + } +} + +async function killTreeOnWindows(pid: number): Promise { + try { + await runCommand('taskkill', ['/PID', String(pid), '/T', '/F']); + } catch (error) { + if (isNoSuchProcessError(error)) { + return; + } + throw error; + } +} + +async function collectDescendantPids(rootPid: number): Promise { + const { stdout } = await runCommand('ps', ['-Ao', 'pid=,ppid=']); + const childrenByParent = new Map(); + for (const line of stdout.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]); + } + } + + const descendants: number[] = []; + const queue = [...(childrenByParent.get(rootPid) ?? [])]; + while (queue.length > 0) { + const pid = queue.shift(); + if (!pid) { + continue; + } + + descendants.push(pid); + for (const childPid of childrenByParent.get(pid) ?? []) { + queue.push(childPid); + } + } + return descendants; +} + +async function runCommand(command: string, args: readonly string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + proc.stdout.on('data', (data: string) => { + stdout += data; + }); + proc.stderr.on('data', (data: string) => { + stderr += data; + }); + proc.on('error', (error) => { + reject(error); + }); + proc.on('close', (status) => { + if (status === 0) { + resolve({ stdout, stderr }); + } else { + reject(new CommandExecutionError(command, args, status, stderr)); + } + }); + }); +} + +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 isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + +class CommandExecutionError extends Error { + readonly status: number | null; + readonly stderr: string; + + constructor(command: string, args: readonly string[], status: number | null, stderr: string) { + super(`Command failed: ${command} ${args.join(' ')}`); + this.name = 'CommandExecutionError'; + this.status = status; + this.stderr = stderr; + } +} 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..732d1569 --- /dev/null +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -0,0 +1,135 @@ +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 { treeKill } from '../src/treeKill.js'; + +type ChildProcessWithPipeOut = ChildProcessByStdio; + +describe('treeKill', () => { + it('kills parent and descendant processes', async () => { + const { childPid, parent } = await spawnProcessTree(); + expect(parent.pid).toBeDefined(); + expect(isProcessRunning(parent.pid as number)).toBe(true); + expect(isProcessRunning(childPid)).toBe(true); + + await treeKill(parent.pid as number); + + await Promise.all([ + waitForProcessStopped(parent.pid as number, 10_000), + waitForProcessStopped(childPid, 10_000), + waitForClose(parent, 10_000), + ]); + }); + + it('does not throw when process is already gone', async () => { + await expect(treeKill(999_999_999)).resolves.toBeUndefined(); + }); +}); + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (isErrnoException(error) && error.code === 'ESRCH') { + return false; + } + throw error; + } +} + +async function spawnProcessTree(): Promise<{ parent: ChildProcessWithPipeOut; childPid: number }> { + const parent = spawn( + process.execPath, + [ + '-e', + [ + "const { spawn } = require('node:child_process');", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", + 'console.log(child.pid);', + 'setInterval(() => {}, 1000);', + ].join(''), + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + parent.stdout.setEncoding('utf8'); + parent.stderr.setEncoding('utf8'); + const childPid = await readChildPid(parent, 10_000); + return { parent, childPid }; +} + +async function readChildPid(parent: ChildProcessWithPipeOut, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out while waiting child pid. stdout=${stdout}, stderr=${stderr}`)); + }, timeoutMs); + const onStdout = (chunk: string): void => { + stdout += chunk; + const matched = /^\s*(\d+)\s*$/m.exec(stdout); + if (!matched) { + return; + } + + cleanup(); + resolve(Number(matched[1])); + }; + const onStderr = (chunk: string): void => { + stderr += chunk; + }; + const onClose = (): void => { + cleanup(); + reject(new Error(`Parent process exited before printing child pid. stdout=${stdout}, stderr=${stderr}`)); + }; + const cleanup = (): void => { + clearTimeout(timer); + parent.stdout.off('data', onStdout); + parent.stderr.off('data', onStderr); + parent.off('close', onClose); + }; + + parent.stdout.on('data', onStdout); + parent.stderr.on('data', onStderr); + parent.on('close', onClose); + }); +} + +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`); +} + +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); + }); +} + +async function wait(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} 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..8833936e --- /dev/null +++ b/packages/wb/src/commands/treeKill.ts @@ -0,0 +1,20 @@ +import { treeKill } from '@willbooster/shared-lib-node/src'; +import type { CommandModule } from 'yargs'; + +const builder = { + signal: { + description: 'Signal to send to the process tree.', + type: 'string', + default: 'SIGTERM', + }, +} as const; + +export const treeKillCommand: CommandModule = { + command: 'tree-kill [signal]', + describe: 'Kill the given process and all descendants', + builder, + async handler(argv) { + const signal = (argv.signal as NodeJS.Signals | undefined) ?? 'SIGTERM'; + await treeKill(Number(argv.pid), signal); + }, +}; diff --git a/packages/wb/src/index.ts b/packages/wb/src/index.ts index a33c02b8..27aeffe7 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() @@ -61,7 +62,7 @@ function getVersion(): string { for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) { process.on(signal, () => { - treeKill(process.pid); + void treeKill(process.pid); process.exit(); }); } diff --git a/packages/wb/test/treeKill.test.ts b/packages/wb/test/treeKill.test.ts new file mode 100644 index 00000000..7daecc78 --- /dev/null +++ b/packages/wb/test/treeKill.test.ts @@ -0,0 +1,134 @@ +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 { treeKillCommand } from '../src/commands/treeKill.js'; + +type ChildProcessWithPipeOut = ChildProcessByStdio; + +describe('tree-kill command', () => { + it('kills target process tree', async () => { + const { childPid, parent } = await spawnProcessTree(); + expect(parent.pid).toBeDefined(); + expect(isProcessRunning(parent.pid as number)).toBe(true); + expect(isProcessRunning(childPid)).toBe(true); + + await treeKillCommand.handler({ + pid: parent.pid, + signal: 'SIGTERM', + } as never); + + await Promise.all([ + waitForProcessStopped(parent.pid as number, 10_000), + waitForProcessStopped(childPid, 10_000), + waitForClose(parent, 10_000), + ]); + }); +}); + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (isErrnoException(error) && error.code === 'ESRCH') { + return false; + } + throw error; + } +} + +async function spawnProcessTree(): Promise<{ parent: ChildProcessWithPipeOut; childPid: number }> { + const parent = spawn( + process.execPath, + [ + '-e', + [ + "const { spawn } = require('node:child_process');", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", + 'console.log(child.pid);', + 'setInterval(() => {}, 1000);', + ].join(''), + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + parent.stdout.setEncoding('utf8'); + parent.stderr.setEncoding('utf8'); + const childPid = await readChildPid(parent, 10_000); + return { parent, childPid }; +} + +async function readChildPid(parent: ChildProcessWithPipeOut, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out while waiting child pid. stdout=${stdout}, stderr=${stderr}`)); + }, timeoutMs); + const onStdout = (chunk: string): void => { + stdout += chunk; + const matched = /^\s*(\d+)\s*$/m.exec(stdout); + if (!matched) { + return; + } + + cleanup(); + resolve(Number(matched[1])); + }; + const onStderr = (chunk: string): void => { + stderr += chunk; + }; + const onClose = (): void => { + cleanup(); + reject(new Error(`Parent process exited before printing child pid. stdout=${stdout}, stderr=${stderr}`)); + }; + const cleanup = (): void => { + clearTimeout(timer); + parent.stdout.off('data', onStdout); + parent.stderr.off('data', onStderr); + parent.off('close', onClose); + }; + + parent.stdout.on('data', onStdout); + parent.stderr.on('data', onStderr); + parent.on('close', onClose); + }); +} + +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`); +} + +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); + }); +} + +async function wait(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} 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" From 35e63d8ab043631d066b248243640d549c3b51db Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 19:11:48 +0900 Subject: [PATCH 02/18] test: increase treeKill integration test timeout Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/test/treeKill.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts index 732d1569..ed484d0c 100644 --- a/packages/shared-lib-node/test/treeKill.test.ts +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -22,7 +22,7 @@ describe('treeKill', () => { waitForProcessStopped(childPid, 10_000), waitForClose(parent, 10_000), ]); - }); + }, 30_000); it('does not throw when process is already gone', async () => { await expect(treeKill(999_999_999)).resolves.toBeUndefined(); From 6943a1b394112e80538752d9867f2c9d7648b98e Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 19:18:06 +0900 Subject: [PATCH 03/18] test: stabilize tree-kill process-tree tests Co-authored-by: WillBooster (Codex CLI) --- .../shared-lib-node/test/treeKill.test.ts | 95 ++++++++++--------- packages/wb/test/treeKill.test.ts | 95 ++++++++++--------- 2 files changed, 100 insertions(+), 90 deletions(-) diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts index ed484d0c..d8c43d22 100644 --- a/packages/shared-lib-node/test/treeKill.test.ts +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -1,5 +1,5 @@ import type { ChildProcessByStdio } from 'node:child_process'; -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import type { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; @@ -10,8 +10,9 @@ type ChildProcessWithPipeOut = ChildProcessByStdio; describe('treeKill', () => { it('kills parent and descendant processes', async () => { - const { childPid, parent } = await spawnProcessTree(); + const parent = spawnProcessTree(); expect(parent.pid).toBeDefined(); + const childPid = await waitForDescendantPid(parent.pid as number, 10_000); expect(isProcessRunning(parent.pid as number)).toBe(true); expect(isProcessRunning(childPid)).toBe(true); @@ -41,62 +42,31 @@ function isProcessRunning(pid: number): boolean { } } -async function spawnProcessTree(): Promise<{ parent: ChildProcessWithPipeOut; childPid: number }> { - const parent = spawn( +function spawnProcessTree(): ChildProcessWithPipeOut { + return spawn( process.execPath, [ '-e', [ "const { spawn } = require('node:child_process');", "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", - 'console.log(child.pid);', 'setInterval(() => {}, 1000);', ].join(''), ], { stdio: ['ignore', 'pipe', 'pipe'] } ); - parent.stdout.setEncoding('utf8'); - parent.stderr.setEncoding('utf8'); - const childPid = await readChildPid(parent, 10_000); - return { parent, childPid }; } -async function readChildPid(parent: ChildProcessWithPipeOut, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`Timed out while waiting child pid. stdout=${stdout}, stderr=${stderr}`)); - }, timeoutMs); - const onStdout = (chunk: string): void => { - stdout += chunk; - const matched = /^\s*(\d+)\s*$/m.exec(stdout); - if (!matched) { - return; - } - - cleanup(); - resolve(Number(matched[1])); - }; - const onStderr = (chunk: string): void => { - stderr += chunk; - }; - const onClose = (): void => { - cleanup(); - reject(new Error(`Parent process exited before printing child pid. stdout=${stdout}, stderr=${stderr}`)); - }; - const cleanup = (): void => { - clearTimeout(timer); - parent.stdout.off('data', onStdout); - parent.stderr.off('data', onStderr); - parent.off('close', onClose); - }; - - parent.stdout.on('data', onStdout); - parent.stderr.on('data', onStderr); - parent.on('close', onClose); - }); +async function waitForDescendantPid(pid: number, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const descendants = listDescendantPids(pid); + if (descendants[0]) { + return descendants[0]; + } + await wait(100); + } + throw new Error(`Timed out while waiting descendant process for ${pid}`); } async function waitForProcessStopped(pid: number, timeoutMs: number): Promise { @@ -130,6 +100,41 @@ async function wait(ms: number): Promise { }); } +function listDescendantPids(rootPid: number): number[] { + const result = spawnSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8' }); + const childrenByParent = new Map(); + for (const line of result.stdout.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]); + } + } + + const descendants: number[] = []; + const queue = [...(childrenByParent.get(rootPid) ?? [])]; + while (queue.length > 0) { + const pid = queue.shift(); + if (!pid) { + continue; + } + + descendants.push(pid); + for (const childPid of childrenByParent.get(pid) ?? []) { + queue.push(childPid); + } + } + return descendants; +} + function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return typeof error === 'object' && error !== null && 'code' in error; } diff --git a/packages/wb/test/treeKill.test.ts b/packages/wb/test/treeKill.test.ts index 7daecc78..44ba3c3e 100644 --- a/packages/wb/test/treeKill.test.ts +++ b/packages/wb/test/treeKill.test.ts @@ -1,5 +1,5 @@ import type { ChildProcessByStdio } from 'node:child_process'; -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import type { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; @@ -10,8 +10,9 @@ type ChildProcessWithPipeOut = ChildProcessByStdio; describe('tree-kill command', () => { it('kills target process tree', async () => { - const { childPid, parent } = await spawnProcessTree(); + const parent = spawnProcessTree(); expect(parent.pid).toBeDefined(); + const childPid = await waitForDescendantPid(parent.pid as number, 10_000); expect(isProcessRunning(parent.pid as number)).toBe(true); expect(isProcessRunning(childPid)).toBe(true); @@ -40,62 +41,31 @@ function isProcessRunning(pid: number): boolean { } } -async function spawnProcessTree(): Promise<{ parent: ChildProcessWithPipeOut; childPid: number }> { - const parent = spawn( +function spawnProcessTree(): ChildProcessWithPipeOut { + return spawn( process.execPath, [ '-e', [ "const { spawn } = require('node:child_process');", "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", - 'console.log(child.pid);', 'setInterval(() => {}, 1000);', ].join(''), ], { stdio: ['ignore', 'pipe', 'pipe'] } ); - parent.stdout.setEncoding('utf8'); - parent.stderr.setEncoding('utf8'); - const childPid = await readChildPid(parent, 10_000); - return { parent, childPid }; } -async function readChildPid(parent: ChildProcessWithPipeOut, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`Timed out while waiting child pid. stdout=${stdout}, stderr=${stderr}`)); - }, timeoutMs); - const onStdout = (chunk: string): void => { - stdout += chunk; - const matched = /^\s*(\d+)\s*$/m.exec(stdout); - if (!matched) { - return; - } - - cleanup(); - resolve(Number(matched[1])); - }; - const onStderr = (chunk: string): void => { - stderr += chunk; - }; - const onClose = (): void => { - cleanup(); - reject(new Error(`Parent process exited before printing child pid. stdout=${stdout}, stderr=${stderr}`)); - }; - const cleanup = (): void => { - clearTimeout(timer); - parent.stdout.off('data', onStdout); - parent.stderr.off('data', onStderr); - parent.off('close', onClose); - }; - - parent.stdout.on('data', onStdout); - parent.stderr.on('data', onStderr); - parent.on('close', onClose); - }); +async function waitForDescendantPid(pid: number, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const descendants = listDescendantPids(pid); + if (descendants[0]) { + return descendants[0]; + } + await wait(100); + } + throw new Error(`Timed out while waiting descendant process for ${pid}`); } async function waitForProcessStopped(pid: number, timeoutMs: number): Promise { @@ -129,6 +99,41 @@ async function wait(ms: number): Promise { }); } +function listDescendantPids(rootPid: number): number[] { + const result = spawnSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8' }); + const childrenByParent = new Map(); + for (const line of result.stdout.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]); + } + } + + const descendants: number[] = []; + const queue = [...(childrenByParent.get(rootPid) ?? [])]; + while (queue.length > 0) { + const pid = queue.shift(); + if (!pid) { + continue; + } + + descendants.push(pid); + for (const childPid of childrenByParent.get(pid) ?? []) { + queue.push(childPid); + } + } + return descendants; +} + function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return typeof error === 'object' && error !== null && 'code' in error; } From a35c1ed52915ef5b4c1ce3c8e88c5ba880f8d5ca Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 19:24:20 +0900 Subject: [PATCH 04/18] fix: address tree-kill review comments Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/spawn.ts | 4 +++- packages/shared-lib-node/src/treeKill.ts | 15 ++++++--------- packages/shared-lib-node/test/treeKill.test.ts | 15 ++++++++++----- packages/wb/src/index.ts | 4 +++- packages/wb/test/treeKill.test.ts | 15 ++++++++++----- 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/shared-lib-node/src/spawn.ts b/packages/shared-lib-node/src/spawn.ts index 8db7aefc..3aaf9c65 100644 --- a/packages/shared-lib-node/src/spawn.ts +++ b/packages/shared-lib-node/src/spawn.ts @@ -108,7 +108,9 @@ export async function spawnAsync( console.info(`treeKill(${proc.pid})`); } void treeKill(proc.pid).catch(() => { - // do nothing + if (options?.verbose) { + console.warn(`Failed to treeKill(${proc.pid})`); + } }); }; if (options?.killOnExit) { diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index c047faea..53c63322 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -50,22 +50,19 @@ async function collectDescendantPids(rootPid: number): Promise { const childPid = Number(matched[1]); const parentPid = Number(matched[2]); - const children = childrenByParent.get(parentPid); - if (children) { - children.push(childPid); - } else { + if (!childrenByParent.has(parentPid)) { childrenByParent.set(parentPid, [childPid]); + continue; } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + childrenByParent.get(parentPid)!.push(childPid); } const descendants: number[] = []; const queue = [...(childrenByParent.get(rootPid) ?? [])]; while (queue.length > 0) { - const pid = queue.shift(); - if (!pid) { - continue; - } - + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const pid = queue.shift()!; descendants.push(pid); for (const childPid of childrenByParent.get(pid) ?? []) { queue.push(childPid); diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts index d8c43d22..0ccf1d7d 100644 --- a/packages/shared-lib-node/test/treeKill.test.ts +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -11,15 +11,20 @@ type ChildProcessWithPipeOut = ChildProcessByStdio; describe('treeKill', () => { it('kills parent and descendant processes', async () => { const parent = spawnProcessTree(); - expect(parent.pid).toBeDefined(); - const childPid = await waitForDescendantPid(parent.pid as number, 10_000); - expect(isProcessRunning(parent.pid as number)).toBe(true); + const { pid: parentPid } = parent; + expect(parentPid).toBeDefined(); + if (!parentPid) { + throw new Error('parent.pid is undefined'); + } + + const childPid = await waitForDescendantPid(parentPid, 10_000); + expect(isProcessRunning(parentPid)).toBe(true); expect(isProcessRunning(childPid)).toBe(true); - await treeKill(parent.pid as number); + await treeKill(parentPid); await Promise.all([ - waitForProcessStopped(parent.pid as number, 10_000), + waitForProcessStopped(parentPid, 10_000), waitForProcessStopped(childPid, 10_000), waitForClose(parent, 10_000), ]); diff --git a/packages/wb/src/index.ts b/packages/wb/src/index.ts index 27aeffe7..4c8a1e80 100644 --- a/packages/wb/src/index.ts +++ b/packages/wb/src/index.ts @@ -62,7 +62,9 @@ function getVersion(): string { for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) { process.on(signal, () => { - void treeKill(process.pid); + void treeKill(process.pid).catch(() => { + // do nothing + }); process.exit(); }); } diff --git a/packages/wb/test/treeKill.test.ts b/packages/wb/test/treeKill.test.ts index 44ba3c3e..ccd26875 100644 --- a/packages/wb/test/treeKill.test.ts +++ b/packages/wb/test/treeKill.test.ts @@ -11,18 +11,23 @@ type ChildProcessWithPipeOut = ChildProcessByStdio; describe('tree-kill command', () => { it('kills target process tree', async () => { const parent = spawnProcessTree(); - expect(parent.pid).toBeDefined(); - const childPid = await waitForDescendantPid(parent.pid as number, 10_000); - expect(isProcessRunning(parent.pid as number)).toBe(true); + const { pid: parentPid } = parent; + expect(parentPid).toBeDefined(); + if (!parentPid) { + throw new Error('parent.pid is undefined'); + } + + const childPid = await waitForDescendantPid(parentPid, 10_000); + expect(isProcessRunning(parentPid)).toBe(true); expect(isProcessRunning(childPid)).toBe(true); await treeKillCommand.handler({ - pid: parent.pid, + pid: parentPid, signal: 'SIGTERM', } as never); await Promise.all([ - waitForProcessStopped(parent.pid as number, 10_000), + waitForProcessStopped(parentPid, 10_000), waitForProcessStopped(childPid, 10_000), waitForClose(parent, 10_000), ]); From feec057f234c238884ab019c30c1017dd6c8cedf Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 21:12:42 +0900 Subject: [PATCH 05/18] test: strengthen tree-kill integration coverage Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 65 +++++----- .../shared-lib-node/test/treeKill.test.ts | 114 +++++++++++++----- packages/wb/test/treeKill.test.ts | 108 ++--------------- 3 files changed, 135 insertions(+), 152 deletions(-) diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 53c63322..b308ab3b 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { execFile } from 'node:child_process'; export async function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): Promise { if (!Number.isInteger(pid) || pid <= 0) { @@ -30,7 +30,10 @@ function killIfNeeded(pid: number, signal: NodeJS.Signals): void { async function killTreeOnWindows(pid: number): Promise { try { - await runCommand('taskkill', ['/PID', String(pid), '/T', '/F']); + await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], { + maxBuffer: 1024 * 1024, + timeout: 2000, + }); } catch (error) { if (isNoSuchProcessError(error)) { return; @@ -40,7 +43,12 @@ async function killTreeOnWindows(pid: number): Promise { } async function collectDescendantPids(rootPid: number): Promise { - const { stdout } = await runCommand('ps', ['-Ao', 'pid=,ppid=']); + const { stdout } = await runCommand( + 'ps', + ['-Ao', 'pid=,ppid='], + // Keep command bounded so watch-mode kill loops cannot hang this path. + { maxBuffer: 1024 * 1024, timeout: 2000 } + ); const childrenByParent = new Map(); for (const line of stdout.split('\n')) { const matched = /^\s*(\d+)\s+(\d+)\s*$/.exec(line); @@ -71,32 +79,24 @@ async function collectDescendantPids(rootPid: number): Promise { return descendants; } -async function runCommand(command: string, args: readonly string[]): Promise<{ stdout: string; stderr: string }> { +async function runCommand( + command: string, + args: readonly string[], + options?: { timeout: number; maxBuffer: number } +): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { - const proc = spawn(command, args, { - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - let stderr = ''; - proc.stdout.setEncoding('utf8'); - proc.stderr.setEncoding('utf8'); - proc.stdout.on('data', (data: string) => { - stdout += data; - }); - proc.stderr.on('data', (data: string) => { - stderr += data; - }); - proc.on('error', (error) => { - reject(error); - }); - proc.on('close', (status) => { - if (status === 0) { + execFile( + command, + [...args], + { encoding: 'utf8', maxBuffer: options?.maxBuffer, timeout: options?.timeout }, + (error, stdout, stderr) => { + if (error) { + reject(new CommandExecutionError(command, args, stderr, toExitCode(error))); + return; + } resolve({ stdout, stderr }); - } else { - reject(new CommandExecutionError(command, args, status, stderr)); } - }); + ); }); } @@ -111,18 +111,25 @@ function isNoSuchProcessError(error: unknown): boolean { return false; } +function toExitCode(error: unknown): number | string | undefined { + if (isErrnoException(error)) { + return error.code; + } + return undefined; +} + function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return typeof error === 'object' && error !== null && 'code' in error; } class CommandExecutionError extends Error { - readonly status: number | null; readonly stderr: string; + readonly code: number | string | undefined; - constructor(command: string, args: readonly string[], status: number | null, stderr: string) { + constructor(command: string, args: readonly string[], stderr: string, code: number | string | undefined) { super(`Command failed: ${command} ${args.join(' ')}`); this.name = 'CommandExecutionError'; - this.status = status; this.stderr = stderr; + this.code = code; } } diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts index 0ccf1d7d..2cbfdb7d 100644 --- a/packages/shared-lib-node/test/treeKill.test.ts +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -10,26 +10,84 @@ type ChildProcessWithPipeOut = ChildProcessByStdio; describe('treeKill', () => { it('kills parent and descendant processes', async () => { - const parent = spawnProcessTree(); + const parent = spawnProcessTree(1); const { pid: parentPid } = parent; expect(parentPid).toBeDefined(); if (!parentPid) { throw new Error('parent.pid is undefined'); } - const childPid = await waitForDescendantPid(parentPid, 10_000); + const descendantPids = await waitForDescendantPidsCount(parentPid, 1, 10_000); expect(isProcessRunning(parentPid)).toBe(true); - expect(isProcessRunning(childPid)).toBe(true); + for (const pid of descendantPids) { + expect(isProcessRunning(pid)).toBe(true); + } + + await 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); await treeKill(parentPid); await Promise.all([ waitForProcessStopped(parentPid, 10_000), - waitForProcessStopped(childPid, 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); + await 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); + await 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', async () => { await expect(treeKill(999_999_999)).resolves.toBeUndefined(); }); @@ -47,31 +105,36 @@ function isProcessRunning(pid: number): boolean { } } -function spawnProcessTree(): ChildProcessWithPipeOut { - return spawn( - process.execPath, - [ - '-e', - [ - "const { spawn } = require('node:child_process');", - "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", - 'setInterval(() => {}, 1000);', - ].join(''), - ], - { stdio: ['ignore', 'pipe', 'pipe'] } - ); +function spawnProcessTree(depth: number): ChildProcessWithPipeOut { + return spawn(process.execPath, ['-e', createTreeScript(depth)], { stdio: ['ignore', 'pipe', 'pipe'] }); } -async function waitForDescendantPid(pid: number, timeoutMs: number): Promise { +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; +} + +async function waitForDescendantPidsCount( + parentPid: number, + minimumCount: number, + timeoutMs: number +): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - const descendants = listDescendantPids(pid); - if (descendants[0]) { - return descendants[0]; + const descendants = listDescendantPids(parentPid); + if (descendants.length >= minimumCount) { + return descendants; } await wait(100); } - throw new Error(`Timed out while waiting descendant process for ${pid}`); + throw new Error(`Timed out while waiting descendant processes for ${parentPid}`); } async function waitForProcessStopped(pid: number, timeoutMs: number): Promise { @@ -127,11 +190,8 @@ function listDescendantPids(rootPid: number): number[] { const descendants: number[] = []; const queue = [...(childrenByParent.get(rootPid) ?? [])]; while (queue.length > 0) { - const pid = queue.shift(); - if (!pid) { - continue; - } - + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const pid = queue.shift()!; descendants.push(pid); for (const childPid of childrenByParent.get(pid) ?? []) { queue.push(childPid); diff --git a/packages/wb/test/treeKill.test.ts b/packages/wb/test/treeKill.test.ts index ccd26875..b28f1201 100644 --- a/packages/wb/test/treeKill.test.ts +++ b/packages/wb/test/treeKill.test.ts @@ -1,36 +1,28 @@ -import type { ChildProcessByStdio } from 'node:child_process'; -import { spawn, spawnSync } from 'node:child_process'; -import type { Readable } from 'node:stream'; +import { spawn } from 'node:child_process'; import { describe, expect, it } from 'vitest'; import { treeKillCommand } from '../src/commands/treeKill.js'; -type ChildProcessWithPipeOut = ChildProcessByStdio; - describe('tree-kill command', () => { - it('kills target process tree', async () => { - const parent = spawnProcessTree(); - const { pid: parentPid } = parent; - expect(parentPid).toBeDefined(); - if (!parentPid) { - throw new Error('parent.pid is undefined'); + 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'); } - const childPid = await waitForDescendantPid(parentPid, 10_000); - expect(isProcessRunning(parentPid)).toBe(true); - expect(isProcessRunning(childPid)).toBe(true); + expect(isProcessRunning(pid)).toBe(true); await treeKillCommand.handler({ - pid: parentPid, + pid, signal: 'SIGTERM', } as never); - await Promise.all([ - waitForProcessStopped(parentPid, 10_000), - waitForProcessStopped(childPid, 10_000), - waitForClose(parent, 10_000), - ]); + await waitForProcessStopped(pid, 10_000); }); }); @@ -46,33 +38,6 @@ function isProcessRunning(pid: number): boolean { } } -function spawnProcessTree(): ChildProcessWithPipeOut { - return spawn( - process.execPath, - [ - '-e', - [ - "const { spawn } = require('node:child_process');", - "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", - 'setInterval(() => {}, 1000);', - ].join(''), - ], - { stdio: ['ignore', 'pipe', 'pipe'] } - ); -} - -async function waitForDescendantPid(pid: number, timeoutMs: number): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const descendants = listDescendantPids(pid); - if (descendants[0]) { - return descendants[0]; - } - await wait(100); - } - throw new Error(`Timed out while waiting descendant process for ${pid}`); -} - async function waitForProcessStopped(pid: number, timeoutMs: number): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -84,61 +49,12 @@ async function waitForProcessStopped(pid: number, 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); - }); -} - async function wait(ms: number): Promise { await new Promise((resolve) => { setTimeout(resolve, ms); }); } -function listDescendantPids(rootPid: number): number[] { - const result = spawnSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8' }); - const childrenByParent = new Map(); - for (const line of result.stdout.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]); - } - } - - const descendants: number[] = []; - const queue = [...(childrenByParent.get(rootPid) ?? [])]; - while (queue.length > 0) { - const pid = queue.shift(); - if (!pid) { - continue; - } - - descendants.push(pid); - for (const childPid of childrenByParent.get(pid) ?? []) { - queue.push(childPid); - } - } - return descendants; -} - function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return typeof error === 'object' && error !== null && 'code' in error; } From 6067e07a8ac1963485c8627677875b3d6a3b4d54 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 21:19:05 +0900 Subject: [PATCH 06/18] fix: resolve remaining tree-kill review feedback Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 8 ++++---- packages/wb/src/index.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index b308ab3b..22fb609a 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -58,12 +58,12 @@ async function collectDescendantPids(rootPid: number): Promise { const childPid = Number(matched[1]); const parentPid = Number(matched[2]); - if (!childrenByParent.has(parentPid)) { + const children = childrenByParent.get(parentPid); + if (children) { + children.push(childPid); + } else { childrenByParent.set(parentPid, [childPid]); - continue; } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - childrenByParent.get(parentPid)!.push(childPid); } const descendants: number[] = []; diff --git a/packages/wb/src/index.ts b/packages/wb/src/index.ts index 4c8a1e80..1f5c7f55 100644 --- a/packages/wb/src/index.ts +++ b/packages/wb/src/index.ts @@ -60,9 +60,13 @@ function getVersion(): string { return packageJson.version; } +let shuttingDown = false; for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) { - process.on(signal, () => { - void treeKill(process.pid).catch(() => { + process.on(signal, async () => { + if (shuttingDown) return; + + shuttingDown = true; + await treeKill(process.pid).catch(() => { // do nothing }); process.exit(); From 569b65aa28e71383aa15c03e555d217dab9aa9e6 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 21:31:18 +0900 Subject: [PATCH 07/18] refactor: resolve review feedback on tree-kill tests Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 6 +- .../shared-lib-node/test/treeKill.test.ts | 68 +------------------ packages/wb/src/index.ts | 4 +- packages/wb/test/treeKill.test.ts | 34 +--------- test/processUtils.ts | 66 ++++++++++++++++++ 5 files changed, 74 insertions(+), 104 deletions(-) create mode 100644 test/processUtils.ts diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 22fb609a..2c2e165e 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -68,13 +68,13 @@ async function collectDescendantPids(rootPid: number): Promise { const descendants: number[] = []; const queue = [...(childrenByParent.get(rootPid) ?? [])]; - while (queue.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const pid = queue.shift()!; + let pid = queue.shift(); + while (pid !== undefined) { descendants.push(pid); for (const childPid of childrenByParent.get(pid) ?? []) { queue.push(childPid); } + pid = queue.shift(); } return descendants; } diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts index 2cbfdb7d..d9c823fb 100644 --- a/packages/shared-lib-node/test/treeKill.test.ts +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -1,9 +1,10 @@ import type { ChildProcessByStdio } from 'node:child_process'; -import { spawn, spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import type { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; +import { isProcessRunning, listDescendantPids, wait, waitForProcessStopped } from '../../../test/processUtils.js'; import { treeKill } from '../src/treeKill.js'; type ChildProcessWithPipeOut = ChildProcessByStdio; @@ -93,18 +94,6 @@ describe('treeKill', () => { }); }); -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (isErrnoException(error) && error.code === 'ESRCH') { - return false; - } - throw error; - } -} - function spawnProcessTree(depth: number): ChildProcessWithPipeOut { return spawn(process.execPath, ['-e', createTreeScript(depth)], { stdio: ['ignore', 'pipe', 'pipe'] }); } @@ -137,17 +126,6 @@ async function waitForDescendantPidsCount( throw new Error(`Timed out while waiting descendant processes for ${parentPid}`); } -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`); -} - async function waitForClose(proc: ChildProcessWithPipeOut, timeoutMs: number): Promise { await new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -161,45 +139,3 @@ async function waitForClose(proc: ChildProcessWithPipeOut, timeoutMs: number): P proc.once('close', onClose); }); } - -async function wait(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function listDescendantPids(rootPid: number): number[] { - const result = spawnSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8' }); - const childrenByParent = new Map(); - for (const line of result.stdout.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]); - } - } - - const descendants: number[] = []; - const queue = [...(childrenByParent.get(rootPid) ?? [])]; - while (queue.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const pid = queue.shift()!; - descendants.push(pid); - for (const childPid of childrenByParent.get(pid) ?? []) { - queue.push(childPid); - } - } - return descendants; -} - -function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return typeof error === 'object' && error !== null && 'code' in error; -} diff --git a/packages/wb/src/index.ts b/packages/wb/src/index.ts index 1f5c7f55..8084e6f0 100644 --- a/packages/wb/src/index.ts +++ b/packages/wb/src/index.ts @@ -66,8 +66,8 @@ for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) { if (shuttingDown) return; shuttingDown = true; - await treeKill(process.pid).catch(() => { - // do nothing + await treeKill(process.pid).catch((error: unknown) => { + 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 index b28f1201..fee53345 100644 --- a/packages/wb/test/treeKill.test.ts +++ b/packages/wb/test/treeKill.test.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { describe, expect, it } from 'vitest'; +import { isProcessRunning, waitForProcessStopped } from '../../../test/processUtils.js'; import { treeKillCommand } from '../src/commands/treeKill.js'; describe('tree-kill command', () => { @@ -25,36 +26,3 @@ describe('tree-kill command', () => { await waitForProcessStopped(pid, 10_000); }); }); - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (isErrnoException(error) && error.code === 'ESRCH') { - return false; - } - throw error; - } -} - -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`); -} - -async function wait(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return typeof error === 'object' && error !== null && 'code' in error; -} diff --git a/test/processUtils.ts b/test/processUtils.ts new file mode 100644 index 00000000..3b3e2f6e --- /dev/null +++ b/test/processUtils.ts @@ -0,0 +1,66 @@ +import { spawnSync } from 'node:child_process'; + +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 = new Map(); + for (const line of result.stdout.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]); + } + } + + const descendants: number[] = []; + const queue = [...(childrenByParent.get(rootPid) ?? [])]; + let pid = queue.shift(); + while (pid !== undefined) { + descendants.push(pid); + for (const childPid of childrenByParent.get(pid) ?? []) { + queue.push(childPid); + } + pid = queue.shift(); + } + return descendants; +} + +export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} From e1c5c86390e9316faf43547e3d545dc3769f51eb Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 21:36:39 +0900 Subject: [PATCH 08/18] fix: address latest tree-kill review feedback Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/processTree.ts | 38 ++++++++++++ packages/shared-lib-node/src/treeKill.ts | 32 ++-------- .../shared-lib-node/test/processUtils.test.ts | 62 +++++++++++++++++++ packages/wb/src/commands/treeKill.ts | 14 ++++- packages/wb/test/treeKill.test.ts | 18 ++++++ test/processUtils.ts | 32 ++-------- 6 files changed, 138 insertions(+), 58 deletions(-) create mode 100644 packages/shared-lib-node/src/processTree.ts create mode 100644 packages/shared-lib-node/test/processUtils.test.ts diff --git a/packages/shared-lib-node/src/processTree.ts b/packages/shared-lib-node/src/processTree.ts new file mode 100644 index 00000000..86261bd6 --- /dev/null +++ b/packages/shared-lib-node/src/processTree.ts @@ -0,0 +1,38 @@ +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 head = 0; + while (head < queue.length) { + const pid = queue[head]; + head++; + if (pid === undefined) { + continue; + } + + descendants.push(pid); + for (const childPid of childrenByParent.get(pid) ?? []) { + queue.push(childPid); + } + } + return descendants; +} diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 2c2e165e..8268b115 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -1,5 +1,7 @@ import { execFile } from 'node:child_process'; +import { buildChildrenByParentMap, collectDescendantPids as collectDescendantPidsFromMap } from './processTree.js'; + export async function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): Promise { if (!Number.isInteger(pid) || pid <= 0) { throw new Error(`Invalid pid: ${pid}`); @@ -49,34 +51,8 @@ async function collectDescendantPids(rootPid: number): Promise { // Keep command bounded so watch-mode kill loops cannot hang this path. { maxBuffer: 1024 * 1024, timeout: 2000 } ); - const childrenByParent = new Map(); - for (const line of stdout.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]); - } - } - - const descendants: number[] = []; - const queue = [...(childrenByParent.get(rootPid) ?? [])]; - let pid = queue.shift(); - while (pid !== undefined) { - descendants.push(pid); - for (const childPid of childrenByParent.get(pid) ?? []) { - queue.push(childPid); - } - pid = queue.shift(); - } - return descendants; + const childrenByParent = buildChildrenByParentMap(stdout); + return collectDescendantPidsFromMap(rootPid, childrenByParent); } async function runCommand( 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..376d34c6 --- /dev/null +++ b/packages/shared-lib-node/test/processUtils.test.ts @@ -0,0 +1,62 @@ +import { spawn } from 'node:child_process'; + +import { describe, expect, it } from 'vitest'; + +import { isProcessRunning, listDescendantPids, 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); + + await 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); + }); +}); + +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; +} + +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}`); +} + +async function wait(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/wb/src/commands/treeKill.ts b/packages/wb/src/commands/treeKill.ts index 8833936e..eea0f34a 100644 --- a/packages/wb/src/commands/treeKill.ts +++ b/packages/wb/src/commands/treeKill.ts @@ -1,3 +1,5 @@ +import { constants } from 'node:os'; + import { treeKill } from '@willbooster/shared-lib-node/src'; import type { CommandModule } from 'yargs'; @@ -14,7 +16,15 @@ export const treeKillCommand: CommandModule = { describe: 'Kill the given process and all descendants', builder, async handler(argv) { - const signal = (argv.signal as NodeJS.Signals | undefined) ?? 'SIGTERM'; - await treeKill(Number(argv.pid), signal); + try { + const signal = argv.signal as NodeJS.Signals; + if (!(signal in constants.signals)) { + throw new Error(`Invalid signal: ${signal}`); + } + await treeKill(Number(argv.pid), signal); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } }, }; diff --git a/packages/wb/test/treeKill.test.ts b/packages/wb/test/treeKill.test.ts index fee53345..91d18ece 100644 --- a/packages/wb/test/treeKill.test.ts +++ b/packages/wb/test/treeKill.test.ts @@ -25,4 +25,22 @@ describe('tree-kill command', () => { 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'); + } + + await treeKillCommand.handler({ + pid, + signal: 'SIGKILL', + } as never); + + await waitForProcessStopped(pid, 10_000); + }); }); diff --git a/test/processUtils.ts b/test/processUtils.ts index 3b3e2f6e..85abf300 100644 --- a/test/processUtils.ts +++ b/test/processUtils.ts @@ -1,5 +1,7 @@ import { spawnSync } from 'node:child_process'; +import { buildChildrenByParentMap, collectDescendantPids } from '../packages/shared-lib-node/src/processTree.js'; + export function isProcessRunning(pid: number): boolean { try { process.kill(pid, 0); @@ -31,34 +33,8 @@ export async function wait(ms: number): Promise { export function listDescendantPids(rootPid: number): number[] { const result = spawnSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8' }); - const childrenByParent = new Map(); - for (const line of result.stdout.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]); - } - } - - const descendants: number[] = []; - const queue = [...(childrenByParent.get(rootPid) ?? [])]; - let pid = queue.shift(); - while (pid !== undefined) { - descendants.push(pid); - for (const childPid of childrenByParent.get(pid) ?? []) { - queue.push(childPid); - } - pid = queue.shift(); - } - return descendants; + const childrenByParent = buildChildrenByParentMap(result.stdout); + return collectDescendantPids(rootPid, childrenByParent); } export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { From 398216f2862624a766e59529c631384849824b3c Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 21:42:14 +0900 Subject: [PATCH 09/18] refactor: make tree-kill synchronous Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/spawn.ts | 6 +- packages/shared-lib-node/src/treeKill.ts | 61 +++++++++++-------- .../shared-lib-node/test/processUtils.test.ts | 2 +- .../shared-lib-node/test/treeKill.test.ts | 14 +++-- packages/wb/src/commands/treeKill.ts | 4 +- packages/wb/src/index.ts | 8 ++- 6 files changed, 57 insertions(+), 38 deletions(-) diff --git a/packages/shared-lib-node/src/spawn.ts b/packages/shared-lib-node/src/spawn.ts index 3aaf9c65..75bf009a 100644 --- a/packages/shared-lib-node/src/spawn.ts +++ b/packages/shared-lib-node/src/spawn.ts @@ -107,11 +107,13 @@ export async function spawnAsync( if (options?.verbose) { console.info(`treeKill(${proc.pid})`); } - void treeKill(proc.pid).catch(() => { + try { + treeKill(proc.pid); + } catch { if (options?.verbose) { console.warn(`Failed to treeKill(${proc.pid})`); } - }); + } }; 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 index 8268b115..532e25d8 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -1,18 +1,18 @@ -import { execFile } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { buildChildrenByParentMap, collectDescendantPids as collectDescendantPidsFromMap } from './processTree.js'; -export async function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): Promise { +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') { - await killTreeOnWindows(pid); + killTreeOnWindows(pid); return; } - const descendants = await collectDescendantPids(pid); + const descendants = collectDescendantPids(pid); const targetPids = [...descendants, pid].toReversed(); for (const targetPid of targetPids) { killIfNeeded(targetPid, signal); @@ -30,9 +30,9 @@ function killIfNeeded(pid: number, signal: NodeJS.Signals): void { } } -async function killTreeOnWindows(pid: number): Promise { +function killTreeOnWindows(pid: number): void { try { - await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], { + runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], { maxBuffer: 1024 * 1024, timeout: 2000, }); @@ -44,8 +44,8 @@ async function killTreeOnWindows(pid: number): Promise { } } -async function collectDescendantPids(rootPid: number): Promise { - const { stdout } = await runCommand( +function collectDescendantPids(rootPid: number): number[] { + const { stdout } = runCommand( 'ps', ['-Ao', 'pid=,ppid='], // Keep command bounded so watch-mode kill loops cannot hang this path. @@ -55,25 +55,23 @@ async function collectDescendantPids(rootPid: number): Promise { return collectDescendantPidsFromMap(rootPid, childrenByParent); } -async function runCommand( +function runCommand( command: string, args: readonly string[], options?: { timeout: number; maxBuffer: number } -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - execFile( - command, - [...args], - { encoding: 'utf8', maxBuffer: options?.maxBuffer, timeout: options?.timeout }, - (error, stdout, stderr) => { - if (error) { - reject(new CommandExecutionError(command, args, stderr, toExitCode(error))); - return; - } - resolve({ stdout, stderr }); - } - ); - }); +): { 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 { @@ -94,6 +92,21 @@ function toExitCode(error: unknown): number | string | undefined { 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 ''; +} + function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return typeof error === 'object' && error !== null && 'code' in error; } diff --git a/packages/shared-lib-node/test/processUtils.test.ts b/packages/shared-lib-node/test/processUtils.test.ts index 376d34c6..9ad9244b 100644 --- a/packages/shared-lib-node/test/processUtils.test.ts +++ b/packages/shared-lib-node/test/processUtils.test.ts @@ -17,7 +17,7 @@ describe('processUtils', () => { const descendants = await waitForDescendants(parentPid, 2, 10_000); expect(descendants.length).toBeGreaterThanOrEqual(2); - await treeKill(parentPid, 'SIGKILL'); + treeKill(parentPid, 'SIGKILL'); await waitForProcessStopped(parentPid, 10_000); for (const pid of descendants) { await waitForProcessStopped(pid, 10_000); diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts index d9c823fb..187c8dd3 100644 --- a/packages/shared-lib-node/test/treeKill.test.ts +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -24,7 +24,7 @@ describe('treeKill', () => { expect(isProcessRunning(pid)).toBe(true); } - await treeKill(parentPid); + treeKill(parentPid); await Promise.all([ waitForProcessStopped(parentPid, 10_000), @@ -42,7 +42,7 @@ describe('treeKill', () => { } const descendantPids = await waitForDescendantPidsCount(parentPid, 2, 10_000); - await treeKill(parentPid); + treeKill(parentPid); await Promise.all([ waitForProcessStopped(parentPid, 10_000), @@ -60,7 +60,7 @@ describe('treeKill', () => { } const descendantPids = await waitForDescendantPidsCount(parentPid, 1, 10_000); - await treeKill(parentPid, 'SIGKILL'); + treeKill(parentPid, 'SIGKILL'); await Promise.all([ waitForProcessStopped(parentPid, 10_000), @@ -79,7 +79,7 @@ describe('treeKill', () => { } const descendantPids = await waitForDescendantPidsCount(parentPid, 2, 10_000); - await treeKill(parentPid); + treeKill(parentPid); await Promise.all([ waitForProcessStopped(parentPid, 10_000), @@ -89,8 +89,10 @@ describe('treeKill', () => { } }, 60_000); - it('does not throw when process is already gone', async () => { - await expect(treeKill(999_999_999)).resolves.toBeUndefined(); + it('does not throw when process is already gone', () => { + expect(() => { + treeKill(999_999_999); + }).not.toThrow(); }); }); diff --git a/packages/wb/src/commands/treeKill.ts b/packages/wb/src/commands/treeKill.ts index eea0f34a..b4c86337 100644 --- a/packages/wb/src/commands/treeKill.ts +++ b/packages/wb/src/commands/treeKill.ts @@ -15,13 +15,13 @@ export const treeKillCommand: CommandModule = { command: 'tree-kill [signal]', describe: 'Kill the given process and all descendants', builder, - async handler(argv) { + handler(argv) { try { const signal = argv.signal as NodeJS.Signals; if (!(signal in constants.signals)) { throw new Error(`Invalid signal: ${signal}`); } - await treeKill(Number(argv.pid), signal); + treeKill(Number(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 8084e6f0..09364c3c 100644 --- a/packages/wb/src/index.ts +++ b/packages/wb/src/index.ts @@ -62,13 +62,15 @@ function getVersion(): string { let shuttingDown = false; for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT']) { - process.on(signal, async () => { + process.on(signal, () => { if (shuttingDown) return; shuttingDown = true; - await treeKill(process.pid).catch((error: unknown) => { + try { + treeKill(process.pid); + } catch (error) { console.warn(`Failed to treeKill(${process.pid}) during shutdown:`, error); - }); + } process.exit(); }); } From abd14d55bf90d9349420e0e3f1c522fe5393f30f Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 22:13:07 +0900 Subject: [PATCH 10/18] refactor: resolve tree-kill review comments Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/errno.ts | 3 +++ packages/shared-lib-node/src/processTree.ts | 9 +------ packages/shared-lib-node/src/treeKill.ts | 15 +++++++---- .../shared-lib-node/test/processUtils.test.ts | 26 +++++-------------- .../shared-lib-node/test/treeKill.test.ts | 20 +++++--------- test/processUtils.ts | 13 ++++++++-- 6 files changed, 39 insertions(+), 47 deletions(-) create mode 100644 packages/shared-lib-node/src/errno.ts diff --git a/packages/shared-lib-node/src/errno.ts b/packages/shared-lib-node/src/errno.ts new file mode 100644 index 00000000..71cee6bc --- /dev/null +++ b/packages/shared-lib-node/src/errno.ts @@ -0,0 +1,3 @@ +export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} diff --git a/packages/shared-lib-node/src/processTree.ts b/packages/shared-lib-node/src/processTree.ts index 86261bd6..e0fbb446 100644 --- a/packages/shared-lib-node/src/processTree.ts +++ b/packages/shared-lib-node/src/processTree.ts @@ -21,14 +21,7 @@ export function buildChildrenByParentMap(psOutput: string): Map): number[] { const descendants: number[] = []; const queue = [...(childrenByParent.get(rootPid) ?? [])]; - let head = 0; - while (head < queue.length) { - const pid = queue[head]; - head++; - if (pid === undefined) { - continue; - } - + for (const pid of queue) { descendants.push(pid); for (const childPid of childrenByParent.get(pid) ?? []) { queue.push(childPid); diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 532e25d8..adf4b254 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -1,5 +1,6 @@ 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 { @@ -13,7 +14,7 @@ export function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void } const descendants = collectDescendantPids(pid); - const targetPids = [...descendants, pid].toReversed(); + const targetPids = toParentFirstPids(pid, descendants); for (const targetPid of targetPids) { killIfNeeded(targetPid, signal); } @@ -55,6 +56,14 @@ function collectDescendantPids(rootPid: number): number[] { return collectDescendantPidsFromMap(rootPid, childrenByParent); } +function toParentFirstPids(pid: number, descendants: readonly number[]): number[] { + const targetPids = [pid]; + for (const descendantPid of descendants) { + targetPids.splice(1, 0, descendantPid); + } + return targetPids; +} + function runCommand( command: string, args: readonly string[], @@ -107,10 +116,6 @@ function extractStderr(error: unknown): string { return ''; } -function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return typeof error === 'object' && error !== null && 'code' in error; -} - class CommandExecutionError extends Error { readonly stderr: string; readonly code: number | string | undefined; diff --git a/packages/shared-lib-node/test/processUtils.test.ts b/packages/shared-lib-node/test/processUtils.test.ts index 9ad9244b..b371e2d2 100644 --- a/packages/shared-lib-node/test/processUtils.test.ts +++ b/packages/shared-lib-node/test/processUtils.test.ts @@ -2,7 +2,13 @@ import { spawn } from 'node:child_process'; import { describe, expect, it } from 'vitest'; -import { isProcessRunning, listDescendantPids, waitForProcessStopped } from '../../../test/processUtils.js'; +import { + createTreeScript, + isProcessRunning, + listDescendantPids, + wait, + waitForProcessStopped, +} from '../../../test/processUtils.js'; import { treeKill } from '../src/treeKill.js'; describe('processUtils', () => { @@ -31,18 +37,6 @@ describe('processUtils', () => { }); }); -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; -} - async function waitForDescendants(parentPid: number, minimumCount: number, timeoutMs: number): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -54,9 +48,3 @@ async function waitForDescendants(parentPid: number, minimumCount: number, timeo } throw new Error(`Timed out while waiting descendants of ${parentPid}`); } - -async function wait(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} diff --git a/packages/shared-lib-node/test/treeKill.test.ts b/packages/shared-lib-node/test/treeKill.test.ts index 187c8dd3..69fcc06a 100644 --- a/packages/shared-lib-node/test/treeKill.test.ts +++ b/packages/shared-lib-node/test/treeKill.test.ts @@ -4,7 +4,13 @@ import type { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; -import { isProcessRunning, listDescendantPids, wait, waitForProcessStopped } from '../../../test/processUtils.js'; +import { + createTreeScript, + isProcessRunning, + listDescendantPids, + wait, + waitForProcessStopped, +} from '../../../test/processUtils.js'; import { treeKill } from '../src/treeKill.js'; type ChildProcessWithPipeOut = ChildProcessByStdio; @@ -100,18 +106,6 @@ function spawnProcessTree(depth: number): ChildProcessWithPipeOut { return spawn(process.execPath, ['-e', createTreeScript(depth)], { stdio: ['ignore', 'pipe', 'pipe'] }); } -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; -} - async function waitForDescendantPidsCount( parentPid: number, minimumCount: number, diff --git a/test/processUtils.ts b/test/processUtils.ts index 85abf300..d05a70b1 100644 --- a/test/processUtils.ts +++ b/test/processUtils.ts @@ -1,5 +1,6 @@ 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 { @@ -37,6 +38,14 @@ export function listDescendantPids(rootPid: number): number[] { return collectDescendantPids(rootPid, childrenByParent); } -export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return typeof error === 'object' && error !== null && 'code' in error; +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; } From 8a3e0585267d15219a85094492d02a2cf8add915 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 22:23:16 +0900 Subject: [PATCH 11/18] fix: kill descendants before parent in tree-kill Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 14 +++++++---- packages/wb/src/commands/treeKill.ts | 30 ++++++++++++++++-------- packages/wb/test/treeKill.test.ts | 21 ++++++++++------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index adf4b254..2d695f7a 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -14,7 +14,7 @@ export function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void } const descendants = collectDescendantPids(pid); - const targetPids = toParentFirstPids(pid, descendants); + const targetPids = toChildrenFirstPids(pid, descendants); for (const targetPid of targetPids) { killIfNeeded(targetPid, signal); } @@ -56,11 +56,15 @@ function collectDescendantPids(rootPid: number): number[] { return collectDescendantPidsFromMap(rootPid, childrenByParent); } -function toParentFirstPids(pid: number, descendants: readonly number[]): number[] { - const targetPids = [pid]; - for (const descendantPid of descendants) { - targetPids.splice(1, 0, descendantPid); +function toChildrenFirstPids(pid: number, descendants: readonly number[]): number[] { + const targetPids: number[] = []; + for (let index = descendants.length - 1; index >= 0; index--) { + const descendantPid = descendants[index]; + if (descendantPid !== undefined) { + targetPids.push(descendantPid); + } } + targetPids.push(pid); return targetPids; } diff --git a/packages/wb/src/commands/treeKill.ts b/packages/wb/src/commands/treeKill.ts index b4c86337..566f4079 100644 --- a/packages/wb/src/commands/treeKill.ts +++ b/packages/wb/src/commands/treeKill.ts @@ -1,17 +1,27 @@ import { constants } from 'node:os'; import { treeKill } from '@willbooster/shared-lib-node/src'; -import type { CommandModule } from 'yargs'; +import type { Argv, CommandModule } from 'yargs'; -const builder = { - signal: { - description: 'Signal to send to the process tree.', - type: 'string', - default: 'SIGTERM', - }, -} as const; +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 = { +export const treeKillCommand: CommandModule = { command: 'tree-kill [signal]', describe: 'Kill the given process and all descendants', builder, @@ -21,7 +31,7 @@ export const treeKillCommand: CommandModule = { if (!(signal in constants.signals)) { throw new Error(`Invalid signal: ${signal}`); } - treeKill(Number(argv.pid), signal); + treeKill(argv.pid, signal); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/packages/wb/test/treeKill.test.ts b/packages/wb/test/treeKill.test.ts index 91d18ece..69afd4f2 100644 --- a/packages/wb/test/treeKill.test.ts +++ b/packages/wb/test/treeKill.test.ts @@ -1,6 +1,7 @@ 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'; @@ -18,10 +19,7 @@ describe('tree-kill command', () => { expect(isProcessRunning(pid)).toBe(true); - await treeKillCommand.handler({ - pid, - signal: 'SIGTERM', - } as never); + runTreeKillHandler(pid, 'SIGTERM'); await waitForProcessStopped(pid, 10_000); }); @@ -36,11 +34,18 @@ describe('tree-kill command', () => { throw new Error('proc.pid is undefined'); } - await treeKillCommand.handler({ - pid, - signal: 'SIGKILL', - } as never); + 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); +} From bae2e0ea78b46ce92d06e9fd57e5246f080de145 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 22:30:52 +0900 Subject: [PATCH 12/18] refactor: improve tree-kill review follow-ups Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/processTree.ts | 13 +++++++++---- packages/shared-lib-node/src/spawn.ts | 4 ++-- packages/shared-lib-node/src/treeKill.ts | 11 ++++------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/shared-lib-node/src/processTree.ts b/packages/shared-lib-node/src/processTree.ts index e0fbb446..b77c4a62 100644 --- a/packages/shared-lib-node/src/processTree.ts +++ b/packages/shared-lib-node/src/processTree.ts @@ -21,11 +21,16 @@ export function buildChildrenByParentMap(psOutput: string): Map): number[] { const descendants: number[] = []; const queue = [...(childrenByParent.get(rootPid) ?? [])]; - for (const pid of queue) { - descendants.push(pid); - for (const childPid of childrenByParent.get(pid) ?? []) { - queue.push(childPid); + let index = 0; + while (index < queue.length) { + const pid = queue[index]; + index++; + if (pid === undefined) { + continue; } + + 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 75bf009a..c88fe009 100644 --- a/packages/shared-lib-node/src/spawn.ts +++ b/packages/shared-lib-node/src/spawn.ts @@ -109,9 +109,9 @@ export async function spawnAsync( } try { treeKill(proc.pid); - } catch { + } catch (error) { if (options?.verbose) { - console.warn(`Failed to treeKill(${proc.pid})`); + console.warn(`Failed to treeKill(${proc.pid})`, error); } } }; diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 2d695f7a..f8551799 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -57,13 +57,10 @@ function collectDescendantPids(rootPid: number): number[] { } function toChildrenFirstPids(pid: number, descendants: readonly number[]): number[] { - const targetPids: number[] = []; - for (let index = descendants.length - 1; index >= 0; index--) { - const descendantPid = descendants[index]; - if (descendantPid !== undefined) { - targetPids.push(descendantPid); - } - } + const targetPids = descendants.reduceRight((accumulator, descendantPid) => { + accumulator.push(descendantPid); + return accumulator; + }, []); targetPids.push(pid); return targetPids; } From c42608d1c90b6c8bf02380c52a404f849eac9b55 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 1 Mar 2026 23:43:11 +0900 Subject: [PATCH 13/18] refactor: simplify process tree queue traversal Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/processTree.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/shared-lib-node/src/processTree.ts b/packages/shared-lib-node/src/processTree.ts index b77c4a62..1340ce65 100644 --- a/packages/shared-lib-node/src/processTree.ts +++ b/packages/shared-lib-node/src/processTree.ts @@ -23,12 +23,8 @@ export function collectDescendantPids(rootPid: number, childrenByParent: Map Date: Sun, 1 Mar 2026 23:55:32 +0900 Subject: [PATCH 14/18] fix: preserve exit status from tree-kill command failures Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index f8551799..632ecc32 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -99,6 +99,12 @@ 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; } From 336685d52d6566fb4e540ba00c379cb627d8dd6b Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Mon, 2 Mar 2026 00:03:42 +0900 Subject: [PATCH 15/18] refactor: simplify descendants ordering in tree-kill Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 632ecc32..08570da3 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -57,10 +57,13 @@ function collectDescendantPids(rootPid: number): number[] { } function toChildrenFirstPids(pid: number, descendants: readonly number[]): number[] { - const targetPids = descendants.reduceRight((accumulator, descendantPid) => { - accumulator.push(descendantPid); - return accumulator; - }, []); + const targetPids: number[] = []; + for (let index = descendants.length - 1; index >= 0; index--) { + const descendantPid = descendants[index]; + if (descendantPid !== undefined) { + targetPids.push(descendantPid); + } + } targetPids.push(pid); return targetPids; } From 00e0c3ada1ba2dcecf570b26eef3ef432bfdf999 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Mon, 2 Mar 2026 00:07:29 +0900 Subject: [PATCH 16/18] refactor: remove redundant tree-kill pid guard Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 08570da3..632ecc32 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -57,13 +57,10 @@ function collectDescendantPids(rootPid: number): number[] { } function toChildrenFirstPids(pid: number, descendants: readonly number[]): number[] { - const targetPids: number[] = []; - for (let index = descendants.length - 1; index >= 0; index--) { - const descendantPid = descendants[index]; - if (descendantPid !== undefined) { - targetPids.push(descendantPid); - } - } + const targetPids = descendants.reduceRight((accumulator, descendantPid) => { + accumulator.push(descendantPid); + return accumulator; + }, []); targetPids.push(pid); return targetPids; } From 96dfc385e6211192753485d160d7e4d824be3a0f Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Mon, 2 Mar 2026 00:15:02 +0900 Subject: [PATCH 17/18] refactor: tighten errno type guard Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/errno.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared-lib-node/src/errno.ts b/packages/shared-lib-node/src/errno.ts index 71cee6bc..12008715 100644 --- a/packages/shared-lib-node/src/errno.ts +++ b/packages/shared-lib-node/src/errno.ts @@ -1,3 +1,3 @@ export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return typeof error === 'object' && error !== null && 'code' in error; + return error instanceof Error && 'code' in error; } From a669e89ad3dd6c9c0e5e15ed5a363880166677d2 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Mon, 2 Mar 2026 00:24:42 +0900 Subject: [PATCH 18/18] refactor: use toReversed in tree-kill ordering Co-authored-by: WillBooster (Codex CLI) --- packages/shared-lib-node/src/treeKill.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/shared-lib-node/src/treeKill.ts b/packages/shared-lib-node/src/treeKill.ts index 632ecc32..5cebcd9d 100644 --- a/packages/shared-lib-node/src/treeKill.ts +++ b/packages/shared-lib-node/src/treeKill.ts @@ -57,10 +57,7 @@ function collectDescendantPids(rootPid: number): number[] { } function toChildrenFirstPids(pid: number, descendants: readonly number[]): number[] { - const targetPids = descendants.reduceRight((accumulator, descendantPid) => { - accumulator.push(descendantPid); - return accumulator; - }, []); + const targetPids = descendants.toReversed(); targetPids.push(pid); return targetPids; }