@markjaquith/agency 3.2.10 → 3.2.11
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 +1 -1
- package/src/utils/process.test.ts +56 -1
- package/src/utils/process.ts +159 -99
package/package.json
CHANGED
|
@@ -1,7 +1,39 @@
|
|
|
1
1
|
import { describe, expect, spyOn, test } from "bun:test"
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { Effect, Fiber } from "effect"
|
|
3
6
|
import { spawnProcess } from "./process"
|
|
4
7
|
|
|
8
|
+
const waitFor = async <A>(attempt: () => Promise<A>): Promise<A> => {
|
|
9
|
+
const deadline = Date.now() + 2_000
|
|
10
|
+
while (true) {
|
|
11
|
+
try {
|
|
12
|
+
return await attempt()
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (Date.now() >= deadline) throw error
|
|
15
|
+
await Bun.sleep(10)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isProcessRunning = (pid: number): boolean => {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0)
|
|
23
|
+
return true
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (
|
|
26
|
+
typeof error === "object" &&
|
|
27
|
+
error !== null &&
|
|
28
|
+
"code" in error &&
|
|
29
|
+
error.code === "ESRCH"
|
|
30
|
+
) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
5
37
|
describe("spawnProcess", () => {
|
|
6
38
|
test("forwards and captures output in tee mode", async () => {
|
|
7
39
|
const forwardedStdout: Uint8Array[] = []
|
|
@@ -74,4 +106,27 @@ describe("spawnProcess", () => {
|
|
|
74
106
|
).rejects.toThrow("Process timed out")
|
|
75
107
|
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
|
76
108
|
})
|
|
109
|
+
|
|
110
|
+
test("terminates the subprocess when interrupted", async () => {
|
|
111
|
+
const directory = await mkdtemp(join(tmpdir(), "agency-process-"))
|
|
112
|
+
const pidPath = join(directory, "pid")
|
|
113
|
+
const script = [
|
|
114
|
+
`process.on("SIGTERM", () => {})`,
|
|
115
|
+
`await Bun.write(${JSON.stringify(pidPath)}, String(process.pid))`,
|
|
116
|
+
`setInterval(() => process.stdout.write("running\\n"), 10)`,
|
|
117
|
+
].join("\n")
|
|
118
|
+
const fiber = Effect.runFork(spawnProcess([process.execPath, "-e", script]))
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const pid = Number(await waitFor(() => readFile(pidPath, "utf8")))
|
|
122
|
+
expect(isProcessRunning(pid)).toBe(true)
|
|
123
|
+
|
|
124
|
+
await Effect.runPromise(Fiber.interrupt(fiber))
|
|
125
|
+
|
|
126
|
+
expect(isProcessRunning(pid)).toBe(false)
|
|
127
|
+
} finally {
|
|
128
|
+
await Effect.runPromise(Fiber.interrupt(fiber))
|
|
129
|
+
await rm(directory, { recursive: true, force: true })
|
|
130
|
+
}
|
|
131
|
+
})
|
|
77
132
|
})
|
package/src/utils/process.ts
CHANGED
|
@@ -21,22 +21,38 @@ interface SpawnOptions {
|
|
|
21
21
|
readonly timeoutMs?: number
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
interface OutputReader {
|
|
25
|
+
readonly output: Promise<string>
|
|
26
|
+
readonly cancel: () => Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const readOutput = (
|
|
25
30
|
stream: ReadableStream<Uint8Array> | null | undefined,
|
|
26
31
|
target?: { write(chunk: Uint8Array): unknown },
|
|
27
|
-
) => {
|
|
28
|
-
if (!stream)
|
|
32
|
+
): OutputReader => {
|
|
33
|
+
if (!stream) {
|
|
34
|
+
return { output: Promise.resolve(""), cancel: () => Promise.resolve() }
|
|
35
|
+
}
|
|
29
36
|
|
|
30
37
|
const reader = stream.getReader()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
return {
|
|
39
|
+
output: (async () => {
|
|
40
|
+
const decoder = new TextDecoder()
|
|
41
|
+
let output = ""
|
|
42
|
+
try {
|
|
43
|
+
while (true) {
|
|
44
|
+
const { done, value } = await reader.read()
|
|
45
|
+
if (done) break
|
|
46
|
+
target?.write(value)
|
|
47
|
+
output += decoder.decode(value, { stream: true })
|
|
48
|
+
}
|
|
49
|
+
return output + decoder.decode()
|
|
50
|
+
} finally {
|
|
51
|
+
reader.releaseLock()
|
|
52
|
+
}
|
|
53
|
+
})(),
|
|
54
|
+
cancel: () => reader.cancel(),
|
|
38
55
|
}
|
|
39
|
-
return output + decoder.decode()
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
/**
|
|
@@ -70,95 +86,139 @@ export const spawnProcess = (
|
|
|
70
86
|
args: readonly string[],
|
|
71
87
|
options?: SpawnOptions,
|
|
72
88
|
): Effect.Effect<ProcessResult, ProcessError> =>
|
|
73
|
-
Effect.
|
|
74
|
-
try
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
89
|
+
Effect.acquireUseRelease(
|
|
90
|
+
Effect.try({
|
|
91
|
+
try: () => {
|
|
92
|
+
const detached = options?.timeoutMs !== undefined
|
|
93
|
+
const proc = Bun.spawn([...args], {
|
|
94
|
+
cwd: options?.cwd ?? process.cwd(),
|
|
95
|
+
stdin: options?.stdin ?? "pipe",
|
|
96
|
+
stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
|
|
97
|
+
stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
|
|
98
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
99
|
+
detached,
|
|
100
|
+
})
|
|
101
|
+
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
102
|
+
// cannot block on filled pipe buffers before they exit.
|
|
103
|
+
const stdout =
|
|
104
|
+
options?.stdout === "inherit"
|
|
105
|
+
? readOutput(undefined)
|
|
106
|
+
: readOutput(
|
|
107
|
+
proc.stdout,
|
|
108
|
+
options?.stdout === "tee" ? process.stdout : undefined,
|
|
109
|
+
)
|
|
110
|
+
const stderr =
|
|
111
|
+
options?.stderr === "inherit"
|
|
112
|
+
? readOutput(undefined)
|
|
113
|
+
: readOutput(
|
|
114
|
+
proc.stderr,
|
|
115
|
+
options?.stderr === "tee" ? process.stderr : undefined,
|
|
116
|
+
)
|
|
100
117
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!stopped) {
|
|
121
|
-
try {
|
|
122
|
-
process.kill(-proc.pid, "SIGKILL")
|
|
123
|
-
} catch {
|
|
124
|
-
proc.kill("SIGKILL")
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
resolve(await proc.exited)
|
|
128
|
-
}, options.timeoutMs)
|
|
129
|
-
}),
|
|
118
|
+
let termination: Promise<void> | undefined
|
|
119
|
+
const terminate = (): Promise<void> => {
|
|
120
|
+
if (proc.exitCode !== null) return Promise.resolve()
|
|
121
|
+
return (termination ??= (async () => {
|
|
122
|
+
const signal = (name: "SIGTERM" | "SIGKILL") => {
|
|
123
|
+
if (!detached) {
|
|
124
|
+
proc.kill(name)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
process.kill(-proc.pid, name)
|
|
129
|
+
} catch {
|
|
130
|
+
proc.kill(name)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
signal("SIGTERM")
|
|
134
|
+
const stopped = await Promise.race([
|
|
135
|
+
proc.exited.then(() => true),
|
|
136
|
+
Bun.sleep(250).then(() => false),
|
|
130
137
|
])
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
if (!stopped) signal("SIGKILL")
|
|
139
|
+
await proc.exited
|
|
140
|
+
})())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
proc,
|
|
145
|
+
stdout,
|
|
146
|
+
stderr,
|
|
147
|
+
terminate,
|
|
148
|
+
startedAt: performance.now(),
|
|
149
|
+
state: {
|
|
150
|
+
timedOut: false,
|
|
151
|
+
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
catch: (error) =>
|
|
156
|
+
new ProcessError({
|
|
139
157
|
command: args.join(" "),
|
|
140
|
-
exitCode:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
exitCode: -1,
|
|
159
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
160
|
+
}),
|
|
161
|
+
}),
|
|
162
|
+
({ proc, stdout, stderr, terminate, startedAt, state }) =>
|
|
163
|
+
Effect.tryPromise({
|
|
164
|
+
try: async () => {
|
|
165
|
+
const exited =
|
|
166
|
+
options?.timeoutMs === undefined
|
|
167
|
+
? proc.exited
|
|
168
|
+
: Promise.race([
|
|
169
|
+
proc.exited,
|
|
170
|
+
new Promise<number>((resolve) => {
|
|
171
|
+
state.timer = setTimeout(async () => {
|
|
172
|
+
state.timedOut = true
|
|
173
|
+
await terminate()
|
|
174
|
+
resolve(await proc.exited)
|
|
175
|
+
}, options.timeoutMs)
|
|
176
|
+
}),
|
|
177
|
+
])
|
|
178
|
+
const [exitCode, stdoutOutput, stderrOutput] = await Promise.all([
|
|
179
|
+
exited,
|
|
180
|
+
stdout.output,
|
|
181
|
+
stderr.output,
|
|
182
|
+
])
|
|
183
|
+
if (state.timer) clearTimeout(state.timer)
|
|
184
|
+
if (state.timedOut) {
|
|
185
|
+
throw new ProcessError({
|
|
186
|
+
command: args.join(" "),
|
|
187
|
+
exitCode:
|
|
188
|
+
typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
|
|
189
|
+
stderr: stderrOutput.trim(),
|
|
190
|
+
timedOut: true,
|
|
191
|
+
timeoutMs: options?.timeoutMs,
|
|
192
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
193
|
+
})
|
|
194
|
+
}
|
|
148
195
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
return {
|
|
197
|
+
stdout: stdoutOutput.trim(),
|
|
198
|
+
stderr: stderrOutput.trim(),
|
|
199
|
+
exitCode:
|
|
200
|
+
typeof exitCode === "number" ? exitCode : (proc.exitCode ?? 0),
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
catch: (error) =>
|
|
204
|
+
error instanceof ProcessError
|
|
205
|
+
? error
|
|
206
|
+
: new ProcessError({
|
|
207
|
+
command: args.join(" "),
|
|
208
|
+
exitCode: -1,
|
|
209
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
210
|
+
}),
|
|
211
|
+
}),
|
|
212
|
+
({ proc, stdout, stderr, terminate, state }) =>
|
|
213
|
+
Effect.promise(async () => {
|
|
214
|
+
if (state.timer) clearTimeout(state.timer)
|
|
215
|
+
const terminating = terminate()
|
|
216
|
+
await Promise.allSettled([stdout.cancel(), stderr.cancel()])
|
|
217
|
+
await Promise.allSettled([
|
|
218
|
+
terminating,
|
|
219
|
+
proc.exited,
|
|
220
|
+
stdout.output,
|
|
221
|
+
stderr.output,
|
|
222
|
+
])
|
|
223
|
+
}),
|
|
224
|
+
)
|