@oh-my-pi/pi-utils 18.7.0 → 18.8.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/types/ar/index.d.ts +1 -0
  3. package/dist/types/ar/zip-package.d.ts +21 -0
  4. package/dist/types/docx/converter.d.ts +2 -0
  5. package/dist/types/dom/core.d.ts +15 -4
  6. package/dist/types/dom/parser.d.ts +5 -3
  7. package/dist/types/dom/selector.d.ts +7 -1
  8. package/dist/types/logger/rotating-file.d.ts +4 -2
  9. package/dist/types/marked/core.d.ts +7 -6
  10. package/dist/types/marked-list.d.ts +38 -0
  11. package/dist/types/math-delimiters.d.ts +10 -0
  12. package/dist/types/readability/types.d.ts +2 -0
  13. package/dist/types/turndown/types.d.ts +7 -0
  14. package/dist/types/vterm/buffer.d.ts +16 -5
  15. package/package.json +2 -2
  16. package/src/ar/index.ts +1 -0
  17. package/src/ar/zip-package.ts +57 -0
  18. package/src/chalk.ts +66 -23
  19. package/src/docx/converter.ts +26 -12
  20. package/src/dom/core.ts +354 -111
  21. package/src/dom/parser.ts +42 -19
  22. package/src/dom/selector.ts +328 -95
  23. package/src/fetch-retry.ts +2 -0
  24. package/src/json-parse.ts +21 -6
  25. package/src/logger/rotating-file.ts +5 -3
  26. package/src/logger.ts +39 -23
  27. package/src/marked/core.ts +42 -18
  28. package/src/marked-list.ts +73 -0
  29. package/src/math-delimiters.ts +16 -0
  30. package/src/prompt.ts +3 -1
  31. package/src/ptree.ts +28 -35
  32. package/src/readability/readability.ts +349 -39
  33. package/src/readability/types.ts +2 -0
  34. package/src/stream.ts +34 -13
  35. package/src/temp.ts +1 -10
  36. package/src/template.ts +7 -4
  37. package/src/turndown/gfm.ts +16 -5
  38. package/src/turndown/html.ts +28 -8
  39. package/src/turndown/service.ts +43 -15
  40. package/src/turndown/types.ts +7 -0
  41. package/src/vterm/buffer.ts +31 -11
  42. package/src/vterm/terminal.ts +185 -92
package/src/json-parse.ts CHANGED
@@ -309,15 +309,30 @@ export function parseJsonWithRepair<T>(json: string): T {
309
309
  export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
310
310
  const trimmed = partialJson?.trimStart();
311
311
  if (!trimmed) return {} as T;
312
+ // A container can only be strict JSON once its closer arrived; skip the
313
+ // doomed full-buffer JSON.parse (and its SyntaxError) while it streams.
314
+ const first = trimmed.charCodeAt(0);
315
+ if ((first !== 0x7b && first !== 0x5b) || canCloseContainer(trimmed)) {
316
+ try {
317
+ return JSON.parse(trimmed) as T;
318
+ } catch {}
319
+ }
312
320
  try {
313
- return JSON.parse(trimmed) as T;
321
+ return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
314
322
  } catch {
315
- try {
316
- return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
317
- } catch {
318
- return {} as T;
319
- }
323
+ return {} as T;
324
+ }
325
+ }
326
+
327
+ /** True when the last non-whitespace character is `}` or `]`. */
328
+ function canCloseContainer(text: string): boolean {
329
+ for (let i = text.length - 1; i >= 0; i--) {
330
+ const code = text.charCodeAt(i);
331
+ // JSON whitespace: space, \t, \n, \r
332
+ if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue;
333
+ return code === 0x7d || code === 0x5d;
320
334
  }
335
+ return false;
321
336
  }
322
337
 
323
338
  /**
@@ -109,11 +109,13 @@ export class RotatingFileSink {
109
109
  /**
110
110
  * Append one already-formatted log record. A batching sink buffers it unless
111
111
  * `flushNow` is set or the buffer reached its size threshold; either way the
112
- * whole buffer, this record included, is then written in order.
112
+ * whole buffer, this record included, is then written in order. `day` is the
113
+ * record's `localDay` key, so callers that already took the record's `Date`
114
+ * reuse it.
113
115
  */
