@oh-my-pi/pi-utils 18.2.0 → 18.2.2

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/src/logger.ts CHANGED
@@ -289,12 +289,11 @@ function getLocalTransports(): LocalTransports {
289
289
 
290
290
  function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
291
291
  const transports = getLocalTransports();
292
- const info = normalizeLogInfo(level, message, context);
293
292
  if (!transports.file && !transports.console) return;
294
-
293
+ const info = normalizeLogInfo(level, message, context);
295
294
  const line = formatLogInfo(info);
296
295
  if (transports.file) transports.file.write(line);
297
- if (transports.console) fs.writeSync(1, `${formatLogInfo(info)}${os.EOL}`);
296
+ if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
298
297
  }
299
298
 
300
299
  /**
package/src/mime.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { peekFile, peekFileSync } from "./peek-file";
2
2
 
3
- const DEFAULT_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
4
-
3
+ export const IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
5
4
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
6
5
  const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
7
6
  const WEBP_RIFF_MAGIC = Buffer.from([0x52, 0x49, 0x46, 0x46]);
@@ -144,16 +143,13 @@ export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
144
143
  );
145
144
  }
146
145
 
147
- export function readImageMetadataSync(
148
- filePath: string,
149
- maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
150
- ): ImageMetadata | null {
146
+ export function readImageMetadataSync(filePath: string, maxBytes = IMAGE_METADATA_HEADER_BYTES): ImageMetadata | null {
151
147
  return peekFileSync(filePath, maxBytes, parseImageMetadata);
152
148
  }
153
149
 
154
150
  export function readImageMetadata(
155
151
  filePath: string,
156
- maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
152
+ maxBytes = IMAGE_METADATA_HEADER_BYTES,
157
153
  ): Promise<ImageMetadata | null> {
158
154
  return peekFile(filePath, maxBytes, parseImageMetadata);
159
155
  }
package/src/path.ts CHANGED
@@ -40,3 +40,17 @@ export function stripWindowsExtendedLengthPathPrefix(
40
40
 
41
41
  return filePath;
42
42
  }
43
+
44
+ /**
45
+ * Test whether a path is fully qualified and drive-independent.
46
+ * On Windows, requires a drive letter with separator (e.g. `C:\`) or UNC (`\\server\share` or `//server/share`).
47
+ * On POSIX, requires an absolute path.
48
+ */
49
+ export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform = process.platform): boolean {
50
+ const p = platform === "win32" ? path.win32 : path.posix;
51
+ if (!p.isAbsolute(filePath)) return false;
52
+ if (platform === "win32") {
53
+ return /^[a-zA-Z]:[/\\]/.test(filePath) || /^[\\/]{2}[^\\/]/.test(filePath);
54
+ }
55
+ return true;
56
+ }
package/src/postmortem.ts CHANGED
@@ -57,6 +57,26 @@ export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit"
57
57
 
58
58
  type HardExitFn = (code?: number) => never;
59
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
+
60
80
  /**
61
81
  * Hard-exit the process through the native primitive, resolved on every call.
62
82
  *
@@ -68,14 +88,30 @@ type HardExitFn = (code?: number) => never;
68
88
  * init could freeze the throwing stub forever and turn every later shutdown
69
89
  * (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
70
90
  * guard is active the stub carries the native exit under
71
- * {@link NATIVE_PROCESS_EXIT}; unwrapping it lets a mid-guard signal still exit
72
- * (#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.
73
99
  */
74
- function exitProcess(code: number): never {
75
- const current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit;
76
- const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
77
- const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current;
78
- 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`);
79
115
  }
80
116
  let cleanupPromise: Promise<void> | undefined;
81
117
  let stdioDisconnectRegistrations = 0;
@@ -288,19 +324,47 @@ function faultWorkerIpcChannels(err: Error): void {
288
324
  }
289
325
 
290
326
  /**
291
- * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
327
+ * Graceful shutdown driven by `process.stdout`'s own `error` event.
292
328
  *
293
- * Stdio protocol servers call this for their process lifetime so a closed
294
- * client pipe runs registered cleanup callbacks instead of the fatal path.
295
- * 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.
296
354
  */
297
355
  export function registerStdioDisconnectHandling(): () => void {
298
356
  let registered = true;
357
+ if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
358
+ process.stdout.on("error", onStdoutDisconnect);
359
+ }
299
360
  stdioDisconnectRegistrations++;
300
361
  return () => {
301
362
  if (!registered) return;
302
363
  registered = false;
303
364
  stdioDisconnectRegistrations--;
365
+ if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
366
+ process.stdout.removeListener("error", onStdoutDisconnect);
367
+ }
304
368
  };
305
369
  }
306
370
 
@@ -414,6 +478,13 @@ async function exitAfterFatal(output: string, logMessage: string, err: Error, re
414
478
  }
415
479
  }
416
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
+
417
488
  /**
418
489
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
419
490
  */
@@ -443,21 +514,16 @@ if (Bun.isMainThread) {
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 (Bun.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 (Bun.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;
package/src/procmgr.ts CHANGED
@@ -4,8 +4,10 @@ import { Process, ProcessStatus } from "@oh-my-pi/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 {