@oh-my-pi/pi-utils 6.8.0 → 6.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/abortable.ts +20 -6
- package/src/postmortem.ts +58 -28
- package/src/ptree.ts +145 -20
package/package.json
CHANGED
package/src/abortable.ts
CHANGED
|
@@ -5,9 +5,8 @@ export class AbortError extends Error {
|
|
|
5
5
|
assert(signal.aborted, "Abort signal must be aborted");
|
|
6
6
|
|
|
7
7
|
const message = signal.reason instanceof Error ? signal.reason.message : "Cancelled";
|
|
8
|
-
super(`Aborted: ${message}`, { cause:
|
|
8
|
+
super(`Aborted: ${message}`, { cause: signal.reason });
|
|
9
9
|
this.name = "AbortError";
|
|
10
|
-
this.cause = signal.reason;
|
|
11
10
|
}
|
|
12
11
|
}
|
|
13
12
|
|
|
@@ -42,9 +41,11 @@ export function createAbortablePromise<T>(signal?: AbortSignal): {
|
|
|
42
41
|
reject(new AbortError(signal));
|
|
43
42
|
};
|
|
44
43
|
signal.addEventListener("abort", abortHandler, { once: true });
|
|
45
|
-
promise
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
promise
|
|
45
|
+
.finally(() => {
|
|
46
|
+
signal.removeEventListener("abort", abortHandler);
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {});
|
|
48
49
|
return { promise, resolve, reject };
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -63,7 +64,20 @@ export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () =
|
|
|
63
64
|
return Promise.reject(new AbortError(signal));
|
|
64
65
|
}
|
|
65
66
|
const { promise, resolve, reject } = createAbortablePromise<T>(signal);
|
|
66
|
-
|
|
67
|
+
let settled = false;
|
|
68
|
+
const wrappedResolve = (value: T | PromiseLike<T>) => {
|
|
69
|
+
if (!settled) {
|
|
70
|
+
settled = true;
|
|
71
|
+
resolve(value);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const wrappedReject = (reason?: unknown) => {
|
|
75
|
+
if (!settled) {
|
|
76
|
+
settled = true;
|
|
77
|
+
reject(reason);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
pr().then(wrappedResolve, wrappedReject);
|
|
67
81
|
return promise;
|
|
68
82
|
}
|
|
69
83
|
|
package/src/postmortem.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isMainThread } from "node:worker_threads";
|
|
9
10
|
import { logger } from ".";
|
|
10
11
|
|
|
11
12
|
// Cleanup reasons, in order of priority/meaning.
|
|
@@ -45,7 +46,8 @@ function runCleanup(reason: Reason): Promise<void> {
|
|
|
45
46
|
|
|
46
47
|
// Call .cleanup() for each callback that is still "armed".
|
|
47
48
|
// Use Promise.try to handle sync/async, but only those armed.
|
|
48
|
-
|
|
49
|
+
// Create a copy to avoid mutating the original array with reverse()
|
|
50
|
+
const promises = [...callbackList].reverse().map((callback) => {
|
|
49
51
|
return Promise.try(() => callback(reason));
|
|
50
52
|
});
|
|
51
53
|
|
|
@@ -61,33 +63,45 @@ function runCleanup(reason: Reason): Promise<void> {
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// Register signal and error event handlers to trigger cleanup before exit.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
66
|
+
// Main thread: full signal handling (SIGINT, SIGTERM, SIGHUP) + exceptions + exit
|
|
67
|
+
// Worker thread: exit only (workers use self.addEventListener for exceptions)
|
|
68
|
+
if (isMainThread) {
|
|
69
|
+
process
|
|
70
|
+
.on("SIGINT", async () => {
|
|
71
|
+
await runCleanup(Reason.SIGINT);
|
|
72
|
+
process.exit(130); // 128 + SIGINT (2)
|
|
73
|
+
})
|
|
74
|
+
.on("uncaughtException", async (err) => {
|
|
75
|
+
logger.error("Uncaught exception", { err, stack: err.stack });
|
|
76
|
+
await runCleanup(Reason.UNCAUGHT_EXCEPTION);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
})
|
|
79
|
+
.on("unhandledRejection", async (reason) => {
|
|
80
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
81
|
+
logger.error("Unhandled rejection", { err, stack: err.stack });
|
|
82
|
+
await runCleanup(Reason.UNHANDLED_REJECTION);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
})
|
|
85
|
+
.on("exit", async () => {
|
|
86
|
+
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
87
|
+
})
|
|
88
|
+
.on("SIGTERM", async () => {
|
|
89
|
+
await runCleanup(Reason.SIGTERM);
|
|
90
|
+
process.exit(143); // 128 + SIGTERM (15)
|
|
91
|
+
})
|
|
92
|
+
.on("SIGHUP", async () => {
|
|
93
|
+
await runCleanup(Reason.SIGHUP);
|
|
94
|
+
process.exit(129); // 128 + SIGHUP (1)
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
// Worker thread: only register exit handler for cleanup.
|
|
98
|
+
// DO NOT register uncaughtException/unhandledRejection handlers here -
|
|
99
|
+
// they would swallow errors before the worker's own handlers (self.addEventListener)
|
|
100
|
+
// can report failures back to the parent thread.
|
|
101
|
+
process.on("exit", () => {
|
|
102
|
+
void runCleanup(Reason.EXIT);
|
|
90
103
|
});
|
|
104
|
+
}
|
|
91
105
|
|
|
92
106
|
/**
|
|
93
107
|
* Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
|
|
@@ -134,10 +148,26 @@ export function register(id: string, callback: (reason: Reason) => void | Promis
|
|
|
134
148
|
}
|
|
135
149
|
|
|
136
150
|
/**
|
|
137
|
-
* Runs all cleanup callbacks
|
|
151
|
+
* Runs all cleanup callbacks without exiting.
|
|
152
|
+
* Use this in workers or when you need to clean up but continue execution.
|
|
153
|
+
*/
|
|
154
|
+
export function cleanup(): Promise<void> {
|
|
155
|
+
return runCleanup(Reason.MANUAL);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Runs all cleanup callbacks and exits.
|
|
160
|
+
*
|
|
161
|
+
* In main thread: waits for stdout drain, then calls process.exit().
|
|
162
|
+
* In workers: runs cleanup only (process.exit would kill entire process).
|
|
138
163
|
*/
|
|
139
164
|
export async function quit(code: number = 0): Promise<void> {
|
|
140
165
|
await runCleanup(Reason.MANUAL);
|
|
166
|
+
|
|
167
|
+
if (!isMainThread) {
|
|
168
|
+
return; // Workers: cleanup done, let worker exit naturally
|
|
169
|
+
}
|
|
170
|
+
|
|
141
171
|
if (process.stdout.writableLength > 0) {
|
|
142
172
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
143
173
|
process.stdout.once("drain", resolve);
|
package/src/ptree.ts
CHANGED
|
@@ -16,6 +16,59 @@ const isWindows = process.platform === "win32";
|
|
|
16
16
|
// Set of live children for managed termination/cleanup on shutdown.
|
|
17
17
|
const managedChildren = new Set<PipedSubprocess>();
|
|
18
18
|
|
|
19
|
+
class AsyncQueue<T> {
|
|
20
|
+
#items: T[] = [];
|
|
21
|
+
#resolvers: Array<(result: IteratorResult<T>) => void> = [];
|
|
22
|
+
#closed = false;
|
|
23
|
+
|
|
24
|
+
push(item: T): void {
|
|
25
|
+
if (this.#closed) return;
|
|
26
|
+
const resolver = this.#resolvers.shift();
|
|
27
|
+
if (resolver) {
|
|
28
|
+
resolver({ value: item, done: false });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.#items.push(item);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
close(): void {
|
|
35
|
+
if (this.#closed) return;
|
|
36
|
+
this.#closed = true;
|
|
37
|
+
while (this.#resolvers.length > 0) {
|
|
38
|
+
const resolver = this.#resolvers.shift();
|
|
39
|
+
if (resolver) {
|
|
40
|
+
resolver({ value: undefined, done: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async next(): Promise<IteratorResult<T>> {
|
|
46
|
+
if (this.#items.length > 0) {
|
|
47
|
+
return { value: this.#items.shift() as T, done: false };
|
|
48
|
+
}
|
|
49
|
+
if (this.#closed) {
|
|
50
|
+
return { value: undefined, done: true };
|
|
51
|
+
}
|
|
52
|
+
return await new Promise<IteratorResult<T>>((resolve) => {
|
|
53
|
+
this.#resolvers.push(resolve);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createProcessStream(queue: AsyncQueue<Uint8Array>): ReadableStream<Uint8Array> {
|
|
59
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
60
|
+
pull: async (controller) => {
|
|
61
|
+
const result = await queue.next();
|
|
62
|
+
if (result.done) {
|
|
63
|
+
controller.close();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
controller.enqueue(result.value);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return stream;
|
|
70
|
+
}
|
|
71
|
+
|
|
19
72
|
/**
|
|
20
73
|
* Kill a child process and its descendents.
|
|
21
74
|
* - Windows: uses taskkill for tree and forceful kill (/T /F)
|
|
@@ -71,8 +124,8 @@ function registerManaged(child: PipedSubprocess): void {
|
|
|
71
124
|
});
|
|
72
125
|
}
|
|
73
126
|
|
|
74
|
-
// A Bun subprocess with stdin=Writable, stdout/stderr=pipe (for tracking/cleanup).
|
|
75
|
-
type PipedSubprocess = Subprocess<"pipe" | null, "pipe", "pipe">;
|
|
127
|
+
// A Bun subprocess with stdin=Writable/ignore, stdout/stderr=pipe (for tracking/cleanup).
|
|
128
|
+
type PipedSubprocess = Subprocess<"pipe" | "ignore" | null, "pipe", "pipe">;
|
|
76
129
|
|
|
77
130
|
/**
|
|
78
131
|
* ChildProcess wraps a managed subprocess, capturing output, errors, and providing
|
|
@@ -82,8 +135,11 @@ export class ChildProcess {
|
|
|
82
135
|
#proc: PipedSubprocess;
|
|
83
136
|
#detached = false;
|
|
84
137
|
#nothrow = false;
|
|
85
|
-
#stderrTee: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
86
138
|
#stderrBuffer = "";
|
|
139
|
+
#stdoutQueue = new AsyncQueue<Uint8Array>();
|
|
140
|
+
#stderrQueue = new AsyncQueue<Uint8Array>();
|
|
141
|
+
#stdoutStream?: ReadableStream<Uint8Array>;
|
|
142
|
+
#stderrStream?: ReadableStream<Uint8Array>;
|
|
87
143
|
#exitReason?: Exception;
|
|
88
144
|
#exitReasonPending?: Exception;
|
|
89
145
|
#exited: Promise<void>;
|
|
@@ -92,23 +148,75 @@ export class ChildProcess {
|
|
|
92
148
|
constructor(proc: PipedSubprocess) {
|
|
93
149
|
registerManaged(proc);
|
|
94
150
|
|
|
95
|
-
const
|
|
96
|
-
|
|
151
|
+
const exitSettled = proc.exited.then(
|
|
152
|
+
() => {},
|
|
153
|
+
() => {},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Capture stdout at all times. Close the passthrough when the process exits.
|
|
157
|
+
void (async () => {
|
|
158
|
+
const reader = proc.stdout.getReader();
|
|
159
|
+
try {
|
|
160
|
+
while (true) {
|
|
161
|
+
const result = await Promise.race([
|
|
162
|
+
reader.read(),
|
|
163
|
+
exitSettled.then(() => ({ done: true, value: undefined as Uint8Array | undefined })),
|
|
164
|
+
]);
|
|
165
|
+
if (result.done) break;
|
|
166
|
+
if (!result.value) continue;
|
|
167
|
+
this.#stdoutQueue.push(result.value);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// ignore
|
|
171
|
+
} finally {
|
|
172
|
+
try {
|
|
173
|
+
await reader.cancel();
|
|
174
|
+
} catch {}
|
|
175
|
+
try {
|
|
176
|
+
reader.releaseLock();
|
|
177
|
+
} catch {}
|
|
178
|
+
this.#stdoutQueue.close();
|
|
179
|
+
}
|
|
180
|
+
})().catch(() => {
|
|
181
|
+
this.#stdoutQueue.close();
|
|
182
|
+
});
|
|
97
183
|
|
|
98
184
|
// Capture stderr at all times, with a capped buffer for errors.
|
|
99
185
|
const decoder = new TextDecoder();
|
|
100
186
|
void (async () => {
|
|
101
|
-
|
|
102
|
-
|
|
187
|
+
const reader = proc.stderr.getReader();
|
|
188
|
+
try {
|
|
189
|
+
while (true) {
|
|
190
|
+
const result = await Promise.race([
|
|
191
|
+
reader.read(),
|
|
192
|
+
exitSettled.then(() => ({ done: true, value: undefined as Uint8Array | undefined })),
|
|
193
|
+
]);
|
|
194
|
+
if (result.done) break;
|
|
195
|
+
if (!result.value) continue;
|
|
196
|
+
this.#stderrQueue.push(result.value);
|
|
197
|
+
this.#stderrBuffer += decoder.decode(result.value, { stream: true });
|
|
198
|
+
if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
|
|
199
|
+
this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// ignore
|
|
204
|
+
} finally {
|
|
205
|
+
this.#stderrBuffer += decoder.decode();
|
|
103
206
|
if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
|
|
104
207
|
this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
|
|
105
208
|
}
|
|
209
|
+
try {
|
|
210
|
+
await reader.cancel();
|
|
211
|
+
} catch {}
|
|
212
|
+
try {
|
|
213
|
+
reader.releaseLock();
|
|
214
|
+
} catch {}
|
|
215
|
+
this.#stderrQueue.close();
|
|
106
216
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
})().catch(() => {});
|
|
217
|
+
})().catch(() => {
|
|
218
|
+
this.#stderrQueue.close();
|
|
219
|
+
});
|
|
112
220
|
|
|
113
221
|
const { promise, resolve } = Promise.withResolvers<Exception | undefined>();
|
|
114
222
|
|
|
@@ -152,11 +260,17 @@ export class ChildProcess {
|
|
|
152
260
|
get stdin(): FileSink | undefined {
|
|
153
261
|
return this.#proc.stdin;
|
|
154
262
|
}
|
|
155
|
-
get stdout(): ReadableStream<Uint8Array
|
|
156
|
-
|
|
263
|
+
get stdout(): ReadableStream<Uint8Array> {
|
|
264
|
+
if (!this.#stdoutStream) {
|
|
265
|
+
this.#stdoutStream = createProcessStream(this.#stdoutQueue);
|
|
266
|
+
}
|
|
267
|
+
return this.#stdoutStream;
|
|
157
268
|
}
|
|
158
|
-
get stderr(): ReadableStream<Uint8Array
|
|
159
|
-
|
|
269
|
+
get stderr(): ReadableStream<Uint8Array> {
|
|
270
|
+
if (!this.#stderrStream) {
|
|
271
|
+
this.#stderrStream = createProcessStream(this.#stderrQueue);
|
|
272
|
+
}
|
|
273
|
+
return this.#stderrStream;
|
|
160
274
|
}
|
|
161
275
|
|
|
162
276
|
/**
|
|
@@ -201,7 +315,14 @@ export class ChildProcess {
|
|
|
201
315
|
async killAndWait(): Promise<void> {
|
|
202
316
|
// Try killing with SIGTERM, then SIGKILL if it doesn't exit within 1 second
|
|
203
317
|
this.kill("SIGTERM");
|
|
204
|
-
await Promise.race([
|
|
318
|
+
const exitedOrTimeout = await Promise.race([
|
|
319
|
+
this.exited.then(() => "exited" as const),
|
|
320
|
+
Bun.sleep(1000).then(() => "timeout" as const),
|
|
321
|
+
]);
|
|
322
|
+
if (exitedOrTimeout === "timeout") {
|
|
323
|
+
this.kill("SIGKILL");
|
|
324
|
+
await this.exited.catch(() => {});
|
|
325
|
+
}
|
|
205
326
|
}
|
|
206
327
|
|
|
207
328
|
// Output utilities (aliases for easy chaining)
|
|
@@ -220,7 +341,7 @@ export class ChildProcess {
|
|
|
220
341
|
async blob() {
|
|
221
342
|
const { promise, resolve, reject } = Promise.withResolvers<Blob>();
|
|
222
343
|
|
|
223
|
-
const blob = this
|
|
344
|
+
const blob = this.stdout.blob();
|
|
224
345
|
if (!this.#nothrow) {
|
|
225
346
|
this.#exited.catch((ex: Exception) => {
|
|
226
347
|
reject(ex);
|
|
@@ -329,14 +450,17 @@ export class AbortError extends Exception {
|
|
|
329
450
|
*/
|
|
330
451
|
export class TimeoutError extends AbortError {
|
|
331
452
|
constructor(timeout: number, stderr: string) {
|
|
332
|
-
super(new Error(`
|
|
453
|
+
super(new Error(`Timed out after ${Math.round(timeout / 1000)}s`), stderr);
|
|
333
454
|
}
|
|
334
455
|
}
|
|
335
456
|
|
|
336
457
|
/**
|
|
337
458
|
* Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
|
|
338
459
|
*/
|
|
339
|
-
type ChildSpawnOptions = Omit<
|
|
460
|
+
type ChildSpawnOptions = Omit<
|
|
461
|
+
Spawn.SpawnOptions<"pipe" | "ignore" | Buffer | null, "pipe", "pipe">,
|
|
462
|
+
"stdout" | "stderr"
|
|
463
|
+
> & {
|
|
340
464
|
signal?: AbortSignal;
|
|
341
465
|
};
|
|
342
466
|
|
|
@@ -348,6 +472,7 @@ type ChildSpawnOptions = Omit<Spawn.SpawnOptions<"pipe" | null, "pipe", "pipe">,
|
|
|
348
472
|
export function cspawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
|
|
349
473
|
const { timeout, ...rest } = options ?? {};
|
|
350
474
|
const child = spawn(cmd, {
|
|
475
|
+
stdin: "ignore",
|
|
351
476
|
...rest,
|
|
352
477
|
stdout: "pipe",
|
|
353
478
|
stderr: "pipe",
|