@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/pi-utils",
4
- "version": "19.40.2",
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.40.2"
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
- this.#stderrChunks.push(chunk);
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 = new Response(this.stdout).text();
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 = new Response(this.stdout).blob();
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 new Response(this.stdout).json();
260
+ return JSON.parse(await readStreamCappedText(this.stdout, { source: "subprocess stdout" }));
239
261
  }
240
262
 
241
263
  async arrayBuffer(): Promise<ArrayBuffer> {
242
- return new Response(this.stdout).arrayBuffer();
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 new Response(this.stdout).bytes();
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 = new Response(this.stdout).text();
277
+ const stdoutP = readStreamCappedText(this.stdout, { source: "subprocess stdout", maxBytes: maxOutputBytes });
255
278
  const stderrP =
256
279
  stderrMode === "full"
257
- ? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(this.#stderrChunks)))
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
- const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
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[];