@oh-my-pi/pi-utils 18.17.0 → 18.18.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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.0] - 2026-11-07
6
+
7
+ ### Fixed
8
+
9
+ - Fixed abort signals leaving command output reads blocked when a descendant keeps stdout or stderr open after the root process exits. ([#18309](https://github.com/can1357/oh-my-pi/pull/18309) by [@eggpeat](https://github.com/eggpeat))
10
+
5
11
  ## [18.17.0] - 2026-11-06
6
12
 
7
13
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.17.0",
3
+ "version": "18.18.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -56,8 +56,8 @@
56
56
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
57
57
  },
58
58
  "dependencies": {
59
- "@oh-my-pi/omptype": "18.17.0",
60
- "@oh-my-pi/pi-natives": "18.17.0"
59
+ "@oh-my-pi/omptype": "18.18.0",
60
+ "@oh-my-pi/pi-natives": "18.18.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/ptree.ts CHANGED
@@ -184,12 +184,15 @@ export class ChildProcess<In extends InMask = InMask> {
184
184
  #stderrDone: Promise<void>;
185
185
  #exited: Promise<number>;
186
186
  #openPipeReaders = 1;
187
- // Set by attachTimeout() at the command deadline; untimed commands keep
188
- // complete EOF-based capture. Active pipe readers are cancelled at cutoff so
189
- // their pending read() settles as done and partial output is kept.
187
+ // Set at the command deadline or on abort; otherwise commands keep complete
188
+ // EOF-based capture. Active pipe readers are cancelled at cutoff so their
189
+ // pending read() settles as done and partial output is kept.
190
190
  #cutoff = false;
191
- /** Readers of piped stdio; the timeout cancels them so pending reads settle. */
191
+ /** Readers of piped stdio; timeout and abort cancel them so pending reads settle. */
192
192
  #pipeReaders = new Set<{ cancel(reason?: unknown): Promise<void> }>();
193
+ // Each registration reconciles its listener with root-exit and reader activity.
194
+ // Keeping the registration after idle lets a later output read re-arm the signal.
195
+ #abortRegistrations?: Set<() => void>;
193
196
  #timeoutTimer?: NodeJS.Timeout;
194
197
  #stderrStream?: ReadableStream<Uint8Array>;
195
198
  // Termination in flight after kill(); aborted exits await it before reporting.
@@ -249,11 +252,13 @@ export class ChildProcess<In extends InMask = InMask> {
249
252
  this.#openPipeReaders--;
250
253
  this.#stderrTail += dec.decode();
251
254
  trim();
255
+ this.#syncAbortListeners();
252
256
  })();
253
257
 
254
258
  proc.exited
255
259
  .catch(() => null)
256
260
  .then(async exitCode => {
261
+ this.#syncAbortListeners();
257
262
  if (this.#exitReasonPending) {
258
263
  this.#exitReason = this.#exitReasonPending;
259
264
  reject(this.#exitReasonPending);
@@ -522,12 +527,13 @@ export class ChildProcess<In extends InMask = InMask> {
522
527
  }
523
528
 
524
529
  /**
525
- * Read a pipe fully, stopping early only at an explicit command deadline.
530
+ * Read a pipe fully, stopping early only at an explicit command deadline or abort.
526
531
  */
527
532
  async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
528
- this.#openPipeReaders++;
529
533
  const reader = stream.getReader();
534
+ this.#openPipeReaders++;
530
535
  this.#pipeReaders.add(reader);
536
+ this.#syncAbortListeners();
531
537
  const dec = new TextDecoder();
532
538
  let out = "";
533
539
  try {
@@ -543,15 +549,18 @@ export class ChildProcess<In extends InMask = InMask> {
543
549
  } catch {
544
550
  // A cancelled or failed read keeps whatever was already collected.
545
551
  }
552
+ const tail = dec.decode();
546
553
  this.#pipeReaders.delete(reader);
547
554
  this.#openPipeReaders--;
548
- return out + dec.decode();
555
+ this.#syncAbortListeners();
556
+ return out + tail;
549
557
  }
550
558
 
551
559
  async #readBytes(): Promise<Uint8Array<ArrayBuffer>> {
552
560
  const reader = this.proc.stdout.getReader();
553
561
  this.#openPipeReaders++;
554
562
  this.#pipeReaders.add(reader);
563
+ this.#syncAbortListeners();
555
564
  const chunks: Uint8Array[] = [];
556
565
  let length = 0;
557
566
  try {
@@ -571,6 +580,7 @@ export class ChildProcess<In extends InMask = InMask> {
571
580
  this.#pipeReaders.delete(reader);
572
581
  this.#openPipeReaders--;
573
582
  reader.releaseLock();
583
+ this.#syncAbortListeners();
574
584
  }
575
585
  const bytes = new Uint8Array(length);
576
586
  let offset = 0;
@@ -653,11 +663,43 @@ export class ChildProcess<In extends InMask = InMask> {
653
663
  // ── Signal / timeout ─────────────────────────────────────────────────
654
664
 
655
665
  attachSignal(signal: AbortSignal): void {
656
- const cleanup = this.#exited.catch(() => {});
657
- const onAbort = () => this.kill(new AbortError(signal.reason, "<cancelled>"));
658
- if (signal.aborted) return void onAbort();
659
- signal.addEventListener("abort", onAbort, { once: true });
660
- cleanup.finally(() => signal.removeEventListener("abort", onAbort));
666
+ this.#exited.catch(() => {});
667
+ let attached = false;
668
+ const onAbort = () => {
669
+ // The { once: true } listener has removed itself at this point.
670
+ attached = false;
671
+ const rootRunning = this.proc.exitCode === null && this.proc.signalCode === null;
672
+ if (rootRunning || this.#openPipeReaders > 0) {
673
+ this.#abortRegistrations?.delete(sync);
674
+ }
675
+ this.kill(new AbortError(signal.reason, "<cancelled>"));
676
+ this.#cutoffOutput();
677
+ };
678
+ const sync = () => {
679
+ const rootRunning = this.proc.exitCode === null && this.proc.signalCode === null;
680
+ const active = rootRunning || this.#openPipeReaders > 0;
681
+ if (signal.aborted) {
682
+ if (active) onAbort();
683
+ return;
684
+ }
685
+ if (active === attached) return;
686
+ if (active) signal.addEventListener("abort", onAbort, { once: true });
687
+ else signal.removeEventListener("abort", onAbort);
688
+ attached = active;
689
+ };
690
+ (this.#abortRegistrations ??= new Set()).add(sync);
691
+ if (signal.aborted) onAbort();
692
+ else sync();
693
+ }
694
+
695
+ #syncAbortListeners(): void {
696
+ if (!this.#abortRegistrations) return;
697
+ for (const sync of this.#abortRegistrations) sync();
698
+ }
699
+
700
+ #cutoffOutput(): void {
701
+ this.#cutoff = true;
702
+ for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
661
703
  }
662
704
 
663
705
  #clearTimeout(): void {
@@ -684,8 +726,7 @@ export class ChildProcess<In extends InMask = InMask> {
684
726
  ) {
685
727
  this.kill(new TimeoutError(ms, this.#stderrTail), -1);
686
728
  }
687
- this.#cutoff = true;
688
- for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
729
+ this.#cutoffOutput();
689
730
  }, ms);
690
731
  timer.unref?.();
691
732
  this.#timeoutTimer = timer;