@oh-my-pi/pi-utils 6.8.0 → 6.8.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "6.8.0",
3
+ "version": "6.8.2",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/abortable.ts CHANGED
@@ -5,9 +5,8 @@ export class AbortError extends Error {
5
5
  assert(signal.aborted, "Abort signal must be aborted");
6
6
 
7
7
  const message = signal.reason instanceof Error ? signal.reason.message : "Cancelled";
8
- super(`Aborted: ${message}`, { cause: message });
8
+ super(`Aborted: ${message}`, { cause: signal.reason });
9
9
  this.name = "AbortError";
10
- this.cause = signal.reason;
11
10
  }
12
11
  }
13
12
 
@@ -42,9 +41,11 @@ export function createAbortablePromise<T>(signal?: AbortSignal): {
42
41
  reject(new AbortError(signal));
43
42
  };
44
43
  signal.addEventListener("abort", abortHandler, { once: true });
45
- promise.finally(() => {
46
- signal.removeEventListener("abort", abortHandler);
47
- });
44
+ promise
45
+ .finally(() => {
46
+ signal.removeEventListener("abort", abortHandler);
47
+ })
48
+ .catch(() => {});
48
49
  return { promise, resolve, reject };
49
50
  }
50
51
 
@@ -63,7 +64,20 @@ export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () =
63
64
  return Promise.reject(new AbortError(signal));
64
65
  }
65
66
  const { promise, resolve, reject } = createAbortablePromise<T>(signal);
66
- pr().then(resolve, reject);
67
+ let settled = false;
68
+ const wrappedResolve = (value: T | PromiseLike<T>) => {
69
+ if (!settled) {
70
+ settled = true;
71
+ resolve(value);
72
+ }
73
+ };
74
+ const wrappedReject = (reason?: unknown) => {
75
+ if (!settled) {
76
+ settled = true;
77
+ reject(reason);
78
+ }
79
+ };
80
+ pr().then(wrappedResolve, wrappedReject);
67
81
  return promise;
68
82
  }
69
83
 
package/src/postmortem.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
7
  */
8
8
 
9
+ import { isMainThread } from "node:worker_threads";
9
10
  import { logger } from ".";
10
11
 
11
12
  // Cleanup reasons, in order of priority/meaning.
