@orkestrel/process 0.0.3 → 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.
@@ -1,9 +1,10 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { statSync } from "node:fs";
4
- import { delimiter, extname, join, resolve } from "node:path";
4
+ import { join, posix, win32 } from "node:path";
5
+ import { platform } from "node:process";
5
6
  import { boundsOf, holds, isNonEmptyString, isNonNegativeInteger, isString } from "@orkestrel/contract";
6
- import { PROCESS_BACKLOG, PROCESS_CONFIRMATION, PROCESS_EVIDENCE, PROCESS_GRACE, PROCESS_OUTPUT, PROCESS_PATHEXT, PROCESS_TIMER, createDuplicateError, createInvalidError, createProtocolError, createRunError } 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";
7
8
  import { createInterface } from "node:readline";
8
9
  import { StringDecoder } from "node:string_decoder";
9
10
  import { Emitter } from "@orkestrel/emitter";
@@ -65,10 +66,44 @@ function trimHead(bytes, limit) {
65
66
  return Buffer.from(buffer.subarray(0, end));
66
67
  }
67
68
  /**
69
+ * Takes one owned frozen snapshot of a caller's command.
70
+ *
71
+ * @remarks
72
+ * Every public entry point snapshots before it validates, so the object validated is the object
73
+ * spawned. Each property is read exactly once, because reading one runs the caller's own getter: a
74
+ * command whose `file` changes between reads would otherwise validate one executable and spawn
75
+ * another. The argument vector and the environment record are copied and frozen, so a caller
76
+ * mutating either after the call cannot reach the spawn. An absent optional stays absent rather than
77
+ * becoming an explicit `undefined`.
78
+ *
79
+ * @param command - The caller's command, whose properties may be getters
80
+ * @returns A frozen command carrying the values read at this instant
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * snapshotCommand({ file: 'git', arguments: ['status'] }) // { file: 'git', arguments: ['status'] }
85
+ * ```
86
+ */
87
+ function snapshotCommand(command) {
88
+ const file = command.file;
89
+ const argumentsList = Object.freeze([...command.arguments]);
90
+ const sourceEnvironment = command.environment;
91
+ const environment = sourceEnvironment === void 0 ? void 0 : Object.freeze({ ...sourceEnvironment });
92
+ const input = command.input;
93
+ const isolated = command.isolated;
94
+ return Object.freeze({
95
+ file,
96
+ arguments: argumentsList,
97
+ ...environment === void 0 ? {} : { environment },
98
+ ...input === void 0 ? {} : { input },
99
+ ...isolated === void 0 ? {} : { isolated }
100
+ });
101
+ }
102
+ /**
68
103
  * Renders one command into its diagnostic command line.
69
104
  *
70
105
  * @param command - The executable and its argument vector
71
- * @returns The space-joined command line, for a {@link RunResult} and error messages
106
+ * @returns The space-joined command line, for an {@link ExecuteResult} and error messages
72
107
  *
73
108
  * @example
74
109
  * ```ts
@@ -79,36 +114,54 @@ function formatCommand(command) {
79
114
  return [command.file, ...command.arguments].join(" ");
80
115
  }
81
116
  /**
82
- * Reads one environment variable the way the host resolves it.
117
+ * Reads one environment variable under an explicit platform's key rules.
83
118
  *
84
119
  * @remarks
85
120
  * Windows environment keys are case-insensitive, so a merged record can hold `Path` where the
86
- * caller asks for `PATH`. The exact key is tried first and a folded scan runs only on Windows.
121
+ * caller asks for `PATH`. The exact key is tried first and a folded scan runs only for `win32`.
87
122
  *
88
123
  * @param environment - The environment record to read
89
124
  * @param name - The variable name
125
+ * @param platform - The platform whose key rules apply
90
126
  * @returns The value, or `undefined` when the environment declares no such variable
91
127
  *
92
128
  * @example
93
129
  * ```ts
94
- * readVariable({ Path: 'C:\\Windows' }, 'PATH') // 'C:\\Windows' on Windows
130
+ * readPlatformVariable({ Path: 'C:\\Windows' }, 'PATH', 'win32') // 'C:\\Windows'
95
131
  * ```
96
132
  */
