@linxiraos/pi-utils 1.1.14 → 1.1.16
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 +2 -52
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/async.d.ts +18 -0
- package/dist/types/browsers.d.ts +2 -30
- package/dist/types/color.d.ts +2 -0
- package/dist/types/dirs.d.ts +21 -0
- package/dist/types/env.d.ts +12 -2
- package/dist/types/executable.d.ts +4 -0
- package/dist/types/fetch-retry.d.ts +8 -2
- package/dist/types/file-lock.d.ts +12 -0
- package/dist/types/format.d.ts +7 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mime.d.ts +1 -0
- package/dist/types/path.d.ts +6 -0
- package/dist/types/peek-file.d.ts +2 -3
- package/dist/types/postmortem.d.ts +26 -5
- package/dist/types/procmgr.d.ts +2 -4
- package/dist/types/snowflake.d.ts +1 -0
- package/dist/types/sqlite.d.ts +27 -7
- package/dist/types/stream.d.ts +76 -1
- package/dist/types/which.d.ts +8 -2
- package/dist/types/yaml-config.d.ts +2 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/async.ts +31 -0
- package/src/browsers.ts +46 -191
- package/src/color.ts +1 -1
- package/src/dirs.ts +34 -6
- package/src/env.ts +81 -34
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +72 -13
- package/src/file-lock.ts +36 -2
- package/src/format.ts +16 -0
- package/src/index.ts +2 -1
- package/src/json.ts +12 -1
- package/src/logger/rotating-file.ts +40 -3
- package/src/logger.ts +16 -4
- package/src/mime.ts +3 -7
- package/src/path.ts +14 -0
- package/src/peek-file.ts +17 -67
- package/src/postmortem.ts +95 -47
- package/src/procmgr.ts +2 -12
- package/src/ptree.ts +36 -5
- package/src/snowflake.ts +12 -1
- package/src/sqlite.ts +242 -7
- package/src/stream.ts +167 -35
- package/src/which.ts +38 -14
- package/src/xml.ts +16 -0
- package/src/yaml-config.ts +8 -0
- package/dist/types/glob.d.ts +0 -28
- package/src/glob.ts +0 -189
package/src/peek-file.ts
CHANGED
|
@@ -1,67 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* queue, falling back to a fresh allocation when the pool and queue are saturated
|
|
7
|
-
* or when `maxBytes` exceeds the pool slot size.
|
|
4
|
+
* Synchronous peeks reuse one growable `Uint8Array`; asynchronous peeks allocate
|
|
5
|
+
* their result window so callback-retained slices cannot be changed by a later read.
|
|
8
6
|
*/
|
|
9
7
|
import * as fs from "node:fs";
|
|
10
8
|
|
|
11
|
-
/** Async pool slot size; larger peeks allocate ad hoc. */
|
|
12
|
-
const POOLED_BUFFER_SIZE = 512;
|
|
13
|
-
const ASYNC_POOL_SIZE = 10;
|
|
14
|
-
/** Cap waiter queue so heavy concurrency does not queue unbounded; overflow uses alloc. */
|
|
15
|
-
const MAX_ASYNC_WAITERS = 4;
|
|
16
9
|
const INITIAL_SYNC_BUFFER_SIZE = 1024;
|
|
17
|
-
const EMPTY_BUFFER =
|
|
10
|
+
const EMPTY_BUFFER = new Uint8Array(0);
|
|
18
11
|
|
|
19
|
-
const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
|
|
20
|
-
const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
|
|
21
|
-
const asyncPoolWaiters: Array<(index: number) => void> = [];
|
|
22
12
|
let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return index;
|
|
29
|
-
}
|
|
30
|
-
if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
|
|
31
|
-
return -1;
|
|
32
|
-
}
|
|
33
|
-
const { promise, resolve } = Promise.withResolvers<number>();
|
|
34
|
-
asyncPoolWaiters.push(resolve);
|
|
35
|
-
return promise;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function releaseAsyncPoolIndex(index: number): void {
|
|
39
|
-
if (index < 0) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
const waiter = asyncPoolWaiters.shift();
|
|
43
|
-
if (waiter) {
|
|
44
|
-
waiter(index);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
availableAsyncPoolIndexes.push(index);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
|
|
51
|
-
if (maxBytes <= 0) {
|
|
52
|
-
return op(EMPTY_BUFFER);
|
|
53
|
-
}
|
|
54
|
-
if (maxBytes > POOLED_BUFFER_SIZE) {
|
|
55
|
-
return op(Buffer.allocUnsafe(maxBytes));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const poolIndex = await acquireAsyncPoolIndex();
|
|
59
|
-
const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
|
|
60
|
-
try {
|
|
61
|
-
return await op(buffer.subarray(0, maxBytes));
|
|
62
|
-
} finally {
|
|
63
|
-
releaseAsyncPoolIndex(poolIndex);
|
|
64
|
-
}
|
|
14
|
+
function allocateWindow(length: number): Uint8Array {
|
|
15
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
16
|
+
// A plain view preserves Uint8Array.slice semantics without zeroing bytes the read replaces.
|
|
17
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
65
18
|
}
|
|
66
19
|
|
|
67
20
|
function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
|
|
@@ -104,10 +57,9 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
|
|
|
104
57
|
|
|
105
58
|
const fileHandle = await fs.promises.open(filePath, "r");
|
|
106
59
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
60
|
+
const buffer = allocateWindow(maxBytes);
|
|
61
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
|
|
62
|
+
return op(buffer.subarray(0, bytesRead));
|
|
111
63
|
} finally {
|
|
112
64
|
await fileHandle.close();
|
|
113
65
|
}
|
|
@@ -116,9 +68,8 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
|
|
|
116
68
|
/**
|
|
117
69
|
* Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
|
|
118
70
|
*
|
|
119
|
-
* The tail mirror of {@link peekFile}:
|
|
120
|
-
*
|
|
121
|
-
* window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
71
|
+
* The tail mirror of {@link peekFile}: the read is positioned at `size - len` so
|
|
72
|
+
* the window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
122
73
|
* returned. A multi-byte codepoint straddling the leading cut decodes to a
|
|
123
74
|
* replacement char — callers that parse line-oriented tails drop the partial
|
|
124
75
|
* leading line anyway.
|
|
@@ -135,10 +86,9 @@ export async function peekFileTail<T>(filePath: string, maxBytes: number, op: (t
|
|
|
135
86
|
if (len <= 0) {
|
|
136
87
|
return op(EMPTY_BUFFER);
|
|
137
88
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
89
|
+
const buffer = allocateWindow(len);
|
|
90
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
|
|
91
|
+
return op(buffer.subarray(0, bytesRead));
|
|
142
92
|
} finally {
|
|
143
93
|
await fileHandle.close();
|
|
144
94
|
}
|
|
@@ -168,7 +118,7 @@ export async function peekFileEnds<T>(
|
|
|
168
118
|
const headLen = prefixBytes > 0 ? Math.min(prefixBytes, size) : 0;
|
|
169
119
|
const tailLen = suffixBytes > 0 ? Math.min(suffixBytes, size) : 0;
|
|
170
120
|
|
|
171
|
-
const head = headLen > 0 ?
|
|
121
|
+
const head = headLen > 0 ? allocateWindow(headLen) : EMPTY_BUFFER;
|
|
172
122
|
const headBytesRead = headLen > 0 ? (await fileHandle.read(head, 0, head.byteLength, 0)).bytesRead : 0;
|
|
173
123
|
const headSlice = head.subarray(0, headBytesRead);
|
|
174
124
|
|
|
@@ -179,7 +129,7 @@ export async function peekFileEnds<T>(
|
|
|
179
129
|
return op(headSlice, headSlice.subarray(Math.max(0, headBytesRead - tailLen)));
|
|
180
130
|
}
|
|
181
131
|
|
|
182
|
-
const tail =
|
|
132
|
+
const tail = allocateWindow(tailLen);
|
|
183
133
|
const { bytesRead: tailBytesRead } = await fileHandle.read(tail, 0, tail.byteLength, size - tailLen);
|
|
184
134
|
return op(headSlice, tail.subarray(0, tailBytesRead));
|
|
185
135
|
} finally {
|
package/src/postmortem.ts
CHANGED
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
-
import inspector from "node:inspector";
|
|
11
|
-
import { isMainThread } from "node:worker_threads";
|
|
12
10
|
import * as logger from "./logger";
|
|
13
11
|
import { restoreTerminalStderr } from "./stderr-guard";
|
|
14
12
|
|
|
@@ -59,6 +57,26 @@ export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit"
|
|
|
59
57
|
|
|
60
58
|
type HardExitFn = (code?: number) => never;
|
|
61
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Walk a guarded exit primitive down to the native it shadows.
|
|
62
|
+
*
|
|
63
|
+
* `withHostGuard` stamps each throwing replacement with the primitive it
|
|
64
|
+
* shadows under {@link NATIVE_PROCESS_EXIT}; nested guard windows stack, so a
|
|
65
|
+
* single unwrap can still land on another throwing stub. Follow the chain
|
|
66
|
+
* (cycle-guarded) until a link carries no stamp — that link is native.
|
|
67
|
+
*/
|
|
68
|
+
function nativeHardExit(fn: HardExitFn | undefined): HardExitFn | undefined {
|
|
69
|
+
let current = fn;
|
|
70
|
+
const seen = new Set<HardExitFn>();
|
|
71
|
+
while (typeof current === "function" && !seen.has(current)) {
|
|
72
|
+
seen.add(current);
|
|
73
|
+
const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
|
|
74
|
+
if (typeof behind !== "function") return current;
|
|
75
|
+
current = behind as HardExitFn;
|
|
76
|
+
}
|
|
77
|
+
return typeof current === "function" ? current : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
/**
|
|
63
81
|
* Hard-exit the process through the native primitive, resolved on every call.
|
|
64
82
|
*
|
|
@@ -70,14 +88,30 @@ type HardExitFn = (code?: number) => never;
|
|
|
70
88
|
* init could freeze the throwing stub forever and turn every later shutdown
|
|
71
89
|
* (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
|
|
72
90
|
* guard is active the stub carries the native exit under
|
|
73
|
-
* {@link NATIVE_PROCESS_EXIT}
|
|
74
|
-
*
|
|
91
|
+
* {@link NATIVE_PROCESS_EXIT} (#6488).
|
|
92
|
+
*
|
|
93
|
+
* Both globals are reinstalled to their natives before exiting: Bun's
|
|
94
|
+
* `process.exit` re-reads `process.reallyExit` at call time, so exiting through
|
|
95
|
+
* one primitive while its sibling still holds the throwing stub re-enters the
|
|
96
|
+
* guard and loops the rejection storm (#11789). After restoring, `reallyExit`
|
|
97
|
+
* (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
|
|
98
|
+
* are fallbacks so a poisoned or absent chain can never leave the process alive.
|
|
75
99
|
*/
|
|
76
|
-
function exitProcess(code: number): never {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
100
|
+
export function exitProcess(code: number): never {
|
|
101
|
+
const reallyExit = nativeHardExit(typeof process.reallyExit === "function" ? process.reallyExit : undefined);
|
|
102
|
+
const exit = nativeHardExit(process.exit as HardExitFn);
|
|
103
|
+
if (reallyExit) process.reallyExit = reallyExit as typeof process.reallyExit;
|
|
104
|
+
if (exit) process.exit = exit as typeof process.exit;
|
|
105
|
+
try {
|
|
106
|
+
reallyExit?.call(process, code);
|
|
107
|
+
} catch {}
|
|
108
|
+
try {
|
|
109
|
+
exit?.call(process, code);
|
|
110
|
+
} catch {}
|
|
111
|
+
try {
|
|
112
|
+
process.kill(process.pid, "SIGKILL");
|
|
113
|
+
} catch {}
|
|
114
|
+
throw new Error(`exitProcess(${code}) failed to terminate the process`);
|
|
81
115
|
}
|
|
82
116
|
let cleanupPromise: Promise<void> | undefined;
|
|
83
117
|
let stdioDisconnectRegistrations = 0;
|
|
@@ -290,19 +324,47 @@ function faultWorkerIpcChannels(err: Error): void {
|
|
|
290
324
|
}
|
|
291
325
|
|
|
292
326
|
/**
|
|
293
|
-
*
|
|
327
|
+
* Graceful shutdown driven by `process.stdout`'s own `error` event.
|
|
294
328
|
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
329
|
+
* A closed stdout consumer (`omp --help | head`, an ACP client dropping the
|
|
330
|
+
* pipe) delivers the broken-pipe write here — attributable to stdout by
|
|
331
|
+
* construction, unlike a process-wide `syscall: "write"` match that a closed
|
|
332
|
+
* subprocess stdin or socket would also satisfy — so it runs cleanup and exits
|
|
333
|
+
* 0 (Unix `| head` semantics).
|
|
334
|
+
*
|
|
335
|
+
* Only the broken-pipe case is claimed. A non-EPIPE stdout error (a revoked PTY
|
|
336
|
+
* reporting `EIO`) is left for other `error` listeners: the TUI installs its own
|
|
337
|
+
* stdout handler that treats a disconnect as SIGHUP/exit-129, and this listener
|
|
338
|
+
* is installed first on an interactive launch, so forcing a fatal exit here
|
|
339
|
+
* would preempt that established path. Attaching a listener already suppresses
|
|
340
|
+
* Node's default throw, so deferring is a safe no-op when no other listener runs.
|
|
341
|
+
*/
|
|
342
|
+
function onStdoutDisconnect(err: Error): void {
|
|
343
|
+
if (classifyBrokenPipe(err) !== "stdio-write") return;
|
|
344
|
+
logger.warn("Stdout peer disconnected; shutting down gracefully", { err });
|
|
345
|
+
void runQuit(0, "native", { drainStdout: false });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Treat a closed stdout consumer as a graceful peer disconnect for the caller's
|
|
350
|
+
* active lifetime. Attaches one shared `process.stdout` `error` listener,
|
|
351
|
+
* ref-counted across registrants (the ACP protocol server, the one-shot CLI
|
|
352
|
+
* entry). The returned callback removes the registration; the listener detaches
|
|
353
|
+
* when the last registrant unregisters.
|
|
298
354
|
*/
|
|
299
355
|
export function registerStdioDisconnectHandling(): () => void {
|
|
300
356
|
let registered = true;
|
|
357
|
+
if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
|
|
358
|
+
process.stdout.on("error", onStdoutDisconnect);
|
|
359
|
+
}
|
|
301
360
|
stdioDisconnectRegistrations++;
|
|
302
361
|
return () => {
|
|
303
362
|
if (!registered) return;
|
|
304
363
|
registered = false;
|
|
305
364
|
stdioDisconnectRegistrations--;
|
|
365
|
+
if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
|
|
366
|
+
process.stdout.removeListener("error", onStdoutDisconnect);
|
|
367
|
+
}
|
|
306
368
|
};
|
|
307
369
|
}
|
|
308
370
|
|
|
@@ -416,48 +478,52 @@ async function exitAfterFatal(output: string, logMessage: string, err: Error, re
|
|
|
416
478
|
}
|
|
417
479
|
}
|
|
418
480
|
|
|
481
|
+
/** Contain an EPIPE from an optional worker IPC `send()` (#2997, #9158). */
|
|
482
|
+
function handleWorkerSendEpipe(err: Error): boolean {
|
|
483
|
+
if (!isIpcSendEpipe(err)) return false;
|
|
484
|
+
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
|
|
419
488
|
/**
|
|
420
489
|
* Reports a caught top-level failure after terminal owners restore their display, then exits.
|
|
421
490
|
*/
|
|
422
491
|
export async function fatal(error: unknown): Promise<never> {
|
|
423
492
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
424
493
|
const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
|
|
425
|
-
if (!isMainThread) {
|
|
494
|
+
if (!Bun.isMainThread) {
|
|
426
495
|
process.stderr.write(output);
|
|
427
496
|
process.exit(1);
|
|
428
497
|
}
|
|
429
498
|
return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
|
|
430
499
|
}
|
|
431
500
|
|
|
432
|
-
if (isMainThread) {
|
|
501
|
+
if (Bun.isMainThread) {
|
|
433
502
|
process
|
|
434
503
|
.on("SIGINT", async () => {
|
|
435
504
|
await runCleanup(Reason.SIGINT);
|
|
436
505
|
exitProcess(130); // 128 + SIGINT (2)
|
|
437
506
|
})
|
|
438
|
-
.on("SIGUSR1", () => {
|
|
507
|
+
.on("SIGUSR1", async () => {
|
|
439
508
|
if (inspectorOpened) return;
|
|
440
509
|
inspectorOpened = true;
|
|
510
|
+
// Signal-only boundary: successful startup never constructs inspector.
|
|
511
|
+
const { default: inspector } = await import("node:inspector");
|
|
441
512
|
inspector.open(undefined, undefined, false);
|
|
442
513
|
const url = inspector.url();
|
|
443
514
|
process.stderr.write(`Inspector opened: ${url}\n`);
|
|
444
515
|
})
|
|
445
516
|
.on("uncaughtException", async thrown => {
|
|
446
|
-
//
|
|
447
|
-
// AbortError/socket classification is limited to promise rejections:
|
|
448
|
-
// a synchronously thrown error may indicate an application bug.
|
|
517
|
+
// Expected cleanup is safe globally; unrelated synchronous errors stay fatal.
|
|
449
518
|
if (hasExpectedCleanupMarker(thrown)) {
|
|
450
519
|
logger.warn("Ignoring expected cleanup exception", { err: thrown });
|
|
451
520
|
return;
|
|
452
521
|
}
|
|
453
522
|
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
if (
|
|
458
|
-
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
523
|
+
// A worker IPC `send()` race can surface through either global error event;
|
|
524
|
+
// contain it in both. Stdout write disconnects are attributed to stdout by
|
|
525
|
+
// registerStdioDisconnectHandling's `error` listener, not classified here.
|
|
526
|
+
if (handleWorkerSendEpipe(err)) return;
|
|
461
527
|
// A malformed advanced-serialization frame from a worker subprocess
|
|
462
528
|
// surfaces here as a process-level uncaughtException (oven-sh/bun#37287)
|
|
463
529
|
// rather than in the channel's ipc() callback, and Bun gives no way to
|
|
@@ -466,7 +532,7 @@ if (isMainThread) {
|
|
|
466
532
|
// worker so its owning client rejects in-flight requests and recycles
|
|
467
533
|
// the subprocess — a worker that sent a bad frame but stays alive would
|
|
468
534
|
// otherwise never fire onExit and leave callers awaiting forever.
|
|
469
|
-
//
|
|
535
|
+
// See the analogous worker IPC containment in handleBrokenPipe (#9158, #2997).
|
|
470
536
|
if (isWorkerIpcDeserializeError(err)) {
|
|
471
537
|
logger.warn("Malformed worker IPC frame; faulting active worker subsystems", { err });
|
|
472
538
|
faultWorkerIpcChannels(err);
|
|
@@ -487,25 +553,7 @@ if (isMainThread) {
|
|
|
487
553
|
})
|
|
488
554
|
.on("unhandledRejection", async reason => {
|
|
489
555
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
490
|
-
|
|
491
|
-
// EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
|
|
492
|
-
// worker subprocess whose pipe broke between the exit being observed
|
|
493
|
-
// and the next `proc.send()` — a race window that Bun surfaces as an
|
|
494
|
-
// async rejection rather than the synchronous "cannot be used after
|
|
495
|
-
// the process has exited" guard. Every `send()` target is an optional
|
|
496
|
-
// worker subsystem (TTS, STT, tiny-title, MCP servers), so a broken
|
|
497
|
-
// send pipe must never take down the whole session. Log and continue
|
|
498
|
-
// instead of exiting; the owning client detects the dead worker via
|
|
499
|
-
// its own `onExit`/error path and respawns or disables it. See #2997.
|
|
500
|
-
if (brokenPipeSource === "ipc-send") {
|
|
501
|
-
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
|
|
505
|
-
logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
|
|
506
|
-
await runQuit(0, "native");
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
556
|
+
if (handleWorkerSendEpipe(err)) return;
|
|
509
557
|
if (isExpectedCleanupError(reason)) {
|
|
510
558
|
logger.warn("Ignoring expected cleanup rejection", { err });
|
|
511
559
|
return;
|
|
@@ -656,7 +704,7 @@ export async function drainStdout(): Promise<void> {
|
|
|
656
704
|
async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
|
|
657
705
|
await runCleanup(Reason.MANUAL);
|
|
658
706
|
|
|
659
|
-
if (!isMainThread) {
|
|
707
|
+
if (!Bun.isMainThread) {
|
|
660
708
|
return; // Workers: cleanup done, let worker exit naturally
|
|
661
709
|
}
|
|
662
710
|
|
package/src/procmgr.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { Process, ProcessStatus } from "@linxiraos/pi-natives";
|
|
|
4
4
|
import type { Subprocess } from "bun";
|
|
5
5
|
import { getAgentDir, MAIN_CONFIG_FILENAMES } from "./dirs";
|
|
6
6
|
import { $env, filterChildShellEnv } from "./env";
|
|
7
|
+
import { isExecutable } from "./executable";
|
|
7
8
|
import { $which } from "./which";
|
|
8
9
|
|
|
10
|
+
export { isExecutable };
|
|
9
11
|
export interface ShellConfig {
|
|
10
12
|
shell: string;
|
|
11
13
|
args: string[];
|
|
@@ -20,18 +22,6 @@ export interface ShellConfigOptions {
|
|
|
20
22
|
}
|
|
21
23
|
let cachedShellConfig: ShellConfig | null = null;
|
|
22
24
|
|
|
23
|
-
/**
|
|
24
|
-
* Check if a shell binary is executable.
|
|
25
|
-
*/
|
|
26
|
-
export function isExecutable(path: string): boolean {
|
|
27
|
-
try {
|
|
28
|
-
fs.accessSync(path, fs.constants.X_OK);
|
|
29
|
-
return true;
|
|
30
|
-
} catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
/**
|
|
36
26
|
* Build the spawn environment (cached).
|
|
37
27
|
*/
|
package/src/ptree.ts
CHANGED
|
@@ -17,6 +17,8 @@ type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe"
|
|
|
17
17
|
|
|
18
18
|
const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
|
|
19
19
|
const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
|
|
20
|
+
const SUBREAPER_KILL_WINDOW_MS = 100;
|
|
21
|
+
const SUBREAPER_KILL_POLL_MS = 5;
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Build the Linux child-subreaper entrypoint.
|
|
@@ -189,6 +191,8 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
189
191
|
#stderrStream?: ReadableStream<Uint8Array>;
|
|
190
192
|
// Termination in flight after kill(); aborted exits await it before reporting.
|
|
191
193
|
#terminating?: Promise<boolean | void>;
|
|
194
|
+
// A hard subreaper sweep must remain authoritative across overlapping kill requests.
|
|
195
|
+
#hardKillSweep?: Promise<void>;
|
|
192
196
|
#terminateGroup: boolean;
|
|
193
197
|
#hardKillTree: boolean;
|
|
194
198
|
// Windows has no process groups. Retaining the root's native handle pins
|
|
@@ -340,14 +344,22 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
340
344
|
// group leader; wait() still needs to report the later deadline.
|
|
341
345
|
if (this.proc.exitCode !== null) this.#exitReason = reason;
|
|
342
346
|
}
|
|
347
|
+
// An AbortSignal can race a timeout after its hard subreaper sweep has
|
|
348
|
+
// started. Do not replace that sweep with a normal root termination: the
|
|
349
|
+
// root must stay alive until adopted descendants have been collected.
|
|
350
|
+
if (this.#hardKillSweep) return;
|
|
343
351
|
if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
352
|
+
// Keep the subreaper alive while descendants are killed. A single
|
|
353
|
+
// killTree() snapshot can miss a worker whose parent exits during the
|
|
354
|
+
// walk and reparents it to the subreaper after that root was enumerated.
|
|
347
355
|
const root = Process.fromPid(this.proc.pid);
|
|
348
356
|
if (root) {
|
|
349
|
-
root.
|
|
350
|
-
this.#
|
|
357
|
+
const sweep = this.#hardKillSubreaperTree(root).catch(e => void e);
|
|
358
|
+
this.#hardKillSweep = sweep;
|
|
359
|
+
this.#terminating = sweep;
|
|
360
|
+
void sweep.finally(() => {
|
|
361
|
+
if (this.#hardKillSweep === sweep) this.#hardKillSweep = undefined;
|
|
362
|
+
});
|
|
351
363
|
return;
|
|
352
364
|
}
|
|
353
365
|
}
|
|
@@ -386,6 +398,25 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
386
398
|
}
|
|
387
399
|
}
|
|
388
400
|
|
|
401
|
+
async #hardKillSubreaperTree(root: Process): Promise<void> {
|
|
402
|
+
try {
|
|
403
|
+
const deadline = Date.now() + SUBREAPER_KILL_WINDOW_MS;
|
|
404
|
+
let emptySweeps = 0;
|
|
405
|
+
while (emptySweeps < 2 && Date.now() < deadline) {
|
|
406
|
+
const children = root.children();
|
|
407
|
+
if (children.length === 0) {
|
|
408
|
+
emptySweeps++;
|
|
409
|
+
} else {
|
|
410
|
+
emptySweeps = 0;
|
|
411
|
+
for (const child of children) child.killTree(9);
|
|
412
|
+
}
|
|
413
|
+
if (emptySweeps < 2) await Bun.sleep(SUBREAPER_KILL_POLL_MS);
|
|
414
|
+
}
|
|
415
|
+
} finally {
|
|
416
|
+
root.killTree(9);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
389
420
|
// ── Output helpers ───────────────────────────────────────────────────
|
|
390
421
|
|
|
391
422
|
async #throwIfAborted(): Promise<void> {
|
package/src/snowflake.ts
CHANGED
|
@@ -4,6 +4,7 @@ function randu32() {
|
|
|
4
4
|
|
|
5
5
|
const EPOCH = 1420070400000;
|
|
6
6
|
const MAX_SEQ = 0x3fffff;
|
|
7
|
+
const MAX_DT = 2 ** 42 - 1;
|
|
7
8
|
|
|
8
9
|
// Snowflake as a hex string (16 chars, zero-padded).
|
|
9
10
|
//
|
|
@@ -25,14 +26,24 @@ namespace Snowflake {
|
|
|
25
26
|
//
|
|
26
27
|
export const MAX_SEQUENCE = MAX_SEQ;
|
|
27
28
|
|
|
29
|
+
// Last timestamp representable in the 42-bit timestamp field (~year 2154).
|
|
30
|
+
//
|
|
31
|
+
export const MAX_TIMESTAMP = EPOCH + MAX_DT;
|
|
32
|
+
|
|
28
33
|
// Formats a sequence and timestamp into a snowflake hex string.
|
|
29
34
|
//
|
|
30
35
|
// dt fits well within BigInt range: (dt << 22) | seq stays under 2^64 for
|
|
31
36
|
// any dt < 2^42 (~year 2154), so a single 64-bit format is exact — and
|
|
32
37
|
// measures ~1.7x faster than stitching four 16-bit hex segments.
|
|
33
38
|
//
|
|
39
|
+
// dt is saturated into [0, 2^42) so the result is always a valid snowflake:
|
|
40
|
+
// a negative delta (a timestamp before EPOCH) would otherwise render a
|
|
41
|
+
// leading "-", and a delta past ~2154 would widen the string beyond 16
|
|
42
|
+
// chars. Both cases produce a value that fails this module's own valid().
|
|
43
|
+
//
|
|
34
44
|
export function formatParts(dt: number, seq: number): Snowflake {
|
|
35
|
-
|
|
45
|
+
const clamped = Math.min(Math.max(dt, 0), MAX_DT);
|
|
46
|
+
return ((BigInt(clamped) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
// Snowflake generator type.
|