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