@oh-my-pi/pi-utils 18.16.0 → 18.18.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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.0] - 2026-11-07
6
+
7
+ ### Fixed
8
+
9
+ - Fixed abort signals leaving command output reads blocked when a descendant keeps stdout or stderr open after the root process exits. ([#18309](https://github.com/can1357/oh-my-pi/pull/18309) by [@eggpeat](https://github.com/eggpeat))
10
+
11
+ ## [18.17.0] - 2026-11-06
12
+
13
+ ### Added
14
+
15
+ - Added `getUpdateStateDir()`, returning the auto-update state directory (`~/.omp/update`).
16
+ - Added `logger.setLogForwarder()` and `logger.emitForeign()` to forward log records across threads, preserving context and timestamps.
17
+ - Added `expandTilde()` to expand a leading `~` to the home directory.
18
+
19
+ ### Fixed
20
+
21
+ - Fixed `spawn`/`exec` timeouts never firing when the command's root exited before `spawn()` returned, which let a descendant holding stdout or stderr stall output reads until it exited.
22
+ - `ptree` commands with `subreaper: true` now reliably terminate all descendants on graceful shutdown.
23
+ - `fatal()` in a worker thread now reaches the parent's `error` listeners instead of only exiting with code 1.
24
+ - Fixed the terminal stderr guard leaking a copy of the terminal into processes spawned from native code.
25
+
5
26
  ## [18.16.0] - 2026-11-02
6
27
 
7
28
  ### Added
@@ -142,6 +142,12 @@ export declare function getAgentDir(): string;
142
142
  export declare function getProjectAgentDir(cwd?: string): string;
143
143
  /** Get the reports directory (~/.omp/reports). */
144
144
  export declare function getReportsDir(): string;
145
+ /**
146
+ * Get the self-update state directory (~/.omp/update): the background
147
+ * auto-update's status file and worker log, and the lock that keeps two
148
+ * installs (`omp update`, auto-update) from running at once.
149
+ */
150
+ export declare function getUpdateStateDir(): string;
145
151
  /**
146
152
  * Get the logs directory (~/.omp/logs). `bun test` processes get
147
153
  * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
@@ -9,6 +9,35 @@ export interface LogEvent {
9
9
  }
10
10
  /** Receives each structured log event after the local transport path runs. */
11
11
  export type LogSink = (event: LogEvent) => void;
12
+ /**
13
+ * Destination that takes this thread's log records in place of its own
14
+ * transports and sinks (a worker thread routing records to the main thread's
15
+ * logger); see {@link setLogForwarder}.
16
+ */
17
+ export interface LogForwarder {
18
+ /**
19
+ * Take one record. Its context is already merged over the enclosing
20
+ * {@link withContext} fields and reduced to plain JSON data, so it survives
21
+ * a structured clone; `timestamp` is when the record was logged.
22
+ */
23
+ emit(event: LogEvent): void;
24
+ /** Write every record emitted so far on the receiving side; backs {@link flush}. */
25
+ flush(): void;
26
+ }
27
+ /**
28
+ * Route every later record logged on this thread to `forwarder` instead of the
29
+ * local transports and sinks, which then stay unused (no log file is opened);
30
+ * {@link flush} asks the forwarder to flush. `undefined` restores local logging.
31
+ * For worker threads, whose main thread owns the process log file.
32
+ */
33
+ export declare function setLogForwarder(forwarder: LogForwarder | undefined): void;
34
+ /**
35
+ * Log a record another thread forwarded (see {@link setLogForwarder}): written
36
+ * to the local transports with its own timestamp, then handed to the sinks. Its
37
+ * context is taken as is; the caller's enclosing {@link withContext} is not
38
+ * merged in. On a thread that forwards itself, the record is forwarded on.
39
+ */
40
+ export declare function emitForeign(event: LogEvent): void;
12
41
  /** Register an out-of-band log sink and return a disposer. */
13
42
  export declare function registerLogSink(sink: LogSink): () => void;
14
43
  /**
@@ -8,3 +8,5 @@ export declare function stripWindowsExtendedLengthPathPrefix(filePath: string, p
8
8
  * On POSIX, requires an absolute path.
9
9
  */
10
10
  export declare function isFullyQualifiedPath(filePath: string, platform?: NodeJS.Platform): boolean;
11
+ /** Expands a leading `~` (`~`, `~/…`, `~\…`, `~name`) to `home`, the user's home directory by default. */
12
+ export declare function expandTilde(filePath: string, home?: string): string;
@@ -40,7 +40,7 @@ export declare function resolveBasicShell(): string | undefined;
40
40
  * Resolve the external shell to advertise on Windows.
41
41
  *
42
42
  * A host bash is OPTIONAL: bash tool commands always execute in the embedded
43
- * brush-core shell. The resolved binary only serves the spawn-a-shell paths
43
+ * bash engine (pi-shell). The resolved binary only serves the spawn-a-shell paths
44
44
  * (interactive PTY sessions, ACP client terminals, SHELL env), so this
45
45
  * prefers a real Git Bash when one exists and otherwise falls back to
46
46
  * cmd.exe — it never fails.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.16.0",
3
+ "version": "18.18.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -49,15 +49,15 @@
49
49
  },
50
50
  "scripts": {
51
51
  "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
52
- "check:types": "tsgo -p tsconfig.json --noEmit",
52
+ "check:types": "bun check --no-script -p tsconfig.json",
53
53
  "lint": "oxlint .",
54
54
  "test": "bun test --parallel",
55
55
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
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/omptype": "18.16.0",
60
- "@oh-my-pi/pi-natives": "18.16.0"
59
+ "@oh-my-pi/omptype": "18.18.0",
60
+ "@oh-my-pi/pi-natives": "18.18.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/dirs.ts CHANGED
@@ -656,6 +656,15 @@ export function getReportsDir(): string {
656
656
  return dirs.rootSubdir("reports", "state");
657
657
  }
658
658
 
659
+ /**
660
+ * Get the self-update state directory (~/.omp/update): the background
661
+ * auto-update's status file and worker log, and the lock that keeps two
662
+ * installs (`omp update`, auto-update) from running at once.
663
+ */
664
+ export function getUpdateStateDir(): string {
665
+ return dirs.rootSubdir("update", "state");
666
+ }
667
+
659
668
  /**
660
669
  * Get the logs directory (~/.omp/logs). `bun test` processes get
661
670
  * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
package/src/logger.ts CHANGED
@@ -25,6 +25,7 @@ import { RotatingFileSink } from "./logger/rotating-file";
25
25
  import { isPidAlive } from "./pid";
26
26
  import { setStderrRedirectTarget } from "./stderr-guard";
27
27
  import { drainModuleLoadEvents } from "./timing-buffer";
28
+ import { isRecord } from "./type-guards";
28
29
  /** Severity names accepted by the centralized logger. */
29
30
  export type LogLevel = "error" | "warn" | "info" | "debug";
30
31
 
@@ -39,8 +40,103 @@ export interface LogEvent {
39
40
  /** Receives each structured log event after the local transport path runs. */
40
41
  export type LogSink = (event: LogEvent) => void;
41
42
 
43
+ /**
44
+ * Destination that takes this thread's log records in place of its own
45
+ * transports and sinks (a worker thread routing records to the main thread's
46
+ * logger); see {@link setLogForwarder}.
47
+ */
48
+ export interface LogForwarder {
49
+ /**
50
+ * Take one record. Its context is already merged over the enclosing
51
+ * {@link withContext} fields and reduced to plain JSON data, so it survives
52
+ * a structured clone; `timestamp` is when the record was logged.
53
+ */
54
+ emit(event: LogEvent): void;
55
+ /** Write every record emitted so far on the receiving side; backs {@link flush}. */
56
+ flush(): void;
57
+ }
58
+
42
59
  const logSinks = new Set<LogSink>();
43
60
 
61
+ /** Set by {@link setLogForwarder}: records go there instead of to local transports and sinks. */
62
+ let logForwarder: LogForwarder | undefined;
63
+
64
+ /**
65
+ * Route every later record logged on this thread to `forwarder` instead of the
66
+ * local transports and sinks, which then stay unused (no log file is opened);
67
+ * {@link flush} asks the forwarder to flush. `undefined` restores local logging.
68
+ * For worker threads, whose main thread owns the process log file.
69
+ */
70
+ export function setLogForwarder(forwarder: LogForwarder | undefined): void {
71
+ logForwarder = forwarder;
72
+ }
73
+
74
+ /**
75
+ * Log a record another thread forwarded (see {@link setLogForwarder}): written
76
+ * to the local transports with its own timestamp, then handed to the sinks. Its
77
+ * context is taken as is; the caller's enclosing {@link withContext} is not
78
+ * merged in. On a thread that forwards itself, the record is forwarded on.
79
+ */
80
+ export function emitForeign(event: LogEvent): void {
81
+ const forwarder = logForwarder;
82
+ if (forwarder) {
83
+ try {
84
+ forwarder.emit(event);
85
+ } catch {
86
+ // Forwarding is as best-effort as local writes.
87
+ }
88
+ return;
89
+ }
90
+ try {
91
+ emitLocally(event.level, event.message, event.context, event.timestamp);
92
+ } catch {
93
+ // Silently ignore logging failures
94
+ }
95
+ dispatchToSinks(event);
96
+ }
97
+
98
+ /** Forward one record logged on this thread; never throws. */
99
+ function forwardRecord(
100
+ forwarder: LogForwarder,
101
+ level: LogLevel,
102
+ message: string,
103
+ context: Record<string, unknown> | undefined,
104
+ ): void {
105
+ try {
106
+ forwarder.emit({ level, message, context: forwardableContext(context), timestamp: new Date() });
107
+ } catch {
108
+ // Forwarding is as best-effort as local writes.
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The call's context merged over the enclosing {@link withContext} fields (call
114
+ * fields win, as for sinks), as plain JSON data: errors become
115
+ * `{ name, message, stack, … }` exactly as in the log file. A context that
116
+ * cannot be serialized (a cycle, a bigint) is dropped from the record.
117
+ */
118
+ function forwardableContext(context: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
119
+ // An Error passed as the context carries its fields as non-enumerable properties.
120
+ let merged = context instanceof Error ? errorToRecord(context) : context;
121
+ const scoped = logContextStorage.getStore();
122
+ if (scoped) {
123
+ try {
124
+ merged = { ...scoped, ...merged };
125
+ } catch {
126
+ // A throwing getter must not turn a log call into a failure; keep the call's own fields.
127
+ }
128
+ }
129
+ if (merged === undefined) return undefined;
130
+ try {
131
+ const parsed: unknown = JSON.parse(
132
+ mayContainError(merged) ? JSON.stringify(merged, jsonReplacer) : JSON.stringify(merged),
133
+ );
134
+ return isRecord(parsed) ? parsed : undefined;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
44
140
  /** Register an out-of-band log sink and return a disposer. */
45
141
  export function registerLogSink(sink: LogSink): () => void {
46
142
  logSinks.add(sink);
@@ -62,7 +158,11 @@ function emitToSinks(level: LogLevel, message: string, context: Record<string, u
62
158
  // A throwing getter must not turn a log call into a failure; sinks get the call's own fields.
63
159
  }
64
160
  }
65
- const event: LogEvent = { level, message, context: merged, timestamp: new Date() };
161
+ dispatchToSinks({ level, message, context: merged, timestamp: new Date() });
162
+ }
163
+
164
+ function dispatchToSinks(event: LogEvent): void {
165
+ if (logSinks.size === 0) return;
66
166
  for (const sink of logSinks) {
67
167
  try {
68
168
  sink(event);
@@ -199,19 +299,21 @@ function ensureDir(dir: string): string {
199
299
  * forensic logs showed only an opaque empty object.
200
300
  */
201
301
  function jsonReplacer(_key: string, value: unknown): unknown {
202
- if (value instanceof Error) {
203
- const out: Record<string, unknown> = {
204
- name: value.name,
205
- message: value.message,
206
- stack: value.stack,
207
- };
208
- // Preserve `.cause` and any custom enumerable fields the caller attached.
209
- const errAsRecord = value as unknown as Record<string, unknown>;
210
- for (const k in errAsRecord) out[k] = errAsRecord[k];
211
- if (value.cause !== undefined) out.cause = value.cause;
212
- return out;
213
- }
214
- return value;
302
+ return value instanceof Error ? errorToRecord(value) : value;
303
+ }
304
+
305
+ /** An Error's fields as a plain object: name, message, stack, enumerable extras, and `cause`. */
306
+ function errorToRecord(value: Error): Record<string, unknown> {
307
+ const out: Record<string, unknown> = {
308
+ name: value.name,
309
+ message: value.message,
310
+ stack: value.stack,
311
+ };
312
+ // Preserve `.cause` and any custom enumerable fields the caller attached.
313
+ const errAsRecord = value as unknown as Record<string, unknown>;
314
+ for (const k in errAsRecord) out[k] = errAsRecord[k];
315
+ if (value.cause !== undefined) out.cause = value.cause;
316
+ return out;
215
317
  }
216
318
 
217
319
  // Values visited by the Error pre-scan before falling back to the replacer.
@@ -278,12 +380,16 @@ export function withoutContext<T>(fn: () => T): T {
278
380
  return logContextStorage.exit(fn);
279
381
  }
280
382
 
281
- /** Serialize one log record: timestamp, level, pid, message, then context fields. */
383
+ /**
384
+ * Serialize one log record: timestamp, level, pid, message, then the `scoped`
385
+ * ({@link withContext}) fields and the context fields.
386
+ */
282
387
  function formatLogRecord(
283
388
  level: LogLevel,
284
389
  message: string,
285
390
  context: Record<string, unknown> | undefined,
286
391
  timestamp: string,
392
+ scoped: Readonly<Record<string, unknown>> | undefined,
287
393
  ): string {
288
394
  const metadata =
289
395
  !FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
@@ -293,7 +399,6 @@ function formatLogRecord(
293
399
  pid: process.pid,
294
400
  message: metadata?.message ? `${message} ${metadata.message}` : message,
295
401
  };
296
- const scoped = logContextStorage.getStore();
297
402
  if (scoped) {
298
403
  for (const key of Object.keys(scoped)) {
299
404
  if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = scoped[key];
@@ -370,7 +475,8 @@ function resolveFileLevelRank(): number {
370
475
  */
371
476
  export function flush(): void {
372
477
  try {
373
- activeTransports?.file?.flush();
478
+ if (logForwarder) logForwarder.flush();
479
+ else activeTransports?.file?.flush();
374
480
  } catch {
375
481
  // Flushing diagnostics must never mask the caller's own work or failure.
376
482
  }
@@ -405,13 +511,25 @@ function getLocalTransports(): LocalTransports {
405
511
  return activeTransports;
406
512
  }
407
513
 
408
- function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
514
+ /**
515
+ * Write one record to the local transports. A `foreignTimestamp` marks a record
516
+ * another thread logged ({@link emitForeign}): it keeps that time and gets no
517
+ * {@link withContext} fields of this call stack, while the file still rotates by
518
+ * the current day so a late record cannot reopen an earlier day's file.
519
+ */
520
+ function emitLocally(
521
+ level: LogLevel,
522
+ message: string,
523
+ context: Record<string, unknown> | undefined,
524
+ foreignTimestamp?: Date,
525
+ ): void {
409
526
  const transports = getLocalTransports();
410
527
  const rank = LOG_LEVEL_RANK[level];
411
528
  const file = rank <= transports.fileLevelRank ? transports.file : undefined;
412
529
  if (!file && !transports.console) return;
413
530
  const now = new Date();
414
- const line = formatLogRecord(level, message, context, formatLocalTimestamp(now));
531
+ const scoped = foreignTimestamp ? undefined : logContextStorage.getStore();
532
+ const line = formatLogRecord(level, message, context, formatLocalTimestamp(foreignTimestamp ?? now), scoped);
415
533
  file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
416
534
  if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
417
535
  }
@@ -438,6 +556,7 @@ export function setTransports(opts: { console?: boolean; file?: boolean | string
438
556
  * @param context - The context to log.
439
557
  */
440
558
  export function error(message: string, context?: Record<string, unknown>): void {
559
+ if (logForwarder) return forwardRecord(logForwarder, "error", message, context);
441
560
  try {
442
561
  emitLocally("error", message, context);
443
562
  } catch {
@@ -452,6 +571,7 @@ export function error(message: string, context?: Record<string, unknown>): void
452
571
  * @param context - The context to log.
453
572
  */
454
573
  export function warn(message: string, context?: Record<string, unknown>): void {
574
+ if (logForwarder) return forwardRecord(logForwarder, "warn", message, context);
455
575
  try {
456
576
  emitLocally("warn", message, context);
457
577
  } catch {
@@ -466,6 +586,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
466
586
  * @param context - The context to log.
467
587
  */
468
588
  export function info(message: string, context?: Record<string, unknown>): void {
589
+ if (logForwarder) return forwardRecord(logForwarder, "info", message, context);
469
590
  try {
470
591
  emitLocally("info", message, context);
471
592
  } catch {
@@ -480,6 +601,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
480
601
  * @param context - The context to log.
481
602
  */
482
603
  export function debug(message: string, context?: Record<string, unknown>): void {
604
+ if (logForwarder) return forwardRecord(logForwarder, "debug", message, context);
483
605
  try {
484
606
  emitLocally("debug", message, context);
485
607
  } catch {
package/src/path.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as os from "node:os";
1
2
  import * as path from "node:path";
2
3
 
3
4
  const WINDOWS_DRIVE_PATH = /^([A-Za-z]):[\\/](.*)$/;
@@ -54,3 +55,16 @@ export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform
54
55
  }
55
56
  return true;
56
57
  }
58
+
59
+ /** Expands a leading `~` (`~`, `~/…`, `~\…`, `~name`) to `home`, the user's home directory by default. */
60
+ export function expandTilde(filePath: string, home?: string): string {
61
+ const h = home ?? os.homedir();
62
+ if (filePath === "~") return h;
63
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
64
+ return h + filePath.slice(1);
65
+ }
66
+ if (filePath.startsWith("~")) {
67
+ return path.join(h, filePath.slice(1));
68
+ }
69
+ return filePath;
70
+ }
package/src/postmortem.ts CHANGED
@@ -713,6 +713,8 @@ export async function fatal(error: unknown): Promise<never> {
713
713
  const report = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n`;
714
714
  if (!Bun.isMainThread) {
715
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);
716
718
  process.exit(1);
717
719
  }
718
720
  return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
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
 
@@ -183,18 +184,22 @@ export class ChildProcess<In extends InMask = InMask> {
183
184
  #stderrDone: Promise<void>;
184
185
  #exited: Promise<number>;
185
186
  #openPipeReaders = 1;
186
- // Set by attachTimeout() at the command deadline; untimed commands keep
187
- // complete EOF-based capture. Active pipe readers are cancelled at cutoff so
188
- // their pending read() settles as done and partial output is kept.
187
+ // Set at the command deadline or on abort; otherwise commands keep complete
188
+ // EOF-based capture. Active pipe readers are cancelled at cutoff so their
189
+ // pending read() settles as done and partial output is kept.
189
190
  #cutoff = false;
190
- /** Readers of piped stdio; the timeout cancels them so pending reads settle. */
191
+ /** Readers of piped stdio; timeout and abort cancel them so pending reads settle. */
191
192
  #pipeReaders = new Set<{ cancel(reason?: unknown): Promise<void> }>();
193
+ // Each registration reconciles its listener with root-exit and reader activity.
194
+ // Keeping the registration after idle lets a later output read re-arm the signal.
195
+ #abortRegistrations?: Set<() => void>;
192
196
  #timeoutTimer?: NodeJS.Timeout;
193
197
  #stderrStream?: ReadableStream<Uint8Array>;
194
198
  // Termination in flight after kill(); aborted exits await it before reporting.
195
199
  #terminating?: Promise<boolean | void>;
196
- // A hard subreaper sweep must remain authoritative across overlapping kill requests.
197
- #hardKillSweep?: Promise<void>;
200
+ // The one stop sequence of a subreaper-supervised command; a later hard
201
+ // kill request ends its grace period, and any other later request is a no-op.
202
+ #subreaperStop?: { escalate: () => void };
198
203
  #terminateGroup: boolean;
199
204
  #hardKillTree: boolean;
200
205
  // Windows has no process groups. Retaining the root's native handle pins
@@ -247,11 +252,13 @@ export class ChildProcess<In extends InMask = InMask> {
247
252
  this.#openPipeReaders--;
248
253
  this.#stderrTail += dec.decode();
249
254
  trim();
255
+ this.#syncAbortListeners();
250
256
  })();
251
257
 
252
258
  proc.exited
253
259
  .catch(() => null)
254
260
  .then(async exitCode => {
261
+ this.#syncAbortListeners();
255
262
  if (this.#exitReasonPending) {
256
263
  this.#exitReason = this.#exitReasonPending;
257
264
  reject(this.#exitReasonPending);
@@ -339,22 +346,31 @@ export class ChildProcess<In extends InMask = InMask> {
339
346
  // group leader; wait() still needs to report the later deadline.
340
347
  if (this.proc.exitCode !== null) this.#exitReason = reason;
341
348
  }
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);
349
+ // A command under the Linux subreaper is stopped without ever signalling
350
+ // the subreaper politely: SIGTERM would end it, its orphaned descendants
351
+ // would reparent to init, and the SIGKILL that follows would not find a
352
+ // descendant that ignored SIGTERM. One stop sequence serves every kill
353
+ // request; a hard request during its grace period ends the grace early.
354
+ if (this.#subreaperStop) {
355
+ if (gracefulMs !== undefined && gracefulMs < 0) this.#subreaperStop.escalate();
356
+ return;
357
+ }
358
+ if (this.#hardKillTree && this.proc.exitCode === null && this.proc.signalCode === null) {
359
+ let root: Process | null = null;
360
+ try {
361
+ root = Process.fromPid(this.proc.pid);
362
+ } catch {
363
+ // No usable native handle: the direct-signal fallback below still ends the subreaper.
364
+ }
351
365
  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
- });
366
+ const { promise: escalated, resolve: escalate } = Promise.withResolvers<void>();
367
+ const graceMs = gracefulMs === undefined ? 1000 : gracefulMs;
368
+ if (graceMs < 0) escalate();
369
+ // A stop that fails part-way (a native handle that cannot be used)
370
+ // must not count as a finished termination: signal the subreaper directly.
371
+ const stop = this.#stopSubreaperTree(root, graceMs, escalated).catch(() => this.#signalChild(graceMs));
372
+ this.#subreaperStop = { escalate };
373
+ this.#terminating = stop;
358
374
  return;
359
375
  }
360
376
  }
@@ -421,6 +437,57 @@ export class ChildProcess<In extends InMask = InMask> {
421
437
  if (this.proc.exitCode === null && this.proc.signalCode === null) signal("SIGKILL");
422
438
  }
423
439
 
440
+ /**
441
+ * Stop a subreaper-supervised command: SIGTERM to each of the subreaper's
442
+ * children and their trees (never the subreaper itself), then, after
443
+ * `gracefulMs`, the subreaper's own exit, or `escalated`, the hard sweep.
444
+ * A negative `gracefulMs` skips the SIGTERM; zero sends it and sweeps at once.
445
+ * The subreaper stays alive through the grace period, so a descendant
446
+ * orphaned in it is still the subreaper's child when the sweep runs.
447
+ *
448
+ * A subreaper with no child has not launched the command yet (or the
449
+ * command has ended). It is looked at again while frozen with SIGSTOP and,
450
+ * when still childless, killed, so a command cancelled before its launch
451
+ * never starts. Freeze, kill and resume all go through `root`, the native
452
+ * handle pinned to the subreaper's identity, never through the numeric pid:
453
+ * once the subreaper has been reaped and its pid reused, they signal
454
+ * nothing. The resume runs on every way out of the frozen section,
455
+ * including a throw; it is a no-op on a killed subreaper. The grace timer is
456
+ * cleared as soon as the sequence moves on, so a finished stop never keeps
457
+ * the caller's process alive.
458
+ */
459
+ async #stopSubreaperTree(root: Process, gracefulMs: number, escalated: Promise<void>): Promise<void> {
460
+ let children = root.children();
461
+ if (children.length === 0) {
462
+ try {
463
+ root.killTree(os.constants.signals.SIGSTOP);
464
+ children = root.children();
465
+ if (children.length === 0) {
466
+ root.killTree(9);
467
+ return;
468
+ }
469
+ // It launched the command just before the freeze: the first wave
470
+ // goes to the snapshot taken while it cannot fork.
471
+ for (const child of children) child.killTree(gracefulMs < 0 ? 9 : 15);
472
+ } finally {
473
+ root.killTree(os.constants.signals.SIGCONT);
474
+ }
475
+ } else {
476
+ // The subreaper launches one command; once it has a child, no later launch can escape the snapshot.
477
+ for (const child of children) child.killTree(gracefulMs < 0 ? 9 : 15);
478
+ }
479
+ if (gracefulMs >= 0) {
480
+ const { promise: graceOver, resolve: endGrace } = Promise.withResolvers<void>();
481
+ const graceTimer = setTimeout(endGrace, gracefulMs);
482
+ try {
483
+ await Promise.race([this.proc.exited, graceOver, escalated]);
484
+ } finally {
485
+ clearTimeout(graceTimer);
486
+ }
487
+ }
488
+ await this.#hardKillSubreaperTree(root);
489
+ }
490
+
424
491
  async #hardKillSubreaperTree(root: Process): Promise<void> {
425
492
  try {
426
493
  const deadline = Date.now() + SUBREAPER_KILL_WINDOW_MS;
@@ -460,12 +527,13 @@ export class ChildProcess<In extends InMask = InMask> {
460
527
  }
461
528
 
462
529
  /**
463
- * Read a pipe fully, stopping early only at an explicit command deadline.
530
+ * Read a pipe fully, stopping early only at an explicit command deadline or abort.
464
531
  */
465
532
  async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
466
- this.#openPipeReaders++;
467
533
  const reader = stream.getReader();
534
+ this.#openPipeReaders++;
468
535
  this.#pipeReaders.add(reader);
536
+ this.#syncAbortListeners();
469
537
  const dec = new TextDecoder();
470
538
  let out = "";
471
539
  try {
@@ -481,15 +549,18 @@ export class ChildProcess<In extends InMask = InMask> {
481
549
  } catch {
482
550
  // A cancelled or failed read keeps whatever was already collected.
483
551
  }
552
+ const tail = dec.decode();
484
553
  this.#pipeReaders.delete(reader);
485
554
  this.#openPipeReaders--;
486
- return out + dec.decode();
555
+ this.#syncAbortListeners();
556
+ return out + tail;
487
557
  }
488
558
 
489
559
  async #readBytes(): Promise<Uint8Array<ArrayBuffer>> {
490
560
  const reader = this.proc.stdout.getReader();
491
561
  this.#openPipeReaders++;
492
562
  this.#pipeReaders.add(reader);
563
+ this.#syncAbortListeners();
493
564
  const chunks: Uint8Array[] = [];
494
565
  let length = 0;
495
566
  try {
@@ -509,6 +580,7 @@ export class ChildProcess<In extends InMask = InMask> {
509
580
  this.#pipeReaders.delete(reader);
510
581
  this.#openPipeReaders--;
511
582
  reader.releaseLock();
583
+ this.#syncAbortListeners();
512
584
  }
513
585
  const bytes = new Uint8Array(length);
514
586
  let offset = 0;
@@ -591,11 +663,43 @@ export class ChildProcess<In extends InMask = InMask> {
591
663
  // ── Signal / timeout ─────────────────────────────────────────────────
592
664
 
593
665
  attachSignal(signal: AbortSignal): void {
594
- const cleanup = this.#exited.catch(() => {});
595
- const onAbort = () => this.kill(new AbortError(signal.reason, "<cancelled>"));
596
- if (signal.aborted) return void onAbort();
597
- signal.addEventListener("abort", onAbort, { once: true });
598
- cleanup.finally(() => signal.removeEventListener("abort", onAbort));
666
+ this.#exited.catch(() => {});
667
+ let attached = false;
668
+ const onAbort = () => {
669
+ // The { once: true } listener has removed itself at this point.
670
+ attached = false;
671
+ const rootRunning = this.proc.exitCode === null && this.proc.signalCode === null;
672
+ if (rootRunning || this.#openPipeReaders > 0) {
673
+ this.#abortRegistrations?.delete(sync);
674
+ }
675
+ this.kill(new AbortError(signal.reason, "<cancelled>"));
676
+ this.#cutoffOutput();
677
+ };
678
+ const sync = () => {
679
+ const rootRunning = this.proc.exitCode === null && this.proc.signalCode === null;
680
+ const active = rootRunning || this.#openPipeReaders > 0;
681
+ if (signal.aborted) {
682
+ if (active) onAbort();
683
+ return;
684
+ }
685
+ if (active === attached) return;
686
+ if (active) signal.addEventListener("abort", onAbort, { once: true });
687
+ else signal.removeEventListener("abort", onAbort);
688
+ attached = active;
689
+ };
690
+ (this.#abortRegistrations ??= new Set()).add(sync);
691
+ if (signal.aborted) onAbort();
692
+ else sync();
693
+ }
694
+
695
+ #syncAbortListeners(): void {
696
+ if (!this.#abortRegistrations) return;
697
+ for (const sync of this.#abortRegistrations) sync();
698
+ }
699
+
700
+ #cutoffOutput(): void {
701
+ this.#cutoff = true;
702
+ for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
599
703
  }
600
704
 
601
705
  #clearTimeout(): void {
@@ -605,7 +709,9 @@ export class ChildProcess<In extends InMask = InMask> {
605
709
  }
606
710
 
607
711
  attachTimeout(ms: number): void {
608
- if (ms <= 0 || this.proc.killed) return;
712
+ // Not skipped for a root Bun already reports as exited (`proc.killed`): it can
713
+ // exit before spawn() returns, while a descendant still holds its pipes.
714
+ if (ms <= 0) return;
609
715
  this.#exited.catch(() => {});
610
716
  // One unref'd deadline controls both termination and pipe collection.
611
717
  // A clean command clears it in wait(), so fast invocations do not hold
@@ -620,8 +726,7 @@ export class ChildProcess<In extends InMask = InMask> {
620
726
  ) {
621
727
  this.kill(new TimeoutError(ms, this.#stderrTail), -1);
622
728
  }
623
- this.#cutoff = true;
624
- for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
729
+ this.#cutoffOutput();
625
730
  }, ms);
626
731
  timer.unref?.();
627
732
  this.#timeoutTimer = timer;
@@ -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);