@oh-my-pi/pi-utils 17.4.1 → 18.0.0

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.4.2] - 2026-08-21
6
+
7
+ ### Fixed
8
+
9
+ - Made malformed advanced-serialization frames from a worker subprocess non-fatal: Bun surfaces an undecodable IPC frame as a process-level `uncaughtException` in the parent (oven-sh/bun#37287), which the postmortem handler treated as fatal and tore down every active session and subagent. The handler now recognizes the decode failure and, keeping the session alive, faults the active advanced-IPC worker subsystems so their clients reject in-flight requests and recycle the subprocess instead of awaiting forever — mirroring the existing ipc-send EPIPE containment. ([#9158](https://github.com/can1357/oh-my-pi/issues/9158))
10
+
5
11
  ## [17.4.1] - 2026-08-21
6
12
 
7
13
  ### Added
@@ -44,6 +44,39 @@ export type BrokenPipeSource = "ipc-send" | "stdio-write";
44
44
  export declare function classifyBrokenPipe(err: Error): BrokenPipeSource | undefined;
45
45
  /** Whether an EPIPE came from an IPC `send()` to an optional worker. */
46
46
  export declare function isIpcSendEpipe(err: Error): boolean;
47
+ /**
48
+ * Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
49
+ *
50
+ * When a worker subprocess spawned with `serialization: "advanced"` sends a
51
+ * malformed or truncated frame, Bun raises the decode failure as a
52
+ * process-level `uncaughtException` in the *parent* rather than routing it to
53
+ * the channel's `ipc()` callback (oven-sh/bun#37287). The error is a bare
54
+ * `TypeError: Unable to deserialize data.` whose only own property is `message`
55
+ * — it carries no `code`, no `syscall`, and no `stack`. Matching all four traits
56
+ * keeps unrelated application `TypeError`s (which always carry a populated
57
+ * multi-frame stack) on the fatal path, so a genuine bug is never silently
58
+ * swallowed.
59
+ *
60
+ * Every advanced-serialization channel in this process is an optional worker
61
+ * subsystem (TTS, STT, tiny-title, mnemopi embeddings, JS eval), so one
62
+ * worker's bad frame must fault only that worker — via its own `onExit`/error
63
+ * path — never tear down the whole session. Callers log-and-continue instead of
64
+ * taking the fatal path. Mirrors {@link classifyBrokenPipe} for the send side
65
+ * (#2997, #9158).
66
+ */
67
+ export declare function isWorkerIpcDeserializeError(err: unknown): boolean;
68
+ /**
69
+ * Register a fault/recycle callback for an active advanced-serialization worker
70
+ * IPC channel.
71
+ *
72
+ * Bun surfaces a malformed frame as a process-global `uncaughtException`
73
+ * ({@link isWorkerIpcDeserializeError}) with no way to attribute it to a
74
+ * specific channel, so when one fires every registered handler is invoked to
75
+ * conservatively fault its worker — reject in-flight requests and recycle the
76
+ * subprocess — instead of leaving pending work to await forever. Returns an
77
+ * unregister function; callers MUST unregister when the worker exits.
78
+ */
79
+ export declare function registerWorkerIpcFaultHandler(handler: (err: Error) => void): () => void;
47
80
  /**
48
81
  * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
49
82
  *
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.4.1",
4
+ "version": "18.0.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "17.4.1"
34
+ "@oh-my-pi/pi-natives": "18.0.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
package/src/logger.ts CHANGED
@@ -91,7 +91,7 @@ function pruneStaleProcessLogs(dir: string): void {
91
91
  `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-` +
92
92
  String(cutoff.getDate()).padStart(2, "0");
93
93
 
94
- const staleLogsByProcessDay = new Map<string, Array<{ path: string; mtimeMs: number; rollover: number }>>();
94
+ const staleLogsByProcessDay = new Map<string, Array<{ path: string; rollover: number }>>();
95
95
  for (const entry of entries) {
96
96
  if (!entry.isFile()) continue;
97
97
  const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
@@ -120,25 +120,29 @@ function pruneStaleProcessLogs(dir: string): void {
120
120
  continue;
121
121
  }
122
122
 
123
- try {
124
- const key = `${pidText}:${logMatch[1]}`;
125
- const staleLogs = staleLogsByProcessDay.get(key) ?? [];
126
- staleLogs.push({
127
- path: entryPath,
128
- mtimeMs: fs.statSync(entryPath).mtimeMs,
129
- rollover: Number(logMatch[3] ?? 0),
130
- });
131
- staleLogsByProcessDay.set(key, staleLogs);
132
- } catch {
133
- // Another process may have pruned the same stale namespace.
134
- }
123
+ const key = `${pidText}:${logMatch[1]}`;
124
+ const staleLogs = staleLogsByProcessDay.get(key) ?? [];
125
+ staleLogs.push({
126
+ path: entryPath,
127
+ rollover: Number(logMatch[3] ?? 0),
128
+ });
129
+ staleLogsByProcessDay.set(key, staleLogs);
135
130
  }
136
131
 
137
132
  for (const staleLogs of staleLogsByProcessDay.values()) {
138
- staleLogs.sort(
133
+ if (staleLogs.length <= RETAINED_STALE_LOGS_PER_PROCESS_DAY) continue;
134
+ const ranked: Array<{ path: string; mtimeMs: number; rollover: number }> = [];
135
+ for (const stale of staleLogs) {
136
+ try {
137
+ ranked.push({ ...stale, mtimeMs: fs.statSync(stale.path).mtimeMs });
138
+ } catch {
139
+ // Another process may have pruned the same stale namespace.
140
+ }
141
+ }
142
+ ranked.sort(
139
143
  (a, b) => b.mtimeMs - a.mtimeMs || b.rollover - a.rollover || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
140
144
  );
141
- for (const stale of staleLogs.slice(RETAINED_STALE_LOGS_PER_PROCESS_DAY)) {
145
+ for (const stale of ranked.slice(RETAINED_STALE_LOGS_PER_PROCESS_DAY)) {
142
146
  try {
143
147
  fs.rmSync(stale.path, { force: true });
144
148
  } catch {
package/src/postmortem.ts CHANGED
@@ -146,6 +146,66 @@ export function isIpcSendEpipe(err: Error): boolean {
146
146
  return classifyBrokenPipe(err) === "ipc-send";
147
147
  }
148
148
 
149
+ /**
150
+ * Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
151
+ *
152
+ * When a worker subprocess spawned with `serialization: "advanced"` sends a
153
+ * malformed or truncated frame, Bun raises the decode failure as a
154
+ * process-level `uncaughtException` in the *parent* rather than routing it to
155
+ * the channel's `ipc()` callback (oven-sh/bun#37287). The error is a bare
156
+ * `TypeError: Unable to deserialize data.` whose only own property is `message`
157
+ * — it carries no `code`, no `syscall`, and no `stack`. Matching all four traits
158
+ * keeps unrelated application `TypeError`s (which always carry a populated
159
+ * multi-frame stack) on the fatal path, so a genuine bug is never silently
160
+ * swallowed.
161
+ *
162
+ * Every advanced-serialization channel in this process is an optional worker
163
+ * subsystem (TTS, STT, tiny-title, mnemopi embeddings, JS eval), so one
164
+ * worker's bad frame must fault only that worker — via its own `onExit`/error
165
+ * path — never tear down the whole session. Callers log-and-continue instead of
166
+ * taking the fatal path. Mirrors {@link classifyBrokenPipe} for the send side
167
+ * (#2997, #9158).
168
+ */
169
+ export function isWorkerIpcDeserializeError(err: unknown): boolean {
170
+ return (
171
+ err instanceof TypeError &&
172
+ err.message === "Unable to deserialize data." &&
173
+ !err.stack &&
174
+ !("code" in err) &&
175
+ !("syscall" in err)
176
+ );
177
+ }
178
+
179
+ /** Recycle callbacks for the active advanced-serialization worker IPC channels. */
180
+ const workerIpcFaultHandlers = new Set<(err: Error) => void>();
181
+
182
+ /**
183
+ * Register a fault/recycle callback for an active advanced-serialization worker
184
+ * IPC channel.
185
+ *
186
+ * Bun surfaces a malformed frame as a process-global `uncaughtException`
187
+ * ({@link isWorkerIpcDeserializeError}) with no way to attribute it to a
188
+ * specific channel, so when one fires every registered handler is invoked to
189
+ * conservatively fault its worker — reject in-flight requests and recycle the
190
+ * subprocess — instead of leaving pending work to await forever. Returns an
191
+ * unregister function; callers MUST unregister when the worker exits.
192
+ */
193
+ export function registerWorkerIpcFaultHandler(handler: (err: Error) => void): () => void {
194
+ workerIpcFaultHandlers.add(handler);
195
+ return () => workerIpcFaultHandlers.delete(handler);
196
+ }
197
+
198
+ /** Invoke every registered worker IPC fault handler, isolating handler throws. */
199
+ function faultWorkerIpcChannels(err: Error): void {
200
+ for (const handler of workerIpcFaultHandlers) {
201
+ try {
202
+ handler(err);
203
+ } catch (handlerErr) {
204
+ logger.warn("Worker IPC fault handler threw", { err: handlerErr });
205
+ }
206
+ }
207
+ }
208
+
149
209
  /**
150
210
  * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
151
211
  *
@@ -283,6 +343,20 @@ if (isMainThread) {
283
343
  logger.warn("Ignoring expected cleanup exception", { err });
284
344
  return;
285
345
  }
346
+ // A malformed advanced-serialization frame from a worker subprocess
347
+ // surfaces here as a process-level uncaughtException (oven-sh/bun#37287)
348
+ // rather than in the channel's ipc() callback, and Bun gives no way to
349
+ // tell which channel produced it. Contain it to the worker layer: keep
350
+ // the session alive and conservatively fault every active advanced-IPC
351
+ // worker so its owning client rejects in-flight requests and recycles
352
+ // the subprocess — a worker that sent a bad frame but stays alive would
353
+ // otherwise never fire onExit and leave callers awaiting forever.
354
+ // Mirrors the ipc-send EPIPE containment below (#9158, #2997).
355
+ if (isWorkerIpcDeserializeError(err)) {
356
+ logger.warn("Malformed worker IPC frame; faulting active worker subsystems", { err });
357
+ faultWorkerIpcChannels(err);
358
+ return;
359
+ }
286
360
  await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
287
361
  })
288
362
  .on("unhandledRejection", async reason => {
package/src/stream.ts CHANGED
@@ -4,25 +4,6 @@ import { abortableSource } from "./abortable";
4
4
  import { parseStreamingJson } from "./json-parse";
5
5
 
6
6
  const LF = 0x0a;
7
- type JsonlChunkResult = {
8
- values: unknown[];
9
- error: unknown;
10
- read: number;
11
- done: boolean;
12
- };
13
-
14
- function parseJsonlChunkCompat(input: Uint8Array, beg?: number, end?: number): JsonlChunkResult;
15
- function parseJsonlChunkCompat(input: string): JsonlChunkResult;
16
- function parseJsonlChunkCompat(input: Uint8Array | string, beg?: number, end?: number): JsonlChunkResult {
17
- if (typeof input === "string") {
18
- const { values, error, read, done } = Bun.JSONL.parseChunk(input);
19
- return { values, error, read, done };
20
- }
21
- const start = beg ?? 0;
22
- const stop = end ?? input.length;
23
- const { values, error, read, done } = Bun.JSONL.parseChunk(input, start, stop);
24
- return { values, error, read, done };
25
- }
26
7
 
27
8
  export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array> {
28
9
  const buffer = new ConcatSink();
@@ -58,7 +39,7 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
58
39
  const tail = buffer.flush();
59
40
  if (tail) {
60
41
  buffer.clear();
61
- const { values, error, done } = parseJsonlChunkCompat(tail, 0, tail.length);
42
+ const { values, error, done } = Bun.JSONL.parseChunk(tail, 0, tail.length);
62
43
  if (values.length > 0) {
63
44
  yield* values as T[];
64
45
  }
@@ -174,8 +155,15 @@ class ConcatSink {
174
155
  return text;
175
156
  }
176
157
  *pullJSONL<T>(chunk: Uint8Array, beg: number, end: number) {
158
+ const newline = chunk.indexOf(LF, beg);
159
+ if (newline === -1 || newline >= end) {
160
+ if (this.isEmpty) this.reset(chunk.subarray(beg, end));
161
+ else this.append(chunk.subarray(beg, end));
162
+ return;
163
+ }
164
+
177
165
  if (this.isEmpty) {
178
- const { values, error, read, done } = parseJsonlChunkCompat(chunk, beg, end);
166
+ const { values, error, read, done } = Bun.JSONL.parseChunk(chunk, beg, end);
179
167
  if (values.length > 0) {
180
168
  yield* values as T[];
181
169
  }
@@ -192,7 +180,7 @@ class ConcatSink {
192
180
  space.set(chunk.subarray(beg, end), offset);
193
181
  this.#length = total;
194
182
 
195
- const { values, error, read, done } = parseJsonlChunkCompat(space.subarray(0, total), 0, total);
183
+ const { values, error, read, done } = Bun.JSONL.parseChunk(space, 0, total);
196
184
  if (values.length > 0) {
197
185
  yield* values as T[];
198
186
  }
@@ -456,7 +444,7 @@ export function parseJsonlLenient<T>(buffer: string, options: { onMalformedRecor
456
444
  let entries: T[] | undefined;
457
445
 
458
446
  while (buffer.length > 0) {
459
- const { values, error, read, done } = parseJsonlChunkCompat(buffer);
447
+ const { values, error, read, done } = Bun.JSONL.parseChunk(buffer);
460
448
  if (values.length > 0) {
461
449
  const ext = values as T[];
462
450
  if (!entries) {