@linxiraos/pi-utils 1.1.15 → 1.1.16

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 (51) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/THIRD-PARTY-NOTICES.txt +54 -25
  3. package/dist/types/async.d.ts +18 -0
  4. package/dist/types/browsers.d.ts +2 -30
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +18 -1
  7. package/dist/types/env.d.ts +12 -2
  8. package/dist/types/executable.d.ts +4 -0
  9. package/dist/types/fetch-retry.d.ts +8 -2
  10. package/dist/types/file-lock.d.ts +6 -0
  11. package/dist/types/format.d.ts +7 -0
  12. package/dist/types/index.d.ts +2 -1
  13. package/dist/types/mime.d.ts +1 -0
  14. package/dist/types/path.d.ts +6 -0
  15. package/dist/types/peek-file.d.ts +2 -3
  16. package/dist/types/postmortem.d.ts +26 -5
  17. package/dist/types/procmgr.d.ts +2 -4
  18. package/dist/types/snowflake.d.ts +1 -0
  19. package/dist/types/sqlite.d.ts +27 -7
  20. package/dist/types/stream.d.ts +76 -1
  21. package/dist/types/which.d.ts +8 -2
  22. package/dist/types/yaml-config.d.ts +2 -0
  23. package/package.json +2 -2
  24. package/src/acp/transport.ts +37 -3
  25. package/src/async.ts +31 -0
  26. package/src/browsers.ts +10 -169
  27. package/src/color.ts +1 -1
  28. package/src/dirs.ts +28 -6
  29. package/src/env.ts +81 -34
  30. package/src/executable.ts +17 -0
  31. package/src/fetch-retry.ts +72 -13
  32. package/src/file-lock.ts +28 -0
  33. package/src/format.ts +16 -0
  34. package/src/index.ts +2 -1
  35. package/src/json.ts +12 -1
  36. package/src/logger/rotating-file.ts +40 -3
  37. package/src/logger.ts +16 -4
  38. package/src/mime.ts +3 -7
  39. package/src/path.ts +14 -0
  40. package/src/peek-file.ts +17 -67
  41. package/src/postmortem.ts +95 -47
  42. package/src/procmgr.ts +2 -12
  43. package/src/ptree.ts +36 -5
  44. package/src/snowflake.ts +12 -1
  45. package/src/sqlite.ts +242 -7
  46. package/src/stream.ts +167 -35
  47. package/src/which.ts +38 -14
  48. package/src/xml.ts +16 -0
  49. package/src/yaml-config.ts +8 -0
  50. package/dist/types/glob.d.ts +0 -28
  51. package/src/glob.ts +0 -189
package/src/postmortem.ts CHANGED
@@ -7,8 +7,6 @@
7
7
  */
8
8
 
9
9
  import * as fs from "node:fs";
10
- import inspector from "node:inspector";
11
- import { isMainThread } from "node:worker_threads";
12
10
  import * as logger from "./logger";
13
11
  import { restoreTerminalStderr } from "./stderr-guard";
14
12
 
@@ -59,6 +57,26 @@ export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit"
59
57
 
60
58
  type HardExitFn = (code?: number) => never;
61
59
 
