@oh-my-pi/pi-utils 18.15.0 → 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 CHANGED
@@ -2,6 +2,18 @@
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
+
5
17
  ## [18.15.0] - 2026-11-01
6
18
 
7
19
  ### 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: Array<string | number>;
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`. */
@@ -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. */
@@ -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>;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Throws an `EBUSY` error when `filePath` is a file this process holds open with
3
+ * locks: a SQLite database or `-shm` of a live connection. Always passes outside Linux.
4
+ */
5
+ export declare function assertNotHeldOpen(filePath: string): void;
@@ -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;
@@ -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.15.0",
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/pi-natives": "18.15.0"
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: Array<string | number>; message: string }>;
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
- function validator<T>(check: (value: unknown) => boolean, label: string): Validator<T> {
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
- return check(value)
33
- ? { success: true, data: value as T }
34
- : { success: false, error: { issues: [{ path: [], message: `Invalid ${label}` }] } };
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
- if (!check(value)) throw new Error(`Invalid ${label}`);
38
- return value as T;
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
- function objectWithString(value: unknown, key: string): boolean {
44
- return typeof value === "object" && value !== null && typeof (value as Record<string, unknown>)[key] === "string";
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
- function sessionResponse(value: unknown, needsId: boolean): boolean {
59
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
60
- const response = value as Record<string, unknown>;
61
- if (needsId && typeof response.sessionId !== "string") return false;
62
- if (!validModes(response.modes)) return false;
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
- function contentBlock(value: unknown): boolean {
69
- if (typeof value !== "object" || value === null) return false;
70
- const block = value as Record<string, unknown>;
71
- switch (block.type) {
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
- function sessionNotification(value: unknown): boolean {
87
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
88
- const notification = value as Record<string, unknown>;
89
- if (
90
- typeof notification.sessionId !== "string" ||
91
- typeof notification.update !== "object" ||
92
- notification.update === null
93
- )
94
- return false;
95
- const update = notification.update as Record<string, unknown>;
96
- if (typeof update.sessionUpdate !== "string") return false;
97
- switch (update.sessionUpdate) {
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>(value => {
140
- if (typeof value !== "object" || value === null) return false;
141
- const stopReason = (value as Record<string, unknown>).stopReason;
142
- return (
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>(sessionNotification, "session notification");
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 = u16(bytes, offset + 2);
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)) !== u32(bytes, 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 = u16(bytes, cursor);
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)) !== u32(bytes, 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 = u16(bytes, 2);
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) === u32(bytes, 4 + size);
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 = u32(bytes, block.bodyStart + 12);
270
- const size = u32(bytes, block.bodyStart + 16);
271
- const fileCrc = u32(bytes, block.bodyStart + 20);
272
- const accessMode = u16(bytes, block.bodyStart + 26);
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 = u32(bytes, block.bodyStart + 8);
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 (!isArchiveRecord(value)) {
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 (!isArchiveRecord(header) || !isArchiveRecord(header.files)) {
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 (!isArchiveRecord(current.node)) {
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 (!isArchiveRecord(current.node.files)) {
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 });
@@ -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
- function deltaDecode(bytes: Uint8Array, distance: number): void {
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
- function x86Decode(bytes: Uint8Array, startOffset: number): void {
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 = (read32BE(bytes, index) << 2) >>> 0;
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 = (read32BE(bytes, index + 4) - startOffset - index) >>> 0;
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 = u32(bytes, offset) + u32(bytes, offset + 4) * 0x100000000;
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
- function dosTimeToMs(value: number): number | undefined {
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 = u16(data, 0);
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 = u32(data, 8);
369
- const high = u32(data, 12);
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 = u16(data, 0);
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 = u32(data, 0) * 1000;
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 = u32(bytes, offset + 7);
468
- let size = u32(bytes, offset + 11);
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 = u16(bytes, offset + 22 + nameLength);
490
- mtimeMs = dosTimeToMs(u32(bytes, offset + 15));
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 = u32(extended, 2) * 1000;
498
- fields.mode = u16(extended, extended.byteLength - 6);
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 = u16(bytes, baseEnd - 2);
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 = u16(bytes, dataEnd);
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 = u16(bytes, offset);
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 = u16(bytes, offset + 21);
528
+ crc = readUInt16LE(bytes, offset + 21);
535
529
  osId = bytes[offset + 23]!;
536
- mtimeMs = u32(bytes, offset + 15) * 1000;
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 = u16(bytes, cursor);
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/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
+ }
@@ -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 || processIsRunning(Number(pidText))) continue;
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 = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
280
+ const err = toError(result.reason);
280
281
  logger.error("Cleanup callback failed", { err, stack: err.stack });
281
282
  }
282
283
  }
@@ -708,7 +709,7 @@ function handleWorkerSendEpipe(err: Error): boolean {
708
709
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
709
710
  */
710
711
  export async function fatal(error: unknown): Promise<never> {
711
- const err = error instanceof Error ? error : new Error(String(error));
712
+ const err = toError(error);
712
713
  const report = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n`;
713
714
  if (!Bun.isMainThread) {
714
715
  process.stderr.write(`${report}${formatFatalRecoveryHints(fatalRecoveryHintProviders)}`);
@@ -753,7 +754,7 @@ export function installProcessHandlers(): void {
753
754
  logger.warn("Ignoring expected cleanup exception", { err: thrown });
754
755
  return;
755
756
  }
756
- const err = thrown instanceof Error ? thrown : new Error(String(thrown));
757
+ const err = toError(thrown);
757
758
  // A worker IPC `send()` race can surface through either global error event;
758
759
  // contain it in both. Stdout write disconnects are attributed to stdout by
759
760
  // registerStdioDisconnectHandling's `error` listener, not classified here.
@@ -786,7 +787,7 @@ export function installProcessHandlers(): void {
786
787
  );
787
788
  })
788
789
  .on("unhandledRejection", async reason => {
789
- const err = reason instanceof Error ? reason : new Error(String(reason));
790
+ const err = toError(reason);
790
791
  if (handleWorkerSendEpipe(err)) return;
791
792
  if (isExpectedCleanupError(reason)) {
792
793
  logger.warn("Ignoring expected cleanup rejection", { err });
@@ -876,7 +877,7 @@ export function register(
876
877
  const pending = invokeCleanup(registration, reason, keepAlive, cleanupPass);
877
878
  if (!pending) return;
878
879
  const tracked = pending.catch(error => {
879
- const err = error instanceof Error ? error : new Error(String(error));
880
+ const err = toError(error);
880
881
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
881
882
  });
882
883
  // Join the active pass so cleanup()/signal exits await it; after a
@@ -886,7 +887,7 @@ export function register(
886
887
  activePassDeadline?.extend(tracked, registration.deadlineMs, registration.waitMessage);
887
888
  }
888
889
  } catch (error) {
889
- const err = error instanceof Error ? error : new Error(String(error));
890
+ const err = toError(error);
890
891
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
891
892
  }
892
893
  };
@@ -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 = error instanceof Error ? error : new Error(String(error));
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 = error instanceof Error ? error : new Error(String(error));
366
+ const annotated = toError(error);
337
367
  annotated.message = `Database "${dbPath}": ${annotated.message}`;
338
368
  return annotated;
339
369
  }