@orkestrel/process 0.0.4 → 0.0.5

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_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";
@@ -683,8 +683,11 @@ function buildExecuteResult(input) {
683
683
  * overshoot it by the line that crossed it plus the rest of its delivered chunk. Termination never
684
684
  * reapplies backpressure, and retained lines are capped at twice `backlog`; later lines are dropped
685
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`,
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`,
688
691
  * `code`, and `signal` read the spawned child directly, so the native exit is readable before the
689
692
  * `exit` promise settles on stdio close. `stop` ends the whole tree and reports whether the native
690
693
  * exit was observed; `destroy` stops the child, destroys stdin so every pending `send` settles, and
@@ -710,6 +713,7 @@ var Process = class {
710
713
  #grace;
711
714
  #evidence;
712
715
  #backlog;
716
+ #delivery;
713
717
  #decoder = new StringDecoder("utf8");
714
718
  #exit = Promise.withResolvers();
715
719
  #lines;
@@ -717,6 +721,7 @@ var Process = class {
717
721
  #waiters = [];
718
722
  #signal;
719
723
  #abort;
724
+ #writes = /* @__PURE__ */ new Map();
720
725
  #tail = Buffer.alloc(0);
721
726
  #head = 0;
722
727
  #pending = 0;
@@ -726,6 +731,8 @@ var Process = class {
726
731
  #terminating = false;
727
732
  #closed = false;
728
733
  #ended = false;
734
+ #input = 0;
735
+ #failure;
729
736
  #stopping;
730
737
  #ending;
731
738
  /**
@@ -740,6 +747,7 @@ var Process = class {
740
747
  const grace = options.grace;
741
748
  const evidence = options.evidence;
742
749
  const backlog = options.backlog;
750
+ const delivery = options.delivery;
743
751
  const writable = options.writable;
744
752
  const signal = options.signal;
745
753
  const on = options.on;
@@ -749,6 +757,7 @@ var Process = class {
749
757
  validateCommand(command);
750
758
  validateWorkspace(workspace);
751
759
  validateTimer(grace, "option 'grace'");
760
+ validateTimer(delivery, "option 'delivery'");
752
761
  validateBytes(evidence, "option 'evidence'", 0);
753
762
  validateBytes(backlog, "option 'backlog'", 1);
754
763
  this.#emitter = new Emitter({
@@ -758,6 +767,7 @@ var Process = class {
758
767
  this.#grace = grace ?? PROCESS_GRACE;
759
768
  this.#evidence = evidence ?? PROCESS_EVIDENCE;
760
769
  this.#backlog = backlog ?? PROCESS_BACKLOG;
770
+ this.#delivery = delivery ?? 0;
761
771
  this.#signal = signal;
762
772
  this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
763
773
  const childEnvironment = mergeEnvironment(command.isolated === true, command.environment);
@@ -785,10 +795,16 @@ var Process = class {
785
795
  this.#reader.once("close", this.#finish.bind(this));
786
796
  this.#child.once("error", (cause) => this.#emitter.emit("error", cause));
787
797
  this.#child.once("close", this.#close.bind(this));
788
- this.#child.stdin.on("error", () => void 0);
798
+ this.#child.stdin.on("error", (cause) => this.#failInputStream(cause));
789
799
  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();
800
+ if (input !== void 0) {
801
+ this.#input += 1;
802
+ this.#child.stdin.write(input, this.#completeInput.bind(this));
803
+ }
804
+ if (writable !== true) {
805
+ this.#input += 1;
806
+ this.#child.stdin.end(this.#completeInput.bind(this));
807
+ }
792
808
  if (signal !== void 0) {
793
809
  this.#abort = this.#terminate.bind(this);
794
810
  signal.addEventListener("abort", this.#abort, { once: true });
@@ -831,24 +847,34 @@ var Process = class {
831
847
  * Write one line to the open standard-input channel.
832
848
  *
833
849
  * @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`.
850
+ * Never rejects. `true` means the host accepted the bytes without reporting a fault; it does not
851
+ * prove that the child read them. An ordinary write settles when the kernel accepts it. Only a
852
+ * full pipe can hold the write unconfirmed. The `delivery` option can bound that wait, and every
853
+ * terminal teardown path settles pending writes. On Windows 11 with Node v24.18.1, measured on
854
+ * 2026-08-21, a child that closes its own file descriptor 0 can leave the parent pipe writable:
855
+ * the write can settle `true` without a callback error or a stream error while the child remains
856
+ * alive. After `stop` or `destroy` begins, a later call settles `false`. Version 0.0.4 could settle
857
+ * that call `true` before teardown destroyed the pipe; returning `false` avoids claiming delivery
858
+ * for bytes the package is about to discard.
838
859
  *
839
860
  * @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
861
+ * @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
862
  */
842
863
  send(text) {
843
864
  const stdin = this.#child.stdin;
844
- if (this.#closed || !stdin.writable) return Promise.resolve(false);
865
+ if (this.#closed || this.#terminating || this.#failure !== void 0 || !stdin.writable) return Promise.resolve(false);
845
866
  const settled = Promise.withResolvers();
867
+ this.#writes.set(settled, void 0);
846
868
  try {
847
- stdin.write(`${text}\n`, (error) => {
848
- settled.resolve(error === void 0 || error === null);
849
- });
869
+ stdin.write(`${text}\n`, this.#confirmWrite.bind(this, settled));
850
870
  } catch {
851
- return Promise.resolve(false);
871
+ this.#settleWrite(settled, false);
872
+ return settled.promise;
873
+ }
874
+ if (this.#delivery > 0 && this.#writes.has(settled)) {
875
+ const timer = setTimeout(() => this.#settleWrite(settled, false), this.#delivery);
876
+ timer.unref();
877
+ this.#writes.set(settled, timer);
852
878
  }
853
879
  return settled.promise;
854
880
  }
@@ -979,6 +1005,42 @@ var Process = class {
979
1005
  if (this.#signal === void 0 || this.#abort === void 0) return;
980
1006
  this.#signal.removeEventListener("abort", this.#abort);
981
1007
  }
1008
+ #confirmWrite(settled, error) {
1009
+ if (error === void 0 || error === null) {
1010
+ this.#settleWrite(settled, true);
1011
+ return;
1012
+ }
1013
+ this.#failInputCallback(error);
1014
+ }
1015
+ #completeInput() {
1016
+ this.#input -= 1;
1017
+ }
1018
+ #settleWrite(settled, accepted) {
1019
+ if (!this.#writes.has(settled)) return;
1020
+ const timer = this.#writes.get(settled);
1021
+ this.#writes.delete(settled);
1022
+ clearTimeout(timer);
1023
+ settled.resolve(accepted);
1024
+ }
1025
+ #settleWrites() {
1026
+ for (const settled of this.#writes.keys()) this.#settleWrite(settled, false);
1027
+ }
1028
+ #failInputStream(cause) {
1029
+ if (this.#child.stdin.writableEnded || this.#input > 0) {
1030
+ this.#settleWrites();
1031
+ return;
1032
+ }
1033
+ this.#failInputCallback(cause);
1034
+ }
1035
+ #failInputCallback(cause) {
1036
+ if (this.#failure !== void 0 || this.#terminating) return;
1037
+ this.#failure = cause;
1038
+ this.#settleWrites();
1039
+ this.#emitter.emit("error", new ProcessError("The standard-input channel failed", {
1040
+ code: "protocol",
1041
+ cause
1042
+ }));
1043
+ }
982
1044
  #capBacklog() {
983
1045
  const limit = this.#backlog * 2;
984
1046
  while (this.#pending > limit && this.#queue.length > this.#head) {
@@ -993,6 +1055,7 @@ var Process = class {
993
1055
  this.#capBacklog();
994
1056
  this.#relieve();
995
1057
  const confirmed = await stopChild(this.#child, this.#grace, PROCESS_CONFIRMATION);
1058
+ this.#settleWrites();
996
1059
  this.#child.stdin.destroy();
997
1060
  return confirmed;
998
1061
  }
@@ -1257,9 +1320,13 @@ var Retention = class {
1257
1320
  * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
1258
1321
  * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
1259
1322
  * 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.
1323
+ * overrides `command.input`. A host fault while writing that input terminates the run by design and
1324
+ * marks its result failed; a strict rejection carries the host fault as its cause. This differs
1325
+ * from `Process` constructor input, whose package-initiated input phase stays quiet. Unless `strict`
1326
+ * is `false`, a failed run rejects with a {@link ProcessError} carrying the {@link ExecuteResult};
1327
+ * {@link createExecuteError} constructs every rejection outside the input-fault door. An invalid
1328
+ * option or command string rejects before the child is spawned, because an async function cannot
1329
+ * throw synchronously.
1263
1330
  *
1264
1331
  * @param command - The executable, arguments, and optional environment and input
1265
1332
  * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
@@ -1303,6 +1370,7 @@ async function execute(command, options) {
1303
1370
  const terminate = new AbortController();
1304
1371
  const cleanup = new AbortController();
1305
1372
  const finish = new AbortController();
1373
+ const inputFailure = new AbortController();
1306
1374
  const outChunks = [];
1307
1375
  const errChunks = [];
1308
1376
  const outRetention = new Retention();
@@ -1350,7 +1418,12 @@ async function execute(command, options) {
1350
1418
  confirmTimer = setTimeout(() => finish.abort(), PROCESS_CONFIRMATION);
1351
1419
  });
1352
1420
  }, { once: true });
1353
- child.stdin.on("error", () => void 0);
1421
+ inputFailure.signal.addEventListener("abort", () => {
1422
+ if (finish.signal.aborted) return;
1423
+ cause = inputFailure.signal.reason;
1424
+ terminate.abort();
1425
+ }, { once: true });
1426
+ child.stdin.on("error", (error) => inputFailure.abort(error));
1354
1427
  child.stdout.on("data", (chunk) => {
1355
1428
  const retained = outRetention.retain(chunk, limit);
1356
1429
  if (retained !== void 0) outChunks.push(retained);
@@ -1368,7 +1441,9 @@ async function execute(command, options) {
1368
1441
  finish.abort();
1369
1442
  });
1370
1443
  child.once("close", () => finish.abort());
1371
- if (text !== void 0) child.stdin.write(text);
1444
+ if (text !== void 0) child.stdin.write(text, (error) => {
1445
+ if (error !== void 0 && error !== null) inputFailure.abort(error);
1446
+ });
1372
1447
  child.stdin.end();
1373
1448
  if (timeout > 0) timeoutTimer = setTimeout(() => {
1374
1449
  expired = true;
@@ -1388,7 +1463,19 @@ async function execute(command, options) {
1388
1463
  }
1389
1464
  }
1390
1465
  const result = await settled.promise;
1391
- if (result.failed && strict) throw createExecuteError(result, cause);
1466
+ if (result.failed && strict) {
1467
+ if (cause !== void 0 && cause === inputFailure.signal.reason) throw new ProcessError(`Command '${result.command}' failed while writing standard input`, {
1468
+ code: "input",
1469
+ context: {
1470
+ command: result.command,
1471
+ code: result.code,
1472
+ signal: result.signal
1473
+ },
1474
+ cause: inputFailure.signal.reason,
1475
+ result
1476
+ });
1477
+ throw createExecuteError(result, cause);
1478
+ }
1392
1479
  return result;
1393
1480
  }
1394
1481
  //#endregion