@oh-my-pi/pi-utils 8.12.2 → 8.12.4
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/index.ts +1 -1
- package/src/ptree.ts +357 -361
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -5,6 +5,6 @@ export * from "./glob";
|
|
|
5
5
|
export * as logger from "./logger";
|
|
6
6
|
export * as postmortem from "./postmortem";
|
|
7
7
|
export * as ptree from "./ptree";
|
|
8
|
-
export { AbortError, ChildProcess,
|
|
8
|
+
export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
|
|
9
9
|
export * from "./stream";
|
|
10
10
|
export * from "./temp";
|
package/src/ptree.ts
CHANGED
|
@@ -1,455 +1,442 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Process tree management utilities for Bun subprocesses.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
4
|
+
* Exposes the same public interface as the original implementation, but with
|
|
5
|
+
* much less code:
|
|
6
|
+
* - Track managed child processes for cleanup on shutdown (postmortem).
|
|
7
|
+
* - Drain stdout/stderr to avoid subprocess pipe deadlocks.
|
|
8
|
+
* - Cross-platform tree kill for process groups (Windows taskkill, Unix -pid).
|
|
9
|
+
* - Convenience helpers: captureText / execText, AbortSignal, timeouts.
|
|
8
10
|
*/
|
|
9
|
-
import { type FileSink, type Spawn, type Subprocess
|
|
11
|
+
import { $, type FileSink, type Spawn, type Subprocess } from "bun";
|
|
10
12
|
import { postmortem } from ".";
|
|
11
13
|
|
|
12
|
-
// Platform detection: process tree kill behavior differs.
|
|
13
14
|
const isWindows = process.platform === "win32";
|
|
15
|
+
const managedChildren = new Set<ChildProcess>();
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
/** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
|
|
18
|
+
type PipedSubprocess = Subprocess<"pipe" | "ignore" | null, "pipe", "pipe">;
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** Minimal push-based ReadableStream that buffers unboundedly (like the old queue). */
|
|
21
|
+
function pushStream<T>() {
|
|
22
|
+
let controller!: ReadableStreamDefaultController<T>;
|
|
23
|
+
let closed = false;
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
}
|
|
25
|
+
const stream = new ReadableStream<T>({
|
|
26
|
+
start(c) {
|
|
27
|
+
controller = c;
|
|
28
|
+
},
|
|
29
|
+
cancel() {
|
|
30
|
+
closed = true; // consumer no longer cares; keep draining but drop
|
|
31
|
+
},
|
|
32
|
+
});
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
return {
|
|
35
|
+
stream,
|
|
36
|
+
push(value: T) {
|
|
37
|
+
if (closed) return;
|
|
38
|
+
try {
|
|
39
|
+
controller.enqueue(value);
|
|
40
|
+
} catch {
|
|
41
|
+
closed = true;
|
|
40
42
|
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
+
},
|
|
44
|
+
close() {
|
|
45
|
+
if (closed) return;
|
|
46
|
+
closed = true;
|
|
47
|
+
try {
|
|
48
|
+
controller.close();
|
|
49
|
+
} catch {}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return await new Promise<IteratorResult<T>>(resolve => {
|
|
52
|
-
this.#resolvers.push(resolve);
|
|
53
|
-
});
|
|
54
|
-
}
|
|
54
|
+
const DONE = { done: true, value: undefined } as const;
|
|
55
|
+
|
|
56
|
+
function abortRead(signal: AbortSignal) {
|
|
57
|
+
if (signal.aborted) return Promise.resolve(DONE);
|
|
58
|
+
const { promise, resolve } = Promise.withResolvers<typeof DONE>();
|
|
59
|
+
signal.addEventListener("abort", () => resolve(DONE), { once: true });
|
|
60
|
+
return promise;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
/** Drain a ReadableStream into a pushStream, optionally tapping each chunk. */
|
|
64
|
+
async function pump(
|
|
65
|
+
src: ReadableStream<Uint8Array>,
|
|
66
|
+
dst: ReturnType<typeof pushStream<Uint8Array>>,
|
|
67
|
+
opts?: { signal?: AbortSignal; onChunk?: (chunk: Uint8Array) => void; onFinally?: () => void },
|
|
68
|
+
) {
|
|
69
|
+
const reader = src.getReader();
|
|
70
|
+
const stop = opts?.signal ? abortRead(opts.signal) : null;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
while (true) {
|
|
74
|
+
const r = stop ? await Promise.race([reader.read(), stop]) : await reader.read();
|
|
75
|
+
if (r.done) break;
|
|
76
|
+
if (!r.value) continue;
|
|
77
|
+
opts?.onChunk?.(r.value);
|
|
78
|
+
dst.push(r.value);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore; this module is "best effort" for streaming/cleanup
|
|
82
|
+
} finally {
|
|
83
|
+
try {
|
|
84
|
+
await reader.cancel();
|
|
85
|
+
} catch {}
|
|
86
|
+
try {
|
|
87
|
+
reader.releaseLock();
|
|
88
|
+
} catch {}
|
|
89
|
+
dst.close();
|
|
90
|
+
opts?.onFinally?.();
|
|
91
|
+
}
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
/**
|
|
72
95
|
* Kill a child process and its descendents.
|
|
73
|
-
* - Windows:
|
|
74
|
-
* - Unix: negative PID
|
|
96
|
+
* - Windows: taskkill /T, add /F on SIGKILL
|
|
97
|
+
* - Unix: negative PID signals the process group
|
|
75
98
|
*/
|
|
76
|
-
function killChild(child:
|
|
99
|
+
async function killChild(child: ChildProcess) {
|
|
77
100
|
const pid = child.pid;
|
|
78
|
-
if (!pid) return;
|
|
101
|
+
if (!pid || child.killed) return;
|
|
79
102
|
|
|
103
|
+
const exited = child.proc.exited.then(
|
|
104
|
+
() => true,
|
|
105
|
+
() => true,
|
|
106
|
+
);
|
|
107
|
+
const waitForExit = (timeout = 1000) => Promise.race([Bun.sleep(timeout).then(() => false), exited]);
|
|
108
|
+
|
|
109
|
+
// Give it a moment to exit gracefully first.
|
|
80
110
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
child.proc.kill();
|
|
112
|
+
} catch {}
|
|
113
|
+
if (await waitForExit(1000)) return true;
|
|
114
|
+
|
|
115
|
+
if (child.isProcessGroup) {
|
|
116
|
+
try {
|
|
117
|
+
if (isWindows) {
|
|
118
|
+
await $`taskkill /F /T /PID ${pid}`.quiet().nothrow();
|
|
119
|
+
} else {
|
|
120
|
+
process.kill(-pid);
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
child.proc.kill("SIGKILL");
|
|
126
|
+
} catch {}
|
|
92
127
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
128
|
+
return await waitForExit(1000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
postmortem.register("managed-children", async () => {
|
|
132
|
+
const children = Array.from(managedChildren);
|
|
133
|
+
managedChildren.clear();
|
|
134
|
+
await Promise.all(children.map(killChild));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Options for waiting for process exit and capturing output.
|
|
139
|
+
*/
|
|
140
|
+
export interface WaitOptions {
|
|
141
|
+
allowNonZero?: boolean;
|
|
142
|
+
allowAbort?: boolean;
|
|
143
|
+
stderr?: "full" | "buffer";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Result from wait and captureText.
|
|
148
|
+
*/
|
|
149
|
+
export interface ExecResult {
|
|
150
|
+
stdout: string;
|
|
151
|
+
stderr: string;
|
|
152
|
+
exitCode: number | null;
|
|
153
|
+
ok: boolean;
|
|
154
|
+
exitError?: Exception;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Base for all exceptions representing child process nonzero exit, killed, or cancellation.
|
|
159
|
+
*/
|
|
160
|
+
export abstract class Exception extends Error {
|
|
161
|
+
constructor(
|
|
162
|
+
message: string,
|
|
163
|
+
public readonly exitCode: number,
|
|
164
|
+
public readonly stderr: string,
|
|
165
|
+
) {
|
|
166
|
+
super(message);
|
|
167
|
+
this.name = this.constructor.name;
|
|
100
168
|
}
|
|
169
|
+
abstract get aborted(): boolean;
|
|
101
170
|
}
|
|
102
171
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Exception for nonzero exit codes (not cancellation).
|
|
174
|
+
*/
|
|
175
|
+
export class NonZeroExitError extends Exception {
|
|
176
|
+
static readonly MAX_TRACE = 32 * 1024;
|
|
177
|
+
|
|
178
|
+
constructor(
|
|
179
|
+
public readonly exitCode: number,
|
|
180
|
+
public readonly stderr: string,
|
|
181
|
+
) {
|
|
182
|
+
super(`Process exited with code ${exitCode}:\n${stderr}`, exitCode, stderr);
|
|
107
183
|
}
|
|
108
|
-
|
|
184
|
+
get aborted(): boolean {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
109
188
|
|
|
110
189
|
/**
|
|
111
|
-
*
|
|
112
|
-
* Will attach to exit Promise so removal happens even if child exits "naturally".
|
|
190
|
+
* Exception for explicit process abortion (via signal).
|
|
113
191
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
192
|
+
export class AbortError extends Exception {
|
|
193
|
+
constructor(
|
|
194
|
+
public readonly reason: unknown,
|
|
195
|
+
stderr: string,
|
|
196
|
+
) {
|
|
197
|
+
const reasonString = reason instanceof Error ? reason.message : String(reason ?? "aborted");
|
|
198
|
+
super(`Operation cancelled: ${reasonString}`, -1, stderr);
|
|
199
|
+
}
|
|
200
|
+
get aborted(): boolean {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
124
203
|
}
|
|
125
204
|
|
|
126
|
-
|
|
127
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Exception for process timeout.
|
|
207
|
+
*/
|
|
208
|
+
export class TimeoutError extends AbortError {
|
|
209
|
+
constructor(timeout: number, stderr: string) {
|
|
210
|
+
super(new Error(`Timed out after ${Math.round(timeout / 1000)}s`), stderr);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
128
213
|
|
|
129
214
|
/**
|
|
130
|
-
* ChildProcess wraps a managed subprocess, capturing
|
|
215
|
+
* ChildProcess wraps a managed subprocess, capturing stderr tail, providing
|
|
131
216
|
* cross-platform kill/detach logic plus AbortSignal integration.
|
|
132
217
|
*/
|
|
133
218
|
export class ChildProcess {
|
|
134
|
-
#proc: PipedSubprocess;
|
|
135
|
-
#detached = false;
|
|
136
219
|
#nothrow = false;
|
|
220
|
+
|
|
137
221
|
#stderrBuffer = "";
|
|
138
|
-
#stdoutQueue = new AsyncQueue<Uint8Array>();
|
|
139
|
-
#stderrQueue = new AsyncQueue<Uint8Array>();
|
|
140
|
-
#stdoutStream?: ReadableStream<Uint8Array>;
|
|
141
|
-
#stderrStream?: ReadableStream<Uint8Array>;
|
|
142
222
|
#exitReason?: Exception;
|
|
143
223
|
#exitReasonPending?: Exception;
|
|
224
|
+
|
|
225
|
+
#stop = new AbortController();
|
|
226
|
+
|
|
227
|
+
#stdoutOut = pushStream<Uint8Array>();
|
|
228
|
+
#stderrOut = pushStream<Uint8Array>();
|
|
229
|
+
|
|
230
|
+
#stderrDone: Promise<void>;
|
|
144
231
|
#exited: Promise<number>;
|
|
145
|
-
#resolveExited: (ex?: PromiseLike<Exception> | Exception) => void;
|
|
146
232
|
|
|
147
|
-
constructor(
|
|
148
|
-
|
|
233
|
+
constructor(
|
|
234
|
+
public readonly proc: PipedSubprocess,
|
|
235
|
+
public readonly isProcessGroup: boolean,
|
|
236
|
+
) {
|
|
237
|
+
const { promise: stderrDone, resolve: resolveStderrDone } = Promise.withResolvers<void>();
|
|
238
|
+
this.#stderrDone = stderrDone;
|
|
149
239
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
() => {},
|
|
153
|
-
);
|
|
240
|
+
// Drain stdout always -> expose our buffered stream to the user.
|
|
241
|
+
void pump(proc.stdout, this.#stdoutOut, { signal: this.#stop.signal }).catch(() => this.#stdoutOut.close());
|
|
154
242
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const result = await Promise.race([
|
|
161
|
-
reader.read(),
|
|
162
|
-
exitSettled.then(() => ({ done: true, value: undefined as Uint8Array | undefined })),
|
|
163
|
-
]);
|
|
164
|
-
if (result.done) break;
|
|
165
|
-
if (!result.value) continue;
|
|
166
|
-
this.#stdoutQueue.push(result.value);
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
// ignore
|
|
170
|
-
} finally {
|
|
171
|
-
try {
|
|
172
|
-
await reader.cancel();
|
|
173
|
-
} catch {}
|
|
174
|
-
try {
|
|
175
|
-
reader.releaseLock();
|
|
176
|
-
} catch {}
|
|
177
|
-
this.#stdoutQueue.close();
|
|
243
|
+
// Drain stderr always -> expose stream + keep a bounded tail buffer.
|
|
244
|
+
const decoder = new TextDecoder();
|
|
245
|
+
const trim = () => {
|
|
246
|
+
if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
|
|
247
|
+
this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
|
|
178
248
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
249
|
+
};
|
|
250
|
+
void pump(proc.stderr, this.#stderrOut, {
|
|
251
|
+
signal: this.#stop.signal,
|
|
252
|
+
onChunk: chunk => {
|
|
253
|
+
this.#stderrBuffer += decoder.decode(chunk, { stream: true });
|
|
254
|
+
trim();
|
|
255
|
+
},
|
|
256
|
+
onFinally: () => {
|
|
257
|
+
this.#stderrBuffer += decoder.decode();
|
|
258
|
+
trim();
|
|
259
|
+
resolveStderrDone();
|
|
260
|
+
},
|
|
261
|
+
}).catch(() => {
|
|
262
|
+
try {
|
|
263
|
+
this.#stderrBuffer += decoder.decode();
|
|
264
|
+
trim();
|
|
265
|
+
} catch {}
|
|
266
|
+
this.#stderrOut.close();
|
|
267
|
+
resolveStderrDone();
|
|
181
268
|
});
|
|
182
269
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.#stderrBuffer += decoder.decode(result.value, { stream: true });
|
|
197
|
-
if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
|
|
198
|
-
this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
|
|
199
|
-
}
|
|
270
|
+
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
271
|
+
this.#exited = promise;
|
|
272
|
+
|
|
273
|
+
if (this.proc.exitCode === null) managedChildren.add(this);
|
|
274
|
+
|
|
275
|
+
// Normalize Bun's exited promise into our "exitReason / exitedCleanly" model.
|
|
276
|
+
proc.exited
|
|
277
|
+
.catch(() => null)
|
|
278
|
+
.then(async exitCode => {
|
|
279
|
+
if (this.#exitReasonPending) {
|
|
280
|
+
this.#exitReason = this.#exitReasonPending;
|
|
281
|
+
reject(this.#exitReasonPending);
|
|
282
|
+
return;
|
|
200
283
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
|
|
206
|
-
this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
|
|
284
|
+
|
|
285
|
+
if (exitCode === 0) {
|
|
286
|
+
resolve(0);
|
|
287
|
+
return;
|
|
207
288
|
}
|
|
208
|
-
try {
|
|
209
|
-
await reader.cancel();
|
|
210
|
-
} catch {}
|
|
211
|
-
try {
|
|
212
|
-
reader.releaseLock();
|
|
213
|
-
} catch {}
|
|
214
|
-
this.#stderrQueue.close();
|
|
215
|
-
}
|
|
216
|
-
})().catch(() => {
|
|
217
|
-
this.#stderrQueue.close();
|
|
218
|
-
});
|
|
219
289
|
|
|
220
|
-
|
|
290
|
+
await this.#stderrDone;
|
|
221
291
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
this.#exitReason = ex;
|
|
228
|
-
return Promise.reject(ex);
|
|
229
|
-
});
|
|
230
|
-
this.#resolveExited = resolve;
|
|
292
|
+
if (exitCode !== null) {
|
|
293
|
+
this.#exitReason = new NonZeroExitError(exitCode, this.#stderrBuffer);
|
|
294
|
+
resolve(exitCode);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
231
297
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
resolve(new NonZeroExitError(exitCode, this.#stderrBuffer));
|
|
236
|
-
} else {
|
|
237
|
-
resolve(undefined);
|
|
238
|
-
}
|
|
239
|
-
});
|
|
298
|
+
const ex = this.proc.killed
|
|
299
|
+
? new AbortError(new Error("process killed"), this.#stderrBuffer)
|
|
300
|
+
: new NonZeroExitError(-1, this.#stderrBuffer);
|
|
240
301
|
|
|
241
|
-
|
|
302
|
+
this.#exitReason = ex;
|
|
303
|
+
reject(ex);
|
|
304
|
+
})
|
|
305
|
+
.finally(() => {
|
|
306
|
+
managedChildren.delete(this);
|
|
307
|
+
});
|
|
242
308
|
}
|
|
243
309
|
|
|
244
310
|
get pid(): number | undefined {
|
|
245
|
-
return this
|
|
311
|
+
return this.proc.pid;
|
|
246
312
|
}
|
|
247
313
|
get exited(): Promise<number> {
|
|
248
314
|
return this.#exited;
|
|
249
315
|
}
|
|
316
|
+
get exitedCleanly(): Promise<number> {
|
|
317
|
+
if (this.#nothrow) return this.exited;
|
|
318
|
+
return this.exited.then(code => {
|
|
319
|
+
if (code !== 0) throw new NonZeroExitError(code, this.#stderrBuffer);
|
|
320
|
+
return code;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
250
323
|
get exitCode(): number | null {
|
|
251
|
-
return this
|
|
324
|
+
return this.proc.exitCode;
|
|
252
325
|
}
|
|
253
326
|
get exitReason(): Exception | undefined {
|
|
254
327
|
return this.#exitReason;
|
|
255
328
|
}
|
|
256
329
|
get killed(): boolean {
|
|
257
|
-
return this
|
|
330
|
+
return this.proc.killed;
|
|
258
331
|
}
|
|
259
332
|
get stdin(): FileSink | undefined {
|
|
260
|
-
return this
|
|
333
|
+
return this.proc.stdin;
|
|
261
334
|
}
|
|
262
335
|
get stdout(): ReadableStream<Uint8Array> {
|
|
263
|
-
|
|
264
|
-
this.#stdoutStream = createProcessStream(this.#stdoutQueue);
|
|
265
|
-
}
|
|
266
|
-
return this.#stdoutStream;
|
|
336
|
+
return this.#stdoutOut.stream;
|
|
267
337
|
}
|
|
268
338
|
get stderr(): ReadableStream<Uint8Array> {
|
|
269
|
-
|
|
270
|
-
this.#stderrStream = createProcessStream(this.#stderrQueue);
|
|
271
|
-
}
|
|
272
|
-
return this.#stderrStream;
|
|
339
|
+
return this.#stderrOut.stream;
|
|
273
340
|
}
|
|
274
341
|
|
|
275
|
-
/**
|
|
276
|
-
* Peek at the stderr buffer.
|
|
277
|
-
* @returns The stderr buffer.
|
|
278
|
-
*/
|
|
279
342
|
peekStderr(): string {
|
|
280
343
|
return this.#stderrBuffer;
|
|
281
344
|
}
|
|
282
345
|
|
|
283
|
-
/**
|
|
284
|
-
* Detach this process from management (no cleanup on shutdown).
|
|
285
|
-
*/
|
|
286
|
-
detach(): void {
|
|
287
|
-
if (this.#detached || this.#proc.killed) return;
|
|
288
|
-
this.#detached = true;
|
|
289
|
-
if (managedChildren.delete(this.#proc)) {
|
|
290
|
-
this.#proc.unref();
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Prevents thrown ChildError on nonzero exit code, for optional error handling.
|
|
296
|
-
*/
|
|
297
346
|
nothrow(): this {
|
|
298
347
|
this.#nothrow = true;
|
|
299
348
|
return this;
|
|
300
349
|
}
|
|
301
350
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (this.#proc.killed) return;
|
|
308
|
-
if (reason) {
|
|
309
|
-
this.#exitReasonPending = reason;
|
|
310
|
-
}
|
|
311
|
-
killChild(this.#proc, signal);
|
|
351
|
+
kill(reason?: Exception) {
|
|
352
|
+
if (reason && !this.#exitReasonPending) this.#exitReasonPending = reason;
|
|
353
|
+
this.#stop.abort();
|
|
354
|
+
if (this.proc.killed) return;
|
|
355
|
+
void killChild(this);
|
|
312
356
|
}
|
|
313
357
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
this.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
]);
|
|
321
|
-
if (exitedOrTimeout === "timeout") {
|
|
322
|
-
this.kill("SIGKILL");
|
|
323
|
-
await this.exited.catch(() => {});
|
|
324
|
-
}
|
|
358
|
+
// Output helpers
|
|
359
|
+
async blob(): Promise<Blob> {
|
|
360
|
+
const blobPromise = new Response(this.stdout).blob();
|
|
361
|
+
if (this.#nothrow) return await blobPromise;
|
|
362
|
+
const [blob] = await Promise.all([blobPromise, this.exitedCleanly]);
|
|
363
|
+
return blob;
|
|
325
364
|
}
|
|
326
|
-
|
|
327
|
-
// Output utilities (aliases for easy chaining)
|
|
328
365
|
async text(): Promise<string> {
|
|
329
366
|
return (await this.blob()).text();
|
|
330
367
|
}
|
|
331
368
|
async json(): Promise<unknown> {
|
|
332
|
-
return (await this.blob()).json();
|
|
369
|
+
return await new Response(await this.blob()).json();
|
|
333
370
|
}
|
|
334
371
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
335
372
|
return (await this.blob()).arrayBuffer();
|
|
336
373
|
}
|
|
337
|
-
async bytes() {
|
|
338
|
-
return (await this.
|
|
374
|
+
async bytes(): Promise<Uint8Array> {
|
|
375
|
+
return new Uint8Array(await this.arrayBuffer());
|
|
339
376
|
}
|
|
340
|
-
async blob() {
|
|
341
|
-
const { promise, resolve, reject } = Promise.withResolvers<Blob>();
|
|
342
377
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
378
|
+
async wait(options?: WaitOptions): Promise<ExecResult> {
|
|
379
|
+
const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = options ?? {};
|
|
380
|
+
|
|
381
|
+
const stdoutPromise = new Response(this.stdout).text();
|
|
382
|
+
const stderrPromise =
|
|
383
|
+
stderrMode === "full"
|
|
384
|
+
? new Response(this.stderr).text()
|
|
385
|
+
: (async () => {
|
|
386
|
+
await Promise.allSettled([stdoutPromise, this.exited, this.#stderrDone]);
|
|
387
|
+
return this.peekStderr();
|
|
388
|
+
})();
|
|
389
|
+
|
|
390
|
+
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
391
|
+
|
|
392
|
+
let exitError: Exception | undefined;
|
|
393
|
+
try {
|
|
394
|
+
await this.exited;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
if (err instanceof Exception) exitError = err;
|
|
397
|
+
else throw err;
|
|
348
398
|
}
|
|
349
|
-
blob.then(resolve, reject);
|
|
350
|
-
return promise;
|
|
351
|
-
}
|
|
352
399
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
this.kill("SIGKILL", cause);
|
|
360
|
-
if (this.#proc.killed) {
|
|
361
|
-
queueMicrotask(() => {
|
|
362
|
-
try {
|
|
363
|
-
this.#resolveExited(cause);
|
|
364
|
-
} catch {
|
|
365
|
-
// Ignore
|
|
366
|
-
}
|
|
367
|
-
});
|
|
400
|
+
const exitCode = this.exitCode ?? (exitError && !exitError.aborted ? exitError.exitCode : null);
|
|
401
|
+
const ok = exitCode === 0;
|
|
402
|
+
|
|
403
|
+
if (exitError) {
|
|
404
|
+
if ((exitError.aborted && !allowAbort) || (!exitError.aborted && !allowNonZero)) {
|
|
405
|
+
throw exitError;
|
|
368
406
|
}
|
|
369
|
-
};
|
|
370
|
-
if (signal.aborted) {
|
|
371
|
-
return void onAbort();
|
|
372
407
|
}
|
|
408
|
+
|
|
409
|
+
return { stdout, stderr, exitCode, ok, exitError };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
attachSignal(signal: AbortSignal): void {
|
|
413
|
+
const onAbort = () => this.kill(new AbortError(signal.reason, "<cancelled>"));
|
|
414
|
+
if (signal.aborted) return void onAbort();
|
|
415
|
+
|
|
373
416
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
374
|
-
// Use .finally().catch() to avoid unhandled rejection when #exited rejects
|
|
375
417
|
this.#exited
|
|
418
|
+
.catch(() => {})
|
|
376
419
|
.finally(() => {
|
|
377
420
|
signal.removeEventListener("abort", onAbort);
|
|
378
|
-
})
|
|
379
|
-
.catch(() => {});
|
|
421
|
+
});
|
|
380
422
|
}
|
|
381
423
|
|
|
382
|
-
/**
|
|
383
|
-
* Attach a timeout to this process. Will kill the process with SIGKILL if the timeout is reached.
|
|
384
|
-
*/
|
|
385
424
|
attachTimeout(timeout: number): void {
|
|
386
|
-
if (timeout <= 0) return;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
.
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Base for all exceptions representing child process nonzero exit, killed, or cancellation.
|
|
401
|
-
*/
|
|
402
|
-
export abstract class Exception extends Error {
|
|
403
|
-
constructor(
|
|
404
|
-
message: string,
|
|
405
|
-
public readonly exitCode: number,
|
|
406
|
-
public readonly stderr: string,
|
|
407
|
-
) {
|
|
408
|
-
super(message);
|
|
409
|
-
this.name = this.constructor.name;
|
|
410
|
-
}
|
|
411
|
-
abstract get aborted(): boolean;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Exception for nonzero exit codes (not cancellation).
|
|
416
|
-
*/
|
|
417
|
-
export class NonZeroExitError extends Exception {
|
|
418
|
-
static readonly MAX_TRACE = 32 * 1024;
|
|
419
|
-
|
|
420
|
-
constructor(
|
|
421
|
-
public readonly exitCode: number,
|
|
422
|
-
public readonly stderr: string,
|
|
423
|
-
) {
|
|
424
|
-
super(`Process exited with code ${exitCode}:\n${stderr}`, exitCode, stderr);
|
|
425
|
-
}
|
|
426
|
-
get aborted(): boolean {
|
|
427
|
-
return false;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Exception for explicit process abortion (via signal).
|
|
433
|
-
*/
|
|
434
|
-
export class AbortError extends Exception {
|
|
435
|
-
constructor(
|
|
436
|
-
public readonly reason: unknown,
|
|
437
|
-
stderr: string,
|
|
438
|
-
) {
|
|
439
|
-
const reasonString = reason instanceof Error ? reason.message : String(reason ?? "aborted");
|
|
440
|
-
super(`Operation cancelled: ${reasonString}`, -1, stderr);
|
|
441
|
-
}
|
|
442
|
-
get aborted(): boolean {
|
|
443
|
-
return true;
|
|
425
|
+
if (timeout <= 0 || this.proc.killed) return;
|
|
426
|
+
void (async () => {
|
|
427
|
+
const timedOut = await Promise.race([
|
|
428
|
+
Bun.sleep(timeout).then(() => true),
|
|
429
|
+
this.proc.exited.then(
|
|
430
|
+
() => false,
|
|
431
|
+
() => false,
|
|
432
|
+
),
|
|
433
|
+
]);
|
|
434
|
+
if (timedOut) this.kill(new TimeoutError(timeout, this.#stderrBuffer));
|
|
435
|
+
})();
|
|
444
436
|
}
|
|
445
|
-
}
|
|
446
437
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
*/
|
|
450
|
-
export class TimeoutError extends AbortError {
|
|
451
|
-
constructor(timeout: number, stderr: string) {
|
|
452
|
-
super(new Error(`Timed out after ${Math.round(timeout / 1000)}s`), stderr);
|
|
438
|
+
[Symbol.dispose](): void {
|
|
439
|
+
this.kill(new AbortError("process disposed", this.#stderrBuffer));
|
|
453
440
|
}
|
|
454
441
|
}
|
|
455
442
|
|
|
@@ -457,33 +444,42 @@ export class TimeoutError extends AbortError {
|
|
|
457
444
|
* Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
|
|
458
445
|
*/
|
|
459
446
|
type ChildSpawnOptions = Omit<
|
|
460
|
-
Spawn.SpawnOptions<"pipe" | "ignore" | Buffer | null, "pipe", "pipe">,
|
|
447
|
+
Spawn.SpawnOptions<"pipe" | "ignore" | Buffer | Uint8Array | null, "pipe", "pipe">,
|
|
461
448
|
"stdout" | "stderr"
|
|
462
|
-
> & {
|
|
463
|
-
signal?: AbortSignal;
|
|
464
|
-
};
|
|
449
|
+
> & { signal?: AbortSignal; detached?: boolean };
|
|
465
450
|
|
|
466
451
|
/**
|
|
467
|
-
* Spawn a
|
|
468
|
-
*
|
|
469
|
-
* -
|
|
452
|
+
* Spawn a child process.
|
|
453
|
+
* @param cmd - The command to spawn.
|
|
454
|
+
* @param options - The options for the spawn.
|
|
455
|
+
* @returns A ChildProcess instance.
|
|
470
456
|
*/
|
|
471
|
-
export function
|
|
472
|
-
const { timeout, ...rest } = options ?? {};
|
|
473
|
-
const child = spawn(cmd, {
|
|
457
|
+
export function spawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
|
|
458
|
+
const { detached = false, timeout, signal, ...rest } = options ?? {};
|
|
459
|
+
const child = Bun.spawn(cmd, {
|
|
474
460
|
stdin: "ignore",
|
|
475
|
-
...rest,
|
|
476
461
|
stdout: "pipe",
|
|
477
462
|
stderr: "pipe",
|
|
478
|
-
|
|
479
|
-
|
|
463
|
+
detached,
|
|
464
|
+
...rest,
|
|
480
465
|
});
|
|
481
|
-
const cproc = new ChildProcess(child);
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
if (timeout && timeout > 0) {
|
|
486
|
-
cproc.attachTimeout(timeout);
|
|
487
|
-
}
|
|
466
|
+
const cproc = new ChildProcess(child, detached);
|
|
467
|
+
if (signal) cproc.attachSignal(signal);
|
|
468
|
+
if (timeout && timeout > 0) cproc.attachTimeout(timeout);
|
|
488
469
|
return cproc;
|
|
489
470
|
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Options for execText.
|
|
474
|
+
*/
|
|
475
|
+
export interface ExecOptions extends Omit<ChildSpawnOptions, "stdin">, WaitOptions {
|
|
476
|
+
input?: string | Buffer | Uint8Array;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function exec(cmd: string[], options?: ExecOptions): Promise<ExecResult> {
|
|
480
|
+
const { input, stderr, allowAbort, allowNonZero, ...spawnOptions } = options ?? {};
|
|
481
|
+
const stdin = typeof input === "string" ? Buffer.from(input) : input;
|
|
482
|
+
const resolvedOptions: ChildSpawnOptions = stdin === undefined ? { ...spawnOptions } : { ...spawnOptions, stdin };
|
|
483
|
+
using child = spawn(cmd, resolvedOptions);
|
|
484
|
+
return await child.wait({ stderr, allowAbort, allowNonZero });
|
|
485
|
+
}
|