@oh-my-pi/pi-utils 17.0.0 → 17.0.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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.1] - 2026-07-16
6
+
7
+ ### Fixed
8
+
9
+ - Added scoped graceful handling for stdio-write EPIPE rejections so protocol servers can await postmortem cleanup when their peer disconnects ([#4788](https://github.com/can1357/oh-my-pi/issues/4788)).
10
+
5
11
  ## [17.0.0] - 2026-07-15
6
12
 
7
13
  ### Fixed
@@ -8,14 +8,23 @@ export declare enum Reason {
8
8
  UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
9
9
  MANUAL = "manual"
10
10
  }
11
+ /** Origin of an EPIPE raised by a process communication channel. */
12
+ export type BrokenPipeSource = "ipc-send" | "stdio-write";
11
13
  /**
12
- * Detect an EPIPE rejection that originated from an IPC `send()` to a worker
13
- * subprocess (`syscall: "send"`), as opposed to a stdin/stdout pipe write
14
- * (`syscall: "write"`). Only the IPC-send path can break an optional worker
15
- * subsystem without affecting the main process, so only this shape is safe to
16
- * swallow at the global `unhandledRejection` level. See issue #2997.
14
+ * Classify EPIPE errors from worker IPC and stdio without treating unrelated
15
+ * broken pipes as globally recoverable.
17
16
  */
17
+ export declare function classifyBrokenPipe(err: Error): BrokenPipeSource | undefined;
18
+ /** Whether an EPIPE came from an IPC `send()` to an optional worker. */
18
19
  export declare function isIpcSendEpipe(err: Error): boolean;
20
+ /**
21
+ * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
22
+ *
23
+ * Stdio protocol servers call this for their process lifetime so a closed
24
+ * client pipe runs registered cleanup callbacks instead of the fatal path.
25
+ * The returned callback removes the registration.
26
+ */
27
+ export declare function registerStdioDisconnectHandling(): () => void;
19
28
  /**
20
29
  * Mark an error as expected cleanup fallout so the global fatal handlers
21
30
  * downgrade it to a log line instead of tearing down the process. Use for
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "17.0.0",
4
+ "version": "17.0.1",
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": "17.0.0",
34
+ "@oh-my-pi/pi-natives": "17.0.1",
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
@@ -27,6 +27,8 @@ const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
27
27
  // Tracks cleanup run state (to prevent recursion/reentry issues)
28
28
  let cleanupStage: "idle" | "running" | "complete" = "idle";
29
29
  const CLEANUP_DEADLINE_MS = 10_000;
30
+ let cleanupPromise: Promise<void> | undefined;
31
+ let stdioDisconnectRegistrations = 0;
30
32
 
31
33
  /**
32
34
  * Internal: runs all registered cleanup callbacks for the given reason.
@@ -40,7 +42,7 @@ function runCleanup(reason: Reason): Promise<void> {
40
42
  cleanupStage = "running";
41
43
  break;
42
44
  case "running":
43
- return Promise.resolve();
45
+ return cleanupPromise ?? Promise.resolve();
44
46
  case "complete":
45
47
  return Promise.resolve();
46
48
  }
@@ -67,9 +69,10 @@ function runCleanup(reason: Reason): Promise<void> {
67
69
  deadline.resolve();
68
70
  }, CLEANUP_DEADLINE_MS);
69
71
  deadlineTimer.unref();
70
- return Promise.race([cleanupSettled, deadline.promise]).finally(() => {
72
+ cleanupPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
71
73
  clearTimeout(deadlineTimer);
72
74
  });
75
+ return cleanupPromise;
73
76
  }
74
77
 
75
78
  // Register signal and error event handlers to trigger cleanup before exit.
@@ -77,17 +80,40 @@ function runCleanup(reason: Reason): Promise<void> {
77
80
  // Worker thread: exit only (workers use self.addEventListener for exceptions)
78
81
  let inspectorOpened = false;
79
82
 
83
+ /** Origin of an EPIPE raised by a process communication channel. */
84
+ export type BrokenPipeSource = "ipc-send" | "stdio-write";
85
+
80
86
  /**
81
- * Detect an EPIPE rejection that originated from an IPC `send()` to a worker
82
- * subprocess (`syscall: "send"`), as opposed to a stdin/stdout pipe write
83
- * (`syscall: "write"`). Only the IPC-send path can break an optional worker
84
- * subsystem without affecting the main process, so only this shape is safe to
85
- * swallow at the global `unhandledRejection` level. See issue #2997.
87
+ * Classify EPIPE errors from worker IPC and stdio without treating unrelated
88
+ * broken pipes as globally recoverable.
86
89
  */
90
+ export function classifyBrokenPipe(err: Error): BrokenPipeSource | undefined {
91
+ if (!("code" in err) || err.code !== "EPIPE" || !("syscall" in err)) return undefined;
92
+ if (err.syscall === "send") return "ipc-send";
93
+ if (err.syscall === "write") return "stdio-write";
94
+ return undefined;
95
+ }
96
+
97
+ /** Whether an EPIPE came from an IPC `send()` to an optional worker. */
87
98
  export function isIpcSendEpipe(err: Error): boolean {
88
- const code = (err as { code?: unknown }).code;
89
- const syscall = (err as { syscall?: unknown }).syscall;
90
- return code === "EPIPE" && syscall === "send";
99
+ return classifyBrokenPipe(err) === "ipc-send";
100
+ }
101
+
102
+ /**
103
+ * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
104
+ *
105
+ * Stdio protocol servers call this for their process lifetime so a closed
106
+ * client pipe runs registered cleanup callbacks instead of the fatal path.
107
+ * The returned callback removes the registration.
108
+ */
109
+ export function registerStdioDisconnectHandling(): () => void {
110
+ let registered = true;
111
+ stdioDisconnectRegistrations++;
112
+ return () => {
113
+ if (!registered) return;
114
+ registered = false;
115
+ stdioDisconnectRegistrations--;
116
+ };
91
117
  }
92
118
 
93
119
  // Well-known key marking an error as an *expected* teardown artifact (e.g. a
@@ -179,6 +205,7 @@ if (isMainThread) {
179
205
  })
180
206
  .on("unhandledRejection", async reason => {
181
207
  const err = reason instanceof Error ? reason : new Error(String(reason));
208
+ const brokenPipeSource = classifyBrokenPipe(err);
182
209
  // EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
183
210
  // worker subprocess whose pipe broke between the exit being observed
184
211
  // and the next `proc.send()` — a race window that Bun surfaces as an
@@ -188,10 +215,15 @@ if (isMainThread) {
188
215
  // send pipe must never take down the whole session. Log and continue
189
216
  // instead of exiting; the owning client detects the dead worker via
190
217
  // its own `onExit`/error path and respawns or disables it. See #2997.
191
- if (isIpcSendEpipe(err)) {
218
+ if (brokenPipeSource === "ipc-send") {
192
219
  logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
193
220
  return;
194
221
  }
222
+ if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
223
+ logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
224
+ await quit(0);
225
+ return;
226
+ }
195
227
  if (isExpectedCleanupError(reason)) {
196
228
  logger.warn("Ignoring expected cleanup rejection", { err });
197
229
  return;