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

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.1",
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
@@ -201,7 +201,14 @@ export class ChildProcess {
201
201
  async killAndWait(): Promise<void> {
202
202
  // Try killing with SIGTERM, then SIGKILL if it doesn't exit within 1 second
203
203
  this.kill("SIGTERM");
204
- await Promise.race([this.exited, Bun.sleep(1000).then(() => this.kill("SIGKILL"))]);
204
+ const exitedOrTimeout = await Promise.race([
205
+ this.exited.then(() => "exited" as const),
206
+ Bun.sleep(1000).then(() => "timeout" as const),
207
+ ]);
208
+ if (exitedOrTimeout === "timeout") {
209
+ this.kill("SIGKILL");
210
+ await this.exited.catch(() => {});
211
+ }
205
212
  }
206
213
 
207
214
  // Output utilities (aliases for easy chaining)
@@ -329,7 +336,7 @@ export class AbortError extends Exception {
329
336
  */
330
337
  export class TimeoutError extends AbortError {
331
338
  constructor(timeout: number, stderr: string) {
332
- super(new Error(`Process timed out after ${timeout}ms`), stderr);
339
+ super(new Error(`Timed out after ${Math.round(timeout / 1000)}s`), stderr);
333
340
  }
334
341
  }
335
342