@linxiraos/pi-utils 1.4.6 → 1.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -2
- package/README.md +1 -1
- package/dist/types/abortable.d.ts +2 -1
- package/dist/types/acp/schema.d.ts +1 -1
- package/dist/types/ar/codecs/xz.d.ts +4 -0
- package/dist/types/ar/lzh.d.ts +2 -0
- package/dist/types/async.d.ts +5 -0
- package/dist/types/bytes.d.ts +7 -0
- package/dist/types/color.d.ts +2 -0
- package/dist/types/file-lock.d.ts +6 -0
- package/dist/types/held-file.d.ts +5 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/logger.d.ts +14 -0
- package/dist/types/pid.d.ts +8 -0
- package/dist/types/ports.d.ts +4 -2
- package/dist/types/postmortem.d.ts +3 -2
- package/dist/types/sqlite.d.ts +12 -0
- package/package.json +5 -4
- package/src/abortable.ts +6 -2
- package/src/acp/schema.ts +53 -107
- package/src/ar/arj.ts +13 -32
- package/src/ar/asar.ts +5 -8
- package/src/ar/codecs/xz.ts +7 -8
- package/src/ar/lzh.ts +21 -27
- package/src/ar/sevenzip/decode.ts +1 -56
- package/src/ar/tar.ts +1 -8
- package/src/async.ts +13 -0
- package/src/bytes.ts +14 -0
- package/src/color.ts +14 -2
- package/src/file-lock.ts +9 -0
- package/src/held-file.ts +22 -0
- package/src/index.ts +2 -1
- package/src/logger/rotating-file.ts +1 -2
- package/src/logger.ts +49 -16
- package/src/pid.ts +16 -0
- package/src/ports.ts +4 -2
- package/src/postmortem.ts +61 -33
- package/src/runtime-install.ts +1 -4
- package/src/sqlite.ts +32 -2
- package/dist/types/fs-open.d.ts +0 -17
- package/src/fs-open.ts +0 -32
package/src/postmortem.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import { APP_NAME, CLI_BIN_NAME } from "./dirs";
|
|
11
11
|
import * as logger from "./logger";
|
|
12
12
|
import { restoreTerminalStderr } from "./stderr-guard";
|
|
13
|
+
import { toError } from "./type-guards";
|
|
13
14
|
|
|
14
15
|
// Cleanup reasons, in order of priority/meaning.
|
|
15
16
|
export enum Reason {
|
|
@@ -276,7 +277,7 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
|
|
|
276
277
|
const cleanupSettled = Promise.allSettled(promises).then(async results => {
|
|
277
278
|
for (const result of results) {
|
|
278
279
|
if (result.status === "rejected") {
|
|
279
|
-
const err =
|
|
280
|
+
const err = toError(result.reason);
|
|
280
281
|
logger.error("Cleanup callback failed", { err, stack: err.stack });
|
|
281
282
|
}
|
|
282
283
|
}
|
|
@@ -307,9 +308,7 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
|
|
|
307
308
|
return cleanupPromise;
|
|
308
309
|
}
|
|
309
310
|
|
|
310
|
-
//
|
|
311
|
-
// Main thread: full signal handling (SIGINT, SIGTERM, SIGHUP) + exceptions + exit
|
|
312
|
-
// Worker thread: exit only (workers use self.addEventListener for exceptions)
|
|
311
|
+
// CLI-owned main threads handle signals and fatal errors; workers handle exit only.
|
|
313
312
|
let inspectorOpened = false;
|
|
314
313
|
|
|
315
314
|
/** Origin of an EPIPE raised by a process communication channel. */
|
|
@@ -558,13 +557,47 @@ export function isExpectedCleanupError(reason: unknown): boolean {
|
|
|
558
557
|
/** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
|
|
559
558
|
const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
|
|
560
559
|
|
|
560
|
+
function interceptRejection(reason: unknown): boolean {
|
|
561
|
+
for (const interceptor of rejectionInterceptors) {
|
|
562
|
+
try {
|
|
563
|
+
if (interceptor(reason)) return true;
|
|
564
|
+
} catch (interceptorErr) {
|
|
565
|
+
logger.warn("Unhandled-rejection interceptor threw; continuing with fatal path", { err: interceptorErr });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Without the CLI's handlers (SDK embedding), interceptors still need a listener. It exists only while
|
|
573
|
+
* interceptors do, and rethrows what none consumes unless the host listens too, keeping Bun's default.
|
|
574
|
+
*/
|
|
575
|
+
let embeddedRejectionListener: ((reason: unknown) => void) | undefined;
|
|
576
|
+
|
|
561
577
|
/**
|
|
562
578
|
* Register an interceptor consulted before an unhandled rejection tears the
|
|
563
579
|
* process down. A consuming interceptor owns reporting and keeps the process alive.
|
|
564
580
|
*/
|
|
565
581
|
export function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void {
|
|
566
582
|
rejectionInterceptors.add(interceptor);
|
|
567
|
-
|
|
583
|
+
if (!processHandlersInstalled && Bun.isMainThread && !embeddedRejectionListener) {
|
|
584
|
+
const listener = (reason: unknown) => {
|
|
585
|
+
if (interceptRejection(reason) || process.listenerCount("unhandledRejection") > 1) return;
|
|
586
|
+
throw reason;
|
|
587
|
+
};
|
|
588
|
+
embeddedRejectionListener = listener;
|
|
589
|
+
process.on("unhandledRejection", listener);
|
|
590
|
+
}
|
|
591
|
+
return () => {
|
|
592
|
+
rejectionInterceptors.delete(interceptor);
|
|
593
|
+
if (rejectionInterceptors.size === 0) removeEmbeddedRejectionListener();
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function removeEmbeddedRejectionListener(): void {
|
|
598
|
+
if (!embeddedRejectionListener) return;
|
|
599
|
+
process.off("unhandledRejection", embeddedRejectionListener);
|
|
600
|
+
embeddedRejectionListener = undefined;
|
|
568
601
|
}
|
|
569
602
|
|
|
570
603
|
/**
|
|
@@ -676,7 +709,7 @@ function handleWorkerSendEpipe(err: Error): boolean {
|
|
|
676
709
|
* Reports a caught top-level failure after terminal owners restore their display, then exits.
|
|
677
710
|
*/
|
|
678
711
|
export async function fatal(error: unknown): Promise<never> {
|
|
679
|
-
const err =
|
|
712
|
+
const err = toError(error);
|
|
680
713
|
const report = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n`;
|
|
681
714
|
if (!Bun.isMainThread) {
|
|
682
715
|
process.stderr.write(`${report}${formatFatalRecoveryHints(fatalRecoveryHintProviders)}`);
|
|
@@ -685,7 +718,22 @@ export async function fatal(error: unknown): Promise<never> {
|
|
|
685
718
|
return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
|
|
686
719
|
}
|
|
687
720
|
|
|
688
|
-
|
|
721
|
+
// Exit-time cleanup is safe in any host: it neither delays nor triggers an exit. Signal and fatal
|
|
722
|
+
// handlers change the process lifecycle, so only {@link installProcessHandlers} adds them.
|
|
723
|
+
process.on("exit", () => {
|
|
724
|
+
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
let processHandlersInstalled = false;
|
|
728
|
+
|
|
729
|
+
/** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
|
|
730
|
+
export function installProcessHandlers(): void {
|
|
731
|
+
if (processHandlersInstalled) return;
|
|
732
|
+
processHandlersInstalled = true;
|
|
733
|
+
removeEmbeddedRejectionListener();
|
|
734
|
+
// Workers report failures through their own error listeners to the parent.
|
|
735
|
+
if (!Bun.isMainThread) return;
|
|
736
|
+
|
|
689
737
|
process
|
|
690
738
|
.on("SIGINT", async () => {
|
|
691
739
|
await runCleanup(Reason.SIGINT);
|
|
@@ -706,7 +754,7 @@ if (Bun.isMainThread) {
|
|
|
706
754
|
logger.warn("Ignoring expected cleanup exception", { err: thrown });
|
|
707
755
|
return;
|
|
708
756
|
}
|
|
709
|
-
const err =
|
|
757
|
+
const err = toError(thrown);
|
|
710
758
|
// A worker IPC `send()` race can surface through either global error event;
|
|
711
759
|
// contain it in both. Stdout write disconnects are attributed to stdout by
|
|
712
760
|
// registerStdioDisconnectHandling's `error` listener, not classified here.
|
|
@@ -739,21 +787,13 @@ if (Bun.isMainThread) {
|
|
|
739
787
|
);
|
|
740
788
|
})
|
|
741
789
|
.on("unhandledRejection", async reason => {
|
|
742
|
-
const err =
|
|
790
|
+
const err = toError(reason);
|
|
743
791
|
if (handleWorkerSendEpipe(err)) return;
|
|
744
792
|
if (isExpectedCleanupError(reason)) {
|
|
745
793
|
logger.warn("Ignoring expected cleanup rejection", { err });
|
|
746
794
|
return;
|
|
747
795
|
}
|
|
748
|
-
|
|
749
|
-
try {
|
|
750
|
-
if (interceptor(reason)) return;
|
|
751
|
-
} catch (interceptorErr) {
|
|
752
|
-
logger.warn("Unhandled-rejection interceptor threw; continuing with fatal path", {
|
|
753
|
-
err: interceptorErr,
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
}
|
|
796
|
+
if (interceptRejection(reason)) return;
|
|
757
797
|
await exitAfterFatal(
|
|
758
798
|
formatFatalError("Unhandled Rejection", err),
|
|
759
799
|
"Unhandled rejection",
|
|
@@ -761,9 +801,6 @@ if (Bun.isMainThread) {
|
|
|
761
801
|
Reason.UNHANDLED_REJECTION,
|
|
762
802
|
);
|
|
763
803
|
})
|
|
764
|
-
.on("exit", async () => {
|
|
765
|
-
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
766
|
-
})
|
|
767
804
|
.on("SIGTERM", async () => {
|
|
768
805
|
// A stopped session otherwise leaves its last frame above the shell prompt,
|
|
769
806
|
// which reads as a crash. A repeated SIGTERM joins the same cleanup and the
|
|
@@ -777,14 +814,6 @@ if (Bun.isMainThread) {
|
|
|
777
814
|
await runCleanup(Reason.SIGHUP);
|
|
778
815
|
exitProcess(129); // 128 + SIGHUP (1)
|
|
779
816
|
});
|
|
780
|
-
} else {
|
|
781
|
-
// Worker thread: only register exit handler for cleanup.
|
|
782
|
-
// DO NOT register uncaughtException/unhandledRejection handlers here -
|
|
783
|
-
// they would swallow errors before the worker's own handlers (self.addEventListener)
|
|
784
|
-
// can report failures back to the parent thread.
|
|
785
|
-
process.on("exit", () => {
|
|
786
|
-
void runCleanup(Reason.EXIT);
|
|
787
|
-
});
|
|
788
817
|
}
|
|
789
818
|
|
|
790
819
|
/** Controls when a registered cleanup callback participates in cleanup passes. */
|
|
@@ -811,8 +840,7 @@ export interface CleanupRegistrationOptions {
|
|
|
811
840
|
}
|
|
812
841
|
|
|
813
842
|
/**
|
|
814
|
-
* Registers a cleanup callback for
|
|
815
|
-
* repeatable manual cleanup passes.
|
|
843
|
+
* Registers a cleanup callback for manual passes and, when installed, process shutdown.
|
|
816
844
|
*
|
|
817
845
|
* Registrations persist across keep-alive {@link cleanup} passes and run at
|
|
818
846
|
* most once per pass. Set `exitOnly` for resources the continuing process still
|
|
@@ -849,7 +877,7 @@ export function register(
|
|
|
849
877
|
const pending = invokeCleanup(registration, reason, keepAlive, cleanupPass);
|
|
850
878
|
if (!pending) return;
|
|
851
879
|
const tracked = pending.catch(error => {
|
|
852
|
-
const err =
|
|
880
|
+
const err = toError(error);
|
|
853
881
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
854
882
|
});
|
|
855
883
|
// Join the active pass so cleanup()/signal exits await it; after a
|
|
@@ -859,7 +887,7 @@ export function register(
|
|
|
859
887
|
activePassDeadline?.extend(tracked, registration.deadlineMs, registration.waitMessage);
|
|
860
888
|
}
|
|
861
889
|
} catch (error) {
|
|
862
|
-
const err =
|
|
890
|
+
const err = toError(error);
|
|
863
891
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
864
892
|
}
|
|
865
893
|
};
|
package/src/runtime-install.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as Module from "node:module";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { withFileLock } from "./file-lock";
|
|
6
6
|
import { isEexist, isEnoent } from "./fs-error";
|
|
7
|
+
import { isRecord } from "./type-guards";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* On-demand runtime dependency support for native-heavy optional packages
|
|
@@ -32,10 +33,6 @@ const RUNTIME_CONDITIONS: Record<string, true> = { node: true, require: true, de
|
|
|
32
33
|
/** Extension probes appended to a `main`/`exports` target that lacks one. */
|
|
33
34
|
const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"];
|
|
34
35
|
|
|
35
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
36
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
36
|
/**
|
|
40
37
|
* Walk a conditional `exports` target (string, array of fallbacks, or a
|
|
41
38
|
* condition object) and return the first relative path that matches a runtime
|
package/src/sqlite.ts
CHANGED
|
@@ -2,15 +2,43 @@
|
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { installSqliteLockShim } from "@linxiraos/pi-natives/sqlite-locks";
|
|
5
6
|
import { getDbBusyTimeoutMs } from "./env";
|
|
6
7
|
import { withFileLockSync } from "./file-lock";
|
|
7
8
|
import { isEnoent } from "./fs-error";
|
|
9
|
+
import { toError } from "./type-guards";
|
|
8
10
|
import * as logger from "./logger";
|
|
9
11
|
|
|
10
12
|
const BUSY_MAX_ATTEMPTS = 4;
|
|
11
13
|
const BUSY_BASE_DELAY_MS = 100;
|
|
12
14
|
const SQLITE_STORE_SUFFIXES = ["-wal", "-shm", "-journal", ""];
|
|
13
15
|
|
|
16
|
+
let sqliteLockShimInstalled: boolean | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Moves this process's SQLite locks onto open-file-description locks (Linux), once.
|
|
20
|
+
*
|
|
21
|
+
* SQLite's POSIX record locks belong to the process, so any close of another
|
|
22
|
+
* descriptor of a database or its `-shm` released them, including Bun's own file
|
|
23
|
+
* APIs and the bash tool's builtins; a peer omp then unlinked the WAL under the live
|
|
24
|
+
* connection (issue #15586). Call before the process opens its first database: a
|
|
25
|
+
* connection opened earlier keeps POSIX locks. Returns whether the shim is active; a
|
|
26
|
+
* Linux build where it cannot be installed (Bun's SQLite layout changed) logs a warning
|
|
27
|
+
* once, and callers with a UI surface it.
|
|
28
|
+
*/
|
|
29
|
+
export function ensureSqliteLockShim(): boolean {
|
|
30
|
+
if (sqliteLockShimInstalled === undefined) {
|
|
31
|
+
sqliteLockShimInstalled = installSqliteLockShim();
|
|
32
|
+
if (!sqliteLockShimInstalled && process.platform === "linux") {
|
|
33
|
+
logger.warn("SQLite OFD lock shim unavailable; in-process reads of a live store can release its locks", {
|
|
34
|
+
bun: Bun.version,
|
|
35
|
+
issue: "https://github.com/can1357/oh-my-pi/issues/15586",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return sqliteLockShimInstalled;
|
|
40
|
+
}
|
|
41
|
+
|
|
14
42
|
/**
|
|
15
43
|
* A store's identity as `dev:ino:birthtimeMs`, `null` when the path holds no
|
|
16
44
|
* file, or `undefined` when the stat itself failed and no conclusion is safe.
|
|
@@ -100,7 +128,7 @@ function closeFailedDatabase(db: Database | undefined, error: unknown, identity:
|
|
|
100
128
|
try {
|
|
101
129
|
db?.close();
|
|
102
130
|
} catch (closeError) {
|
|
103
|
-
const original =
|
|
131
|
+
const original = toError(error);
|
|
104
132
|
const detail = closeError instanceof Error ? closeError.message : String(closeError);
|
|
105
133
|
original.message += `; failed to close the SQLite handle: ${detail}`;
|
|
106
134
|
throw new SqliteAttemptFailure(original, identity, { canRecover: false });
|
|
@@ -152,6 +180,7 @@ async function openWithBusyRetries<T>(
|
|
|
152
180
|
let db: Database | undefined;
|
|
153
181
|
const identity = sqliteFileIdentity(dbPath);
|
|
154
182
|
try {
|
|
183
|
+
ensureSqliteLockShim();
|
|
155
184
|
db = new Database(dbPath);
|
|
156
185
|
// WAL recovery can bypass the busy handler; both it and retries are needed (#2421).
|
|
157
186
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
@@ -175,6 +204,7 @@ function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: S
|
|
|
175
204
|
let db: Database | undefined;
|
|
176
205
|
const identity = sqliteFileIdentity(dbPath);
|
|
177
206
|
try {
|
|
207
|
+
ensureSqliteLockShim();
|
|
178
208
|
db = new Database(dbPath);
|
|
179
209
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
180
210
|
return initialize(db);
|
|
@@ -333,7 +363,7 @@ export function openSqliteDatabaseSync<T>(
|
|
|
333
363
|
|
|
334
364
|
/** Adds the failing store's path to an error without losing SQLite result codes or its original stack. */
|
|
335
365
|
export function annotateSqliteError(error: unknown, dbPath: string): Error {
|
|
336
|
-
const annotated =
|
|
366
|
+
const annotated = toError(error);
|
|
337
367
|
annotated.message = `Database "${dbPath}": ${annotated.message}`;
|
|
338
368
|
return annotated;
|
|
339
369
|
}
|
package/dist/types/fs-open.d.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `fs.openSync` with close-on-exec, matching Node's default.
|
|
3
|
-
*
|
|
4
|
-
* libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
|
|
5
|
-
* dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
|
|
6
|
-
* `bun@>=1.4` pin), and descriptors it returns survive
|
|
7
|
-
* into any child spawned through a path that does not close strays itself — the
|
|
8
|
-
* bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
|
|
9
|
-
* this for every descriptor kept open past such a spawn (session transcripts,
|
|
10
|
-
* logs, spools); a child must never hold a writable handle to them (#13224).
|
|
11
|
-
*
|
|
12
|
-
* Flags are numeric only: the bit cannot be OR-ed into `"a"`/`"w+"` strings.
|
|
13
|
-
*
|
|
14
|
-
* @example
|
|
15
|
-
* const fd = openCloexecSync(logPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
|
|
16
|
-
*/
|
|
17
|
-
export declare function openCloexecSync(filePath: string, flags: number, mode?: number): number;
|
package/src/fs-open.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/** Close-on-exec file opens for descriptors held past a child spawn. */
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* `O_CLOEXEC`, which neither Node nor Bun exposes in `fs.constants`.
|
|
6
|
-
*
|
|
7
|
-
* Kernel ABI values: `0o2000000` on Linux, `0x0100_0000` on Darwin. Windows has
|
|
8
|
-
* no `exec` and its CRT handles are inherited only when a spawn explicitly asks
|
|
9
|
-
* for it, so the bit is 0 there — as it is on any other platform, where adding
|
|
10
|
-
* an unknown flag bit would be worse than leaving the descriptor inheritable.
|
|
11
|
-
*/
|
|
12
|
-
const O_CLOEXEC = process.platform === "linux" ? 0o2000000 : process.platform === "darwin" ? 0x0100_0000 : 0;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* `fs.openSync` with close-on-exec, matching Node's default.
|
|
16
|
-
*
|
|
17
|
-
* libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
|
|
18
|
-
* dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
|
|
19
|
-
* `bun@>=1.4` pin), and descriptors it returns survive
|
|
20
|
-
* into any child spawned through a path that does not close strays itself — the
|
|
21
|
-
* bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
|
|
22
|
-
* this for every descriptor kept open past such a spawn (session transcripts,
|
|
23
|
-
* logs, spools); a child must never hold a writable handle to them (#13224).
|
|
24
|
-
*
|
|
25
|
-
* Flags are numeric only: the bit cannot be OR-ed into `"a"`/`"w+"` strings.
|
|
26
|
-
*
|
|
27
|
-
* @example
|
|
28
|
-
* const fd = openCloexecSync(logPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
|
|
29
|
-
*/
|
|
30
|
-
export function openCloexecSync(filePath: string, flags: number, mode?: number): number {
|
|
31
|
-
return fs.openSync(filePath, flags | O_CLOEXEC, mode);
|
|
32
|
-
}
|