Skip to content

Commit a04d377

Browse files
committed
ci: add cross-platform integration test matrix
1 parent de087ff commit a04d377

File tree

6 files changed

+289
-1
lines changed

6 files changed

+289
-1
lines changed

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
7+
jobs:
8+
test:
9+
name: Test (${{ matrix.os }})
10+
runs-on: ${{ matrix.os }}
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
os:
15+
- ubuntu-latest
16+
- macos-latest
17+
- windows-latest
18+
19+
steps:
20+
- name: Check out repository
21+
uses: actions/checkout@v4
22+
23+
- name: Set up pnpm
24+
uses: pnpm/action-setup@v4
25+
with:
26+
version: 10
27+
28+
- name: Set up Node.js
29+
uses: actions/setup-node@v4
30+
with:
31+
node-version: 18
32+
cache: pnpm
33+
34+
- name: Install dependencies
35+
run: pnpm install --frozen-lockfile
36+
37+
- name: Lint
38+
run: pnpm lint
39+
40+
- name: Typecheck
41+
run: pnpm typecheck
42+
43+
- name: Test
44+
run: pnpm test
45+
46+
- name: Build
47+
run: pnpm build
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { spawn } from "node:child_process"
2+
import { dirname, resolve } from "node:path"
3+
import { fileURLToPath } from "node:url"
4+
5+
const fixtureDir = dirname(fileURLToPath(import.meta.url))
6+
7+
const grandchild = spawn(process.execPath, [resolve(fixtureDir, "grandchild.mjs")], {
8+
stdio: "ignore",
9+
windowsHide: true,
10+
})
11+
12+
process.send?.({
13+
grandchildPid: grandchild.pid,
14+
})
15+
16+
setInterval(() => {}, 1_000)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
setInterval(() => {}, 1_000)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { spawn } from "node:child_process"
2+
import { dirname, resolve } from "node:path"
3+
import { fileURLToPath } from "node:url"
4+
5+
const fixtureDir = dirname(fileURLToPath(import.meta.url))
6+
7+
const child = spawn(process.execPath, [resolve(fixtureDir, "child.mjs")], {
8+
stdio: ["ignore", "ignore", "inherit", "ipc"],
9+
windowsHide: true,
10+
})
11+
12+
child.on("message", (message) => {
13+
process.send?.({
14+
childPid: child.pid,
15+
grandchildPid: message?.grandchildPid,
16+
})
17+
})
18+
19+
setInterval(() => {}, 1_000)

