@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.
- package/package.json +49 -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/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@o/run",
|
|
3
|
+
"version": "0.16.19",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/run.ts",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/run.ts",
|
|
10
|
+
"default": "./src/run.ts"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
"./helpers/*": {
|
|
14
|
+
"types": "./src/helpers/*.ts",
|
|
15
|
+
"default": "./src/helpers/*.ts"
|
|
16
|
+
},
|
|
17
|
+
"./*": {
|
|
18
|
+
"types": "./src/*.ts",
|
|
19
|
+
"default": "./src/*.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"lint": "oxlint src",
|
|
24
|
+
"lint:fix": "oxfmt src && oxlint --fix --fix-suggestions src",
|
|
25
|
+
"test:bun": "bun test src/run-supervision.test.ts src/run-pty-supervision.test.ts",
|
|
26
|
+
"typecheck": "tko run typecheck"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@lydell/node-pty": "^1.2.0-beta.3",
|
|
33
|
+
"semver": "^7.7.3"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/semver": "^7.7.1"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/natew/orez.git",
|
|
41
|
+
"directory": "packages/run"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src",
|
|
45
|
+
"src/run-pty.mjs"
|
|
46
|
+
],
|
|
47
|
+
"peerDependencies": null,
|
|
48
|
+
"optionalDependencies": null
|
|
49
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { appendFileSync, rmSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
addProcessHandler,
|
|
6
|
+
setExitCleanupState,
|
|
7
|
+
type ProcessHandler,
|
|
8
|
+
type ProcessType,
|
|
9
|
+
} from './process-state'
|
|
10
|
+
|
|
11
|
+
type ExitCallback = (info: { signal: string }) => void | Promise<void>
|
|
12
|
+
|
|
13
|
+
interface HandleProcessExitReturn {
|
|
14
|
+
addChildProcess: ProcessHandler
|
|
15
|
+
cleanup: () => Promise<void>
|
|
16
|
+
exit: (code?: number) => Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pidFilePath = `/tmp/tko-run-${process.pid}.pids`
|
|
20
|
+
|
|
21
|
+
function writePidFile(pid: number) {
|
|
22
|
+
try {
|
|
23
|
+
appendFileSync(pidFilePath, `${pid}\n`)
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function removePidFile() {
|
|
28
|
+
try {
|
|
29
|
+
rmSync(pidFilePath, { force: true })
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// find descendant pids that survived the group kill
|
|
34
|
+
// uses pgrep -P instead of ps (ps is a setuid binary blocked by sandbox-exec)
|
|
35
|
+
function findStragglers(trackedPids: number[]): number[] {
|
|
36
|
+
try {
|
|
37
|
+
const descendants = new Set<number>()
|
|
38
|
+
const queue = [...trackedPids]
|
|
39
|
+
while (queue.length > 0) {
|
|
40
|
+
const current = queue.pop()!
|
|
41
|
+
try {
|
|
42
|
+
const output = execSync(`pgrep -P ${current}`, {
|
|
43
|
+
encoding: 'utf-8',
|
|
44
|
+
timeout: 2000,
|
|
45
|
+
})
|
|
46
|
+
for (const line of output.trim().split('\n')) {
|
|
47
|
+
const pid = parseInt(line, 10)
|
|
48
|
+
if (!isNaN(pid) && !descendants.has(pid)) {
|
|
49
|
+
descendants.add(pid)
|
|
50
|
+
queue.push(pid)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// pgrep exits 1 when no children found, that's fine
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...descendants]
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// kill entire process group to prevent orphaned descendants
|
|
64
|
+
function killProcess(
|
|
65
|
+
pid: number,
|
|
66
|
+
signal: NodeJS.Signals = 'SIGTERM',
|
|
67
|
+
forceful: boolean = false
|
|
68
|
+
): void {
|
|
69
|
+
// kill process group first (negative pid), then direct process
|
|
70
|
+
try {
|
|
71
|
+
process.kill(-pid, signal)
|
|
72
|
+
} catch (_) {}
|
|
73
|
+
try {
|
|
74
|
+
process.kill(pid, signal)
|
|
75
|
+
} catch (_) {}
|
|
76
|
+
|
|
77
|
+
if (forceful && signal !== 'SIGKILL') {
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(-pid, 'SIGKILL')
|
|
81
|
+
} catch (_) {}
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, 'SIGKILL')
|
|
84
|
+
} catch (_) {}
|
|
85
|
+
}, 100)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let isHandling = false
|
|
90
|
+
|
|
91
|
+
export function handleProcessExit({
|
|
92
|
+
onExit,
|
|
93
|
+
}: {
|
|
94
|
+
onExit?: ExitCallback
|
|
95
|
+
} = {}): HandleProcessExitReturn {
|
|
96
|
+
if (isHandling) {
|
|
97
|
+
throw new Error(`Only one handleProcessExit per process should be registered!`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
isHandling = true
|
|
101
|
+
const processes: ProcessType[] = []
|
|
102
|
+
let cleanupPromise: Promise<void> | null = null
|
|
103
|
+
|
|
104
|
+
const cleanup = (signal: string = 'SIGTERM'): Promise<void> => {
|
|
105
|
+
// return existing cleanup promise if already running, so process.exit
|
|
106
|
+
// override waits for the real cleanup instead of exiting early
|
|
107
|
+
if (cleanupPromise) return cleanupPromise
|
|
108
|
+
|
|
109
|
+
cleanupPromise = doCleanup(signal)
|
|
110
|
+
return cleanupPromise
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const doCleanup = async (signal: string) => {
|
|
114
|
+
setExitCleanupState(true)
|
|
115
|
+
|
|
116
|
+
if (onExit) {
|
|
117
|
+
try {
|
|
118
|
+
await onExit({ signal })
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error('Error in exit callback:', error)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (processes.length === 0) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isInterrupt = signal === 'SIGINT'
|
|
129
|
+
const killSignal = isInterrupt ? 'SIGTERM' : (signal as NodeJS.Signals)
|
|
130
|
+
|
|
131
|
+
for (const proc of processes) {
|
|
132
|
+
if (proc.pid) {
|
|
133
|
+
killProcess(proc.pid, killSignal, isInterrupt)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// brief wait for graceful shutdown
|
|
138
|
+
await new Promise((res) => setTimeout(res, isInterrupt ? 80 : 200))
|
|
139
|
+
|
|
140
|
+
// force kill any remaining
|
|
141
|
+
for (const proc of processes) {
|
|
142
|
+
if (proc.pid && !proc.exitCode) {
|
|
143
|
+
killProcess(proc.pid, 'SIGKILL')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// straggler walk: find any descendants that survived the group kill
|
|
148
|
+
const trackedPids = processes.map((p) => p.pid).filter(Boolean) as number[]
|
|
149
|
+
if (trackedPids.length > 0) {
|
|
150
|
+
const stragglers = findStragglers(trackedPids)
|
|
151
|
+
for (const pid of stragglers) {
|
|
152
|
+
try {
|
|
153
|
+
process.kill(pid, 'SIGKILL')
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
removePidFile()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const addChildProcess = (proc: ProcessType) => {
|
|
162
|
+
processes.push(proc)
|
|
163
|
+
if (proc.pid) writePidFile(proc.pid)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
addProcessHandler(addChildProcess)
|
|
167
|
+
|
|
168
|
+
let finalized = false
|
|
169
|
+
|
|
170
|
+
const finalizeWithSignal = (signal: NodeJS.Signals, fallbackCode: number) => {
|
|
171
|
+
if (finalized) return
|
|
172
|
+
finalized = true
|
|
173
|
+
process.off('SIGINT', sigintHandler)
|
|
174
|
+
process.off('SIGTERM', sigtermHandler)
|
|
175
|
+
process.off('SIGHUP', sighupHandler)
|
|
176
|
+
process.off('beforeExit', beforeExitHandler)
|
|
177
|
+
process.exit = originalExit
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
process.kill(process.pid, signal)
|
|
181
|
+
} catch {
|
|
182
|
+
originalExit(fallbackCode)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const sigtermHandler = () => {
|
|
187
|
+
cleanup('SIGTERM').then(() => {
|
|
188
|
+
finalizeWithSignal('SIGTERM', 143)
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sigintHandler = () => {
|
|
193
|
+
cleanup('SIGINT').then(() => {
|
|
194
|
+
finalizeWithSignal('SIGINT', 130)
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const sighupHandler = () => {
|
|
199
|
+
cleanup('SIGHUP').then(() => {
|
|
200
|
+
finalizeWithSignal('SIGHUP', 129)
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// intercept process.exit to ensure cleanup completes before exiting.
|
|
205
|
+
// if cleanup is already running, this awaits the SAME promise instead of
|
|
206
|
+
// early-returning and calling originalExit prematurely.
|
|
207
|
+
const originalExit = process.exit
|
|
208
|
+
process.exit = ((code?: number) => {
|
|
209
|
+
cleanup('SIGTERM').then(() => {
|
|
210
|
+
originalExit(code)
|
|
211
|
+
})
|
|
212
|
+
}) as typeof process.exit
|
|
213
|
+
|
|
214
|
+
const beforeExitHandler = () => cleanup('SIGTERM')
|
|
215
|
+
process.on('beforeExit', beforeExitHandler)
|
|
216
|
+
process.on('SIGINT', sigintHandler)
|
|
217
|
+
process.on('SIGTERM', sigtermHandler)
|
|
218
|
+
process.on('SIGHUP', sighupHandler)
|
|
219
|
+
|
|
220
|
+
const exit = async (code: number = 0) => {
|
|
221
|
+
await cleanup('SIGTERM')
|
|
222
|
+
process.exit(code)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
addChildProcess,
|
|
227
|
+
cleanup,
|
|
228
|
+
exit,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, describe, expect, it } 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 waitForDead(pid: number, timeoutMs = 3000): Promise<boolean> {
|
|
18
|
+
const start = Date.now()
|
|
19
|
+
while (Date.now() - start < timeoutMs) {
|
|
20
|
+
if (!isAlive(pid)) return true
|
|
21
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function waitForAlive(pid: number, timeoutMs = 3000): Promise<boolean> {
|
|
27
|
+
const start = Date.now()
|
|
28
|
+
while (Date.now() - start < timeoutMs) {
|
|
29
|
+
if (isAlive(pid)) return true
|
|
30
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function waitForFile(path: string, timeoutMs = 3000): Promise<boolean> {
|
|
36
|
+
const start = Date.now()
|
|
37
|
+
while (Date.now() - start < timeoutMs) {
|
|
38
|
+
if (existsSync(path)) return true
|
|
39
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('process cleanup', () => {
|
|
45
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'tko-test-'))
|
|
46
|
+
const pids: number[] = []
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const pid of pids) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(-pid, 'SIGKILL')
|
|
52
|
+
} catch {}
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 'SIGKILL')
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
pids.length = 0
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
try {
|
|
62
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
63
|
+
} catch {}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('detached child gets its own process group', async () => {
|
|
67
|
+
const pidFile = join(tmpDir, 'child1.pid')
|
|
68
|
+
const child = spawn('bash', ['-c', `echo $$ > ${pidFile}; sleep 999`], {
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: 'ignore',
|
|
71
|
+
})
|
|
72
|
+
pids.push(child.pid!)
|
|
73
|
+
child.unref()
|
|
74
|
+
|
|
75
|
+
expect(await waitForFile(pidFile)).toBe(true)
|
|
76
|
+
const childPid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10)
|
|
77
|
+
|
|
78
|
+
expect(await waitForAlive(childPid)).toBe(true)
|
|
79
|
+
|
|
80
|
+
// killing the process group should kill it
|
|
81
|
+
try {
|
|
82
|
+
process.kill(-child.pid!, 'SIGKILL')
|
|
83
|
+
} catch {}
|
|
84
|
+
expect(await waitForDead(childPid)).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('process group kill reaches grandchildren', async () => {
|
|
88
|
+
const grandchildPidFile = join(tmpDir, 'grandchild.pid')
|
|
89
|
+
// write a helper script so escaping is clean
|
|
90
|
+
const helperScript = join(tmpDir, 'grandchild-helper.sh')
|
|
91
|
+
writeFileSync(
|
|
92
|
+
helperScript,
|
|
93
|
+
`#!/bin/bash\necho $$ > ${grandchildPidFile}\nsleep 999\n`,
|
|
94
|
+
{ mode: 0o755 }
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const parent = spawn('bash', ['-c', `bash ${helperScript} & wait`], {
|
|
98
|
+
detached: true,
|
|
99
|
+
stdio: 'ignore',
|
|
100
|
+
})
|
|
101
|
+
pids.push(parent.pid!)
|
|
102
|
+
|
|
103
|
+
expect(await waitForFile(grandchildPidFile)).toBe(true)
|
|
104
|
+
const grandchildPid = parseInt(readFileSync(grandchildPidFile, 'utf-8').trim(), 10)
|
|
105
|
+
pids.push(grandchildPid)
|
|
106
|
+
|
|
107
|
+
expect(await waitForAlive(grandchildPid)).toBe(true)
|
|
108
|
+
|
|
109
|
+
// kill the process group — grandchild should die too
|
|
110
|
+
try {
|
|
111
|
+
process.kill(-parent.pid!, 'SIGKILL')
|
|
112
|
+
} catch {}
|
|
113
|
+
expect(await waitForDead(grandchildPid)).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('SIGTERM on parent with trap kills children', async () => {
|
|
117
|
+
const childPidFile = join(tmpDir, 'term-child.pid')
|
|
118
|
+
const wrapperScript = join(tmpDir, 'wrapper.sh')
|
|
119
|
+
writeFileSync(
|
|
120
|
+
wrapperScript,
|
|
121
|
+
[
|
|
122
|
+
'#!/bin/bash',
|
|
123
|
+
'cleanup() { kill 0; exit 0; }',
|
|
124
|
+
'trap cleanup SIGTERM',
|
|
125
|
+
`bash -c 'echo $$ > ${childPidFile}; sleep 999' &`,
|
|
126
|
+
'wait',
|
|
127
|
+
'',
|
|
128
|
+
].join('\n'),
|
|
129
|
+
{ mode: 0o755 }
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const parent = spawn('bash', [wrapperScript], {
|
|
133
|
+
detached: true,
|
|
134
|
+
stdio: 'ignore',
|
|
135
|
+
})
|
|
136
|
+
pids.push(parent.pid!)
|
|
137
|
+
|
|
138
|
+
expect(await waitForFile(childPidFile)).toBe(true)
|
|
139
|
+
const childPid = parseInt(readFileSync(childPidFile, 'utf-8').trim(), 10)
|
|
140
|
+
pids.push(childPid)
|
|
141
|
+
|
|
142
|
+
expect(await waitForAlive(childPid)).toBe(true)
|
|
143
|
+
|
|
144
|
+
// send SIGTERM to the process group
|
|
145
|
+
try {
|
|
146
|
+
process.kill(-parent.pid!, 'SIGTERM')
|
|
147
|
+
} catch {}
|
|
148
|
+
expect(await waitForDead(childPid, 5000)).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export type ProcessType = ChildProcess
|
|
4
|
+
export type ProcessHandler = (process: ProcessType) => void
|
|
5
|
+
|
|
6
|
+
// shared state: tracks if we're in cleanup (another process failed)
|
|
7
|
+
let isInExitCleanup = false
|
|
8
|
+
|
|
9
|
+
export function setExitCleanupState(state: boolean) {
|
|
10
|
+
isInExitCleanup = state
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getIsExiting() {
|
|
14
|
+
return isInExitCleanup
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// handlers called when any process is spawned via run()
|
|
18
|
+
const processHandlers = new Set<ProcessHandler>()
|
|
19
|
+
|
|
20
|
+
export const addProcessHandler = (cb: ProcessHandler) => {
|
|
21
|
+
processHandlers.add(cb)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function notifyProcessHandlers(proc: ProcessType) {
|
|
25
|
+
processHandlers.forEach((cb) => cb(proc))
|
|
26
|
+
}
|