@@ -45,7 +46,8 @@ function runCleanup(reason: Reason): Promise<void> {
45
46
 
46
47
  // Call .cleanup() for each callback that is still "armed".
47
48
  // Use Promise.try to handle sync/async, but only those armed.
48
- const promises = callbackList.reverse().map((callback) => {
49
+ // Create a copy to avoid mutating the original array with reverse()
50
+ const promises = [...callbackList].reverse().map((callback) => {
49
51
  return Promise.try(() => callback(reason));
50
52
  });
51
53
 
@@ -61,33 +63,45 @@ function runCleanup(reason: Reason): Promise<void> {
61
63
  }
62
64
 
63
65
  // Register signal and error event handlers to trigger cleanup before exit.
64
- process
65
- .on("SIGINT", async () => {
66
- await runCleanup(Reason.SIGINT);
67
- process.exit(130); // 128 + SIGINT (2)
68
- })
69
- .on("uncaughtException", async (err) => {
70
- logger.error("Uncaught exception", { err, stack: err.stack });
71
- await runCleanup(Reason.UNCAUGHT_EXCEPTION);
72
- process.exit(1);
73
- })
74
- .on("unhandledRejection", async (reason) => {
75
- const err = reason instanceof Error ? reason : new Error(String(reason));
76
- logger.error("Unhandled rejection", { err, stack: err.stack });
77
- await runCleanup(Reason.UNHANDLED_REJECTION);
78
- process.exit(1);
79
- })
80
- .on("exit", async () => {
81
- void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
82
- })
83
- .on("SIGTERM", async () => {
84
- await runCleanup(Reason.SIGTERM);
85
- process.exit(143); // 128 + SIGTERM (15)
86
- })
87
- .on("SIGHUP", async () => {
88
- await runCleanup(Reason.SIGHUP);
89
- process.exit(129); // 128 + SIGHUP (1)
66
+ // Main thread: full signal handling (SIGINT, SIGTERM, SIGHUP) + exceptions + exit
67
+ // Worker thread: exit only (workers use self.addEventListener for exceptions)
68
+ if (isMainThread) {
69
+ process
70
+ .on("SIGINT", async () => {
71
+ await runCleanup(Reason.SIGINT);
72
+ process.exit(130); // 128 + SIGINT (2)
73
+ })
74
+ .on("uncaughtException", async (err) => {
75
+ logger.error("Uncaught exception", { err, stack: err.stack });
76
+ await runCleanup(Reason.UNCAUGHT_EXCEPTION);
77
+ process.exit(1);
78
+ })
79
+ .on("unhandledRejection", async (reason) => {
80
+ const err = reason instanceof Error ? reason : new Error(String(reason));
81
+ logger.error("Unhandled rejection", { err, stack: err.stack });
82
+ await runCleanup(Reason.UNHANDLED_REJECTION);
83
+ process.exit(1);
84
+ })
85
+ .on("exit", async () => {
86
+ void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
87
+ })
88
+ .on("SIGTERM", async () => {
89
+ await runCleanup(Reason.SIGTERM);
90
+ process.exit(143); // 128 + SIGTERM (15)
91
+ })
92
+ .on("SIGHUP", async () => {
93
+ await runCleanup(Reason.SIGHUP);
94
+ process.exit(129); // 128 + SIGHUP (1)
95
+ });
96
+ } else {
97
+ // Worker thread: only register exit handler for cleanup.
98
+ // DO NOT register uncaughtException/unhandledRejection handlers here -
99
+ // they would swallow errors before the worker's own handlers (self.addEventListener)
100
+ // can report failures back to the parent thread.
101
+ process.on("exit", () => {
102
+ void runCleanup(Reason.EXIT);
90
103
  });
104
+ }
91
105
 
92
106
  /**
93
107
  * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
@@ -134,10 +148,26 @@ export function register(id: string, callback: (reason: Reason) => void | Promis
134
148
  }
135
149
 
136
150
  /**
137
- * Runs all cleanup callbacks and exits the process.
151
+ * Runs all cleanup callbacks without exiting.
152
+ * Use this in workers or when you need to clean up but continue execution.
153
+ */
154
+ export function cleanup(): Promise<void> {
155
+ return runCleanup(Reason.MANUAL);
156
+ }
157
+
158
+ /**
159
+ * Runs all cleanup callbacks and exits.
160
+ *
161
+ * In main thread: waits for stdout drain, then calls process.exit().
162
+ * In workers: runs cleanup only (process.exit would kill entire process).
138
163
  */
139
164
  export async function quit(code: number = 0): Promise<void> {
140
165
  await runCleanup(Reason.MANUAL);
166
+
167
+ if (!isMainThread) {
168
+ return; // Workers: cleanup done, let worker exit naturally
169
+ }
170
+
141
171
  if (process.stdout.writableLength > 0) {
142
172
  const { promise, resolve } = Promise.withResolvers<void>();
143
173
  process.stdout.once("drain", resolve);
package/src/ptree.ts CHANGED
@@ -16,6 +16,59 @@ const isWindows = process.platform === "win32";
16
16
  // Set of live children for managed termination/cleanup on shutdown.
17
17
  const managedChildren = new Set<PipedSubprocess>();
18
18
 
19
+ class AsyncQueue<T> {
20
+ #items: T[] = [];
21
+ #resolvers: Array<(result: IteratorResult<T>) => void> = [];
22
+ #closed = false;
23
+
24
+ push(item: T): void {
25
+ if (this.#closed) return;
26
+ const resolver = this.#resolvers.shift();
27
+ if (resolver) {
28
+ resolver({ value: item, done: false });
29
+ return;
30
+ }
31
+ this.#items.push(item);
32
+ }
33
+
34
+ close(): void {
35
+ if (this.#closed) return;
36
+ this.#closed = true;
37
+ while (this.#resolvers.length > 0) {
38
+ const resolver = this.#resolvers.shift();
39
+ if (resolver) {
40
+ resolver({ value: undefined, done: true });
41
+ }
42
+ }
43
+ }
44
+
45
+ async next(): Promise<IteratorResult<T>> {
46
+ if (this.#items.length > 0) {
47
+ return { value: this.#items.shift() as T, done: false };
48
+ }
49
+ if (this.#closed) {
50
+ return { value: undefined, done: true };
51
+ }
52
+ return await new Promise<IteratorResult<T>>((resolve) => {
53
+ this.#resolvers.push(resolve);
54
+ });
55
+ }
56
+ }
57
+
58
+ function createProcessStream(queue: AsyncQueue<Uint8Array>): ReadableStream<Uint8Array> {
59
+ const stream = new ReadableStream<Uint8Array>({
60
+ pull: async (controller) => {
61
+ const result = await queue.next();
62
+ if (result.done) {
63
+ controller.close();
64
+ return;
65
+ }
66
+ controller.enqueue(result.value);
67
+ },
68
+ });
69
+ return stream;
70
+ }
71
+
19
72
  /**
20
73
  * Kill a child process and its descendents.
21
74
  * - Windows: uses taskkill for tree and forceful kill (/T /F)
@@ -71,8 +124,8 @@ function registerManaged(child: PipedSubprocess): void {
71
124
  });
72
125
  }
73
126
 
74
- // A Bun subprocess with stdin=Writable, stdout/stderr=pipe (for tracking/cleanup).
75
- type PipedSubprocess = Subprocess<"pipe" | null, "pipe", "pipe">;
127
+ // A Bun subprocess with stdin=Writable/ignore, stdout/stderr=pipe (for tracking/cleanup).
128
+ type PipedSubprocess = Subprocess<"pipe" | "ignore" | null, "pipe", "pipe">;
76
129
 
77
130
  /**
78
131
  * ChildProcess wraps a managed subprocess, capturing output, errors, and providing
@@ -82,8 +135,11 @@ export class ChildProcess {
82
135
  #proc: PipedSubprocess;
83
136
  #detached = false;
84
137
  #nothrow = false;
85
- #stderrTee: ReadableStream<Uint8Array<ArrayBuffer>>;
86
138
  #stderrBuffer = "";
139
+ #stdoutQueue = new AsyncQueue<Uint8Array>();
140
+ #stderrQueue = new AsyncQueue<Uint8Array>();
141
+ #stdoutStream?: ReadableStream<Uint8Array>;
142
+ #stderrStream?: ReadableStream<Uint8Array>;
87
143
  #exitReason?: Exception;
88
144
  #exitReasonPending?: Exception;
89
145
  #exited: Promise<void>;
@@ -92,23 +148,75 @@ export class ChildProcess {
92
148
  constructor(proc: PipedSubprocess) {
93
149
  registerManaged(proc);
94
150
 
95
- const [left, right] = proc.stderr.tee();
96
- this.#stderrTee = right;
151
+ const exitSettled = proc.exited.then(
152
+ () => {},
153
+ () => {},
154
+ );
155
+
156
+ // Capture stdout at all times. Close the passthrough when the process exits.
157
+ void (async () => {
158
+ const reader = proc.stdout.getReader();
159
+ try {
160
+ while (true) {
161
+ const result = await Promise.race([
162
+ reader.read(),
163
+ exitSettled.then(() => ({ done: true, value: undefined as Uint8Array | undefined })),
164
+ ]);
165
+ if (result.done) break;
166
+ if (!result.value) continue;
167
+ this.#stdoutQueue.push(result.value);
168
+ }
169
+ } catch {
170
+ // ignore
171
+ } finally {
172
+ try {
173
+ await reader.cancel();
174
+ } catch {}
175
+ try {
176
+ reader.releaseLock();
177
+ } catch {}
178
+ this.#stdoutQueue.close();
179
+ }
180
+ })().catch(() => {
181
+ this.#stdoutQueue.close();
182
+ });
97
183
 
98
184
  // Capture stderr at all times, with a capped buffer for errors.
99
185
  const decoder = new TextDecoder();
100
186
  void (async () => {
101
- for await (const chunk of left) {
102
- this.#stderrBuffer += decoder.decode(chunk, { stream: true });
187
+ const reader = proc.stderr.getReader();
188
+ try {
189
+ while (true) {
190
+ const result = await Promise.race([
191
+ reader.read(),
192
+ exitSettled.then(() => ({ done: true, value: undefined as Uint8Array | undefined })),
193
+ ]);
194
+ if (result.done) break;
195
+ if (!result.value) continue;
196
+ this.#stderrQueue.push(result.value);
197
+ this.#stderrBuffer += decoder.decode(result.value, { stream: true });
198
+ if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
199
+ this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
200
+ }
201
+ }
202
+ } catch {
203
+ // ignore
204
+ } finally {
205
+ this.#stderrBuffer += decoder.decode();
103
206
  if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
104
207
  this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
105
208
  }
209
+ try {
210
+ await reader.cancel();
211
+ } catch {}
212
+ try {
213
+ reader.releaseLock();
214
+ } catch {}
215
+ this.#stderrQueue.close();
106
216
  }
107
- this.#stderrBuffer += decoder.decode();
108
- if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
109
- this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
110
- }
111
- })().catch(() => {});
217
+ })().catch(() => {
218
+ this.#stderrQueue.close();
219
+ });
112
220
 
113
221
  const { promise, resolve } = Promise.withResolvers<Exception | undefined>();
114
222
 
@@ -152,11 +260,17 @@ export class ChildProcess {
152
260
  get stdin(): FileSink | undefined {
153
261
  return this.#proc.stdin;
154
262
  }
155
- get stdout(): ReadableStream<Uint8Array<ArrayBuffer>> {
156
- return this.#proc.stdout;
263
+ get stdout(): ReadableStream<Uint8Array> {
264
+ if (!this.#stdoutStream) {
265
+ this.#stdoutStream = createProcessStream(this.#stdoutQueue);
266
+ }
267
+ return this.#stdoutStream;
157
268
  }
158
- get stderr(): ReadableStream<Uint8Array<ArrayBuffer>> {
159
- return this.#stderrTee;
269
+ get stderr(): ReadableStream<Uint8Array> {
270
+ if (!this.#stderrStream) {
271
+ this.#stderrStream = createProcessStream(this.#stderrQueue);
272
+ }
273
+ return this.#stderrStream;
160
274
  }
161
275
 
162
276
  /**
@@ -201,7 +315,14 @@ export class ChildProcess {
201
315
  async killAndWait(): Promise<void> {
202
316
  // Try killing with SIGTERM, then SIGKILL if it doesn't exit within 1 second
203
317
  this.kill("SIGTERM");
204
- await Promise.race([this.exited, Bun.sleep(1000).then(() => this.kill("SIGKILL"))]);
318
+ const exitedOrTimeout = await Promise.race([
319
+ this.exited.then(() => "exited" as const),
320
+ Bun.sleep(1000).then(() => "timeout" as const),
321
+ ]);
322
+ if (exitedOrTimeout === "timeout") {
323
+ this.kill("SIGKILL");
324
+ await this.exited.catch(() => {});
325
+ }
205
326
  }
206
327
 
207
328
  // Output utilities (aliases for easy chaining)
@@ -220,7 +341,7 @@ export class ChildProcess {
220
341
  async blob() {
221
342
  const { promise, resolve, reject } = Promise.withResolvers<Blob>();
222
343
 
223
- const blob = this.#proc.stdout.blob();
344
+ const blob = this.stdout.blob();
224
345
  if (!this.#nothrow) {
225
346
  this.#exited.catch((ex: Exception) => {
226
347
  reject(ex);
@@ -329,14 +450,17 @@ export class AbortError extends Exception {
329
450
  */
330
451
  export class TimeoutError extends AbortError {
331
452
  constructor(timeout: number, stderr: string) {
332
- super(new Error(`Process timed out after ${timeout}ms`), stderr);
453
+ super(new Error(`Timed out after ${Math.round(timeout / 1000)}s`), stderr);
333
454
  }
334
455
  }
335
456
 
336
457
  /**
337
458
  * Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
338
459
  */
339
- type ChildSpawnOptions = Omit<Spawn.SpawnOptions<"pipe" | null, "pipe", "pipe">, "stdout" | "stderr"> & {
460
+ type ChildSpawnOptions = Omit<
461
+ Spawn.SpawnOptions<"pipe" | "ignore" | Buffer | null, "pipe", "pipe">,
462
+ "stdout" | "stderr"
463
+ > & {
340
464
  signal?: AbortSignal;
341
465
  };
342
466
 
@@ -348,6 +472,7 @@ type ChildSpawnOptions = Omit<Spawn.SpawnOptions<"pipe" | null, "pipe", "pipe">,
348
472
  export function cspawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
349
473
  const { timeout, ...rest } = options ?? {};
350
474
  const child = spawn(cmd, {
475
+ stdin: "ignore",
351
476
  ...rest,
352
477
  stdout: "pipe",
353
478
  stderr: "pipe",