@oh-my-pi/pi-utils 18.14.2 → 18.16.0
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/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/held-file.d.ts +5 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/pid.d.ts +8 -0
- package/dist/types/postmortem.d.ts +3 -2
- package/dist/types/sqlite.d.ts +12 -0
- package/package.json +3 -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/held-file.ts +22 -0
- package/src/index.ts +2 -0
- package/src/logger.ts +2 -10
- package/src/pid.ts +16 -0
- package/src/postmortem.ts +61 -33
- package/src/runtime-install.ts +1 -4
- package/src/sqlite.ts +32 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.16.0] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `isPidAlive` (signal-0 liveness probe) and `asStrictBytes` (a `Uint8Array` narrowed to its own `ArrayBuffer` for WebCrypto)
|
|
10
|
+
- Added `ensureSqliteLockShim()`, which `openSqliteDatabase`/`openSqliteDatabaseSync` call before opening a store, so on Linux the process's SQLite locks are open-file-description locks that other closes of the file cannot release ([#15586](https://github.com/can1357/oh-my-pi/issues/15586))
|
|
11
|
+
- Added `assertNotHeldOpen(path)` (`@oh-my-pi/pi-utils/held-file`), which throws `EBUSY` for a SQLite database or `-shm` this process holds open ([#15586](https://github.com/can1357/oh-my-pi/issues/15586))
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `acp` runtime validators (`zNewSessionResponse`, `zSessionNotification`, …) now report each failing field's path and reason in `error.issues` instead of a single `Invalid <label>`; `ValidationError` issue paths are typed `PropertyKey[]` ([#17699](https://github.com/can1357/oh-my-pi/pull/17699))
|
|
16
|
+
|
|
17
|
+
## [18.15.0] - 2026-11-01
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Added abortable `sleepUntil` for wall-clock deadline waits that include system sleep, with bounded timer slices to recheck deadlines after resume ([#17606](https://github.com/can1357/oh-my-pi/pull/17606) by [@zhiland](https://github.com/zhiland)).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Importing pi-utils no longer installs process-wide signal and fatal handlers in embedding hosts; exit-time cleanup stays installed ([#17674](https://github.com/can1357/oh-my-pi/issues/17674)).
|
|
26
|
+
|
|
5
27
|
## [18.14.2] - 2026-10-31
|
|
6
28
|
|
|
7
29
|
### Added
|
|
@@ -2,7 +2,7 @@ import type { ForkSessionResponse, LoadSessionResponse, NewSessionResponse, Prom
|
|
|
2
2
|
/** Validation failure compatible with the used Zod error surface. */
|
|
3
3
|
export interface ValidationError {
|
|
4
4
|
issues: Array<{
|
|
5
|
-
path:
|
|
5
|
+
path: PropertyKey[];
|
|
6
6
|
message: string;
|
|
7
7
|
}>;
|
|
8
8
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Undo the delta filter (shared by XZ and 7z) in place. */
|
|
2
|
+
export declare function deltaDecode(bytes: Uint8Array, distance: number): void;
|
|
3
|
+
/** Undo the x86 BCJ filter (shared by XZ and 7z) in place; `startOffset` is the stream position of `bytes[0]`. */
|
|
4
|
+
export declare function x86Decode(bytes: Uint8Array, startOffset: number): void;
|
|
1
5
|
/** Whether bytes begin with the XZ stream-header magic. */
|
|
2
6
|
export declare function isXz(bytes: Uint8Array): boolean;
|
|
3
7
|
/** Decompress all concatenated streams in an XZ container within `maxOutput`. */
|
package/dist/types/ar/lzh.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { FormatReader } from "./types.js";
|
|
2
2
|
/** @internal Decode the static-Huffman LZSS stream shared by LZH and ARJ methods 1-3. */
|
|
3
3
|
export declare function decompressLhStatic(packed: Uint8Array, outSize: number, dictionarySize: number, positionBits: number, positionSymbols: number, label: string): Uint8Array;
|
|
4
|
+
/** MS-DOS packed date/time as UTC milliseconds; `undefined` for 0 or an out-of-range field. */
|
|
5
|
+
export declare function dosTimeToMs(value: number): number | undefined;
|
|
4
6
|
/** Probe whether bytes begin with an LZH/LHA member header. */
|
|
5
7
|
export declare function sniffLzh(bytes: Uint8Array): boolean;
|
|
6
8
|
/** Index an LZH/LHA archive and lazily decode its members from the bounded archive buffer. */
|
package/dist/types/async.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
|
16
16
|
* the unelapsed duration instead of shortening the requested sleep.
|
|
17
17
|
*/
|
|
18
18
|
export declare function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Wait for a wall-clock deadline, including time spent suspended. Some platforms
|
|
21
|
+
* exclude suspend from relative timers, so recheck at least once per second.
|
|
22
|
+
*/
|
|
23
|
+
export declare function sleepUntil(deadlineMs: number, signal?: AbortSignal): Promise<void>;
|
|
19
24
|
/**
|
|
20
25
|
* Wrap a promise with a timeout and optional abort signal.
|
|
21
26
|
* Rejects with the given error or a new error containing the given message if
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow `bytes` to a view over its own whole `ArrayBuffer`, which is what
|
|
3
|
+
* `crypto.subtle.{digest,sign,importKey,encrypt,decrypt}` require under the
|
|
4
|
+
* strict TS DOM typings. Returns `bytes` itself when it already is one,
|
|
5
|
+
* otherwise a copy.
|
|
6
|
+
*/
|
|
7
|
+
export declare function asStrictBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { once, untilAborted } from "./abortable.js";
|
|
2
2
|
export * from "./async.js";
|
|
3
3
|
export * from "./binary.js";
|
|
4
|
+
export * from "./bytes.js";
|
|
4
5
|
export * from "./color.js";
|
|
5
6
|
export * from "./dirs.js";
|
|
6
7
|
export * from "./env.js";
|
|
@@ -22,6 +23,7 @@ export * from "./mime.js";
|
|
|
22
23
|
export * from "./path.js";
|
|
23
24
|
export * from "./path-tree.js";
|
|
24
25
|
export * from "./peek-file.js";
|
|
26
|
+
export * from "./pid.js";
|
|
25
27
|
export * as postmortem from "./postmortem.js";
|
|
26
28
|
export * from "./process-name.js";
|
|
27
29
|
export * as procmgr from "./procmgr.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-0 liveness probe: true unless the OS reports the pid as gone
|
|
3
|
+
* (`ESRCH`) or invalid (`EINVAL`). Any other failure, notably `EPERM` (alive
|
|
4
|
+
* but owned by another user), reads as alive, so callers never reclaim a
|
|
5
|
+
* resource a live process may still hold. Unlike `procmgr.isPidRunning`, this
|
|
6
|
+
* does not consult the process table's run state.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isPidAlive(pid: number): boolean;
|
|
@@ -178,6 +178,8 @@ export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintPro
|
|
|
178
178
|
* Reports a caught top-level failure after terminal owners restore their display, then exits.
|
|
179
179
|
*/
|
|
180
180
|
export declare function fatal(error: unknown): Promise<never>;
|
|
181
|
+
/** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
|
|
182
|
+
export declare function installProcessHandlers(): void;
|
|
181
183
|
/** Controls when a registered cleanup callback participates in cleanup passes. */
|
|
182
184
|
export interface CleanupRegistrationOptions {
|
|
183
185
|
/**
|
|
@@ -201,8 +203,7 @@ export interface CleanupRegistrationOptions {
|
|
|
201
203
|
waitMessage?: string;
|
|
202
204
|
}
|
|
203
205
|
/**
|
|
204
|
-
* Registers a cleanup callback for
|
|
205
|
-
* repeatable manual cleanup passes.
|
|
206
|
+
* Registers a cleanup callback for manual passes and, when installed, process shutdown.
|
|
206
207
|
*
|
|
207
208
|
* Registrations persist across keep-alive {@link cleanup} passes and run at
|
|
208
209
|
* most once per pass. Set `exitOnly` for resources the continuing process still
|
package/dist/types/sqlite.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
/** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
|
+
/**
|
|
4
|
+
* Moves this process's SQLite locks onto open-file-description locks (Linux), once.
|
|
5
|
+
*
|
|
6
|
+
* SQLite's POSIX record locks belong to the process, so any close of another
|
|
7
|
+
* descriptor of a database or its `-shm` released them, including Bun's own file
|
|
8
|
+
* APIs and the bash tool's builtins; a peer omp then unlinked the WAL under the live
|
|
9
|
+
* connection (issue #15586). Call before the process opens its first database: a
|
|
10
|
+
* connection opened earlier keeps POSIX locks. Returns whether the shim is active; a
|
|
11
|
+
* Linux build where it cannot be installed (Bun's SQLite layout changed) logs a warning
|
|
12
|
+
* once, and callers with a UI surface it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ensureSqliteLockShim(): boolean;
|
|
3
15
|
/**
|
|
4
16
|
* A store's identity as `dev:ino:birthtimeMs`, `null` when the path holds no
|
|
5
17
|
* file, or `undefined` when the stat itself failed and no conclusion is safe.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.16.0",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@oh-my-pi/
|
|
59
|
+
"@oh-my-pi/omptype": "18.16.0",
|
|
60
|
+
"@oh-my-pi/pi-natives": "18.16.0"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/bun": "^1.3.14"
|
package/src/acp/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Type, type } from "@oh-my-pi/omptype";
|
|
1
2
|
import type {
|
|
2
3
|
ForkSessionResponse,
|
|
3
4
|
LoadSessionResponse,
|
|
@@ -8,7 +9,7 @@ import type {
|
|
|
8
9
|
|
|
9
10
|
/** Validation failure compatible with the used Zod error surface. */
|
|
10
11
|
export interface ValidationError {
|
|
11
|
-
issues: Array<{ path:
|
|
12
|
+
issues: Array<{ path: PropertyKey[]; message: string }>;
|
|
12
13
|
}
|
|
13
14
|
/** Successful validation result. */
|
|
14
15
|
export interface ValidationSuccess<T> {
|
|
@@ -26,129 +27,74 @@ export interface Validator<T> {
|
|
|
26
27
|
parse(value: unknown): T;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Wraps a structural schema in the Zod call shape. Schemas check the fields
|
|
32
|
+
* ACP consumers read and keep every other key, so the result is the full
|
|
33
|
+
* protocol value.
|
|
34
|
+
*/
|
|
35
|
+
function validator<T>(schema: Type, label: string): Validator<T> {
|
|
30
36
|
return {
|
|
31
37
|
safeParse(value) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
const result = schema(value);
|
|
39
|
+
if (!(result instanceof type.errors)) return { success: true, data: result as T };
|
|
40
|
+
return {
|
|
41
|
+
success: false,
|
|
42
|
+
error: { issues: result.map(issue => ({ path: [...issue.path], message: issue.message })) },
|
|
43
|
+
};
|
|
35
44
|
},
|
|
36
45
|
parse(value) {
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const result = schema(value);
|
|
47
|
+
if (result instanceof type.errors) throw new Error(`Invalid ${label}: ${result.summary}`);
|
|
48
|
+
return result as T;
|
|
39
49
|
},
|
|
40
50
|
};
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function validModes(value: unknown): boolean {
|
|
48
|
-
if (value === undefined || value === null) return true;
|
|
49
|
-
if (typeof value !== "object" || value === null) return false;
|
|
50
|
-
const modes = value as Record<string, unknown>;
|
|
51
|
-
return (
|
|
52
|
-
typeof modes.currentModeId === "string" &&
|
|
53
|
-
Array.isArray(modes.availableModes) &&
|
|
54
|
-
modes.availableModes.every(mode => objectWithString(mode, "id") && objectWithString(mode, "name"))
|
|
55
|
-
);
|
|
56
|
-
}
|
|
53
|
+
const Modes = type({
|
|
54
|
+
currentModeId: "string",
|
|
55
|
+
availableModes: type({ id: "string", name: "string" }).array(),
|
|
56
|
+
}).or("null | undefined");
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
response.configOptions === undefined || response.configOptions === null || Array.isArray(response.configOptions)
|
|
65
|
-
);
|
|
66
|
-
}
|
|
58
|
+
// Responses and notifications are also checked in-process, where absent optionals may be explicit `undefined`.
|
|
59
|
+
const sessionResponseFields = { "modes?": Modes, "configOptions?": "unknown[] | null | undefined" } as const;
|
|
60
|
+
const notArray = (value: object): boolean => !Array.isArray(value);
|
|
61
|
+
const SessionResponse = type(sessionResponseFields).narrow(notArray);
|
|
62
|
+
const IdentifiedSessionResponse = type({ sessionId: "string", ...sessionResponseFields }).narrow(notArray);
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
case "text":
|
|
73
|
-
return typeof block.text === "string";
|
|
74
|
-
case "image":
|
|
75
|
-
case "audio":
|
|
76
|
-
return typeof block.data === "string" && typeof block.mimeType === "string";
|
|
77
|
-
case "resource_link":
|
|
78
|
-
return typeof block.uri === "string" && typeof block.name === "string";
|
|
79
|
-
case "resource":
|
|
80
|
-
return typeof block.resource === "object" && block.resource !== null;
|
|
81
|
-
default:
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
64
|
+
const ContentBlock = type({ type: "'text'", text: "string" })
|
|
65
|
+
.or({ type: "'image' | 'audio'", data: "string", mimeType: "string" })
|
|
66
|
+
.or({ type: "'resource_link'", uri: "string", name: "string" })
|
|
67
|
+
.or({ type: "'resource'", resource: "object" });
|
|
85
68
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
case "agent_message_chunk":
|
|
99
|
-
case "agent_thought_chunk":
|
|
100
|
-
case "user_message_chunk":
|
|
101
|
-
return contentBlock(update.content);
|
|
102
|
-
case "tool_call":
|
|
103
|
-
return typeof update.toolCallId === "string" && typeof update.title === "string";
|
|
104
|
-
case "tool_call_update":
|
|
105
|
-
return typeof update.toolCallId === "string";
|
|
106
|
-
case "plan":
|
|
107
|
-
return Array.isArray(update.entries);
|
|
108
|
-
case "current_mode_update":
|
|
109
|
-
return typeof update.currentModeId === "string";
|
|
110
|
-
case "available_commands_update":
|
|
111
|
-
return Array.isArray(update.availableCommands);
|
|
112
|
-
case "config_option_update":
|
|
113
|
-
return Array.isArray(update.configOptions);
|
|
114
|
-
case "session_info_update":
|
|
115
|
-
return update.title === undefined || update.title === null || typeof update.title === "string";
|
|
116
|
-
case "usage_update":
|
|
117
|
-
return typeof update.size === "number" && typeof update.used === "number";
|
|
118
|
-
default:
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
69
|
+
const SessionUpdate = type({
|
|
70
|
+
sessionUpdate: "'agent_message_chunk' | 'agent_thought_chunk' | 'user_message_chunk'",
|
|
71
|
+
content: ContentBlock,
|
|
72
|
+
})
|
|
73
|
+
.or({ sessionUpdate: "'tool_call'", toolCallId: "string", title: "string" })
|
|
74
|
+
.or({ sessionUpdate: "'tool_call_update'", toolCallId: "string" })
|
|
75
|
+
.or({ sessionUpdate: "'plan'", entries: "unknown[]" })
|
|
76
|
+
.or({ sessionUpdate: "'current_mode_update'", currentModeId: "string" })
|
|
77
|
+
.or({ sessionUpdate: "'available_commands_update'", availableCommands: "unknown[]" })
|
|
78
|
+
.or({ sessionUpdate: "'config_option_update'", configOptions: "unknown[]" })
|
|
79
|
+
.or({ sessionUpdate: "'session_info_update'", "title?": "string | null | undefined" })
|
|
80
|
+
.or({ sessionUpdate: "'usage_update'", size: "number", used: "number" });
|
|
122
81
|
|
|
123
82
|
/** Validator for new-session responses. */
|
|
124
|
-
export const zNewSessionResponse = validator<NewSessionResponse>(
|
|
125
|
-
value => sessionResponse(value, true),
|
|
126
|
-
"new session response",
|
|
127
|
-
);
|
|
83
|
+
export const zNewSessionResponse = validator<NewSessionResponse>(IdentifiedSessionResponse, "new session response");
|
|
128
84
|
/** Validator for load-session responses. */
|
|
129
|
-
export const zLoadSessionResponse = validator<LoadSessionResponse>(
|
|
130
|
-
value => sessionResponse(value, false),
|
|
131
|
-
"load session response",
|
|
132
|
-
);
|
|
85
|
+
export const zLoadSessionResponse = validator<LoadSessionResponse>(SessionResponse, "load session response");
|
|
133
86
|
/** Validator for fork-session responses. */
|
|
134
|
-
export const zForkSessionResponse = validator<ForkSessionResponse>(
|
|
135
|
-
value => sessionResponse(value, true),
|
|
136
|
-
"fork session response",
|
|
137
|
-
);
|
|
87
|
+
export const zForkSessionResponse = validator<ForkSessionResponse>(IdentifiedSessionResponse, "fork session response");
|
|
138
88
|
/** Validator for prompt responses. */
|
|
139
|
-
export const zPromptResponse = validator<PromptResponse>(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
stopReason === "end_turn" ||
|
|
144
|
-
stopReason === "max_tokens" ||
|
|
145
|
-
stopReason === "max_turn_requests" ||
|
|
146
|
-
stopReason === "refusal" ||
|
|
147
|
-
stopReason === "cancelled"
|
|
148
|
-
);
|
|
149
|
-
}, "prompt response");
|
|
89
|
+
export const zPromptResponse = validator<PromptResponse>(
|
|
90
|
+
type({ stopReason: "'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled'" }),
|
|
91
|
+
"prompt response",
|
|
92
|
+
);
|
|
150
93
|
/** Validator for session notifications. */
|
|
151
|
-
export const zSessionNotification = validator<SessionNotification>(
|
|
94
|
+
export const zSessionNotification = validator<SessionNotification>(
|
|
95
|
+
type({ sessionId: "string", update: SessionUpdate }).narrow(notArray),
|
|
96
|
+
"session notification",
|
|
97
|
+
);
|
|
152
98
|
|
|
153
99
|
/** ACP runtime validators. */
|
|
154
100
|
export const schema = {
|
package/src/ar/arj.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { readUInt16LE, readUInt32LE } from "./bytes";
|
|
1
2
|
import { crc32 } from "./checksums";
|
|
2
3
|
import { ArchiveError } from "./error";
|
|
3
4
|
import { assertArchiveMemberSize, assertEntryCount, assertIndexSize, assertInMemorySize } from "./limits";
|
|
4
|
-
import { decompressLhStatic } from "./lzh";
|
|
5
|
+
import { decompressLhStatic, dosTimeToMs } from "./lzh";
|
|
5
6
|
import { assertArchivePathBytes, assertArchivePathString, normalizeArchiveEntryPath } from "./paths";
|
|
6
7
|
import { type ByteSource, readAllBytes } from "./source";
|
|
7
8
|
import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
|
|
@@ -11,14 +12,6 @@ const ARJ_SIGNATURE_1 = 0xea;
|
|
|
11
12
|
const ARJ_MAX_BASIC_HEADER = 2600;
|
|
12
13
|
const LEGACY_DECODER = new TextDecoder("windows-1252");
|
|
13
14
|
|
|
14
|
-
function u16(bytes: Uint8Array, offset: number): number {
|
|
15
|
-
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function u32(bytes: Uint8Array, offset: number): number {
|
|
19
|
-
return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
15
|
function assertRange(bytes: Uint8Array, start: number, end: number, what: string): void {
|
|
23
16
|
if (
|
|
24
17
|
!Number.isSafeInteger(start) ||
|
|
@@ -31,18 +24,6 @@ function assertRange(bytes: Uint8Array, start: number, end: number, what: string
|
|
|
31
24
|
}
|
|
32
25
|
}
|
|
33
26
|
|
|
34
|
-
function dosTimeToMs(value: number): number | undefined {
|
|
35
|
-
if (value === 0) return undefined;
|
|
36
|
-
const year = 1980 + ((value >>> 25) & 0x7f);
|
|
37
|
-
const month = (value >>> 21) & 0x0f;
|
|
38
|
-
const day = (value >>> 16) & 0x1f;
|
|
39
|
-
const hour = (value >>> 11) & 0x1f;
|
|
40
|
-
const minute = (value >>> 5) & 0x3f;
|
|
41
|
-
const second = (value & 0x1f) * 2;
|
|
42
|
-
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return undefined;
|
|
43
|
-
return Date.UTC(year, month - 1, day, hour, minute, second);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
27
|
function readCString(bytes: Uint8Array, start: number, end: number, field: string): { value: string; next: number } {
|
|
47
28
|
let terminator = start;
|
|
48
29
|
while (terminator < end && bytes[terminator] !== 0) terminator++;
|
|
@@ -63,26 +44,26 @@ function parseArjBlock(bytes: Uint8Array, offset: number, options: FormatReadOpt
|
|
|
63
44
|
if (bytes[offset] !== ARJ_SIGNATURE_0 || bytes[offset + 1] !== ARJ_SIGNATURE_1) {
|
|
64
45
|
throw new ArchiveError("Invalid ARJ header signature");
|
|
65
46
|
}
|
|
66
|
-
const bodySize =
|
|
47
|
+
const bodySize = readUInt16LE(bytes, offset + 2);
|
|
67
48
|
if (bodySize === 0)
|
|
68
49
|
return { bodyStart: offset + 4, bodySize: 0, nextOffset: offset + 4, metadataSize: 4, isEnd: true };
|
|
69
50
|
if (bodySize < 30 || bodySize > ARJ_MAX_BASIC_HEADER) throw new ArchiveError("Invalid ARJ basic header size");
|
|
70
51
|
const bodyStart = offset + 4;
|
|
71
52
|
const bodyEnd = bodyStart + bodySize;
|
|
72
53
|
assertRange(bytes, bodyStart, bodyEnd + 4, "basic header");
|
|
73
|
-
if (crc32(bytes.subarray(bodyStart, bodyEnd)) !==
|
|
54
|
+
if (crc32(bytes.subarray(bodyStart, bodyEnd)) !== readUInt32LE(bytes, bodyEnd)) {
|
|
74
55
|
throw new ArchiveError("Invalid ARJ basic header CRC32");
|
|
75
56
|
}
|
|
76
57
|
let cursor = bodyEnd + 4;
|
|
77
58
|
let extensionCount = 0;
|
|
78
59
|
for (;;) {
|
|
79
60
|
assertRange(bytes, cursor, cursor + 2, "extended header size");
|
|
80
|
-
const extensionSize =
|
|
61
|
+
const extensionSize = readUInt16LE(bytes, cursor);
|
|
81
62
|
cursor += 2;
|
|
82
63
|
if (extensionSize === 0) break;
|
|
83
64
|
if (++extensionCount > 65_535) throw new ArchiveError("Invalid ARJ archive: too many extended headers");
|
|
84
65
|
assertRange(bytes, cursor, cursor + extensionSize + 4, "extended header");
|
|
85
|
-
if (crc32(bytes.subarray(cursor, cursor + extensionSize)) !==
|
|
66
|
+
if (crc32(bytes.subarray(cursor, cursor + extensionSize)) !== readUInt32LE(bytes, cursor + extensionSize)) {
|
|
86
67
|
throw new ArchiveError("Invalid ARJ extended header CRC32");
|
|
87
68
|
}
|
|
88
69
|
cursor += extensionSize + 4;
|
|
@@ -216,10 +197,10 @@ function normalizeHostPath(rawPath: string, hostOs: number): string {
|
|
|
216
197
|
/** Probe whether bytes begin with a CRC-framed ARJ main header. */
|
|
217
198
|
export function sniffArj(bytes: Uint8Array): boolean {
|
|
218
199
|
if (bytes.byteLength < 34 || bytes[0] !== ARJ_SIGNATURE_0 || bytes[1] !== ARJ_SIGNATURE_1) return false;
|
|
219
|
-
const size =
|
|
200
|
+
const size = readUInt16LE(bytes, 2);
|
|
220
201
|
if (size < 30 || size > ARJ_MAX_BASIC_HEADER || bytes.byteLength < size + 8) return false;
|
|
221
202
|
const body = bytes.subarray(4, 4 + size);
|
|
222
|
-
return body[0]! >= 30 && body[0]! <= size && body[6] === 2 && crc32(body) ===
|
|
203
|
+
return body[0]! >= 30 && body[0]! <= size && body[6] === 2 && crc32(body) === readUInt32LE(bytes, 4 + size);
|
|
223
204
|
}
|
|
224
205
|
|
|
225
206
|
/** Index an ARJ archive and lazily decode stored, static-Huffman, and fast-LZSS members. */
|
|
@@ -266,10 +247,10 @@ export const readArj: FormatReader = async (
|
|
|
266
247
|
const fileType = bytes[block.bodyStart + 6]!;
|
|
267
248
|
if ((flags & 0x01) !== 0) throw new ArchiveError("Encrypted ARJ members are unsupported");
|
|
268
249
|
if ((flags & 0x0c) !== 0) throw new ArchiveError("Multi-volume ARJ members are unsupported");
|
|
269
|
-
const packedSize =
|
|
270
|
-
const size =
|
|
271
|
-
const fileCrc =
|
|
272
|
-
const accessMode =
|
|
250
|
+
const packedSize = readUInt32LE(bytes, block.bodyStart + 12);
|
|
251
|
+
const size = readUInt32LE(bytes, block.bodyStart + 16);
|
|
252
|
+
const fileCrc = readUInt32LE(bytes, block.bodyStart + 20);
|
|
253
|
+
const accessMode = readUInt16LE(bytes, block.bodyStart + 26);
|
|
273
254
|
const filename = readCString(
|
|
274
255
|
bytes,
|
|
275
256
|
block.bodyStart + firstHeaderSize,
|
|
@@ -295,7 +276,7 @@ export const readArj: FormatReader = async (
|
|
|
295
276
|
if ((hostOs === 2 || hostOs === 8) && accessMode !== 0) {
|
|
296
277
|
mode = (isDirectory ? 0x4000 : 0x8000) | (accessMode & 0x0fff);
|
|
297
278
|
}
|
|
298
|
-
const rawMtime =
|
|
279
|
+
const rawMtime = readUInt32LE(bytes, block.bodyStart + 8);
|
|
299
280
|
const mtimeMs =
|
|
300
281
|
hostOs === 2 || hostOs === 8 ? (rawMtime === 0 ? undefined : rawMtime * 1000) : dosTimeToMs(rawMtime);
|
|
301
282
|
entries.push({
|
package/src/ar/asar.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as path from "node:path";
|
|
|
2
2
|
import { readUInt32LE, writeUInt32LE } from "./bytes";
|
|
3
3
|
import { ArchiveError } from "./error";
|
|
4
4
|
import { assertArchiveMemberSize, assertEntryCount, assertIndexSize } from "./limits";
|
|
5
|
+
import { isRecord } from "../type-guards";
|
|
5
6
|
import {
|
|
6
7
|
assertArchivePathBytes,
|
|
7
8
|
assertArchivePathString,
|
|
@@ -55,14 +56,10 @@ function alignAsarPayload(size: number): number {
|
|
|
55
56
|
return size + ((4 - (size % 4)) % 4);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function isArchiveRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
59
|
function parseIntegrity(value: unknown, entryPath: string, size: number): AsarIntegrity | undefined {
|
|
63
60
|
if (value === undefined) return undefined;
|
|
64
61
|
const label = formatArchivePathForError(entryPath);
|
|
65
|
-
if (!
|
|
62
|
+
if (!isRecord(value)) {
|
|
66
63
|
throw invalidAsar(`file '${label}' has an invalid integrity record`);
|
|
67
64
|
}
|
|
68
65
|
if (value.algorithm !== "SHA256") {
|
|
@@ -228,7 +225,7 @@ async function readAsarIndex(source: ByteSource, options: FormatReadOptions): Pr
|
|
|
228
225
|
} catch {
|
|
229
226
|
throw invalidAsar("header is not valid JSON");
|
|
230
227
|
}
|
|
231
|
-
if (!
|
|
228
|
+
if (!isRecord(header) || !isRecord(header.files)) {
|
|
232
229
|
throw invalidAsar("root must contain a files object");
|
|
233
230
|
}
|
|
234
231
|
|
|
@@ -257,7 +254,7 @@ async function readAsarIndex(source: ByteSource, options: FormatReadOptions): Pr
|
|
|
257
254
|
) {
|
|
258
255
|
throw invalidAsar(`invalid entry name '${formatArchivePathForError(current.name)}'`);
|
|
259
256
|
}
|
|
260
|
-
if (!
|
|
257
|
+
if (!isRecord(current.node)) {
|
|
261
258
|
throw invalidAsar(`entry '${formatArchivePathForError(current.name)}' must be an object`);
|
|
262
259
|
}
|
|
263
260
|
const entryPathBytes = current.parentPathBytes + (current.parentPath ? 1 : 0) + nameBytes;
|
|
@@ -295,7 +292,7 @@ async function readAsarIndex(source: ByteSource, options: FormatReadOptions): Pr
|
|
|
295
292
|
}
|
|
296
293
|
|
|
297
294
|
if ("files" in current.node) {
|
|
298
|
-
if (!
|
|
295
|
+
if (!isRecord(current.node.files)) {
|
|
299
296
|
throw invalidAsar(`directory '${entryLabel}' must contain a files object`);
|
|
300
297
|
}
|
|
301
298
|
entries.push({ path: entryPath, isDirectory: true, size: 0 });
|
package/src/ar/codecs/xz.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readUInt32BE } from "../bytes";
|
|
1
2
|
import { crc32, crc64 } from "../checksums";
|
|
2
3
|
import { ArchiveError } from "../error";
|
|
3
4
|
import { lzma2Decompress } from "./lzma";
|
|
@@ -16,10 +17,6 @@ function write32LE(bytes: Uint8Array, offset: number, value: number): void {
|
|
|
16
17
|
bytes[offset + 3] = value >>> 24;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
function read32BE(bytes: Uint8Array, offset: number): number {
|
|
20
|
-
return ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
20
|
function write32BE(bytes: Uint8Array, offset: number, value: number): void {
|
|
24
21
|
bytes[offset] = value >>> 24;
|
|
25
22
|
bytes[offset + 1] = (value >>> 16) & 0xff;
|
|
@@ -155,7 +152,8 @@ interface XzFilter {
|
|
|
155
152
|
properties: Uint8Array;
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
|
|
155
|
+
/** Undo the delta filter (shared by XZ and 7z) in place. */
|
|
156
|
+
export function deltaDecode(bytes: Uint8Array, distance: number): void {
|
|
159
157
|
const history = new Uint8Array(256);
|
|
160
158
|
let position = 0;
|
|
161
159
|
for (let index = 0; index < bytes.byteLength; index++) {
|
|
@@ -166,7 +164,8 @@ function deltaDecode(bytes: Uint8Array, distance: number): void {
|
|
|
166
164
|
}
|
|
167
165
|
}
|
|
168
166
|
|
|
169
|
-
|
|
167
|
+
/** Undo the x86 BCJ filter (shared by XZ and 7z) in place; `startOffset` is the stream position of `bytes[0]`. */
|
|
168
|
+
export function x86Decode(bytes: Uint8Array, startOffset: number): void {
|
|
170
169
|
const maskToBitNumber = [0, 1, 2, 2, 3] as const;
|
|
171
170
|
let previousMask = 0;
|
|
172
171
|
let previousPosition = 0xfffffffb;
|
|
@@ -267,7 +266,7 @@ function sparcDecode(bytes: Uint8Array, startOffset: number): void {
|
|
|
267
266
|
)
|
|
268
267
|
)
|
|
269
268
|
continue;
|
|
270
|
-
const source = (
|
|
269
|
+
const source = (readUInt32BE(bytes, index) << 2) >>> 0;
|
|
271
270
|
let destination = (source - startOffset - index) >>> 2;
|
|
272
271
|
destination = (((0 - ((destination >>> 22) & 1)) << 22) & 0x3fffffff) | (destination & 0x3fffff) | 0x40000000;
|
|
273
272
|
write32BE(bytes, index, destination >>> 0);
|
|
@@ -356,7 +355,7 @@ function riscvDecode(bytes: Uint8Array, startOffset: number): void {
|
|
|
356
355
|
index += 2;
|
|
357
356
|
continue;
|
|
358
357
|
}
|
|
359
|
-
const address = (
|
|
358
|
+
const address = (readUInt32BE(bytes, index + 4) - startOffset - index) >>> 0;
|
|
360
359
|
instruction2 = ((instruction >>> 12) | (address << 20)) >>> 0;
|
|
361
360
|
instruction = (0x17 | (register << 7) | ((address + 0x800) & 0xfffff000)) >>> 0;
|
|
362
361
|
}
|
package/src/ar/lzh.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readUInt16LE, readUInt32LE } from "./bytes";
|
|
1
2
|
import { crc16Arc } from "./checksums";
|
|
2
3
|
import { ArchiveError } from "./error";
|
|
3
4
|
import { assertArchiveMemberSize, assertEntryCount, assertIndexSize, assertInMemorySize } from "./limits";
|
|
@@ -279,16 +280,8 @@ function crc16WithZeroRange(bytes: Uint8Array, zeroStart: number, zeroEnd: numbe
|
|
|
279
280
|
return crc16Arc(bytes.subarray(zeroEnd), value);
|
|
280
281
|
}
|
|
281
282
|
|
|
282
|
-
function u16(bytes: Uint8Array, offset: number): number {
|
|
283
|
-
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function u32(bytes: Uint8Array, offset: number): number {
|
|
287
|
-
return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
283
|
function u64(bytes: Uint8Array, offset: number): number {
|
|
291
|
-
const value =
|
|
284
|
+
const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * 0x100000000;
|
|
292
285
|
if (!Number.isSafeInteger(value)) throw new ArchiveError("LZH uses sizes too large to read safely");
|
|
293
286
|
return value;
|
|
294
287
|
}
|
|
@@ -318,7 +311,8 @@ function decodeUtf16(bytes: Uint8Array): string {
|
|
|
318
311
|
return UTF16LE_DECODER.decode(bytes.subarray(0, end));
|
|
319
312
|
}
|
|
320
313
|
|
|
321
|
-
|
|
314
|
+
/** MS-DOS packed date/time as UTC milliseconds; `undefined` for 0 or an out-of-range field. */
|
|
315
|
+
export function dosTimeToMs(value: number): number | undefined {
|
|
322
316
|
if (value === 0) return undefined;
|
|
323
317
|
const year = 1980 + ((value >>> 25) & 0x7f);
|
|
324
318
|
const month = (value >>> 21) & 0x0f;
|
|
@@ -352,7 +346,7 @@ function processExtendedHeader(
|
|
|
352
346
|
switch (type) {
|
|
353
347
|
case 0x00:
|
|
354
348
|
if (data.byteLength < 2) throw new ArchiveError("Invalid LZH common extended header");
|
|
355
|
-
fields.commonCrc =
|
|
349
|
+
fields.commonCrc = readUInt16LE(data, 0);
|
|
356
350
|
fields.commonCrcOffset = absoluteDataOffset;
|
|
357
351
|
break;
|
|
358
352
|
case 0x01:
|
|
@@ -365,8 +359,8 @@ function processExtendedHeader(
|
|
|
365
359
|
throw new ArchiveError("Multi-volume LZH archives are unsupported");
|
|
366
360
|
case 0x41:
|
|
367
361
|
if (data.byteLength >= 16) {
|
|
368
|
-
const low =
|
|
369
|
-
const high =
|
|
362
|
+
const low = readUInt32LE(data, 8);
|
|
363
|
+
const high = readUInt32LE(data, 12);
|
|
370
364
|
const filetime = low + high * 0x100000000;
|
|
371
365
|
if (Number.isSafeInteger(filetime)) fields.mtimeMs = filetime / 10_000 - 11_644_473_600_000;
|
|
372
366
|
}
|
|
@@ -384,11 +378,11 @@ function processExtendedHeader(
|
|
|
384
378
|
break;
|
|
385
379
|
case 0x50:
|
|
386
380
|
if (data.byteLength < 2) throw new ArchiveError("Invalid LZH Unix permissions extended header");
|
|
387
|
-
fields.mode =
|
|
381
|
+
fields.mode = readUInt16LE(data, 0);
|
|
388
382
|
break;
|
|
389
383
|
case 0x54:
|
|
390
384
|
if (data.byteLength < 4) throw new ArchiveError("Invalid LZH Unix timestamp extended header");
|
|
391
|
-
fields.mtimeMs =
|
|
385
|
+
fields.mtimeMs = readUInt32LE(data, 0) * 1000;
|
|
392
386
|
break;
|
|
393
387
|
}
|
|
394
388
|
}
|
|
@@ -464,8 +458,8 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
464
458
|
if (level > 2) throw new ArchiveError(`Unsupported LZH header level ${level}`);
|
|
465
459
|
const method = String.fromCharCode(...bytes.subarray(offset + 2, offset + 7));
|
|
466
460
|
if (!LHA_METHOD_PATTERN.test(method)) throw new ArchiveError(`Invalid LZH compression method '${method}'`);
|
|
467
|
-
let packedSize =
|
|
468
|
-
let size =
|
|
461
|
+
let packedSize = readUInt32LE(bytes, offset + 7);
|
|
462
|
+
let size = readUInt32LE(bytes, offset + 11);
|
|
469
463
|
let crc = 0;
|
|
470
464
|
let legacyFilename: string | undefined;
|
|
471
465
|
let mtimeMs: number | undefined;
|
|
@@ -486,22 +480,22 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
486
480
|
if (22 + nameLength + 2 > headerLength + 2) throw new ArchiveError("Invalid LZH filename length");
|
|
487
481
|
assertArchivePathBytes(nameLength, "member path", options.limits.maxPathBytes);
|
|
488
482
|
legacyFilename = decodeLegacy(bytes.subarray(offset + 22, offset + 22 + nameLength));
|
|
489
|
-
crc =
|
|
490
|
-
mtimeMs = dosTimeToMs(
|
|
483
|
+
crc = readUInt16LE(bytes, offset + 22 + nameLength);
|
|
484
|
+
mtimeMs = dosTimeToMs(readUInt32LE(bytes, offset + 15));
|
|
491
485
|
if (level === 0) {
|
|
492
486
|
const extendedStart = offset + 24 + nameLength;
|
|
493
487
|
if (baseEnd - extendedStart >= 12) {
|
|
494
488
|
const extended = bytes.subarray(extendedStart, baseEnd);
|
|
495
489
|
if ((extended[0] === 0x55 || extended[0] === 0x4b) && extended[1] === 0) {
|
|
496
490
|
osId = extended[0]!;
|
|
497
|
-
fields.mtimeMs =
|
|
498
|
-
fields.mode =
|
|
491
|
+
fields.mtimeMs = readUInt32LE(extended, 2) * 1000;
|
|
492
|
+
fields.mode = readUInt16LE(extended, extended.byteLength - 6);
|
|
499
493
|
}
|
|
500
494
|
}
|
|
501
495
|
dataStart = baseEnd;
|
|
502
496
|
} else {
|
|
503
497
|
osId = bytes[offset + 24 + nameLength]!;
|
|
504
|
-
let extensionSize =
|
|
498
|
+
let extensionSize = readUInt16LE(bytes, baseEnd - 2);
|
|
505
499
|
let cursor = baseEnd;
|
|
506
500
|
let totalExtensionSize = 0;
|
|
507
501
|
let extensionCount = 0;
|
|
@@ -519,7 +513,7 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
519
513
|
}
|
|
520
514
|
processExtendedHeader(type, data, cursor + 1, fields);
|
|
521
515
|
const currentSize = extensionSize;
|
|
522
|
-
extensionSize =
|
|
516
|
+
extensionSize = readUInt16LE(bytes, dataEnd);
|
|
523
517
|
cursor += currentSize;
|
|
524
518
|
}
|
|
525
519
|
dataStart = baseEnd + totalExtensionSize;
|
|
@@ -527,17 +521,17 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
527
521
|
if (packedSize < 0) throw new ArchiveError("Invalid LZH level-1 packed size");
|
|
528
522
|
}
|
|
529
523
|
} else {
|
|
530
|
-
const headerLength =
|
|
524
|
+
const headerLength = readUInt16LE(bytes, offset);
|
|
531
525
|
if (headerLength < 26) throw new ArchiveError("Invalid LZH level-2 header size");
|
|
532
526
|
const headerEnd = offset + headerLength;
|
|
533
527
|
assertRange(bytes, offset, headerEnd, "header");
|
|
534
|
-
crc =
|
|
528
|
+
crc = readUInt16LE(bytes, offset + 21);
|
|
535
529
|
osId = bytes[offset + 23]!;
|
|
536
|
-
mtimeMs =
|
|
530
|
+
mtimeMs = readUInt32LE(bytes, offset + 15) * 1000;
|
|
537
531
|
let cursor = offset + 24;
|
|
538
532
|
let extensionCount = 0;
|
|
539
533
|
while (cursor + 2 <= headerEnd) {
|
|
540
|
-
const extensionSize =
|
|
534
|
+
const extensionSize = readUInt16LE(bytes, cursor);
|
|
541
535
|
if (extensionSize === 0) {
|
|
542
536
|
cursor += 2;
|
|
543
537
|
break;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { crc32 } from "../checksums";
|
|
2
2
|
import { lzma2Decompress, lzmaDecompress } from "../codecs/lzma";
|
|
3
|
+
import { deltaDecode, x86Decode } from "../codecs/xz";
|
|
3
4
|
import { ArchiveError } from "../error";
|
|
4
5
|
import type { ArchiveLimits } from "../limits";
|
|
5
6
|
import { assertInMemorySize } from "../limits";
|
|
@@ -81,62 +82,6 @@ function read32LE(bytes: Uint8Array, offset: number): number {
|
|
|
81
82
|
return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function deltaDecode(bytes: Uint8Array, distance: number): void {
|
|
85
|
-
const history = new Uint8Array(256);
|
|
86
|
-
let position = 0;
|
|
87
|
-
for (let index = 0; index < bytes.byteLength; index++) {
|
|
88
|
-
const value = (bytes[index]! + history[(distance + position) & 0xff]!) & 0xff;
|
|
89
|
-
history[position] = value;
|
|
90
|
-
bytes[index] = value;
|
|
91
|
-
position = (position - 1) & 0xff;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function x86Decode(bytes: Uint8Array, startOffset: number): void {
|
|
96
|
-
const maskToBitNumber = [0, 1, 2, 2, 3] as const;
|
|
97
|
-
let previousMask = 0;
|
|
98
|
-
let previousPosition = 0xfffffffb;
|
|
99
|
-
if (bytes.byteLength < 5) return;
|
|
100
|
-
let position = 0;
|
|
101
|
-
const limit = bytes.byteLength - 5;
|
|
102
|
-
while (position <= limit) {
|
|
103
|
-
let byte = bytes[position]!;
|
|
104
|
-
if (byte !== 0xe8 && byte !== 0xe9) {
|
|
105
|
-
position++;
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
const absolutePosition = (startOffset + position) >>> 0;
|
|
109
|
-
const offset = (absolutePosition - previousPosition) >>> 0;
|
|
110
|
-
previousPosition = absolutePosition;
|
|
111
|
-
if (offset > 5) previousMask = 0;
|
|
112
|
-
else for (let index = 0; index < offset; index++) previousMask = ((previousMask & 0x77) << 1) >>> 0;
|
|
113
|
-
byte = bytes[position + 4]!;
|
|
114
|
-
if ((byte === 0 || byte === 0xff) && previousMask >>> 1 <= 4 && previousMask >>> 1 !== 3) {
|
|
115
|
-
let source = read32LE(bytes, position + 1);
|
|
116
|
-
let destination = 0;
|
|
117
|
-
for (;;) {
|
|
118
|
-
destination = (source - absolutePosition - 5) >>> 0;
|
|
119
|
-
if (previousMask === 0) break;
|
|
120
|
-
const bitIndex = maskToBitNumber[previousMask >>> 1]! * 8;
|
|
121
|
-
const testByte = (destination >>> (24 - bitIndex)) & 0xff;
|
|
122
|
-
if (testByte !== 0 && testByte !== 0xff) break;
|
|
123
|
-
const lowMask = bitIndex === 0 ? 0xffffffff : 2 ** (32 - bitIndex) - 1;
|
|
124
|
-
source = (destination ^ lowMask) >>> 0;
|
|
125
|
-
}
|
|
126
|
-
bytes[position + 4] = ~(((destination >>> 24) & 1) - 1) & 0xff;
|
|
127
|
-
bytes[position + 3] = destination >>> 16;
|
|
128
|
-
bytes[position + 2] = destination >>> 8;
|
|
129
|
-
bytes[position + 1] = destination;
|
|
130
|
-
position += 5;
|
|
131
|
-
previousMask = 0;
|
|
132
|
-
} else {
|
|
133
|
-
position++;
|
|
134
|
-
previousMask |= 1;
|
|
135
|
-
if (byte === 0 || byte === 0xff) previousMask |= 0x10;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
85
|
/** Shared lazy decoder/cache for a single 7z folder (solid block). */
|
|
141
86
|
export class SevenZipFolderSource {
|
|
142
87
|
readonly #source: ByteSource;
|
package/src/ar/tar.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { bytesMatchAscii } from "./bytes";
|
|
2
|
+
import { bytesMatchAscii, indexOfAscii } from "./bytes";
|
|
3
3
|
import { resolveArchiveLinkPath, upsertArchiveEntry } from "./entries";
|
|
4
4
|
import { ArchiveError } from "./error";
|
|
5
5
|
import {
|
|
@@ -419,13 +419,6 @@ function paxDeclaresSparse(
|
|
|
419
419
|
return paxAttribute(globalPax, localPax, PAX_SPARSE_MARKER) === "1";
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
-
function indexOfAscii(bytes: Uint8Array, value: string, start: number): number {
|
|
423
|
-
for (let offset = start; offset <= bytes.byteLength - value.length; offset++) {
|
|
424
|
-
if (bytesMatchAscii(bytes, offset, value)) return offset;
|
|
425
|
-
}
|
|
426
|
-
return -1;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
422
|
function normalizeOldGnuName(value: string, field: string, limits: ArchiveLimits): string {
|
|
430
423
|
const portable = value.replace(/\\/g, "/");
|
|
431
424
|
if (path.posix.isAbsolute(portable)) throw new ArchiveError(`Invalid old-GNU ${field}`);
|
package/src/async.ts
CHANGED
|
@@ -29,6 +29,19 @@ export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Wait for a wall-clock deadline, including time spent suspended. Some platforms
|
|
34
|
+
* exclude suspend from relative timers, so recheck at least once per second.
|
|
35
|
+
*/
|
|
36
|
+
export async function sleepUntil(deadlineMs: number, signal?: AbortSignal): Promise<void> {
|
|
37
|
+
while (true) {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
const remaining = deadlineMs - Date.now();
|
|
40
|
+
if (!(remaining > 0)) return;
|
|
41
|
+
await scheduler.wait(Math.min(remaining, 1_000), { signal });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* Wrap a promise with a timeout and optional abort signal.
|
|
34
47
|
* Rejects with the given error or a new error containing the given message if
|
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow `bytes` to a view over its own whole `ArrayBuffer`, which is what
|
|
3
|
+
* `crypto.subtle.{digest,sign,importKey,encrypt,decrypt}` require under the
|
|
4
|
+
* strict TS DOM typings. Returns `bytes` itself when it already is one,
|
|
5
|
+
* otherwise a copy.
|
|
6
|
+
*/
|
|
7
|
+
export function asStrictBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
8
|
+
if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
|
|
9
|
+
return bytes as Uint8Array<ArrayBuffer>;
|
|
10
|
+
}
|
|
11
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
12
|
+
copy.set(bytes);
|
|
13
|
+
return copy;
|
|
14
|
+
}
|
package/src/held-file.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuses overwriting a file this process holds open with locks (issue #15586).
|
|
3
|
+
*
|
|
4
|
+
* Writing over a live `agent.db` or its `-shm` destroys the store (a truncated
|
|
5
|
+
* `-shm` also faults the connection's memory map), so tools that write
|
|
6
|
+
* agent-chosen paths check here first.
|
|
7
|
+
*/
|
|
8
|
+
import { heldFileKind } from "@oh-my-pi/pi-natives/sqlite-locks";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Throws an `EBUSY` error when `filePath` is a file this process holds open with
|
|
12
|
+
* locks: a SQLite database or `-shm` of a live connection. Always passes outside Linux.
|
|
13
|
+
*/
|
|
14
|
+
export function assertNotHeldOpen(filePath: string): void {
|
|
15
|
+
const kind = heldFileKind(filePath);
|
|
16
|
+
if (!kind) return;
|
|
17
|
+
const holder = kind === "locked" ? "a file lock" : "a SQLite connection";
|
|
18
|
+
throw Object.assign(
|
|
19
|
+
new Error(`EBUSY: '${filePath}' is held open by ${holder} in this process; overwriting it would destroy it`),
|
|
20
|
+
{ code: "EBUSY", path: filePath },
|
|
21
|
+
);
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { once, untilAborted } from "./abortable";
|
|
2
2
|
export * from "./async";
|
|
3
3
|
export * from "./binary";
|
|
4
|
+
export * from "./bytes";
|
|
4
5
|
export * from "./color";
|
|
5
6
|
export * from "./dirs";
|
|
6
7
|
export * from "./env";
|
|
@@ -22,6 +23,7 @@ export * from "./mime";
|
|
|
22
23
|
export * from "./path";
|
|
23
24
|
export * from "./path-tree";
|
|
24
25
|
export * from "./peek-file";
|
|
26
|
+
export * from "./pid";
|
|
25
27
|
export * as postmortem from "./postmortem";
|
|
26
28
|
export * from "./process-name";
|
|
27
29
|
export * as procmgr from "./procmgr";
|
package/src/logger.ts
CHANGED
|
@@ -22,6 +22,7 @@ import * as path from "node:path";
|
|
|
22
22
|
import { isPromise } from "node:util/types";
|
|
23
23
|
import { getLogsDir, localDay } from "./dirs";
|
|
24
24
|
import { RotatingFileSink } from "./logger/rotating-file";
|
|
25
|
+
import { isPidAlive } from "./pid";
|
|
25
26
|
import { setStderrRedirectTarget } from "./stderr-guard";
|
|
26
27
|
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
27
28
|
/** Severity names accepted by the centralized logger. */
|
|
@@ -81,15 +82,6 @@ const LEGACY_AUDIT_PATTERN = /^\.[0-9a-f]{8,}-audit\.json$/;
|
|
|
81
82
|
const RETAINED_STALE_LOGS_PER_PROCESS_DAY = 1;
|
|
82
83
|
const RETAINED_STALE_LOG_DAYS = 5;
|
|
83
84
|
|
|
84
|
-
function processIsRunning(pid: number): boolean {
|
|
85
|
-
try {
|
|
86
|
-
process.kill(pid, 0);
|
|
87
|
-
return true;
|
|
88
|
-
} catch (error) {
|
|
89
|
-
return !(error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL"));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
85
|
/**
|
|
94
86
|
* Retain one newest completed-process log per process/day within the current
|
|
95
87
|
* and previous four local calendar days, remove completed-process audit files
|
|
@@ -133,7 +125,7 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
133
125
|
const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
|
|
134
126
|
const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
|
|
135
127
|
const pidText = logMatch?.[2] ?? auditMatch?.[1];
|
|
136
|
-
if (!pidText ||
|
|
128
|
+
if (!pidText || isPidAlive(Number(pidText))) continue;
|
|
137
129
|
|
|
138
130
|
if (auditMatch) {
|
|
139
131
|
removeBestEffort(entryPath);
|
package/src/pid.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-0 liveness probe: true unless the OS reports the pid as gone
|
|
3
|
+
* (`ESRCH`) or invalid (`EINVAL`). Any other failure, notably `EPERM` (alive
|
|
4
|
+
* but owned by another user), reads as alive, so callers never reclaim a
|
|
5
|
+
* resource a live process may still hold. Unlike `procmgr.isPidRunning`, this
|
|
6
|
+
* does not consult the process table's run state.
|
|
7
|
+
*/
|
|
8
|
+
export function isPidAlive(pid: number): boolean {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
14
|
+
return code !== "ESRCH" && code !== "EINVAL";
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/postmortem.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import { APP_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 "@oh-my-pi/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
|
}
|