@linxiraos/pi-utils 1.1.4 → 1.1.6
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 +6 -3
- package/README.md +1 -0
- package/dist/types/acp/transport.d.ts +7 -0
- package/dist/types/async.d.ts +2 -0
- package/dist/types/color.d.ts +34 -0
- package/dist/types/dirs.d.ts +48 -24
- package/dist/types/index.d.ts +1 -0
- package/dist/types/json.d.ts +6 -0
- package/dist/types/math-delimiters.d.ts +45 -0
- package/dist/types/postmortem.d.ts +46 -9
- package/dist/types/ptree.d.ts +15 -1
- package/dist/types/sqlite.d.ts +3 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +9 -0
- package/src/async.ts +20 -6
- package/src/browsers.ts +14 -2
- package/src/color.ts +173 -0
- package/src/dirs.ts +129 -59
- package/src/env.ts +2 -2
- package/src/index.ts +1 -0
- package/src/json.ts +23 -0
- package/src/math-delimiters.ts +143 -0
- package/src/postmortem.ts +212 -63
- package/src/ptree.ts +308 -39
- package/src/sqlite.ts +6 -0
|
@@ -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
|
@@ -24,10 +24,25 @@ export enum Reason {
|
|
|
24
24
|
MANUAL = "manual", // Manual cleanup (not triggered by process)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
interface CleanupRegistration {
|
|
28
|
+
id: string;
|
|
29
|
+
callback: (reason: Reason) => Promise<void> | void;
|
|
30
|
+
exitOnly: boolean;
|
|
31
|
+
cancelled: boolean;
|
|
32
|
+
lastPass: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Active cleanup callbacks in registration order. Registrations survive
|
|
36
|
+
// keep-alive passes; `lastPass` enforces at-most-once invocation per pass.
|
|
37
|
+
const callbackList: CleanupRegistration[] = [];
|
|
38
|
+
// Tracks cleanup run state (to prevent recursion/reentry issues).
|
|
30
39
|
let cleanupStage: "idle" | "running" | "complete" = "idle";
|
|
40
|
+
let cleanupPass = 0;
|
|
41
|
+
let activeCleanupReason: Reason | undefined;
|
|
42
|
+
let activeCleanupKeepAlive = false;
|
|
43
|
+
// Promises of callbacks invoked late (registered while a pass runs), joined by
|
|
44
|
+
// the active pass before it settles so `cleanup()`/signal exits await them.
|
|
45
|
+
let activeLatePromises: Promise<void>[] | undefined;
|
|
31
46
|
const CLEANUP_DEADLINE_MS = 10_000;
|
|
32
47
|
/**
|
|
33
48
|
* Symbol stamped by the extension-load guard onto the throwing replacement it
|
|
@@ -78,13 +93,32 @@ export interface FatalRecoveryHint {
|
|
|
78
93
|
type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined;
|
|
79
94
|
const fatalRecoveryHintProviders = new Set<FatalRecoveryHintProvider>();
|
|
80
95
|
|
|
96
|
+
function invokeCleanup(
|
|
97
|
+
registration: CleanupRegistration,
|
|
98
|
+
reason: Reason,
|
|
99
|
+
keepAlive: boolean,
|
|
100
|
+
pass: number,
|
|
101
|
+
): Promise<void> | void {
|
|
102
|
+
if (registration.cancelled || registration.lastPass === pass) return;
|
|
103
|
+
if (registration.exitOnly && keepAlive) return;
|
|
104
|
+
registration.lastPass = pass;
|
|
105
|
+
return registration.callback(reason);
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
/**
|
|
82
109
|
* Internal: runs all registered cleanup callbacks for the given reason.
|
|
83
|
-
* Ensures each
|
|
110
|
+
* Ensures each registration is invoked at most once per pass, handles errors,
|
|
111
|
+
* and prevents reentrancy.
|
|
112
|
+
*
|
|
113
|
+
* `keepAlive` marks a manual cleanup that keeps the process running (see
|
|
114
|
+
* {@link cleanup}). Such a pass returns the stage to `idle`; registrations stay
|
|
115
|
+
* active for later resources and the eventual real exit. Exit-only callbacks
|
|
116
|
+
* skip keep-alive passes without consuming their registration. An exit-driven
|
|
117
|
+
* pass instead settles to `complete` and stays there.
|
|
84
118
|
*
|
|
85
119
|
* Returns a Promise that settles after all cleanups complete or error out.
|
|
86
120
|
*/
|
|
87
|
-
function runCleanup(reason: Reason): Promise<void> {
|
|
121
|
+
function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
|
|
88
122
|
switch (cleanupStage) {
|
|
89
123
|
case "idle":
|
|
90
124
|
cleanupStage = "running";
|
|
@@ -95,30 +129,52 @@ function runCleanup(reason: Reason): Promise<void> {
|
|
|
95
129
|
return Promise.resolve();
|
|
96
130
|
}
|
|
97
131
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
const pass = ++cleanupPass;
|
|
133
|
+
activeCleanupReason = reason;
|
|
134
|
+
activeCleanupKeepAlive = keepAlive;
|
|
135
|
+
const late: Promise<void>[] = [];
|
|
136
|
+
activeLatePromises = late;
|
|
137
|
+
const settle = (): void => {
|
|
138
|
+
if (activeLatePromises === late) activeLatePromises = undefined;
|
|
139
|
+
if (cleanupPass !== pass) return;
|
|
140
|
+
cleanupStage = keepAlive ? "idle" : "complete";
|
|
141
|
+
if (keepAlive) {
|
|
142
|
+
activeCleanupReason = undefined;
|
|
143
|
+
activeCleanupKeepAlive = false;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Snapshot the pass. Registrations added while a keep-alive cleanup runs are
|
|
148
|
+
// invoked by register() when appropriate and remain active for later passes.
|
|
149
|
+
const promises = callbackList.toReversed().map(registration => {
|
|
150
|
+
return Promise.try(() => invokeCleanup(registration, reason, keepAlive, pass));
|
|
102
151
|
});
|
|
103
152
|
|
|
104
|
-
const cleanupSettled = Promise.allSettled(promises).then(results => {
|
|
153
|
+
const cleanupSettled = Promise.allSettled(promises).then(async results => {
|
|
105
154
|
for (const result of results) {
|
|
106
155
|
if (result.status === "rejected") {
|
|
107
156
|
const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
|
|
108
157
|
logger.error("Cleanup callback failed", { err, stack: err.stack });
|
|
109
158
|
}
|
|
110
159
|
}
|
|
111
|
-
|
|
160
|
+
// Join callbacks registered while this pass ran (already error-caught);
|
|
161
|
+
// each batch may register more. The deadline race still bounds the pass.
|
|
162
|
+
while (late.length > 0) await Promise.allSettled(late.splice(0));
|
|
163
|
+
settle();
|
|
112
164
|
});
|
|
113
165
|
const deadline = Promise.withResolvers<void>();
|
|
114
166
|
const deadlineTimer = setTimeout(() => {
|
|
115
167
|
logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
|
|
116
|
-
|
|
168
|
+
settle();
|
|
117
169
|
deadline.resolve();
|
|
118
170
|
}, CLEANUP_DEADLINE_MS);
|
|
119
|
-
|
|
171
|
+
const passPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
|
|
120
172
|
clearTimeout(deadlineTimer);
|
|
173
|
+
// A re-armed pass must drop only its own settled promise; an older
|
|
174
|
+
// deadline-limited pass may finish after a newer one has already started.
|
|
175
|
+
if (keepAlive && cleanupPass === pass && cleanupPromise === passPromise) cleanupPromise = undefined;
|
|
121
176
|
});
|
|
177
|
+
cleanupPromise = passPromise;
|
|
122
178
|
return cleanupPromise;
|
|
123
179
|
}
|
|
124
180
|
|
|
@@ -146,6 +202,33 @@ export function isIpcSendEpipe(err: Error): boolean {
|
|
|
146
202
|
return classifyBrokenPipe(err) === "ipc-send";
|
|
147
203
|
}
|
|
148
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Whether an uncaught error is Bun's asynchronous `ERR_SOCKET_CLOSED` thrown
|
|
207
|
+
* from inside `node:net` internals with no application frames on the stack.
|
|
208
|
+
*
|
|
209
|
+
* Bun ≥1.4 can fire the close callback of an already-closed `node:net` socket
|
|
210
|
+
* on a fresh stack; the throw bypasses every callsite try/catch and surfaces
|
|
211
|
+
* here as a process-level uncaughtException. Closing an already-closed socket
|
|
212
|
+
* is inherently a no-op — the socket owner's own `error`/`close` handlers
|
|
213
|
+
* still drive recovery — so tearing the session down for it is pure loss.
|
|
214
|
+
* Only frameless internal stacks qualify: an `ERR_SOCKET_CLOSED` raised
|
|
215
|
+
* through application code keeps the fatal path.
|
|
216
|
+
*/
|
|
217
|
+
export function isInternalSocketClosedError(err: unknown): boolean {
|
|
218
|
+
if (!(err instanceof Error) || !("code" in err) || err.code !== "ERR_SOCKET_CLOSED") return false;
|
|
219
|
+
const frames = (err.stack ?? "").split("\n").slice(1);
|
|
220
|
+
if (frames.length === 0) return false;
|
|
221
|
+
let hasNetFrame = false;
|
|
222
|
+
const internal = frames.every(frame => {
|
|
223
|
+
const trimmed = frame.trim();
|
|
224
|
+
if (trimmed === "" || trimmed === "at unknown" || trimmed === "at native") return true;
|
|
225
|
+
if (!/\(node:[^)]*\)$/.test(trimmed) && !/^at node:/.test(trimmed)) return false;
|
|
226
|
+
hasNetFrame ||= trimmed.includes("node:net:");
|
|
227
|
+
return true;
|
|
228
|
+
});
|
|
229
|
+
return internal && hasNetFrame;
|
|
230
|
+
}
|
|
231
|
+
|
|
149
232
|
/**
|
|
150
233
|
* Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
|
|
151
234
|
*
|
|
@@ -236,25 +319,29 @@ const EXPECTED_CLEANUP = Symbol.for("omp.expectedCleanupError");
|
|
|
236
319
|
* consumer. Returns the same error for inline use at the `abort()` callsite.
|
|
237
320
|
*/
|
|
238
321
|
export function markExpectedCleanupError<T extends object>(reason: T): T {
|
|
239
|
-
(reason
|
|
322
|
+
Reflect.set(reason, EXPECTED_CLEANUP, true);
|
|
240
323
|
return reason;
|
|
241
324
|
}
|
|
242
325
|
|
|
243
|
-
|
|
244
|
-
* Whether `reason` (or any error in its `cause` chain) was marked via
|
|
245
|
-
* {@link markExpectedCleanupError}. Walks the chain because the unhandled
|
|
246
|
-
* reason is often a wrapper (`AbortError`) with the marked abort reason as
|
|
247
|
-
* its `cause`.
|
|
248
|
-
*/
|
|
249
|
-
export function isExpectedCleanupError(reason: unknown): boolean {
|
|
326
|
+
function hasExpectedCleanupMarker(reason: unknown): boolean {
|
|
250
327
|
let current: unknown = reason;
|
|
251
328
|
for (let depth = 0; depth < 8 && current !== null && typeof current === "object"; depth++) {
|
|
252
|
-
if ((current
|
|
253
|
-
current = (current
|
|
329
|
+
if (Reflect.get(current, EXPECTED_CLEANUP) === true) return true;
|
|
330
|
+
current = Reflect.get(current, "cause");
|
|
254
331
|
}
|
|
255
332
|
return false;
|
|
256
333
|
}
|
|
257
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Whether `reason` (or any object in its bounded `cause` chain) was explicitly
|
|
337
|
+
* marked via {@link markExpectedCleanupError}. Runtime error names and codes
|
|
338
|
+
* are intentionally insufficient: unmarked `AbortError` and socket failures
|
|
339
|
+
* can originate from application code and must remain fatal when unhandled.
|
|
340
|
+
*/
|
|
341
|
+
export function isExpectedCleanupError(reason: unknown): boolean {
|
|
342
|
+
return hasExpectedCleanupMarker(reason);
|
|
343
|
+
}
|
|
344
|
+
|
|
258
345
|
/** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
|
|
259
346
|
const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
|
|
260
347
|
|
|
@@ -338,9 +425,20 @@ if (isMainThread) {
|
|
|
338
425
|
const url = inspector.url();
|
|
339
426
|
process.stderr.write(`Inspector opened: ${url}\n`);
|
|
340
427
|
})
|
|
341
|
-
.on("uncaughtException", async
|
|
342
|
-
|
|
343
|
-
|
|
428
|
+
.on("uncaughtException", async thrown => {
|
|
429
|
+
// Only explicitly marked exceptions are safe here. Structural
|
|
430
|
+
// AbortError/socket classification is limited to promise rejections:
|
|
431
|
+
// a synchronously thrown error may indicate an application bug.
|
|
432
|
+
if (hasExpectedCleanupMarker(thrown)) {
|
|
433
|
+
logger.warn("Ignoring expected cleanup exception", { err: thrown });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
437
|
+
// Bun can surface a worker IPC send race through uncaughtException
|
|
438
|
+
// instead of unhandledRejection. Apply the same optional-worker
|
|
439
|
+
// containment in either global error channel.
|
|
440
|
+
if (isIpcSendEpipe(err)) {
|
|
441
|
+
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
344
442
|
return;
|
|
345
443
|
}
|
|
346
444
|
// A malformed advanced-serialization frame from a worker subprocess
|
|
@@ -357,6 +455,12 @@ if (isMainThread) {
|
|
|
357
455
|
faultWorkerIpcChannels(err);
|
|
358
456
|
return;
|
|
359
457
|
}
|
|
458
|
+
if (isInternalSocketClosedError(err)) {
|
|
459
|
+
logger.warn("Ignoring async ERR_SOCKET_CLOSED from node:net internals; socket owner recovers itself", {
|
|
460
|
+
err,
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
360
464
|
await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
|
|
361
465
|
})
|
|
362
466
|
.on("unhandledRejection", async reason => {
|
|
@@ -416,57 +520,92 @@ if (isMainThread) {
|
|
|
416
520
|
});
|
|
417
521
|
}
|
|
418
522
|
|
|
523
|
+
/** Controls when a registered cleanup callback participates in cleanup passes. */
|
|
524
|
+
export interface CleanupRegistrationOptions {
|
|
525
|
+
/**
|
|
526
|
+
* Run only on a real exit, never during a manual keep-alive cleanup.
|
|
527
|
+
* The registration remains armed when a keep-alive pass skips it.
|
|
528
|
+
*/
|
|
529
|
+
exitOnly?: boolean;
|
|
530
|
+
}
|
|
531
|
+
|
|
419
532
|
/**
|
|
420
|
-
*
|
|
533
|
+
* Registers a cleanup callback for shutdown, signals, fatal errors, and
|
|
534
|
+
* repeatable manual cleanup passes.
|
|
535
|
+
*
|
|
536
|
+
* Registrations persist across keep-alive {@link cleanup} passes and run at
|
|
537
|
+
* most once per pass. Set `exitOnly` for resources the continuing process still
|
|
538
|
+
* holds (open databases, cached handles): keep-alive passes skip the callback
|
|
539
|
+
* without consuming its registration, while the eventual real exit runs it.
|
|
540
|
+
*
|
|
541
|
+
* A callback registered during a running keep-alive pass joins future passes;
|
|
542
|
+
* normal callbacks also run immediately for the current pass. Registrations
|
|
543
|
+
* made during a real exit run immediately.
|
|
421
544
|
*
|
|
422
|
-
* Returns a
|
|
423
|
-
* If register is called after cleanup already began, invokes callback on a microtask.
|
|
545
|
+
* Returns a function that permanently cancels the registration.
|
|
424
546
|
*/
|
|
425
|
-
export function register(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
547
|
+
export function register(
|
|
548
|
+
id: string,
|
|
549
|
+
callback: (reason: Reason) => void | Promise<void>,
|
|
550
|
+
options: CleanupRegistrationOptions = {},
|
|
551
|
+
): () => void {
|
|
552
|
+
const registration: CleanupRegistration = {
|
|
553
|
+
id,
|
|
554
|
+
callback,
|
|
555
|
+
exitOnly: options.exitOnly ?? false,
|
|
556
|
+
cancelled: false,
|
|
557
|
+
lastPass: 0,
|
|
558
|
+
};
|
|
559
|
+
const cancel = (): void => {
|
|
560
|
+
registration.cancelled = true;
|
|
561
|
+
const index = callbackList.indexOf(registration);
|
|
562
|
+
if (index >= 0) callbackList.splice(index, 1);
|
|
563
|
+
};
|
|
564
|
+
const invokeLate = (reason: Reason, keepAlive: boolean): void => {
|
|
430
565
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
566
|
+
const pending = invokeCleanup(registration, reason, keepAlive, cleanupPass);
|
|
567
|
+
if (!pending) return;
|
|
568
|
+
const tracked = pending.catch(error => {
|
|
569
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
570
|
+
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
571
|
+
});
|
|
572
|
+
// Join the active pass so cleanup()/signal exits await it; after a
|
|
573
|
+
// completed exit pass there is nothing left to join.
|
|
574
|
+
activeLatePromises?.push(tracked);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
434
577
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
435
578
|
}
|
|
436
579
|
};
|
|
437
580
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
done = true;
|
|
444
|
-
};
|
|
581
|
+
if (cleanupStage === "idle") {
|
|
582
|
+
callbackList.push(registration);
|
|
583
|
+
return cancel;
|
|
584
|
+
}
|
|
445
585
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
454
|
-
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
455
|
-
}
|
|
456
|
-
return () => {};
|
|
586
|
+
const reason = activeCleanupReason ?? Reason.MANUAL;
|
|
587
|
+
if (cleanupStage === "running" && activeCleanupKeepAlive) {
|
|
588
|
+
// The current pass already snapshotted its callbacks. Keep the new owner
|
|
589
|
+
// registered for future passes; normal callbacks also join this pass now.
|
|
590
|
+
callbackList.push(registration);
|
|
591
|
+
if (!registration.exitOnly) invokeLate(reason, true);
|
|
592
|
+
return cancel;
|
|
457
593
|
}
|
|
458
594
|
|
|
459
|
-
//
|
|
460
|
-
|
|
595
|
+
// A real exit is running or complete. There is no later pass to arm for, so
|
|
596
|
+
// invoke every late registration now, including exit-only callbacks.
|
|
597
|
+
logger.debug("Cleanup already started; running late callback once", { id });
|
|
598
|
+
invokeLate(reason, false);
|
|
461
599
|
return cancel;
|
|
462
600
|
}
|
|
463
601
|
|
|
464
602
|
/**
|
|
465
|
-
* Runs all cleanup callbacks without exiting
|
|
603
|
+
* Runs all cleanup callbacks without exiting, then re-arms the system so
|
|
604
|
+
* resources opened afterwards are still cleaned at the eventual real exit.
|
|
466
605
|
* Use this in workers or when you need to clean up but continue execution.
|
|
467
606
|
*/
|
|
468
607
|
export function cleanup(): Promise<void> {
|
|
469
|
-
return runCleanup(Reason.MANUAL);
|
|
608
|
+
return runCleanup(Reason.MANUAL, true);
|
|
470
609
|
}
|
|
471
610
|
|
|
472
611
|
/** Controls how manual process shutdown handles terminal output. */
|
|
@@ -475,6 +614,18 @@ export interface QuitOptions {
|
|
|
475
614
|
drainStdout?: boolean;
|
|
476
615
|
}
|
|
477
616
|
|
|
617
|
+
/**
|
|
618
|
+
* Waits (bounded) for buffered stdout to reach the terminal. Used before
|
|
619
|
+
* process exit and before an exec-replace, where unflushed output would be
|
|
620
|
+
* lost with the process image.
|
|
621
|
+
*/
|
|
622
|
+
export async function drainStdout(): Promise<void> {
|
|
623
|
+
if (process.stdout.writableLength === 0) return;
|
|
624
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
625
|
+
process.stdout.once("drain", resolve);
|
|
626
|
+
await Promise.race([promise, Bun.sleep(5000)]);
|
|
627
|
+
}
|
|
628
|
+
|
|
478
629
|
async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
|
|
479
630
|
await runCleanup(Reason.MANUAL);
|
|
480
631
|
|
|
@@ -482,10 +633,8 @@ async function runQuit(code: number, exitMode: "guarded" | "native", options: Qu
|
|
|
482
633
|
return; // Workers: cleanup done, let worker exit naturally
|
|
483
634
|
}
|
|
484
635
|
|
|
485
|
-
if (options.drainStdout !== false
|
|
486
|
-
|
|
487
|
-
process.stdout.once("drain", resolve);
|
|
488
|
-
await Promise.race([promise, Bun.sleep(5000)]);
|
|
636
|
+
if (options.drainStdout !== false) {
|
|
637
|
+
await drainStdout();
|
|
489
638
|
}
|
|
490
639
|
|
|
491
640
|
switch (exitMode) {
|