@oh-my-pi/pi-utils 16.3.9 → 16.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.10] - 2026-07-06
6
+
7
+ ### Added
8
+
9
+ - Added `postmortem.markExpectedCleanupError()` / `postmortem.isExpectedCleanupError()` to tag errors thrown by routine resource teardown; the global `uncaughtException`/`unhandledRejection` handlers downgrade marked errors (walking the `cause` chain) to warnings instead of exiting the process.
10
+
11
+ ### Fixed
12
+
13
+ - Bounded postmortem cleanup with a 10s deadline so a hanging cleanup callback can no longer wedge the process indefinitely after a fatal error or signal; the process now always reaches `process.exit`.
14
+
5
15
  ## [16.3.7] - 2026-07-05
6
16
 
7
17
  ### Added
@@ -16,6 +16,21 @@ export declare enum Reason {
16
16
  * swallow at the global `unhandledRejection` level. See issue #2997.
17
17
  */
18
18
  export declare function isIpcSendEpipe(err: Error): boolean;
19
+ /**
20
+ * Mark an error as expected cleanup fallout so the global fatal handlers
21
+ * downgrade it to a log line instead of tearing down the process. Use for
22
+ * abort reasons fired by routine resource teardown (browser run end, tab
23
+ * close) whose rejections may surface on fire-and-forget promises with no
24
+ * consumer. Returns the same error for inline use at the `abort()` callsite.
25
+ */
26
+ export declare function markExpectedCleanupError<T extends object>(reason: T): T;
27
+ /**
28
+ * Whether `reason` (or any error in its `cause` chain) was marked via
29
+ * {@link markExpectedCleanupError}. Walks the chain because the unhandled
30
+ * reason is often a wrapper (`AbortError`) with the marked abort reason as
31
+ * its `cause`.
32
+ */
33
+ export declare function isExpectedCleanupError(reason: unknown): boolean;
19
34
  /**
20
35
  * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
21
36
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "16.3.9",
4
+ "version": "16.3.11",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "16.3.9",
34
+ "@oh-my-pi/pi-natives": "16.3.11",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/postmortem.ts CHANGED
@@ -25,6 +25,7 @@ export enum Reason {
25
25
  const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
26
26
  // Tracks cleanup run state (to prevent recursion/reentry issues)
27
27
  let cleanupStage: "idle" | "running" | "complete" = "idle";
28
+ const CLEANUP_DEADLINE_MS = 10_000;
28
29
 
29
30
  /**
30
31
  * Internal: runs all registered cleanup callbacks for the given reason.
@@ -49,7 +50,7 @@ function runCleanup(reason: Reason): Promise<void> {
49
50
  return Promise.try(() => callback(reason));
50
51
  });
51
52
 
52
- return Promise.allSettled(promises).then(results => {
53
+ const cleanupSettled = Promise.allSettled(promises).then(results => {
53
54
  for (const result of results) {
54
55
  if (result.status === "rejected") {
55
56
  const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
@@ -58,6 +59,16 @@ function runCleanup(reason: Reason): Promise<void> {
58
59
  }
59
60
  cleanupStage = "complete";
60
61
  });
62
+ const deadline = Promise.withResolvers<void>();
63
+ const deadlineTimer = setTimeout(() => {
64
+ logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
65
+ cleanupStage = "complete";
66
+ deadline.resolve();
67
+ }, CLEANUP_DEADLINE_MS);
68
+ deadlineTimer.unref();
69
+ return Promise.race([cleanupSettled, deadline.promise]).finally(() => {
70
+ clearTimeout(deadlineTimer);
71
+ });
61
72
  }
62
73
 
63
74
  // Register signal and error event handlers to trigger cleanup before exit.
@@ -78,6 +89,38 @@ export function isIpcSendEpipe(err: Error): boolean {
78
89
  return code === "EPIPE" && syscall === "send";
79
90
  }
80
91
 
92
+ // Well-known key marking an error as an *expected* teardown artifact (e.g. a
93
+ // browser run-scope abort at normal run end). `Symbol.for` so the marker
94
+ // survives duplicate module instances across bundles/realms.
95
+ const EXPECTED_CLEANUP = Symbol.for("omp.expectedCleanupError");
96
+
97
+ /**
98
+ * Mark an error as expected cleanup fallout so the global fatal handlers
99
+ * downgrade it to a log line instead of tearing down the process. Use for
100
+ * abort reasons fired by routine resource teardown (browser run end, tab
101
+ * close) whose rejections may surface on fire-and-forget promises with no
102
+ * consumer. Returns the same error for inline use at the `abort()` callsite.
103
+ */
104
+ export function markExpectedCleanupError<T extends object>(reason: T): T {
105
+ (reason as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] = true;
106
+ return reason;
107
+ }
108
+
109
+ /**
110
+ * Whether `reason` (or any error in its `cause` chain) was marked via
111
+ * {@link markExpectedCleanupError}. Walks the chain because the unhandled
112
+ * reason is often a wrapper (`AbortError`) with the marked abort reason as
113
+ * its `cause`.
114
+ */
115
+ export function isExpectedCleanupError(reason: unknown): boolean {
116
+ let current: unknown = reason;
117
+ for (let depth = 0; depth < 8 && current !== null && typeof current === "object"; depth++) {
118
+ if ((current as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] === true) return true;
119
+ current = (current as { cause?: unknown }).cause;
120
+ }
121
+ return false;
122
+ }
123
+
81
124
  function formatFatalError(label: string, err: Error): string {
82
125
  const name = err.name || "Error";
83
126
  const message = err.message || "(no message)";
@@ -101,6 +144,10 @@ if (isMainThread) {
101
144
  process.stderr.write(`Inspector opened: ${url}\n`);
102
145
  })
103
146
  .on("uncaughtException", async err => {
147
+ if (isExpectedCleanupError(err)) {
148
+ logger.warn("Ignoring expected cleanup exception", { err });
149
+ return;
150
+ }
104
151
  process.stderr.write(formatFatalError("Uncaught Exception", err));
105
152
  logger.error("Uncaught exception", { err });
106
153
  await runCleanup(Reason.UNCAUGHT_EXCEPTION);
@@ -121,6 +168,10 @@ if (isMainThread) {
121
168
  logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
122
169
  return;
123
170
  }
171
+ if (isExpectedCleanupError(reason)) {
172
+ logger.warn("Ignoring expected cleanup rejection", { err });
173
+ return;
174
+ }
124
175
  process.stderr.write(formatFatalError("Unhandled Rejection", err));
125
176
  logger.error("Unhandled rejection", { err });
126
177
  await runCleanup(Reason.UNHANDLED_REJECTION);