@orkestrel/process 0.0.5 → 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, ProcessError, 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,29 +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 a
687
- * child `error` cause on a spawn fault, a `protocol` {@link ProcessError} on a host-reported stdin
688
- * fault after the constructor input phase, and the terminal `exit`, alongside the `exit` promise.
689
- * A fault arising from the constructor-supplied `input` write or its closing `end` stays quiet
690
- * because the package initiated that sequence. `pid`,
691
- * `code`, and `signal` read the spawned child directly, so the native exit is readable before the
692
- * `exit` promise settles on stdio close. `stop` ends the whole tree and reports whether the native
693
- * exit was observed; `destroy` stops the child, destroys stdin so every pending `send` settles, and
694
- * destroys the emitter last. `destroy` resolving does not imply the child's stdio has closed, so
695
- * `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.
696
714
  *
697
715
  * @example
698
716
  * ```ts
@@ -711,6 +729,7 @@ var Process = class {
711
729
  #child;
712
730
  #reader;
713
731
  #grace;
732
+ #drain;
714
733
  #evidence;
715
734
  #backlog;
716
735
  #delivery;
@@ -728,12 +747,13 @@ var Process = class {
728
747
  #requested = false;
729
748
  #paused = false;
730
749
  #truncated = false;
731
- #terminating = false;
732
- #closed = false;
750
+ #stopping = false;
751
+ #settled = false;
733
752
  #ended = false;
734
753
  #input = 0;
735
754
  #failure;
736
- #stopping;
755
+ #waiting;
756
+ #termination;
737
757
  #ending;
738
758
  /**
739
759
  * Spawn one child process and begin stream capture.
@@ -745,6 +765,7 @@ var Process = class {
745
765
  const source = options.command;
746
766
  const workspace = options.workspace;
747
767
  const grace = options.grace;
768
+ const drain = options.drain;
748
769
  const evidence = options.evidence;
749
770
  const backlog = options.backlog;
750
771
  const delivery = options.delivery;
@@ -757,6 +778,7 @@ var Process = class {
757
778
  validateCommand(command);
758
779
  validateWorkspace(workspace);
759
780
  validateTimer(grace, "option 'grace'");
781
+ validateTimer(drain, "option 'drain'");
760
782
  validateTimer(delivery, "option 'delivery'");
761
783
  validateBytes(evidence, "option 'evidence'", 0);
762
784
  validateBytes(backlog, "option 'backlog'", 1);
@@ -765,6 +787,7 @@ var Process = class {
765
787
  ...error === void 0 ? {} : { error }
766
788
  });
767
789
  this.#grace = grace ?? PROCESS_GRACE;
790
+ this.#drain = drain ?? PROCESS_DRAIN;
768
791
  this.#evidence = evidence ?? PROCESS_EVIDENCE;
769
792
  this.#backlog = backlog ?? PROCESS_BACKLOG;
770
793
  this.#delivery = delivery ?? 0;
@@ -794,6 +817,7 @@ var Process = class {
794
817
  this.#reader.on("line", this.#push.bind(this));
795
818
  this.#reader.once("close", this.#finish.bind(this));
796
819
  this.#child.once("error", (cause) => this.#emitter.emit("error", cause));
820
+ this.#child.once("exit", this.#expire.bind(this));
797
821
  this.#child.once("close", this.#close.bind(this));
798
822
  this.#child.stdin.on("error", (cause) => this.#failInputStream(cause));
799
823
  this.#child.stderr.on("data", this.#retain.bind(this));
@@ -827,11 +851,11 @@ var Process = class {
827
851
  get emitter() {
828
852
  return this.#emitter;
829
853
  }
830
- /** 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. */
831
855
  get lines() {
832
856
  return this.#lines;
833
857
  }
834
- /** The decoded byte-bounded stderr tail. */
858
+ /** The decoded byte-bounded stderr tail, frozen at the terminal moment. */
835
859
  get evidence() {
836
860
  return this.#tail.toString("utf8");
837
861
  }
@@ -839,7 +863,15 @@ var Process = class {
839
863
  get truncated() {
840
864
  return this.#truncated;
841
865
  }
842
- /** 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. */
843
875
  get exit() {
844
876
  return this.#exit.promise;
845
877
  }
@@ -862,7 +894,7 @@ var Process = class {
862
894
  */
863
895
  send(text) {
864
896
  const stdin = this.#child.stdin;
865
- if (this.#closed || this.#terminating || this.#failure !== void 0 || !stdin.writable) return Promise.resolve(false);
897
+ if (this.#settled || this.#stopping || this.#failure !== void 0 || !stdin.writable) return Promise.resolve(false);
866
898
  const settled = Promise.withResolvers();
867
899
  this.#writes.set(settled, void 0);
868
900
  try {
@@ -879,29 +911,32 @@ var Process = class {
879
911
  return settled.promise;
880
912
  }
881
913
  /**
882
- * Terminate the child process tree and await its observed exit.
914
+ * Terminates the child process tree and reaches the terminal observation moment.
883
915
  *
884
916
  * @remarks
885
- * 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.
886
921
  *
887
922
  * @returns True when the child's native exit was observed; false when the confirmation deadline elapsed without it
888
923
  */
889
924
  stop() {
890
- if (this.#stopping !== void 0) return this.#stopping;
891
- this.#stopping = this.#kill();
892
- return this.#stopping;
925
+ if (this.#termination !== void 0) return this.#termination;
926
+ this.#termination = this.#kill();
927
+ return this.#termination;
893
928
  }
894
929
  /**
895
- * 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.
896
931
  *
897
932
  * @remarks
898
- * Always resolves, including when termination was never confirmed.
933
+ * Always resolves, including when termination was never confirmed. Every call shares one barrier.
899
934
  *
900
935
  * @returns The stable barrier shared by every call
901
936
  */
902
937
  destroy() {
903
938
  if (this.#ending !== void 0) return this.#ending;
904
- this.#ending = this.#teardown();
939
+ this.#ending = this.#end();
905
940
  return this.#ending;
906
941
  }
907
942
  #iterate() {
@@ -947,8 +982,8 @@ var Process = class {
947
982
  return;
948
983
  }
949
984
  const bytes = Buffer.byteLength(line) + 1;
950
- const limit = this.#terminating ? this.#backlog * 2 : this.#backlog;
951
- 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) {
952
987
  this.#truncated = true;
953
988
  return;
954
989
  }
@@ -957,14 +992,14 @@ var Process = class {
957
992
  this.#restrain();
958
993
  }
959
994
  #restrain() {
960
- if (this.#paused || this.#terminating || !this.#requested) return;
995
+ if (this.#paused || this.#stopping || !this.#requested) return;
961
996
  if (this.#pending < this.#backlog) return;
962
997
  this.#paused = true;
963
998
  this.#reader.pause();
964
999
  }
965
1000
  #relieve() {
966
1001
  if (!this.#paused || this.#ended) return;
967
- if (!this.#terminating && this.#pending > this.#backlog / 2) return;
1002
+ if (!this.#stopping && this.#pending > this.#backlog / 2) return;
968
1003
  this.#paused = false;
969
1004
  this.#reader.resume();
970
1005
  }
@@ -979,24 +1014,40 @@ var Process = class {
979
1014
  this.#waiters.length = 0;
980
1015
  }
981
1016
  #retain(chunk) {
982
- if (!Buffer.isBuffer(chunk) || this.#closed) return;
1017
+ if (!Buffer.isBuffer(chunk) || this.#settled) return;
983
1018
  this.#tail = trimTail(Buffer.concat([this.#tail, chunk]), this.#evidence);
984
1019
  const text = this.#decoder.write(chunk);
985
1020
  if (text.length > 0) this.#emitter.emit("stderr", text);
986
1021
  }
987
- #close(code, signal) {
988
- 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) {
989
1037
  const suffix = this.#decoder.end();
990
1038
  if (suffix.length > 0) this.#emitter.emit("stderr", suffix);
991
- this.#closed = true;
1039
+ this.#settled = true;
992
1040
  this.#removeAbortListener();
993
1041
  this.#reader.close();
994
1042
  const exit = Object.freeze({
995
- code,
996
- signal
1043
+ code: this.code,
1044
+ signal: this.signal,
1045
+ drained
997
1046
  });
998
1047
  this.#exit.resolve(exit);
999
1048
  this.#emitter.emit("exit", exit);
1049
+ this.#child.stdout.destroy();
1050
+ this.#child.stderr.destroy();
1000
1051
  }
1001
1052
  #terminate() {
1002
1053
  this.stop();
@@ -1033,7 +1084,7 @@ var Process = class {
1033
1084
  this.#failInputCallback(cause);
1034
1085
  }
1035
1086
  #failInputCallback(cause) {
1036
- if (this.#failure !== void 0 || this.#terminating) return;
1087
+ if (this.#failure !== void 0 || this.#stopping) return;
1037
1088
  this.#failure = cause;
1038
1089
  this.#settleWrites();
1039
1090
  this.#emitter.emit("error", new ProcessError("The standard-input channel failed", {
@@ -1051,16 +1102,17 @@ var Process = class {
1051
1102
  }
1052
1103
  }
1053
1104
  async #kill() {
1054
- this.#terminating = true;
1105
+ this.#stopping = true;
1055
1106
  this.#capBacklog();
1056
1107
  this.#relieve();
1057
1108
  const confirmed = await stopChild(this.#child, this.#grace, PROCESS_CONFIRMATION);
1058
1109
  this.#settleWrites();
1059
1110
  this.#child.stdin.destroy();
1111
+ if (!this.#settled) await this.#wait();
1112
+ if (!this.#settled) this.#settle(false);
1060
1113
  return confirmed;
1061
1114
  }
1062
- async #teardown() {
1063
- this.#removeAbortListener();
1115
+ async #end() {
1064
1116
  await this.stop();
1065
1117
  this.#emitter.destroy();
1066
1118
  }
@@ -1079,9 +1131,9 @@ var Process = class {
1079
1131
  * the child registered. `launch` throws a {@link createDuplicateError} when the id is already live
1080
1132
  * and a {@link createProtocolError} after `destroy` has begun, including when the caller's own option
1081
1133
  * getter begins that teardown mid-construction — a spawn fault surfaces through the returned child's
1082
- * `exit`, never from `launch`. `destroy` awaits every child's own teardown, which destroys each
1083
- * child's observation emitter and leaves every subscription on it silently inert, then destroys the
1084
- * 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.
1085
1137
  *
1086
1138
  * @example
1087
1139
  * ```ts
@@ -1100,6 +1152,7 @@ var ProcessManager = class {
1100
1152
  #emitter;
1101
1153
  #children = /* @__PURE__ */ new Map();
1102
1154
  #ids = /* @__PURE__ */ new Set();
1155
+ #orphans = /* @__PURE__ */ new Set();
1103
1156
  #destroying = false;
1104
1157
  #ending;
1105
1158
  /**
@@ -1146,11 +1199,11 @@ var ProcessManager = class {
1146
1199
  * @remarks
1147
1200
  * {@link Process} reads every option before it spawns, so a caller's own option getter runs while
1148
1201
  * nothing has started and a throw from one strands no process. A getter that begins `destroy`
1149
- * without throwing is the one remaining race, and it leaves a bounded residual: the child is
1150
- * already spawned, so the launch is refused with a {@link createProtocolError} and that child is
1151
- * torn down asynchronously, bounded by `grace` plus the confirmation window. The `protocol`
1152
- * refusal throws synchronously before the `destroy` barrier settles. The barrier does not cover
1153
- * 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.
1154
1207
  *
1155
1208
  * @param id - The registry key, unique among live children
1156
1209
  * @param options - The child construction options
@@ -1164,6 +1217,7 @@ var ProcessManager = class {
1164
1217
  const child = this.#construct(id, options);
1165
1218
  if (this.#destroying) {
1166
1219
  this.#ids.delete(id);
1220
+ this.#orphans.add(child);
1167
1221
  child.destroy();
1168
1222
  throw createProtocolError(id);
1169
1223
  }
@@ -1220,6 +1274,11 @@ var ProcessManager = class {
1220
1274
  }
1221
1275
  async #teardown() {
1222
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
+ }
1223
1282
  this.#children.clear();
1224
1283
  this.#ids.clear();
1225
1284
  this.#emitter.destroy();
@@ -1411,6 +1470,7 @@ async function execute(command, options) {
1411
1470
  }));
1412
1471
  }, { once: true });
1413
1472
  terminate.signal.addEventListener("abort", () => {
1473
+ if (finish.signal.aborted) return;
1414
1474
  clearTimeout(timeoutTimer);
1415
1475
  cleanup.abort();
1416
1476
  stopChild(child, grace, PROCESS_CONFIRMATION).then(() => {
@@ -1600,6 +1660,6 @@ function detach(command, options) {
1600
1660
  child.unref();
1601
1661
  }
1602
1662
  //#endregion
1603
- 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 };
1604
1664
 
1605
1665
  //# sourceMappingURL=index.js.map