@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/CHANGELOG.md +22 -0
- package/dist/types/dirs.d.ts +7 -0
- package/dist/types/env.d.ts +8 -0
- package/dist/types/executable.d.ts +4 -0
- package/dist/types/fetch-retry.d.ts +8 -3
- package/dist/types/file-lock.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mime.d.ts +1 -0
- package/dist/types/path.d.ts +6 -0
- package/dist/types/postmortem.d.ts +26 -5
- package/dist/types/procmgr.d.ts +2 -4
- package/dist/types/snowflake.d.ts +1 -0
- package/dist/types/sqlite.d.ts +27 -7
- package/dist/types/stream.d.ts +45 -0
- package/dist/types/which.d.ts +6 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/dirs.ts +10 -0
- package/src/env.ts +45 -0
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +26 -17
- package/src/file-lock.ts +23 -0
- package/src/index.ts +1 -0
- package/src/logger.ts +2 -3
- package/src/mime.ts +3 -7
- package/src/path.ts +14 -0
- package/src/postmortem.ts +89 -41
- package/src/procmgr.ts +2 -12
- package/src/ptree.ts +36 -5
- package/src/snowflake.ts +12 -1
- package/src/sqlite.ts +242 -7
- package/src/stream.ts +89 -8
- package/src/which.ts +27 -1
- package/src/xml.ts +16 -0
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, `${
|
|
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
|
|
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 =
|
|
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}
|
|
72
|
-
*
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
327
|
+
* Graceful shutdown driven by `process.stdout`'s own `error` event.
|
|
292
328
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
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.
|
|
350
|
-
this.#
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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 {
|