@oh-my-pi/pi-utils 16.5.2 → 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 +13 -0
- package/dist/types/postmortem.d.ts +14 -5
- package/dist/types/stream.d.ts +2 -3
- package/package.json +2 -2
- package/src/frontmatter.ts +16 -3
- package/src/postmortem.ts +43 -11
- package/src/stream.ts +48 -26
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
11
|
+
## [17.0.0] - 2026-07-15
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Improved SSE streaming performance by batching complete lines into a single UTF-8 decode per chunk, reducing decoder overhead.
|
|
16
|
+
- Fixed an issue in `parseFrontmatter` where a single malformed YAML line would corrupt sibling values by parsing each line independently.
|
|
17
|
+
|
|
5
18
|
## [16.5.2] - 2026-07-14
|
|
6
19
|
|
|
7
20
|
### 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
|
-
*
|
|
13
|
-
*
|
|
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/dist/types/stream.d.ts
CHANGED
|
@@ -41,9 +41,8 @@ export interface ServerSentEvent {
|
|
|
41
41
|
* Use `readSseJson` instead when every event is a single `data:` JSON object
|
|
42
42
|
* and you don't need access to the `event:` field.
|
|
43
43
|
*
|
|
44
|
-
* Internally backed by a Buffer-based
|
|
45
|
-
*
|
|
46
|
-
* accumulated buffer.
|
|
44
|
+
* Internally backed by a Buffer-based reader (`ConcatSink`) that batches all
|
|
45
|
+
* complete lines in each source chunk into one UTF-8 decode.
|
|
47
46
|
*
|
|
48
47
|
* @example
|
|
49
48
|
* ```ts
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "
|
|
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": "
|
|
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/frontmatter.ts
CHANGED
|
@@ -149,12 +149,25 @@ export function parseFrontmatter(
|
|
|
149
149
|
throw err;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
// Simple
|
|
152
|
+
// Simple key: value fallback. Reparse each value on its own so one
|
|
153
|
+
// malformed line (e.g. `scope: "text","thinking"`) can't leave sibling
|
|
154
|
+
// values wrapped in literal quotes; values that don't parse as YAML fall
|
|
155
|
+
// back to the raw trimmed string (issue #4796).
|
|
153
156
|
for (const line of metadata.split("\n")) {
|
|
154
157
|
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
|
155
|
-
if (match)
|
|
156
|
-
|
|
158
|
+
if (!match) continue;
|
|
159
|
+
const raw = match[2].trim();
|
|
160
|
+
let value: unknown = raw;
|
|
161
|
+
if (raw.length > 0) {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = YAML.parse(raw);
|
|
164
|
+
if (parsed !== null && typeof parsed !== "object") value = parsed;
|
|
165
|
+
else if (Array.isArray(parsed)) value = parsed;
|
|
166
|
+
} catch {
|
|
167
|
+
// keep the raw string
|
|
168
|
+
}
|
|
157
169
|
}
|
|
170
|
+
frontmatter[match[1]] = value;
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
return { frontmatter: normalizeKeys(frontmatter) as Record<string, unknown>, body };
|
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
|
-
|
|
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
|
-
*
|
|
82
|
-
*
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 (
|
|
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;
|
package/src/stream.ts
CHANGED
|
@@ -150,6 +150,29 @@ class ConcatSink {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined {
|
|
155
|
+
const lastNewline = chunk.lastIndexOf(LF);
|
|
156
|
+
if (lastNewline === -1) {
|
|
157
|
+
this.append(chunk);
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const completeEnd = lastNewline + 1;
|
|
162
|
+
let text: string;
|
|
163
|
+
if (this.isEmpty) {
|
|
164
|
+
const complete = completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd);
|
|
165
|
+
text = decoder.decode(complete);
|
|
166
|
+
} else {
|
|
167
|
+
this.append(completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd));
|
|
168
|
+
text = decoder.decode(this.flush());
|
|
169
|
+
this.clear();
|
|
170
|
+
}
|
|
171
|
+
if (completeEnd < chunk.length) {
|
|
172
|
+
this.append(chunk.subarray(completeEnd));
|
|
173
|
+
}
|
|
174
|
+
return text;
|
|
175
|
+
}
|
|
153
176
|
*pullJSONL<T>(chunk: Uint8Array, beg: number, end: number) {
|
|
154
177
|
if (this.isEmpty) {
|
|
155
178
|
const { values, error, read, done } = parseJsonlChunkCompat(chunk, beg, end);
|
|
@@ -275,14 +298,9 @@ interface SseEventState {
|
|
|
275
298
|
raw: string[];
|
|
276
299
|
}
|
|
277
300
|
|
|
278
|
-
//
|
|
279
|
-
// LF
|
|
280
|
-
|
|
281
|
-
const SSE_LINE_DECODER = new TextDecoder("utf-8");
|
|
282
|
-
|
|
283
|
-
function decodeSseLineBytes(line: Uint8Array, end: number): string {
|
|
284
|
-
return end === line.length ? SSE_LINE_DECODER.decode(line) : SSE_LINE_DECODER.decode(line.subarray(0, end));
|
|
285
|
-
}
|
|
301
|
+
// Complete lines are decoded in one batch per source chunk. Each batch ends on
|
|
302
|
+
// LF, which cannot split a multi-byte UTF-8 sequence.
|
|
303
|
+
const SSE_DECODER = new TextDecoder("utf-8");
|
|
286
304
|
|
|
287
305
|
function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
288
306
|
if (state.event === null && state.data === null) {
|
|
@@ -300,25 +318,25 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
|
300
318
|
return event;
|
|
301
319
|
}
|
|
302
320
|
|
|
303
|
-
function pushSseLine(line:
|
|
304
|
-
//
|
|
321
|
+
function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null {
|
|
322
|
+
// Complete-line batches split on LF only; strip a trailing CR so CRLF sources
|
|
305
323
|
// don't leak `\r` into field values.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
324
|
+
if (line.charCodeAt(line.length - 1) === 0x0d /* '\r' */) {
|
|
325
|
+
line = line.slice(0, -1);
|
|
326
|
+
}
|
|
327
|
+
if (line.length === 0) return flushSseEvent(state);
|
|
309
328
|
|
|
310
329
|
// Comment line: keep in `raw` for diagnostic context, skip parsing.
|
|
311
|
-
if (line
|
|
312
|
-
state.raw.push(
|
|
330
|
+
if (line.charCodeAt(0) === 0x3a /* ':' */) {
|
|
331
|
+
state.raw.push(line);
|
|
313
332
|
return null;
|
|
314
333
|
}
|
|
315
334
|
|
|
316
|
-
|
|
317
|
-
state.raw.push(text);
|
|
335
|
+
state.raw.push(line);
|
|
318
336
|
|
|
319
|
-
const colon =
|
|
320
|
-
const fieldName = colon === -1 ?
|
|
321
|
-
let value = colon === -1 ? "" :
|
|
337
|
+
const colon = line.indexOf(":");
|
|
338
|
+
const fieldName = colon === -1 ? line : line.slice(0, colon);
|
|
339
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
322
340
|
if (value.charCodeAt(0) === 0x20 /* ' ' */) value = value.slice(1);
|
|
323
341
|
|
|
324
342
|
if (fieldName === "event") {
|
|
@@ -344,9 +362,8 @@ function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent |
|
|
|
344
362
|
* Use `readSseJson` instead when every event is a single `data:` JSON object
|
|
345
363
|
* and you don't need access to the `event:` field.
|
|
346
364
|
*
|
|
347
|
-
* Internally backed by a Buffer-based
|
|
348
|
-
*
|
|
349
|
-
* accumulated buffer.
|
|
365
|
+
* Internally backed by a Buffer-based reader (`ConcatSink`) that batches all
|
|
366
|
+
* complete lines in each source chunk into one UTF-8 decode.
|
|
350
367
|
*
|
|
351
368
|
* @example
|
|
352
369
|
* ```ts
|
|
@@ -365,9 +382,14 @@ export async function* readSseEvents(
|
|
|
365
382
|
const source = abortableSource(stream, signal);
|
|
366
383
|
try {
|
|
367
384
|
for await (const chunk of source) {
|
|
368
|
-
|
|
369
|
-
|
|
385
|
+
const text = lineBuffer.appendAndFlushText(chunk, SSE_DECODER);
|
|
386
|
+
if (text === undefined) continue;
|
|
387
|
+
let start = 0;
|
|
388
|
+
while (start < text.length) {
|
|
389
|
+
const newline = text.indexOf("\n", start);
|
|
390
|
+
const event = pushSseLine(text.slice(start, newline), state);
|
|
370
391
|
if (event) yield event;
|
|
392
|
+
start = newline + 1;
|
|
371
393
|
}
|
|
372
394
|
}
|
|
373
395
|
// Treat any trailing partial line (no terminating LF) as a complete line.
|
|
@@ -375,7 +397,7 @@ export async function* readSseEvents(
|
|
|
375
397
|
const tail = lineBuffer.flush();
|
|
376
398
|
if (tail) {
|
|
377
399
|
lineBuffer.clear();
|
|
378
|
-
const event = pushSseLine(tail, state);
|
|
400
|
+
const event = pushSseLine(SSE_DECODER.decode(tail), state);
|
|
379
401
|
if (event) {
|
|
380
402
|
trailingEvents.add(event);
|
|
381
403
|
yield event;
|