@linxiraos/pi-utils 1.1.19 → 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.
Files changed (113) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/README.md +1 -1
  3. package/THIRD-PARTY-NOTICES.txt +3 -40
  4. package/dist/types/dirs.d.ts +26 -4
  5. package/dist/types/file-lock.d.ts +2 -6
  6. package/dist/types/fs-open.d.ts +16 -0
  7. package/dist/types/index.d.ts +13 -0
  8. package/dist/types/logger/rotating-file.d.ts +9 -0
  9. package/dist/types/mermaid-ascii.d.ts +12 -4
  10. package/dist/types/postmortem.d.ts +23 -0
  11. package/dist/types/procmgr.d.ts +3 -0
  12. package/dist/types/stderr-guard.d.ts +23 -4
  13. package/dist/types/stream.d.ts +11 -4
  14. package/package.json +2 -2
  15. package/src/cli.ts +1 -1
  16. package/src/dirs.ts +54 -12
  17. package/src/file-lock.ts +7 -9
  18. package/src/fs-open.ts +31 -0
  19. package/src/index.ts +40 -0
  20. package/src/logger/rotating-file.ts +22 -9
  21. package/src/logger.ts +10 -9
  22. package/src/mermaid-ascii.ts +13 -7
  23. package/src/module-timer.ts +7 -4
  24. package/src/postmortem.ts +57 -0
  25. package/src/procmgr.ts +18 -18
  26. package/src/ptree.ts +3 -3
  27. package/src/runtime-install.ts +43 -7
  28. package/src/sqlite.ts +29 -2
  29. package/src/stderr-guard.ts +53 -21
  30. package/src/stream.ts +26 -8
  31. package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +0 -41
  32. package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +0 -89
  33. package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +0 -7
  34. package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +0 -12
  35. package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +0 -66
  36. package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +0 -48
  37. package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +0 -43
  38. package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +0 -7
  39. package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +0 -56
  40. package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +0 -65
  41. package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +0 -27
  42. package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +0 -17
  43. package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +0 -7
  44. package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +0 -11
  45. package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +0 -34
  46. package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +0 -11
  47. package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +0 -11
  48. package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +0 -26
  49. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +0 -31
  50. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +0 -11
  51. package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +0 -59
  52. package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +0 -17
  53. package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +0 -30
  54. package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +0 -55
  55. package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +0 -206
  56. package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +0 -51
  57. package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +0 -2
  58. package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +0 -6
  59. package/dist/types/vendor/mermaid-ascii/class/types.d.ts +0 -102
  60. package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +0 -6
  61. package/dist/types/vendor/mermaid-ascii/er/types.d.ts +0 -76
  62. package/dist/types/vendor/mermaid-ascii/index.d.ts +0 -1
  63. package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +0 -9
  64. package/dist/types/vendor/mermaid-ascii/parser.d.ts +0 -7
  65. package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +0 -6
  66. package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +0 -130
  67. package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +0 -23
  68. package/dist/types/vendor/mermaid-ascii/types.d.ts +0 -114
  69. package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +0 -25
  70. package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +0 -6
  71. package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +0 -145
  72. package/src/vendor/mermaid-ascii/NOTICE +0 -33
  73. package/src/vendor/mermaid-ascii/ascii/ansi.ts +0 -409
  74. package/src/vendor/mermaid-ascii/ascii/canvas.ts +0 -478
  75. package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +0 -699
  76. package/src/vendor/mermaid-ascii/ascii/converter.ts +0 -271
  77. package/src/vendor/mermaid-ascii/ascii/draw.ts +0 -1389
  78. package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +0 -328
  79. package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +0 -297
  80. package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +0 -441
  81. package/src/vendor/mermaid-ascii/ascii/grid.ts +0 -593
  82. package/src/vendor/mermaid-ascii/ascii/index.ts +0 -187
  83. package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +0 -78
  84. package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +0 -277
  85. package/src/vendor/mermaid-ascii/ascii/sequence.ts +0 -460
  86. package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +0 -27
  87. package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +0 -127
  88. package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +0 -27
  89. package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +0 -27
  90. package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +0 -101
  91. package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +0 -175
  92. package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +0 -27
  93. package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +0 -296
  94. package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +0 -114
  95. package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +0 -192
  96. package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +0 -73
  97. package/src/vendor/mermaid-ascii/ascii/types.ts +0 -273
  98. package/src/vendor/mermaid-ascii/ascii/validate.ts +0 -120
  99. package/src/vendor/mermaid-ascii/ascii/xychart.ts +0 -875
  100. package/src/vendor/mermaid-ascii/class/parser.ts +0 -290
  101. package/src/vendor/mermaid-ascii/class/types.ts +0 -121
  102. package/src/vendor/mermaid-ascii/er/parser.ts +0 -181
  103. package/src/vendor/mermaid-ascii/er/types.ts +0 -91
  104. package/src/vendor/mermaid-ascii/index.ts +0 -14
  105. package/src/vendor/mermaid-ascii/multiline-utils.ts +0 -30
  106. package/src/vendor/mermaid-ascii/parser.ts +0 -645
  107. package/src/vendor/mermaid-ascii/sequence/parser.ts +0 -207
  108. package/src/vendor/mermaid-ascii/sequence/types.ts +0 -146
  109. package/src/vendor/mermaid-ascii/text-metrics.ts +0 -74
  110. package/src/vendor/mermaid-ascii/types.ts +0 -164
  111. package/src/vendor/mermaid-ascii/xychart/colors.ts +0 -140
  112. package/src/vendor/mermaid-ascii/xychart/parser.ts +0 -115
  113. 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(this.#localDay(now));
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 = fs.openSync(filePath, "a");
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(this.#localDay(now));
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 = crypto.createHash("sha256").update(`${filePath}LOG_FILE${date}`).digest("hex");
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 omp instances stay
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
- lines.push(`${indent}${span.op}: ${fmtMs(dur)}${selfStr}${tag}`);
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[] = [];
@@ -1,14 +1,20 @@
1
- import { type AsciiRenderOptions, renderMermaidASCII } from "./vendor/mermaid-ascii";
1
+ import { type MermaidRenderOptions, renderMermaidAscii } from "@linxiraos/pi-natives";
2
2
 
3
- export type { AsciiRenderOptions as MermaidAsciiRenderOptions };
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
- export function renderMermaidAscii(source: string, options?: AsciiRenderOptions): string {
6
- return renderMermaidASCII(source, options);
7
- }
11
+ /** Options for {@link renderMermaidAscii}; every field optional. */
12
+ export type MermaidAsciiRenderOptions = MermaidRenderOptions;
8
13
 
9
- export function renderMermaidAsciiSafe(source: string, options?: AsciiRenderOptions): string | null {
14
+ /** {@link renderMermaidAscii}, returning `null` instead of throwing. */
15
+ export function renderMermaidAsciiSafe(source: string, options?: MermaidAsciiRenderOptions): string | null {
10
16
  try {
11
- return renderMermaidASCII(source, options);
17
+ return renderMermaidAscii(source, options);
12
18
  } catch {
13
19
  return null;
14
20
  }
@@ -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 = /\.[mc]?tsx?$/;
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
- build.onLoad({ filter: MODULE_LOADER_FILTER }, async args => {
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 = await Bun.file(args.path).text();
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
- let cachedShellConfig: ShellConfig | null = null;
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 (cachedShellConfig?.shell !== customShellPath) {
191
- cachedShellConfig = buildConfig(customShellPath);
196
+ if (cachedCustomShellConfig?.shell !== customShellPath) {
197
+ cachedCustomShellConfig = buildConfig(customShellPath);
192
198
  }
193
- return cachedShellConfig;
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
- cachedShellConfig = buildConfig(resolveWindowsShell());
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
- cachedShellConfig = buildConfig(userShell);
209
- return cachedShellConfig;
215
+ return userShell;
210
216
  }
211
217
 
212
218
  // 4. Fallback: use basic shell
213
- const basicShell = resolveBasicShell();
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 as ArrayBuffer;
517
+ return (await this.#readOutputBytes()).buffer;
518
518
  }
519
519
 
520
520
  async bytes(): Promise<Uint8Array> {
@@ -172,9 +172,43 @@ function resolverRegistry(): ResolverRegistration[] {
172
172
  holder[REGISTRY] ??= [];
173
173
  return holder[REGISTRY];
174
174
  }
175
- function pathContains(root: string, candidate: string): boolean {
176
- const relative = path.relative(root, candidate);
177
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
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 = parentFile !== null && pathContains(registration.runtimeNodeModules, parentFile);
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 || !pathContains(registration.runtimeNodeModules, 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
- if (!stockResolved.startsWith(pkgDir + path.sep)) continue;
264
- if (path.relative(pkgDir, stockResolved).split(path.sep).includes("node_modules")) continue;
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 (error) {
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 (error) {
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
  }