@orkestrel/process 0.0.4 → 0.0.6

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.
@@ -4,7 +4,7 @@ import { statSync } from "node:fs";
4
4
  import { join, posix, win32 } from "node:path";
5
5
  import { platform } from "node:process";
6
6
  import { boundsOf, holds, isNonEmptyString, isNonNegativeInteger, isString } from "@orkestrel/contract";
7
- import { PROCESS_BACKLOG, PROCESS_CONFIRMATION, PROCESS_EVIDENCE, PROCESS_GRACE, PROCESS_OUTPUT, PROCESS_PATHEXT, PROCESS_TIMER, createDuplicateError, createExecuteError, createInvalidError, createProtocolError } from "../core/index.js";
7
+ import { PROCESS_BACKLOG, PROCESS_CONFIRMATION, PROCESS_DRAIN, PROCESS_EVIDENCE, PROCESS_GRACE, PROCESS_OUTPUT, PROCESS_PATHEXT, PROCESS_TIMER, ProcessError, createDuplicateError, createExecuteError, createInvalidError, createProtocolError } from "../core/index.js";
8
8
  import { createInterface } from "node:readline";
9
9
  import { StringDecoder } from "node:string_decoder";
10
10
  import { Emitter } from "@orkestrel/emitter";
@@ -592,17 +592,41 @@ function waitForExit(child, timeout) {
592
592
  return settled.promise.finally(() => clearTimeout(timer));
593
593
  }
