@oh-my-pi/pi-utils 18.15.0 → 18.17.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/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,10 +709,12 @@ 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)}`);
716
+ // An explicit exit alone leaves the parent only `close` with code 1; this reaches its `error` listeners.
717
+ reportError(err);
715
718
  process.exit(1);
716
719
  }
717
720
  return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
@@ -753,7 +756,7 @@ export function installProcessHandlers(): void {
753
756
  logger.warn("Ignoring expected cleanup exception", { err: thrown });
754
757
  return;
755
758
  }
756
- const err = thrown instanceof Error ? thrown : new Error(String(thrown));
759
+ const err = toError(thrown);
757
760
  // A worker IPC `send()` race can surface through either global error event;
758
761
  // contain it in both. Stdout write disconnects are attributed to stdout by
759
762
  // registerStdioDisconnectHandling's `error` listener, not classified here.
@@ -786,7 +789,7 @@ export function installProcessHandlers(): void {
786
789
  );
787
790
  })
788
791
  .on("unhandledRejection", async reason => {
789
- const err = reason instanceof Error ? reason : new Error(String(reason));
792
+ const err = toError(reason);
790
793
  if (handleWorkerSendEpipe(err)) return;
791
794
  if (isExpectedCleanupError(reason)) {
792
795
  logger.warn("Ignoring expected cleanup rejection", { err });
@@ -876,7 +879,7 @@ export function register(
876
879
  const pending = invokeCleanup(registration, reason, keepAlive, cleanupPass);
877
880
  if (!pending) return;
878
881
  const tracked = pending.catch(error => {
879
- const err = error instanceof Error ? error : new Error(String(error));
882
+ const err = toError(error);
880
883
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
881
884
  });
882
885
  // Join the active pass so cleanup()/signal exits await it; after a
@@ -886,7 +889,7 @@ export function register(
886
889
  activePassDeadline?.extend(tracked, registration.deadlineMs, registration.waitMessage);
887
890
  }
888
891
  } catch (error) {
889
- const err = error instanceof Error ? error : new Error(String(error));
892
+ const err = toError(error);
890
893
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
891
894
  }
892
895
  };
package/src/procmgr.ts CHANGED
@@ -142,7 +142,7 @@ export function resolveBasicShell(): string | undefined {
142
142
  * Resolve the external shell to advertise on Windows.
143
143
  *
144
144
  * A host bash is OPTIONAL: bash tool commands always execute in the embedded
145
- * brush-core shell. The resolved binary only serves the spawn-a-shell paths
145
+ * bash engine (pi-shell). The resolved binary only serves the spawn-a-shell paths
146
146
  * (interactive PTY sessions, ACP client terminals, SHELL env), so this
147
147
  * prefers a real Git Bash when one exists and otherwise falls back to
148
148
  * cmd.exe — it never fails.
package/src/ptree.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * - Convenience helpers: captureText / execText, AbortSignal, timeouts.
8
8
  */
9
9
 
10
+ import * as os from "node:os";
10
11
  import { Process, type ProcessTerminateOptions } from "@oh-my-pi/pi-natives";
11
12
  import type { Spawn, Subprocess } from "bun";
12
13
 
@@ -193,8 +194,9 @@ export class ChildProcess<In extends InMask = InMask> {
193
194
  #stderrStream?: ReadableStream<Uint8Array>;
194
195
  // Termination in flight after kill(); aborted exits await it before reporting.
195
196
  #terminating?: Promise<boolean | void>;
196
- // A hard subreaper sweep must remain authoritative across overlapping kill requests.
197
- #hardKillSweep?: Promise<void>;
197
+ // The one stop sequence of a subreaper-supervised command; a later hard
198
+ // kill request ends its grace period, and any other later request is a no-op.
199
+ #subreaperStop?: { escalate: () => void };
198
200
  #terminateGroup: boolean;
199
201
  #hardKillTree: boolean;
200
202
  // Windows has no process groups. Retaining the root's native handle pins
@@ -339,22 +341,31 @@ export class ChildProcess<In extends InMask = InMask> {
339
341
  // group leader; wait() still needs to report the later deadline.
340
342
  if (this.proc.exitCode !== null) this.#exitReason = reason;
341
343
  }
342
- // An AbortSignal can race a timeout after its hard subreaper sweep has
343
- // started. Do not replace that sweep with a normal root termination: the
344
- // root must stay alive until adopted descendants have been collected.
345
- if (this.#hardKillSweep) return;
346
- if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
347
- // Keep the subreaper alive while descendants are killed. A single
348
- // killTree() snapshot can miss a worker whose parent exits during the
349
- // walk and reparents it to the subreaper after that root was enumerated.
350
- const root = Process.fromPid(this.proc.pid);
344
+ // A command under the Linux subreaper is stopped without ever signalling
345
+ // the subreaper politely: SIGTERM would end it, its orphaned descendants
346
+ // would reparent to init, and the SIGKILL that follows would not find a
347
+ // descendant that ignored SIGTERM. One stop sequence serves every kill
348
+ // request; a hard request during its grace period ends the grace early.
349
+ if (this.#subreaperStop) {
350
+ if (gracefulMs !== undefined && gracefulMs < 0) this.#subreaperStop.escalate();
351
+ return;
352
+ }
353
+ if (this.#hardKillTree && this.proc.exitCode === null && this.proc.signalCode === null) {
354
+ let root: Process | null = null;
355
+ try {
356
+ root = Process.fromPid(this.proc.pid);
357
+ } catch {
358
+ // No usable native handle: the direct-signal fallback below still ends the subreaper.
359
+ }
351
360
  if (root) {
352
- const sweep = this.#hardKillSubreaperTree(root).catch(e => void e);
353
- this.#hardKillSweep = sweep;
354
- this.#terminating = sweep;
355
- void sweep.finally(() => {
356
- if (this.#hardKillSweep === sweep) this.#hardKillSweep = undefined;
357
- });
361
+ const { promise: escalated, resolve: escalate } = Promise.withResolvers<void>();
362
+ const graceMs = gracefulMs === undefined ? 1000 : gracefulMs;
363
+ if (graceMs < 0) escalate();
364
+ // A stop that fails part-way (a native handle that cannot be used)
365
+ // must not count as a finished termination: signal the subreaper directly.
366
+ const stop = this.#stopSubreaperTree(root, graceMs, escalated).catch(() => this.#signalChild(graceMs));
367
+ this.#subreaperStop = { escalate };
368
+ this.#terminating = stop;
358
369
  return;
359
370
  }
360
371
  }
@@ -421,6 +432,57 @@ export class ChildProcess<In extends InMask = InMask> {
421
432
  if (this.proc.exitCode === null && this.proc.signalCode === null) signal("SIGKILL");
422
433
  }
423
434
 
435
+ /**
436
+ * Stop a subreaper-supervised command: SIGTERM to each of the subreaper's
437
+ * children and their trees (never the subreaper itself), then, after
438
+ * `gracefulMs`, the subreaper's own exit, or `escalated`, the hard sweep.
439
+ * A negative `gracefulMs` skips the SIGTERM; zero sends it and sweeps at once.
440
+ * The subreaper stays alive through the grace period, so a descendant
441
+ * orphaned in it is still the subreaper's child when the sweep runs.
442
+ *
443
+ * A subreaper with no child has not launched the command yet (or the
444
+ * command has ended). It is looked at again while frozen with SIGSTOP and,
445
+ * when still childless, killed, so a command cancelled before its launch
446
+ * never starts. Freeze, kill and resume all go through `root`, the native
447
+ * handle pinned to the subreaper's identity, never through the numeric pid:
448
+ * once the subreaper has been reaped and its pid reused, they signal
449
+ * nothing. The resume runs on every way out of the frozen section,
450
+ * including a throw; it is a no-op on a killed subreaper. The grace timer is
451
+ * cleared as soon as the sequence moves on, so a finished stop never keeps
452
+ * the caller's process alive.
453
+ */
454
+ async #stopSubreaperTree(root: Process, gracefulMs: number, escalated: Promise<void>): Promise<void> {
455
+ let children = root.children();
456
+ if (children.length === 0) {
457
+ try {
458
+ root.killTree(os.constants.signals.SIGSTOP);
459
+ children = root.children();
460
+ if (children.length === 0) {
461
+ root.killTree(9);
462
+ return;
463
+ }
464
+ // It launched the command just before the freeze: the first wave
465
+ // goes to the snapshot taken while it cannot fork.
466
+ for (const child of children) child.killTree(gracefulMs < 0 ? 9 : 15);
467
+ } finally {
468
+ root.killTree(os.constants.signals.SIGCONT);
469
+ }
470
+ } else {
471
+ // The subreaper launches one command; once it has a child, no later launch can escape the snapshot.
472
+ for (const child of children) child.killTree(gracefulMs < 0 ? 9 : 15);
473
+ }
474
+ if (gracefulMs >= 0) {
475
+ const { promise: graceOver, resolve: endGrace } = Promise.withResolvers<void>();
476
+ const graceTimer = setTimeout(endGrace, gracefulMs);
477
+ try {
478
+ await Promise.race([this.proc.exited, graceOver, escalated]);
479
+ } finally {
480
+ clearTimeout(graceTimer);
481
+ }
482
+ }
483
+ await this.#hardKillSubreaperTree(root);
484
+ }
485
+
424
486
  async #hardKillSubreaperTree(root: Process): Promise<void> {
425
487
  try {
426
488
  const deadline = Date.now() + SUBREAPER_KILL_WINDOW_MS;
@@ -605,7 +667,9 @@ export class ChildProcess<In extends InMask = InMask> {
605
667
  }
606
668
 
607
669
  attachTimeout(ms: number): void {
608
- if (ms <= 0 || this.proc.killed) return;
670
+ // Not skipped for a root Bun already reports as exited (`proc.killed`): it can
671
+ // exit before spawn() returns, while a descendant still holds its pipes.
672
+ if (ms <= 0) return;
609
673
  this.#exited.catch(() => {});
610
674
  // One unref'd deadline controls both termination and pipe collection.
611
675
  // A clean command clears it in wait(), so fast invocations do not hold
@@ -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
  }
@@ -14,9 +14,14 @@
14
14
  * so diagnostics stay greppable and Bun native-crash reports (which abort
15
15
  * before any JS cleanup can restore fd 2) are preserved.
16
16
  *
17
- * Only dup/dup2 go through bun:ffi. fcntl is deliberately avoided: it is
18
- * variadic, and the arm64-darwin ABI passes variadic arguments on the stack,
19
- * so a fixed-arity FFI signature would read garbage for the third argument.
17
+ * Only dup2 goes through bun:ffi. The saved copy of fd 2 comes from opening
18
+ * `/dev/fd/2` instead of `dup(2)`, because Bun opens files close-on-exec: a
19
+ * plain dup would leak the terminal into every child spawned without explicit
20
+ * fd handling (Rust `Command`, gitoxide filter drivers), which could then read
21
+ * the user's keystrokes and terminal replies. fcntl is deliberately avoided:
22
+ * it is variadic, and the arm64-darwin ABI passes variadic arguments on the
23
+ * stack, so a fixed-arity FFI signature would read garbage for the third
24
+ * argument.
20
25
  */
21
26
  import { dlopen, FFIType } from "bun:ffi";
22
27
  import * as fs from "node:fs";
@@ -27,7 +32,6 @@ const STDOUT_FILENO = 1;
27
32
  const STDERR_FILENO = 2;
28
33
 
29
34
  interface LibcFdOps {
30
- dup(fd: number): number;
31
35
  dup2(oldFd: number, newFd: number): number;
32
36
  }
33
37
 
@@ -44,7 +48,6 @@ function libcFdOps(): LibcFdOps | null {
44
48
  for (const candidate of candidates) {
45
49
  try {
46
50
  const libc = dlopen(candidate, {
47
- dup: { args: [FFIType.i32], returns: FFIType.i32 },
48
51
  dup2: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
49
52
  });
50
53
  libcFdOpsCache = libc.symbols;
@@ -102,6 +105,15 @@ export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions):
102
105
  const libc = libcFdOps();
103
106
  if (!libc) return false;
104
107
 
108
+ // First, so a fd 2 that cannot be reopened (a socket: Linux refuses
109
+ // `/dev/fd/N` for those) leaves the guard inert with no redirect file made.
110
+ let saved: number;
111
+ try {
112
+ saved = fs.openSync(`/dev/fd/${STDERR_FILENO}`, fs.constants.O_WRONLY);
113
+ } catch {
114
+ return false;
115
+ }
116
+
105
117
  let redirectFd: number;
106
118
  try {
107
119
  const redirectPath = options?.redirectPath ?? redirectTarget ?? getLogPath();
@@ -115,15 +127,11 @@ export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions):
115
127
  try {
116
128
  redirectFd = fs.openSync("/dev/null", "w");
117
129
  } catch {
130
+ fs.closeSync(saved);
118
131
  return false;
119
132
  }
120
133
  }
121
134
 
122
- const saved = libc.dup(STDERR_FILENO);
123
- if (saved === -1) {
124
- fs.closeSync(redirectFd);
125
- return false;
126
- }
127
135
  if (libc.dup2(redirectFd, STDERR_FILENO) === -1) {
128
136
  fs.closeSync(redirectFd);
129
137
  fs.closeSync(saved);