@oh-my-pi/pi-utils 17.0.1 → 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,21 @@
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
+
5
20
  ## [17.0.1] - 2026-07-16
6
21
 
7
22
  ### 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
@@ -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.1",
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.1",
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 ".";
@@ -68,7 +70,6 @@ function runCleanup(reason: Reason): Promise<void> {
68
70
  cleanupStage = "complete";
69
71
  deadline.resolve();
70
72
  }, CLEANUP_DEADLINE_MS);
71
- deadlineTimer.unref();
72
73
  cleanupPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
73
74
  clearTimeout(deadlineTimer);
74
75
  });
@@ -175,6 +176,23 @@ function formatFatalError(label: string, err: Error): string {
175
176
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
176
177
  }
177
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
+
178
196
  if (isMainThread) {
179
197
  process
180
198
  .on("SIGINT", async () => {
@@ -193,15 +211,7 @@ if (isMainThread) {
193
211
  logger.warn("Ignoring expected cleanup exception", { err });
194
212
  return;
195
213
  }
196
- // fd 2 may be redirected to the log while a TUI owns the terminal
197
- // (stderr-guard); re-point it at the real terminal so the fatal
198
- // report is visible. Terminal modes are restored moments later by
199
- // the terminal-restore cleanup callback inside runCleanup().
200
- restoreTerminalStderr();
201
- process.stderr.write(formatFatalError("Uncaught Exception", err));
202
- logger.error("Uncaught exception", { err });
203
- await runCleanup(Reason.UNCAUGHT_EXCEPTION);
204
- process.exit(1);
214
+ await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
205
215
  })
206
216
  .on("unhandledRejection", async reason => {
207
217
  const err = reason instanceof Error ? reason : new Error(String(reason));
@@ -237,12 +247,7 @@ if (isMainThread) {
237
247
  });
238
248
  }
239
249
  }
240
- // See uncaughtException above: surface the report on the real stderr.
241
- restoreTerminalStderr();
242
- process.stderr.write(formatFatalError("Unhandled Rejection", err));
243
- logger.error("Unhandled rejection", { err });
244
- await runCleanup(Reason.UNHANDLED_REJECTION);
245
- process.exit(1);
250
+ await exitAfterFatal("Unhandled Rejection", "Unhandled rejection", err, Reason.UNHANDLED_REJECTION);
246
251
  })
247
252
  .on("exit", async () => {
248
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