@linxiraos/pi-utils 1.1.5 → 1.1.7
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 +9 -0
- package/README.md +1 -0
- package/dist/types/acp/transport.d.ts +7 -0
- package/dist/types/async.d.ts +2 -0
- package/dist/types/color.d.ts +34 -0
- package/dist/types/dirs.d.ts +44 -25
- package/dist/types/index.d.ts +1 -0
- package/dist/types/math-delimiters.d.ts +45 -0
- package/dist/types/postmortem.d.ts +46 -9
- package/dist/types/ptree.d.ts +15 -1
- package/dist/types/runtime-install.d.ts +8 -1
- package/dist/types/sqlite.d.ts +3 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +9 -0
- package/src/async.ts +20 -6
- package/src/color.ts +173 -0
- package/src/dirs.ts +121 -60
- package/src/env.ts +2 -2
- package/src/index.ts +1 -0
- package/src/math-delimiters.ts +143 -0
- package/src/postmortem.ts +212 -63
- package/src/ptree.ts +308 -39
- package/src/runtime-install.ts +77 -26
- package/src/sqlite.ts +6 -0
package/src/ptree.ts
CHANGED
|
@@ -15,6 +15,83 @@ type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
|
|
|
15
15
|
/** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
|
|
16
16
|
type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
|
|
17
17
|
|
|
18
|
+
const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
|
|
19
|
+
const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the Linux child-subreaper entrypoint.
|
|
23
|
+
*
|
|
24
|
+
* @internal Exported so tests can force a missing first libc soname and verify
|
|
25
|
+
* the loader continues to the next candidate.
|
|
26
|
+
*/
|
|
27
|
+
export function createLinuxSubreaperScript(libcCandidates: readonly string[] = ["libc.so.6", "libc.so"]): string {
|
|
28
|
+
return `
|
|
29
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
30
|
+
|
|
31
|
+
let libc;
|
|
32
|
+
for (const soname of ${JSON.stringify(libcCandidates)}) {
|
|
33
|
+
try {
|
|
34
|
+
libc = dlopen(soname, {
|
|
35
|
+
prctl: {
|
|
36
|
+
args: [FFIType.i32, FFIType.u64, FFIType.u64, FFIType.u64, FFIType.u64],
|
|
37
|
+
returns: FFIType.i32,
|
|
38
|
+
},
|
|
39
|
+
waitpid: {
|
|
40
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.i32],
|
|
41
|
+
returns: FFIType.i32,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
break;
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
if (!libc) throw new Error("failed to load libc for Linux child supervision");
|
|
48
|
+
|
|
49
|
+
if (libc.symbols.prctl(36, 1, 0, 0, 0) !== 0) {
|
|
50
|
+
throw new Error("failed to become a Linux child subreaper");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const commandJson = Bun.env.${LINUX_SUBREAPER_COMMAND_ENV};
|
|
54
|
+
if (!commandJson) throw new Error("missing supervised command");
|
|
55
|
+
const callerBunBeBun = Bun.env.${LINUX_SUBREAPER_BUN_BE_BUN_ENV};
|
|
56
|
+
delete Bun.env.${LINUX_SUBREAPER_COMMAND_ENV};
|
|
57
|
+
delete Bun.env.${LINUX_SUBREAPER_BUN_BE_BUN_ENV};
|
|
58
|
+
if (callerBunBeBun === undefined) delete Bun.env.BUN_BE_BUN;
|
|
59
|
+
else Bun.env.BUN_BE_BUN = callerBunBeBun;
|
|
60
|
+
const command = JSON.parse(commandJson);
|
|
61
|
+
const child = Bun.spawn(command, {
|
|
62
|
+
stdin: "inherit",
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
env: Bun.env,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function relay(stream, destination) {
|
|
70
|
+
const writer = destination.writer();
|
|
71
|
+
for await (const chunk of stream) writer.write(chunk);
|
|
72
|
+
await writer.flush();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasLiveChildren() {
|
|
76
|
+
let childPid;
|
|
77
|
+
do {
|
|
78
|
+
childPid = libc.symbols.waitpid(-1, null, 1);
|
|
79
|
+
} while (childPid > 0);
|
|
80
|
+
return childPid === 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [exitCode] = await Promise.all([
|
|
84
|
+
child.exited,
|
|
85
|
+
relay(child.stdout, Bun.stdout),
|
|
86
|
+
relay(child.stderr, Bun.stderr),
|
|
87
|
+
]);
|
|
88
|
+
while (hasLiveChildren()) await Bun.sleep(10);
|
|
89
|
+
process.exit(exitCode ?? 1);
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const LINUX_SUBREAPER_SCRIPT = createLinuxSubreaperScript();
|
|
94
|
+
|
|
18
95
|
// ── Exceptions ───────────────────────────────────────────────────────────────
|
|
19
96
|
|
|
20
97
|
/**
|
|
@@ -103,13 +180,30 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
103
180
|
#exitReasonPending?: Exception;
|
|
104
181
|
#stderrDone: Promise<void>;
|
|
105
182
|
#exited: Promise<number>;
|
|
183
|
+
#openPipeReaders = 1;
|
|
184
|
+
// Pipe reads race this cutoff only when attachTimeout() configures a
|
|
185
|
+
// command deadline. Untimed commands preserve complete EOF-based capture.
|
|
186
|
+
#drainCutoff: Promise<void>;
|
|
187
|
+
#resolveDrainCutoff: () => void;
|
|
188
|
+
#timeoutTimer?: NodeJS.Timeout;
|
|
106
189
|
#stderrStream?: ReadableStream<Uint8Array>;
|
|
107
|
-
|
|
190
|
+
// Termination in flight after kill(); aborted exits await it before reporting.
|
|
191
|
+
#terminating?: Promise<boolean | void>;
|
|
192
|
+
#terminateGroup: boolean;
|
|
193
|
+
#hardKillTree: boolean;
|
|
194
|
+
// Windows has no process groups. Retaining the root's native handle pins
|
|
195
|
+
// its PID after exit so killTree() can still enumerate its original children.
|
|
196
|
+
#windowsRootProcess?: Process;
|
|
108
197
|
constructor(
|
|
109
198
|
readonly proc: PipedSubprocess<In>,
|
|
110
199
|
readonly exposeStderr: boolean,
|
|
111
200
|
retainFullStderr = exposeStderr,
|
|
201
|
+
terminateGroup = false,
|
|
202
|
+
hardKillTree = false,
|
|
112
203
|
) {
|
|
204
|
+
this.#terminateGroup = terminateGroup;
|
|
205
|
+
this.#hardKillTree = hardKillTree;
|
|
206
|
+
this.#windowsRootProcess = process.platform === "win32" ? (Process.fromPid(proc.pid) ?? undefined) : undefined;
|
|
113
207
|
if (retainFullStderr) this.#stderrChunks = [];
|
|
114
208
|
// Eagerly drain stderr into a truncated tail, retaining raw chunks only for explicit full capture.
|
|
115
209
|
const dec = new TextDecoder();
|
|
@@ -123,22 +217,39 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
123
217
|
this.#stderrStream = teeStream;
|
|
124
218
|
stderrStream = drainStream;
|
|
125
219
|
}
|
|
220
|
+
// Normalize Bun's exited promise into our exitReason / exitedCleanly model.
|
|
221
|
+
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
222
|
+
this.#exited = promise;
|
|
223
|
+
const drainCutoff = Promise.withResolvers<void>();
|
|
224
|
+
this.#drainCutoff = drainCutoff.promise;
|
|
225
|
+
this.#resolveDrainCutoff = drainCutoff.resolve;
|
|
226
|
+
// The cutoff remains pending for untimed commands, preserving complete
|
|
227
|
+
// EOF-based capture. attachTimeout() resolves it at the command deadline.
|
|
228
|
+
|
|
229
|
+
const pipeCutoff = this.#drainCutoff;
|
|
126
230
|
this.#stderrDone = (async () => {
|
|
231
|
+
const reader = stderrStream.getReader();
|
|
127
232
|
try {
|
|
128
|
-
for
|
|
129
|
-
|
|
130
|
-
|
|
233
|
+
for (;;) {
|
|
234
|
+
const chunk = await Promise.race([
|
|
235
|
+
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
236
|
+
pipeCutoff.then(() => ({ cutoff: true as const })),
|
|
237
|
+
]);
|
|
238
|
+
if (chunk.cutoff) {
|
|
239
|
+
await reader.cancel().catch(() => {});
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
if (chunk.r.done) break;
|
|
243
|
+
this.#stderrChunks?.push(chunk.r.value);
|
|
244
|
+
this.#stderrTail += dec.decode(chunk.r.value, { stream: true });
|
|
131
245
|
trim();
|
|
132
246
|
}
|
|
133
247
|
} catch {}
|
|
248
|
+
this.#openPipeReaders--;
|
|
134
249
|
this.#stderrTail += dec.decode();
|
|
135
250
|
trim();
|
|
136
251
|
})();
|
|
137
252
|
|
|
138
|
-
// Normalize Bun's exited promise into our exitReason / exitedCleanly model.
|
|
139
|
-
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
140
|
-
this.#exited = promise;
|
|
141
|
-
|
|
142
253
|
proc.exited
|
|
143
254
|
.catch(() => null)
|
|
144
255
|
.then(async exitCode => {
|
|
@@ -153,6 +264,11 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
153
264
|
}
|
|
154
265
|
|
|
155
266
|
await this.#stderrDone;
|
|
267
|
+
if (this.#exitReasonPending) {
|
|
268
|
+
this.#exitReason = this.#exitReasonPending;
|
|
269
|
+
reject(this.#exitReasonPending);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
156
272
|
|
|
157
273
|
if (exitCode !== null) {
|
|
158
274
|
this.#exitReason = new NonZeroExitError(exitCode, this.#stderrTail);
|
|
@@ -218,44 +334,160 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
218
334
|
}
|
|
219
335
|
|
|
220
336
|
kill(reason?: Exception, gracefulMs?: number) {
|
|
221
|
-
if (reason && !this.#exitReasonPending)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
337
|
+
if (reason && !this.#exitReasonPending) {
|
|
338
|
+
this.#exitReasonPending = reason;
|
|
339
|
+
// The normalized exit promise may already have resolved from a dead
|
|
340
|
+
// group leader; wait() still needs to report the later deadline.
|
|
341
|
+
if (this.proc.exitCode !== null) this.#exitReason = reason;
|
|
342
|
+
}
|
|
343
|
+
if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
|
|
344
|
+
// terminate() sends its polite wave to the root before rebuilding the
|
|
345
|
+
// hard-kill tree. A subreaper root can die in that gap and release its
|
|
346
|
+
// adopted descendants, so snapshot and hard-kill the live tree first.
|
|
347
|
+
const root = Process.fromPid(this.proc.pid);
|
|
348
|
+
if (root) {
|
|
349
|
+
root.killTree(9);
|
|
350
|
+
this.#terminating = Promise.resolve();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (
|
|
355
|
+
this.proc.exitCode !== null &&
|
|
356
|
+
this.#terminateGroup &&
|
|
357
|
+
this.#openPipeReaders > 0 &&
|
|
358
|
+
process.platform !== "win32"
|
|
359
|
+
) {
|
|
360
|
+
// Bun detached children are POSIX session/process-group leaders. If
|
|
361
|
+
// the leader has exited, the native Process handle cannot rediscover
|
|
362
|
+
// its PGID, but a pipe-holding descendant keeps that exact group alive.
|
|
363
|
+
try {
|
|
364
|
+
process.kill(-this.proc.pid, "SIGKILL");
|
|
365
|
+
} catch {}
|
|
366
|
+
this.#terminating = Promise.resolve();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (this.proc.exitCode !== null && this.#windowsRootProcess && this.#openPipeReaders > 0) {
|
|
370
|
+
// The retained handle keeps the dead root PID reserved, making the
|
|
371
|
+
// Windows Toolhelp descendant walk identity-safe after root exit.
|
|
372
|
+
this.#windowsRootProcess.killTree();
|
|
373
|
+
this.#terminating = Promise.resolve();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!this.proc.killed) {
|
|
377
|
+
const options =
|
|
378
|
+
gracefulMs === undefined
|
|
379
|
+
? this.#terminateGroup
|
|
380
|
+
? { group: true }
|
|
381
|
+
: undefined
|
|
382
|
+
: { gracefulMs, group: this.#terminateGroup };
|
|
383
|
+
this.#terminating = (this.#windowsRootProcess ?? Process.fromPid(this.proc.pid))
|
|
384
|
+
?.terminate(options)
|
|
225
385
|
?.catch(e => void e);
|
|
386
|
+
}
|
|
226
387
|
}
|
|
227
388
|
|
|
228
389
|
// ── Output helpers ───────────────────────────────────────────────────
|
|
229
390
|
|
|
391
|
+
async #throwIfAborted(): Promise<void> {
|
|
392
|
+
const exitReason = this.exitReason;
|
|
393
|
+
if (!exitReason?.aborted) return;
|
|
394
|
+
if (this.#terminating) await this.#terminating;
|
|
395
|
+
throw exitReason;
|
|
396
|
+
}
|
|
397
|
+
|
|
230
398
|
async text(): Promise<string> {
|
|
231
|
-
const p =
|
|
399
|
+
const p = this.#readStream(this.proc.stdout);
|
|
232
400
|
if (this.#nothrow) return p;
|
|
233
401
|
const [text] = await Promise.all([p, this.exitedCleanly]);
|
|
402
|
+
await this.#throwIfAborted();
|
|
234
403
|
return text;
|
|
235
404
|
}
|
|
236
405
|
|
|
237
|
-
|
|
238
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Read a pipe fully, stopping early only at an explicit command deadline.
|
|
408
|
+
*/
|
|
409
|
+
async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
410
|
+
this.#openPipeReaders++;
|
|
411
|
+
const reader = stream.getReader();
|
|
412
|
+
const dec = new TextDecoder();
|
|
413
|
+
let out = "";
|
|
414
|
+
try {
|
|
415
|
+
for (;;) {
|
|
416
|
+
const chunk = await Promise.race([
|
|
417
|
+
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
418
|
+
this.#drainCutoff.then(() => ({ cutoff: true as const })),
|
|
419
|
+
]);
|
|
420
|
+
if (chunk.cutoff) {
|
|
421
|
+
await reader.cancel().catch(() => {});
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
if (chunk.r.done) break;
|
|
425
|
+
out += dec.decode(chunk.r.value, { stream: true });
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
// A cancelled or failed read keeps whatever was already collected.
|
|
429
|
+
}
|
|
430
|
+
this.#openPipeReaders--;
|
|
431
|
+
return out + dec.decode();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async #readBytes(): Promise<Uint8Array> {
|
|
435
|
+
const reader = this.proc.stdout.getReader();
|
|
436
|
+
this.#openPipeReaders++;
|
|
437
|
+
const chunks: Uint8Array[] = [];
|
|
438
|
+
let length = 0;
|
|
439
|
+
try {
|
|
440
|
+
for (;;) {
|
|
441
|
+
const chunk = await Promise.race([
|
|
442
|
+
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
443
|
+
this.#drainCutoff.then(() => ({ cutoff: true as const })),
|
|
444
|
+
]);
|
|
445
|
+
if (chunk.cutoff) {
|
|
446
|
+
await reader.cancel().catch(() => {});
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
if (chunk.r.done) break;
|
|
450
|
+
chunks.push(chunk.r.value);
|
|
451
|
+
length += chunk.r.value.byteLength;
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// A cancelled or failed read keeps whatever was already collected.
|
|
455
|
+
} finally {
|
|
456
|
+
this.#openPipeReaders--;
|
|
457
|
+
reader.releaseLock();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const bytes = new Uint8Array(length);
|
|
461
|
+
let offset = 0;
|
|
462
|
+
for (const chunk of chunks) {
|
|
463
|
+
bytes.set(chunk, offset);
|
|
464
|
+
offset += chunk.byteLength;
|
|
465
|
+
}
|
|
466
|
+
return bytes;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async #readOutputBytes(waitForCleanExit = false): Promise<Uint8Array> {
|
|
470
|
+
const p = this.#readBytes();
|
|
239
471
|
if (this.#nothrow) return p;
|
|
240
|
-
const
|
|
241
|
-
|
|
472
|
+
const bytes = waitForCleanExit ? (await Promise.all([p, this.exitedCleanly]))[0] : await p;
|
|
473
|
+
await this.#throwIfAborted();
|
|
474
|
+
return bytes;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async blob(): Promise<Blob> {
|
|
478
|
+
return new Blob([await this.#readOutputBytes(true)]);
|
|
242
479
|
}
|
|
243
480
|
|
|
244
481
|
async json(): Promise<unknown> {
|
|
245
|
-
return new
|
|
482
|
+
return JSON.parse(new TextDecoder().decode(await this.#readOutputBytes()));
|
|
246
483
|
}
|
|
247
484
|
|
|
248
485
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
249
|
-
return
|
|
486
|
+
return (await this.#readOutputBytes()).buffer as ArrayBuffer;
|
|
250
487
|
}
|
|
251
488
|
|
|
252
489
|
async bytes(): Promise<Uint8Array> {
|
|
253
|
-
|
|
254
|
-
// stream emits more than one chunk (subprocess stdout chunks past ~128 KB).
|
|
255
|
-
// Normalize at the contract boundary so every caller — SSH read,
|
|
256
|
-
// `decodeUtf8Text`, callers slicing with `.subarray` — sees a `Uint8Array`.
|
|
257
|
-
const body = (await new Response(this.stdout).bytes()) as Uint8Array | ArrayBuffer;
|
|
258
|
-
return body instanceof Uint8Array ? body : new Uint8Array(body);
|
|
490
|
+
return this.#readOutputBytes();
|
|
259
491
|
}
|
|
260
492
|
|
|
261
493
|
// ── Wait ─────────────────────────────────────────────────────────────
|
|
@@ -267,7 +499,7 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
267
499
|
throw new Error('Full stderr capture must be requested when spawning the process (pass stderr: "full")');
|
|
268
500
|
}
|
|
269
501
|
|
|
270
|
-
const stdoutP =
|
|
502
|
+
const stdoutP = this.#readStream(this.proc.stdout);
|
|
271
503
|
const stderrP =
|
|
272
504
|
stderrMode === "full" && stderrChunks
|
|
273
505
|
? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(stderrChunks)))
|
|
@@ -282,12 +514,17 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
282
514
|
if (err instanceof Exception) exitError = err;
|
|
283
515
|
else throw err;
|
|
284
516
|
}
|
|
285
|
-
|
|
517
|
+
this.#clearTimeout();
|
|
286
518
|
if (!exitError) exitError = this.exitReason;
|
|
287
519
|
if (!exitError && this.exitCode !== null && this.exitCode !== 0) {
|
|
288
520
|
exitError = new NonZeroExitError(this.exitCode, this.#stderrTail);
|
|
289
521
|
}
|
|
290
522
|
|
|
523
|
+
// On abort/timeout, hold the result until the tree is actually gone: the
|
|
524
|
+
// native terminate() is graceful-first, and reporting before it finishes
|
|
525
|
+
// would leave timed-out descendants alive past the caller's budget.
|
|
526
|
+
if (exitError?.aborted && this.#terminating) await this.#terminating;
|
|
527
|
+
|
|
291
528
|
const exitCode = this.exitCode ?? (exitError && !exitError.aborted ? exitError.exitCode : null);
|
|
292
529
|
const ok = exitCode === 0;
|
|
293
530
|
|
|
@@ -307,18 +544,32 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
307
544
|
this.#exited.catch(() => {}).finally(() => signal.removeEventListener("abort", onAbort));
|
|
308
545
|
}
|
|
309
546
|
|
|
547
|
+
#clearTimeout(): void {
|
|
548
|
+
if (!this.#timeoutTimer) return;
|
|
549
|
+
clearTimeout(this.#timeoutTimer);
|
|
550
|
+
this.#timeoutTimer = undefined;
|
|
551
|
+
}
|
|
552
|
+
|
|
310
553
|
attachTimeout(ms: number): void {
|
|
311
554
|
if (ms <= 0 || this.proc.killed) return;
|
|
312
555
|
this.#exited.catch(() => {});
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
556
|
+
// One unref'd deadline controls both termination and pipe collection.
|
|
557
|
+
// A clean command clears it in wait(), so fast invocations do not hold
|
|
558
|
+
// the event loop for the unused remainder.
|
|
559
|
+
const timer = setTimeout(() => {
|
|
560
|
+
// A detached group can remain alive after its leader exits. Only use
|
|
561
|
+
// the dead-leader fallback while an inherited pipe proves that exact
|
|
562
|
+
// group still has a live member; this avoids stale-PGID reuse.
|
|
563
|
+
if (
|
|
564
|
+
this.proc.exitCode === null ||
|
|
565
|
+
(this.#openPipeReaders > 0 && (this.#terminateGroup || this.#windowsRootProcess))
|
|
566
|
+
) {
|
|
567
|
+
this.kill(new TimeoutError(ms, this.#stderrTail), -1);
|
|
568
|
+
}
|
|
569
|
+
this.#resolveDrainCutoff();
|
|
570
|
+
}, ms);
|
|
571
|
+
timer.unref?.();
|
|
572
|
+
this.#timeoutTimer = timer;
|
|
322
573
|
}
|
|
323
574
|
|
|
324
575
|
[Symbol.dispose](): void {
|
|
@@ -336,6 +587,13 @@ type ChildSpawnOptions<In extends InMask = InMask> = Omit<
|
|
|
336
587
|
> & {
|
|
337
588
|
signal?: AbortSignal;
|
|
338
589
|
detached?: boolean;
|
|
590
|
+
/**
|
|
591
|
+
* On Linux, supervise the command from a child subreaper so descendants
|
|
592
|
+
* remain reachable after changing session and reparenting. Other platforms
|
|
593
|
+
* ignore this option. macOS process groups cannot retain a daemonized
|
|
594
|
+
* descendant that creates a new session and reparents to launchd.
|
|
595
|
+
*/
|
|
596
|
+
subreaper?: boolean;
|
|
339
597
|
/** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
|
|
340
598
|
stderr?: "full" | null;
|
|
341
599
|
};
|
|
@@ -345,15 +603,26 @@ function spawnInternal<In extends InMask = InMask>(
|
|
|
345
603
|
opts: ChildSpawnOptions<In> | undefined,
|
|
346
604
|
retainFullStderr: boolean,
|
|
347
605
|
): ChildProcess<In> {
|
|
348
|
-
const { timeout = -1, signal, stderr, ...rest } = opts ?? {};
|
|
349
|
-
const
|
|
606
|
+
const { timeout = -1, signal, stderr, detached, subreaper = false, ...rest } = opts ?? {};
|
|
607
|
+
const useSubreaper = subreaper && process.platform === "linux";
|
|
608
|
+
const commandEnv = rest.env ?? Bun.env;
|
|
609
|
+
const child = Bun.spawn(useSubreaper ? [process.execPath, "-e", LINUX_SUBREAPER_SCRIPT] : cmd, {
|
|
350
610
|
stdin: "ignore",
|
|
351
611
|
stdout: "pipe",
|
|
352
612
|
stderr: "pipe",
|
|
353
613
|
windowsHide: true,
|
|
614
|
+
detached,
|
|
354
615
|
...rest,
|
|
616
|
+
env: useSubreaper
|
|
617
|
+
? {
|
|
618
|
+
...commandEnv,
|
|
619
|
+
BUN_BE_BUN: "1",
|
|
620
|
+
[LINUX_SUBREAPER_COMMAND_ENV]: JSON.stringify(cmd),
|
|
621
|
+
[LINUX_SUBREAPER_BUN_BE_BUN_ENV]: commandEnv.BUN_BE_BUN,
|
|
622
|
+
}
|
|
623
|
+
: rest.env,
|
|
355
624
|
});
|
|
356
|
-
const cp = new ChildProcess(child, stderr === "full", retainFullStderr);
|
|
625
|
+
const cp = new ChildProcess(child, stderr === "full", retainFullStderr, detached === true, useSubreaper);
|
|
357
626
|
if (signal) cp.attachSignal(signal);
|
|
358
627
|
if (timeout > 0) cp.attachTimeout(timeout);
|
|
359
628
|
return cp;
|
package/src/runtime-install.ts
CHANGED
|
@@ -2,6 +2,8 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as fsp from "node:fs/promises";
|
|
3
3
|
import * as Module from "node:module";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { withFileLock } from "./file-lock";
|
|
6
|
+
import { isEexist, isEnoent } from "./fs-error";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* On-demand runtime dependency support for native-heavy optional packages
|
|
@@ -303,25 +305,62 @@ export interface EnsureRuntimeInstalledOptions {
|
|
|
303
305
|
lockSleepMs?: number;
|
|
304
306
|
}
|
|
305
307
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
308
|
+
/** No runtime install plausibly runs this long, so older legacy lock directories are crash orphans. */
|
|
309
|
+
const STALE_LEGACY_LOCK_MS = 10 * 60_000;
|
|
309
310
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Run `fn` while reserving the pre-crash-safe `${runtimeDir}.lock` namespace.
|
|
313
|
+
*
|
|
314
|
+
* Versions through 18.0.10 serialized installs with a bare lock *directory*
|
|
315
|
+
* that only its creator removed; an installer killed outside that window
|
|
316
|
+
* (SIGKILL/OOM/Ctrl-C) left it unreleasable, wedging every later install for
|
|
317
|
+
* the full wait envelope (issue #10120). During an in-flight upgrade a legacy
|
|
318
|
+
* process may still legitimately own this directory, so poll until it is
|
|
319
|
+
* released and only force-reclaim once the directory is older than any
|
|
320
|
+
* plausible install ({@link STALE_LEGACY_LOCK_MS}) — never merely because a
|
|
321
|
+
* retry budget elapsed, which would delete a still-active legacy lock and let
|
|
322
|
+
* two installers race the same tree. Once the namespace is free, atomically
|
|
323
|
+
* create and retain a regular file through `fn`: an older process cannot
|
|
324
|
+
* acquire it between the handoff check and the new install. A file left by a
|
|
325
|
+
* crashed new installer can be reused immediately because the outer OS lock
|
|
326
|
+
* proves its owner is gone, unlike a legacy directory whose owner is unknown.
|
|
327
|
+
*/
|
|
328
|
+
async function withLegacyInstallLock<T>(runtimeDir: string, sleepMs: number, fn: () => Promise<T>): Promise<T> {
|
|
329
|
+
const legacy = `${runtimeDir}.lock`;
|
|
330
|
+
for (;;) {
|
|
314
331
|
try {
|
|
315
|
-
await fsp.
|
|
316
|
-
|
|
317
|
-
await fsp.rm(lockDir, { recursive: true, force: true });
|
|
318
|
-
};
|
|
332
|
+
const reservation = await fsp.open(legacy, "wx");
|
|
333
|
+
await reservation.close();
|
|
319
334
|
} catch (error) {
|
|
320
|
-
if (!
|
|
321
|
-
|
|
335
|
+
if (!isEexist(error)) throw error;
|
|
336
|
+
let stat: fs.Stats;
|
|
337
|
+
try {
|
|
338
|
+
stat = await fsp.stat(legacy);
|
|
339
|
+
} catch (statError) {
|
|
340
|
+
if (isEnoent(statError)) continue; // released between open and stat; retry
|
|
341
|
+
throw statError;
|
|
342
|
+
}
|
|
343
|
+
// A non-directory is a reservation left by a newer installer. The
|
|
344
|
+
// outer OS lock proves that installer is gone, so reuse it at once.
|
|
345
|
+
if (!stat.isDirectory()) break;
|
|
346
|
+
// A fresh directory may still belong to a live pre-18.x installer, so
|
|
347
|
+
// wait for it to finish; only a crash orphan (older than any plausible
|
|
348
|
+
// install) is force-reclaimed.
|
|
349
|
+
if (Date.now() - stat.mtimeMs > STALE_LEGACY_LOCK_MS) {
|
|
350
|
+
await fsp.rm(legacy, { recursive: true, force: true });
|
|
351
|
+
} else {
|
|
352
|
+
await Bun.sleep(sleepMs);
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
322
355
|
}
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
// Retain the regular-file reservation across the install.
|
|
359
|
+
try {
|
|
360
|
+
return await fn();
|
|
361
|
+
} finally {
|
|
362
|
+
await fsp.rm(legacy, { force: true });
|
|
323
363
|
}
|
|
324
|
-
throw new Error(`Timed out waiting for runtime install lock: ${lockDir}`);
|
|
325
364
|
}
|
|
326
365
|
|
|
327
366
|
export async function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void> {
|
|
@@ -363,7 +402,14 @@ async function runRuntimeInstall(runtimeDir: string): Promise<void> {
|
|
|
363
402
|
|
|
364
403
|
/**
|
|
365
404
|
* Materialize a pinned dependency set into `runtimeDir` (idempotent,
|
|
366
|
-
* cross-process safe
|
|
405
|
+
* cross-process safe). Returns `runtimeDir`.
|
|
406
|
+
*
|
|
407
|
+
* Serialization uses the OS-backed {@link withFileLock} at
|
|
408
|
+
* `${runtimeDir}.install.lock`, which the kernel releases on process death, so
|
|
409
|
+
* a crashed installer cannot wedge later attempts (issue #10120). The path is
|
|
410
|
+
* deliberately distinct from the legacy `${runtimeDir}.lock` mkdir directory;
|
|
411
|
+
* {@link withLegacyInstallLock} atomically reserves that namespace during the
|
|
412
|
+
* new install so older processes cannot cross the migration boundary.
|
|
367
413
|
*/
|
|
368
414
|
export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string> {
|
|
369
415
|
const { runtimeDir, install, onPhase, lockAttempts = 240, lockSleepMs = 250 } = options;
|
|
@@ -379,15 +425,20 @@ export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOpti
|
|
|
379
425
|
if (await probeManifest.exists()) return runtimeDir;
|
|
380
426
|
|
|
381
427
|
onPhase?.("initiate");
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
428
|
+
// withFileLock does not create parent directories; the runtime cache dir may
|
|
429
|
+
// not exist yet on the very first install.
|
|
430
|
+
await fsp.mkdir(path.dirname(runtimeDir), { recursive: true });
|
|
431
|
+
return withFileLock(
|
|
432
|
+
`${runtimeDir}.install`,
|
|
433
|
+
() =>
|
|
434
|
+
withLegacyInstallLock(runtimeDir, lockSleepMs, async () => {
|
|
435
|
+
if (await probeManifest.exists()) return runtimeDir;
|
|
436
|
+
await writeRuntimeManifest(runtimeDir, install);
|
|
437
|
+
onPhase?.("download");
|
|
438
|
+
await runRuntimeInstall(runtimeDir);
|
|
439
|
+
onPhase?.("done");
|
|
440
|
+
return runtimeDir;
|
|
441
|
+
}),
|
|
442
|
+
{ retries: lockAttempts, retryDelayMs: lockSleepMs },
|
|
443
|
+
);
|
|
393
444
|
}
|
package/src/sqlite.ts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* one implementation here prevents the classifiers from drifting between the
|
|
8
8
|
* credential store and the model cache.
|
|
9
9
|
*/
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
|
|
12
|
+
/** Checkpoints committed WAL frames without waiting for concurrent readers. */
|
|
13
|
+
export function checkpointWal(db: Database): void {
|
|
14
|
+
db.run("PRAGMA wal_checkpoint(PASSIVE)");
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* SQLite's busy result-code family — base `SQLITE_BUSY` plus the extended
|