594
594
  /**
595
+ * Waits for one child process's streams to close, bounded by a deadline.
596
+ *
597
+ * @param child - The child boundary
598
+ * @param timeout - The milliseconds to wait before giving up
599
+ * @returns True when the host close arrived inside the deadline; false otherwise
600
+ *
601
+ * @example
602
+ * ```ts
603
+ * const closed = await waitForClose(child, 5_000)
604
+ * ```
605
+ */
606
+ function waitForClose(child, timeout) {
607
+ const closed = Promise.withResolvers();
608
+ const expired = Promise.withResolvers();
609
+ const timer = setTimeout(() => expired.resolve(false), timeout);
610
+ child.once("close", closed.resolve);
611
+ return Promise.race([closed.promise.then(() => true), expired.promise]).finally(() => {
612
+ clearTimeout(timer);
613
+ child.off("close", closed.resolve);
614
+ });
615
+ }
616
+ /**
595
617
  * Terminates one child process tree and reports whether its native exit was observed.
596
618
  *
597
619
  * @remarks
598
- * Windows ends the whole tree at once through {@link killTree}, falling back to a direct kill after
599
- * the utility fails, because the host has no cooperative termination to offer. A POSIX host signals
600
- * the process group `SIGTERM`, falls back to the direct child when no group owns its pid, waits
601
- * `grace`, then sends `SIGKILL` through the same route. No signal is initiated after the native exit
602
- * is observed; the window between initiating a signal and the host delivering it belongs to the
603
- * operating system. `confirm` bounds each awaited step rather than the call as a whole: on Windows
604
- * it bounds the `taskkill` call and then the final wait, and on a POSIX host `grace` bounds the
605
- * cooperative wait and `confirm` the final one. Never rejects.
620
+ * An already-exited child is returned on before any route to its pid runs, because the host has
621
+ * reaped that number and may have handed it to another process. Windows ends the whole tree at once
622
+ * through {@link killTree}, falling back to a direct kill after the utility fails, because the host
623
+ * has no cooperative termination to offer. A POSIX host signals the process group `SIGTERM`, falls
624
+ * back to the direct child when no group owns its pid, waits `grace`, then sends `SIGKILL` through
625
+ * the same route. No signal is initiated after the native exit is observed; the window between
626
+ * initiating a signal and the host delivering it belongs to the operating system. `confirm` bounds
627
+ * each awaited step rather than the call as a whole: on Windows it bounds the `taskkill` call and
628
+ * then the final wait, and on a POSIX host `grace` bounds the cooperative wait and `confirm` the
629
+ * final one. Never rejects.
606
630
  *
607
631
  * @param child - The child boundary
608
632
  * @param grace - The cooperative POSIX window in milliseconds between `SIGTERM` and `SIGKILL`
@@ -670,26 +694,23 @@ function buildExecuteResult(input) {
670
694
  //#endregion
671
695
  //#region src/server/Process.ts
672
696
  /**
673
- * One supervised child process with framed output, a bounded backlog, and bounded termination.
697
+ * Supervises one child while keeping every observation channel aligned at termination.
674
698
  *
675
699
  * @remarks
676
- * Standard output is framed through `readline` into a single-consumer stream: each line goes to
677
- * exactly one waiting iterator, so concurrent iterators split the output rather than each receiving
678
- * all of it. While no consumer has ever requested an iterator, stdout keeps draining so `exit` still
679
- * resolves, and retention stops at the `backlog` mark: a
680
- * consumer attaching later receives the retained head, a gap, then the live stream. After an iterator
681
- * has been requested, stdout pauses at the mark and resumes at half of it, so that consumer loses
682
- * nothing before termination and the child feels real backpressure. The mark is soft — the ordinary backlog can
683
- * overshoot it by the line that crossed it plus the rest of its delivered chunk. Termination never
684
- * reapplies backpressure, and retained lines are capped at twice `backlog`; later lines are dropped
685
- * and `truncated` reports the omission. Standard error is decoded and forwarded live as the `stderr`
686
- * event while a byte-bounded raw tail is retained as `evidence`. The typed `emitter` also carries the
687
- * child `error` cause on a spawn fault and the terminal `exit`, alongside the `exit` promise. `pid`,
688
- * `code`, and `signal` read the spawned child directly, so the native exit is readable before the
689
- * `exit` promise settles on stdio close. `stop` ends the whole tree and reports whether the native
690
- * exit was observed; `destroy` stops the child, destroys stdin so every pending `send` settles, and
691
- * destroys the emitter last. `destroy` resolving does not imply the child's stdio has closed, so
692
- * `exit` and `lines` can still be outstanding when a descendant holds an inherited pipe.
700
+ * The child's ending and the supervision's ending are distinct. `pid`, `code`, and `signal` read
701
+ * the host child directly, so they expose native exit as soon as the host records it. `settled`,
702
+ * `exit`, `evidence`, and `lines` reach one terminal moment after the read channels close or the
703
+ * bounded `drain` window cuts them off. That window is armed by the child's native exit and by a
704
+ * termination this package initiated, so every way a child can end reaches the moment. The terminal
705
+ * routine freezes `evidence` and ends `lines` before it destroys stdout, stderr, or the emitter, so
706
+ * the push channel never disappears while a pull channel can still change or wait indefinitely.
707
+ *
708
+ * Standard output is framed through `readline` into a single-consumer stream. Lines already framed
709
+ * and queued remain available through the terminal moment, while bytes arriving after a requested
710
+ * termination may be cut off. Standard error is forwarded through the live `stderr` event and
711
+ * retained as a byte-bounded raw tail until that same moment. `stop` ends the child tree and the
712
+ * observation surfaces. `destroy` performs that stop and then destroys the emitter after the
713
+ * frozen terminal state exists.
693
714
  *
694
715
  * @example
695
716
  * ```ts
@@ -708,8 +729,10 @@ var Process = class {
708
729
  #child;
709
730
  #reader;
710
731
  #grace;
732
+ #drain;
711
733
  #evidence;
712
734
  #backlog;
735
+ #delivery;
713
736
  #decoder = new StringDecoder("utf8");
714
737
  #exit = Promise.withResolvers();
715
738
  #lines;
@@ -717,16 +740,20 @@ var Process = class {
717
740
  #waiters = [];
718
741
  #signal;
719
742
  #abort;
743
+ #writes = /* @__PURE__ */ new Map();
720
744
  #tail = Buffer.alloc(0);
721
745
  #head = 0;
722
746
  #pending = 0;
723
747
  #requested = false;
