@o/run 0.16.19

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.
@@ -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
+ })