@oh-my-pi/pi-utils 17.0.0 → 17.0.2

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
+ ## [17.0.2] - 2026-07-17
6
+
7
+ ### Added
8
+
9
+ - Added a structured log sink API (`registerLogSink`, `LogEvent`, `LogLevel`) to the centralized logger, enabling out-of-band consumers (such as OpenTelemetry) to receive log events without affecting local file or console logging.
10
+
11
+ ### Changed
12
+
13
+ - Bounded default `ptree.ChildProcess` stderr retention to a 32 KiB tail to prevent memory leaks in long-lived subprocesses. Full stderr capture must now be explicitly requested at spawn time using `{ stderr: "full" }` on `spawn` or `exec`.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed fatal cleanup failing to reach `process.exit()` when terminal stderr is revoked.
18
+ - Isolated rotating log files and audit state per process to prevent concurrent instances from racing during compression and rotation.
19
+
20
+ ## [17.0.1] - 2026-07-16
21
+
22
+ ### Fixed
23
+
24
+ - Added scoped graceful handling for stdio-write EPIPE rejections so protocol servers can await postmortem cleanup when their peer disconnects ([#4788](https://github.com/can1357/oh-my-pi/issues/4788)).
25
+
5
26
  ## [17.0.0] - 2026-07-15
6
27
 
7
28
  ### Fixed
@@ -102,8 +102,8 @@ export declare function getProjectAgentDir(cwd?: string): string;
102
102
  export declare function getReportsDir(): string;
103
103
  /** Get the logs directory (~/.omp/logs). */
104
104
  export declare function getLogsDir(): string;
105
- /** Get the path to a dated log file (~/.omp/logs/omp.YYYY-MM-DD.log). */
106
- export declare function getLogPath(date?: Date): string;
105
+ /** Get this process's dated log path (~/.omp/logs/omp.YYYY-MM-DD.PID.log). */
106
+ export declare function getLogPath(date?: Date, pid?: number): string;
107
107
  /**
108
108
  * Get the plugins directory (~/.omp/plugins or its XDG equivalent).
109
109
  *
@@ -1,3 +1,16 @@
1
+ /** Severity names accepted by the centralized logger. */
2
+ export type LogLevel = "error" | "warn" | "info" | "debug";
3
+ /** Structured log event forwarded to out-of-band sinks such as OpenTelemetry. */
4
+ export interface LogEvent {
5
+ readonly level: LogLevel;
6
+ readonly message: string;
7
+ readonly context: Record<string, unknown> | undefined;
8
+ readonly timestamp: Date;
9
+ }
10
+ /** Receives each structured log event after the local transport path runs. */
11
+ export type LogSink = (event: LogEvent) => void;
12
+ /** Register an out-of-band log sink and return a disposer. */
13
+ export declare function registerLogSink(sink: LogSink): () => void;
1
14
  /**
2
15
  * Replace the active log transports. Pass `console: true, file: false` for
3
16
  * long-running services (the auth broker, etc.) that want their structured
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Cleanup and postmortem handler utilities.
3
+ *
4
+ * This module provides a system for registering and running cleanup callbacks
5
+ * in response to process exit, signals, or fatal exceptions. It is intended to
6
+ * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
+ */
1
8
  export declare enum Reason {
2
9
  PRE_EXIT = "pre_exit",// Pre-exit phase (not used by default)
3
10
  EXIT = "exit",// Normal process exit
@@ -8,14 +15,23 @@ export declare enum Reason {
8
15
  UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
9
16
  MANUAL = "manual"
10
17
  }
18
+ /** Origin of an EPIPE raised by a process communication channel. */
19
+ export type BrokenPipeSource = "ipc-send" | "stdio-write";
11
20
  /**
12
- * Detect an EPIPE rejection that originated from an IPC `send()` to a worker
13
- * subprocess (`syscall: "send"`), as opposed to a stdin/stdout pipe write
14
- * (`syscall: "write"`). Only the IPC-send path can break an optional worker
15
- * subsystem without affecting the main process, so only this shape is safe to
16
- * swallow at the global `unhandledRejection` level. See issue #2997.
21
+ * Classify EPIPE errors from worker IPC and stdio without treating unrelated
22
+ * broken pipes as globally recoverable.
17
23
  */
24
+ export declare function classifyBrokenPipe(err: Error): BrokenPipeSource | undefined;
25
+ /** Whether an EPIPE came from an IPC `send()` to an optional worker. */
18
26
  export declare function isIpcSendEpipe(err: Error): boolean;
27
+ /**
28
+ * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
29
+ *
30
+ * Stdio protocol servers call this for their process lifetime so a closed
31
+ * client pipe runs registered cleanup callbacks instead of the fatal path.
32
+ * The returned callback removes the registration.
33
+ */
34
+ export declare function registerStdioDisconnectHandling(): () => void;
19
35
  /**
20
36
  * Mark an error as expected cleanup fallout so the global fatal handlers
21
37
  * downgrade it to a log line instead of tearing down the process. Use for
@@ -40,6 +40,7 @@ export declare class TimeoutError extends AbortError {
40
40
  export interface WaitOptions {
41
41
  allowNonZero?: boolean;
42
42
  allowAbort?: boolean;
43
+ /** `full` requires upfront capture; `exec` enables it, while direct `spawn` callers pass `stderr: "full"`. */
43
44
  stderr?: "full" | "buffer";
44
45
  }
45
46
  /** Result from wait and exec. */
@@ -62,7 +63,7 @@ export declare class ChildProcess<In extends InMask = InMask> {
62
63
  #private;
63
64
  readonly proc: PipedSubprocess<In>;
64
65
  readonly exposeStderr: boolean;
65
- constructor(proc: PipedSubprocess<In>, exposeStderr: boolean);
66
+ constructor(proc: PipedSubprocess<In>, exposeStderr: boolean, retainFullStderr?: boolean);
66
67
  get pid(): number;
67
68
  get exited(): Promise<number>;
68
69
  get exitCode(): number | null;
@@ -92,6 +93,7 @@ export declare class ChildProcess<In extends InMask = InMask> {
92
93
  type ChildSpawnOptions<In extends InMask = InMask> = Omit<Spawn.SpawnOptions<In, "pipe", "pipe">, "stdout" | "stderr" | "detached"> & {
93
94
  signal?: AbortSignal;
94
95
  detached?: boolean;
96
+ /** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
95
97
  stderr?: "full" | null;
96
98
  };
97
99
  /** Spawn a child process with piped stdout/stderr. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "17.0.0",
4
+ "version": "17.0.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "17.0.0",
34
+ "@oh-my-pi/pi-natives": "17.0.2",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/dirs.ts CHANGED
@@ -513,9 +513,9 @@ export function getLogsDir(): string {
513
513
  return dirs.rootSubdir("logs", "state");
514
514
  }
515
515
 
516
- /** Get the path to a dated log file (~/.omp/logs/omp.YYYY-MM-DD.log). */
517
- export function getLogPath(date = new Date()): string {
518
- return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`);
516
+ /** Get this process's dated log path (~/.omp/logs/omp.YYYY-MM-DD.PID.log). */
517
+ export function getLogPath(date = new Date(), pid = process.pid): string {
518
+ return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.${pid}.log`);
519
519
  }
520
520
 
521
521
  /**
package/src/logger.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Centralized logger for omp.
3
3
  *
4
- * Default: rotating `~/.omp/logs/omp.<DATE>.log`, no console output (writing
4
+ * Default: rotating `~/.omp/logs/omp.<DATE>.<PID>.log`, no console output (writing
5
5
  * to stdout/stderr would corrupt the TUI). Long-running headless services
6
6
  * (the auth broker, etc.) call {@link setTransports} to swap in a console
7
7
  * transport so a process supervisor (pm2, journald, k8s) captures the logs.
@@ -11,11 +11,107 @@
11
11
  */
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import * as fs from "node:fs";
14
+ import * as path from "node:path";
14
15
  import { isPromise } from "node:util/types";
15
16
  import winston from "winston";
16
17
  import DailyRotateFile from "winston-daily-rotate-file";
17
18
  import { getLogsDir } from "./dirs";
18
19
  import { drainModuleLoadEvents } from "./timing-buffer";
20
+ /** Severity names accepted by the centralized logger. */
21
+ export type LogLevel = "error" | "warn" | "info" | "debug";
22
+
23
+ /** Structured log event forwarded to out-of-band sinks such as OpenTelemetry. */
24
+ export interface LogEvent {
25
+ readonly level: LogLevel;
26
+ readonly message: string;
27
+ readonly context: Record<string, unknown> | undefined;
28
+ readonly timestamp: Date;
29
+ }
30
+
31
+ /** Receives each structured log event after the local transport path runs. */
32
+ export type LogSink = (event: LogEvent) => void;
33
+
34
+ const logSinks = new Set<LogSink>();
35
+
36
+ /** Register an out-of-band log sink and return a disposer. */
37
+ export function registerLogSink(sink: LogSink): () => void {
38
+ logSinks.add(sink);
39
+ return () => {
40
+ logSinks.delete(sink);
41
+ };
42
+ }
43
+
44
+ function emitToSinks(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
45
+ if (logSinks.size === 0) return;
46
+ const event: LogEvent = { level, message, context, timestamp: new Date() };
47
+ for (const sink of logSinks) {
48
+ try {
49
+ sink(event);
50
+ } catch {
51
+ // Sinks are side channels; they must never break local logging.
52
+ }
53
+ }
54
+ }
55
+
56
+ const PROCESS_LOG_PATTERN = /^omp\.\d{4}-\d{2}-\d{2}\.(\d+)\.log(?:\.\d+)?$/;
57
+ const PROCESS_AUDIT_PATTERN = /^\.omp\.(\d+)-audit\.json$/;
58
+ const RETAINED_STALE_LOG_FILES = 5;
59
+
60
+ function processIsRunning(pid: number): boolean {
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ } catch (error) {
65
+ return !(error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL"));
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Retain the newest completed-process logs globally and remove their one-use
71
+ * audit files. Live PID namespaces are never touched.
72
+ */
73
+ function pruneStaleProcessLogs(dir: string): void {
74
+ let entries: fs.Dirent[];
75
+ try {
76
+ entries = fs.readdirSync(dir, { withFileTypes: true });
77
+ } catch {
78
+ return;
79
+ }
80
+
81
+ const staleLogs: Array<{ path: string; mtimeMs: number }> = [];
82
+ for (const entry of entries) {
83
+ if (!entry.isFile()) continue;
84
+ const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
85
+ const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
86
+ const pidText = logMatch?.[1] ?? auditMatch?.[1];
87
+ if (!pidText || processIsRunning(Number(pidText))) continue;
88
+ const entryPath = path.join(dir, entry.name);
89
+
90
+ if (auditMatch) {
91
+ try {
92
+ fs.rmSync(entryPath, { force: true });
93
+ } catch {
94
+ // Retention is best-effort; logging must still initialize.
95
+ }
96
+ continue;
97
+ }
98
+
99
+ try {
100
+ staleLogs.push({ path: entryPath, mtimeMs: fs.statSync(entryPath).mtimeMs });
101
+ } catch {
102
+ // Another process may have pruned the same stale namespace.
103
+ }
104
+ }
105
+
106
+ staleLogs.sort((a, b) => b.mtimeMs - a.mtimeMs);
107
+ for (const stale of staleLogs.slice(RETAINED_STALE_LOG_FILES)) {
108
+ try {
109
+ fs.rmSync(stale.path, { force: true });
110
+ } catch {
111
+ // Another process may have pruned the same stale namespace.
112
+ }
113
+ }
114
+ }
19
115
 
20
116
  /** Ensure a logs directory exists; return the resolved path. */
21
117
  function ensureDir(dir: string): string {
@@ -72,15 +168,18 @@ function getLogFormat(): winston.Logform.Format {
72
168
  return logFormat;
73
169
  }
74
170
 
75
- /** Build a rotating file transport, materializing the target directory lazily. */
171
+ /** Build a rotating file transport with process-local rotation and shared retention. */
76
172
  function makeFileTransport(dir?: string): winston.transport {
173
+ const logsDir = ensureDir(dir ?? getLogsDir());
174
+ pruneStaleProcessLogs(logsDir);
77
175
  return new DailyRotateFile({
78
- dirname: ensureDir(dir ?? getLogsDir()),
79
- filename: "omp.%DATE%.log",
176
+ dirname: logsDir,
177
+ filename: `omp.%DATE%.${process.pid}.log`,
80
178
  datePattern: "YYYY-MM-DD",
81
179
  maxSize: "10m",
82
180
  maxFiles: 5,
83
- zippedArchive: true,
181
+ zippedArchive: false,
182
+ auditFile: path.join(logsDir, `.omp.${process.pid}-audit.json`),
84
183
  });
85
184
  }
86
185
 
@@ -148,6 +247,7 @@ export function error(message: string, context?: Record<string, unknown>): void
148
247
  } catch {
149
248
  // Silently ignore logging failures
150
249
  }
250
+ emitToSinks("error", message, context);
151
251
  }
152
252
 
153
253
  /**
@@ -161,6 +261,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
161
261
  } catch {
162
262
  // Silently ignore logging failures
163
263
  }
264
+ emitToSinks("warn", message, context);
164
265
  }
165
266
 
166
267
  /**
@@ -174,6 +275,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
174
275
  } catch {
175
276
  // Silently ignore logging failures
176
277
  }
278
+ emitToSinks("info", message, context);
177
279
  }
178
280
 
179
281
  /**
@@ -187,6 +289,7 @@ export function debug(message: string, context?: Record<string, unknown>): void
187
289
  } catch {
188
290
  // Silently ignore logging failures
189
291
  }
292
+ emitToSinks("debug", message, context);
190
293
  }
191
294
 
192
295
  /**
package/src/postmortem.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * in response to process exit, signals, or fatal exceptions. It is intended to
6
6
  * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
7
  */
8
+
9
+ import * as fs from "node:fs";
8
10
  import inspector from "node:inspector";
9
11
  import { isMainThread } from "node:worker_threads";
10
12
  import { logger } from ".";
@@ -27,6 +29,8 @@ const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
27
29
  // Tracks cleanup run state (to prevent recursion/reentry issues)
28
30
  let cleanupStage: "idle" | "running" | "complete" = "idle";
29
31
  const CLEANUP_DEADLINE_MS = 10_000;
32
+ let cleanupPromise: Promise<void> | undefined;
33
+ let stdioDisconnectRegistrations = 0;
30
34
 
31
35
  /**
32
36
  * Internal: runs all registered cleanup callbacks for the given reason.
@@ -40,7 +44,7 @@ function runCleanup(reason: Reason): Promise<void> {
40
44
  cleanupStage = "running";
41
45
  break;
42
46
  case "running":
43
- return Promise.resolve();
47
+ return cleanupPromise ?? Promise.resolve();
44
48
  case "complete":
45
49
  return Promise.resolve();
46
50
  }
@@ -66,10 +70,10 @@ function runCleanup(reason: Reason): Promise<void> {
66
70
  cleanupStage = "complete";
67
71
  deadline.resolve();
68
72
  }, CLEANUP_DEADLINE_MS);
69
- deadlineTimer.unref();
70
- return Promise.race([cleanupSettled, deadline.promise]).finally(() => {
73
+ cleanupPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
71
74
  clearTimeout(deadlineTimer);
72
75
  });
76
+ return cleanupPromise;
73
77
  }
74
78
 
75
79
  // Register signal and error event handlers to trigger cleanup before exit.
@@ -77,17 +81,40 @@ function runCleanup(reason: Reason): Promise<void> {
77
81
  // Worker thread: exit only (workers use self.addEventListener for exceptions)
78
82
  let inspectorOpened = false;
79
83
 
84
+ /** Origin of an EPIPE raised by a process communication channel. */
85
+ export type BrokenPipeSource = "ipc-send" | "stdio-write";
86
+
80
87
  /**
81
- * Detect an EPIPE rejection that originated from an IPC `send()` to a worker
82
- * subprocess (`syscall: "send"`), as opposed to a stdin/stdout pipe write
83
- * (`syscall: "write"`). Only the IPC-send path can break an optional worker
84
- * subsystem without affecting the main process, so only this shape is safe to
85
- * swallow at the global `unhandledRejection` level. See issue #2997.
88
+ * Classify EPIPE errors from worker IPC and stdio without treating unrelated
89
+ * broken pipes as globally recoverable.
86
90
  */
91
+ export function classifyBrokenPipe(err: Error): BrokenPipeSource | undefined {
92
+ if (!("code" in err) || err.code !== "EPIPE" || !("syscall" in err)) return undefined;
93
+ if (err.syscall === "send") return "ipc-send";
94
+ if (err.syscall === "write") return "stdio-write";
95
+ return undefined;
96
+ }
97
+
98
+ /** Whether an EPIPE came from an IPC `send()` to an optional worker. */
87
99
  export function isIpcSendEpipe(err: Error): boolean {
88
- const code = (err as { code?: unknown }).code;
89
- const syscall = (err as { syscall?: unknown }).syscall;
90
- return code === "EPIPE" && syscall === "send";
100
+ return classifyBrokenPipe(err) === "ipc-send";
101
+ }
102
+
103
+ /**
104
+ * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
105
+ *
106
+ * Stdio protocol servers call this for their process lifetime so a closed
107
+ * client pipe runs registered cleanup callbacks instead of the fatal path.
108
+ * The returned callback removes the registration.
109
+ */
110
+ export function registerStdioDisconnectHandling(): () => void {
111
+ let registered = true;
112
+ stdioDisconnectRegistrations++;
113
+ return () => {
114
+ if (!registered) return;
115
+ registered = false;
116
+ stdioDisconnectRegistrations--;
117
+ };
91
118
  }
92
119
 
93
120
  // Well-known key marking an error as an *expected* teardown artifact (e.g. a
@@ -149,6 +176,23 @@ function formatFatalError(label: string, err: Error): string {
149
176
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
150
177
  }
151
178
 
179
+ async function exitAfterFatal(label: string, logMessage: string, err: Error, reason: Reason): Promise<void> {
180
+ const forcedExit = setTimeout(() => process.exit(1), CLEANUP_DEADLINE_MS);
181
+ try {
182
+ restoreTerminalStderr();
183
+ // A revoked terminal can make stream writes raise another fatal error. Use
184
+ // the descriptor directly so failure stays synchronous and contained.
185
+ try {
186
+ fs.writeSync(2, formatFatalError(label, err));
187
+ } catch {}
188
+ logger.error(logMessage, { err });
189
+ await runCleanup(reason);
190
+ } finally {
191
+ clearTimeout(forcedExit);
192
+ process.exit(1);
193
+ }
194
+ }
195
+
152
196
  if (isMainThread) {
153
197
  process
154
198
  .on("SIGINT", async () => {
@@ -167,18 +211,11 @@ if (isMainThread) {
167
211
  logger.warn("Ignoring expected cleanup exception", { err });
168
212
  return;
169
213
  }
170
- // fd 2 may be redirected to the log while a TUI owns the terminal
171
- // (stderr-guard); re-point it at the real terminal so the fatal
172
- // report is visible. Terminal modes are restored moments later by
173
- // the terminal-restore cleanup callback inside runCleanup().
174
- restoreTerminalStderr();
175
- process.stderr.write(formatFatalError("Uncaught Exception", err));
176
- logger.error("Uncaught exception", { err });
177
- await runCleanup(Reason.UNCAUGHT_EXCEPTION);
178
- process.exit(1);
214
+ await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
179
215
  })
180
216
  .on("unhandledRejection", async reason => {
181
217
  const err = reason instanceof Error ? reason : new Error(String(reason));
218
+ const brokenPipeSource = classifyBrokenPipe(err);
182
219
  // EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
183
220
  // worker subprocess whose pipe broke between the exit being observed
184
221
  // and the next `proc.send()` — a race window that Bun surfaces as an
@@ -188,10 +225,15 @@ if (isMainThread) {
188
225
  // send pipe must never take down the whole session. Log and continue
189
226
  // instead of exiting; the owning client detects the dead worker via
190
227
  // its own `onExit`/error path and respawns or disables it. See #2997.
191
- if (isIpcSendEpipe(err)) {
228
+ if (brokenPipeSource === "ipc-send") {
192
229
  logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
193
230
  return;
194
231
  }
232
+ if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
233
+ logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
234
+ await quit(0);
235
+ return;
236
+ }
195
237
  if (isExpectedCleanupError(reason)) {
196
238
  logger.warn("Ignoring expected cleanup rejection", { err });
197
239
  return;
@@ -205,12 +247,7 @@ if (isMainThread) {
205
247
  });
206
248
  }
207
249
  }
208
- // See uncaughtException above: surface the report on the real stderr.
209
- restoreTerminalStderr();
210
- process.stderr.write(formatFatalError("Unhandled Rejection", err));
211
- logger.error("Unhandled rejection", { err });
212
- await runCleanup(Reason.UNHANDLED_REJECTION);
213
- process.exit(1);
250
+ await exitAfterFatal("Unhandled Rejection", "Unhandled rejection", err, Reason.UNHANDLED_REJECTION);
214
251
  })
215
252
  .on("exit", async () => {
216
253
  void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
package/src/ptree.ts CHANGED
@@ -72,6 +72,7 @@ export class TimeoutError extends AbortError {
72
72
  export interface WaitOptions {
73
73
  allowNonZero?: boolean;
74
74
  allowAbort?: boolean;
75
+ /** `full` requires upfront capture; `exec` enables it, while direct `spawn` callers pass `stderr: "full"`. */
75
76
  stderr?: "full" | "buffer";
76
77
  }
77
78
 
@@ -97,7 +98,7 @@ export interface ExecResult {
97
98
  export class ChildProcess<In extends InMask = InMask> {
98
99
  #nothrow = false;
99
100
  #stderrTail = "";
100
- #stderrChunks: Uint8Array[] = [];
101
+ #stderrChunks?: Uint8Array[];
101
102
  #exitReason?: Exception;
102
103
  #exitReasonPending?: Exception;
103
104
  #stderrDone: Promise<void>;
@@ -107,8 +108,10 @@ export class ChildProcess<In extends InMask = InMask> {
107
108
  constructor(
108
109
  readonly proc: PipedSubprocess<In>,
109
110
  readonly exposeStderr: boolean,
111
+ retainFullStderr = exposeStderr,
110
112
  ) {
111
- // Eagerly drain stderr into a truncated tail string + raw chunks.
113
+ if (retainFullStderr) this.#stderrChunks = [];
114
+ // Eagerly drain stderr into a truncated tail, retaining raw chunks only for explicit full capture.
112
115
  const dec = new TextDecoder();
113
116
  const trim = () => {
114
117
  if (this.#stderrTail.length > NonZeroExitError.MAX_TRACE)
@@ -123,7 +126,7 @@ export class ChildProcess<In extends InMask = InMask> {
123
126
  this.#stderrDone = (async () => {
124
127
  try {
125
128
  for await (const chunk of stderrStream) {
126
- this.#stderrChunks.push(chunk);
129
+ this.#stderrChunks?.push(chunk);
127
130
  this.#stderrTail += dec.decode(chunk, { stream: true });
128
131
  trim();
129
132
  }
@@ -259,11 +262,15 @@ export class ChildProcess<In extends InMask = InMask> {
259
262
 
260
263
  async wait(opts?: WaitOptions): Promise<ExecResult> {
261
264
  const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = opts ?? {};
265
+ const stderrChunks = this.#stderrChunks;
266
+ if (stderrMode === "full" && !stderrChunks) {
267
+ throw new Error('Full stderr capture must be requested when spawning the process (pass stderr: "full")');
268
+ }
262
269
 
263
270
  const stdoutP = new Response(this.stdout).text();
264
271
  const stderrP =
265
- stderrMode === "full"
266
- ? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(this.#stderrChunks)))
272
+ stderrMode === "full" && stderrChunks
273
+ ? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(stderrChunks)))
267
274
  : this.#stderrDone.then(() => this.#stderrTail);
268
275
 
269
276
  const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
@@ -328,11 +335,15 @@ type ChildSpawnOptions<In extends InMask = InMask> = Omit<
328
335
  > & {
329
336
  signal?: AbortSignal;
330
337
  detached?: boolean;
338
+ /** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
331
339
  stderr?: "full" | null;
332
340
  };
333
341
 
334
- /** Spawn a child process with piped stdout/stderr. */
335
- export function spawn<In extends InMask = InMask>(cmd: string[], opts?: ChildSpawnOptions<In>): ChildProcess<In> {
342
+ function spawnInternal<In extends InMask = InMask>(
343
+ cmd: string[],
344
+ opts: ChildSpawnOptions<In> | undefined,
345
+ retainFullStderr: boolean,
346
+ ): ChildProcess<In> {
336
347
  const { timeout = -1, signal, stderr, ...rest } = opts ?? {};
337
348
  const child = Bun.spawn(cmd, {
338
349
  stdin: "ignore",
@@ -341,12 +352,17 @@ export function spawn<In extends InMask = InMask>(cmd: string[], opts?: ChildSpa
341
352
  windowsHide: true,
342
353
  ...rest,
343
354
  });
344
- const cp = new ChildProcess(child, stderr === "full");
355
+ const cp = new ChildProcess(child, stderr === "full", retainFullStderr);
345
356
  if (signal) cp.attachSignal(signal);
346
357
  if (timeout > 0) cp.attachTimeout(timeout);
347
358
  return cp;
348
359
  }
349
360
 
361
+ /** Spawn a child process with piped stdout/stderr. */
362
+ export function spawn<In extends InMask = InMask>(cmd: string[], opts?: ChildSpawnOptions<In>): ChildProcess<In> {
363
+ return spawnInternal(cmd, opts, opts?.stderr === "full");
364
+ }
365
+
350
366
  /** Options for exec. */
351
367
  export interface ExecOptions extends Omit<ChildSpawnOptions, "stderr" | "stdin">, WaitOptions {
352
368
  input?: string | Buffer | Uint8Array;
@@ -357,7 +373,7 @@ export async function exec(cmd: string[], opts?: ExecOptions): Promise<ExecResul
357
373
  const { input, stderr, allowAbort, allowNonZero, ...spawnOpts } = opts ?? {};
358
374
  const stdin = typeof input === "string" ? Buffer.from(input) : input;
359
375
  const resolved: ChildSpawnOptions = stdin === undefined ? spawnOpts : { ...spawnOpts, stdin };
360
- using child = spawn(cmd, resolved);
376
+ using child = spawnInternal(cmd, resolved, stderr === "full");
361
377
  return await child.wait({ stderr, allowAbort, allowNonZero });
362
378
  }
363
379