60
+ /**
61
+ * Walk a guarded exit primitive down to the native it shadows.
62
+ *
63
+ * `withHostGuard` stamps each throwing replacement with the primitive it
64
+ * shadows under {@link NATIVE_PROCESS_EXIT}; nested guard windows stack, so a
65
+ * single unwrap can still land on another throwing stub. Follow the chain
66
+ * (cycle-guarded) until a link carries no stamp — that link is native.
67
+ */
68
+ function nativeHardExit(fn: HardExitFn | undefined): HardExitFn | undefined {
69
+ let current = fn;
70
+ const seen = new Set<HardExitFn>();
71
+ while (typeof current === "function" && !seen.has(current)) {
72
+ seen.add(current);
73
+ const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
74
+ if (typeof behind !== "function") return current;
75
+ current = behind as HardExitFn;
76
+ }
77
+ return typeof current === "function" ? current : undefined;
78
+ }
79
+
62
80
  /**
63
81
  * Hard-exit the process through the native primitive, resolved on every call.
64
82
  *
@@ -70,14 +88,30 @@ type HardExitFn = (code?: number) => never;
70
88
  * init could freeze the throwing stub forever and turn every later shutdown
71
89
  * (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
72
90
  * guard is active the stub carries the native exit under
73
- * {@link NATIVE_PROCESS_EXIT}; unwrapping it lets a mid-guard signal still exit
74
- * (#6488). Otherwise the current `process.reallyExit`/`process.exit` is native.
91
+ * {@link NATIVE_PROCESS_EXIT} (#6488).
92
+ *
93
+ * Both globals are reinstalled to their natives before exiting: Bun's
94
+ * `process.exit` re-reads `process.reallyExit` at call time, so exiting through
95
+ * one primitive while its sibling still holds the throwing stub re-enters the
96
+ * guard and loops the rejection storm (#11789). After restoring, `reallyExit`
97
+ * (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
98
+ * are fallbacks so a poisoned or absent chain can never leave the process alive.
75
99
  */
76
- function exitProcess(code: number): never {
77
- const current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit;
78
- const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
79
- const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current;
80
- return nativeExit.call(process, code) as never;
100
+ export function exitProcess(code: number): never {
101
+ const reallyExit = nativeHardExit(typeof process.reallyExit === "function" ? process.reallyExit : undefined);
102
+ const exit = nativeHardExit(process.exit as HardExitFn);
103
+ if (reallyExit) process.reallyExit = reallyExit as typeof process.reallyExit;
104
+ if (exit) process.exit = exit as typeof process.exit;
105
+ try {
106
+ reallyExit?.call(process, code);
107
+ } catch {}
108
+ try {
109
+ exit?.call(process, code);
110
+ } catch {}
111
+ try {
112
+ process.kill(process.pid, "SIGKILL");
113
+ } catch {}
114
+ throw new Error(`exitProcess(${code}) failed to terminate the process`);
81
115
  }
82
116
  let cleanupPromise: Promise<void> | undefined;
83
117
  let stdioDisconnectRegistrations = 0;
@@ -290,19 +324,47 @@ function faultWorkerIpcChannels(err: Error): void {
290
324
  }
291
325
 
292
326
  /**
293
- * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
327
+ * Graceful shutdown driven by `process.stdout`'s own `error` event.
294
328
  *
295
- * Stdio protocol servers call this for their process lifetime so a closed
296
- * client pipe runs registered cleanup callbacks instead of the fatal path.
297
- * The returned callback removes the registration.
329
+ * A closed stdout consumer (`omp --help | head`, an ACP client dropping the
330
+ * pipe) delivers the broken-pipe write here — attributable to stdout by
331
+ * construction, unlike a process-wide `syscall: "write"` match that a closed
332
+ * subprocess stdin or socket would also satisfy — so it runs cleanup and exits
333
+ * 0 (Unix `| head` semantics).
334
+ *
335
+ * Only the broken-pipe case is claimed. A non-EPIPE stdout error (a revoked PTY
336
+ * reporting `EIO`) is left for other `error` listeners: the TUI installs its own
337
+ * stdout handler that treats a disconnect as SIGHUP/exit-129, and this listener
338
+ * is installed first on an interactive launch, so forcing a fatal exit here
339
+ * would preempt that established path. Attaching a listener already suppresses
340
+ * Node's default throw, so deferring is a safe no-op when no other listener runs.
341
+ */
342
+ function onStdoutDisconnect(err: Error): void {
343
+ if (classifyBrokenPipe(err) !== "stdio-write") return;
344
+ logger.warn("Stdout peer disconnected; shutting down gracefully", { err });
345
+ void runQuit(0, "native", { drainStdout: false });
346
+ }
347
+
348
+ /**
349
+ * Treat a closed stdout consumer as a graceful peer disconnect for the caller's
350
+ * active lifetime. Attaches one shared `process.stdout` `error` listener,
351
+ * ref-counted across registrants (the ACP protocol server, the one-shot CLI
352
+ * entry). The returned callback removes the registration; the listener detaches
353
+ * when the last registrant unregisters.
298
354
  */
299
355
  export function registerStdioDisconnectHandling(): () => void {
300
356
  let registered = true;
357
+ if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
358
+ process.stdout.on("error", onStdoutDisconnect);
359
+ }
301
360
  stdioDisconnectRegistrations++;
302
361
  return () => {
303
362
  if (!registered) return;
304
363
  registered = false;
305
364
  stdioDisconnectRegistrations--;
365
+ if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
366
+ process.stdout.removeListener("error", onStdoutDisconnect);
367
+ }
306
368
  };
