@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
package/src/run-pty.mjs
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import { join, relative, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import pty from '@lydell/node-pty'
|
|
7
|
+
|
|
8
|
+
const colors = [
|
|
9
|
+
'\x1b[38;5;81m',
|
|
10
|
+
'\x1b[38;5;209m',
|
|
11
|
+
'\x1b[38;5;156m',
|
|
12
|
+
'\x1b[38;5;183m',
|
|
13
|
+
'\x1b[38;5;222m',
|
|
14
|
+
'\x1b[38;5;117m',
|
|
15
|
+
]
|
|
16
|
+
const reset = '\x1b[0m'
|
|
17
|
+
const dim = '\x1b[2m'
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2)
|
|
20
|
+
const ownFlags = ['--no-root', '--bun', '--watch', '--flags=last']
|
|
21
|
+
const runCommands = []
|
|
22
|
+
const forwardArgs = []
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
const arg = args[i]
|
|
26
|
+
if (arg.startsWith('--')) {
|
|
27
|
+
if (ownFlags.includes(arg)) continue
|
|
28
|
+
forwardArgs.push(arg)
|
|
29
|
+
const nextArg = args[i + 1]
|
|
30
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
31
|
+
forwardArgs.push(nextArg)
|
|
32
|
+
i++
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
runCommands.push(arg)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const noRoot = args.includes('--no-root')
|
|
40
|
+
const runBun = args.includes('--bun')
|
|
41
|
+
const flagsLast = args.includes('--flags=last')
|
|
42
|
+
const watch = args.includes('--watch')
|
|
43
|
+
const MAX_RESTARTS = 3
|
|
44
|
+
|
|
45
|
+
const processes = []
|
|
46
|
+
let focusedIndex = -1 // -1 = interleaved/dashboard
|
|
47
|
+
let exiting = false
|
|
48
|
+
|
|
49
|
+
function getPrefix(index) {
|
|
50
|
+
const p = processes[index]
|
|
51
|
+
if (!p) return ''
|
|
52
|
+
const color = colors[index % colors.length]
|
|
53
|
+
return `${color}${p.shortcut}${reset}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (runCommands.length === 0) {
|
|
57
|
+
console.error('Usage: run-pty <script1> [script2] ...')
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readPackageJson(dir) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(await fs.promises.readFile(join(dir, 'package.json'), 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getWorkspacePatterns() {
|
|
70
|
+
const pkg = await readPackageJson('.')
|
|
71
|
+
if (!pkg?.workspaces) return []
|
|
72
|
+
return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages || []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function findPackageJsonDirs(base, depth = 3) {
|
|
76
|
+
if (depth <= 0) return []
|
|
77
|
+
const results = []
|
|
78
|
+
try {
|
|
79
|
+
if (
|
|
80
|
+
await fs.promises
|
|
81
|
+
.access(join(base, 'package.json'))
|
|
82
|
+
.then(() => true)
|
|
83
|
+
.catch(() => false)
|
|
84
|
+
) {
|
|
85
|
+
results.push(base)
|
|
86
|
+
}
|
|
87
|
+
const entries = await fs.promises.readdir(base, { withFileTypes: true })
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
|
|
90
|
+
results.push(...(await findPackageJsonDirs(join(base, e.name), depth - 1)))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
return results
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function findWorkspaceScripts(scripts) {
|
|
98
|
+
const patterns = await getWorkspacePatterns()
|
|
99
|
+
if (!patterns.length) return new Map()
|
|
100
|
+
const dirs = await findPackageJsonDirs('.')
|
|
101
|
+
const result = new Map()
|
|
102
|
+
for (const dir of dirs) {
|
|
103
|
+
if (dir === '.') continue
|
|
104
|
+
const rel = relative('.', dir).replace(/\\/g, '/')
|
|
105
|
+
const matches = patterns.some((p) => {
|
|
106
|
+
const np = p.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
107
|
+
return np.endsWith('/*')
|
|
108
|
+
? rel.startsWith(np.slice(0, -1))
|
|
109
|
+
: rel === np || rel.startsWith(np + '/')
|
|
110
|
+
})
|
|
111
|
+
if (!matches) continue
|
|
112
|
+
const pkg = await readPackageJson(dir)
|
|
113
|
+
if (!pkg?.scripts) continue
|
|
114
|
+
const available = scripts.filter((s) => typeof pkg.scripts[s] === 'string')
|
|
115
|
+
if (available.length) result.set(dir, { scripts: available, name: pkg.name || dir })
|
|
116
|
+
}
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function spawnScript(name, cwd, label, extraArgs, index, restarts = 0) {
|
|
121
|
+
const runArgs = ['run', '--silent', runBun ? '--bun' : '', name, ...extraArgs].filter(
|
|
122
|
+
Boolean
|
|
123
|
+
)
|
|
124
|
+
const terminal = pty.spawn('bun', runArgs, {
|
|
125
|
+
cwd: resolve(cwd),
|
|
126
|
+
cols: process.stdout.columns || 80,
|
|
127
|
+
rows: process.stdout.rows || 24,
|
|
128
|
+
env: { ...process.env, FORCE_COLOR: '3' },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const idx = index ?? processes.length
|
|
132
|
+
const startedAt = Date.now()
|
|
133
|
+
const previous = index === undefined ? undefined : processes[index]
|
|
134
|
+
const managed = {
|
|
135
|
+
terminal,
|
|
136
|
+
name,
|
|
137
|
+
cwd,
|
|
138
|
+
label,
|
|
139
|
+
extraArgs,
|
|
140
|
+
index: idx,
|
|
141
|
+
// Keep the same keyboard shortcut when a process is restarted in-place.
|
|
142
|
+
shortcut: previous?.shortcut || '',
|
|
143
|
+
killed: false,
|
|
144
|
+
restartTimer: null,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (index !== undefined) processes[index] = managed
|
|
148
|
+
else processes.push(managed)
|
|
149
|
+
|
|
150
|
+
terminal.onData((data) => {
|
|
151
|
+
if (focusedIndex === -1) {
|
|
152
|
+
// interleaved - prefix each line, skip bun's $ command echo
|
|
153
|
+
const lines = data.split(/\r?\n/)
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (!line) continue
|
|
156
|
+
// eslint-disable-next-line no-control-regex
|
|
157
|
+
const stripped = line.replace(/\x1b\[[0-9;]*m/g, '')
|
|
158
|
+
if (stripped.startsWith('$ ')) continue
|
|
159
|
+
process.stdout.write(`${getPrefix(idx)} ${line}\n`)
|
|
160
|
+
}
|
|
161
|
+
} else if (focusedIndex === idx) {
|
|
162
|
+
// focused - raw output
|
|
163
|
+
process.stdout.write(data)
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
terminal.onExit(({ exitCode, signal }) => {
|
|
168
|
+
if (managed.killed) return
|
|
169
|
+
if (focusedIndex === idx) {
|
|
170
|
+
focusedIndex = -1
|
|
171
|
+
showDashboard()
|
|
172
|
+
}
|
|
173
|
+
const terminatedBySignal = signal != null && signal !== 0
|
|
174
|
+
if (exitCode !== 0 || terminatedBySignal) {
|
|
175
|
+
const result = terminatedBySignal ? `signal ${signal}` : `code ${exitCode}`
|
|
176
|
+
console.error(`${getPrefix(idx)} exited with ${result}`)
|
|
177
|
+
if (watch && !exiting) {
|
|
178
|
+
const effectiveRestarts = Date.now() - startedAt >= 20_000 ? 0 : restarts
|
|
179
|
+
if (effectiveRestarts < MAX_RESTARTS) {
|
|
180
|
+
const nextRestart = effectiveRestarts + 1
|
|
181
|
+
const delayMs = Math.min(1_000 * 2 ** effectiveRestarts, 30_000)
|
|
182
|
+
managed.killed = true
|
|
183
|
+
console.info(
|
|
184
|
+
`${getPrefix(idx)} restarting (${nextRestart}/${MAX_RESTARTS}) in ${Math.round(delayMs / 1_000)}s`
|
|
185
|
+
)
|
|
186
|
+
managed.restartTimer = setTimeout(() => {
|
|
187
|
+
if (exiting || processes[idx] !== managed) return
|
|
188
|
+
managed.restartTimer = null
|
|
189
|
+
spawnScript(name, cwd, label, extraArgs, idx, nextRestart)
|
|
190
|
+
}, delayMs)
|
|
191
|
+
} else {
|
|
192
|
+
console.error(`${getPrefix(idx)} gave up after ${MAX_RESTARTS} rapid restarts`)
|
|
193
|
+
cleanup(1)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return managed
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function computeShortcuts() {
|
|
203
|
+
// use last word of label
|
|
204
|
+
for (let i = 0; i < processes.length; i++) {
|
|
205
|
+
const p = processes[i]
|
|
206
|
+
const words = p.label
|
|
207
|
+
.toLowerCase()
|
|
208
|
+
.split(/[^a-z]+/)
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
const lastWord = words[words.length - 1] || words[0] || String(i)
|
|
211
|
+
|
|
212
|
+
// find unique shortcut
|
|
213
|
+
let shortcut = lastWord[0]
|
|
214
|
+
let len = 1
|
|
215
|
+
while (
|
|
216
|
+
processes.slice(0, i).some((q) => q.shortcut === shortcut) &&
|
|
217
|
+
len < lastWord.length
|
|
218
|
+
) {
|
|
219
|
+
len++
|
|
220
|
+
shortcut = lastWord.slice(0, len)
|
|
221
|
+
}
|
|
222
|
+
p.shortcut = shortcut
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function showDashboard() {
|
|
227
|
+
const tabs = processes
|
|
228
|
+
.map((p, i) => {
|
|
229
|
+
const color = colors[i % colors.length]
|
|
230
|
+
return `${color}[${p.shortcut}]${reset} ${p.label.split(' ').pop()}${p.killed ? dim + ' ✗' + reset : ''}`
|
|
231
|
+
})
|
|
232
|
+
.join(' ')
|
|
233
|
+
console.log(`\n${tabs}`)
|
|
234
|
+
console.log(
|
|
235
|
+
`${dim}press shortcut to focus, r+shortcut restart, k+shortcut kill, ctrl+c exit${reset}\n`
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let pendingAction = null // 'r' or 'k'
|
|
240
|
+
|
|
241
|
+
function handleInput(data) {
|
|
242
|
+
const str = data.toString()
|
|
243
|
+
|
|
244
|
+
// ctrl+c exit
|
|
245
|
+
if (str === '\x03') {
|
|
246
|
+
cleanup()
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ctrl+z toggle focus
|
|
251
|
+
if (str === '\x1a') {
|
|
252
|
+
if (focusedIndex >= 0) {
|
|
253
|
+
focusedIndex = -1
|
|
254
|
+
showDashboard()
|
|
255
|
+
} else {
|
|
256
|
+
showDashboard()
|
|
257
|
+
}
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// if focused, forward to process
|
|
262
|
+
if (focusedIndex >= 0) {
|
|
263
|
+
const p = processes[focusedIndex]
|
|
264
|
+
if (p && !p.killed) {
|
|
265
|
+
p.terminal.write(str)
|
|
266
|
+
}
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// dashboard mode
|
|
271
|
+
if (str === 'r') {
|
|
272
|
+
pendingAction = 'restart'
|
|
273
|
+
process.stdout.write(`${dim}restart which? ${reset}`)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
if (str === 'k') {
|
|
277
|
+
pendingAction = 'kill'
|
|
278
|
+
process.stdout.write(`${dim}kill which? ${reset}`)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// check for shortcut match
|
|
283
|
+
const match = processes.find((p) => p.shortcut === str.toLowerCase())
|
|
284
|
+
if (match) {
|
|
285
|
+
if (pendingAction === 'restart') {
|
|
286
|
+
pendingAction = null
|
|
287
|
+
console.log(match.shortcut)
|
|
288
|
+
if (match.restartTimer) {
|
|
289
|
+
clearTimeout(match.restartTimer)
|
|
290
|
+
match.restartTimer = null
|
|
291
|
+
}
|
|
292
|
+
if (!match.killed) {
|
|
293
|
+
match.killed = true
|
|
294
|
+
killProcessTree(match.terminal.pid)
|
|
295
|
+
}
|
|
296
|
+
match.restartTimer = setTimeout(() => {
|
|
297
|
+
if (exiting || processes[match.index] !== match) return
|
|
298
|
+
match.restartTimer = null
|
|
299
|
+
spawnScript(match.name, match.cwd, match.label, match.extraArgs, match.index)
|
|
300
|
+
console.log(`${getPrefix(match.index)} restarted`)
|
|
301
|
+
}, 100)
|
|
302
|
+
} else if (pendingAction === 'kill') {
|
|
303
|
+
pendingAction = null
|
|
304
|
+
console.log(match.shortcut)
|
|
305
|
+
if (match.restartTimer) {
|
|
306
|
+
clearTimeout(match.restartTimer)
|
|
307
|
+
match.restartTimer = null
|
|
308
|
+
}
|
|
309
|
+
if (!match.killed) {
|
|
310
|
+
match.killed = true
|
|
311
|
+
killProcessTree(match.terminal.pid)
|
|
312
|
+
console.log(`${getPrefix(match.index)} killed`)
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
// focus
|
|
316
|
+
focusedIndex = match.index
|
|
317
|
+
console.log(`${dim}focused: ${match.label} (ctrl+z to unfocus)${reset}\n`)
|
|
318
|
+
}
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// escape cancels pending
|
|
323
|
+
if (str === '\x1b' && pendingAction) {
|
|
324
|
+
pendingAction = null
|
|
325
|
+
console.log('cancelled')
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// kill entire process group to prevent orphans
|
|
330
|
+
function killProcessTree(pid) {
|
|
331
|
+
if (!pid) return
|
|
332
|
+
// try killing process group first (negative pid)
|
|
333
|
+
try {
|
|
334
|
+
process.kill(-pid, 'SIGTERM')
|
|
335
|
+
} catch {}
|
|
336
|
+
// also kill direct process
|
|
337
|
+
try {
|
|
338
|
+
process.kill(pid, 'SIGTERM')
|
|
339
|
+
} catch {}
|
|
340
|
+
// schedule force kill
|
|
341
|
+
setTimeout(() => {
|
|
342
|
+
try {
|
|
343
|
+
process.kill(-pid, 'SIGKILL')
|
|
344
|
+
} catch {}
|
|
345
|
+
try {
|
|
346
|
+
process.kill(pid, 'SIGKILL')
|
|
347
|
+
} catch {}
|
|
348
|
+
}, 100)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function cleanup(exitCode = 0) {
|
|
352
|
+
if (exiting) return
|
|
353
|
+
exiting = true
|
|
354
|
+
// restore terminal to cooked mode before exiting
|
|
355
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
356
|
+
try {
|
|
357
|
+
process.stdin.setRawMode(false)
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
// reset terminal attributes (colors, etc)
|
|
361
|
+
process.stdout.write('\x1b[0m\n')
|
|
362
|
+
|
|
363
|
+
for (const p of processes) {
|
|
364
|
+
if (!p.killed) {
|
|
365
|
+
p.killed = true
|
|
366
|
+
killProcessTree(p.terminal.pid)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// wait for kills to complete before exit
|
|
371
|
+
setTimeout(() => process.exit(exitCode), 150)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function main() {
|
|
375
|
+
const lastScript = runCommands[runCommands.length - 1]
|
|
376
|
+
|
|
377
|
+
if (!noRoot) {
|
|
378
|
+
const pkg = await readPackageJson('.')
|
|
379
|
+
if (pkg?.scripts) {
|
|
380
|
+
for (const name of runCommands) {
|
|
381
|
+
if (typeof pkg.scripts[name] === 'string') {
|
|
382
|
+
spawnScript(
|
|
383
|
+
name,
|
|
384
|
+
'.',
|
|
385
|
+
name,
|
|
386
|
+
!flagsLast || name === lastScript ? forwardArgs : []
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const wsScripts = await findWorkspaceScripts(runCommands)
|
|
394
|
+
for (const [dir, { scripts, name }] of wsScripts) {
|
|
395
|
+
for (const script of scripts) {
|
|
396
|
+
spawnScript(
|
|
397
|
+
script,
|
|
398
|
+
dir,
|
|
399
|
+
`${name} ${script}`,
|
|
400
|
+
!flagsLast || script === lastScript ? forwardArgs : []
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (processes.length === 0) {
|
|
406
|
+
console.error('No scripts found')
|
|
407
|
+
process.exit(1)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
computeShortcuts()
|
|
411
|
+
showDashboard()
|
|
412
|
+
|
|
413
|
+
if (process.stdin.isTTY) {
|
|
414
|
+
process.stdin.setRawMode(true)
|
|
415
|
+
process.stdin.resume()
|
|
416
|
+
process.stdin.on('data', handleInput)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
process.stdout.on('resize', () => {
|
|
420
|
+
for (const p of processes) {
|
|
421
|
+
if (!p.killed) {
|
|
422
|
+
try {
|
|
423
|
+
p.terminal.resize(process.stdout.columns || 80, process.stdout.rows || 24)
|
|
424
|
+
} catch {
|
|
425
|
+
// pty already closed
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
process.on('SIGINT', () => cleanup())
|
|
432
|
+
process.on('SIGTERM', () => cleanup())
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// restore terminal on any unexpected exit
|
|
436
|
+
function restoreTerminal() {
|
|
437
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
438
|
+
try {
|
|
439
|
+
process.stdin.setRawMode(false)
|
|
440
|
+
} catch {}
|
|
441
|
+
}
|
|
442
|
+
process.stdout.write('\x1b[0m')
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
process.on('uncaughtException', (e) => {
|
|
446
|
+
restoreTerminal()
|
|
447
|
+
console.error(e)
|
|
448
|
+
process.exit(1)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
main().catch((e) => {
|
|
452
|
+
restoreTerminal()
|
|
453
|
+
console.error(e)
|
|
454
|
+
process.exit(1)
|
|
455
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
appendFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const RUNNER = resolve(import.meta.dir, 'run.ts')
|
|
15
|
+
const running: ChildProcess[] = []
|
|
16
|
+
const directories: string[] = []
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
for (const child of running.splice(0)) {
|
|
20
|
+
if (child.exitCode !== null || child.signalCode !== null) continue
|
|
21
|
+
child.kill('SIGTERM')
|
|
22
|
+
await new Promise<void>((done) => child.once('exit', () => done()))
|
|
23
|
+
}
|
|
24
|
+
for (const directory of directories.splice(0)) {
|
|
25
|
+
rmSync(directory, { recursive: true, force: true })
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
async function waitFor(read: () => boolean, timeoutMs = 5_000) {
|
|
30
|
+
const deadline = Date.now() + timeoutMs
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
if (read()) return
|
|
33
|
+
await Bun.sleep(25)
|
|
34
|
+
}
|
|
35
|
+
throw new Error('timed out waiting for supervised process state')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function startRunner(watch: boolean) {
|
|
39
|
+
const directory = mkdtempSync(join(tmpdir(), 'tko-run-supervision-'))
|
|
40
|
+
directories.push(directory)
|
|
41
|
+
const startsPath = join(directory, 'starts.txt')
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(directory, 'child.ts'),
|
|
44
|
+
`import { appendFileSync } from 'node:fs'\nappendFileSync(process.env.STARTS_PATH!, String(process.pid) + '\\n')\nawait new Promise(() => {})\n`
|
|
45
|
+
)
|
|
46
|
+
writeFileSync(
|
|
47
|
+
join(directory, 'package.json'),
|
|
48
|
+
JSON.stringify({ scripts: { child: 'bun child.ts' } })
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const child = spawn('bun', [RUNNER, ...(watch ? ['--watch'] : []), 'child'], {
|
|
52
|
+
cwd: directory,
|
|
53
|
+
env: { ...process.env, STARTS_PATH: startsPath },
|
|
54
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
|
+
})
|
|
56
|
+
running.push(child)
|
|
57
|
+
let output = ''
|
|
58
|
+
child.stdout?.on('data', (chunk) => {
|
|
59
|
+
output += String(chunk)
|
|
60
|
+
})
|
|
61
|
+
child.stderr?.on('data', (chunk) => {
|
|
62
|
+
output += String(chunk)
|
|
63
|
+
})
|
|
64
|
+
const managedPidsPath = `/tmp/tko-run-${child.pid}.pids`
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
child,
|
|
68
|
+
managedPidsPath,
|
|
69
|
+
startsPath,
|
|
70
|
+
output() {
|
|
71
|
+
return output
|
|
72
|
+
},
|
|
73
|
+
starts() {
|
|
74
|
+
if (!existsSync(startsPath)) return []
|
|
75
|
+
return readFileSync(startsPath, 'utf8').trim().split('\n').filter(Boolean)
|
|
76
|
+
},
|
|
77
|
+
managedPids() {
|
|
78
|
+
if (!existsSync(managedPidsPath)) return []
|
|
79
|
+
return readFileSync(managedPidsPath, 'utf8').trim().split('\n').filter(Boolean)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('run process supervision', () => {
|
|
85
|
+
test('watch restarts a child terminated by a signal', async () => {
|
|
86
|
+
const runner = startRunner(true)
|
|
87
|
+
await waitFor(() => runner.starts().length === 1 && runner.managedPids().length === 1)
|
|
88
|
+
|
|
89
|
+
process.kill(Number(runner.managedPids()[0]), 'SIGTERM')
|
|
90
|
+
|
|
91
|
+
await waitFor(() => runner.starts().length === 2)
|
|
92
|
+
expect(runner.starts()[1]).not.toBe(runner.starts()[0])
|
|
93
|
+
}, 15_000)
|
|
94
|
+
})
|