Files
pnpm/pnpm11/exec/lifecycle/test/trackChildProcess.ts
arman b46f96165f fix: kill spawned process trees with taskkill on Windows error exits (#12925)
Main thread panicked: begin > end (105 > 28) when slicing
`@pnpm/npm-lifecycle@1100.0.0(patch_hash=e3541c…)(supports-color@10.2.2)`
at pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:2490:51

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-07-13 16:30:01 +02:00

84 lines
2.6 KiB
TypeScript

import { type ChildProcess, spawn } from 'node:child_process'
import path from 'node:path'
import { expect, test } from '@jest/globals'
import { killTrackedProcessTrees, trackChildProcess } from '@pnpm/exec.lifecycle'
import isWindows from 'is-windows'
const parentScript = path.join(import.meta.dirname, 'fixtures/process-tree/parent.cjs')
test('killTrackedProcessTrees() kills a tracked child process and, on Windows, its descendants', async () => {
const child = spawn(process.execPath, [parentScript], { stdio: ['ignore', 'pipe', 'ignore'] })
// Wait for 'close' rather than 'exit': the tracker untracks the PID on
// 'close', so awaiting it guarantees the registry is empty before the next
// test, whose assertion relies on it being so.
const childClosed = new Promise<void>((resolve) => {
child.once('close', () => {
resolve()
})
})
const grandchildPid = await readGrandchildPid(child)
try {
trackChildProcess(child)
await killTrackedProcessTrees()
await childClosed
expect(child.exitCode !== 0 || child.signalCode != null).toBe(true)
if (isWindows()) {
expect(await exited(grandchildPid)).toBe(true)
}
} finally {
killSilently(child.pid!)
killSilently(grandchildPid)
}
})
test('killTrackedProcessTrees() is a no-op for a tracked child process that already exited', async () => {
const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' })
trackChildProcess(child)
await new Promise<void>((resolve) => {
child.once('close', () => {
resolve()
})
})
await killTrackedProcessTrees()
})
async function readGrandchildPid (child: ChildProcess): Promise<number> {
const firstLine = await new Promise<string>((resolve, reject) => {
let output = ''
child.stdout!.on('data', (data: Buffer) => {
output += data.toString()
if (output.includes('\n')) {
resolve(output)
}
})
child.once('error', reject)
child.once('exit', () => {
reject(new Error(`the spawned process tree exited early: ${output}`))
})
})
return parseInt(firstLine, 10)
}
async function exited (pid: number, timeoutMs: number = 10_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
process.kill(pid, 0)
} catch {
return true
}
await new Promise<void>((resolve) => setTimeout(resolve, 100)) // eslint-disable-line no-await-in-loop
}
return false
}
function killSilently (pid: number): void {
try {
process.kill(pid, 'SIGKILL')
} catch {
// the process has already exited
}
}