@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 +21 -0
- package/dist/types/dirs.d.ts +6 -0
- package/dist/types/logger.d.ts +29 -0
- package/dist/types/path.d.ts +2 -0
- package/dist/types/procmgr.d.ts +1 -1
- package/package.json +4 -4
- package/src/dirs.ts +9 -0
- package/src/logger.ts +141 -19
- package/src/path.ts +14 -0
- package/src/postmortem.ts +2 -0
- package/src/procmgr.ts +1 -1
- package/src/ptree.ts +137 -32
- package/src/stderr-guard.ts +18 -10
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
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -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
|
package/dist/types/logger.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/types/path.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/procmgr.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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": "
|
|
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.
|
|
60
|
-
"@oh-my-pi/pi-natives": "18.
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
197
|
-
|
|
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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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.#
|
|
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;
|
package/src/stderr-guard.ts
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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);
|