@f5xc-salesdemos/pi-utils 19.40.2 → 19.41.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/package.json +2 -2
- package/src/ptree.ts +52 -12
- package/src/stream.ts +78 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/pi-utils",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.41.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/bun": "^1.3",
|
|
42
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
42
|
+
"@f5xc-salesdemos/pi-natives": "19.41.0"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
45
|
"bun": ">=1.3.7"
|
package/src/ptree.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { Spawn, Subprocess } from "bun";
|
|
10
10
|
import { terminate } from "./procmgr";
|
|
11
|
+
import { DEFAULT_MAX_OUTPUT_BYTES, readStreamCapped, readStreamCappedText } from "./stream";
|
|
11
12
|
|
|
12
13
|
type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
|
|
13
14
|
|
|
@@ -72,6 +73,8 @@ export interface WaitOptions {
|
|
|
72
73
|
allowNonZero?: boolean;
|
|
73
74
|
allowAbort?: boolean;
|
|
74
75
|
stderr?: "full" | "buffer";
|
|
76
|
+
/** Max bytes of stdout to buffer before throwing OutputTooLargeError (default 50 MiB). */
|
|
77
|
+
maxOutputBytes?: number;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/** Result from wait and exec. */
|
|
@@ -97,6 +100,8 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
97
100
|
#nothrow = false;
|
|
98
101
|
#stderrTail = "";
|
|
99
102
|
#stderrChunks: Uint8Array[] = [];
|
|
103
|
+
#stderrBytes = 0;
|
|
104
|
+
#stderrTruncated = false;
|
|
100
105
|
#exitReason?: Exception;
|
|
101
106
|
#exitReasonPending?: Exception;
|
|
102
107
|
#stderrDone: Promise<void>;
|
|
@@ -122,7 +127,15 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
122
127
|
this.#stderrDone = (async () => {
|
|
123
128
|
try {
|
|
124
129
|
for await (const chunk of stderrStream) {
|
|
125
|
-
|
|
130
|
+
// Bound the retained full-stderr buffer so a subprocess that floods
|
|
131
|
+
// stderr can't exhaust the heap. Keep draining (to avoid pipe
|
|
132
|
+
// deadlock) but stop retaining chunks past the cap; #stderrTail keeps
|
|
133
|
+
// the last 32KB regardless.
|
|
134
|
+
if (this.#stderrBytes < DEFAULT_MAX_OUTPUT_BYTES) {
|
|
135
|
+
this.#stderrChunks.push(chunk);
|
|
136
|
+
this.#stderrBytes += chunk.byteLength;
|
|
137
|
+
if (this.#stderrBytes >= DEFAULT_MAX_OUTPUT_BYTES) this.#stderrTruncated = true;
|
|
138
|
+
}
|
|
126
139
|
this.#stderrTail += dec.decode(chunk, { stream: true });
|
|
127
140
|
trim();
|
|
128
141
|
}
|
|
@@ -220,44 +233,71 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
220
233
|
|
|
221
234
|
// ── Output helpers ───────────────────────────────────────────────────
|
|
222
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Fully read stdout into memory, bounded by a hard byte cap. Throws
|
|
238
|
+
* OutputTooLargeError past the cap instead of growing the heap until the
|
|
239
|
+
* process dies with `RangeError: Out of memory`.
|
|
240
|
+
*/
|
|
241
|
+
#readStdoutCapped(): Promise<Uint8Array> {
|
|
242
|
+
return readStreamCapped(this.stdout, { source: "subprocess stdout" });
|
|
243
|
+
}
|
|
244
|
+
|
|
223
245
|
async text(): Promise<string> {
|
|
224
|
-
const p =
|
|
246
|
+
const p = readStreamCappedText(this.stdout, { source: "subprocess stdout" });
|
|
225
247
|
if (this.#nothrow) return p;
|
|
226
248
|
const [text] = await Promise.all([p, this.exitedCleanly]);
|
|
227
249
|
return text;
|
|
228
250
|
}
|
|
229
251
|
|
|
230
252
|
async blob(): Promise<Blob> {
|
|
231
|
-
const p =
|
|
253
|
+
const p = this.#readStdoutCapped().then(bytes => new Blob([bytes]));
|
|
232
254
|
if (this.#nothrow) return p;
|
|
233
255
|
const [blob] = await Promise.all([p, this.exitedCleanly]);
|
|
234
256
|
return blob;
|
|
235
257
|
}
|
|
236
258
|
|
|
237
259
|
async json(): Promise<unknown> {
|
|
238
|
-
return
|
|
260
|
+
return JSON.parse(await readStreamCappedText(this.stdout, { source: "subprocess stdout" }));
|
|
239
261
|
}
|
|
240
262
|
|
|
241
263
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
242
|
-
|
|
264
|
+
const bytes = await this.#readStdoutCapped();
|
|
265
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
async bytes(): Promise<Uint8Array> {
|
|
246
|
-
return
|
|
269
|
+
return this.#readStdoutCapped();
|
|
247
270
|
}
|
|
248
271
|
|
|
249
272
|
// ── Wait ─────────────────────────────────────────────────────────────
|
|
250
273
|
|
|
251
274
|
async wait(opts?: WaitOptions): Promise<ExecResult> {
|
|
252
|
-
const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = opts ?? {};
|
|
275
|
+
const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer", maxOutputBytes } = opts ?? {};
|
|
253
276
|
|
|
254
|
-
const stdoutP =
|
|
277
|
+
const stdoutP = readStreamCappedText(this.stdout, { source: "subprocess stdout", maxBytes: maxOutputBytes });
|
|
255
278
|
const stderrP =
|
|
256
279
|
stderrMode === "full"
|
|
257
|
-
? this.#stderrDone.then(() =>
|
|
280
|
+
? this.#stderrDone.then(() => {
|
|
281
|
+
const text = new TextDecoder().decode(Buffer.concat(this.#stderrChunks));
|
|
282
|
+
return this.#stderrTruncated
|
|
283
|
+
? `${text}\n…[stderr truncated at ${DEFAULT_MAX_OUTPUT_BYTES} bytes]`
|
|
284
|
+
: text;
|
|
285
|
+
})
|
|
258
286
|
: this.#stderrDone.then(() => this.#stderrTail);
|
|
259
287
|
|
|
260
|
-
|
|
288
|
+
let stdout: string;
|
|
289
|
+
let stderr: string;
|
|
290
|
+
try {
|
|
291
|
+
[stdout, stderr] = await Promise.all([stdoutP, stderrP]);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Output collection failed (e.g. OutputTooLargeError). Terminate the child
|
|
294
|
+
// and consume its exit/stderr promises so the kill's rejection isn't left
|
|
295
|
+
// unhandled (which would otherwise trip the global crash handler).
|
|
296
|
+
this.kill();
|
|
297
|
+
void this.#exited.catch(() => {});
|
|
298
|
+
void this.#stderrDone.catch(() => {});
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
261
301
|
|
|
262
302
|
let exitError: Exception | undefined;
|
|
263
303
|
try {
|
|
@@ -345,11 +385,11 @@ export interface ExecOptions extends Omit<ChildSpawnOptions, "stderr" | "stdin">
|
|
|
345
385
|
|
|
346
386
|
/** Spawn, wait, and return captured output. */
|
|
347
387
|
export async function exec(cmd: string[], opts?: ExecOptions): Promise<ExecResult> {
|
|
348
|
-
const { input, stderr, allowAbort, allowNonZero, ...spawnOpts } = opts ?? {};
|
|
388
|
+
const { input, stderr, allowAbort, allowNonZero, maxOutputBytes, ...spawnOpts } = opts ?? {};
|
|
349
389
|
const stdin = typeof input === "string" ? Buffer.from(input) : input;
|
|
350
390
|
const resolved: ChildSpawnOptions = stdin === undefined ? spawnOpts : { ...spawnOpts, stdin };
|
|
351
391
|
using child = spawn(cmd, resolved);
|
|
352
|
-
return await child.wait({ stderr, allowAbort, allowNonZero });
|
|
392
|
+
return await child.wait({ stderr, allowAbort, allowNonZero, maxOutputBytes });
|
|
353
393
|
}
|
|
354
394
|
|
|
355
395
|
// ── Signal combinators ───────────────────────────────────────────────────────
|
package/src/stream.ts
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
import { createAbortableStream } from "./abortable";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Default ceiling for fully buffering a stream into memory (50 MiB). Reading an
|
|
5
|
+
* unbounded subprocess/HTTP stream into one buffer can exhaust the heap and crash
|
|
6
|
+
* the process with `RangeError: Out of memory`; this bounds that.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
/** Thrown when a capped stream read exceeds its byte limit. */
|
|
11
|
+
export class OutputTooLargeError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly maxBytes: number,
|
|
14
|
+
readonly source?: string,
|
|
15
|
+
) {
|
|
16
|
+
super(`Output exceeded ${maxBytes} bytes${source ? ` (${source})` : ""}`);
|
|
17
|
+
this.name = "OutputTooLargeError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReadStreamCappedOptions {
|
|
22
|
+
/** Maximum bytes to buffer before throwing OutputTooLargeError (default 50 MiB). */
|
|
23
|
+
maxBytes?: number;
|
|
24
|
+
/** Short label (e.g. the command/URL) included in the error and any log. */
|
|
25
|
+
source?: string;
|
|
26
|
+
/** Abort signal; aborting cancels the reader and rejects. */
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read a ReadableStream fully into a single Uint8Array, enforcing a hard byte
|
|
32
|
+
* cap. On exceeding the cap the reader is cancelled and `OutputTooLargeError` is
|
|
33
|
+
* thrown — the size is checked BEFORE retaining each chunk, so memory never
|
|
34
|
+
* exceeds the cap (unlike `new Response(stream).text()`, which buffers unbounded).
|
|
35
|
+
*/
|
|
36
|
+
export async function readStreamCapped(
|
|
37
|
+
stream: ReadableStream<Uint8Array>,
|
|
38
|
+
options: ReadStreamCappedOptions = {},
|
|
39
|
+
): Promise<Uint8Array> {
|
|
40
|
+
const { maxBytes = DEFAULT_MAX_OUTPUT_BYTES, source, signal } = options;
|
|
41
|
+
const reader = stream.getReader();
|
|
42
|
+
const chunks: Uint8Array[] = [];
|
|
43
|
+
let total = 0;
|
|
44
|
+
try {
|
|
45
|
+
while (true) {
|
|
46
|
+
if (signal?.aborted) {
|
|
47
|
+
await reader.cancel();
|
|
48
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
49
|
+
}
|
|
50
|
+
const { done, value } = await reader.read();
|
|
51
|
+
if (done) break;
|
|
52
|
+
if (!value || value.byteLength === 0) continue;
|
|
53
|
+
total += value.byteLength;
|
|
54
|
+
if (total > maxBytes) {
|
|
55
|
+
await reader.cancel();
|
|
56
|
+
throw new OutputTooLargeError(maxBytes, source);
|
|
57
|
+
}
|
|
58
|
+
chunks.push(value);
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
reader.releaseLock();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const out = new Uint8Array(total);
|
|
65
|
+
let offset = 0;
|
|
66
|
+
for (const chunk of chunks) {
|
|
67
|
+
out.set(chunk, offset);
|
|
68
|
+
offset += chunk.byteLength;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Like {@link readStreamCapped} but decodes the result as UTF-8 text. */
|
|
74
|
+
export async function readStreamCappedText(
|
|
75
|
+
stream: ReadableStream<Uint8Array>,
|
|
76
|
+
options: ReadStreamCappedOptions = {},
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
return new TextDecoder().decode(await readStreamCapped(stream, options));
|
|
79
|
+
}
|
|
80
|
+
|
|
3
81
|
const LF = 0x0a;
|
|
4
82
|
type JsonlChunkResult = {
|
|
5
83
|
values: unknown[];
|