test/integration.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { spawn, type ChildProcess } from "node:child_process"
2+
import { fileURLToPath } from "node:url"
3+
4+
import { afterEach, describe, expect, it } from "vitest"
5+
6+
import treeKill, { treeKillSync } from "../src/index.js"
7+
8+
const rootFixturePath = fileURLToPath(new URL("./fixtures/process-tree/root.mjs", import.meta.url))
9+
10+
type TreeInfo = {
11+
root: ChildProcess
12+
childPid: number
13+
grandchildPid: number
14+
}
15+
16+
const trackedPids = new Set<number>()
17+
18+
afterEach(async () => {
19+
for (const pid of trackedPids) {
20+
killPidBestEffort(pid)
21+
trackedPids.delete(pid)
22+
}
23+
})
24+
25+
describe("real process-tree killing", () => {
26+
it("kills a live descendant tree with the async API", async () => {
27+
const tree = await spawnProcessTree()
28+
29+
await treeKill(tree.root, "SIGTERM")
30+
31+
await assertTreeExited(tree)
32+
})
33+
34+
it("kills a live descendant tree with the sync API", async () => {
35+
const tree = await spawnProcessTree()
36+
37+
treeKillSync(tree.root, "SIGTERM")
38+
39+
await assertTreeExited(tree)
40+
})
41+
})
42+
43+
async function spawnProcessTree(): Promise<TreeInfo> {
44+
const root = spawn(process.execPath, [rootFixturePath], {
45+
stdio: ["ignore", "ignore", "inherit", "ipc"],
46+
windowsHide: true,
47+
})
48+
49+
if (typeof root.pid === "number") {
50+
trackedPids.add(root.pid)
51+
}
52+
53+
const ready = await waitForReadyMessage(root)
54+
const tree = {
55+
root,
56+
childPid: ready.childPid,
57+
grandchildPid: ready.grandchildPid,
58+
}
59+
60+
trackedPids.add(tree.childPid)
61+
trackedPids.add(tree.grandchildPid)
62+
63+
return tree
64+
}
65+
66+
function waitForReadyMessage(
67+
root: ChildProcess,
68+
timeoutMs = 10_000,
69+
): Promise<{ childPid: number; grandchildPid: number }> {
70+
return new Promise((resolve, reject) => {
71+
const timeout = setTimeout(() => {
72+
cleanup()
73+
reject(new Error("timed out while waiting for process-tree fixture readiness"))
74+
}, timeoutMs)
75+
76+
const cleanup = () => {
77+
clearTimeout(timeout)
78+
root.off("message", onMessage)
79+
root.off("exit", onExit)
80+
root.off("error", onError)
81+
}
82+
83+
const onMessage = (message: unknown) => {
84+
if (!isReadyMessage(message)) {
85+
cleanup()
86+
reject(new Error(`received invalid fixture message: ${JSON.stringify(message)}`))
87+
return
88+
}
89+
cleanup()
90+
resolve(message)
91+
}
92+
93+
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
94+
cleanup()
95+
reject(new Error(`fixture root exited before readiness (code=${code}, signal=${signal})`))
96+
}
97+
98+
const onError = (error: Error) => {
99+
cleanup()
100+
reject(error)
101+
}
102+
103+
root.on("message", onMessage)
104+
root.on("exit", onExit)
105+
root.on("error", onError)
106+
})
107+
}
108+
109+
function isReadyMessage(message: unknown): message is { childPid: number; grandchildPid: number } {
110+
return (
111+
typeof message === "object" &&
112+
message !== null &&
113+
typeof (message as { childPid?: unknown }).childPid === "number" &&
114+
typeof (message as { grandchildPid?: unknown }).grandchildPid === "number"
115+
)
116+
}
117+
118+
async function assertTreeExited(tree: TreeInfo): Promise<void> {
119+
await waitForChildExit(tree.root)
120+
await Promise.all([
121+
waitForPidToExit(tree.root.pid!),
122+
waitForPidToExit(tree.childPid),
123+
waitForPidToExit(tree.grandchildPid),
124+
])
125+
126+
expect(isProcessAlive(tree.root.pid!)).toBe(false)
127+
expect(isProcessAlive(tree.childPid)).toBe(false)
128+
expect(isProcessAlive(tree.grandchildPid)).toBe(false)
129+
130+
trackedPids.delete(tree.root.pid!)
131+
trackedPids.delete(tree.childPid)
132+
trackedPids.delete(tree.grandchildPid)
133+
}
134+
135+
async function waitForChildExit(root: ChildProcess, timeoutMs = 10_000): Promise<void> {
136+
if (root.exitCode !== null || root.signalCode !== null) {
137+
return
138+
}
139+
140+
await new Promise<void>((resolve, reject) => {
141+
const timeout = setTimeout(() => {
142+
cleanup()
143+
reject(new Error("timed out while waiting for root process to exit"))
144+
}, timeoutMs)
145+
146+
const cleanup = () => {
147+
clearTimeout(timeout)
148+
root.off("exit", onExit)
149+
root.off("error", onError)
150+
}
151+
152+
const onExit = () => {
153+
cleanup()
154+
resolve()
155+
}
156+
157+
const onError = (error: Error) => {
158+
cleanup()
159+
reject(error)
160+
}
161+
162+
root.on("exit", onExit)
163+
root.on("error", onError)
164+
})
165+
}
166+
167+
async function waitForPidToExit(pid: number, timeoutMs = 10_000): Promise<void> {
168+
const startedAt = Date.now()
169+
170+
while (Date.now() - startedAt < timeoutMs) {
171+
if (!isProcessAlive(pid)) {
172+
return
173+
}
174+
await new Promise((resolve) => setTimeout(resolve, 50))
175+
}
176+
177+
throw new Error(`timed out while waiting for pid ${pid} to exit`)
178+
}
179+
180+
function isProcessAlive(pid: number): boolean {
181+
try {
182+
process.kill(pid, 0)
183+
return true
184+
} catch (error) {
185+
const code = (error as NodeJS.ErrnoException).code
186+
if (code === "ESRCH") {
187+
return false
188+
}
189+
if (code === "EPERM") {
190+
return true
191+
}
192+
throw error
193+
}
194+
}
195+
196+
function killPidBestEffort(pid: number): void {
197+
try {
198+
process.kill(pid, "SIGKILL")
199+
} catch (error) {
200+
const code = (error as NodeJS.ErrnoException).code
201+
if (code !== "ESRCH") {
202+
throw error
203+
}
204+
}
205+
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"types": ["node"],
77
"noEmit": true
88
},
9-
"include": ["src", "examples"]
9+
"include": ["src", "examples", "test"]
1010
}

0 commit comments

Comments
 (0)