@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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +1 -1
  3. package/src/ptree.ts +357 -361
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "8.12.2",
3
+ "version": "8.12.4",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
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, cspawn, Exception, NonZeroExitError } from "./ptree";
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
- * Provides:
5
- * - Managed tracking of child subprocesses for cleanup on exit/signals.
6
- * - Windows and Unix support for proper tree killing.
7
- * - ChildProcess wrapper for capturing output, errors, and kill/detach.
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, spawn, spawnSync } from "bun";
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
- // Set of live children for managed termination/cleanup on shutdown.
16
- const managedChildren = new Set<PipedSubprocess>();
17
+ /** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
18
+ type PipedSubprocess = Subprocess<"pipe" | "ignore" | null, "pipe", "pipe">;
17
19
 
18
- class AsyncQueue<T> {
19
- #items: T[] = [];
20
- #resolvers: Array<(result: IteratorResult<T>) => void> = [];
21
- #closed = false;
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
- push(item: T): void {
24
- if (this.#closed) return;
25
- const resolver = this.#resolvers.shift();
26
- if (resolver) {
27
- resolver({ value: item, done: false });
28
- return;
29
- }
30
- this.#items.push(item);
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
- close(): void {
34
- if (this.#closed) return;
35
- this.#closed = true;
36
- while (this.#resolvers.length > 0) {
37
- const resolver = this.#resolvers.shift();
38
- if (resolver) {
39
- resolver({ value: undefined, done: true });
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
- async next(): Promise<IteratorResult<T>> {
45
- if (this.#items.length > 0) {
46
- return { value: this.#items.shift() as T, done: false };
47
- }
48
- if (this.#closed) {
49
- return { value: undefined, done: true };
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
- function createProcessStream(queue: AsyncQueue<Uint8Array>): ReadableStream<Uint8Array> {
58
- const stream = new ReadableStream<Uint8Array>({
59
- pull: async controller => {
60
- const result = await queue.next();
61
- if (result.done) {
62
- controller.close();
63
- return;
64
- }
65
- controller.enqueue(result.value);
66
- },
67
- });
68
- return stream;
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: uses taskkill for tree and forceful kill (/T /F)
74
- * - Unix: negative PID sends signal to process group (tree kill)
96
+ * - Windows: taskkill /T, add /F on SIGKILL
97
+ * - Unix: negative PID signals the process group
75
98
  */
