@oh-my-pi/pi-utils 18.3.5 → 18.4.1
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 +7 -0
- package/dist/types/dirs.d.ts +7 -2
- package/dist/types/logger/rotating-file.d.ts +9 -0
- package/dist/types/postmortem.d.ts +9 -5
- package/dist/types/stderr-guard.d.ts +23 -4
- package/package.json +2 -2
- package/src/dirs.ts +27 -7
- package/src/logger/rotating-file.ts +16 -0
- package/src/logger.ts +6 -7
- package/src/postmortem.ts +18 -6
- package/src/sqlite.ts +29 -2
- package/src/stderr-guard.ts +53 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.1] - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed SQLite stores opened with `recoverCorruption` crashing on a corrupt file instead of being preserved and recreated when the corruption surfaced as a different initialization error such as `no such table` ([#13530](https://github.com/can1357/oh-my-pi/pull/13530) by [@Hunter-124](https://github.com/Hunter-124))
|
|
10
|
+
- Fixed raw stderr output staying on the previous day's log file after the log sink rotates at local midnight ([#13003](https://github.com/can1357/oh-my-pi/issues/13003)).
|
|
11
|
+
|
|
5
12
|
## [18.3.1] - 2026-09-25
|
|
6
13
|
|
|
7
14
|
### Fixed
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -228,6 +228,11 @@ export declare function getGithubCacheDbPath(): string;
|
|
|
228
228
|
* Honors `OMP_COMMIT_CACHE_DB` so tests and operators can isolate the cache.
|
|
229
229
|
*/
|
|
230
230
|
export declare function getCommitCacheDbPath(): string;
|
|
231
|
+
/**
|
|
232
|
+
* Get the judgment answer cache database path (~/.omp/cache/judgment-cache.db).
|
|
233
|
+
* Honors `OMP_JUDGMENT_CACHE_DB` so tests and operators can isolate the cache.
|
|
234
|
+
*/
|
|
235
|
+
export declare function getJudgmentCacheDbPath(): string;
|
|
231
236
|
/** Get the legacy Pi extension parse cache database path. */
|
|
232
237
|
export declare function getLegacyPiExtensionCacheDbPath(): string;
|
|
233
238
|
/**
|
|
@@ -270,8 +275,8 @@ export declare function getModelDbPath(agentDir?: string): string;
|
|
|
270
275
|
export declare function getTinyModelsCacheDir(agentDir?: string): string;
|
|
271
276
|
/** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
|
|
272
277
|
export declare function getDocumentConversionCacheDir(agentDir?: string): string;
|
|
273
|
-
/** Get the
|
|
274
|
-
export declare function
|
|
278
|
+
/** Get the composer speculative cache database (~/.omp/agent/cache/composer.db; XDG default: $XDG_CACHE_HOME/omp/cache/composer.db). */
|
|
279
|
+
export declare function getComposerCacheDbPath(agentDir?: string): string;
|
|
275
280
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
276
281
|
export declare function getSessionsDir(agentDir?: string): string;
|
|
277
282
|
/** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
|
|
@@ -6,6 +6,15 @@ export interface RotatingFileOptions {
|
|
|
6
6
|
readonly auditFile: string;
|
|
7
7
|
readonly maxBytes: number;
|
|
8
8
|
readonly maxFiles: number;
|
|
9
|
+
/**
|
|
10
|
+
* Called with the new active file path whenever the sink opens a different
|
|
11
|
+
* file — construction, local-day rotation, and size rotation alike.
|
|
12
|
+
*
|
|
13
|
+
* Consumers that hold their own descriptor on the active log (the macOS
|
|
14
|
+
* stderr guard dup2s it onto fd 2) use this to follow the sink instead of
|
|
15
|
+
* staying pinned to a file the sink later prunes.
|
|
16
|
+
*/
|
|
17
|
+
readonly onRotate?: (filePath: string) => void;
|
|
9
18
|
}
|
|
10
19
|
/** Synchronous append sink with local-day and size rotation plus bounded retention. */
|
|
11
20
|
export declare class RotatingFileSink {
|
|
@@ -126,13 +126,17 @@ export declare function registerStdioDisconnectHandling(): () => void;
|
|
|
126
126
|
* `--bytecode` builds. When a one-shot command awaits a promise that never
|
|
127
127
|
* settles and holds no live handle, the loop drains and Bun exits 0 without
|
|
128
128
|
* output: an unfinished command reported as success. `beforeExit` fires in
|
|
129
|
-
*
|
|
129
|
+
* that state; explicit exits (`process.exit`, {@link quit},
|
|
130
130
|
* {@link exitProcess}, the signal handlers) never emit it.
|
|
131
131
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
132
|
+
* `beforeExit` alone is not the verdict: on Windows Bun emits it while I/O is
|
|
133
|
+
* still in flight and then keeps running the loop, so a command that goes on
|
|
134
|
+
* to complete must keep its own exit code (#13470). The verdict is taken at
|
|
135
|
+
* `exit` instead, and only when the last `beforeExit` was final: an unref'd
|
|
136
|
+
* timer armed there runs only if the loop turns again, clearing the mark
|
|
137
|
+
* before a later explicit exit. Any later drain emits `beforeExit` again and
|
|
138
|
+
* re-arms it, so other `beforeExit` listeners (LSP shutdown) still run before
|
|
139
|
+
* the process exits 1.
|
|
136
140
|
*
|
|
137
141
|
* `describe` names what was running (e.g. the resolved subcommand) at report
|
|
138
142
|
* time; it must return only a command name, never user arguments.
|
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
export interface SuppressTerminalStderrOptions {
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Redirect target path; defaults to the log file the rotating sink last
|
|
4
|
+
* reported (see {@link setStderrRedirectTarget}), then today's omp log
|
|
5
|
+
* file, then /dev/null.
|
|
6
|
+
*/
|
|
3
7
|
redirectPath?: string;
|
|
4
|
-
/** Bypass the
|
|
8
|
+
/** Bypass the same-terminal gate. Tests only. */
|
|
5
9
|
force?: boolean;
|
|
6
10
|
}
|
|
7
11
|
/**
|
|
8
12
|
* Redirect fd 2 away from the terminal while the TUI owns the viewport.
|
|
9
13
|
* Returns true when suppression is (already) active. No-op — returning
|
|
10
|
-
* false —
|
|
11
|
-
*
|
|
14
|
+
* false — when stderr does not target the stdout terminal or when libc fd
|
|
15
|
+
* operations are unavailable.
|
|
12
16
|
*/
|
|
13
17
|
export declare function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Point fd 2 at `logPath`, the log file the rotating sink is currently
|
|
20
|
+
* writing.
|
|
21
|
+
*
|
|
22
|
+
* The logger calls this on every sink rotation. Without it fd 2 keeps the
|
|
23
|
+
* descriptor opened at TUI start, so after local midnight raw stderr — this
|
|
24
|
+
* process's and every child that inherited fd 2 — keeps landing in the
|
|
25
|
+
* previous day's file, which the sink eventually prunes out from under it.
|
|
26
|
+
*
|
|
27
|
+
* The path is recorded even while suppression is inactive, so a later
|
|
28
|
+
* {@link suppressTerminalStderr} redirects to the live file instead of
|
|
29
|
+
* re-deriving a dated name. Best-effort: if the new file cannot be opened,
|
|
30
|
+
* fd 2 keeps its current target.
|
|
31
|
+
*/
|
|
32
|
+
export declare function setStderrRedirectTarget(logPath: string): void;
|
|
14
33
|
/**
|
|
15
34
|
* Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
|
|
16
35
|
* no-op when suppression is not active. Called at every terminal-ownership
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.4.1",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@oh-my-pi/pi-natives": "18.
|
|
60
|
+
"@oh-my-pi/pi-natives": "18.4.1"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.3.14"
|
package/src/dirs.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
|
+
import { expandWindowsLongPath } from "@oh-my-pi/pi-natives/path";
|
|
17
18
|
import { engines, version } from "../package.json" with { type: "json" };
|
|
18
19
|
import { isEnoent, isEnotdir } from "./fs-error";
|
|
19
20
|
|
|
@@ -154,6 +155,11 @@ function standardizeMacOSPath(p: string): string {
|
|
|
154
155
|
return p;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/** Keep the current directory's spelling while expanding Windows 8.3 aliases. */
|
|
159
|
+
function standardizeProjectPath(p: string): string {
|
|
160
|
+
return process.platform === "win32" ? expandWindowsLongPath(p) : standardizeMacOSPath(p);
|
|
161
|
+
}
|
|
162
|
+
|
|
157
163
|
export function resolveEquivalentPath(inputPath: string): string {
|
|
158
164
|
const resolvedPath = path.resolve(inputPath);
|
|
159
165
|
try {
|
|
@@ -200,29 +206,33 @@ let projectDir: string | undefined;
|
|
|
200
206
|
/** Get the project directory. */
|
|
201
207
|
export function getProjectDir(): string {
|
|
202
208
|
if (projectDir === undefined) {
|
|
209
|
+
let cwd: string | undefined;
|
|
203
210
|
try {
|
|
204
|
-
|
|
211
|
+
cwd = process.cwd();
|
|
205
212
|
} catch {
|
|
206
213
|
const candidates = [process.env.PWD, os.homedir(), os.tmpdir()];
|
|
207
214
|
for (const candidate of candidates) {
|
|
208
215
|
if (!candidate || !path.isAbsolute(candidate)) continue;
|
|
209
216
|
try {
|
|
210
217
|
process.chdir(candidate);
|
|
211
|
-
|
|
218
|
+
cwd = candidate;
|
|
212
219
|
break;
|
|
213
220
|
} catch {}
|
|
214
221
|
}
|
|
215
|
-
if (
|
|
222
|
+
if (cwd === undefined) {
|
|
216
223
|
throw new Error("Unable to determine an accessible working directory");
|
|
217
224
|
}
|
|
218
225
|
}
|
|
226
|
+
// Normalize outside the fallback: a native-addon failure is not an inaccessible cwd,
|
|
227
|
+
// and must surface as itself instead of relocating the process.
|
|
228
|
+
projectDir = standardizeProjectPath(cwd);
|
|
219
229
|
}
|
|
220
230
|
return projectDir;
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
/** Set the project directory. */
|
|
224
234
|
export function setProjectDir(dir: string): void {
|
|
225
|
-
const resolved =
|
|
235
|
+
const resolved = standardizeProjectPath(path.resolve(dir));
|
|
226
236
|
process.chdir(resolved);
|
|
227
237
|
projectDir = resolved;
|
|
228
238
|
}
|
|
@@ -787,6 +797,16 @@ export function getCommitCacheDbPath(): string {
|
|
|
787
797
|
return dirs.rootSubdir(path.join("cache", "commit-inference.db"), "cache");
|
|
788
798
|
}
|
|
789
799
|
|
|
800
|
+
/**
|
|
801
|
+
* Get the judgment answer cache database path (~/.omp/cache/judgment-cache.db).
|
|
802
|
+
* Honors `OMP_JUDGMENT_CACHE_DB` so tests and operators can isolate the cache.
|
|
803
|
+
*/
|
|
804
|
+
export function getJudgmentCacheDbPath(): string {
|
|
805
|
+
const override = process.env.OMP_JUDGMENT_CACHE_DB;
|
|
806
|
+
if (override) return override;
|
|
807
|
+
return dirs.rootSubdir(path.join("cache", "judgment-cache.db"), "cache");
|
|
808
|
+
}
|
|
809
|
+
|
|
790
810
|
/** Get the legacy Pi extension parse cache database path. */
|
|
791
811
|
export function getLegacyPiExtensionCacheDbPath(): string {
|
|
792
812
|
return dirs.rootSubdir(path.join("cache", "legacy-pi-extension-cache.db"), "cache");
|
|
@@ -891,9 +911,9 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
|
|
|
891
911
|
export function getDocumentConversionCacheDir(agentDir?: string): string {
|
|
892
912
|
return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
|
|
893
913
|
}
|
|
894
|
-
/** Get the
|
|
895
|
-
export function
|
|
896
|
-
return dirs.agentSubdir(agentDir, path.join("cache", "composer"), "cache");
|
|
914
|
+
/** Get the composer speculative cache database (~/.omp/agent/cache/composer.db; XDG default: $XDG_CACHE_HOME/omp/cache/composer.db). */
|
|
915
|
+
export function getComposerCacheDbPath(agentDir?: string): string {
|
|
916
|
+
return dirs.agentSubdir(agentDir, path.join("cache", "composer.db"), "cache");
|
|
897
917
|
}
|
|
898
918
|
|
|
899
919
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
@@ -26,6 +26,15 @@ export interface RotatingFileOptions {
|
|
|
26
26
|
readonly auditFile: string;
|
|
27
27
|
readonly maxBytes: number;
|
|
28
28
|
readonly maxFiles: number;
|
|
29
|
+
/**
|
|
30
|
+
* Called with the new active file path whenever the sink opens a different
|
|
31
|
+
* file — construction, local-day rotation, and size rotation alike.
|
|
32
|
+
*
|
|
33
|
+
* Consumers that hold their own descriptor on the active log (the macOS
|
|
34
|
+
* stderr guard dup2s it onto fd 2) use this to follow the sink instead of
|
|
35
|
+
* staying pinned to a file the sink later prunes.
|
|
36
|
+
*/
|
|
37
|
+
readonly onRotate?: (filePath: string) => void;
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
function isAuditEntry(value: unknown): value is AuditEntry {
|
|
@@ -42,6 +51,7 @@ export class RotatingFileSink {
|
|
|
42
51
|
readonly #auditFile: string;
|
|
43
52
|
readonly #maxBytes: number;
|
|
44
53
|
readonly #maxFiles: number;
|
|
54
|
+
readonly #onRotate: ((filePath: string) => void) | undefined;
|
|
45
55
|
#files: AuditEntry[];
|
|
46
56
|
#activeDay: string | undefined;
|
|
47
57
|
#activeIndex = 0;
|
|
@@ -62,6 +72,7 @@ export class RotatingFileSink {
|
|
|
62
72
|
this.#auditFile = options.auditFile;
|
|
63
73
|
this.#maxBytes = options.maxBytes;
|
|
64
74
|
this.#maxFiles = options.maxFiles;
|
|
75
|
+
this.#onRotate = options.onRotate;
|
|
65
76
|
this.#files = this.#readAudit();
|
|
66
77
|
const now = new Date();
|
|
67
78
|
this.#selectFile(localDay(now));
|
|
@@ -75,6 +86,11 @@ export class RotatingFileSink {
|
|
|
75
86
|
#openFd(filePath: string): void {
|
|
76
87
|
this.#closeFd();
|
|
77
88
|
this.#fd = openCloexecSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
|
|
89
|
+
try {
|
|
90
|
+
this.#onRotate?.(filePath);
|
|
91
|
+
} catch {
|
|
92
|
+
// A rotation observer must never break logging.
|
|
93
|
+
}
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
#closeFd(): void {
|
package/src/logger.ts
CHANGED
|
@@ -14,8 +14,9 @@ import * as fs from "node:fs";
|
|
|
14
14
|
import * as os from "node:os";
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { isPromise } from "node:util/types";
|
|
17
|
-
import { getLogsDir } from "./dirs";
|
|
17
|
+
import { getLogsDir, localDay } from "./dirs";
|
|
18
18
|
import { RotatingFileSink } from "./logger/rotating-file";
|
|
19
|
+
import { setStderrRedirectTarget } from "./stderr-guard";
|
|
19
20
|
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
20
21
|
/** Severity names accepted by the centralized logger. */
|
|
21
22
|
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
@@ -82,14 +83,10 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
82
83
|
return;
|
|
83
84
|
}
|
|
84
85
|
const current = new Date();
|
|
85
|
-
const currentDate =
|
|
86
|
-
`${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-` +
|
|
87
|
-
String(current.getDate()).padStart(2, "0");
|
|
86
|
+
const currentDate = localDay(current);
|
|
88
87
|
const cutoff = new Date(current);
|
|
89
88
|
cutoff.setDate(cutoff.getDate() - (RETAINED_STALE_LOG_DAYS - 1));
|
|
90
|
-
const cutoffDate =
|
|
91
|
-
`${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-` +
|
|
92
|
-
String(cutoff.getDate()).padStart(2, "0");
|
|
89
|
+
const cutoffDate = localDay(cutoff);
|
|
93
90
|
|
|
94
91
|
const staleLogsByProcessDay = new Map<string, Array<{ path: string; rollover: number }>>();
|
|
95
92
|
for (const entry of entries) {
|
|
@@ -258,6 +255,8 @@ function makeFileTransport(dir?: string): RotatingFileSink {
|
|
|
258
255
|
maxBytes: 10 * 1024 * 1024,
|
|
259
256
|
maxFiles: 5,
|
|
260
257
|
auditFile: path.join(logsDir, `.omp.${process.pid}-audit.json`),
|
|
258
|
+
// Keep the stderr guard's fd 2 on the file this sink is writing.
|
|
259
|
+
onRotate: setStderrRedirectTarget,
|
|
261
260
|
});
|
|
262
261
|
}
|
|
263
262
|
|
package/src/postmortem.ts
CHANGED
|
@@ -376,26 +376,38 @@ export function registerStdioDisconnectHandling(): () => void {
|
|
|
376
376
|
* `--bytecode` builds. When a one-shot command awaits a promise that never
|
|
377
377
|
* settles and holds no live handle, the loop drains and Bun exits 0 without
|
|
378
378
|
* output: an unfinished command reported as success. `beforeExit` fires in
|
|
379
|
-
*
|
|
379
|
+
* that state; explicit exits (`process.exit`, {@link quit},
|
|
380
380
|
* {@link exitProcess}, the signal handlers) never emit it.
|
|
381
381
|
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
382
|
+
* `beforeExit` alone is not the verdict: on Windows Bun emits it while I/O is
|
|
383
|
+
* still in flight and then keeps running the loop, so a command that goes on
|
|
384
|
+
* to complete must keep its own exit code (#13470). The verdict is taken at
|
|
385
|
+
* `exit` instead, and only when the last `beforeExit` was final: an unref'd
|
|
386
|
+
* timer armed there runs only if the loop turns again, clearing the mark
|
|
387
|
+
* before a later explicit exit. Any later drain emits `beforeExit` again and
|
|
388
|
+
* re-arms it, so other `beforeExit` listeners (LSP shutdown) still run before
|
|
389
|
+
* the process exits 1.
|
|
386
390
|
*
|
|
387
391
|
* `describe` names what was running (e.g. the resolved subcommand) at report
|
|
388
392
|
* time; it must return only a command name, never user arguments.
|
|
389
393
|
*/
|
|
390
394
|
export function reportUnsettledEntry(work: Promise<unknown>, describe?: () => string | undefined): void {
|
|
391
395
|
let pending = true;
|
|
396
|
+
let drained = false;
|
|
392
397
|
const settled = (): void => {
|
|
393
398
|
pending = false;
|
|
394
399
|
};
|
|
395
400
|
// Observe both outcomes without claiming the rejection: the caller's `.catch` still owns it.
|
|
396
401
|
void work.then(settled, settled);
|
|
397
|
-
process.
|
|
402
|
+
process.on("beforeExit", () => {
|
|
398
403
|
if (!pending) return;
|
|
404
|
+
drained = true;
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
drained = false;
|
|
407
|
+
}, 0).unref();
|
|
408
|
+
});
|
|
409
|
+
process.once("exit", () => {
|
|
410
|
+
if (!pending || !drained) return;
|
|
399
411
|
const command = describe?.();
|
|
400
412
|
const subject = command ? `\`${APP_NAME} ${command}\`` : "command";
|
|
401
413
|
const message = `${subject} ended before completing: the event loop drained while it was still pending (rerun with PI_DEBUG_STARTUP=1 to see the last phase reached)`;
|
package/src/sqlite.ts
CHANGED
|
@@ -58,6 +58,31 @@ export interface SqliteOpenOptions {
|
|
|
58
58
|
onCorruptionPreserved?: (backupPath: string, error: unknown) => void;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Bun's multi-statement `db.run()` reports only the final statement's step
|
|
63
|
+
* error (oven-sh/bun#37415), so a corrupt page hit mid-script can resurface as
|
|
64
|
+
* an unrelated failure such as "no such table". When an initializer fails for
|
|
65
|
+
* any other reason, a `quick_check` on the still-open handle decides whether
|
|
66
|
+
* the store itself is damaged. Runs only on the failure path.
|
|
67
|
+
*/
|
|
68
|
+
function revealHiddenCorruption(db: Database | undefined, error: unknown): unknown {
|
|
69
|
+
if (!db || isSqliteCorruptionError(error) || isSqliteBusyError(error)) return error;
|
|
70
|
+
let detail: string;
|
|
71
|
+
let code: unknown = "SQLITE_CORRUPT";
|
|
72
|
+
let errno: unknown = 11;
|
|
73
|
+
try {
|
|
74
|
+
const rows = db.query<{ quick_check: string }, []>("PRAGMA quick_check(1)").all();
|
|
75
|
+
if (rows[0]?.quick_check === "ok") return error;
|
|
76
|
+
detail = `database disk image is malformed (${rows[0]?.quick_check})`;
|
|
77
|
+
} catch (probeError) {
|
|
78
|
+
if (!isSqliteCorruptionError(probeError)) return error;
|
|
79
|
+
detail = probeError instanceof Error ? probeError.message : String(probeError);
|
|
80
|
+
({ code, errno } = probeError as { code: unknown; errno?: unknown });
|
|
81
|
+
}
|
|
82
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return Object.assign(new Error(`${detail}; initialization failed: ${original}`, { cause: error }), { code, errno });
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
async function openWithBusyRetries<T>(
|
|
62
87
|
dbPath: string,
|
|
63
88
|
initialize: (db: Database) => T | Promise<T>,
|
|
@@ -71,7 +96,8 @@ async function openWithBusyRetries<T>(
|
|
|
71
96
|
// WAL recovery can bypass the busy handler; both it and retries are needed (#2421).
|
|
72
97
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
73
98
|
return await initialize(db);
|
|
74
|
-
} catch (
|
|
99
|
+
} catch (caught) {
|
|
100
|
+
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
75
101
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
76
102
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
77
103
|
}
|
|
@@ -91,7 +117,8 @@ function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: S
|
|
|
91
117
|
db = new Database(dbPath);
|
|
92
118
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
93
119
|
return initialize(db);
|
|
94
|
-
} catch (
|
|
120
|
+
} catch (caught) {
|
|
121
|
+
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
95
122
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
96
123
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
97
124
|
}
|
package/src/stderr-guard.ts
CHANGED
|
@@ -2,20 +2,17 @@
|
|
|
2
2
|
* Terminal stderr guard: keeps unmanaged fd-2 writes off the terminal while a
|
|
3
3
|
* TUI owns the viewport.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* bypass the renderer and paint straight into the viewport. Stripping the
|
|
10
|
-
* MallocStackLogging* env vars (cli.ts) only protects child processes; it
|
|
11
|
-
* cannot stop libmalloc inside THIS process from logging.
|
|
5
|
+
* Native runtimes and libraries can write directly to file descriptor 2 at
|
|
6
|
+
* arbitrary times. Examples include macOS libmalloc diagnostics and Bun HTTP
|
|
7
|
+
* decompression diagnostics on Linux. Those bytes bypass the renderer and
|
|
8
|
+
* paint straight into the viewport.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
10
|
+
* While the TUI owns the terminal, dup fd 2 aside and dup2 a redirect target
|
|
11
|
+
* over it; restore the saved fd whenever terminal ownership is released
|
|
12
|
+
* (external editor, Ctrl+Z suspend, shutdown, crash restore). This mirrors
|
|
13
|
+
* openai/codex#24459, but redirects to the omp log file instead of /dev/null
|
|
14
|
+
* so diagnostics stay greppable and Bun native-crash reports (which abort
|
|
15
|
+
* before any JS cleanup can restore fd 2) are preserved.
|
|
19
16
|
*
|
|
20
17
|
* Only dup/dup2 go through bun:ffi. fcntl is deliberately avoided: it is
|
|
21
18
|
* variadic, and the arm64-darwin ABI passes variadic arguments on the stack,
|
|
@@ -79,35 +76,41 @@ function stderrSharesStdoutTerminal(): boolean {
|
|
|
79
76
|
/** Saved dup of the real stderr while suppression is active, else null. */
|
|
80
77
|
let savedStderrFd: number | null = null;
|
|
81
78
|
|
|
79
|
+
/** Newest log file the guard knows about; see {@link setStderrRedirectTarget}. */
|
|
80
|
+
let redirectTarget: string | null = null;
|
|
81
|
+
|
|
82
82
|
export interface SuppressTerminalStderrOptions {
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Redirect target path; defaults to the log file the rotating sink last
|
|
85
|
+
* reported (see {@link setStderrRedirectTarget}), then today's omp log
|
|
86
|
+
* file, then /dev/null.
|
|
87
|
+
*/
|
|
84
88
|
redirectPath?: string;
|
|
85
|
-
/** Bypass the
|
|
89
|
+
/** Bypass the same-terminal gate. Tests only. */
|
|
86
90
|
force?: boolean;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/**
|
|
90
94
|
* Redirect fd 2 away from the terminal while the TUI owns the viewport.
|
|
91
95
|
* Returns true when suppression is (already) active. No-op — returning
|
|
92
|
-
* false —
|
|
93
|
-
*
|
|
96
|
+
* false — when stderr does not target the stdout terminal or when libc fd
|
|
97
|
+
* operations are unavailable.
|
|
94
98
|
*/
|
|
95
99
|
export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean {
|
|
96
100
|
if (savedStderrFd !== null) return true;
|
|
97
|
-
if (!options?.force &&
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
101
|
+
if (!options?.force && !stderrSharesStdoutTerminal()) return false;
|
|
100
102
|
const libc = libcFdOps();
|
|
101
103
|
if (!libc) return false;
|
|
102
104
|
|
|
103
105
|
let redirectFd: number;
|
|
104
106
|
try {
|
|
105
|
-
const redirectPath = options?.redirectPath ?? getLogPath();
|
|
107
|
+
const redirectPath = options?.redirectPath ?? redirectTarget ?? getLogPath();
|
|
106
108
|
// getLogsDir() only computes the path; the logger creates it lazily, so
|
|
107
109
|
// on a fresh profile ~/.omp/logs may not exist yet. Create it here so
|
|
108
110
|
// diagnostics land in the log instead of falling through to /dev/null.
|
|
109
111
|
fs.mkdirSync(path.dirname(redirectPath), { recursive: true });
|
|
110
112
|
redirectFd = fs.openSync(redirectPath, "a");
|
|
113
|
+
redirectTarget = redirectPath;
|
|
111
114
|
} catch {
|
|
112
115
|
try {
|
|
113
116
|
redirectFd = fs.openSync("/dev/null", "w");
|
|
@@ -131,6 +134,35 @@ export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions):
|
|
|
131
134
|
return true;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Point fd 2 at `logPath`, the log file the rotating sink is currently
|
|
139
|
+
* writing.
|
|
140
|
+
*
|
|
141
|
+
* The logger calls this on every sink rotation. Without it fd 2 keeps the
|
|
142
|
+
* descriptor opened at TUI start, so after local midnight raw stderr — this
|
|
143
|
+
* process's and every child that inherited fd 2 — keeps landing in the
|
|
144
|
+
* previous day's file, which the sink eventually prunes out from under it.
|
|
145
|
+
*
|
|
146
|
+
* The path is recorded even while suppression is inactive, so a later
|
|
147
|
+
* {@link suppressTerminalStderr} redirects to the live file instead of
|
|
148
|
+
* re-deriving a dated name. Best-effort: if the new file cannot be opened,
|
|
149
|
+
* fd 2 keeps its current target.
|
|
150
|
+
*/
|
|
151
|
+
export function setStderrRedirectTarget(logPath: string): void {
|
|
152
|
+
redirectTarget = logPath;
|
|
153
|
+
if (savedStderrFd === null) return;
|
|
154
|
+
const libc = libcFdOps();
|
|
155
|
+
if (!libc) return;
|
|
156
|
+
let redirectFd: number;
|
|
157
|
+
try {
|
|
158
|
+
redirectFd = fs.openSync(logPath, "a");
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
libc.dup2(redirectFd, STDERR_FILENO);
|
|
163
|
+
fs.closeSync(redirectFd);
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
/**
|
|
135
167
|
* Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
|
|
136
168
|
* no-op when suppression is not active. Called at every terminal-ownership
|