114
- write(line: string, flushNow = false): void {
116
+ write(line: string, flushNow = false, day = localDay(new Date())): void {
115
117
  if (this.#closed) return;
116
- this.#selectFile(localDay(new Date()));
118
+ this.#selectFile(day);
117
119
  const record = `${line}${os.EOL}`;
118
120
  const bytes = Buffer.byteLength(record, "utf8");
119
121
  this.#pending += record;
package/src/logger.ts CHANGED
@@ -209,9 +209,28 @@ function jsonReplacer(_key: string, value: unknown): unknown {
209
209
  return value;
210
210
  }
211
211
 
212
- interface NormalizedLogInfo extends Record<string, unknown> {
213
- level: LogLevel;
214
- message: unknown;
212
+ // Values visited by the Error pre-scan before falling back to the replacer.
213
+ const ERROR_SCAN_BUDGET = 128;
214
+
215
+ /**
216
+ * Whether `value` may contain an {@link Error} that {@link jsonReplacer} must
217
+ * unwrap. Walks what `JSON.stringify` serializes (arrays and own enumerable
218
+ * keys); objects with a `toJSON` other than `Date` may return an Error, and an
219
+ * exhausted budget answers true.
220
+ */
221
+ function mayContainError(root: unknown): boolean {
222
+ const stack: unknown[] = [root];
223
+ let budget = ERROR_SCAN_BUDGET;
224
+ while (stack.length > 0) {
225
+ const value = stack.pop();
226
+ if (value === null || typeof value !== "object") continue;
227
+ if (value instanceof Error) return true;
228
+ if (--budget < 0) return true;
229
+ if (value instanceof Date) continue;
230
+ if ("toJSON" in value && typeof value.toJSON === "function") return true;
231
+ for (const child of Object.values(value)) stack.push(child);
232
+ }
233
+ return false;
215
234
  }
216
235
 
217
236
  function padTimestampPart(value: number, width = 2): string {
@@ -232,33 +251,30 @@ function formatLocalTimestamp(date: Date): string {
232
251
 
233
252
  const FORMAT_TOKEN_PATTERN = /%[scdjifoO%]/;
234
253
 
235
- function normalizeLogInfo(
254
+ /** Serialize one log record: timestamp, level, pid, message, then context fields. */
255
+ function formatLogRecord(
236
256
  level: LogLevel,
237
257
  message: string,
238
258
  context: Record<string, unknown> | undefined,
239
- ): NormalizedLogInfo {
259
+ timestamp: string,
260
+ ): string {
240
261
  const metadata =
241
262
  !FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
242
- const info = Object.assign({}, metadata, { level, message }) as NormalizedLogInfo;
243
- if (metadata?.message) info.message = `${message} ${metadata.message}`;
244
- if (metadata?.stack) info.stack = metadata.stack;
245
- if (metadata?.cause) info.cause = metadata.cause;
246
- return info;
247
- }
248
-
249
- function formatLogInfo(info: NormalizedLogInfo): string {
250
- const timestamp = formatLocalTimestamp(new Date());
251
- info.timestamp = timestamp;
252
263
  const entry: Record<string, unknown> = {
253
264
  timestamp,
254
- level: info.level,
265
+ level,
255
266
  pid: process.pid,
256
- message: info.message,
267
+ message: metadata?.message ? `${message} ${metadata.message}` : message,
257
268
  };
258
- for (const [key, value] of Object.entries(info)) {
259
- if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = value;
269
+ if (metadata) {
270
+ for (const key of Object.keys(metadata)) {
271
+ if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = metadata[key];
272
+ }
273
+ // An Error passed as the context carries these as non-enumerable fields.
274
+ if (metadata.stack) entry.stack = metadata.stack;
275
+ if (metadata.cause) entry.cause = metadata.cause;
260
276
  }
261
- return JSON.stringify(entry, jsonReplacer) as string;
277
+ return mayContainError(entry) ? JSON.stringify(entry, jsonReplacer) : JSON.stringify(entry);
262
278
  }
263
279
 
264
280
  /** Longest a buffered file record waits before it is written. */
@@ -361,9 +377,9 @@ function emitLocally(level: LogLevel, message: string, context: Record<string, u
361
377
  const rank = LOG_LEVEL_RANK[level];
362
378
  const file = rank <= transports.fileLevelRank ? transports.file : undefined;
363
379
  if (!file && !transports.console) return;
364
- const info = normalizeLogInfo(level, message, context);
365
- const line = formatLogInfo(info);
366
- file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting);
380
+ const now = new Date();
381
+ const line = formatLogRecord(level, message, context, formatLocalTimestamp(now));
382
+ file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
367
383
  if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
368
384
  }
369
385
 
@@ -248,14 +248,15 @@ export type TokensList = Token[] & { links: Links };
248
248
  export interface TokenizerThis {
249
249
  lexer: Lexer;
250
250
  /**
251
- * For an inline tokenizer, the whole inline source, such as a paragraph's text. The `src` it receives is the part
252
- * of it before `end` that starts at `end - src.length`: inside a link label or the text of emphasis, `end` is where
253
- * that text ends, right before its closer, and a token there must not reach past it. Inline tokenizers get one
254
- * context object per inline source, the same for every call while the lexer works through it and the labels and
255
- * emphasis inside it, so a tokenizer can keep state for it in a `WeakMap`.
251
+ * The whole source the lexer works through. The `src` a tokenizer receives is the part of it before `end` that
252
+ * starts at `end - src.length`. For a block tokenizer, `source` is the text passed to one `blockTokens` call and
253
+ * `end` its length. For an inline tokenizer, it is the inline source, such as a paragraph's text: inside a link
254
+ * label or the text of emphasis, `end` is where that text ends, right before its closer, and a token there must
255
+ * not reach past it. Tokenizers get one context object per source, the same for every call while the lexer works
256
+ * through it (and, inline, the labels and emphasis inside it), so a tokenizer can keep state for it in a `WeakMap`.
256
257
  */
257
258
  source?: string;
258
- /** For an inline tokenizer, where the text the lexer works through ends in `source` (see `source`). */
259
+ /** Where the text the lexer works through ends in `source` (see `source`). */
259
260
  end?: number;
260
261
  }
261
262
  /** A tokenizer extension callback. */
@@ -971,13 +972,30 @@ function parseList(lines: string[], index: number, lexer: Lexer): { token: Token
971
972
 
972
973
  function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
973
974
  const lines = lineArray(src);
975
+ // `lines` tiles `src` exactly, so the unconsumed source at line `i` is
976
+ // `src.slice(lineStarts[i])`. Only block extensions and an lheading override
977
+ // read it; computing offsets once keeps the loop linear.
978
+ const blockExtensions = lexer.extensions.block;
979
+ const context: TokenizerThis = { lexer, source: src, end: src.length };
980
+ const lheadingOverride = lexer.tokenizerOverrides.lheading;
981
+ let lineStarts: number[] | undefined;
982
+ if (blockExtensions.length > 0 || lheadingOverride) {
983
+ lineStarts = [];
984
+ let offset = 0;
985
+ for (let index = 0; index < lines.length; index++) {
986
+ lineStarts.push(offset);
987
+ offset += lines[index]!.length;
988
+ }
989
+ }
974
990
  let i = 0;
975
991
  while (i < lines.length) {
976
- const remaining = lines.slice(i).join("");
977
992
  let custom: Tokens.Generic | undefined;
978
- for (const extension of lexer.extensions.block) {
979
- custom = extension.tokenizer.call({ lexer }, remaining, output);
980
- if (custom?.raw) break;
993
+ if (blockExtensions.length > 0) {
994
+ const remaining = src.slice(lineStarts![i]);
995
+ for (const extension of blockExtensions) {
996
+ custom = extension.tokenizer.call(context, remaining, output);
997
+ if (custom?.raw) break;
998
+ }
981
999
  }
982
1000
  if (custom?.raw) {
983
1001
  output.push(custom);
@@ -1006,9 +1024,10 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
1006
1024
  let raw = line;
1007
1025
  let text = "";
1008
1026
  i++;
1027
+ const closer = new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*(?:\\n|$)$`);
1009
1028
  while (i < lines.length) {
1010
1029
  const next = lines[i++]!;
1011
- if (new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*(?:\\n|$)$`).test(next)) {
1030
+ if (closer.test(next)) {
1012
1031
  raw += next;
1013
1032
  break;
1014
1033
  }
@@ -1090,10 +1109,16 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
1090
1109
  i++;
1091
1110
  if (line.trimStart().startsWith("<!--")) {
1092
1111
  while (!raw.includes("-->") && i < lines.length) raw += lines[i++]!;
1093
- } else if (tag && !new RegExp(`</${tag}>`, "i").test(raw)) {
1094
- while (i < lines.length) {
1095
- raw += lines[i++]!;
1096
- if (new RegExp(`</${tag}>`, "i").test(raw)) break;
1112
+ } else if (tag) {
1113
+ // A closing tag never spans lines, so testing each appended line is
1114
+ // equivalent to re-testing the accumulated block.
1115
+ const closeTag = new RegExp(`</${tag}>`, "i");
1116
+ if (!closeTag.test(raw)) {
1117
+ while (i < lines.length) {
1118
+ const next = lines[i++]!;
1119
+ raw += next;
1120
+ if (closeTag.test(next)) break;
1121
+ }
1097
1122
  }
1098
1123
  }
1099
1124
  const value = i < lines.length && /^\s*\n$/.test(lines[i]!) ? stripFinalNewline(raw) : raw;
@@ -1129,9 +1154,8 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
1129
1154
  }
1130
1155
  if (i + 1 < lines.length && /^ {0,3}(=+|-+)[ \t]*(?:\n|$)$/.test(lines[i + 1]!)) {
1131
1156
  let fallback: Tokens.Heading | undefined | false;
1132
- const override = lexer.tokenizerOverrides.lheading;
1133
- if (override) fallback = override.call(lexer.tokenizer, remaining);
1134
- if (!override || fallback === false) {
1157
+ if (lheadingOverride) fallback = lheadingOverride.call(lexer.tokenizer, src.slice(lineStarts![i]));
1158
+ if (!lheadingOverride || fallback === false) {
1135
1159
  const raw = line + lines[i + 1]!;
1136
1160
  const text = stripFinalNewline(line);
1137
1161
  const tokens: Token[] = [];
@@ -0,0 +1,73 @@
1
+ /**
2
+ * List-continuation grammar of `@oh-my-pi/pi-utils/marked`, for streaming
3
+ * renderers that freeze a lexed prefix at blank-line boundaries (TUI and
4
+ * collab web).
5
+ *
6
+ * marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
7
+ * blank lines only when the remaining source matches
8
+ * `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
9
+ * where `marker` is the exact bullet char for unordered lists (`\${char}`) or
10
+ * 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
11
+ * The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
12
+ * sits at the start of a top-level list token's raw.
13
+ */
14
+
15
+ /** A list's marker, read from the start of its raw: bullet in group 1, ordered delimiter in group 2. */
16
+ const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
17
+
18
+ /**
19
+ * Whether a same-marker item could still continue the list `listRaw` at
20
+ * `tailStart` (directly after the blank line ending the list), now or after
21
+ * any append to `text`.
22
+ *
23
+ * Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
24
+ * lex(full text) for the current text and every append-only extension of it,
25
+ * because a frozen prefix is sticky. At a blank-line cut directly after a
26
+ * top-level `list` token, the only construct that can straddle the cut is a
27
+ * continuation item of that list: marked consumed the blank line into the last
28
+ * item's raw and re-ran `listItemRegex` at exactly `tailStart`, merging a
29
+ * same-marker item into one renumbered loose list. The cut is safe only when
30
+ * that regex can NEVER match at `tailStart`, no matter what is appended later.
31
+ *
32
+ * Append-only growth means "closed" may only be concluded from a present
33
+ * character that contradicts every possible continuation (tail "1x" can never
34
+ * grow into an ordered item, but tail "1" can become "1. c"). Running out of
35
+ * text mid-marker therefore answers "may continue".
36
+ *
37
+ * Returns true when the tail could still continue the list (or the list's
38
+ * marker is unrecognizable) — the conservative "don't freeze" answer. marked
39
+ * may break the list anyway when the matching line is also an hr (`- - -`);
40
+ * treating that as "may continue" merely skips a freeze, never corrupts one.
41
+ */
42
+ export function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
43
+ const marker = LIST_MARKER_RE.exec(listRaw);
44
+ if (marker === null) return true; // unrecognized list shape — stay conservative
45
+ const n = text.length;
46
+ let i = tailStart;
47
+ // `listItemRegex` allows up to 3 leading spaces.
48
+ while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
49
+ if (i >= n) return true;
50
+ const bullet = marker[1];
51
+ if (bullet !== undefined) {
52
+ if (text[i] !== bullet) return false; // wrong marker char — closed forever
53
+ i++;
54
+ } else {
55
+ // Ordered: 1-9 digits, then the same `.`/`)` delimiter.
56
+ let digits = 0;
57
+ while (i < n && digits < 10) {
58
+ const c = text.charCodeAt(i);
59
+ if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
60
+ digits++;
61
+ i++;
62
+ }
63
+ if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
64
+ if (i >= n) return true; // delimiter (or more digits) may still arrive
65
+ if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
66
+ i++;
67
+ }
68
+ // After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
69
+ // bare newline, or end-of-input (which appends can still extend).
70
+ if (i >= n) return true;
71
+ const after = text.charCodeAt(i);
72
+ return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
73
+ }
@@ -235,6 +235,22 @@ export class MathBlockScan {
235
235
  }
236
236
  }
237
237
 
238
+ const blockScansByContext = new WeakMap<object, MathBlockScan>();
239
+
240
+ /**
241
+ * The own-line display block at the start of `src`, for a marked block tokenizer: `src` is the part of
242
+ * `context.source` that starts at `context.end - src.length`. One {@link MathBlockScan} is kept per context, which
243
+ * marked keeps for one `blockTokens` source, so asking at every block start takes linear time even when an opener
244
+ * never closes. Without `context.source` it is {@link mathBlockAt}.
245
+ */
246
+ export function mathBlockInContext(context: { source?: string; end?: number }, src: string): MathBlock | undefined {
247
+ const source = context.source;
248
+ if (source === undefined) return mathBlockAt(src);
249
+ let scan = blockScansByContext.get(context);
250
+ if (!scan) blockScansByContext.set(context, (scan = new MathBlockScan(source)));
251
+ return scan.at((context.end ?? source.length) - src.length);
252
+ }
253
+
238
254
  /**
239
255
  * The display opener alone on the line at `from` (up to 3 leading spaces,
240
256
  * trailing spaces or tabs; the source's last line may be unterminated), or
package/src/prompt.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { LRUCache } from "./lru";
1
2
  import type { HelperDelegate, Template } from "./template";
2
3
  import * as Handlebars from "./template";
3
4
 
@@ -526,7 +527,8 @@ export function registerPartial(name: string, fn: Template): void {
526
527
  handlebars.registerPartial(name, fn);
527
528
  }
528
529
 
529
- const compiledTemplateCache = new Map<string, (context: TemplateContext) => string>();
530
+ // Bounded: some callers compile template text that already embeds user arguments.
531
+ const compiledTemplateCache = new LRUCache<string, (context: TemplateContext) => string>({ max: 256 });
530
532
 
531
533
  export function compile(template: string): (context: TemplateContext) => string {
532
534
  // Keyed on the raw template so repeat renders skip parsing.
package/src/ptree.ts CHANGED
@@ -183,10 +183,12 @@ export class ChildProcess<In extends InMask = InMask> {
183
183
  #stderrDone: Promise<void>;
184
184
  #exited: Promise<number>;
185
185
  #openPipeReaders = 1;
186
- // Pipe reads race this cutoff only when attachTimeout() configures a
187
- // command deadline. Untimed commands preserve complete EOF-based capture.
188
- #drainCutoff: Promise<void>;
189
- #resolveDrainCutoff: () => void;
186
+ // Set by attachTimeout() at the command deadline; untimed commands keep
187
+ // complete EOF-based capture. Active pipe readers are cancelled at cutoff so
188
+ // their pending read() settles as done and partial output is kept.
189
+ #cutoff = false;
190
+ /** Readers of piped stdio; the timeout cancels them so pending reads settle. */
191
+ #pipeReaders = new Set<{ cancel(reason?: unknown): Promise<void> }>();
190
192
  #timeoutTimer?: NodeJS.Timeout;
191
193
  #stderrStream?: ReadableStream<Uint8Array>;
192
194
  // Termination in flight after kill(); aborted exits await it before reporting.
@@ -224,31 +226,24 @@ export class ChildProcess<In extends InMask = InMask> {
224
226
  // Normalize Bun's exited promise into our exitReason / exitedCleanly model.
225
227
  const { promise, resolve, reject } = Promise.withResolvers<number>();
226
228
  this.#exited = promise;
227
- const drainCutoff = Promise.withResolvers<void>();
228
- this.#drainCutoff = drainCutoff.promise;
229
- this.#resolveDrainCutoff = drainCutoff.resolve;
230
- // The cutoff remains pending for untimed commands, preserving complete
231
- // EOF-based capture. attachTimeout() resolves it at the command deadline.
232
229
 
233
- const pipeCutoff = this.#drainCutoff;
234
230
  this.#stderrDone = (async () => {
235
231
  const reader = stderrStream.getReader();
232
+ this.#pipeReaders.add(reader);
236
233
  try {
237
234
  for (;;) {
238
- const chunk = await Promise.race([
239
- reader.read().then(r => ({ cutoff: false as const, r })),
240
- pipeCutoff.then(() => ({ cutoff: true as const })),
241
- ]);
242
- if (chunk.cutoff) {
235
+ if (this.#cutoff) {
243
236
  await reader.cancel().catch(() => {});
244
237
  break;
245
238
  }
246
- if (chunk.r.done) break;
247
- this.#stderrChunks?.push(chunk.r.value);
248
- this.#stderrTail += dec.decode(chunk.r.value, { stream: true });
239
+ const r = await reader.read();
240
+ if (r.done) break;
241
+ this.#stderrChunks?.push(r.value);
242
+ this.#stderrTail += dec.decode(r.value, { stream: true });
249
243
  trim();
250
244
  }
251
245
  } catch {}
246
+ this.#pipeReaders.delete(reader);
252
247
  this.#openPipeReaders--;
253
248
  this.#stderrTail += dec.decode();
254
249
  trim();
@@ -440,24 +435,23 @@ export class ChildProcess<In extends InMask = InMask> {
440
435
  async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
441
436
  this.#openPipeReaders++;
442
437
  const reader = stream.getReader();
438
+ this.#pipeReaders.add(reader);
443
439
  const dec = new TextDecoder();
444
440
  let out = "";
445
441
  try {
446
442
  for (;;) {
447
- const chunk = await Promise.race([
448
- reader.read().then(r => ({ cutoff: false as const, r })),
449
- this.#drainCutoff.then(() => ({ cutoff: true as const })),
450
- ]);
451
- if (chunk.cutoff) {
443
+ if (this.#cutoff) {
452
444
  await reader.cancel().catch(() => {});
453
445
  break;
454
446
  }
455
- if (chunk.r.done) break;
456
- out += dec.decode(chunk.r.value, { stream: true });
447
+ const r = await reader.read();
448
+ if (r.done) break;
449
+ out += dec.decode(r.value, { stream: true });
457
450
  }
458
451
  } catch {
459
452
  // A cancelled or failed read keeps whatever was already collected.
460
453
  }
454
+ this.#pipeReaders.delete(reader);
461
455
  this.#openPipeReaders--;
462
456
  return out + dec.decode();
463
457
  }
@@ -465,29 +459,27 @@ export class ChildProcess<In extends InMask = InMask> {
465
459
  async #readBytes(): Promise<Uint8Array<ArrayBuffer>> {
466
460
  const reader = this.proc.stdout.getReader();
467
461
  this.#openPipeReaders++;
462
+ this.#pipeReaders.add(reader);
468
463
  const chunks: Uint8Array[] = [];
469
464
  let length = 0;
470
465
  try {
471
466
  for (;;) {
472
- const chunk = await Promise.race([
473
- reader.read().then(r => ({ cutoff: false as const, r })),
474
- this.#drainCutoff.then(() => ({ cutoff: true as const })),
475
- ]);
476
- if (chunk.cutoff) {
467
+ if (this.#cutoff) {
477
468
  await reader.cancel().catch(() => {});
478
469
  break;
479
470
  }
480
- if (chunk.r.done) break;
481
- chunks.push(chunk.r.value);
482
- length += chunk.r.value.byteLength;
471
+ const r = await reader.read();
472
+ if (r.done) break;
473
+ chunks.push(r.value);
474
+ length += r.value.byteLength;
483
475
  }
484
476
  } catch {
485
477
  // A cancelled or failed read keeps whatever was already collected.
486
478
  } finally {
479
+ this.#pipeReaders.delete(reader);
487
480
  this.#openPipeReaders--;
488
481
  reader.releaseLock();
489
482
  }
490
-
491
483
  const bytes = new Uint8Array(length);
492
484
  let offset = 0;
493
485
  for (const chunk of chunks) {
@@ -597,7 +589,8 @@ export class ChildProcess<In extends InMask = InMask> {
597
589
  ) {
598
590
  this.kill(new TimeoutError(ms, this.#stderrTail), -1);
599
591
  }
600
- this.#resolveDrainCutoff();
592
+ this.#cutoff = true;
593
+ for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
601
594
  }, ms);
602
595
  timer.unref?.();
603
596
  this.#timeoutTimer = timer;