76
- function killChild(child: PipedSubprocess, signal: NodeJS.Signals = "SIGTERM"): void {
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
- if (isWindows) {
82
- // /T (tree), /F (force): ensure entire tree is killed.
83
- spawnSync(["taskkill", ...(signal === "SIGKILL" ? ["/F"] : []), "/T", "/PID", pid.toString()], {
84
- stdout: "ignore",
85
- stderr: "ignore",
86
- timeout: 1000,
87
- });
88
- } else {
89
- // Send signal to process group (negative PID).
90
- process.kill(-pid, signal);
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
- // If killed, remove from managed set and clean up.
94
- if (child.killed) {
95
- managedChildren.delete(child);
96
- child.unref();
97
- }
98
- } catch {
99
- // Ignore: process may already be dead.
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
- postmortem.register("managed-children", () => {
104
- for (const child of [...managedChildren]) {
105
- killChild(child, "SIGKILL");
106
- managedChildren.delete(child);
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
- * Register a subprocess for managed cleanup.
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
- function registerManaged(child: PipedSubprocess): void {
115
- if (child.exitCode !== null) return;
116
- if (managedChildren.has(child)) return;
117
- child.ref();
118
- managedChildren.add(child);
119
-
120
- child.exited.then(() => {
121
- managedChildren.delete(child);
122
- child.unref();
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
- // A Bun subprocess with stdin=Writable/ignore, stdout/stderr=pipe (for tracking/cleanup).
127
- type PipedSubprocess = Subprocess<"pipe" | "ignore" | null, "pipe", "pipe">;
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 output, errors, and providing
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(proc: PipedSubprocess) {
148
- registerManaged(proc);
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
- const exitSettled = proc.exited.then(
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
- // Capture stdout at all times. Close the passthrough when the process exits.
156
- void (async () => {
157
- const reader = proc.stdout.getReader();
158
- try {
159
- while (true) {
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
- })().catch(() => {
180
- this.#stdoutQueue.close();
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
- // Capture stderr at all times, with a capped buffer for errors.
184
- const decoder = new TextDecoder();
185
- void (async () => {
186
- const reader = proc.stderr.getReader();
187
- try {
188
- while (true) {
189
- const result = await Promise.race([
190
- reader.read(),
191
- exitSettled.then(() => ({ done: true, value: undefined as Uint8Array | undefined })),
192
- ]);
193
- if (result.done) break;
194
- if (!result.value) continue;
195
- this.#stderrQueue.push(result.value);
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
- } catch {
202
- // ignore
203
- } finally {
204
- this.#stderrBuffer += decoder.decode();
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
- const { promise, resolve } = Promise.withResolvers<Exception | undefined>();
290
+ await this.#stderrDone;
221
291
 
222
- this.#exited = promise.then((ex?: Exception) => {
223
- if (!ex) return proc.exitCode ?? -1337; // success, no exception
224
- if (proc.killed && this.#exitReasonPending) {
225
- ex = this.#exitReasonPending; // propagate reason if killed
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
- // On exit, resolve with a ChildError if nonzero code.
233
- proc.exited.then(exitCode => {
234
- if (exitCode !== 0) {
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
- this.#proc = proc;
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.#proc.pid;
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.#proc.exitCode;
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.#proc.killed;
330
+ return this.proc.killed;
258
331
  }
259
332
  get stdin(): FileSink | undefined {
260
- return this.#proc.stdin;
333
+ return this.proc.stdin;
261
334
  }
262
335
  get stdout(): ReadableStream<Uint8Array> {
263
- if (!this.#stdoutStream) {
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
- if (!this.#stderrStream) {
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
- * Kill the process tree.
304
- * Optionally set an exit reason (for better error propagation on cancellation).
305
- */
306
- kill(signal: NodeJS.Signals = "SIGTERM", reason?: Exception) {
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
- async killAndWait(): Promise<void> {
315
- // Try killing with SIGTERM, then SIGKILL if it doesn't exit within 1 second
316
- this.kill("SIGTERM");
317
- const exitedOrTimeout = await Promise.race([
318
- this.exited.then(() => "exited" as const),
319
- Bun.sleep(1000).then(() => "timeout" as const),
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.blob()).bytes();
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
- const blob = this.stdout.blob();
344
- if (!this.#nothrow) {
345
- this.#exited.catch((ex: Exception) => {
346
- reject(ex);
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
- * Attach an AbortSignal to this process. Will kill tree with SIGKILL if aborted.
355
- */
356
- attachSignal(signal: AbortSignal): void {
357
- const onAbort = () => {
358
- const cause = new AbortError(signal.reason, "<cancelled>");
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
- const timeoutId = setTimeout(() => {
388
- this.kill("SIGKILL", new TimeoutError(timeout, this.#stderrBuffer));
389
- }, timeout);
390
- // Use .finally().catch() to avoid unhandled rejection when #exited rejects
391
- this.#exited
392
- .finally(() => {
393
- clearTimeout(timeoutId);
394
- })
395
- .catch(() => {});
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
- * Exception for process timeout.
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 subprocess as a managed child process.
468
- * - Always pipes stdout/stderr, launches in new session/process group (detached).
469
- * - Optional AbortSignal integrates with kill-on-abort.
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 cspawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
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
- // Windows: new console/pgroup; Unix: setsid for process group.
479
- detached: true,
463
+ detached,
464
+ ...rest,
480
465
  });
481
- const cproc = new ChildProcess(child);
482
- if (options?.signal) {
483
- cproc.attachSignal(options.signal);
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
+ }