@linxiraos/pi-utils 1.1.20 → 1.1.22
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 -6
- package/README.md +1 -1
- package/THIRD-PARTY-NOTICES.txt +3 -40
- package/dist/types/dirs.d.ts +26 -4
- package/dist/types/file-lock.d.ts +2 -6
- package/dist/types/fs-open.d.ts +16 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/logger/rotating-file.d.ts +9 -0
- package/dist/types/mermaid-ascii.d.ts +12 -4
- package/dist/types/postmortem.d.ts +23 -0
- package/dist/types/procmgr.d.ts +3 -0
- package/dist/types/stderr-guard.d.ts +23 -4
- package/dist/types/stream.d.ts +11 -4
- package/package.json +2 -2
- package/src/cli.ts +1 -1
- package/src/dirs.ts +54 -12
- package/src/file-lock.ts +7 -9
- package/src/fs-open.ts +31 -0
- package/src/index.ts +40 -0
- package/src/logger/rotating-file.ts +22 -9
- package/src/logger.ts +10 -9
- package/src/mermaid-ascii.ts +13 -7
- package/src/module-timer.ts +7 -4
- package/src/postmortem.ts +57 -0
- package/src/procmgr.ts +18 -18
- package/src/ptree.ts +3 -3
- package/src/runtime-install.ts +43 -7
- package/src/sqlite.ts +29 -2
- package/src/stderr-guard.ts +53 -21
- package/src/stream.ts +26 -8
- package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +0 -41
- package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +0 -89
- package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +0 -12
- package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +0 -66
- package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +0 -48
- package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +0 -43
- package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +0 -56
- package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +0 -65
- package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +0 -27
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +0 -17
- package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +0 -34
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +0 -26
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +0 -31
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +0 -59
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +0 -17
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +0 -30
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +0 -55
- package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +0 -206
- package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +0 -51
- package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +0 -2
- package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/class/types.d.ts +0 -102
- package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/er/types.d.ts +0 -76
- package/dist/types/vendor/mermaid-ascii/index.d.ts +0 -1
- package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +0 -9
- package/dist/types/vendor/mermaid-ascii/parser.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +0 -130
- package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +0 -23
- package/dist/types/vendor/mermaid-ascii/types.d.ts +0 -114
- package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +0 -25
- package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +0 -145
- package/src/vendor/mermaid-ascii/NOTICE +0 -33
- package/src/vendor/mermaid-ascii/ascii/ansi.ts +0 -409
- package/src/vendor/mermaid-ascii/ascii/canvas.ts +0 -478
- package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +0 -699
- package/src/vendor/mermaid-ascii/ascii/converter.ts +0 -271
- package/src/vendor/mermaid-ascii/ascii/draw.ts +0 -1389
- package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +0 -328
- package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +0 -297
- package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +0 -441
- package/src/vendor/mermaid-ascii/ascii/grid.ts +0 -593
- package/src/vendor/mermaid-ascii/ascii/index.ts +0 -187
- package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +0 -78
- package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +0 -277
- package/src/vendor/mermaid-ascii/ascii/sequence.ts +0 -460
- package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +0 -27
- package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +0 -127
- package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +0 -27
- package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +0 -27
- package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +0 -101
- package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +0 -175
- package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +0 -27
- package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +0 -296
- package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +0 -114
- package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +0 -192
- package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +0 -73
- package/src/vendor/mermaid-ascii/ascii/types.ts +0 -273
- package/src/vendor/mermaid-ascii/ascii/validate.ts +0 -120
- package/src/vendor/mermaid-ascii/ascii/xychart.ts +0 -875
- package/src/vendor/mermaid-ascii/class/parser.ts +0 -290
- package/src/vendor/mermaid-ascii/class/types.ts +0 -121
- package/src/vendor/mermaid-ascii/er/parser.ts +0 -181
- package/src/vendor/mermaid-ascii/er/types.ts +0 -91
- package/src/vendor/mermaid-ascii/index.ts +0 -14
- package/src/vendor/mermaid-ascii/multiline-utils.ts +0 -30
- package/src/vendor/mermaid-ascii/parser.ts +0 -645
- package/src/vendor/mermaid-ascii/sequence/parser.ts +0 -207
- package/src/vendor/mermaid-ascii/sequence/types.ts +0 -146
- package/src/vendor/mermaid-ascii/text-metrics.ts +0 -74
- package/src/vendor/mermaid-ascii/types.ts +0 -164
- package/src/vendor/mermaid-ascii/xychart/colors.ts +0 -140
- package/src/vendor/mermaid-ascii/xychart/parser.ts +0 -115
- package/src/vendor/mermaid-ascii/xychart/types.ts +0 -150
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./file-lock";
|
|
|
10
10
|
export * from "./format";
|
|
11
11
|
export * from "./frontmatter";
|
|
12
12
|
export * from "./fs-error";
|
|
13
|
+
export * from "./fs-open";
|
|
13
14
|
export * from "./incoming-json";
|
|
14
15
|
export * from "./json";
|
|
15
16
|
export * from "./json-parse";
|
|
@@ -62,3 +63,42 @@ export function structuredCloneJSON<T>(value: T): T {
|
|
|
62
63
|
}
|
|
63
64
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Deep-copies a JSON-shaped tree (arrays and plain or null-prototype objects)
|
|
69
|
+
* so later mutation of the source can never reach the copy. Strings and other
|
|
70
|
+
* primitives are immutable and shared, so the cost is O(containers) instead of
|
|
71
|
+
* `structuredClone`'s O(bytes) — the difference matters for hot paths that
|
|
72
|
+
* re-copy large string payloads (e.g. streamed tool-call arguments) per delta.
|
|
73
|
+
*
|
|
74
|
+
* Copies own enumerable string keys only. Any other object (class instance,
|
|
75
|
+
* Date, Map, inherited-prototype object) is copied with {@link structuredCloneJSON}.
|
|
76
|
+
* Not cycle-safe: callers MUST pass acyclic data, as any JSON-serializable value is.
|
|
77
|
+
*/
|
|
78
|
+
export function cloneJsonTree<T>(value: T): T {
|
|
79
|
+
return cloneJsonNode(value) as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isJsonRecord(value: object): value is Record<string, unknown> {
|
|
83
|
+
const proto = Object.getPrototypeOf(value);
|
|
84
|
+
return proto === Object.prototype || proto === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cloneJsonNode(value: unknown): unknown {
|
|
88
|
+
if (value === null || typeof value !== "object") return value;
|
|
89
|
+
if (Array.isArray(value)) return value.map(cloneJsonNode);
|
|
90
|
+
if (!isJsonRecord(value)) return structuredCloneJSON(value);
|
|
91
|
+
const out: Record<string, unknown> = {};
|
|
92
|
+
for (const key in value) {
|
|
93
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
94
|
+
const child = cloneJsonNode(value[key]);
|
|
95
|
+
// Assigning `__proto__` would swap the copy's prototype instead of
|
|
96
|
+
// creating the own data property JSON.parse produces.
|
|
97
|
+
if (key === "__proto__") {
|
|
98
|
+
Object.defineProperty(out, key, { value: child, enumerable: true, writable: true, configurable: true });
|
|
99
|
+
} else {
|
|
100
|
+
out[key] = child;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** Behavior-compatible reimplementation of winston-daily-rotate-file's used surface. */
|
|
2
|
-
import * as crypto from "node:crypto";
|
|
3
2
|
import * as fs from "node:fs";
|
|
4
3
|
import * as os from "node:os";
|
|
5
4
|
import * as path from "node:path";
|
|
5
|
+
import { localDay } from "../dirs";
|
|
6
|
+
import { openCloexecSync } from "../fs-open";
|
|
6
7
|
|
|
7
8
|
interface AuditEntry {
|
|
8
9
|
readonly date: number;
|
|
@@ -25,6 +26,15 @@ export interface RotatingFileOptions {
|
|
|
25
26
|
readonly auditFile: string;
|
|
26
27
|
readonly maxBytes: number;
|
|
27
28
|
readonly maxFiles: number;
|
|
29
|
+
/**
|
|
30
|
+
* Called with the new active file path whenever the sink opens a different
|
|
31
|
+
* file — construction, local-day rotation, and size rotation alike.
|
|
32
|
+
*
|
|
33
|
+
* Consumers that hold their own descriptor on the active log (the macOS
|
|
34
|
+
* stderr guard dup2s it onto fd 2) use this to follow the sink instead of
|
|
35
|
+
* staying pinned to a file the sink later prunes.
|
|
36
|
+
*/
|
|
37
|
+
readonly onRotate?: (filePath: string) => void;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
function isAuditEntry(value: unknown): value is AuditEntry {
|
|
@@ -41,6 +51,7 @@ export class RotatingFileSink {
|
|
|
41
51
|
readonly #auditFile: string;
|
|
42
52
|
readonly #maxBytes: number;
|
|
43
53
|
readonly #maxFiles: number;
|
|
54
|
+
readonly #onRotate: ((filePath: string) => void) | undefined;
|
|
44
55
|
#files: AuditEntry[];
|
|
45
56
|
#activeDay: string | undefined;
|
|
46
57
|
#activeIndex = 0;
|
|
@@ -61,9 +72,10 @@ export class RotatingFileSink {
|
|
|
61
72
|
this.#auditFile = options.auditFile;
|
|
62
73
|
this.#maxBytes = options.maxBytes;
|
|
63
74
|
this.#maxFiles = options.maxFiles;
|
|
75
|
+
this.#onRotate = options.onRotate;
|
|
64
76
|
this.#files = this.#readAudit();
|
|
65
77
|
const now = new Date();
|
|
66
|
-
this.#selectFile(
|
|
78
|
+
this.#selectFile(localDay(now));
|
|
67
79
|
const activePath = this.#activePath;
|
|
68
80
|
if (activePath) {
|
|
69
81
|
this.#registerFile(activePath, now.getTime());
|
|
@@ -73,7 +85,12 @@ export class RotatingFileSink {
|
|
|
73
85
|
|
|
74
86
|
#openFd(filePath: string): void {
|
|
75
87
|
this.#closeFd();
|
|
76
|
-
this.#fd =
|
|
88
|
+
this.#fd = openCloexecSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
|
|
89
|
+
try {
|
|
90
|
+
this.#onRotate?.(filePath);
|
|
91
|
+
} catch {
|
|
92
|
+
// A rotation observer must never break logging.
|
|
93
|
+
}
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
#closeFd(): void {
|
|
@@ -92,7 +109,7 @@ export class RotatingFileSink {
|
|
|
92
109
|
if (this.#closed) return;
|
|
93
110
|
const prevPath = this.#activePath;
|
|
94
111
|
const now = new Date();
|
|
95
|
-
this.#selectFile(
|
|
112
|
+
this.#selectFile(localDay(now));
|
|
96
113
|
const activePath = this.#activePath;
|
|
97
114
|
if (!activePath) return;
|
|
98
115
|
// Rotation moved the active path: close the old descriptor BEFORE
|
|
@@ -120,10 +137,6 @@ export class RotatingFileSink {
|
|
|
120
137
|
this.#closeFd();
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
#localDay(date: Date): string {
|
|
124
|
-
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
140
|
#selectFile(day: string): void {
|
|
128
141
|
if (day !== this.#activeDay) {
|
|
129
142
|
this.#activeDay = day;
|
|
@@ -151,7 +164,7 @@ export class RotatingFileSink {
|
|
|
151
164
|
|
|
152
165
|
#registerFile(filePath: string, date: number): void {
|
|
153
166
|
if (this.#files.some(file => file.name === filePath)) return;
|
|
154
|
-
const hash =
|
|
167
|
+
const hash = Bun.SHA256.hash(`${filePath}LOG_FILE${date}`, "hex");
|
|
155
168
|
this.#files.push({ date, name: filePath, hash });
|
|
156
169
|
while (this.#files.length > this.#maxFiles) {
|
|
157
170
|
const removed = this.#files.shift();
|
package/src/logger.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (the auth broker, etc.) call {@link setTransports} to swap in a console
|
|
7
7
|
* transport so a process supervisor (pm2, journald, k8s) captures the logs.
|
|
8
8
|
*
|
|
9
|
-
* Each entry includes `process.pid` so concurrent
|
|
9
|
+
* Each entry includes `process.pid` so concurrent zeta instances stay
|
|
10
10
|
* traceable.
|
|
11
11
|
*/
|
|
12
12
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -14,8 +14,9 @@ import * as fs from "node:fs";
|
|
|
14
14
|
import * as os from "node:os";
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { isPromise } from "node:util/types";
|
|
17
|
-
import { getLogsDir } from "./dirs";
|
|
17
|
+
import { getLogsDir, localDay } from "./dirs";
|
|
18
18
|
import { RotatingFileSink } from "./logger/rotating-file";
|
|
19
|
+
import { setStderrRedirectTarget } from "./stderr-guard";
|
|
19
20
|
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
20
21
|
/** Severity names accepted by the centralized logger. */
|
|
21
22
|
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
@@ -82,14 +83,10 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
82
83
|
return;
|
|
83
84
|
}
|
|
84
85
|
const current = new Date();
|
|
85
|
-
const currentDate =
|
|
86
|
-
`${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-` +
|
|
87
|
-
String(current.getDate()).padStart(2, "0");
|
|
86
|
+
const currentDate = localDay(current);
|
|
88
87
|
const cutoff = new Date(current);
|
|
89
88
|
cutoff.setDate(cutoff.getDate() - (RETAINED_STALE_LOG_DAYS - 1));
|
|
90
|
-
const cutoffDate =
|
|
91
|
-
`${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-` +
|
|
92
|
-
String(cutoff.getDate()).padStart(2, "0");
|
|
89
|
+
const cutoffDate = localDay(cutoff);
|
|
93
90
|
|
|
94
91
|
const staleLogsByProcessDay = new Map<string, Array<{ path: string; rollover: number }>>();
|
|
95
92
|
for (const entry of entries) {
|
|
@@ -258,6 +255,8 @@ function makeFileTransport(dir?: string): RotatingFileSink {
|
|
|
258
255
|
maxBytes: 10 * 1024 * 1024,
|
|
259
256
|
maxFiles: 5,
|
|
260
257
|
auditFile: path.join(logsDir, `.zeta.${process.pid}-audit.json`),
|
|
258
|
+
// Keep the stderr guard's fd 2 on the file this sink is writing.
|
|
259
|
+
onRotate: setStderrRedirectTarget,
|
|
261
260
|
});
|
|
262
261
|
}
|
|
263
262
|
|
|
@@ -644,7 +643,9 @@ function printSpan(span: Span, depth: number, lines: string[]): void {
|
|
|
644
643
|
const tag = parallel ? " [parallel]" : "";
|
|
645
644
|
const self = selfTimeOf(span);
|
|
646
645
|
const selfStr = span.children.length > 0 && self > LOGGED_TIMING_THRESHOLD_MS ? ` (self ${fmtMs(self)})` : "";
|
|
647
|
-
|
|
646
|
+
// Start offset from process origin: gaps between consecutive siblings are
|
|
647
|
+
// the parent's own (unspanned) work, which duration alone cannot locate.
|
|
648
|
+
lines.push(`${indent}${span.op}: ${fmtMs(dur)}${selfStr}${tag} @${span.start.toFixed(0)}ms`);
|
|
648
649
|
|
|
649
650
|
// Split children into work spans and module-load spans for summarization.
|
|
650
651
|
const work: Span[] = [];
|
package/src/mermaid-ascii.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type MermaidRenderOptions, renderMermaidAscii } from "@linxiraos/pi-natives";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Native Mermaid → ASCII/Unicode renderer (flowchart, state, sequence, class,
|
|
5
|
+
* ER, xychart). Synchronous because callers render inside the compositor;
|
|
6
|
+
* throws on empty input, an unknown flowchart header, or an invalid
|
|
7
|
+
* `direction`/`colorMode` value.
|
|
8
|
+
*/
|
|
9
|
+
export { renderMermaidAscii };
|
|
4
10
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
11
|
+
/** Options for {@link renderMermaidAscii}; every field optional. */
|
|
12
|
+
export type MermaidAsciiRenderOptions = MermaidRenderOptions;
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
/** {@link renderMermaidAscii}, returning `null` instead of throwing. */
|
|
15
|
+
export function renderMermaidAsciiSafe(source: string, options?: MermaidAsciiRenderOptions): string | null {
|
|
10
16
|
try {
|
|
11
|
-
return
|
|
17
|
+
return renderMermaidAscii(source, options);
|
|
12
18
|
} catch {
|
|
13
19
|
return null;
|
|
14
20
|
}
|
package/src/module-timer.ts
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* - **Dev runs only.** In the compiled `omp` binary every module is pre-bundled
|
|
41
41
|
* into bunfs, so `onLoad` never fires; profile with a `bun --preload` dev run.
|
|
42
42
|
*/
|
|
43
|
+
import { readFileSync } from "node:fs";
|
|
43
44
|
import { plugin } from "bun";
|
|
44
45
|
import { moduleLoadBuffer } from "./timing-buffer";
|
|
45
46
|
|
|
@@ -48,8 +49,8 @@ import { moduleLoadBuffer } from "./timing-buffer";
|
|
|
48
49
|
// `{ contents, loader: "js" }`, Bun forces ESM and CJS modules fail to load
|
|
49
50
|
// (e.g. `Missing 'default' export`). Our own source tree (where the interesting
|
|
50
51
|
// timing lives) is uniformly TypeScript, so a TS-only filter is both safe and
|
|
51
|
-
// sufficient.
|
|
52
|
-
const MODULE_LOADER_FILTER =
|
|
52
|
+
// sufficient. Declaration files are imported as text assets, never evaluated.
|
|
53
|
+
const MODULE_LOADER_FILTER = /^(?!.*\.d\.[mc]?ts$).*\.[mc]?tsx?$/;
|
|
53
54
|
const MODULE_COMPLETE_KEY: symbol = Symbol.for("omp.moduleLoadComplete");
|
|
54
55
|
const MODULE_BODY_START_KEY: symbol = Symbol.for("omp.moduleBodyStart");
|
|
55
56
|
const STATIC_IMPORT_PATTERN =
|
|
@@ -133,10 +134,12 @@ if (process.env.PI_TIMING) {
|
|
|
133
134
|
plugin({
|
|
134
135
|
name: "pi-module-load-timer",
|
|
135
136
|
setup(build) {
|
|
136
|
-
|
|
137
|
+
// Synchronous on purpose: a module served by an async onLoad cannot be
|
|
138
|
+
// `require()`d (cli.ts requires postmortem through Bun's CJS bridge).
|
|
139
|
+
build.onLoad({ filter: MODULE_LOADER_FILTER }, args => {
|
|
137
140
|
starts.set(args.path, performance.now());
|
|
138
141
|
childSetFor(importsByPath, args.path);
|
|
139
|
-
const contents =
|
|
142
|
+
const contents = readFileSync(args.path, "utf8");
|
|
140
143
|
addImportEdges(importsByPath, args.path, contents);
|
|
141
144
|
return {
|
|
142
145
|
contents: instrumentContents(args.path, contents),
|
package/src/postmortem.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
+
import { APP_NAME, CLI_BIN_NAME } from "./dirs";
|
|
10
11
|
import * as logger from "./logger";
|
|
11
12
|
import { restoreTerminalStderr } from "./stderr-guard";
|
|
12
13
|
|
|
@@ -368,6 +369,62 @@ export function registerStdioDisconnectHandling(): () => void {
|
|
|
368
369
|
};
|
|
369
370
|
}
|
|
370
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Fail a process entry whose event loop drains while `work` is still pending.
|
|
374
|
+
*
|
|
375
|
+
* The CLI starts from a floating `runCli()` call because top-level await breaks
|
|
376
|
+
* `--bytecode` builds. When a one-shot command awaits a promise that never
|
|
377
|
+
* settles and holds no live handle, the loop drains and Bun exits 0 without
|
|
378
|
+
* output: an unfinished command reported as success. `beforeExit` fires in
|
|
379
|
+
* that state; explicit exits (`process.exit`, {@link quit},
|
|
380
|
+
* {@link exitProcess}, the signal handlers) never emit it.
|
|
381
|
+
*
|
|
382
|
+
* `beforeExit` alone is not the verdict: on Windows Bun emits it while I/O is
|
|
383
|
+
* still in flight and then keeps running the loop, so a command that goes on
|
|
384
|
+
* to complete must keep its own exit code (#13470). The verdict is taken at
|
|
385
|
+
* `exit` instead, and only when the last `beforeExit` was final: an unref'd
|
|
386
|
+
* timer armed there runs only if the loop turns again, clearing the mark
|
|
387
|
+
* before a later explicit exit. Any later drain emits `beforeExit` again and
|
|
388
|
+
* re-arms it, so other `beforeExit` listeners (LSP shutdown) still run before
|
|
389
|
+
* the process exits 1.
|
|
390
|
+
*
|
|
391
|
+
* `describe` names what was running (e.g. the resolved subcommand) at report
|
|
392
|
+
* time; it must return only a command name, never user arguments.
|
|
393
|
+
*/
|
|
394
|
+
export function reportUnsettledEntry(work: Promise<unknown>, describe?: () => string | undefined): void {
|
|
395
|
+
let pending = true;
|
|
396
|
+
let drained = false;
|
|
397
|
+
const settled = (): void => {
|
|
398
|
+
pending = false;
|
|
399
|
+
};
|
|
400
|
+
// Observe both outcomes without claiming the rejection: the caller's `.catch` still owns it.
|
|
401
|
+
void work.then(settled, settled);
|
|
402
|
+
process.on("beforeExit", () => {
|
|
403
|
+
if (!pending) return;
|
|
404
|
+
drained = true;
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
drained = false;
|
|
407
|
+
}, 0).unref();
|
|
408
|
+
});
|
|
409
|
+
process.once("exit", code => {
|
|
410
|
+
if (!pending || !drained) return;
|
|
411
|
+
// A non-zero code is already a failure verdict — an explicit
|
|
412
|
+
// `process.exit(n)` (possibly on the same loop turn as a Windows
|
|
413
|
+
// mid-I/O `beforeExit`, before the clearing timer runs) or an
|
|
414
|
+
// `exitCode` the command set. Overwriting it with 1 and a second
|
|
415
|
+
// "ended before completing" line only obscures the real error.
|
|
416
|
+
if (code !== 0) return;
|
|
417
|
+
const command = describe?.();
|
|
418
|
+
const subject = command ? `\`${CLI_BIN_NAME} ${command}\`` : "command";
|
|
419
|
+
const message = `${subject} ended before completing: the event loop drained while it was still pending (rerun with PI_DEBUG_STARTUP=1 to see the last phase reached)`;
|
|
420
|
+
try {
|
|
421
|
+
fs.writeSync(2, `${CLI_BIN_NAME}: ${message}\n`);
|
|
422
|
+
} catch {}
|
|
423
|
+
logger.error(message, { command });
|
|
424
|
+
process.exitCode = 1;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
371
428
|
// Well-known key marking an error as an *expected* teardown artifact (e.g. a
|
|
372
429
|
// browser run-scope abort at normal run end). `Symbol.for` so the marker
|
|
373
430
|
// survives duplicate module instances across bundles/realms.
|
package/src/procmgr.ts
CHANGED
|
@@ -20,7 +20,10 @@ export interface ShellConfigOptions {
|
|
|
20
20
|
/** File path or runtime layer that supplied the active shell setting. */
|
|
21
21
|
configSource?: string;
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
/** Auto-resolved shell (no custom path); stable for the process lifetime. */
|
|
24
|
+
let cachedDefaultShellConfig: ShellConfig | null = null;
|
|
25
|
+
/** Config for the most recent custom `shellPath`; never served once the path is cleared. */
|
|
26
|
+
let cachedCustomShellConfig: ShellConfig | null = null;
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Build the spawn environment (cached).
|
|
@@ -177,6 +180,9 @@ export function resolveWindowsShell(env: Record<string, string | undefined> = Bu
|
|
|
177
180
|
* {@link resolveWindowsShell}) — never fails
|
|
178
181
|
* 3. On Unix: $SHELL if bash/zsh, then fallback paths
|
|
179
182
|
* 4. Fallback: sh
|
|
183
|
+
*
|
|
184
|
+
* Custom and auto-resolved configs are cached separately, so clearing
|
|
185
|
+
* `shellPath` mid-process immediately falls back to the platform default.
|
|
180
186
|
*/
|
|
181
187
|
export function getShellConfig(customShellPath?: string, options: ShellConfigOptions = {}): ShellConfig {
|
|
182
188
|
const configSource = options.configSource ?? path.join(getAgentDir(), MAIN_CONFIG_FILENAMES[0]);
|
|
@@ -187,36 +193,30 @@ export function getShellConfig(customShellPath?: string, options: ShellConfigOpt
|
|
|
187
193
|
if (!fs.existsSync(customShellPath)) {
|
|
188
194
|
throw new Error(`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ${configSource}`);
|
|
189
195
|
}
|
|
190
|
-
if (
|
|
191
|
-
|
|
196
|
+
if (cachedCustomShellConfig?.shell !== customShellPath) {
|
|
197
|
+
cachedCustomShellConfig = buildConfig(customShellPath);
|
|
192
198
|
}
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
|
-
if (cachedShellConfig) {
|
|
196
|
-
return cachedShellConfig;
|
|
199
|
+
return cachedCustomShellConfig;
|
|
197
200
|
}
|
|
201
|
+
cachedDefaultShellConfig ??= buildConfig(resolveDefaultShell());
|
|
202
|
+
return cachedDefaultShellConfig;
|
|
203
|
+
}
|
|
198
204
|
|
|
205
|
+
/** Platform shell discovery used when no custom `shellPath` is configured. */
|
|
206
|
+
function resolveDefaultShell(): string {
|
|
199
207
|
if (process.platform === "win32") {
|
|
200
|
-
|
|
201
|
-
return cachedShellConfig;
|
|
208
|
+
return resolveWindowsShell();
|
|
202
209
|
}
|
|
203
210
|
|
|
204
211
|
// Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
|
|
205
212
|
const userShell = Bun.env.SHELL;
|
|
206
213
|
const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
|
|
207
214
|
if (isValidShell && isExecutable(userShell)) {
|
|
208
|
-
|
|
209
|
-
return cachedShellConfig;
|
|
215
|
+
return userShell;
|
|
210
216
|
}
|
|
211
217
|
|
|
212
218
|
// 4. Fallback: use basic shell
|
|
213
|
-
|
|
214
|
-
if (basicShell) {
|
|
215
|
-
cachedShellConfig = buildConfig(basicShell);
|
|
216
|
-
return cachedShellConfig;
|
|
217
|
-
}
|
|
218
|
-
cachedShellConfig = buildConfig("sh");
|
|
219
|
-
return cachedShellConfig;
|
|
219
|
+
return resolveBasicShell() ?? "sh";
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|
package/src/ptree.ts
CHANGED
|
@@ -462,7 +462,7 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
462
462
|
return out + dec.decode();
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
async #readBytes(): Promise<Uint8Array
|
|
465
|
+
async #readBytes(): Promise<Uint8Array<ArrayBuffer>> {
|
|
466
466
|
const reader = this.proc.stdout.getReader();
|
|
467
467
|
this.#openPipeReaders++;
|
|
468
468
|
const chunks: Uint8Array[] = [];
|
|
@@ -497,7 +497,7 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
497
497
|
return bytes;
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
-
async #readOutputBytes(waitForCleanExit = false): Promise<Uint8Array
|
|
500
|
+
async #readOutputBytes(waitForCleanExit = false): Promise<Uint8Array<ArrayBuffer>> {
|
|
501
501
|
const p = this.#readBytes();
|
|
502
502
|
if (this.#nothrow) return p;
|
|
503
503
|
const bytes = waitForCleanExit ? (await Promise.all([p, this.exitedCleanly]))[0] : await p;
|
|
@@ -514,7 +514,7 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
517
|
-
return (await this.#readOutputBytes()).buffer
|
|
517
|
+
return (await this.#readOutputBytes()).buffer;
|
|
518
518
|
}
|
|
519
519
|
|
|
520
520
|
async bytes(): Promise<Uint8Array> {
|
package/src/runtime-install.ts
CHANGED
|
@@ -172,9 +172,43 @@ function resolverRegistry(): ResolverRegistration[] {
|
|
|
172
172
|
holder[REGISTRY] ??= [];
|
|
173
173
|
return holder[REGISTRY];
|
|
174
174
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
175
|
+
/** Canonical form of a path, memoized: resolution is per module request, and roots repeat. */
|
|
176
|
+
const canonicalPaths = new Map<string, string>();
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Canonical form of `target` for path comparison: symlinks and Windows
|
|
180
|
+
* junctions resolved, drive/segment casing folded on Windows. Non-existent
|
|
181
|
+
* paths fall back to `path.resolve`, which still normalizes separators.
|
|
182
|
+
*
|
|
183
|
+
* Comparisons must canonicalize because the two sides come from different
|
|
184
|
+
* worlds: the stock resolver hands back realpath-resolved filenames, while a
|
|
185
|
+
* registered runtime root is whatever the caller configured (a cache under a
|
|
186
|
+
* junctioned `F:\Programs\…`, a home directory symlinked onto another volume,
|
|
187
|
+
* macOS `/tmp` → `/private/tmp`).
|
|
188
|
+
*/
|
|
189
|
+
function canonicalPath(target: string): string {
|
|
190
|
+
const cached = canonicalPaths.get(target);
|
|
191
|
+
if (cached !== undefined) return cached;
|
|
192
|
+
let canonical: string;
|
|
193
|
+
try {
|
|
194
|
+
canonical = fs.realpathSync.native(target);
|
|
195
|
+
} catch {
|
|
196
|
+
canonical = path.resolve(target);
|
|
197
|
+
}
|
|
198
|
+
if (process.platform === "win32") canonical = canonical.toLowerCase();
|
|
199
|
+
canonicalPaths.set(target, canonical);
|
|
200
|
+
return canonical;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Path of `candidate` relative to `root` when `candidate` is inside (or is)
|
|
205
|
+
* `root`, else `null`. Both sides are canonicalized, so a runtime cache
|
|
206
|
+
* reached through a link still matches the filenames the resolver reports.
|
|
207
|
+
*/
|
|
208
|
+
function relativeWithin(root: string, candidate: string): string | null {
|
|
209
|
+
const relative = path.relative(canonicalPath(root), canonicalPath(candidate));
|
|
210
|
+
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return null;
|
|
211
|
+
return relative;
|
|
178
212
|
}
|
|
179
213
|
|
|
180
214
|
function parentFilename(parent: unknown): string | null {
|
|
@@ -243,11 +277,12 @@ export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }:
|
|
|
243
277
|
if (bare) {
|
|
244
278
|
const parentFile = parentFilename(parent);
|
|
245
279
|
for (const registration of resolverRegistry()) {
|
|
246
|
-
const parentInRuntime =
|
|
280
|
+
const parentInRuntime =
|
|
281
|
+
parentFile !== null && relativeWithin(registration.runtimeNodeModules, parentFile) !== null;
|
|
247
282
|
if (parentInRuntime) {
|
|
248
283
|
const stub = registration.stubs[request];
|
|
249
284
|
if (stub) return stub;
|
|
250
|
-
if (!stockResolved ||
|
|
285
|
+
if (!stockResolved || relativeWithin(registration.runtimeNodeModules, stockResolved) === null) {
|
|
251
286
|
const fallback = resolveRuntimeModule(registration.runtimeNodeModules, request);
|
|
252
287
|
if (fallback) return fallback;
|
|
253
288
|
}
|
|
@@ -260,8 +295,9 @@ export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }:
|
|
|
260
295
|
// it with the top-level instance would cross major versions.
|
|
261
296
|
const { packageName } = splitBareSpecifier(request);
|
|
262
297
|
const pkgDir = path.join(registration.runtimeNodeModules, ...packageName.split("/"));
|
|
263
|
-
|
|
264
|
-
if (
|
|
298
|
+
const relative = relativeWithin(pkgDir, stockResolved);
|
|
299
|
+
if (relative === null || relative === "") continue;
|
|
300
|
+
if (relative.split(path.sep).includes("node_modules")) continue;
|
|
265
301
|
const expected = resolveRuntimeModule(registration.runtimeNodeModules, request);
|
|
266
302
|
if (expected) return expected;
|
|
267
303
|
} else {
|
package/src/sqlite.ts
CHANGED
|
@@ -58,6 +58,31 @@ export interface SqliteOpenOptions {
|
|
|
58
58
|
onCorruptionPreserved?: (backupPath: string, error: unknown) => void;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Bun's multi-statement `db.run()` reports only the final statement's step
|
|
63
|
+
* error (oven-sh/bun#37415), so a corrupt page hit mid-script can resurface as
|
|
64
|
+
* an unrelated failure such as "no such table". When an initializer fails for
|
|
65
|
+
* any other reason, a `quick_check` on the still-open handle decides whether
|
|
66
|
+
* the store itself is damaged. Runs only on the failure path.
|
|
67
|
+
*/
|
|
68
|
+
function revealHiddenCorruption(db: Database | undefined, error: unknown): unknown {
|
|
69
|
+
if (!db || isSqliteCorruptionError(error) || isSqliteBusyError(error)) return error;
|
|
70
|
+
let detail: string;
|
|
71
|
+
let code: unknown = "SQLITE_CORRUPT";
|
|
72
|
+
let errno: unknown = 11;
|
|
73
|
+
try {
|
|
74
|
+
const rows = db.query<{ quick_check: string }, []>("PRAGMA quick_check(1)").all();
|
|
75
|
+
if (rows[0]?.quick_check === "ok") return error;
|
|
76
|
+
detail = `database disk image is malformed (${rows[0]?.quick_check})`;
|
|
77
|
+
} catch (probeError) {
|
|
78
|
+
if (!isSqliteCorruptionError(probeError)) return error;
|
|
79
|
+
detail = probeError instanceof Error ? probeError.message : String(probeError);
|
|
80
|
+
({ code, errno } = probeError as { code: unknown; errno?: unknown });
|
|
81
|
+
}
|
|
82
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return Object.assign(new Error(`${detail}; initialization failed: ${original}`, { cause: error }), { code, errno });
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
async function openWithBusyRetries<T>(
|
|
62
87
|
dbPath: string,
|
|
63
88
|
initialize: (db: Database) => T | Promise<T>,
|
|
@@ -71,7 +96,8 @@ async function openWithBusyRetries<T>(
|
|
|
71
96
|
// WAL recovery can bypass the busy handler; both it and retries are needed (#2421).
|
|
72
97
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
73
98
|
return await initialize(db);
|
|
74
|
-
} catch (
|
|
99
|
+
} catch (caught) {
|
|
100
|
+
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
75
101
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
76
102
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
77
103
|
}
|
|
@@ -91,7 +117,8 @@ function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: S
|
|
|
91
117
|
db = new Database(dbPath);
|
|
92
118
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
93
119
|
return initialize(db);
|
|
94
|
-
} catch (
|
|
120
|
+
} catch (caught) {
|
|
121
|
+
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
95
122
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
96
123
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
97
124
|
}
|