@oh-my-pi/pi-utils 18.0.6 → 18.0.7
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 +12 -0
- package/README.md +1 -0
- package/dist/types/acp/transport.d.ts +7 -0
- package/dist/types/dirs.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/math-delimiters.d.ts +45 -0
- package/dist/types/postmortem.d.ts +13 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +9 -0
- package/src/dirs.ts +15 -0
- package/src/index.ts +1 -0
- package/src/math-delimiters.ts +143 -0
- package/src/postmortem.ts +44 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.0.7] - 2026-08-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `math-delimiters`, the LaTeX span/block delimiter grammar (`mathStartIndex`, `mathOpenerAt`, `mathSpanAt`, `mathBlockAt`) shared by every Markdown renderer: pandoc's anti-currency rules for `$…$`, own-line display blocks, and delimiters matched by backslash parity, so an escaped `\$x$` stays literal and a TeX row break cannot end a span early.
|
|
10
|
+
- Added `RequestError.sessionBusy(message, data)` to represent ACP session-busy errors (`-32003`) through the shared JSON-RPC transport.
|
|
11
|
+
- Exported `getComposerCacheDir` for resolving the per-project Composer cache directory, including support for `XDG_CACHE_HOME`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed OMP sessions unexpectedly exiting during socket cleanup or optional-worker communication on Bun.
|
|
16
|
+
|
|
5
17
|
## [18.0.6] - 2026-08-26
|
|
6
18
|
|
|
7
19
|
### Added
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ Shared utilities for [oh-my-pi](https://github.com/can1357/oh-my-pi) packages. Z
|
|
|
17
17
|
| `fs-error` | Errno guards (`isEnoent` and friends) |
|
|
18
18
|
| `env` / `worker-host` | Environment plumbing and side-effect-free worker-host entry contract (`workerHostEntry`) |
|
|
19
19
|
| `abortable` / `async` | AbortSignal-aware stream/promise helpers |
|
|
20
|
+
| `math-delimiters` | LaTeX span/block delimiter grammar (offsets only) shared by the TUI and collab-web renderers |
|
|
20
21
|
| `peek-file` | Read the first N bytes of a file with pooled buffers |
|
|
21
22
|
| `frontmatter`, `glob`, `mime`, `temp`, `format`, `color`, `snowflake`, `tab-spacing`, `path-tree`, `sanitize-text` | Smaller single-purpose helpers |
|
|
22
23
|
|
|
@@ -55,6 +55,13 @@ export declare class RequestError extends Error {
|
|
|
55
55
|
static authRequired(data?: unknown, additionalMessage?: string): RequestError;
|
|
56
56
|
/** Creates a resource-not-found error. */
|
|
57
57
|
static resourceNotFound(uri?: string): RequestError;
|
|
58
|
+
/**
|
|
59
|
+
* Creates a session-busy error: the agent/session is already processing, so the
|
|
60
|
+
* request can be retried once idle (steer/follow-up/wait) instead of treating it
|
|
61
|
+
* as a fault. `message` carries the caller's user-facing wording; `data` should
|
|
62
|
+
* keep the stable discriminator shape (`reason: "session_busy"`).
|
|
63
|
+
*/
|
|
64
|
+
static sessionBusy(message: string, data?: unknown): RequestError;
|
|
58
65
|
/** Converts this error into a JSON-RPC result. */
|
|
59
66
|
toResult(): {
|
|
60
67
|
error: ErrorResponse;
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -228,6 +228,8 @@ export declare function getModelDbPath(agentDir?: string): string;
|
|
|
228
228
|
export declare function getTinyModelsCacheDir(agentDir?: string): string;
|
|
229
229
|
/** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
|
|
230
230
|
export declare function getDocumentConversionCacheDir(agentDir?: string): string;
|
|
231
|
+
/** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
|
|
232
|
+
export declare function getComposerCacheDir(agentDir?: string): string;
|
|
231
233
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
232
234
|
export declare function getSessionsDir(agentDir?: string): string;
|
|
233
235
|
/** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
|
|
@@ -274,6 +276,13 @@ export declare function getProjectPluginOverridesPath(cwd?: string): string;
|
|
|
274
276
|
export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string): string;
|
|
275
277
|
/** Get the SSH config file path. */
|
|
276
278
|
export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string): string;
|
|
279
|
+
/**
|
|
280
|
+
* Application label for usage attribution (`OMP_APP_NAME`), defaulting to
|
|
281
|
+
* `omp`. Embedders that drive omp programmatically (robomp, CI bots, …) set
|
|
282
|
+
* the env var so broker-side per-client burn tracking can answer "what did
|
|
283
|
+
* app X use" instead of folding everything into one install-wide bucket.
|
|
284
|
+
*/
|
|
285
|
+
export declare function getAppName(): string;
|
|
277
286
|
/**
|
|
278
287
|
* Persistent per-install UUID stored at `~/.omp/install-id`.
|
|
279
288
|
*
|
package/dist/types/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./json.js";
|
|
|
14
14
|
export * from "./json-parse.js";
|
|
15
15
|
export * as logger from "./logger.js";
|
|
16
16
|
export * from "./loop-phase.js";
|
|
17
|
+
export * from "./math-delimiters.js";
|
|
17
18
|
export * from "./mermaid-ascii.js";
|
|
18
19
|
export * from "./mime.js";
|
|
19
20
|
export * from "./path.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaTeX delimiter grammar for agent-authored Markdown: where does a math span
|
|
3
|
+
* begin and end, in source offsets. Carries no rendering policy — what to do
|
|
4
|
+
* with an unclosed opener, whether a body is typesettable, and how it is
|
|
5
|
+
* displayed belong to the renderer (Unicode in the TUI, KaTeX in collab web).
|
|
6
|
+
*/
|
|
7
|
+
/** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
|
|
8
|
+
export type MathOpener = "$" | "$$" | "\\(" | "\\[";
|
|
9
|
+
/** A closed math span found in the source. */
|
|
10
|
+
export interface MathSpan {
|
|
11
|
+
opener: MathOpener;
|
|
12
|
+
/** True for the display forms `$$…$$` and `\[…\]`. */
|
|
13
|
+
display: boolean;
|
|
14
|
+
/** Offset one past the closing delimiter. */
|
|
15
|
+
end: number;
|
|
16
|
+
/** Source between the delimiters, verbatim. */
|
|
17
|
+
body: string;
|
|
18
|
+
}
|
|
19
|
+
/** An own-line display block: opener and closer each alone on their line. */
|
|
20
|
+
export interface MathBlock {
|
|
21
|
+
/** Both delimiter lines, the body, and the trailing newline. */
|
|
22
|
+
raw: string;
|
|
23
|
+
body: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Leftmost offset at or after `from` where an opener could begin. A scan hint,
|
|
27
|
+
* not a decision: whether that candidate is really math — escaped, currency,
|
|
28
|
+
* unclosed — is decided by {@link mathSpanAt}.
|
|
29
|
+
*/
|
|
30
|
+
export declare function mathStartIndex(source: string, from?: number): number | undefined;
|
|
31
|
+
/** Math opener at `at`, or `undefined` when no delimiter starts there. */
|
|
32
|
+
export declare function mathOpenerAt(source: string, at: number): MathOpener | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The span opened at `at`, or `undefined` when the run is not math — including
|
|
35
|
+
* an opener the source escaped, so `\$x$` and `\\(x\)` are literal text.
|
|
36
|
+
*
|
|
37
|
+
* `from` bounds how far back the escape scan may look. Leave it at 0 when
|
|
38
|
+
* reading raw source. Pass the offset your own walk resumed at if you have
|
|
39
|
+
* already consumed the escapes behind it, as `renderMathInText` does: after it
|
|
40
|
+
* emits the `\\` of `\\\(x\)`, the `\(` that follows is a real opener even
|
|
41
|
+
* though a backslash precedes it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function mathSpanAt(source: string, at: number, from?: number): MathSpan | undefined;
|
|
44
|
+
/** The own-line display block starting at offset 0, or `undefined`. */
|
|
45
|
+
export declare function mathBlockAt(source: string): MathBlock | undefined;
|
|
@@ -44,6 +44,19 @@ 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
|
+
* Whether an uncaught error is Bun's asynchronous `ERR_SOCKET_CLOSED` thrown
|
|
49
|
+
* from inside `node:net` internals with no application frames on the stack.
|
|
50
|
+
*
|
|
51
|
+
* Bun ≥1.4 can fire the close callback of an already-closed `node:net` socket
|
|
52
|
+
* on a fresh stack; the throw bypasses every callsite try/catch and surfaces
|
|
53
|
+
* here as a process-level uncaughtException. Closing an already-closed socket
|
|
54
|
+
* is inherently a no-op — the socket owner's own `error`/`close` handlers
|
|
55
|
+
* still drive recovery — so tearing the session down for it is pure loss.
|
|
56
|
+
* Only frameless internal stacks qualify: an `ERR_SOCKET_CLOSED` raised
|
|
57
|
+
* through application code keeps the fatal path.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isInternalSocketClosedError(err: unknown): boolean;
|
|
47
60
|
/**
|
|
48
61
|
* Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
|
|
49
62
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "18.0.
|
|
4
|
+
"version": "18.0.7",
|
|
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": "18.0.
|
|
34
|
+
"@oh-my-pi/pi-natives": "18.0.7"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/bun": "^1.3.14"
|
package/src/acp/transport.ts
CHANGED
|
@@ -77,6 +77,15 @@ export class RequestError extends Error {
|
|
|
77
77
|
uri === undefined ? undefined : { uri },
|
|
78
78
|
);
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Creates a session-busy error: the agent/session is already processing, so the
|
|
82
|
+
* request can be retried once idle (steer/follow-up/wait) instead of treating it
|
|
83
|
+
* as a fault. `message` carries the caller's user-facing wording; `data` should
|
|
84
|
+
* keep the stable discriminator shape (`reason: "session_busy"`).
|
|
85
|
+
*/
|
|
86
|
+
static sessionBusy(message: string, data?: unknown): RequestError {
|
|
87
|
+
return new RequestError(-32003, message, data);
|
|
88
|
+
}
|
|
80
89
|
/** Converts this error into a JSON-RPC result. */
|
|
81
90
|
toResult(): { error: ErrorResponse } {
|
|
82
91
|
return { error: this.toErrorResponse() };
|
package/src/dirs.ts
CHANGED
|
@@ -791,6 +791,10 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
|
|
|
791
791
|
export function getDocumentConversionCacheDir(agentDir?: string): string {
|
|
792
792
|
return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
|
|
793
793
|
}
|
|
794
|
+
/** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
|
|
795
|
+
export function getComposerCacheDir(agentDir?: string): string {
|
|
796
|
+
return dirs.agentSubdir(agentDir, path.join("cache", "composer"), "cache");
|
|
797
|
+
}
|
|
794
798
|
|
|
795
799
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
796
800
|
export function getSessionsDir(agentDir?: string): string {
|
|
@@ -955,6 +959,17 @@ export function getSSHConfigPath(scope: "user" | "project", cwd: string = getPro
|
|
|
955
959
|
let cachedInstallId: string | null = null;
|
|
956
960
|
|
|
957
961
|
const INSTALL_ID_FILE = "install-id";
|
|
962
|
+
/**
|
|
963
|
+
* Application label for usage attribution (`OMP_APP_NAME`), defaulting to
|
|
964
|
+
* `omp`. Embedders that drive omp programmatically (robomp, CI bots, …) set
|
|
965
|
+
* the env var so broker-side per-client burn tracking can answer "what did
|
|
966
|
+
* app X use" instead of folding everything into one install-wide bucket.
|
|
967
|
+
*/
|
|
968
|
+
export function getAppName(): string {
|
|
969
|
+
const value = process.env.OMP_APP_NAME?.trim();
|
|
970
|
+
return value ? value : "omp";
|
|
971
|
+
}
|
|
972
|
+
|
|
958
973
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
959
974
|
|
|
960
975
|
/**
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaTeX delimiter grammar for agent-authored Markdown: where does a math span
|
|
3
|
+
* begin and end, in source offsets. Carries no rendering policy — what to do
|
|
4
|
+
* with an unclosed opener, whether a body is typesettable, and how it is
|
|
5
|
+
* displayed belong to the renderer (Unicode in the TUI, KaTeX in collab web).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
|
|
9
|
+
export type MathOpener = "$" | "$$" | "\\(" | "\\[";
|
|
10
|
+
|
|
11
|
+
/** A closed math span found in the source. */
|
|
12
|
+
export interface MathSpan {
|
|
13
|
+
opener: MathOpener;
|
|
14
|
+
/** True for the display forms `$$…$$` and `\[…\]`. */
|
|
15
|
+
display: boolean;
|
|
16
|
+
/** Offset one past the closing delimiter. */
|
|
17
|
+
end: number;
|
|
18
|
+
/** Source between the delimiters, verbatim. */
|
|
19
|
+
body: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An own-line display block: opener and closer each alone on their line. */
|
|
23
|
+
export interface MathBlock {
|
|
24
|
+
/** Both delimiter lines, the body, and the trailing newline. */
|
|
25
|
+
raw: string;
|
|
26
|
+
body: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
|
|
30
|
+
// their own line (≤3 leading spaces). Matched at the block level — before
|
|
31
|
+
// paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
|
|
32
|
+
// row breaks) survives as one unit and blank lines inside the block don't split
|
|
33
|
+
// it. The own-line requirement leaves inline `$$…$$` inside prose to the span
|
|
34
|
+
// grammar below. `\r?\n` at each line boundary keeps the grammar CRLF-safe for
|
|
35
|
+
// direct callers; marked-fed renderers already normalize line endings first.
|
|
36
|
+
const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\$\$[ \t]*(?:\r?\n|$)/;
|
|
37
|
+
const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\\\][ \t]*(?:\r?\n|$)/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Leftmost offset at or after `from` where an opener could begin. A scan hint,
|
|
41
|
+
* not a decision: whether that candidate is really math — escaped, currency,
|
|
42
|
+
* unclosed — is decided by {@link mathSpanAt}.
|
|
43
|
+
*/
|
|
44
|
+
// Three indexOf scans instead of a `/\$|\\\(|\\\[/` alternation — marked calls
|
|
45
|
+
// this on the remaining source at every inline position, where the alternation
|
|
46
|
+
// showed up in CPU profiles (part of a ~4.3% start() tail).
|
|
47
|
+
export function mathStartIndex(source: string, from = 0): number | undefined {
|
|
48
|
+
let best = source.indexOf("$", from);
|
|
49
|
+
const paren = source.indexOf("\\(", from);
|
|
50
|
+
if (paren !== -1 && (best === -1 || paren < best)) best = paren;
|
|
51
|
+
const bracket = source.indexOf("\\[", from);
|
|
52
|
+
if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
|
|
53
|
+
return best === -1 ? undefined : best;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Math opener at `at`, or `undefined` when no delimiter starts there. */
|
|
57
|
+
export function mathOpenerAt(source: string, at: number): MathOpener | undefined {
|
|
58
|
+
const first = source.charCodeAt(at);
|
|
59
|
+
if (first === 0x24 /* $ */) return source.charCodeAt(at + 1) === 0x24 ? "$$" : "$";
|
|
60
|
+
if (first !== 0x5c /* \ */) return undefined;
|
|
61
|
+
const second = source.charCodeAt(at + 1);
|
|
62
|
+
if (second === 0x28 /* ( */) return "\\(";
|
|
63
|
+
if (second === 0x5b /* [ */) return "\\[";
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The span opened at `at`, or `undefined` when the run is not math — including
|
|
69
|
+
* an opener the source escaped, so `\$x$` and `\\(x\)` are literal text.
|
|
70
|
+
*
|
|
71
|
+
* `from` bounds how far back the escape scan may look. Leave it at 0 when
|
|
72
|
+
* reading raw source. Pass the offset your own walk resumed at if you have
|
|
73
|
+
* already consumed the escapes behind it, as `renderMathInText` does: after it
|
|
74
|
+
* emits the `\\` of `\\\(x\)`, the `\(` that follows is a real opener even
|
|
75
|
+
* though a backslash precedes it.
|
|
76
|
+
*/
|
|
77
|
+
export function mathSpanAt(source: string, at: number, from = 0): MathSpan | undefined {
|
|
78
|
+
const opener = mathOpenerAt(source, at);
|
|
79
|
+
if (opener === undefined || escapedAt(source, at, from)) return undefined;
|
|
80
|
+
const bodyStart = at + opener.length;
|
|
81
|
+
const closeAt = opener === "$" ? dollarCloserIndex(source, at) : closerIndex(source, opener, bodyStart);
|
|
82
|
+
if (closeAt === -1) return undefined;
|
|
83
|
+
const body = source.slice(bodyStart, closeAt);
|
|
84
|
+
// `dollarCloserIndex` already rejects an all-space `$…$`; `$$ $$` needs the
|
|
85
|
+
// same guard here, while `\(\)` and `\[\]` are unambiguous enough to keep.
|
|
86
|
+
if (opener === "$$" && body.trim() === "") return undefined;
|
|
87
|
+
return { opener, display: opener === "$$" || opener === "\\[", end: closeAt + opener.length, body };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The own-line display block starting at offset 0, or `undefined`. */
|
|
91
|
+
export function mathBlockAt(source: string): MathBlock | undefined {
|
|
92
|
+
const match = MATH_BLOCK_DOLLAR.exec(source) ?? MATH_BLOCK_BRACKET.exec(source);
|
|
93
|
+
if (!match || match[1].trim() === "") return undefined;
|
|
94
|
+
return { raw: match[0], body: match[1] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Offset of the `$$` / `\)` / `\]` that closes a span, or -1. In `\(a \\) b\)`
|
|
99
|
+
* the `\\` is a TeX row break, so that `)` is body text and the span closes at
|
|
100
|
+
* the final `\)`.
|
|
101
|
+
*/
|
|
102
|
+
function closerIndex(source: string, opener: MathOpener, from: number): number {
|
|
103
|
+
// Dollar closers equal their openers; the bracket forms flip the bracket.
|
|
104
|
+
const closer = opener === "\\(" ? "\\)" : opener === "\\[" ? "\\]" : opener;
|
|
105
|
+
for (let at = source.indexOf(closer, from); at !== -1; at = source.indexOf(closer, at + 1)) {
|
|
106
|
+
if (!escapedAt(source, at, from)) return at;
|
|
107
|
+
}
|
|
108
|
+
return -1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** An odd run of backslashes back to `from` escapes the delimiter at `index`. */
|
|
112
|
+
function escapedAt(source: string, index: number, from: number): boolean {
|
|
113
|
+
let backslashes = 0;
|
|
114
|
+
for (let at = index - 1; at >= from && source.charCodeAt(at) === 0x5c /* \ */; at--) backslashes++;
|
|
115
|
+
return backslashes % 2 === 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Offset of the `$` that closes an inline span opened at `open`, or -1. Pandoc's
|
|
120
|
+
* anti-currency heuristics: the opener must not be followed by whitespace, the
|
|
121
|
+
* closer must not be preceded by whitespace nor followed by a digit, `\$` is a
|
|
122
|
+
* literal dollar, and the span may not cross a newline — so "$5 and $10" is
|
|
123
|
+
* prose, not math.
|
|
124
|
+
*/
|
|
125
|
+
function dollarCloserIndex(source: string, open: number): number {
|
|
126
|
+
const after = source[open + 1];
|
|
127
|
+
if (after === undefined || after === " " || after === "\t" || after === "\n" || after === "$") return -1;
|
|
128
|
+
for (let at = open + 1; at < source.length; at++) {
|
|
129
|
+
const char = source[at];
|
|
130
|
+
if (char === "\\") {
|
|
131
|
+
at++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (char === "\n") return -1;
|
|
135
|
+
if (char !== "$") continue;
|
|
136
|
+
const before = source[at - 1];
|
|
137
|
+
if (before === " " || before === "\t") return -1;
|
|
138
|
+
const next = source[at + 1];
|
|
139
|
+
if (next !== undefined && next >= "0" && next <= "9") continue; // currency: keep scanning
|
|
140
|
+
return source.slice(open + 1, at).trim().length > 0 ? at : -1;
|
|
141
|
+
}
|
|
142
|
+
return -1;
|
|
143
|
+
}
|
package/src/postmortem.ts
CHANGED
|
@@ -146,6 +146,33 @@ export function isIpcSendEpipe(err: Error): boolean {
|
|
|
146
146
|
return classifyBrokenPipe(err) === "ipc-send";
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Whether an uncaught error is Bun's asynchronous `ERR_SOCKET_CLOSED` thrown
|
|
151
|
+
* from inside `node:net` internals with no application frames on the stack.
|
|
152
|
+
*
|
|
153
|
+
* Bun ≥1.4 can fire the close callback of an already-closed `node:net` socket
|
|
154
|
+
* on a fresh stack; the throw bypasses every callsite try/catch and surfaces
|
|
155
|
+
* here as a process-level uncaughtException. Closing an already-closed socket
|
|
156
|
+
* is inherently a no-op — the socket owner's own `error`/`close` handlers
|
|
157
|
+
* still drive recovery — so tearing the session down for it is pure loss.
|
|
158
|
+
* Only frameless internal stacks qualify: an `ERR_SOCKET_CLOSED` raised
|
|
159
|
+
* through application code keeps the fatal path.
|
|
160
|
+
*/
|
|
161
|
+
export function isInternalSocketClosedError(err: unknown): boolean {
|
|
162
|
+
if (!(err instanceof Error) || !("code" in err) || err.code !== "ERR_SOCKET_CLOSED") return false;
|
|
163
|
+
const frames = (err.stack ?? "").split("\n").slice(1);
|
|
164
|
+
if (frames.length === 0) return false;
|
|
165
|
+
let hasNetFrame = false;
|
|
166
|
+
const internal = frames.every(frame => {
|
|
167
|
+
const trimmed = frame.trim();
|
|
168
|
+
if (trimmed === "" || trimmed === "at unknown" || trimmed === "at native") return true;
|
|
169
|
+
if (!/\(node:[^)]*\)$/.test(trimmed) && !/^at node:/.test(trimmed)) return false;
|
|
170
|
+
hasNetFrame ||= trimmed.includes("node:net:");
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
return internal && hasNetFrame;
|
|
174
|
+
}
|
|
175
|
+
|
|
149
176
|
/**
|
|
150
177
|
* Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
|
|
151
178
|
*
|
|
@@ -338,9 +365,17 @@ if (isMainThread) {
|
|
|
338
365
|
const url = inspector.url();
|
|
339
366
|
process.stderr.write(`Inspector opened: ${url}\n`);
|
|
340
367
|
})
|
|
341
|
-
.on("uncaughtException", async
|
|
342
|
-
if (isExpectedCleanupError(
|
|
343
|
-
logger.warn("Ignoring expected cleanup exception", { err });
|
|
368
|
+
.on("uncaughtException", async thrown => {
|
|
369
|
+
if (isExpectedCleanupError(thrown)) {
|
|
370
|
+
logger.warn("Ignoring expected cleanup exception", { err: thrown });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
374
|
+
// Bun can surface a worker IPC send race through uncaughtException
|
|
375
|
+
// instead of unhandledRejection. Apply the same optional-worker
|
|
376
|
+
// containment in either global error channel.
|
|
377
|
+
if (isIpcSendEpipe(err)) {
|
|
378
|
+
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
344
379
|
return;
|
|
345
380
|
}
|
|
346
381
|
// A malformed advanced-serialization frame from a worker subprocess
|
|
@@ -357,6 +392,12 @@ if (isMainThread) {
|
|
|
357
392
|
faultWorkerIpcChannels(err);
|
|
358
393
|
return;
|
|
359
394
|
}
|
|
395
|
+
if (isInternalSocketClosedError(err)) {
|
|
396
|
+
logger.warn("Ignoring async ERR_SOCKET_CLOSED from node:net internals; socket owner recovers itself", {
|
|
397
|
+
err,
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
360
401
|
await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
|
|
361
402
|
})
|
|
362
403
|
.on("unhandledRejection", async reason => {
|