724
748
  #paused = false;
725
749
  #truncated = false;
726
- #terminating = false;
727
- #closed = false;
750
+ #stopping = false;
751
+ #settled = false;
728
752
  #ended = false;
729
- #stopping;
753
+ #input = 0;
754
+ #failure;
755
+ #waiting;
756
+ #termination;
730
757
  #ending;
731
758
  /**
732
759
  * Spawn one child process and begin stream capture.
@@ -738,8 +765,10 @@ var Process = class {
738
765
  const source = options.command;
739
766
  const workspace = options.workspace;
740
767
  const grace = options.grace;
768
+ const drain = options.drain;
741
769
  const evidence = options.evidence;
742
770
  const backlog = options.backlog;
771
+ const delivery = options.delivery;
743
772
  const writable = options.writable;
744
773
  const signal = options.signal;
745
774
  const on = options.on;
@@ -749,6 +778,8 @@ var Process = class {
749
778
  validateCommand(command);
750
779
  validateWorkspace(workspace);
751
780
  validateTimer(grace, "option 'grace'");
781
+ validateTimer(drain, "option 'drain'");
782
+ validateTimer(delivery, "option 'delivery'");
752
783
  validateBytes(evidence, "option 'evidence'", 0);
753
784
  validateBytes(backlog, "option 'backlog'", 1);
754
785
  this.#emitter = new Emitter({
@@ -756,8 +787,10 @@ var Process = class {
756
787
  ...error === void 0 ? {} : { error }
757
788
  });
758
789
  this.#grace = grace ?? PROCESS_GRACE;
790
+ this.#drain = drain ?? PROCESS_DRAIN;
759
791
  this.#evidence = evidence ?? PROCESS_EVIDENCE;
760
792
  this.#backlog = backlog ?? PROCESS_BACKLOG;
793
+ this.#delivery = delivery ?? 0;
761
794
  this.#signal = signal;
762
795
  this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
763
796
  const childEnvironment = mergeEnvironment(command.isolated === true, command.environment);
@@ -784,11 +817,18 @@ var Process = class {
784
817
  this.#reader.on("line", this.#push.bind(this));
785
818
  this.#reader.once("close", this.#finish.bind(this));
786
819
  this.#child.once("error", (cause) => this.#emitter.emit("error", cause));
820
+ this.#child.once("exit", this.#expire.bind(this));
787
821
  this.#child.once("close", this.#close.bind(this));
788
- this.#child.stdin.on("error", () => void 0);
822
+ this.#child.stdin.on("error", (cause) => this.#failInputStream(cause));
789
823
  this.#child.stderr.on("data", this.#retain.bind(this));
790
- if (input !== void 0) this.#child.stdin.write(input);
791
- if (writable !== true) this.#child.stdin.end();
824
+ if (input !== void 0) {
825
+ this.#input += 1;
826
+ this.#child.stdin.write(input, this.#completeInput.bind(this));
827
+ }
828
+ if (writable !== true) {
829
+ this.#input += 1;
830
+ this.#child.stdin.end(this.#completeInput.bind(this));
831
+ }
792
832
  if (signal !== void 0) {
793
833
  this.#abort = this.#terminate.bind(this);
794
834
  signal.addEventListener("abort", this.#abort, { once: true });
@@ -811,11 +851,11 @@ var Process = class {
811
851
  get emitter() {
812
852
  return this.#emitter;
813
853
  }
814
- /** The captured stdout lines, in arrival order, ending when the child's stdout closes. */
854
+ /** The captured stdout lines, in arrival order, ending after queued lines at the terminal moment. */
815
855
  get lines() {
816
856
  return this.#lines;
817
857
  }
818
- /** The decoded byte-bounded stderr tail. */
858
+ /** The decoded byte-bounded stderr tail, frozen at the terminal moment. */
819
859
  get evidence() {
820
860
  return this.#tail.toString("utf8");
821
861
  }