307
369
  }
308
370
 
@@ -416,48 +478,52 @@ async function exitAfterFatal(output: string, logMessage: string, err: Error, re
416
478
  }
417
479
  }
418
480
 
481
+ /** Contain an EPIPE from an optional worker IPC `send()` (#2997, #9158). */
482
+ function handleWorkerSendEpipe(err: Error): boolean {
483
+ if (!isIpcSendEpipe(err)) return false;
484
+ logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
485
+ return true;
486
+ }
487
+
419
488
  /**
420
489
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
421
490
  */
422
491
  export async function fatal(error: unknown): Promise<never> {
423
492
  const err = error instanceof Error ? error : new Error(String(error));
424
493
  const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
425
- if (!isMainThread) {
494
+ if (!Bun.isMainThread) {
426
495
  process.stderr.write(output);
427
496
  process.exit(1);
428
497
  }
429
498
  return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
430
499
  }
431
500
 
432
- if (isMainThread) {
501
+ if (Bun.isMainThread) {
433
502
  process
434
503
  .on("SIGINT", async () => {
435
504
  await runCleanup(Reason.SIGINT);
436
505
  exitProcess(130); // 128 + SIGINT (2)
437
506
  })
438
- .on("SIGUSR1", () => {
507
+ .on("SIGUSR1", async () => {
439
508
  if (inspectorOpened) return;
440
509
  inspectorOpened = true;
510
+ // Signal-only boundary: successful startup never constructs inspector.
511
+ const { default: inspector } = await import("node:inspector");
441
512
  inspector.open(undefined, undefined, false);
442
513
  const url = inspector.url();
443
514
  process.stderr.write(`Inspector opened: ${url}\n`);
444
515
  })
445
516
  .on("uncaughtException", async thrown => {
446
- // Only explicitly marked exceptions are safe here. Structural
447
- // AbortError/socket classification is limited to promise rejections:
448
- // a synchronously thrown error may indicate an application bug.
517
+ // Expected cleanup is safe globally; unrelated synchronous errors stay fatal.
449
518
  if (hasExpectedCleanupMarker(thrown)) {
450
519
  logger.warn("Ignoring expected cleanup exception", { err: thrown });
451
520
  return;
452
521
  }
453
522
  const err = thrown instanceof Error ? thrown : new Error(String(thrown));
454
- // Bun can surface a worker IPC send race through uncaughtException
455
- // instead of unhandledRejection. Apply the same optional-worker
456
- // containment in either global error channel.
457
- if (isIpcSendEpipe(err)) {
458
- logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
459
- return;
460
- }
523
+ // A worker IPC `send()` race can surface through either global error event;
524
+ // contain it in both. Stdout write disconnects are attributed to stdout by
525
+ // registerStdioDisconnectHandling's `error` listener, not classified here.
526
+ if (handleWorkerSendEpipe(err)) return;
461
527
  // A malformed advanced-serialization frame from a worker subprocess
462
528
  // surfaces here as a process-level uncaughtException (oven-sh/bun#37287)
463
529
  // rather than in the channel's ipc() callback, and Bun gives no way to
@@ -466,7 +532,7 @@ if (isMainThread) {
466
532
  // worker so its owning client rejects in-flight requests and recycles
467
533
  // the subprocess — a worker that sent a bad frame but stays alive would
468
534
  // otherwise never fire onExit and leave callers awaiting forever.
469
- // Mirrors the ipc-send EPIPE containment below (#9158, #2997).
535
+ // See the analogous worker IPC containment in handleBrokenPipe (#9158, #2997).
470
536
  if (isWorkerIpcDeserializeError(err)) {
471
537
  logger.warn("Malformed worker IPC frame; faulting active worker subsystems", { err });
472
538
  faultWorkerIpcChannels(err);
@@ -487,25 +553,7 @@ if (isMainThread) {
487
553
  })
488
554
  .on("unhandledRejection", async reason => {
489
555
  const err = reason instanceof Error ? reason : new Error(String(reason));
490
- const brokenPipeSource = classifyBrokenPipe(err);
491
- // EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
492
- // worker subprocess whose pipe broke between the exit being observed
493
- // and the next `proc.send()` — a race window that Bun surfaces as an
494
- // async rejection rather than the synchronous "cannot be used after
495
- // the process has exited" guard. Every `send()` target is an optional
496
- // worker subsystem (TTS, STT, tiny-title, MCP servers), so a broken
497
- // send pipe must never take down the whole session. Log and continue
498
- // instead of exiting; the owning client detects the dead worker via
499
- // its own `onExit`/error path and respawns or disables it. See #2997.
500
- if (brokenPipeSource === "ipc-send") {
501
- logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
502
- return;
503
- }
504
- if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
505
- logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
506
- await runQuit(0, "native");
507
- return;
508
- }
556
+ if (handleWorkerSendEpipe(err)) return;
509
557
  if (isExpectedCleanupError(reason)) {
510
558
  logger.warn("Ignoring expected cleanup rejection", { err });
511
559
  return;
@@ -656,7 +704,7 @@ export async function drainStdout(): Promise<void> {
656
704
  async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
657
705
  await runCleanup(Reason.MANUAL);
658
706
 
659
- if (!isMainThread) {
707
+ if (!Bun.isMainThread) {
660
708
  return; // Workers: cleanup done, let worker exit naturally
661
709
  }
662
710
 
package/src/procmgr.ts CHANGED
@@ -4,8 +4,10 @@ import { Process, ProcessStatus } from "@linxiraos/pi-natives";
4
4
  import type { Subprocess } from "bun";
5
5
  import { getAgentDir, MAIN_CONFIG_FILENAMES } from "./dirs";
6
6
  import { $env, filterChildShellEnv } from "./env";
7
+ import { isExecutable } from "./executable";
7
8
  import { $which } from "./which";
8
9
 
10
+ export { isExecutable };
9
11
  export interface ShellConfig {
10
12
  shell: string;
11
13
  args: string[];
@@ -20,18 +22,6 @@ export interface ShellConfigOptions {
20
22
  }
21
23
  let cachedShellConfig: ShellConfig | null = null;
22
24
 
23
- /**
24
- * Check if a shell binary is executable.
25
- */
26
- export function isExecutable(path: string): boolean {
27
- try {
28
- fs.accessSync(path, fs.constants.X_OK);
29
- return true;
30
- } catch {
31
- return false;
32
- }
33
- }
34
-
35
25
  /**
36
26
  * Build the spawn environment (cached).
37
27
  */
package/src/ptree.ts CHANGED
@@ -17,6 +17,8 @@ type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe"
17
17
 
18
18
  const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
19
19
  const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
20
+ const SUBREAPER_KILL_WINDOW_MS = 100;
21
+ const SUBREAPER_KILL_POLL_MS = 5;
20
22
 
21
23
  /**
22
24
  * Build the Linux child-subreaper entrypoint.
@@ -189,6 +191,8 @@ export class ChildProcess<In extends InMask = InMask> {
189
191
  #stderrStream?: ReadableStream<Uint8Array>;
190
192
  // Termination in flight after kill(); aborted exits await it before reporting.
191
193
  #terminating?: Promise<boolean | void>;
194
+ // A hard subreaper sweep must remain authoritative across overlapping kill requests.
195
+ #hardKillSweep?: Promise<void>;
192
196
  #terminateGroup: boolean;
193
197
  #hardKillTree: boolean;
194
198
  // Windows has no process groups. Retaining the root's native handle pins
@@ -340,14 +344,22 @@ export class ChildProcess<In extends InMask = InMask> {
340
344
  // group leader; wait() still needs to report the later deadline.
341
345
  if (this.proc.exitCode !== null) this.#exitReason = reason;
342
346
  }
347
+ // An AbortSignal can race a timeout after its hard subreaper sweep has
348
+ // started. Do not replace that sweep with a normal root termination: the
349
+ // root must stay alive until adopted descendants have been collected.
350
+ if (this.#hardKillSweep) return;
343
351
  if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
344
- // terminate() sends its polite wave to the root before rebuilding the
345
- // hard-kill tree. A subreaper root can die in that gap and release its
346
- // adopted descendants, so snapshot and hard-kill the live tree first.
352
+ // Keep the subreaper alive while descendants are killed. A single
353
+ // killTree() snapshot can miss a worker whose parent exits during the
354
+ // walk and reparents it to the subreaper after that root was enumerated.
347
355
  const root = Process.fromPid(this.proc.pid);
348
356
  if (root) {
349
- root.killTree(9);
350
- this.#terminating = Promise.resolve();
357
+ const sweep = this.#hardKillSubreaperTree(root).catch(e => void e);
358
+ this.#hardKillSweep = sweep;
359
+ this.#terminating = sweep;
360
+ void sweep.finally(() => {
361
+ if (this.#hardKillSweep === sweep) this.#hardKillSweep = undefined;
362
+ });
351
363
  return;
352
364
  }
353
365
  }
@@ -386,6 +398,25 @@ export class ChildProcess<In extends InMask = InMask> {
386
398
  }
387
399
  }
388
400
 
401
+ async #hardKillSubreaperTree(root: Process): Promise<void> {
402
+ try {
403
+ const deadline = Date.now() + SUBREAPER_KILL_WINDOW_MS;
404
+ let emptySweeps = 0;
405
+ while (emptySweeps < 2 && Date.now() < deadline) {
406
+ const children = root.children();
407
+ if (children.length === 0) {
408
+ emptySweeps++;
409
+ } else {
410
+ emptySweeps = 0;
411
+ for (const child of children) child.killTree(9);
412
+ }
413
+ if (emptySweeps < 2) await Bun.sleep(SUBREAPER_KILL_POLL_MS);
414
+ }
415
+ } finally {
416
+ root.killTree(9);
417
+ }
418
+ }
419
+
389
420
  // ── Output helpers ───────────────────────────────────────────────────
390
421
 
391
422
  async #throwIfAborted(): Promise<void> {
package/src/snowflake.ts CHANGED
@@ -4,6 +4,7 @@ function randu32() {
4
4
 
5
5
  const EPOCH = 1420070400000;
6
6
  const MAX_SEQ = 0x3fffff;
7
+ const MAX_DT = 2 ** 42 - 1;
7
8
 
8
9
  // Snowflake as a hex string (16 chars, zero-padded).
9
10
  //
@@ -25,14 +26,24 @@ namespace Snowflake {
25
26
  //
26
27
  export const MAX_SEQUENCE = MAX_SEQ;
27
28
 
29
+ // Last timestamp representable in the 42-bit timestamp field (~year 2154).
30
+ //
31
+ export const MAX_TIMESTAMP = EPOCH + MAX_DT;
32
+
28
33
  // Formats a sequence and timestamp into a snowflake hex string.
29
34
  //
30
35
  // dt fits well within BigInt range: (dt << 22) | seq stays under 2^64 for
31
36
  // any dt < 2^42 (~year 2154), so a single 64-bit format is exact — and
32
37
  // measures ~1.7x faster than stitching four 16-bit hex segments.
33
38
  //
39
+ // dt is saturated into [0, 2^42) so the result is always a valid snowflake:
40
+ // a negative delta (a timestamp before EPOCH) would otherwise render a
41
+ // leading "-", and a delta past ~2154 would widen the string beyond 16
42
+ // chars. Both cases produce a value that fails this module's own valid().
43
+ //
34
44
  export function formatParts(dt: number, seq: number): Snowflake {
35
- return ((BigInt(dt) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
45
+ const clamped = Math.min(Math.max(dt, 0), MAX_DT);
46
+ return ((BigInt(clamped) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
36
47
  }
37
48
 
38
49
  // Snowflake generator type.
package/src/sqlite.ts CHANGED
@@ -1,13 +1,248 @@
1
+ /** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
2
+ import { Database } from "bun:sqlite";
3
+ import * as fs from "node:fs";
4
+ import { getDbBusyTimeoutMs } from "./env";
5
+ import { withFileLockSync } from "./file-lock";
6
+ import { isEnoent } from "./fs-error";
7
+ import * as logger from "./logger";
8
+
9
+ const BUSY_MAX_ATTEMPTS = 4;
10
+ const BUSY_BASE_DELAY_MS = 100;
11
+ const SQLITE_STORE_SUFFIXES = ["-wal", "-shm", "-journal", ""];
12
+
13
+ type SqliteFileIdentity = string | null | undefined;
14
+
15
+ class SqliteAttemptFailure extends Error {
16
+ readonly original: unknown;
17
+ readonly identity: SqliteFileIdentity;
18
+ readonly canRecover: boolean;
19
+ readonly db?: Database;
20
+
21
+ constructor(original: unknown, identity: SqliteFileIdentity, options: { canRecover?: boolean; db?: Database } = {}) {
22
+ super(original instanceof Error ? original.message : String(original));
23
+ this.original = original;
24
+ this.identity = identity;
25
+ this.canRecover = options.canRecover ?? true;
26
+ this.db = options.db;
27
+ }
28
+ }
29
+
30
+ function sqliteFileIdentity(dbPath: string): SqliteFileIdentity {
31
+ try {
32
+ const stat = fs.statSync(dbPath);
33
+ return `${stat.dev}:${stat.ino}:${stat.birthtimeMs}`;
34
+ } catch (error) {
35
+ return isEnoent(error) ? null : undefined;
36
+ }
37
+ }
38
+
39
+ function closeFailedDatabase(db: Database | undefined, error: unknown, identity: SqliteFileIdentity): void {
40
+ try {
41
+ db?.close();
42
+ } catch (closeError) {
43
+ const original = error instanceof Error ? error : new Error(String(error));
44
+ const detail = closeError instanceof Error ? closeError.message : String(closeError);
45
+ original.message += `; failed to close the SQLite handle: ${detail}`;
46
+ throw new SqliteAttemptFailure(original, identity, { canRecover: false });
47
+ }
48
+ }
49
+
50
+ /** Controls opt-in replacement of an unrecoverably corrupt SQLite store. */
51
+ export interface SqliteOpenOptions {
52
+ /**
53
+ * Preserve a corrupt store and its sidecars, recreate it, and run the
54
+ * initializer once more. Disabled by default.
55
+ */
56
+ recoverCorruption?: boolean;
57
+ /** Runs after preservation and before the replacement is initialized. */
58
+ onCorruptionPreserved?: (backupPath: string, error: unknown) => void;
59
+ }
60
+
61
+ async function openWithBusyRetries<T>(
62
+ dbPath: string,
63
+ initialize: (db: Database) => T | Promise<T>,
64
+ options: SqliteOpenOptions,
65
+ ): Promise<T> {
66
+ for (let attempt = 0; ; attempt++) {
67
+ let db: Database | undefined;
68
+ const identity = sqliteFileIdentity(dbPath);
69
+ try {
70
+ db = new Database(dbPath);
71
+ // WAL recovery can bypass the busy handler; both it and retries are needed (#2421).
72
+ db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
73
+ return await initialize(db);
74
+ } catch (error) {
75
+ if (options.recoverCorruption && isSqliteCorruptionError(error)) {
76
+ throw new SqliteAttemptFailure(error, identity, { db });
77
+ }
78
+ closeFailedDatabase(db, error, identity);
79
+ if (!isSqliteBusyError(error) || attempt + 1 >= BUSY_MAX_ATTEMPTS) {
80
+ throw new SqliteAttemptFailure(error, identity);
81
+ }
82
+ await Bun.sleep(BUSY_BASE_DELAY_MS * 2 ** attempt);
83
+ }
84
+ }
85
+ }
86
+
87
+ function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: SqliteOpenOptions): T {
88
+ let db: Database | undefined;
89
+ const identity = sqliteFileIdentity(dbPath);
90
+ try {
91
+ db = new Database(dbPath);
92
+ db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
93
+ return initialize(db);
94
+ } catch (error) {
95
+ if (options.recoverCorruption && isSqliteCorruptionError(error)) {
96
+ throw new SqliteAttemptFailure(error, identity, { db });
97
+ }
98
+ closeFailedDatabase(db, error, identity);
99
+ throw new SqliteAttemptFailure(error, identity);
100
+ }
101
+ }
102
+
103
+ function quarantineCorruptSqliteStore(dbPath: string, db: Database | undefined): string {
104
+ const backupPath = `${dbPath}.corrupt-${Date.now()}-${crypto.randomUUID()}`;
105
+ const preserved: string[] = [];
106
+ // Closing a failed WAL connection can truncate its WAL. Copy evidence
107
+ // before closing, and remove originals only after every copy succeeds.
108
+ for (const suffix of SQLITE_STORE_SUFFIXES) {
109
+ try {
110
+ fs.chmodSync(`${dbPath}${suffix}`, 0o600);
111
+ fs.copyFileSync(`${dbPath}${suffix}`, `${backupPath}${suffix}`, fs.constants.COPYFILE_EXCL);
112
+ preserved.push(suffix);
113
+ } catch (error) {
114
+ if (isEnoent(error) && suffix !== "") continue;
115
+ throw error;
116
+ }
117
+ }
118
+ db?.close();
119
+
120
+ const removed: string[] = [];
121
+ try {
122
+ // Remove the main file last so a failed sidecar removal cannot leave
123
+ // a path at which another startup creates an empty database.
124
+ for (const suffix of preserved) {
125
+ try {
126
+ fs.unlinkSync(`${dbPath}${suffix}`);
127
+ removed.push(suffix);
128
+ } catch (error) {
129
+ if (!isEnoent(error)) throw error;
130
+ }
131
+ }
132
+ } catch (error) {
133
+ for (const suffix of removed) {
134
+ try {
135
+ fs.copyFileSync(`${backupPath}${suffix}`, `${dbPath}${suffix}`, fs.constants.COPYFILE_EXCL);
136
+ } catch (rollbackError) {
137
+ logger.error("SQLite quarantine rollback failed; original preserved at backup path", {
138
+ path: `${dbPath}${suffix}`,
139
+ backupPath: `${backupPath}${suffix}`,
140
+ error: String(rollbackError),
141
+ });
142
+ }
143
+ }
144
+ throw error;
145
+ }
146
+ return backupPath;
147
+ }
148
+
149
+ function corruptionPreservationError(corruption: unknown, dbPath: string, preservationError: unknown): Error {
150
+ const annotated = annotateSqliteError(corruption, dbPath);
151
+ const detail = preservationError instanceof Error ? preservationError.message : String(preservationError);
152
+ annotated.message += `; failed to preserve the corrupt database: ${detail}`;
153
+ return annotated;
154
+ }
155
+
156
+ function recoverCorruptDatabase(dbPath: string, error: unknown, options: SqliteOpenOptions): void {
157
+ if (!(error instanceof SqliteAttemptFailure)) throw annotateSqliteError(error, dbPath);
158
+ const failure = error;
159
+ if (!options.recoverCorruption || !failure.canRecover || !isSqliteCorruptionError(failure.original)) {
160
+ throw annotateSqliteError(failure.original, dbPath);
161
+ }
162
+
163
+ let backupPath: string | null;
164
+ try {
165
+ try {
166
+ backupPath = withFileLockSync(`${dbPath}.recovery`, () => {
167
+ const currentIdentity = sqliteFileIdentity(dbPath);
168
+ if (failure.identity === undefined || currentIdentity === undefined) {
169
+ throw new Error("could not verify the corrupt database file identity");
170
+ }
171
+ if (currentIdentity !== failure.identity) return null;
172
+ return quarantineCorruptSqliteStore(dbPath, failure.db);
173
+ });
174
+ } finally {
175
+ closeFailedDatabase(failure.db, failure.original, failure.identity);
176
+ }
177
+ } catch (preservationError) {
178
+ throw corruptionPreservationError(failure.original, dbPath, preservationError);
179
+ }
180
+
181
+ if (backupPath === null) return;
182
+ logger.warn("SQLite database corrupt; preserved damaged store before recreating it", {
183
+ path: dbPath,
184
+ backupPath,
185
+ warning: "Stored credentials from this database may require re-login.",
186
+ });
187
+ options.onCorruptionPreserved?.(backupPath, failure.original);
188
+ }
189
+
1
190
  /**
2
- * Shared classifiers for `bun:sqlite` error result codes.
191
+ * Opens and initializes a store, retrying BUSY failures up to four total attempts.
192
+ * Installs the busy handler before initialization and closes failed connections.
193
+ * The initializer may run again on a fresh connection; on success it owns the handle.
3
194
  *
4
- * Every omp SQLite store (`agent.db` credential/usage store, `models.db` model
5
- * cache, `history.db`) needs the same two distinctions: a transient BUSY that
6
- * clears by retrying, and an unrecoverable corruption that never does. Keeping
7
- * one implementation here prevents the classifiers from drifting between the
8
- * credential store and the model cache.
195
+ * With corruption recovery enabled, recovery is serialized across processes.
196
+ * The identity observed by the failed handle is checked under that lock, so a
197
+ * waiter adopts a replacement made by a peer instead of quarantining it.
198
+ * Final failures retain their SQLite codes and include the database path.
9
199
  */
10
- import type { Database } from "bun:sqlite";
200
+ export async function openSqliteDatabase<T>(
201
+ dbPath: string,
202
+ initialize: (db: Database) => T | Promise<T>,
203
+ options: SqliteOpenOptions = {},
204
+ ): Promise<T> {
205
+ try {
206
+ return await openWithBusyRetries(dbPath, initialize, options);
207
+ } catch (error) {
208
+ recoverCorruptDatabase(dbPath, error, options);
209
+ }
210
+
211
+ try {
212
+ return await openWithBusyRetries(dbPath, initialize, {});
213
+ } catch (error) {
214
+ throw annotateSqliteError(error instanceof SqliteAttemptFailure ? error.original : error, dbPath);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Synchronous counterpart to {@link openSqliteDatabase}. It performs no BUSY
220
+ * retry loop; corruption recovery, when enabled, is bounded to one replacement.
221
+ */
222
+ export function openSqliteDatabaseSync<T>(
223
+ dbPath: string,
224
+ initialize: (db: Database) => T,
225
+ options: SqliteOpenOptions = {},
226
+ ): T {
227
+ try {
228
+ return openOnce(dbPath, initialize, options);
229
+ } catch (error) {
230
+ recoverCorruptDatabase(dbPath, error, options);
231
+ }
232
+
233
+ try {
234
+ return openOnce(dbPath, initialize, {});
235
+ } catch (error) {
236
+ throw annotateSqliteError(error instanceof SqliteAttemptFailure ? error.original : error, dbPath);
237
+ }
238
+ }
239
+
240
+ /** Adds the failing store's path to an error without losing SQLite result codes or its original stack. */
241
+ export function annotateSqliteError(error: unknown, dbPath: string): Error {
242
+ const annotated = error instanceof Error ? error : new Error(String(error));
243
+ annotated.message = `Database ${JSON.stringify(dbPath)}: ${annotated.message}`;
244
+ return annotated;
245
+ }
11
246
 
12
247
  /** Checkpoints committed WAL frames without waiting for concurrent readers. */
13
248
  export function checkpointWal(db: Database): void {