@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 +21 -0
- package/dist/types/dirs.d.ts +2 -2
- package/dist/types/logger.d.ts +13 -0
- package/dist/types/postmortem.d.ts +21 -5
- package/dist/types/ptree.d.ts +3 -1
- package/package.json +2 -2
- package/src/dirs.ts +3 -3
- package/src/logger.ts +108 -5
- package/src/postmortem.ts +64 -27
- package/src/ptree.ts +25 -9
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
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -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
|
|
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
|
*
|
package/dist/types/logger.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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
|
package/dist/types/ptree.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
79
|
-
filename:
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
82
|
-
*
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
335
|
-
|
|
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 =
|
|
376
|
+
using child = spawnInternal(cmd, resolved, stderr === "full");
|
|
361
377
|
return await child.wait({ stderr, allowAbort, allowNonZero });
|
|
362
378
|
}
|
|
363
379
|
|