@@ -823,7 +863,15 @@ var Process = class {
823
863
  get truncated() {
824
864
  return this.#truncated;
825
865
  }
826
- /** The terminal child state, observed once from the close event. */
866
+ /** True after the terminal moment arrived and `exit` settled. */
867
+ get settled() {
868
+ return this.#settled;
869
+ }
870
+ /** True after termination began, including after the terminal moment. */
871
+ get stopping() {
872
+ return this.#stopping;
873
+ }
874
+ /** The terminal child state, observed once after stream close or the drain cutoff. */
827
875
  get exit() {
828
876
  return this.#exit.promise;
829
877
  }
@@ -831,51 +879,64 @@ var Process = class {
831
879
  * Write one line to the open standard-input channel.
832
880
  *
833
881
  * @remarks
834
- * Never rejects. The promise settles when the host reports the line handled, so a line written to
835
- * a child that is not reading stays pending until the child drains it or the channel closes. Every
836
- * terminal teardown path destroys stdin, so a pending write always settles by teardown; a caller
837
- * that needs its own deadline arms a timer and calls `stop`.
882
+ * Never rejects. `true` means the host accepted the bytes without reporting a fault; it does not
883
+ * prove that the child read them. An ordinary write settles when the kernel accepts it. Only a
884
+ * full pipe can hold the write unconfirmed. The `delivery` option can bound that wait, and every
885
+ * terminal teardown path settles pending writes. On Windows 11 with Node v24.18.1, measured on
886
+ * 2026-08-21, a child that closes its own file descriptor 0 can leave the parent pipe writable:
887
+ * the write can settle `true` without a callback error or a stream error while the child remains
888
+ * alive. After `stop` or `destroy` begins, a later call settles `false`. Version 0.0.4 could settle
889
+ * that call `true` before teardown destroyed the pipe; returning `false` avoids claiming delivery
890
+ * for bytes the package is about to discard.
838
891
  *
839
892
  * @param text - The line text without its trailing newline
840
- * @returns True when the line reached the host without error; false when the channel was closed, destroyed, ended, or the write failed
893
+ * @returns True when the host accepted the bytes without reporting a fault; false when the channel was closed, destroyed, ended, failed, or remained unconfirmed through `delivery`
841
894
  */
842
895
  send(text) {
843
896
  const stdin = this.#child.stdin;
844
- if (this.#closed || !stdin.writable) return Promise.resolve(false);
897
+ if (this.#settled || this.#stopping || this.#failure !== void 0 || !stdin.writable) return Promise.resolve(false);
845
898
  const settled = Promise.withResolvers();
899
+ this.#writes.set(settled, void 0);
846
900
  try {
847
- stdin.write(`${text}\n`, (error) => {
848
- settled.resolve(error === void 0 || error === null);
849
- });
901
+ stdin.write(`${text}\n`, this.#confirmWrite.bind(this, settled));
850
902
  } catch {
851
- return Promise.resolve(false);
903
+ this.#settleWrite(settled, false);
904
+ return settled.promise;
905
+ }
906
+ if (this.#delivery > 0 && this.#writes.has(settled)) {
907
+ const timer = setTimeout(() => this.#settleWrite(settled, false), this.#delivery);
908
+ timer.unref();
909
+ this.#writes.set(settled, timer);
852
910
  }
853
911
  return settled.promise;
854
912
  }
855
913
  /**
856
- * Terminate the child process tree and await its observed exit.
914
+ * Terminates the child process tree and reaches the terminal observation moment.
857
915
  *
858
916
  * @remarks
859
- * Never rejects, and every call shares one termination.
917
+ * Never rejects, and every call shares one termination. After the termination sequence returns,
918
+ * confirmed or not, it waits at most `drain` for the read channels to close, then cuts them off
919
+ * and settles `exit` when they remain open. A cutoff reached before the native exit reports the
920
+ * `code` and `signal` the host had recorded by then, which is `null` for a child still running.
860
921
  *
861
922
  * @returns True when the child's native exit was observed; false when the confirmation deadline elapsed without it
862
923
  */
863
924
  stop() {
864
- if (this.#stopping !== void 0) return this.#stopping;
865
- this.#stopping = this.#kill();
866
- return this.#stopping;
925
+ if (this.#termination !== void 0) return this.#termination;
926
+ this.#termination = this.#kill();
927
+ return this.#termination;
867
928
  }
868
929
  /**
869
- * Stop the child, close its standard-input channel, and destroy the observation emitter.
930
+ * Stops the child and destroys the observation emitter after the terminal state freezes.
870
931
  *
871
932
  * @remarks
872
- * Always resolves, including when termination was never confirmed.
933
+ * Always resolves, including when termination was never confirmed. Every call shares one barrier.
873
934
  *
874
935
  * @returns The stable barrier shared by every call
875
936
  */
876
937
  destroy() {
877
938
  if (this.#ending !== void 0) return this.#ending;
878
- this.#ending = this.#teardown();
939
+ this.#ending = this.#end();
879
940
  return this.#ending;
880
941
  }
881
942
  #iterate() {
@@ -921,8 +982,8 @@ var Process = class {
921
982
  return;
922
983
  }
923
984
  const bytes = Buffer.byteLength(line) + 1;
924
- const limit = this.#terminating ? this.#backlog * 2 : this.#backlog;
925
- if ((!this.#requested || this.#terminating) && this.#pending + bytes > limit) {
985
+ const limit = this.#stopping ? this.#backlog * 2 : this.#backlog;
986
+ if ((!this.#requested || this.#stopping) && this.#pending + bytes > limit) {
926
987
  this.#truncated = true;
927
988
  return;
928
989
  }
@@ -931,14 +992,14 @@ var Process = class {
931
992
  this.#restrain();
932
993
  }
933
994
  #restrain() {
934
- if (this.#paused || this.#terminating || !this.#requested) return;
995
+ if (this.#paused || this.#stopping || !this.#requested) return;
935
996
  if (this.#pending < this.#backlog) return;
936
997
  this.#paused = true;
937
998
  this.#reader.pause();
938
999
  }
939
1000
  #relieve() {
940
1001
  if (!this.#paused || this.#ended) return;
941
- if (!this.#terminating && this.#pending > this.#backlog / 2) return;
1002
+ if (!this.#stopping && this.#pending > this.#backlog / 2) return;
942
1003
  this.#paused = false;
943
1004
  this.#reader.resume();
944
1005
  }
@@ -953,24 +1014,40 @@ var Process = class {
953
1014
  this.#waiters.length = 0;
954
1015
  }
955
1016
  #retain(chunk) {
956
- if (!Buffer.isBuffer(chunk) || this.#closed) return;
1017
+ if (!Buffer.isBuffer(chunk) || this.#settled) return;
957
1018
  this.#tail = trimTail(Buffer.concat([this.#tail, chunk]), this.#evidence);
958
1019
  const text = this.#decoder.write(chunk);
959
1020
  if (text.length > 0) this.#emitter.emit("stderr", text);
960
1021
  }
961
- #close(code, signal) {
962
- if (this.#closed) return;
1022
+ #close() {
1023
+ if (this.#settled) return;
1024
+ this.#settle(true);
1025
+ }
1026
+ #expire() {
1027
+ if (this.#settled) return;
1028
+ this.#wait();
1029
+ }
1030
+ #wait() {
1031
+ this.#waiting ??= waitForClose(this.#child, this.#drain).then(() => {
1032
+ if (!this.#settled) this.#settle(false);
1033
+ });
1034
+ return this.#waiting;
1035
+ }
1036
+ #settle(drained) {
963
1037
  const suffix = this.#decoder.end();
964
1038
  if (suffix.length > 0) this.#emitter.emit("stderr", suffix);
965
- this.#closed = true;
1039
+ this.#settled = true;
966
1040
  this.#removeAbortListener();
967
1041
  this.#reader.close();
968
1042
  const exit = Object.freeze({
969
- code,
970
- signal
1043
+ code: this.code,
1044
+ signal: this.signal,
1045
+ drained
971
1046
  });
972
1047
  this.#exit.resolve(exit);
973
1048
  this.#emitter.emit("exit", exit);
1049
+ this.#child.stdout.destroy();
1050
+ this.#child.stderr.destroy();
974
1051
  }
975
1052
  #terminate() {
976
1053
  this.stop();
@@ -979,6 +1056,42 @@ var Process = class {
979
1056
  if (this.#signal === void 0 || this.#abort === void 0) return;
980
1057
  this.#signal.removeEventListener("abort", this.#abort);
981
1058
  }
1059
+ #confirmWrite(settled, error) {
1060
+ if (error === void 0 || error === null) {
1061
+ this.#settleWrite(settled, true);
1062
+ return;
1063
+ }
1064
+ this.#failInputCallback(error);
1065
+ }
1066
+ #completeInput() {
1067
+ this.#input -= 1;
1068
+ }
1069
+ #settleWrite(settled, accepted) {
1070
+ if (!this.#writes.has(settled)) return;
1071
+ const timer = this.#writes.get(settled);
1072
+ this.#writes.delete(settled);
1073
+ clearTimeout(timer);
1074
+ settled.resolve(accepted);
1075
+ }
1076
+ #settleWrites() {
1077
+ for (const settled of this.#writes.keys()) this.#settleWrite(settled, false);
1078
+ }
1079
+ #failInputStream(cause) {
1080
+ if (this.#child.stdin.writableEnded || this.#input > 0) {
1081
+ this.#settleWrites();
1082
+ return;
1083
+ }
1084
+ this.#failInputCallback(cause);
1085
+ }
1086
+ #failInputCallback(cause) {
1087
+ if (this.#failure !== void 0 || this.#stopping) return;
1088
+ this.#failure = cause;
1089
+ this.#settleWrites();
1090
+ this.#emitter.emit("error", new ProcessError("The standard-input channel failed", {
1091
+ code: "protocol",
1092
+ cause
1093
+ }));
1094
+ }
982
1095
  #capBacklog() {
983
1096
  const limit = this.#backlog * 2;
984
1097
  while (this.#pending > limit && this.#queue.length > this.#head) {
@@ -989,15 +1102,17 @@ var Process = class {
989
1102
  }
990
1103
  }
991
1104
  async #kill() {
992
- this.#terminating = true;
1105
+ this.#stopping = true;
993
1106
  this.#capBacklog();
994
1107
  this.#relieve();
995
1108
  const confirmed = await stopChild(this.#child, this.#grace, PROCESS_CONFIRMATION);
1109
+ this.#settleWrites();
996
1110
  this.#child.stdin.destroy();
1111
+ if (!this.#settled) await this.#wait();
1112
+ if (!this.#settled) this.#settle(false);
997
1113
  return confirmed;
998
1114
  }
999
- async #teardown() {
1000
- this.#removeAbortListener();
1115
+ async #end() {
1001
1116
  await this.stop();
1002
1117
  this.#emitter.destroy();
1003
1118
  }
@@ -1016,9 +1131,9 @@ var Process = class {
1016
1131
  * the child registered. `launch` throws a {@link createDuplicateError} when the id is already live
1017
1132
  * and a {@link createProtocolError} after `destroy` has begun, including when the caller's own option
1018
1133
  * getter begins that teardown mid-construction — a spawn fault surfaces through the returned child's
1019
- * `exit`, never from `launch`. `destroy` awaits every child's own teardown, which destroys each
1020
- * child's observation emitter and leaves every subscription on it silently inert, then destroys the
1021
- * registry emitter last.
1134
+ * `exit`, never from `launch`. `destroy` awaits every child's own teardown, including the teardown
1135
+ * of a child a lost race refused, which destroys each child's observation emitter and leaves every
1136
+ * subscription on it silently inert, then destroys the registry emitter last.
1022
1137
  *
1023
1138
  * @example
1024
1139
  * ```ts
@@ -1037,6 +1152,7 @@ var ProcessManager = class {
1037
1152
  #emitter;
1038
1153
  #children = /* @__PURE__ */ new Map();
1039
1154
  #ids = /* @__PURE__ */ new Set();
1155
+ #orphans = /* @__PURE__ */ new Set();
1040
1156
  #destroying = false;
1041
1157
  #ending;
1042
1158
  /**
@@ -1083,11 +1199,11 @@ var ProcessManager = class {
1083
1199
  * @remarks
1084
1200
  * {@link Process} reads every option before it spawns, so a caller's own option getter runs while
1085
1201
  * nothing has started and a throw from one strands no process. A getter that begins `destroy`
1086
- * without throwing is the one remaining race, and it leaves a bounded residual: the child is
1087
- * already spawned, so the launch is refused with a {@link createProtocolError} and that child is
1088
- * torn down asynchronously, bounded by `grace` plus the confirmation window. The `protocol`
1089
- * refusal throws synchronously before the `destroy` barrier settles. The barrier does not cover
1090
- * that child's asynchronous teardown.
1202
+ * without throwing is the one remaining race: the child is already spawned, so the launch is
1203
+ * refused with a {@link createProtocolError} and that child is destroyed rather than adopted, its
1204
+ * teardown bounded by `grace` plus the confirmation window. The `protocol` refusal throws
1205
+ * synchronously, and the `destroy` barrier covers that teardown, so the refused child reaches its
1206
+ * terminal moment before the barrier resolves.
1091
1207
  *
1092
1208
  * @param id - The registry key, unique among live children
1093
1209
  * @param options - The child construction options
@@ -1101,6 +1217,7 @@ var ProcessManager = class {
1101
1217
  const child = this.#construct(id, options);
1102
1218
  if (this.#destroying) {
1103
1219
  this.#ids.delete(id);
1220
+ this.#orphans.add(child);
1104
1221
  child.destroy();
1105
1222
  throw createProtocolError(id);
1106
1223
  }
@@ -1157,6 +1274,11 @@ var ProcessManager = class {
1157
1274
  }
1158
1275
  async #teardown() {
1159
1276
  await Promise.allSettled([...this.#children.values()].map((child) => child.destroy()));
1277
+ while (this.#orphans.size > 0) {
1278
+ const orphans = [...this.#orphans];
1279
+ this.#orphans.clear();
1280
+ await Promise.allSettled(orphans.map((child) => child.destroy()));
1281
+ }
1160
1282
  this.#children.clear();
1161
1283
  this.#ids.clear();
1162
1284
  this.#emitter.destroy();
@@ -1257,9 +1379,13 @@ var Retention = class {
1257
1379
  * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
1258
1380
  * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
1259
1381
  * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
1260
- * overrides `command.input`. Unless `strict` is `false`, a failed run rejects with a
1261
- * {@link createExecuteError} carrying the {@link ExecuteResult}. An invalid option or command string rejects
1262
- * before the child is spawned, because an async function cannot throw synchronously.
1382
+ * overrides `command.input`. A host fault while writing that input terminates the run by design and
1383
+ * marks its result failed; a strict rejection carries the host fault as its cause. This differs
1384
+ * from `Process` constructor input, whose package-initiated input phase stays quiet. Unless `strict`
1385
+ * is `false`, a failed run rejects with a {@link ProcessError} carrying the {@link ExecuteResult};
1386
+ * {@link createExecuteError} constructs every rejection outside the input-fault door. An invalid
1387
+ * option or command string rejects before the child is spawned, because an async function cannot
1388
+ * throw synchronously.
1263
1389
  *
1264
1390
  * @param command - The executable, arguments, and optional environment and input
1265
1391
  * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
@@ -1303,6 +1429,7 @@ async function execute(command, options) {
1303
1429
  const terminate = new AbortController();
1304
1430
  const cleanup = new AbortController();
1305
1431
  const finish = new AbortController();
1432
+ const inputFailure = new AbortController();
1306
1433
  const outChunks = [];
1307
1434
  const errChunks = [];
1308
1435
  const outRetention = new Retention();
@@ -1343,6 +1470,7 @@ async function execute(command, options) {
1343
1470
  }));
1344
1471
  }, { once: true });
1345
1472
  terminate.signal.addEventListener("abort", () => {
1473
+ if (finish.signal.aborted) return;
1346
1474
  clearTimeout(timeoutTimer);
1347
1475
  cleanup.abort();
1348
1476
  stopChild(child, grace, PROCESS_CONFIRMATION).then(() => {
@@ -1350,7 +1478,12 @@ async function execute(command, options) {
1350
1478
  confirmTimer = setTimeout(() => finish.abort(), PROCESS_CONFIRMATION);
1351
1479
  });
1352
1480
  }, { once: true });
1353
- child.stdin.on("error", () => void 0);
1481
+ inputFailure.signal.addEventListener("abort", () => {
1482
+ if (finish.signal.aborted) return;
1483
+ cause = inputFailure.signal.reason;
1484
+ terminate.abort();
1485
+ }, { once: true });
1486
+ child.stdin.on("error", (error) => inputFailure.abort(error));
1354
1487
  child.stdout.on("data", (chunk) => {
1355
1488
  const retained = outRetention.retain(chunk, limit);
1356
1489
  if (retained !== void 0) outChunks.push(retained);
@@ -1368,7 +1501,9 @@ async function execute(command, options) {
1368
1501
  finish.abort();
1369
1502
  });
1370
1503
  child.once("close", () => finish.abort());
1371
- if (text !== void 0) child.stdin.write(text);
1504
+ if (text !== void 0) child.stdin.write(text, (error) => {
1505
+ if (error !== void 0 && error !== null) inputFailure.abort(error);
1506
+ });
1372
1507
  child.stdin.end();
1373
1508
  if (timeout > 0) timeoutTimer = setTimeout(() => {
1374
1509
  expired = true;
@@ -1388,7 +1523,19 @@ async function execute(command, options) {
1388
1523
  }
1389
1524
  }
1390
1525
  const result = await settled.promise;
1391
- if (result.failed && strict) throw createExecuteError(result, cause);
1526
+ if (result.failed && strict) {
1527
+ if (cause !== void 0 && cause === inputFailure.signal.reason) throw new ProcessError(`Command '${result.command}' failed while writing standard input`, {
1528
+ code: "input",
1529
+ context: {
1530
+ command: result.command,
1531
+ code: result.code,
1532
+ signal: result.signal
1533
+ },
1534
+ cause: inputFailure.signal.reason,
1535
+ result
1536
+ });
1537
+ throw createExecuteError(result, cause);
1538
+ }
1392
1539
  return result;
1393
1540
  }
1394
1541
  //#endregion
@@ -1513,6 +1660,6 @@ function detach(command, options) {
1513
1660
  child.unref();
1514
1661
  }
1515
1662
  //#endregion
1516
- export { Process, ProcessManager, Retention, buildExecutableCandidates, buildExecuteResult, buildPlatformSpawn, buildSpawn, createProcess, createProcessManager, detach, execute, executeSync, formatCommand, isExited, isFile, killProcess, killTree, mergeEnvironment, mergePlatformEnvironment, quoteArgument, readPlatformVariable, readVariable, resolveExecutable, snapshotCommand, stopChild, trimHead, trimTail, validateBytes, validateCommand, validateEnvironment, validateText, validateTimer, validateWorkspace, waitForExit };
1663
+ export { Process, ProcessManager, Retention, buildExecutableCandidates, buildExecuteResult, buildPlatformSpawn, buildSpawn, createProcess, createProcessManager, detach, execute, executeSync, formatCommand, isExited, isFile, killProcess, killTree, mergeEnvironment, mergePlatformEnvironment, quoteArgument, readPlatformVariable, readVariable, resolveExecutable, snapshotCommand, stopChild, trimHead, trimTail, validateBytes, validateCommand, validateEnvironment, validateText, validateTimer, validateWorkspace, waitForClose, waitForExit };
1517
1664
 
1518
1665
  //# sourceMappingURL=index.js.map