97
- function readVariable(environment, name) {
133
+ function readPlatformVariable(environment, name, platform) {
98
134
  const direct = environment[name];
99
135
  if (direct !== void 0) return direct;
100
- if (process.platform !== "win32") return void 0;
136
+ if (platform !== "win32") return void 0;
101
137
  const target = name.toUpperCase();
102
138
  for (const [key, value] of Object.entries(environment)) if (key.toUpperCase() === target) return value;
103
139
  }
104
140
  /**
105
- * Merges environment overrides into the environment one child receives.
141
+ * Reads one environment variable the way the current host resolves it.
142
+ *
143
+ * @param environment - The environment record to read
144
+ * @param name - The variable name
145
+ * @returns The value, or `undefined` when the environment declares no such variable
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * readVariable({ PATH: '/usr/bin' }, 'PATH') // '/usr/bin'
150
+ * ```
151
+ */
152
+ function readVariable(environment, name) {
153
+ return readPlatformVariable(environment, name, platform);
154
+ }
155
+ /**
156
+ * Merges environment layers under an explicit platform's key rules.
106
157
  *
107
158
  * @remarks
108
- * Later maps override earlier ones and an `undefined` value unsets a key. On Windows the keys fold
159
+ * Later maps override earlier ones and an `undefined` value unsets a key. For `win32` the keys fold
109
160
  * case-insensitively and the last writer wins, so `PATH` followed by `Path` yields one variable
110
161
  * rather than two the host would resolve unpredictably.
111
162
  *
163
+ * @param platform - The platform whose key rules apply
164
+ * @param parent - The parent environment layer
112
165
  * @param isolated - If `true`, the parent environment is excluded; if `false`, the overrides layer over it
113
166
  * @param base - The command's own environment overrides
114
167
  * @param override - Per-invocation overrides applied last
@@ -116,13 +169,13 @@ function readVariable(environment, name) {
116
169
  *
117
170
  * @example
118
171
  * ```ts
119
- * mergeEnvironment(false, { TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
172
+ * mergePlatformEnvironment('linux', {}, false, { TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
120
173
  * ```
121
174
  */
122
- function mergeEnvironment(isolated, base, override) {
123
- const folded = process.platform === "win32";
175
+ function mergePlatformEnvironment(platform, parent, isolated, base, override) {
176
+ const folded = platform === "win32";
124
177
  const layers = [
125
- isolated ? {} : process.env,
178
+ isolated ? {} : parent,
126
179
  base ?? {},
127
180
  override ?? {}
128
181
  ];
@@ -140,6 +193,22 @@ function mergeEnvironment(isolated, base, override) {
140
193
  return merged;
141
194
  }
142
195
  /**
196
+ * Merges environment overrides into the environment one child receives on the current host.
197
+ *
198
+ * @param isolated - If `true`, the parent environment is excluded; if `false`, the overrides layer over it
199
+ * @param base - The command's own environment overrides
200
+ * @param override - Per-invocation overrides applied last
201
+ * @returns The environment for a spawn, carrying no unset key
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * mergeEnvironment(true, { TOKEN: 'a' }) // { TOKEN: 'a' }
206
+ * ```
207
+ */
208
+ function mergeEnvironment(isolated, base, override) {
209
+ return mergePlatformEnvironment(platform, process.env, isolated, base, override);
210
+ }
211
+ /**
143
212
  * Checks whether a path names a regular file.
144
213
  *
145
214
  * @remarks
@@ -158,18 +227,44 @@ function isFile(target) {
158
227
  return holds(() => statSync(target, { throwIfNoEntry: false })?.isFile() === true);
159
228
  }
160
229
  /**
161
- * Resolves a command file to the executable path the host would launch.
230
+ * Builds the executable candidates an explicit platform would search.
162
231
  *
163
232
  * @remarks
164
- * Windows alone needs this: the host searches the working directory before `PATH` and applies
233
+ * Windows alone needs this list: the host searches the working directory before `PATH` and applies
165
234
  * `PATHEXT`, and Node reproduces neither for a direct spawn. Within each searched directory the
166
235
  * literal name is tried first and each `PATHEXT` candidate after it, whether or not the name already
167
236
  * carries an extension — so `report.txt` resolves to a `report.txt` file where one exists and to
168
237
  * `report.txt.cmd` where none does. The lookup reads the child's effective environment, so an
169
238
  * overridden `PATH` selects the executable the child would have found. A POSIX host resolves the
170
- * file itself, so the answer there is always `undefined`. An appended extension is spelled the way
239
+ * file itself, so the candidate list there is empty. An appended extension is spelled the way
171
240
  * `PATHEXT` spells it rather than the way the directory entry does, which the case-insensitive host
172
- * treats as the same file.
241
+ * treats as the same file. A non-Windows platform returns an empty list because `execvp` performs
242
+ * its own lookup.
243
+ *
244
+ * @param file - The command executable name or path
245
+ * @param workspace - The directory searched first
246
+ * @param environment - The child's effective environment
247
+ * @param platform - The platform whose lookup rules apply
248
+ * @returns The ordered absolute candidate paths
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * buildExecutableCandidates('git', 'C:\\work', { PATH: 'C:\\bin' }, 'win32')
253
+ * ```
254
+ */
255
+ function buildExecutableCandidates(file, workspace, environment, platform) {
256
+ if (platform !== "win32") return Object.freeze([]);
257
+ const candidates = [file, ...(readPlatformVariable(environment, "PATHEXT", platform) ?? PROCESS_PATHEXT).split(";").filter((extension) => extension.length > 0).map((extension) => `${file}${extension}`)];
258
+ const directories = file.includes("/") || file.includes("\\") ? [workspace] : [workspace, ...(readPlatformVariable(environment, "PATH", platform) ?? "").split(";")];
259
+ const targets = [];
260
+ for (const directory of directories) {
261
+ if (directory.length === 0) continue;
262
+ for (const candidate of candidates) targets.push(win32.resolve(directory, candidate));
263
+ }
264
+ return Object.freeze(targets);
265
+ }
266
+ /**
267
+ * Resolves a command file to the executable path the host would launch.
173
268
  *
174
269
  * @param file - The command executable name or path
175
270
  * @param options - The directory searched first and the child's effective environment
@@ -181,18 +276,9 @@ function isFile(target) {
181
276
  * ```
182
277
  */
183
278
  function resolveExecutable(file, options) {
184
- if (process.platform !== "win32") return void 0;
185
279
  const environment = options?.environment ?? process.env;
186
- const workspace = options?.workspace ?? process.cwd();
187
- const candidates = [file, ...(readVariable(environment, "PATHEXT") ?? PROCESS_PATHEXT).split(";").filter((extension) => extension.length > 0).map((extension) => `${file}${extension}`)];
188
- const directories = file.includes("/") || file.includes("\\") ? [workspace] : [workspace, ...(readVariable(environment, "PATH") ?? "").split(delimiter)];
189
- for (const directory of directories) {
190
- if (directory.length === 0) continue;
191
- for (const candidate of candidates) {
192
- const target = resolve(directory, candidate);
193
- if (isFile(target)) return target;
194
- }
195
- }
280
+ const candidates = buildExecutableCandidates(file, options?.workspace ?? process.cwd(), environment, platform);
281
+ for (const target of candidates) if (isFile(target)) return target;
196
282
  }
197
283
  /**
198
284
  * Quotes one command-line token for a `cmd.exe` command line.
@@ -200,8 +286,9 @@ function resolveExecutable(file, options) {
200
286
  * @remarks
201
287
  * A token carrying whitespace or a shell metacharacter is wrapped in double quotes so `cmd.exe`
202
288
  * passes it as one literal argument; an embedded double quote is doubled, which is how `cmd.exe`
203
- * reads a literal quote inside a quoted token. Every other token is left exactly as written, so a
204
- * batch script still receives `%1` without added quotes.
289
+ * reads a literal quote inside a quoted token. The quoted set includes `%`, although quoting cannot
290
+ * stop `cmd.exe` expansion; {@link buildSpawn} therefore refuses `%` in an argument to a Windows
291
+ * batch target.
205
292
  *
206
293
  * @param value - The token to quote
207
294
  * @returns The token, quoted only when `cmd.exe` would otherwise split or interpret it
@@ -216,41 +303,41 @@ function quoteArgument(value) {
216
303
  return `"${value.replaceAll("\"", "\"\"")}"`;
217
304
  }
218
305
  /**
219
- * Builds the resolved spawn form of one command.
306
+ * Builds a spawn form from a resolved file and an explicit platform.
220
307
  *
221
308
  * @remarks
222
- * The executable is resolved against the child's effective environment. A resolved `.cmd` or `.bat`
223
- * script cannot be spawned directly **on Windows**, so it runs there through an explicitly quoted
224
- * `cmd.exe /d /s /c` command line with the argument vector passed verbatim. No path uses a shell,
225
- * so a metacharacter in an argument is never interpreted. `cmd.exe` expands `%NAME%` before it
226
- * parses quotes, so no quoting can carry a percent sign through to a Windows batch target: an
227
- * argument carrying one is refused there rather than silently rewritten. The whole batch path is
228
- * Windows-only. A POSIX host has no `cmd.exe` and no restriction on spawning a file directly, so a
229
- * target named `worker.cmd` spawns directly there and receives a percent sign as literal text.
309
+ * A resolved `.cmd` or `.bat` script cannot be spawned directly **on Windows**, so it runs there
310
+ * through an explicitly quoted `cmd.exe /d /s /c` command line with the argument vector passed
311
+ * verbatim. No path uses a shell, so a metacharacter in an argument is never interpreted. `cmd.exe`
312
+ * expands `%NAME%` before it parses quotes, so no quoting can carry a percent sign through to a
313
+ * Windows batch target: an argument carrying one is refused there rather than silently rewritten.
314
+ * The whole batch path is Windows-only. A POSIX host has no `cmd.exe` and no restriction on spawning
315
+ * a file directly, so a target named `worker.cmd` spawns directly there and receives a percent sign
316
+ * as literal text.
230
317
  *
231
318
  * @param command - The executable and its argument vector
232
- * @param options - The directory searched first and the child's effective environment
319
+ * @param file - The resolved executable file
320
+ * @param environment - The child's effective environment
321
+ * @param platform - The platform whose batch rules apply
233
322
  * @returns The file, argument vector, and verbatim flag to spawn with
234
- * @throws A {@link ProcessError} coded `invalid` when the host is Windows, the resolved target is a batch script, and an argument carries a percent sign
323
+ * @throws A {@link ProcessError} coded `invalid` when `platform` is `win32`, the resolved target is a batch script, and an argument carries a percent sign
235
324
  *
236
325
  * @example
237
326
  * ```ts
238
- * buildSpawn({ file: 'node', arguments: ['--version'] }).verbatim // false
327
+ * buildPlatformSpawn({ file: 'node', arguments: ['--version'] }, 'node', {}, 'linux').verbatim // false
239
328
  * ```
240
329
  */
241
- function buildSpawn(command, options) {
242
- const file = resolveExecutable(command.file, options) ?? command.file;
243
- const extension = extname(file).toLowerCase();
244
- if (!(process.platform === "win32" && (extension === ".cmd" || extension === ".bat"))) return Object.freeze({
330
+ function buildPlatformSpawn(command, file, environment, platform) {
331
+ const extension = (platform === "win32" ? win32.extname(file) : posix.extname(file)).toLowerCase();
332
+ if (!(platform === "win32" && (extension === ".cmd" || extension === ".bat"))) return Object.freeze({
245
333
  file,
246
334
  arguments: Object.freeze([...command.arguments]),
247
335
  verbatim: false
248
336
  });
249
337
  for (const argument of command.arguments) if (argument.includes("%")) throw createInvalidError("command argument", argument);
250
338
  const line = [`"${file}"`, ...command.arguments.map(quoteArgument)].join(" ");
251
- const environment = options?.environment ?? process.env;
252
339
  return Object.freeze({
253
- file: readVariable(environment, "ComSpec") ?? "cmd.exe",
340
+ file: readPlatformVariable(environment, "ComSpec", platform) ?? "cmd.exe",
254
341
  arguments: Object.freeze([
255
342
  "/d",
256
343
  "/s",
@@ -261,6 +348,22 @@ function buildSpawn(command, options) {
261
348
  });
262
349
  }
263
350
  /**
351
+ * Builds the resolved spawn form of one command for the current host.
352
+ *
353
+ * @param command - The executable and its argument vector
354
+ * @param options - The directory searched first and the child's effective environment
355
+ * @returns The file, argument vector, and verbatim flag to spawn with
356
+ * @throws A {@link ProcessError} coded `invalid` when the host is Windows, the resolved target is a batch script, and an argument carries a percent sign
357
+ *
358
+ * @example
359
+ * ```ts
360
+ * buildSpawn({ file: 'node', arguments: ['--version'] }).verbatim // false
361
+ * ```
362
+ */
363
+ function buildSpawn(command, options) {
364
+ return buildPlatformSpawn(command, resolveExecutable(command.file, options) ?? command.file, options?.environment ?? process.env, platform);
365
+ }
366
+ /**
264
367
  * Validates one spawn-bound string.
265
368
  *
266
369
  * @remarks
@@ -378,40 +481,6 @@ function validateWorkspace(workspace) {
378
481
  validateText(workspace, "workspace", true);
379
482
  }
380
483
  /**
381
- * Retains one delivered chunk up to a byte limit and records what the stream delivered.
382
- *
383
- * @remarks
384
- * The retained head is kept as chunk slices and joined once, so a long stream never repeatedly
385
- * concatenates a growing buffer. `counts` is a two-slot tally: slot `0` counts every delivered byte
386
- * and slot `1` counts the retained bytes, so a caller compares slot `0` against `limit` to learn
387
- * whether the capture was truncated.
388
- *
389
- * @param chunk - The delivered chunk, ignored when it is not a buffer
390
- * @param chunks - The retained head, appended to in place
391
- * @param counts - The `[delivered, retained]` tally, updated in place
392
- * @param limit - The maximum retained byte count
393
- * @returns Nothing
394
- *
395
- * @example
396
- * ```ts
397
- * const chunks: Buffer[] = []
398
- * const counts = [0, 0]
399
- * retainChunk(Buffer.from('hello'), chunks, counts, 3)
400
- * counts[1] // 3
401
- * ```
402
- */
403
- function retainChunk(chunk, chunks, counts, limit) {
404
- if (!Buffer.isBuffer(chunk)) return;
405
- const delivered = counts[0] ?? 0;
406
- const retained = counts[1] ?? 0;
407
- counts[0] = delivered + chunk.byteLength;
408
- const room = limit - retained;
409
- if (room <= 0) return;
410
- const slice = chunk.byteLength <= room ? chunk : Buffer.from(chunk.subarray(0, room));
411
- chunks.push(slice);
412
- counts[1] = retained + slice.byteLength;
413
- }
414
- /**
415
484
  * Checks whether a child process has reached its native exit.
416
485
  *
417
486
  * @remarks
@@ -435,10 +504,11 @@ function isExited(child) {
435
504
  *
436
505
  * @remarks
437
506
  * On a POSIX host a child spawned `detached` leads its own process group, so signalling the
438
- * negated pid reaches the whole tree. On Windows, or when no pid is available, the signal goes to
439
- * the child directly. A throw during signalling is swallowed: the process can exit between the
440
- * caller's liveness check and this call, and the child's native exit remains the authoritative
441
- * terminal state.
507
+ * negated pid reaches the whole tree. When no group owns the pid and the host reports `ESRCH`, the
508
+ * signal falls back to the child directly. On Windows, or when no pid is available, the signal also
509
+ * goes to the child directly. Every other throw during signalling is swallowed: the process can
510
+ * exit between the caller's liveness check and this call, and the child's native exit remains the
511
+ * authoritative terminal state.
442
512
  *
443
513
  * @param child - The child boundary
444
514
  * @param signal - The signal to deliver
@@ -456,7 +526,12 @@ function killProcess(child, signal) {
456
526
  return;
457
527
  }
458
528
  process.kill(-child.pid, signal);
459
- } catch {}
529
+ } catch (error) {
530
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
531
+ holds(() => child.kill(signal));
532
+ return;
533
+ }
534
+ }
460
535
  }
461
536
  /**
462
537
  * Kills one Windows process tree through `taskkill`.
@@ -509,24 +584,25 @@ function killTree(pid, timeout) {
509
584
  function waitForExit(child, timeout) {
510
585
  if (isExited(child)) return Promise.resolve();
511
586
  const settled = Promise.withResolvers();
512
- const timer = setTimeout(settled.resolve, timeout);
513
- child.once("exit", () => {
514
- clearTimeout(timer);
587
+ const timer = setTimeout(() => {
588
+ child.off("exit", settled.resolve);
515
589
  settled.resolve();
516
- });
517
- return settled.promise;
590
+ }, timeout);
591
+ child.once("exit", settled.resolve);
592
+ return settled.promise.finally(() => clearTimeout(timer));
518
593
  }
519
594
  /**
520
595
  * Terminates one child process tree and reports whether its native exit was observed.
521
596
  *
522
597
  * @remarks
523
- * Windows ends the tree at once through {@link killTree}, falling back to a direct kill when the
524
- * utility fails, because the host has no cooperative termination to offer. A POSIX host signals the
525
- * process group `SIGTERM`, waits `grace`, then signals `SIGKILL`. No signal is initiated once the
526
- * native exit is observed; the window between initiating a signal and the host delivering it belongs
527
- * to the operating system. `confirm` bounds each awaited step rather than the call as a whole: on
528
- * Windows it bounds the `taskkill` call and then the final wait, and on a POSIX host `grace` bounds
529
- * the cooperative wait and `confirm` the final one. Never rejects.
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.
530
606
  *
531
607
  * @param child - The child boundary
532
608
  * @param grace - The cooperative POSIX window in milliseconds between `SIGTERM` and `SIGKILL`
@@ -552,12 +628,12 @@ async function stopChild(child, grace, confirm) {
552
628
  return isExited(child);
553
629
  }
554
630
  /**
555
- * Builds one settled {@link RunResult} from a completed run's captured bytes and terminal facts.
631
+ * Builds one settled {@link ExecuteResult} from a completed run's captured bytes and terminal facts.
556
632
  *
557
633
  * @remarks
558
634
  * `failed` is derived: a run failed when it timed out, was aborted, ended on a host fault, was ended
559
635
  * by a signal, or exited with a code other than `0` — a `null` code from a spawn fault is therefore
560
- * a failure. Both byte fields are bounded by `limit` on a code-point boundary here, which is the one
636
+ * a failure. Each byte field is bounded by `limit` on a code-point boundary here, which is the one
561
637
  * place the captured bytes are decoded.
562
638
  *
563
639
  * @param input - The captured bytes, terminal state, and capture limit
@@ -565,7 +641,7 @@ async function stopChild(child, grace, confirm) {
565
641
  *
566
642
  * @example
567
643
  * ```ts
568
- * buildRunResult({
644
+ * buildExecuteResult({
569
645
  * command: 'git status',
570
646
  * stdout: Buffer.from('ok'),
571
647
  * stderr: Buffer.alloc(0),
@@ -578,7 +654,7 @@ async function stopChild(child, grace, confirm) {
578
654
  * }).failed // false
579
655
  * ```
580
656
  */
581
- function buildRunResult(input) {
657
+ function buildExecuteResult(input) {
582
658
  return Object.freeze({
583
659
  command: input.command,
584
660
  stdout: trimHead(input.stdout, input.limit).toString("utf8"),
@@ -591,247 +667,6 @@ function buildRunResult(input) {
591
667
  truncated: input.truncated
592
668
  });
593
669
  }
594
- /**
595
- * Runs one command to completion, buffering its output, and settles with the outcome.
596
- *
597
- * @remarks
598
- * The executable is resolved through {@link buildSpawn}, so no run uses a shell. On a POSIX host the
599
- * child is detached, which is what lets its whole process group be terminated. Standard output and
600
- * error are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}) and `truncated` reports
601
- * whether either stream exceeded it. A positive `timeout` and an aborting `signal` both terminate the
602
- * child through the same bounded stop, and only the first of them is recorded: a timeout reports
603
- * `expired` and an abort reports `aborted`, never both. After termination the outcome is awaited for
604
- * a bounded window, so a descendant holding the child's stdio cannot keep the run pending. That bound
605
- * covers a terminated run alone: a run with no `timeout` and no `signal` settles on stdio completion
606
- * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
607
- * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
608
- * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
609
- * overrides `command.input`. Unless `strict` is `false`, a failed run rejects with a
610
- * {@link createRunError} carrying the {@link RunResult}. An invalid option or command string rejects
611
- * before the child is spawned, because an async function cannot throw synchronously.
612
- *
613
- * @param command - The executable, arguments, and optional environment and input
614
- * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
615
- * @returns The settled run outcome
616
- * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link RunResult} when the run failed and `strict` is not `false`
617
- *
618
- * @example
619
- * ```ts
620
- * const result = await run({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
621
- * ```
622
- */
623
- async function run(command, options) {
624
- validateCommand(command);
625
- validateEnvironment(options?.environment);
626
- validateWorkspace(options?.workspace);
627
- validateTimer(options?.timeout, "option 'timeout'");
628
- validateTimer(options?.grace, "option 'grace'");
629
- validateBytes(options?.limit, "option 'limit'", 0);
630
- const limit = options?.limit ?? PROCESS_OUTPUT;
631
- const grace = options?.grace ?? PROCESS_GRACE;
632
- const timeout = options?.timeout ?? 0;
633
- const strict = options?.strict ?? true;
634
- const text = options?.input ?? command.input;
635
- const line = formatCommand(command);
636
- const workspace = options?.workspace ?? process.cwd();
637
- const environment = mergeEnvironment(command.isolated === true, command.environment, options?.environment);
638
- const plan = buildSpawn(command, {
639
- workspace,
640
- environment
641
- });
642
- const settled = Promise.withResolvers();
643
- const terminate = new AbortController();
644
- const cleanup = new AbortController();
645
- const finish = new AbortController();
646
- const outChunks = [];
647
- const errChunks = [];
648
- const outCounts = [0, 0];
649
- const errCounts = [0, 0];
650
- let spawned = false;
651
- let expired = false;
652
- let aborted = false;
653
- let cause;
654
- let timeoutTimer;
655
- let confirmTimer;
656
- const child = spawn(plan.file, [...plan.arguments], {
657
- cwd: workspace,
658
- detached: process.platform !== "win32",
659
- env: environment,
660
- stdio: [
661
- "pipe",
662
- "pipe",
663
- "pipe"
664
- ],
665
- windowsHide: true,
666
- windowsVerbatimArguments: plan.verbatim
667
- });
668
- finish.signal.addEventListener("abort", () => {
669
- clearTimeout(timeoutTimer);
670
- clearTimeout(confirmTimer);
671
- cleanup.abort();
672
- settled.resolve(buildRunResult({
673
- command: line,
674
- stdout: Buffer.concat(outChunks),
675
- stderr: Buffer.concat(errChunks),
676
- code: child.exitCode,
677
- signal: child.signalCode,
678
- expired,
679
- aborted,
680
- truncated: (outCounts[0] ?? 0) > limit || (errCounts[0] ?? 0) > limit,
681
- limit,
682
- ...cause === void 0 ? {} : { cause }
683
- }));
684
- }, { once: true });
685
- terminate.signal.addEventListener("abort", () => {
686
- clearTimeout(timeoutTimer);
687
- cleanup.abort();
688
- stopChild(child, grace, PROCESS_CONFIRMATION).then(() => {
689
- if (finish.signal.aborted) return;
690
- confirmTimer = setTimeout(() => finish.abort(), PROCESS_CONFIRMATION);
691
- });
692
- }, { once: true });
693
- child.stdin.on("error", () => void 0);
694
- child.stdout.on("data", (chunk) => retainChunk(chunk, outChunks, outCounts, limit));
695
- child.stderr.on("data", (chunk) => retainChunk(chunk, errChunks, errCounts, limit));
696
- child.once("spawn", () => {
697
- spawned = true;
698
- });
699
- child.once("error", (error) => {
700
- if (spawned) return;
701
- cause = error;
702
- finish.abort();
703
- });
704
- child.once("close", () => finish.abort());
705
- if (text !== void 0) child.stdin.write(text);
706
- child.stdin.end();
707
- if (timeout > 0) timeoutTimer = setTimeout(() => {
708
- expired = true;
709
- terminate.abort();
710
- }, timeout);
711
- if (options?.signal !== void 0) {
712
- const signal = options.signal;
713
- signal.addEventListener("abort", () => {
714
- aborted = true;
715
- terminate.abort();
716
- }, {
717
- once: true,
718
- signal: cleanup.signal
719
- });
720
- if (signal.aborted) {
721
- aborted = true;
722
- terminate.abort();
723
- }
724
- }
725
- const result = await settled.promise;
726
- if (result.failed && strict) throw createRunError(result, cause);
727
- return result;
728
- }
729
- /**
730
- * Runs one command to completion synchronously, buffering its output, and returns the outcome.
731
- *
732
- * @remarks
733
- * The synchronous counterpart of {@link run}, spawned through the same resolver and never through a
734
- * shell. The host offers no cooperative termination window and no in-flight cancellation, so this
735
- * contract carries neither. A positive `timeout` and an output overflow both end the child with
736
- * `SIGKILL`: an overflow reports `truncated` and `failed` together and trims the partial output to
737
- * `limit`, where {@link run} keeps reading and reports `truncated` without failing. The environment
738
- * and input follow the same merge as {@link run}. Unless `strict` is `false`, a failed run throws a
739
- * {@link createRunError} carrying the {@link RunResult}.
740
- *
741
- * @param command - The executable, arguments, and optional environment and input
742
- * @param options - Working directory, timeout, capture limit, and failure delivery
743
- * @returns The run outcome
744
- * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link RunResult} when the run failed and `strict` is not `false`
745
- *
746
- * @example
747
- * ```ts
748
- * const result = runSync({ file: 'git', arguments: ['--version'] }, { strict: false })
749
- * ```
750
- */
751
- function runSync(command, options) {
752
- validateCommand(command);
753
- validateEnvironment(options?.environment);
754
- validateWorkspace(options?.workspace);
755
- validateTimer(options?.timeout, "option 'timeout'");
756
- validateBytes(options?.limit, "option 'limit'", 0);
757
- const limit = options?.limit ?? PROCESS_OUTPUT;
758
- const timeout = options?.timeout ?? 0;
759
- const strict = options?.strict ?? true;
760
- const text = options?.input ?? command.input;
761
- const line = formatCommand(command);
762
- const workspace = options?.workspace ?? process.cwd();
763
- const environment = mergeEnvironment(command.isolated === true, command.environment, options?.environment);
764
- const plan = buildSpawn(command, {
765
- workspace,
766
- environment
767
- });
768
- const outcome = spawnSync(plan.file, [...plan.arguments], {
769
- cwd: workspace,
770
- env: environment,
771
- encoding: "buffer",
772
- maxBuffer: limit,
773
- killSignal: "SIGKILL",
774
- windowsHide: true,
775
- windowsVerbatimArguments: plan.verbatim,
776
- ...text !== void 0 ? { input: text } : {},
777
- ...timeout > 0 ? { timeout } : {}
778
- });
779
- const error = outcome.error;
780
- const fault = error !== void 0 && "code" in error && isString(error.code) ? error.code : "";
781
- const result = buildRunResult({
782
- command: line,
783
- stdout: Buffer.isBuffer(outcome.stdout) ? outcome.stdout : Buffer.alloc(0),
784
- stderr: Buffer.isBuffer(outcome.stderr) ? outcome.stderr : Buffer.alloc(0),
785
- code: outcome.status,
786
- signal: outcome.signal,
787
- expired: fault === "ETIMEDOUT",
788
- aborted: false,
789
- truncated: fault === "ENOBUFS",
790
- limit,
791
- ...error === void 0 ? {} : { cause: error }
792
- });
793
- if (result.failed && strict) throw createRunError(result, error);
794
- return result;
795
- }
796
- /**
797
- * Spawns one command as a detached process and returns without waiting for it.
798
- *
799
- * @remarks
800
- * The child owns no stdio and is unreferenced, so it outlives this process and nothing here observes
801
- * its outcome. The error listener is attached before the child is unreferenced, so a post-spawn host
802
- * fault is swallowed rather than crashing the caller; an invalid command is refused before anything
803
- * is spawned.
804
- *
805
- * @param command - The executable, arguments, and optional environment
806
- * @param options - The working directory the detached child starts in
807
- * @returns Nothing
808
- * @throws A {@link ProcessError} coded `invalid` when the working directory or a command string is malformed
809
- *
810
- * @example
811
- * ```ts
812
- * detach({ file: 'node', arguments: ['daemon.js'] }, { workspace: process.cwd() })
813
- * ```
814
- */
815
- function detach(command, options) {
816
- validateCommand(command);
817
- validateWorkspace(options?.workspace);
818
- const workspace = options?.workspace ?? process.cwd();
819
- const environment = mergeEnvironment(command.isolated === true, command.environment);
820
- const plan = buildSpawn(command, {
821
- workspace,
822
- environment
823
- });
824
- const child = spawn(plan.file, [...plan.arguments], {
825
- cwd: workspace,
826
- detached: true,
827
- env: environment,
828
- stdio: "ignore",
829
- windowsHide: true,
830
- windowsVerbatimArguments: plan.verbatim
831
- });
832
- child.once("error", () => void 0);
833
- child.unref();
834
- }
835
670
  //#endregion
836
671
  //#region src/server/Process.ts
837
672
  /**
@@ -839,20 +674,25 @@ function detach(command, options) {
839
674
  *
840
675
  * @remarks
841
676
  * Standard output is framed through `readline` into a single-consumer stream: each line goes to
842
- * exactly one waiting iterator, so two iterators split the output rather than each receiving all of
843
- * it. While no consumer has ever requested an iterator,
844
- * stdout keeps draining so `exit` still resolves, and retention stops at the `backlog` mark: a
845
- * consumer attaching later receives the retained head, a gap, then the live stream. Once an iterator
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
846
681
  * has been requested, stdout pauses at the mark and resumes at half of it, so that consumer loses
847
- * nothing and the child feels real backpressure. The mark is soft — the host delivers whole chunks,
848
- * so the backlog overshoots it by up to one delivered chunk plus the drain that termination
849
- * releases. Standard error is decoded and forwarded live as the `stderr` event while a byte-bounded
850
- * raw tail is retained as `evidence`. The typed `emitter` also carries the child `error` cause on a
851
- * spawn fault and the terminal `exit`, alongside the `exit` promise. `stop` ends the whole tree and
852
- * reports whether the native exit was observed; `destroy` stops the child, destroys stdin so every
853
- * pending `send` settles, and destroys the emitter last. `destroy` resolving does not imply the
854
- * child's stdio has closed, so `exit` and `lines` can still be outstanding when a descendant holds
855
- * an inherited pipe.
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.
856
696
  *
857
697
  * @example
858
698
  * ```ts
@@ -873,6 +713,7 @@ var Process = class {
873
713
  #grace;
874
714
  #evidence;
875
715
  #backlog;
716
+ #delivery;
876
717
  #decoder = new StringDecoder("utf8");
877
718
  #exit = Promise.withResolvers();
878
719
  #lines;
@@ -880,14 +721,18 @@ var Process = class {
880
721
  #waiters = [];
881
722
  #signal;
882
723
  #abort;
724
+ #writes = /* @__PURE__ */ new Map();
883
725
  #tail = Buffer.alloc(0);
884
726
  #head = 0;
885
727
  #pending = 0;
886
728
  #requested = false;
887
729
  #paused = false;
730
+ #truncated = false;
888
731
  #terminating = false;
889
732
  #closed = false;
890
733
  #ended = false;
734
+ #input = 0;
735
+ #failure;
891
736
  #stopping;
892
737
  #ending;
893
738
  /**
@@ -897,21 +742,22 @@ var Process = class {
897
742
  * @throws A {@link ProcessError} coded `invalid` when an option or command string is malformed
898
743
  */
899
744
  constructor(options) {
900
- const command = options.command;
745
+ const source = options.command;
901
746
  const workspace = options.workspace;
902
747
  const grace = options.grace;
903
748
  const evidence = options.evidence;
904
749
  const backlog = options.backlog;
750
+ const delivery = options.delivery;
905
751
  const writable = options.writable;
906
752
  const signal = options.signal;
907
753
  const on = options.on;
908
754
  const error = options.error;
909
- const isolated = command.isolated;
910
- const overrides = command.environment;
755
+ const command = snapshotCommand(source);
911
756
  const input = command.input;
912
757
  validateCommand(command);
913
758
  validateWorkspace(workspace);
914
759
  validateTimer(grace, "option 'grace'");
760
+ validateTimer(delivery, "option 'delivery'");
915
761
  validateBytes(evidence, "option 'evidence'", 0);
916
762
  validateBytes(backlog, "option 'backlog'", 1);
917
763
  this.#emitter = new Emitter({
@@ -921,17 +767,18 @@ var Process = class {
921
767
  this.#grace = grace ?? PROCESS_GRACE;
922
768
  this.#evidence = evidence ?? PROCESS_EVIDENCE;
923
769
  this.#backlog = backlog ?? PROCESS_BACKLOG;
770
+ this.#delivery = delivery ?? 0;
924
771
  this.#signal = signal;
925
772
  this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
926
- const environment = mergeEnvironment(isolated === true, overrides);
773
+ const childEnvironment = mergeEnvironment(command.isolated === true, command.environment);
927
774
  const plan = buildSpawn(command, {
928
775
  workspace,
929
- environment
776
+ environment: childEnvironment
930
777
  });
931
778
  this.#child = spawn(plan.file, [...plan.arguments], {
932
779
  cwd: workspace,
933
780
  detached: process.platform !== "win32",
934
- env: environment,
781
+ env: childEnvironment,
935
782
  stdio: [
936
783
  "pipe",
937
784
  "pipe",
@@ -948,16 +795,34 @@ var Process = class {
948
795
  this.#reader.once("close", this.#finish.bind(this));
949
796
  this.#child.once("error", (cause) => this.#emitter.emit("error", cause));
950
797
  this.#child.once("close", this.#close.bind(this));
951
- this.#child.stdin.on("error", () => void 0);
798
+ this.#child.stdin.on("error", (cause) => this.#failInputStream(cause));
952
799
  this.#child.stderr.on("data", this.#retain.bind(this));
953
- if (input !== void 0) this.#child.stdin.write(input);
954
- 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
+ }
955
808
  if (signal !== void 0) {
956
809
  this.#abort = this.#terminate.bind(this);
957
810
  signal.addEventListener("abort", this.#abort, { once: true });
958
811
  if (signal.aborted) this.stop();
959
812
  }
960
813
  }
814
+ /** The host process id, fixed when construction returns, or `undefined` when the spawn produced none. */
815
+ get pid() {
816
+ return this.#child.pid;
817
+ }
818
+ /** The exit code the host recorded, or `null` while the child has not exited and when a signal ended it. */
819
+ get code() {
820
+ return this.#child.exitCode;
821
+ }
822
+ /** The terminating signal name the host recorded, or `null` while the child has not exited and when it exited on its own. */
823
+ get signal() {
824
+ return this.#child.signalCode;
825
+ }
961
826
  /** The typed lifecycle observation surface. */
962
827
  get emitter() {
963
828
  return this.#emitter;
@@ -970,6 +835,10 @@ var Process = class {
970
835
  get evidence() {
971
836
  return this.#tail.toString("utf8");
972
837
  }
838
+ /** True when the `lines` stream omitted output after a retention bound was reached. */
839
+ get truncated() {
840
+ return this.#truncated;
841
+ }
973
842
  /** The terminal child state, observed once from the close event. */
974
843
  get exit() {
975
844
  return this.#exit.promise;
@@ -978,24 +847,34 @@ var Process = class {
978
847
  * Write one line to the open standard-input channel.
979
848
  *
980
849
  * @remarks
981
- * Never rejects. The promise settles when the host reports the line handled, so a line written to
982
- * a child that is not reading stays pending until the child drains it or the channel closes. Every
983
- * terminal teardown path destroys stdin, so a pending write always settles by teardown; a caller
984
- * 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.
985
859
  *
986
860
  * @param text - The line text without its trailing newline
987
- * @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`
988
862
  */
989
863
  send(text) {
990
864
  const stdin = this.#child.stdin;
991
- 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);
992
866
  const settled = Promise.withResolvers();
867
+ this.#writes.set(settled, void 0);
993
868
  try {
994
- stdin.write(`${text}\n`, (error) => {
995
- settled.resolve(error === void 0 || error === null);
996
- });
869
+ stdin.write(`${text}\n`, this.#confirmWrite.bind(this, settled));
997
870
  } catch {
998
- 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);
999
878
  }
1000
879
  return settled.promise;
1001
880
  }
@@ -1068,7 +947,11 @@ var Process = class {
1068
947
  return;
1069
948
  }
1070
949
  const bytes = Buffer.byteLength(line) + 1;
1071
- if (!this.#requested && this.#pending + bytes > this.#backlog) return;
950
+ const limit = this.#terminating ? this.#backlog * 2 : this.#backlog;
951
+ if ((!this.#requested || this.#terminating) && this.#pending + bytes > limit) {
952
+ this.#truncated = true;
953
+ return;
954
+ }
1072
955
  this.#queue.push(line);
1073
956
  this.#pending += bytes;
1074
957
  this.#restrain();
@@ -1106,7 +989,7 @@ var Process = class {
1106
989
  const suffix = this.#decoder.end();
1107
990
  if (suffix.length > 0) this.#emitter.emit("stderr", suffix);
1108
991
  this.#closed = true;
1109
- if (this.#signal !== void 0 && this.#abort !== void 0) this.#signal.removeEventListener("abort", this.#abort);
992
+ this.#removeAbortListener();
1110
993
  this.#reader.close();
1111
994
  const exit = Object.freeze({
1112
995
  code,
@@ -1118,14 +1001,66 @@ var Process = class {
1118
1001
  #terminate() {
1119
1002
  this.stop();
1120
1003
  }
1004
+ #removeAbortListener() {
1005
+ if (this.#signal === void 0 || this.#abort === void 0) return;
1006
+ this.#signal.removeEventListener("abort", this.#abort);
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
+ }
1044
+ #capBacklog() {
1045
+ const limit = this.#backlog * 2;
1046
+ while (this.#pending > limit && this.#queue.length > this.#head) {
1047
+ const line = this.#queue.pop();
1048
+ if (line === void 0) break;
1049
+ this.#pending -= Buffer.byteLength(line) + 1;
1050
+ this.#truncated = true;
1051
+ }
1052
+ }
1121
1053
  async #kill() {
1122
1054
  this.#terminating = true;
1055
+ this.#capBacklog();
1123
1056
  this.#relieve();
1124
1057
  const confirmed = await stopChild(this.#child, this.#grace, PROCESS_CONFIRMATION);
1058
+ this.#settleWrites();
1125
1059
  this.#child.stdin.destroy();
1126
1060
  return confirmed;
1127
1061
  }
1128
1062
  async #teardown() {
1063
+ this.#removeAbortListener();
1129
1064
  await this.stop();
1130
1065
  this.#emitter.destroy();
1131
1066
  }
@@ -1213,8 +1148,9 @@ var ProcessManager = class {
1213
1148
  * nothing has started and a throw from one strands no process. A getter that begins `destroy`
1214
1149
  * without throwing is the one remaining race, and it leaves a bounded residual: the child is
1215
1150
  * already spawned, so the launch is refused with a {@link createProtocolError} and that child is
1216
- * torn down asynchronously, bounded by `grace` plus the confirmation window. The `destroy` barrier
1217
- * settled before the refusal, so awaiting it does not cover that teardown.
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.
1218
1154
  *
1219
1155
  * @param id - The registry key, unique among live children
1220
1156
  * @param options - The child construction options
@@ -1328,6 +1264,342 @@ function createProcessManager(options) {
1328
1264
  return new ProcessManager(options);
1329
1265
  }
1330
1266
  //#endregion
1331
- export { Process, ProcessManager, buildRunResult, buildSpawn, createProcess, createProcessManager, detach, formatCommand, isExited, isFile, killProcess, killTree, mergeEnvironment, quoteArgument, readVariable, resolveExecutable, retainChunk, run, runSync, stopChild, trimHead, trimTail, validateBytes, validateCommand, validateEnvironment, validateText, validateTimer, validateWorkspace, waitForExit };
1267
+ //#region src/server/Retention.ts
1268
+ /**
1269
+ * Accumulates byte totals while retaining a bounded stream head.
1270
+ *
1271
+ * @example
1272
+ * ```ts
1273
+ * const retention = new Retention()
1274
+ * retention.retain(Buffer.from('hello'), 3)?.toString('utf8') // 'hel'
1275
+ * retention.retained // 3
1276
+ * ```
1277
+ */
1278
+ var Retention = class {
1279
+ #delivered = 0;
1280
+ #retained = 0;
1281
+ /** The bytes delivered by the stream. */
1282
+ get delivered() {
1283
+ return this.#delivered;
1284
+ }
1285
+ /** The bytes retained from the stream head. */
1286
+ get retained() {
1287
+ return this.#retained;
1288
+ }
1289
+ /**
1290
+ * Retains a delivered chunk within a byte limit.
1291
+ *
1292
+ * @param chunk - The delivered chunk, ignored when it is not a buffer
1293
+ * @param limit - The maximum retained byte count
1294
+ * @returns The retained slice, or `undefined` when the chunk contributes no retained bytes
1295
+ */
1296
+ retain(chunk, limit) {
1297
+ if (!Buffer.isBuffer(chunk)) return void 0;
1298
+ this.#delivered += chunk.byteLength;
1299
+ const room = limit - this.#retained;
1300
+ if (room <= 0) return void 0;
1301
+ const slice = chunk.byteLength <= room ? chunk : Buffer.from(chunk.subarray(0, room));
1302
+ this.#retained += slice.byteLength;
1303
+ return slice;
1304
+ }
1305
+ };
1306
+ //#endregion
1307
+ //#region src/server/execution/execute.ts
1308
+ /**
1309
+ * Runs one command to completion, buffering its output, and settles with the outcome.
1310
+ *
1311
+ * @remarks
1312
+ * The executable is resolved through {@link buildSpawn}, so no run uses a shell. On a POSIX host the
1313
+ * child is detached, which is what lets its whole process group be terminated. Standard output and
1314
+ * error are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}) and `truncated` reports
1315
+ * whether either stream exceeded it. A positive `timeout` and an aborting `signal` both terminate the
1316
+ * child through the same bounded stop, and only the earliest is recorded: a timeout reports
1317
+ * `expired` and an abort reports `aborted`, never both. After termination the outcome is awaited for
1318
+ * a bounded window, so a descendant holding the child's stdio cannot keep the run pending. That bound
1319
+ * covers a terminated run alone: a run with no `timeout` and no `signal` settles on stdio completion
1320
+ * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
1321
+ * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
1322
+ * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
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.
1330
+ *
1331
+ * @param command - The executable, arguments, and optional environment and input
1332
+ * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
1333
+ * @returns The settled run outcome
1334
+ * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
1335
+ *
1336
+ * @example
1337
+ * ```ts
1338
+ * const result = await execute({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
1339
+ * ```
1340
+ */
1341
+ async function execute(command, options) {
1342
+ const snapshot = snapshotCommand(command);
1343
+ const optionEnvironment = options?.environment;
1344
+ const optionWorkspace = options?.workspace;
1345
+ const optionInput = options?.input;
1346
+ const optionTimeout = options?.timeout;
1347
+ const optionGrace = options?.grace;
1348
+ const optionSignal = options?.signal;
1349
+ const optionStrict = options?.strict;
1350
+ const optionLimit = options?.limit;
1351
+ validateCommand(snapshot);
1352
+ validateEnvironment(optionEnvironment);
1353
+ validateWorkspace(optionWorkspace);
1354
+ validateTimer(optionTimeout, "option 'timeout'");
1355
+ validateTimer(optionGrace, "option 'grace'");
1356
+ validateBytes(optionLimit, "option 'limit'", 0);
1357
+ const limit = optionLimit ?? PROCESS_OUTPUT;
1358
+ const grace = optionGrace ?? PROCESS_GRACE;
1359
+ const timeout = optionTimeout ?? 0;
1360
+ const strict = optionStrict ?? true;
1361
+ const text = optionInput ?? snapshot.input;
1362
+ const line = formatCommand(snapshot);
1363
+ const workspace = optionWorkspace ?? process.cwd();
1364
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
1365
+ const plan = buildSpawn(snapshot, {
1366
+ workspace,
1367
+ environment
1368
+ });
1369
+ const settled = Promise.withResolvers();
1370
+ const terminate = new AbortController();
1371
+ const cleanup = new AbortController();
1372
+ const finish = new AbortController();
1373
+ const inputFailure = new AbortController();
1374
+ const outChunks = [];
1375
+ const errChunks = [];
1376
+ const outRetention = new Retention();
1377
+ const errRetention = new Retention();
1378
+ let spawned = false;
1379
+ let expired = false;
1380
+ let aborted = false;
1381
+ let cause;
1382
+ let timeoutTimer;
1383
+ let confirmTimer;
1384
+ const child = spawn(plan.file, [...plan.arguments], {
1385
+ cwd: workspace,
1386
+ detached: process.platform !== "win32",
1387
+ env: environment,
1388
+ stdio: [
1389
+ "pipe",
1390
+ "pipe",
1391
+ "pipe"
1392
+ ],
1393
+ windowsHide: true,
1394
+ windowsVerbatimArguments: plan.verbatim
1395
+ });
1396
+ finish.signal.addEventListener("abort", () => {
1397
+ clearTimeout(timeoutTimer);
1398
+ clearTimeout(confirmTimer);
1399
+ cleanup.abort();
1400
+ settled.resolve(buildExecuteResult({
1401
+ command: line,
1402
+ stdout: Buffer.concat(outChunks),
1403
+ stderr: Buffer.concat(errChunks),
1404
+ code: child.exitCode,
1405
+ signal: child.signalCode,
1406
+ expired,
1407
+ aborted,
1408
+ truncated: outRetention.delivered > limit || errRetention.delivered > limit,
1409
+ limit,
1410
+ ...cause === void 0 ? {} : { cause }
1411
+ }));
1412
+ }, { once: true });
1413
+ terminate.signal.addEventListener("abort", () => {
1414
+ clearTimeout(timeoutTimer);
1415
+ cleanup.abort();
1416
+ stopChild(child, grace, PROCESS_CONFIRMATION).then(() => {
1417
+ if (finish.signal.aborted) return;
1418
+ confirmTimer = setTimeout(() => finish.abort(), PROCESS_CONFIRMATION);
1419
+ });
1420
+ }, { once: true });
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));
1427
+ child.stdout.on("data", (chunk) => {
1428
+ const retained = outRetention.retain(chunk, limit);
1429
+ if (retained !== void 0) outChunks.push(retained);
1430
+ });
1431
+ child.stderr.on("data", (chunk) => {
1432
+ const retained = errRetention.retain(chunk, limit);
1433
+ if (retained !== void 0) errChunks.push(retained);
1434
+ });
1435
+ child.once("spawn", () => {
1436
+ spawned = true;
1437
+ });
1438
+ child.once("error", (error) => {
1439
+ if (spawned) return;
1440
+ cause = error;
1441
+ finish.abort();
1442
+ });
1443
+ child.once("close", () => finish.abort());
1444
+ if (text !== void 0) child.stdin.write(text, (error) => {
1445
+ if (error !== void 0 && error !== null) inputFailure.abort(error);
1446
+ });
1447
+ child.stdin.end();
1448
+ if (timeout > 0) timeoutTimer = setTimeout(() => {
1449
+ expired = true;
1450
+ terminate.abort();
1451
+ }, timeout);
1452
+ if (optionSignal !== void 0) {
1453
+ optionSignal.addEventListener("abort", () => {
1454
+ aborted = true;
1455
+ terminate.abort();
1456
+ }, {
1457
+ once: true,
1458
+ signal: cleanup.signal
1459
+ });
1460
+ if (optionSignal.aborted) {
1461
+ aborted = true;
1462
+ terminate.abort();
1463
+ }
1464
+ }
1465
+ const result = await settled.promise;
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
+ }
1479
+ return result;
1480
+ }
1481
+ //#endregion
1482
+ //#region src/server/execution/executeSync.ts
1483
+ /**
1484
+ * Runs one command to completion synchronously, buffering its output, and returns the outcome.
1485
+ *
1486
+ * @remarks
1487
+ * The synchronous counterpart of {@link execute}, spawned through the same resolver and never through a
1488
+ * shell. The host offers no cooperative termination window and no in-flight cancellation, so this
1489
+ * contract carries neither. A positive `timeout` ends only the root process and can leave
1490
+ * descendants running; use {@link execute} or {@link Process} when timeout must terminate the tree. A
1491
+ * timeout and an output overflow both end the root with `SIGKILL`: an overflow reports `truncated`
1492
+ * and `failed` together and trims the partial output to `limit`, where {@link execute} keeps reading and
1493
+ * reports `truncated` without failing. The environment and input follow the same merge as
1494
+ * {@link execute}. Unless `strict` is `false`, a failed run throws a {@link createExecuteError}
1495
+ * carrying the {@link ExecuteResult}.
1496
+ *
1497
+ * @param command - The executable, arguments, and optional environment and input
1498
+ * @param options - Working directory, timeout, capture limit, and failure delivery
1499
+ * @returns The run outcome
1500
+ * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
1501
+ *
1502
+ * @example
1503
+ * ```ts
1504
+ * const result = executeSync({ file: 'git', arguments: ['--version'] }, { strict: false })
1505
+ * ```
1506
+ */
1507
+ function executeSync(command, options) {
1508
+ const snapshot = snapshotCommand(command);
1509
+ const optionEnvironment = options?.environment;
1510
+ const optionWorkspace = options?.workspace;
1511
+ const optionInput = options?.input;
1512
+ const optionTimeout = options?.timeout;
1513
+ const optionStrict = options?.strict;
1514
+ const optionLimit = options?.limit;
1515
+ validateCommand(snapshot);
1516
+ validateEnvironment(optionEnvironment);
1517
+ validateWorkspace(optionWorkspace);
1518
+ validateTimer(optionTimeout, "option 'timeout'");
1519
+ validateBytes(optionLimit, "option 'limit'", 0);
1520
+ const limit = optionLimit ?? PROCESS_OUTPUT;
1521
+ const timeout = optionTimeout ?? 0;
1522
+ const strict = optionStrict ?? true;
1523
+ const text = optionInput ?? snapshot.input;
1524
+ const line = formatCommand(snapshot);
1525
+ const workspace = optionWorkspace ?? process.cwd();
1526
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
1527
+ const plan = buildSpawn(snapshot, {
1528
+ workspace,
1529
+ environment
1530
+ });
1531
+ const outcome = spawnSync(plan.file, [...plan.arguments], {
1532
+ cwd: workspace,
1533
+ env: environment,
1534
+ encoding: "buffer",
1535
+ maxBuffer: limit,
1536
+ killSignal: "SIGKILL",
1537
+ windowsHide: true,
1538
+ windowsVerbatimArguments: plan.verbatim,
1539
+ ...text !== void 0 ? { input: Buffer.from(text) } : {},
1540
+ ...timeout > 0 ? { timeout } : {}
1541
+ });
1542
+ const error = outcome.error;
1543
+ const fault = error !== void 0 && "code" in error && isString(error.code) ? error.code : "";
1544
+ const result = buildExecuteResult({
1545
+ command: line,
1546
+ stdout: Buffer.isBuffer(outcome.stdout) ? outcome.stdout : Buffer.alloc(0),
1547
+ stderr: Buffer.isBuffer(outcome.stderr) ? outcome.stderr : Buffer.alloc(0),
1548
+ code: outcome.status,
1549
+ signal: outcome.signal,
1550
+ expired: fault === "ETIMEDOUT",
1551
+ aborted: false,
1552
+ truncated: fault === "ENOBUFS",
1553
+ limit,
1554
+ ...error === void 0 ? {} : { cause: error }
1555
+ });
1556
+ if (result.failed && strict) throw createExecuteError(result, error);
1557
+ return result;
1558
+ }
1559
+ //#endregion
1560
+ //#region src/server/execution/detach.ts
1561
+ /**
1562
+ * Spawns one command as a detached process and returns without waiting for it.
1563
+ *
1564
+ * @remarks
1565
+ * The child owns no stdio and is unreferenced, so it outlives this process and nothing here observes
1566
+ * its outcome. The error listener is attached before the child is unreferenced, so a post-spawn host
1567
+ * fault is swallowed rather than crashing the caller; an invalid command is refused before anything
1568
+ * is spawned.
1569
+ *
1570
+ * @param command - The executable, arguments, and optional environment
1571
+ * @param options - The working directory the detached child starts in
1572
+ * @returns Nothing
1573
+ * @throws A {@link ProcessError} coded `invalid` when the working directory or a command string is malformed
1574
+ *
1575
+ * @example
1576
+ * ```ts
1577
+ * detach({ file: 'node', arguments: ['daemon.js'] }, { workspace: process.cwd() })
1578
+ * ```
1579
+ */
1580
+ function detach(command, options) {
1581
+ const snapshot = snapshotCommand(command);
1582
+ const optionWorkspace = options?.workspace;
1583
+ validateCommand(snapshot);
1584
+ validateWorkspace(optionWorkspace);
1585
+ const workspace = optionWorkspace ?? process.cwd();
1586
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment);
1587
+ const plan = buildSpawn(snapshot, {
1588
+ workspace,
1589
+ environment
1590
+ });
1591
+ const child = spawn(plan.file, [...plan.arguments], {
1592
+ cwd: workspace,
1593
+ detached: true,
1594
+ env: environment,
1595
+ stdio: "ignore",
1596
+ windowsHide: true,
1597
+ windowsVerbatimArguments: plan.verbatim
1598
+ });
1599
+ child.once("error", () => void 0);
1600
+ child.unref();
1601
+ }
1602
+ //#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 };
1332
1604
 
1333
1605
  //# sourceMappingURL=index.js.map