@o/run 0.16.19-canary.1790165522484
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +47 -0
- package/src/helpers/handleProcessExit.ts +230 -0
- package/src/helpers/process-cleanup.test.ts +150 -0
- package/src/helpers/process-state.ts +26 -0
- package/src/helpers/resolveScript.test.ts +340 -0
- package/src/helpers/resolveScript.ts +225 -0
- package/src/node-version-check.ts +55 -0
- package/src/process-cleanup.test.ts +255 -0
- package/src/run-pty-supervision.test.ts +110 -0
- package/src/run-pty.mjs +455 -0
- package/src/run-supervision.test.ts +94 -0
- package/src/run.ts +546 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
7
|
+
|
|
8
|
+
function isAlive(pid: number): boolean {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0)
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function waitForCondition(
|
|
18
|
+
fn: () => boolean,
|
|
19
|
+
timeoutMs = 5000,
|
|
20
|
+
intervalMs = 50
|
|
21
|
+
): Promise<boolean> {
|
|
22
|
+
const start = Date.now()
|
|
23
|
+
while (Date.now() - start < timeoutMs) {
|
|
24
|
+
if (fn()) return true
|
|
25
|
+
await new Promise((r) => setTimeout(r, intervalMs))
|
|
26
|
+
}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function waitForFile(path: string, timeoutMs = 5000): Promise<string> {
|
|
31
|
+
const start = Date.now()
|
|
32
|
+
while (Date.now() - start < timeoutMs) {
|
|
33
|
+
if (existsSync(path)) {
|
|
34
|
+
const content = readFileSync(path, 'utf-8').trim()
|
|
35
|
+
if (content) return content
|
|
36
|
+
}
|
|
37
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`file never appeared: ${path}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'proc-cleanup-'))
|
|
43
|
+
const pidsToCleanup: number[] = []
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
for (const pid of pidsToCleanup) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 'SIGKILL')
|
|
49
|
+
} catch {}
|
|
50
|
+
try {
|
|
51
|
+
process.kill(-pid, 'SIGKILL')
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
pidsToCleanup.length = 0
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function makeChildScript(pidFile: string, opts?: { grandchild?: boolean }): string {
|
|
58
|
+
const path = join(
|
|
59
|
+
tmpDir,
|
|
60
|
+
`child-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`
|
|
61
|
+
)
|
|
62
|
+
if (opts?.grandchild) {
|
|
63
|
+
const gcPidFile = pidFile.replace('.pid', '-gc.pid')
|
|
64
|
+
// use a separate script for grandchild to avoid escaping issues
|
|
65
|
+
const gcScript = join(
|
|
66
|
+
tmpDir,
|
|
67
|
+
`gc-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`
|
|
68
|
+
)
|
|
69
|
+
writeFileSync(gcScript, `#!/bin/bash\necho $$ > ${gcPidFile}\nsleep 999\n`, {
|
|
70
|
+
mode: 0o755,
|
|
71
|
+
})
|
|
72
|
+
writeFileSync(
|
|
73
|
+
path,
|
|
74
|
+
`#!/bin/bash\necho $$ > ${pidFile}\nbash ${gcScript} &\nsleep 999\n`,
|
|
75
|
+
{
|
|
76
|
+
mode: 0o755,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
} else {
|
|
80
|
+
writeFileSync(path, `#!/bin/bash\necho $$ > ${pidFile}\nsleep 999\n`, { mode: 0o755 })
|
|
81
|
+
}
|
|
82
|
+
return path
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleProcessExitPath = join(__dirname, 'helpers', 'handleProcessExit.ts')
|
|
86
|
+
|
|
87
|
+
// creates a parent node script that uses real handleProcessExit
|
|
88
|
+
function createParentScript(opts: {
|
|
89
|
+
childScripts: string[]
|
|
90
|
+
parentPidFile: string
|
|
91
|
+
}): string {
|
|
92
|
+
const scriptPath = join(
|
|
93
|
+
tmpDir,
|
|
94
|
+
`parent-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`
|
|
95
|
+
)
|
|
96
|
+
writeFileSync(
|
|
97
|
+
scriptPath,
|
|
98
|
+
`
|
|
99
|
+
import { handleProcessExit } from '${handleProcessExitPath}'
|
|
100
|
+
import { spawn } from 'node:child_process'
|
|
101
|
+
import { writeFileSync } from 'node:fs'
|
|
102
|
+
|
|
103
|
+
const { addChildProcess } = handleProcessExit()
|
|
104
|
+
|
|
105
|
+
writeFileSync(${JSON.stringify(opts.parentPidFile)}, String(process.pid))
|
|
106
|
+
|
|
107
|
+
const scripts = ${JSON.stringify(opts.childScripts)}
|
|
108
|
+
for (const script of scripts) {
|
|
109
|
+
const child = spawn('bash', [script], { detached: true, stdio: 'ignore' })
|
|
110
|
+
child.unref()
|
|
111
|
+
addChildProcess(child)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setInterval(() => {}, 60000)
|
|
115
|
+
`
|
|
116
|
+
)
|
|
117
|
+
return scriptPath
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function spawnParent(scriptPath: string) {
|
|
121
|
+
const child = spawn('bun', [scriptPath], {
|
|
122
|
+
detached: true,
|
|
123
|
+
stdio: 'ignore',
|
|
124
|
+
})
|
|
125
|
+
child.unref()
|
|
126
|
+
pidsToCleanup.push(child.pid!)
|
|
127
|
+
return child
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe('process cleanup', { timeout: 15_000 }, () => {
|
|
131
|
+
test('SIGTERM kills all children', async () => {
|
|
132
|
+
const pidFiles = [join(tmpDir, 'a1.pid'), join(tmpDir, 'a2.pid')]
|
|
133
|
+
const parentPidFile = join(tmpDir, 'p-term.pid')
|
|
134
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
135
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
136
|
+
|
|
137
|
+
spawnParent(script)
|
|
138
|
+
|
|
139
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
140
|
+
pidsToCleanup.push(parentPid)
|
|
141
|
+
const childPids = await Promise.all(pidFiles.map((f) => waitForFile(f).then(Number)))
|
|
142
|
+
childPids.forEach((p) => pidsToCleanup.push(p))
|
|
143
|
+
|
|
144
|
+
expect(isAlive(parentPid)).toBe(true)
|
|
145
|
+
childPids.forEach((pid) => expect(isAlive(pid)).toBe(true))
|
|
146
|
+
|
|
147
|
+
process.kill(parentPid, 'SIGTERM')
|
|
148
|
+
|
|
149
|
+
const allDead = await waitForCondition(
|
|
150
|
+
() => !isAlive(parentPid) && childPids.every((p) => !isAlive(p)),
|
|
151
|
+
5000
|
|
152
|
+
)
|
|
153
|
+
expect(allDead).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('SIGINT kills all children', async () => {
|
|
157
|
+
const pidFiles = [join(tmpDir, 'b1.pid')]
|
|
158
|
+
const parentPidFile = join(tmpDir, 'p-int.pid')
|
|
159
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
160
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
161
|
+
|
|
162
|
+
spawnParent(script)
|
|
163
|
+
|
|
164
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
165
|
+
pidsToCleanup.push(parentPid)
|
|
166
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
167
|
+
pidsToCleanup.push(childPid)
|
|
168
|
+
|
|
169
|
+
process.kill(parentPid, 'SIGINT')
|
|
170
|
+
|
|
171
|
+
const allDead = await waitForCondition(
|
|
172
|
+
() => !isAlive(parentPid) && !isAlive(childPid),
|
|
173
|
+
5000
|
|
174
|
+
)
|
|
175
|
+
expect(allDead).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('SIGHUP kills all children', async () => {
|
|
179
|
+
const pidFiles = [join(tmpDir, 'c1.pid')]
|
|
180
|
+
const parentPidFile = join(tmpDir, 'p-hup.pid')
|
|
181
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
182
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
183
|
+
|
|
184
|
+
spawnParent(script)
|
|
185
|
+
|
|
186
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
187
|
+
pidsToCleanup.push(parentPid)
|
|
188
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
189
|
+
pidsToCleanup.push(childPid)
|
|
190
|
+
|
|
191
|
+
process.kill(parentPid, 'SIGHUP')
|
|
192
|
+
|
|
193
|
+
const allDead = await waitForCondition(
|
|
194
|
+
() => !isAlive(parentPid) && !isAlive(childPid),
|
|
195
|
+
5000
|
|
196
|
+
)
|
|
197
|
+
expect(allDead).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('process group kill reaches grandchildren', async () => {
|
|
201
|
+
const pidFiles = [join(tmpDir, 'd1.pid')]
|
|
202
|
+
const parentPidFile = join(tmpDir, 'p-gc.pid')
|
|
203
|
+
const grandchildPidFile = join(tmpDir, 'd1-gc.pid')
|
|
204
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f, { grandchild: true }))
|
|
205
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
206
|
+
|
|
207
|
+
spawnParent(script)
|
|
208
|
+
|
|
209
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
210
|
+
pidsToCleanup.push(parentPid)
|
|
211
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
212
|
+
pidsToCleanup.push(childPid)
|
|
213
|
+
const gcPid = parseInt(await waitForFile(grandchildPidFile))
|
|
214
|
+
pidsToCleanup.push(gcPid)
|
|
215
|
+
|
|
216
|
+
expect(isAlive(childPid)).toBe(true)
|
|
217
|
+
expect(isAlive(gcPid)).toBe(true)
|
|
218
|
+
|
|
219
|
+
process.kill(parentPid, 'SIGTERM')
|
|
220
|
+
|
|
221
|
+
const allDead = await waitForCondition(
|
|
222
|
+
() => !isAlive(childPid) && !isAlive(gcPid),
|
|
223
|
+
5000
|
|
224
|
+
)
|
|
225
|
+
expect(allDead).toBe(true)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('pid file is created and cleaned up', async () => {
|
|
229
|
+
const pidFiles = [join(tmpDir, 'e1.pid')]
|
|
230
|
+
const parentPidFile = join(tmpDir, 'p-pidfile.pid')
|
|
231
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
232
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
233
|
+
|
|
234
|
+
spawnParent(script)
|
|
235
|
+
|
|
236
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
237
|
+
pidsToCleanup.push(parentPid)
|
|
238
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
239
|
+
pidsToCleanup.push(childPid)
|
|
240
|
+
|
|
241
|
+
// pid file should exist while parent is running
|
|
242
|
+
const tkoFile = `/tmp/tko-run-${parentPid}.pids`
|
|
243
|
+
const pidFileExists = await waitForCondition(() => existsSync(tkoFile), 3000)
|
|
244
|
+
expect(pidFileExists).toBe(true)
|
|
245
|
+
|
|
246
|
+
const contents = readFileSync(tkoFile, 'utf-8').trim()
|
|
247
|
+
expect(contents).toContain(String(childPid))
|
|
248
|
+
|
|
249
|
+
// send SIGTERM and verify pid file is cleaned up
|
|
250
|
+
process.kill(parentPid, 'SIGTERM')
|
|
251
|
+
|
|
252
|
+
const cleaned = await waitForCondition(() => !existsSync(tkoFile), 5000)
|
|
253
|
+
expect(cleaned).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const RUNNER = resolve(import.meta.dir, 'run-pty.mjs')
|
|
8
|
+
const running: ChildProcess[] = []
|
|
9
|
+
const directories: string[] = []
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
for (const child of running.splice(0)) {
|
|
13
|
+
if (child.exitCode !== null || child.signalCode !== null) continue
|
|
14
|
+
child.kill('SIGTERM')
|
|
15
|
+
await new Promise<void>((done) => child.once('exit', () => done()))
|
|
16
|
+
}
|
|
17
|
+
for (const directory of directories.splice(0)) {
|
|
18
|
+
rmSync(directory, { recursive: true, force: true })
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
async function waitFor(read: () => boolean, timeoutMs = 5_000) {
|
|
23
|
+
const deadline = Date.now() + timeoutMs
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
if (read()) return
|
|
26
|
+
await Bun.sleep(25)
|
|
27
|
+
}
|
|
28
|
+
throw new Error('timed out waiting for supervised pty state')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createFixture(childSource = 'await new Promise(() => {})') {
|
|
32
|
+
const directory = mkdtempSync(join(tmpdir(), 'tko-run-pty-supervision-'))
|
|
33
|
+
directories.push(directory)
|
|
34
|
+
const startsPath = join(directory, 'starts.txt')
|
|
35
|
+
writeFileSync(
|
|
36
|
+
join(directory, 'child.ts'),
|
|
37
|
+
`import { appendFileSync } from 'node:fs'\nappendFileSync(process.env.STARTS_PATH!, String(process.pid) + '\\n')\n${childSource}\n`
|
|
38
|
+
)
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(directory, 'package.json'),
|
|
41
|
+
JSON.stringify({ scripts: { child: 'bun child.ts' } })
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
directory,
|
|
46
|
+
starts() {
|
|
47
|
+
if (!existsSync(startsPath)) return []
|
|
48
|
+
return readFileSync(startsPath, 'utf8').trim().split('\n').filter(Boolean)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function startRunner(childSource = 'await new Promise(() => {})') {
|
|
54
|
+
const fixture = createFixture(childSource)
|
|
55
|
+
|
|
56
|
+
const child = spawn('node', [RUNNER, '--watch', 'child'], {
|
|
57
|
+
cwd: fixture.directory,
|
|
58
|
+
env: { ...process.env, STARTS_PATH: join(fixture.directory, 'starts.txt') },
|
|
59
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
60
|
+
})
|
|
61
|
+
running.push(child)
|
|
62
|
+
let output = ''
|
|
63
|
+
child.stdout?.on('data', (chunk) => {
|
|
64
|
+
output += String(chunk)
|
|
65
|
+
})
|
|
66
|
+
child.stderr?.on('data', (chunk) => {
|
|
67
|
+
output += String(chunk)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
child,
|
|
72
|
+
output: () => output,
|
|
73
|
+
starts: fixture.starts,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('pty run process supervision', () => {
|
|
78
|
+
test('watch restarts a child terminated by a signal', async () => {
|
|
79
|
+
const runner = startRunner()
|
|
80
|
+
await waitFor(() => runner.starts().length === 1)
|
|
81
|
+
|
|
82
|
+
process.kill(Number(runner.starts()[0]), 'SIGTERM')
|
|
83
|
+
|
|
84
|
+
await waitFor(() => runner.starts().length === 2).catch((error: unknown) => {
|
|
85
|
+
throw new Error(`${String(error)}\n${runner.output()}`)
|
|
86
|
+
})
|
|
87
|
+
expect(runner.starts()[1]).not.toBe(runner.starts()[0])
|
|
88
|
+
}, 15_000)
|
|
89
|
+
|
|
90
|
+
test('watch leaves a cleanly exited slot stopped', async () => {
|
|
91
|
+
const runner = startRunner('process.exit(0)')
|
|
92
|
+
await waitFor(() => runner.starts().length === 1)
|
|
93
|
+
|
|
94
|
+
await Bun.sleep(1_500)
|
|
95
|
+
expect(runner.starts()).toHaveLength(1)
|
|
96
|
+
}, 15_000)
|
|
97
|
+
|
|
98
|
+
test('handles SIGTERM without passing the signal name to process.exit', async () => {
|
|
99
|
+
const runner = startRunner()
|
|
100
|
+
await waitFor(() => runner.starts().length === 1)
|
|
101
|
+
|
|
102
|
+
runner.child.kill('SIGTERM')
|
|
103
|
+
const exitCode = await new Promise<number | null>((done) => {
|
|
104
|
+
runner.child.once('exit', (code) => done(code))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(exitCode).toBe(0)
|
|
108
|
+
expect(runner.output()).not.toContain('ERR_INVALID_ARG_TYPE')
|
|
109
|
+
}, 15_000)
|
|
110
|
+
})
|