-
Notifications
You must be signed in to change notification settings - Fork 0
feat: replace tree-kill with shared implementation #600
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+519
−10
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
501bed8
feat: replace tree-kill with shared implementation
exKAZUu 35e63d8
test: increase treeKill integration test timeout
exKAZUu 6943a1b
test: stabilize tree-kill process-tree tests
exKAZUu a35c1ed
fix: address tree-kill review comments
exKAZUu feec057
test: strengthen tree-kill integration coverage
exKAZUu 6067e07
fix: resolve remaining tree-kill review feedback
exKAZUu 569b65a
refactor: resolve review feedback on tree-kill tests
exKAZUu e1c5c86
fix: address latest tree-kill review feedback
exKAZUu 398216f
refactor: make tree-kill synchronous
exKAZUu abd14d5
refactor: resolve tree-kill review comments
exKAZUu 8a3e058
fix: kill descendants before parent in tree-kill
exKAZUu bae2e0e
refactor: improve tree-kill review follow-ups
exKAZUu c42608d
refactor: simplify process tree queue traversal
exKAZUu 0248537
fix: preserve exit status from tree-kill command failures
exKAZUu 336685d
refactor: simplify descendants ordering in tree-kill
exKAZUu 00e0c3a
refactor: remove redundant tree-kill pid guard
exKAZUu 96dfc38
refactor: tighten errno type guard
exKAZUu a669e89
refactor: use toReversed in tree-kill ordering
exKAZUu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { | ||
| return error instanceof Error && 'code' in error; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| export function buildChildrenByParentMap(psOutput: string): Map<number, number[]> { | ||
| const childrenByParent = new Map<number, number[]>(); | ||
| for (const line of psOutput.split('\n')) { | ||
| const matched = /^\s*(\d+)\s+(\d+)\s*$/.exec(line); | ||
| if (!matched) { | ||
| continue; | ||
| } | ||
|
|
||
| const childPid = Number(matched[1]); | ||
| const parentPid = Number(matched[2]); | ||
| const children = childrenByParent.get(parentPid); | ||
| if (children) { | ||
| children.push(childPid); | ||
| } else { | ||
| childrenByParent.set(parentPid, [childPid]); | ||
| } | ||
| } | ||
| return childrenByParent; | ||
| } | ||
|
|
||
| export function collectDescendantPids(rootPid: number, childrenByParent: Map<number, number[]>): number[] { | ||
| const descendants: number[] = []; | ||
| const queue = [...(childrenByParent.get(rootPid) ?? [])]; | ||
| let index = 0; | ||
| while (index < queue.length) { | ||
| const pid = queue[index] as number; | ||
| index += 1; | ||
| descendants.push(pid); | ||
| queue.push(...(childrenByParent.get(pid) ?? [])); | ||
| } | ||
| return descendants; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import { execFileSync } from 'node:child_process'; | ||
|
|
||
| import { isErrnoException } from './errno.js'; | ||
| import { buildChildrenByParentMap, collectDescendantPids as collectDescendantPidsFromMap } from './processTree.js'; | ||
|
|
||
| export function treeKill(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void { | ||
| if (!Number.isInteger(pid) || pid <= 0) { | ||
| throw new Error(`Invalid pid: ${pid}`); | ||
| } | ||
|
|
||
| if (process.platform === 'win32') { | ||
| killTreeOnWindows(pid); | ||
| return; | ||
| } | ||
|
|
||
| const descendants = collectDescendantPids(pid); | ||
| const targetPids = toChildrenFirstPids(pid, descendants); | ||
| for (const targetPid of targetPids) { | ||
| killIfNeeded(targetPid, signal); | ||
| } | ||
exKAZUu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| function killIfNeeded(pid: number, signal: NodeJS.Signals): void { | ||
| try { | ||
| process.kill(pid, signal); | ||
| } catch (error) { | ||
| if (isNoSuchProcessError(error)) { | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| function killTreeOnWindows(pid: number): void { | ||
| try { | ||
| runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], { | ||
| maxBuffer: 1024 * 1024, | ||
| timeout: 2000, | ||
| }); | ||
| } catch (error) { | ||
| if (isNoSuchProcessError(error)) { | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| function collectDescendantPids(rootPid: number): number[] { | ||
| const { stdout } = runCommand( | ||
| 'ps', | ||
| ['-Ao', 'pid=,ppid='], | ||
| // Keep command bounded so watch-mode kill loops cannot hang this path. | ||
| { maxBuffer: 1024 * 1024, timeout: 2000 } | ||
| ); | ||
| const childrenByParent = buildChildrenByParentMap(stdout); | ||
| return collectDescendantPidsFromMap(rootPid, childrenByParent); | ||
| } | ||
|
|
||
| function toChildrenFirstPids(pid: number, descendants: readonly number[]): number[] { | ||
| const targetPids = descendants.toReversed(); | ||
| targetPids.push(pid); | ||
| return targetPids; | ||
| } | ||
exKAZUu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| function runCommand( | ||
| command: string, | ||
| args: readonly string[], | ||
| options?: { timeout: number; maxBuffer: number } | ||
| ): { stdout: string; stderr: string } { | ||
| try { | ||
| const stdout = execFileSync(command, [...args], { | ||
| encoding: 'utf8', | ||
| maxBuffer: options?.maxBuffer, | ||
| timeout: options?.timeout, | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| }); | ||
| return { stdout, stderr: '' }; | ||
| } catch (error) { | ||
| const stderr = extractStderr(error); | ||
| throw new CommandExecutionError(command, args, stderr, toExitCode(error)); | ||
| } | ||
| } | ||
exKAZUu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| function isNoSuchProcessError(error: unknown): boolean { | ||
| if (isErrnoException(error) && error.code === 'ESRCH') { | ||
| return true; | ||
| } | ||
|
|
||
| if (error instanceof CommandExecutionError) { | ||
| return /no such process|not found|not recognized|there is no running instance/i.test(error.stderr); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function toExitCode(error: unknown): number | string | undefined { | ||
| if (isErrnoException(error)) { | ||
| return error.code; | ||
| } | ||
| if (typeof error === 'object' && error !== null && 'status' in error) { | ||
| const status = (error as { status: unknown }).status; | ||
| if (typeof status === 'number') { | ||
| return status; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
exKAZUu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| function extractStderr(error: unknown): string { | ||
| if (typeof error !== 'object' || error === null || !('stderr' in error)) { | ||
| return ''; | ||
| } | ||
|
|
||
| const stderr = (error as Record<'stderr', unknown>).stderr; | ||
| if (typeof stderr === 'string') { | ||
| return stderr; | ||
| } | ||
| if (Buffer.isBuffer(stderr)) { | ||
| return stderr.toString('utf8'); | ||
| } | ||
| return ''; | ||
| } | ||
|
|
||
| class CommandExecutionError extends Error { | ||
| readonly stderr: string; | ||
| readonly code: number | string | undefined; | ||
|
|
||
| constructor(command: string, args: readonly string[], stderr: string, code: number | string | undefined) { | ||
| super(`Command failed: ${command} ${args.join(' ')}`); | ||
| this.name = 'CommandExecutionError'; | ||
| this.stderr = stderr; | ||
| this.code = code; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { spawn } from 'node:child_process'; | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { | ||
| createTreeScript, | ||
| isProcessRunning, | ||
| listDescendantPids, | ||
| wait, | ||
| waitForProcessStopped, | ||
| } from '../../../test/processUtils.js'; | ||
| import { treeKill } from '../src/treeKill.js'; | ||
|
|
||
| describe('processUtils', () => { | ||
| it('finds descendants for a running process tree', async () => { | ||
| const parent = spawn(process.execPath, ['-e', createTreeScript(2)], { stdio: ['ignore', 'ignore', 'ignore'] }); | ||
| const { pid: parentPid } = parent; | ||
| expect(parentPid).toBeDefined(); | ||
| if (!parentPid) { | ||
| throw new Error('parent.pid is undefined'); | ||
| } | ||
|
|
||
| const descendants = await waitForDescendants(parentPid, 2, 10_000); | ||
| expect(descendants.length).toBeGreaterThanOrEqual(2); | ||
|
|
||
| treeKill(parentPid, 'SIGKILL'); | ||
| await waitForProcessStopped(parentPid, 10_000); | ||
| for (const pid of descendants) { | ||
| await waitForProcessStopped(pid, 10_000); | ||
| } | ||
| }, 30_000); | ||
|
|
||
| it('returns empty descendants and false running status for unknown pid', () => { | ||
| const unknownPid = 999_999_999; | ||
| expect(listDescendantPids(unknownPid)).toStrictEqual([]); | ||
| expect(isProcessRunning(unknownPid)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| async function waitForDescendants(parentPid: number, minimumCount: number, timeoutMs: number): Promise<number[]> { | ||
| const startedAt = Date.now(); | ||
| while (Date.now() - startedAt < timeoutMs) { | ||
| const descendants = listDescendantPids(parentPid); | ||
| if (descendants.length >= minimumCount) { | ||
| return descendants; | ||
| } | ||
| await wait(100); | ||
| } | ||
| throw new Error(`Timed out while waiting descendants of ${parentPid}`); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import type { ChildProcessByStdio } from 'node:child_process'; | ||
| import { spawn } from 'node:child_process'; | ||
| import type { Readable } from 'node:stream'; | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { | ||
| createTreeScript, | ||
| isProcessRunning, | ||
| listDescendantPids, | ||
| wait, | ||
| waitForProcessStopped, | ||
| } from '../../../test/processUtils.js'; | ||
| import { treeKill } from '../src/treeKill.js'; | ||
|
|
||
| type ChildProcessWithPipeOut = ChildProcessByStdio<null, Readable, Readable>; | ||
|
|
||
| describe('treeKill', () => { | ||
| it('kills parent and descendant processes', async () => { | ||
| const parent = spawnProcessTree(1); | ||
| const { pid: parentPid } = parent; | ||
| expect(parentPid).toBeDefined(); | ||
| if (!parentPid) { | ||
| throw new Error('parent.pid is undefined'); | ||
| } | ||
|
|
||
| const descendantPids = await waitForDescendantPidsCount(parentPid, 1, 10_000); | ||
| expect(isProcessRunning(parentPid)).toBe(true); | ||
| for (const pid of descendantPids) { | ||
| expect(isProcessRunning(pid)).toBe(true); | ||
| } | ||
|
|
||
| treeKill(parentPid); | ||
|
|
||
| await Promise.all([ | ||
| waitForProcessStopped(parentPid, 10_000), | ||
| ...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)), | ||
| waitForClose(parent, 10_000), | ||
| ]); | ||
| }, 30_000); | ||
|
|
||
| it('kills deep process trees', async () => { | ||
| const parent = spawnProcessTree(2); | ||
| const { pid: parentPid } = parent; | ||
| expect(parentPid).toBeDefined(); | ||
| if (!parentPid) { | ||
| throw new Error('parent.pid is undefined'); | ||
| } | ||
|
|
||
| const descendantPids = await waitForDescendantPidsCount(parentPid, 2, 10_000); | ||
| treeKill(parentPid); | ||
|
|
||
| await Promise.all([ | ||
| waitForProcessStopped(parentPid, 10_000), | ||
| ...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)), | ||
| waitForClose(parent, 10_000), | ||
| ]); | ||
| }, 30_000); | ||
|
|
||
| it('kills process trees with custom signal', async () => { | ||
| const parent = spawnProcessTree(1); | ||
| const { pid: parentPid } = parent; | ||
| expect(parentPid).toBeDefined(); | ||
| if (!parentPid) { | ||
| throw new Error('parent.pid is undefined'); | ||
| } | ||
|
|
||
| const descendantPids = await waitForDescendantPidsCount(parentPid, 1, 10_000); | ||
| treeKill(parentPid, 'SIGKILL'); | ||
|
|
||
| await Promise.all([ | ||
| waitForProcessStopped(parentPid, 10_000), | ||
| ...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)), | ||
| waitForClose(parent, 10_000), | ||
| ]); | ||
| }, 30_000); | ||
|
|
||
| it('kills repeatedly in rapid succession', async () => { | ||
| for (let i = 0; i < 3; i++) { | ||
| const parent = spawnProcessTree(2); | ||
| const { pid: parentPid } = parent; | ||
| expect(parentPid).toBeDefined(); | ||
| if (!parentPid) { | ||
| throw new Error('parent.pid is undefined'); | ||
| } | ||
|
|
||
| const descendantPids = await waitForDescendantPidsCount(parentPid, 2, 10_000); | ||
| treeKill(parentPid); | ||
|
|
||
| await Promise.all([ | ||
| waitForProcessStopped(parentPid, 10_000), | ||
| ...descendantPids.map((pid) => waitForProcessStopped(pid, 10_000)), | ||
| waitForClose(parent, 10_000), | ||
| ]); | ||
| } | ||
| }, 60_000); | ||
|
|
||
| it('does not throw when process is already gone', () => { | ||
| expect(() => { | ||
| treeKill(999_999_999); | ||
| }).not.toThrow(); | ||
| }); | ||
| }); | ||
|
|
||
| function spawnProcessTree(depth: number): ChildProcessWithPipeOut { | ||
| return spawn(process.execPath, ['-e', createTreeScript(depth)], { stdio: ['ignore', 'pipe', 'pipe'] }); | ||
| } | ||
|
|
||
| async function waitForDescendantPidsCount( | ||
| parentPid: number, | ||
| minimumCount: number, | ||
| timeoutMs: number | ||
| ): Promise<number[]> { | ||
| const startedAt = Date.now(); | ||
| while (Date.now() - startedAt < timeoutMs) { | ||
| const descendants = listDescendantPids(parentPid); | ||
| if (descendants.length >= minimumCount) { | ||
| return descendants; | ||
| } | ||
| await wait(100); | ||
| } | ||
| throw new Error(`Timed out while waiting descendant processes for ${parentPid}`); | ||
| } | ||
|
|
||
| async function waitForClose(proc: ChildProcessWithPipeOut, timeoutMs: number): Promise<void> { | ||
| await new Promise<void>((resolve, reject) => { | ||
| const timer = setTimeout(() => { | ||
| proc.removeListener('close', onClose); | ||
| reject(new Error('Timed out while waiting process close event')); | ||
| }, timeoutMs); | ||
| const onClose = (): void => { | ||
| clearTimeout(timer); | ||
| resolve(); | ||
| }; | ||
| proc.once('close', onClose); | ||
| }); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.