@orkestrel/process 0.0.1 → 0.0.3

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,40 +1,15 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let node_buffer = require("node:buffer");
3
3
  let node_child_process = require("node:child_process");
4
+ let node_fs = require("node:fs");
4
5
  let node_path = require("node:path");
6
+ let _orkestrel_contract = require("@orkestrel/contract");
5
7
  let _src_core = require("../core/index.cjs");
6
8
  let node_readline = require("node:readline");
7
9
  let node_string_decoder = require("node:string_decoder");
8
10
  let _orkestrel_emitter = require("@orkestrel/emitter");
9
11
  //#region src/server/helpers.ts
10
12
  /**
11
- * Signals one owned child process, or its detached process group on a POSIX host.
12
- *
13
- * @remarks
14
- * On a POSIX host a child spawned `detached` leads its own process group, so signalling the
15
- * negated pid reaches the whole tree. On Windows, or when no pid is available, the signal goes to
16
- * the child directly. A throw during signalling is swallowed: the process can exit and win the
17
- * signal race, and the child's close event remains the authoritative terminal state.
18
- *
19
- * @param child - The child process signal boundary
20
- * @param signal - The signal to deliver
21
- * @returns Nothing
22
- *
23
- * @example
24
- * ```ts
25
- * killProcess(child, 'SIGTERM')
26
- * ```
27
- */
28
- function killProcess(child, signal) {
29
- try {
30
- if (process.platform === "win32" || child.pid === void 0) {
31
- child.kill(signal);
32
- return;
33
- }
34
- process.kill(-child.pid, signal);
35
- } catch {}
36
- }
37
- /**
38
13
  * Trims a buffer to at most `limit` trailing bytes without splitting a UTF-8 sequence.
39
14
  *
40
15
  * @remarks
@@ -42,7 +17,7 @@ function killProcess(child, signal) {
42
17
  * multibyte sequence, the start advances past the leading continuation bytes so the retained
43
18
  * bytes always begin on a code-point boundary.
44
19
  *
45
- * @param buffer - The accumulated bytes
20
+ * @param bytes - The accumulated bytes
46
21
  * @param limit - The maximum retained byte count
47
22
  * @returns The trailing bytes, at most `limit` and never starting mid-sequence
48
23
  *
@@ -51,7 +26,8 @@ function killProcess(child, signal) {
51
26
  * trimTail(Buffer.from('hello'), 3) // <Buffer 6c 6c 6f>
52
27
  * ```
53
28
  */
54
- function trimTail(buffer, limit) {
29
+ function trimTail(bytes, limit) {
30
+ const buffer = node_buffer.Buffer.isBuffer(bytes) ? bytes : node_buffer.Buffer.from(bytes);
55
31
  if (buffer.byteLength <= limit) return buffer;
56
32
  let start = buffer.byteLength - limit;
57
33
  while (start < buffer.byteLength) {
@@ -69,7 +45,7 @@ function trimTail(buffer, limit) {
69
45
  * a multibyte sequence, the end retreats to the sequence's start so the retained bytes always end
70
46
  * on a code-point boundary and decode without a replacement character.
71
47
  *
72
- * @param buffer - The accumulated bytes
48
+ * @param bytes - The accumulated bytes
73
49
  * @param limit - The maximum retained byte count
74
50
  * @returns The leading bytes, at most `limit` and never ending mid-sequence
75
51
  *
@@ -78,7 +54,8 @@ function trimTail(buffer, limit) {
78
54
  * trimHead(Buffer.from('hello'), 3) // <Buffer 68 65 6c>
79
55
  * ```
80
56
  */
81
- function trimHead(buffer, limit) {
57
+ function trimHead(bytes, limit) {
58
+ const buffer = node_buffer.Buffer.isBuffer(bytes) ? bytes : node_buffer.Buffer.from(bytes);
82
59
  if (buffer.byteLength <= limit) return buffer;
83
60
  let end = limit;
84
61
  while (end > 0) {
@@ -89,115 +66,555 @@ function trimHead(buffer, limit) {
89
66
  return node_buffer.Buffer.from(buffer.subarray(0, end));
90
67
  }
91
68
  /**
92
- * Checks whether a command file must be spawned through a shell on the current host.
69
+ * Renders one command into its diagnostic command line.
70
+ *
71
+ * @param command - The executable and its argument vector
72
+ * @returns The space-joined command line, for a {@link RunResult} and error messages
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * formatCommand({ file: 'git', arguments: ['status'] }) // 'git status'
77
+ * ```
78
+ */
79
+ function formatCommand(command) {
80
+ return [command.file, ...command.arguments].join(" ");
81
+ }
82
+ /**
83
+ * Reads one environment variable the way the host resolves it.
84
+ *
85
+ * @remarks
86
+ * Windows environment keys are case-insensitive, so a merged record can hold `Path` where the
87
+ * caller asks for `PATH`. The exact key is tried first and a folded scan runs only on Windows.
88
+ *
89
+ * @param environment - The environment record to read
90
+ * @param name - The variable name
91
+ * @returns The value, or `undefined` when the environment declares no such variable
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * readVariable({ Path: 'C:\\Windows' }, 'PATH') // 'C:\\Windows' on Windows
96
+ * ```
97
+ */
98
+ function readVariable(environment, name) {
99
+ const direct = environment[name];
100
+ if (direct !== void 0) return direct;
101
+ if (process.platform !== "win32") return void 0;
102
+ const target = name.toUpperCase();
103
+ for (const [key, value] of Object.entries(environment)) if (key.toUpperCase() === target) return value;
104
+ }
105
+ /**
106
+ * Merges environment overrides into the environment one child receives.
107
+ *
108
+ * @remarks
109
+ * Later maps override earlier ones and an `undefined` value unsets a key. On Windows the keys fold
110
+ * case-insensitively and the last writer wins, so `PATH` followed by `Path` yields one variable
111
+ * rather than two the host would resolve unpredictably.
112
+ *
113
+ * @param isolated - If `true`, the parent environment is excluded; if `false`, the overrides layer over it
114
+ * @param base - The command's own environment overrides
115
+ * @param override - Per-invocation overrides applied last
116
+ * @returns The environment for a spawn, carrying no unset key
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * mergeEnvironment(false, { TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
121
+ * ```
122
+ */
123
+ function mergeEnvironment(isolated, base, override) {
124
+ const folded = process.platform === "win32";
125
+ const layers = [
126
+ isolated ? {} : process.env,
127
+ base ?? {},
128
+ override ?? {}
129
+ ];
130
+ const entries = /* @__PURE__ */ new Map();
131
+ for (const layer of layers) for (const [key, value] of Object.entries(layer)) {
132
+ const token = folded ? key.toUpperCase() : key;
133
+ if (value === void 0) {
134
+ entries.delete(token);
135
+ continue;
136
+ }
137
+ entries.set(token, [key, value]);
138
+ }
139
+ const merged = {};
140
+ for (const [key, value] of entries.values()) merged[key] = value;
141
+ return merged;
142
+ }
143
+ /**
144
+ * Checks whether a path names a regular file.
145
+ *
146
+ * @remarks
147
+ * Existence alone does not make a lookup hit: a directory named `git` on `PATH` is not the `git`
148
+ * command. An unreadable or malformed path reports `false` rather than throwing.
149
+ *
150
+ * @param target - The absolute or relative path to inspect
151
+ * @returns True when the path resolves to a regular file; false otherwise
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * isFile(process.execPath) // true
156
+ * ```
157
+ */
158
+ function isFile(target) {
159
+ return (0, _orkestrel_contract.holds)(() => (0, node_fs.statSync)(target, { throwIfNoEntry: false })?.isFile() === true);
160
+ }
161
+ /**
162
+ * Resolves a command file to the executable path the host would launch.
93
163
  *
94
164
  * @remarks
95
- * Node v22+ refuses to spawn a Windows `.cmd` or `.bat` batch file directly and returns `EINVAL`,
96
- * and a bare command name such as `git` resolves through `PATHEXT` to a batch shim rather than an
97
- * executable. Both cases run through the shell. A POSIX host, and a Windows path carrying a real
98
- * executable extension such as `.exe`, spawn directly. When the result is `true`, treat the
99
- * command file and arguments as trusted: shell invocation exposes shell metacharacters.
165
+ * Windows alone needs this: the host searches the working directory before `PATH` and applies
166
+ * `PATHEXT`, and Node reproduces neither for a direct spawn. Within each searched directory the
167
+ * literal name is tried first and each `PATHEXT` candidate after it, whether or not the name already
168
+ * carries an extension — so `report.txt` resolves to a `report.txt` file where one exists and to
169
+ * `report.txt.cmd` where none does. The lookup reads the child's effective environment, so an
170
+ * overridden `PATH` selects the executable the child would have found. A POSIX host resolves the
171
+ * file itself, so the answer there is always `undefined`. An appended extension is spelled the way
172
+ * `PATHEXT` spells it rather than the way the directory entry does, which the case-insensitive host
173
+ * treats as the same file.
100
174
  *
101
175
  * @param file - The command executable name or path
102
- * @returns True when the file must run through a shell; false when it spawns directly
176
+ * @param options - The directory searched first and the child's effective environment
177
+ * @returns The resolved absolute path, or `undefined` when no candidate is a regular file
103
178
  *
104
179
  * @example
105
180
  * ```ts
106
- * requiresShell('git') // true on Windows, false on POSIX
181
+ * resolveExecutable('git', {}) ?? 'git' // an absolute path on Windows, 'git' elsewhere
107
182
  * ```
108
183
  */
109
- function requiresShell(file) {
110
- if (process.platform !== "win32") return false;
111
- const lower = file.toLowerCase();
112
- return lower.endsWith(".cmd") || lower.endsWith(".bat") || (0, node_path.extname)(file) === "";
184
+ function resolveExecutable(file, options) {
185
+ if (process.platform !== "win32") return void 0;
186
+ const environment = options?.environment ?? process.env;
187
+ const workspace = options?.workspace ?? process.cwd();
188
+ const candidates = [file, ...(readVariable(environment, "PATHEXT") ?? _src_core.PROCESS_PATHEXT).split(";").filter((extension) => extension.length > 0).map((extension) => `${file}${extension}`)];
189
+ const directories = file.includes("/") || file.includes("\\") ? [workspace] : [workspace, ...(readVariable(environment, "PATH") ?? "").split(node_path.delimiter)];
190
+ for (const directory of directories) {
191
+ if (directory.length === 0) continue;
192
+ for (const candidate of candidates) {
193
+ const target = (0, node_path.resolve)(directory, candidate);
194
+ if (isFile(target)) return target;
195
+ }
196
+ }
113
197
  }
114
198
  /**
115
- * Renders one command into its diagnostic command line.
199
+ * Quotes one command-line token for a `cmd.exe` command line.
200
+ *
201
+ * @remarks
202
+ * A token carrying whitespace or a shell metacharacter is wrapped in double quotes so `cmd.exe`
203
+ * passes it as one literal argument; an embedded double quote is doubled, which is how `cmd.exe`
204
+ * reads a literal quote inside a quoted token. Every other token is left exactly as written, so a
205
+ * batch script still receives `%1` without added quotes.
206
+ *
207
+ * @param value - The token to quote
208
+ * @returns The token, quoted only when `cmd.exe` would otherwise split or interpret it
209
+ *
210
+ * @example
211
+ * ```ts
212
+ * quoteArgument('a&b') // '"a&b"'
213
+ * ```
214
+ */
215
+ function quoteArgument(value) {
216
+ if (value.length > 0 && !/[\s"&|<>^()%!]/.test(value)) return value;
217
+ return `"${value.replaceAll("\"", "\"\"")}"`;
218
+ }
219
+ /**
220
+ * Builds the resolved spawn form of one command.
221
+ *
222
+ * @remarks
223
+ * The executable is resolved against the child's effective environment. A resolved `.cmd` or `.bat`
224
+ * script cannot be spawned directly **on Windows**, so it runs there through an explicitly quoted
225
+ * `cmd.exe /d /s /c` command line with the argument vector passed verbatim. No path uses a shell,
226
+ * so a metacharacter in an argument is never interpreted. `cmd.exe` expands `%NAME%` before it
227
+ * parses quotes, so no quoting can carry a percent sign through to a Windows batch target: an
228
+ * argument carrying one is refused there rather than silently rewritten. The whole batch path is
229
+ * Windows-only. A POSIX host has no `cmd.exe` and no restriction on spawning a file directly, so a
230
+ * target named `worker.cmd` spawns directly there and receives a percent sign as literal text.
116
231
  *
117
232
  * @param command - The executable and its argument vector
118
- * @returns The space-joined command line, for a {@link RunResult} and error messages
233
+ * @param options - The directory searched first and the child's effective environment
234
+ * @returns The file, argument vector, and verbatim flag to spawn with
235
+ * @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
119
236
  *
120
237
  * @example
121
238
  * ```ts
122
- * commandLine({ file: 'git', arguments: ['status'] }) // 'git status'
239
+ * buildSpawn({ file: 'node', arguments: ['--version'] }).verbatim // false
123
240
  * ```
124
241
  */
125
- function commandLine(command) {
126
- return [command.file, ...command.arguments].join(" ");
242
+ function buildSpawn(command, options) {
243
+ const file = resolveExecutable(command.file, options) ?? command.file;
244
+ const extension = (0, node_path.extname)(file).toLowerCase();
245
+ if (!(process.platform === "win32" && (extension === ".cmd" || extension === ".bat"))) return Object.freeze({
246
+ file,
247
+ arguments: Object.freeze([...command.arguments]),
248
+ verbatim: false
249
+ });
250
+ for (const argument of command.arguments) if (argument.includes("%")) throw (0, _src_core.createInvalidError)("command argument", argument);
251
+ const line = [`"${file}"`, ...command.arguments.map(quoteArgument)].join(" ");
252
+ const environment = options?.environment ?? process.env;
253
+ return Object.freeze({
254
+ file: readVariable(environment, "ComSpec") ?? "cmd.exe",
255
+ arguments: Object.freeze([
256
+ "/d",
257
+ "/s",
258
+ "/c",
259
+ `"${line}"`
260
+ ]),
261
+ verbatim: true
262
+ });
127
263
  }
128
264
  /**
129
- * Merges environment overrides over the current process environment.
265
+ * Validates one spawn-bound string.
130
266
  *
131
267
  * @remarks
132
- * Later maps override earlier ones and an `undefined` value unsets a key. The base is the parent
133
- * process environment, so a child inherits it unless a key is overridden or unset.
268
+ * A NUL character terminates a string for the host, so it is refused everywhere rather than
269
+ * reaching the spawn as a raw `ERR_INVALID_ARG_VALUE`.
134
270
  *
135
- * @param base - The command's own environment overrides
136
- * @param override - Per-invocation overrides applied last
137
- * @returns The merged environment for a spawn
271
+ * @param value - The value to inspect
272
+ * @param subject - The rejected input named as the caller wrote it, such as `command file`
273
+ * @param required - If `true`, an empty string is refused; if `false`, it is accepted
274
+ * @returns Nothing; a successful return means the string is spawn-safe
275
+ * @throws A {@link ProcessError} coded `invalid` when the value is not a string, carries a NUL, or is required and empty
276
+ *
277
+ * @example
278
+ * ```ts
279
+ * validateText('status', 'command argument', false) // returns
280
+ * ```
281
+ */
282
+ function validateText(value, subject, required) {
283
+ if (!(0, _orkestrel_contract.isString)(value) || value.includes("\0")) throw (0, _src_core.createInvalidError)(subject, value);
284
+ if (required && !(0, _orkestrel_contract.isNonEmptyString)(value)) throw (0, _src_core.createInvalidError)(subject, value);
285
+ }
286
+ /**
287
+ * Validates one timer-valued option in milliseconds.
288
+ *
289
+ * @remarks
290
+ * The host truncates a fractional delay and converts anything above {@link PROCESS_TIMER} to one
291
+ * millisecond, so a requested 25-day timeout would otherwise fire immediately.
292
+ *
293
+ * @param value - The value to inspect, or `undefined` when the option was omitted
294
+ * @param subject - The rejected input named as the caller wrote it, such as `option 'grace'`
295
+ * @returns Nothing; a successful return means the value schedules as written
296
+ * @throws A {@link ProcessError} coded `invalid` when the value is not a non-negative integer at or below `PROCESS_TIMER`
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * validateTimer(5_000, "option 'grace'") // returns
301
+ * ```
302
+ */
303
+ function validateTimer(value, subject) {
304
+ if (value === void 0) return;
305
+ if (!(0, _orkestrel_contract.isNonNegativeInteger)(value) || !(0, _orkestrel_contract.boundsOf)(0, _src_core.PROCESS_TIMER)(value)) throw (0, _src_core.createInvalidError)(subject, value);
306
+ }
307
+ /**
308
+ * Validates one byte-valued option.
309
+ *
310
+ * @param value - The value to inspect, or `undefined` when the option was omitted
311
+ * @param subject - The rejected input named as the caller wrote it, such as `option 'limit'`
312
+ * @param minimum - The smallest accepted value
313
+ * @returns Nothing; a successful return means the value bounds a real byte count
314
+ * @throws A {@link ProcessError} coded `invalid` when the value is not a safe integer at or above `minimum`
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * validateBytes(1_024, "option 'limit'", 0) // returns
319
+ * ```
320
+ */
321
+ function validateBytes(value, subject, minimum) {
322
+ if (value === void 0) return;
323
+ if (!(0, _orkestrel_contract.isNonNegativeInteger)(value) || !(0, _orkestrel_contract.boundsOf)(minimum, Number.MAX_SAFE_INTEGER)(value)) throw (0, _src_core.createInvalidError)(subject, value);
324
+ }
325
+ /**
326
+ * Validates every spawn-bound string of one environment override map.
327
+ *
328
+ * @remarks
329
+ * The host refuses a NUL in a variable name or value with a raw `ERR_INVALID_ARG_VALUE`, so the same
330
+ * refusal runs here for a command's own overrides and for a per-invocation override map alike.
331
+ *
332
+ * @param environment - The override map, or `undefined` when none was supplied
333
+ * @returns Nothing; a successful return means every name and value reaches the host intact
334
+ * @throws A {@link ProcessError} coded `invalid` when a name is empty or a name or value carries a NUL
335
+ *
336
+ * @example
337
+ * ```ts
338
+ * validateEnvironment({ TOKEN: 'a' }) // returns
339
+ * ```
340
+ */
341
+ function validateEnvironment(environment) {
342
+ if (environment === void 0) return;
343
+ for (const [key, value] of Object.entries(environment)) {
344
+ validateText(key, "environment key", true);
345
+ if (value !== void 0) validateText(value, "environment value", false);
346
+ }
347
+ }
348
+ /**
349
+ * Validates every spawn-bound string of one command.
350
+ *
351
+ * @param command - The executable, arguments, and optional environment overrides
352
+ * @returns Nothing; a successful return means the command reaches the host intact
353
+ * @throws A {@link ProcessError} coded `invalid` when the file is empty or any string carries a NUL
354
+ *
355
+ * @example
356
+ * ```ts
357
+ * validateCommand({ file: 'git', arguments: ['status'] }) // returns
358
+ * ```
359
+ */
360
+ function validateCommand(command) {
361
+ validateText(command.file, "command file", true);
362
+ for (const argument of command.arguments) validateText(argument, "command argument", false);
363
+ validateEnvironment(command.environment);
364
+ }
365
+ /**
366
+ * Validates the working directory one child starts in.
367
+ *
368
+ * @param workspace - The directory, or `undefined` when the option was omitted
369
+ * @returns Nothing; a successful return means the directory reaches the host intact
370
+ * @throws A {@link ProcessError} coded `invalid` when the directory is empty or carries a NUL
371
+ *
372
+ * @example
373
+ * ```ts
374
+ * validateWorkspace(process.cwd()) // returns
375
+ * ```
376
+ */
377
+ function validateWorkspace(workspace) {
378
+ if (workspace === void 0) return;
379
+ validateText(workspace, "workspace", true);
380
+ }
381
+ /**
382
+ * Retains one delivered chunk up to a byte limit and records what the stream delivered.
383
+ *
384
+ * @remarks
385
+ * The retained head is kept as chunk slices and joined once, so a long stream never repeatedly
386
+ * concatenates a growing buffer. `counts` is a two-slot tally: slot `0` counts every delivered byte
387
+ * and slot `1` counts the retained bytes, so a caller compares slot `0` against `limit` to learn
388
+ * whether the capture was truncated.
389
+ *
390
+ * @param chunk - The delivered chunk, ignored when it is not a buffer
391
+ * @param chunks - The retained head, appended to in place
392
+ * @param counts - The `[delivered, retained]` tally, updated in place
393
+ * @param limit - The maximum retained byte count
394
+ * @returns Nothing
395
+ *
396
+ * @example
397
+ * ```ts
398
+ * const chunks: Buffer[] = []
399
+ * const counts = [0, 0]
400
+ * retainChunk(Buffer.from('hello'), chunks, counts, 3)
401
+ * counts[1] // 3
402
+ * ```
403
+ */
404
+ function retainChunk(chunk, chunks, counts, limit) {
405
+ if (!node_buffer.Buffer.isBuffer(chunk)) return;
406
+ const delivered = counts[0] ?? 0;
407
+ const retained = counts[1] ?? 0;
408
+ counts[0] = delivered + chunk.byteLength;
409
+ const room = limit - retained;
410
+ if (room <= 0) return;
411
+ const slice = chunk.byteLength <= room ? chunk : node_buffer.Buffer.from(chunk.subarray(0, room));
412
+ chunks.push(slice);
413
+ counts[1] = retained + slice.byteLength;
414
+ }
415
+ /**
416
+ * Checks whether a child process has reached its native exit.
417
+ *
418
+ * @remarks
419
+ * The host sets exactly one of these fields when the process ends, so together they are the
420
+ * authoritative liveness answer. Reading them before each signal is what keeps a signal from being
421
+ * initiated against a pid the host may have reused.
422
+ *
423
+ * @param child - The child boundary
424
+ * @returns True when the process has exited; false while it is live
425
+ *
426
+ * @example
427
+ * ```ts
428
+ * isExited({ exitCode: 0, signalCode: null }) // true
429
+ * ```
430
+ */
431
+ function isExited(child) {
432
+ return child.exitCode !== null || child.signalCode !== null;
433
+ }
434
+ /**
435
+ * Signals one owned child process, or its detached process group on a POSIX host.
436
+ *
437
+ * @remarks
438
+ * On a POSIX host a child spawned `detached` leads its own process group, so signalling the
439
+ * negated pid reaches the whole tree. On Windows, or when no pid is available, the signal goes to
440
+ * the child directly. A throw during signalling is swallowed: the process can exit between the
441
+ * caller's liveness check and this call, and the child's native exit remains the authoritative
442
+ * terminal state.
443
+ *
444
+ * @param child - The child boundary
445
+ * @param signal - The signal to deliver
446
+ * @returns Nothing
138
447
  *
139
448
  * @example
140
449
  * ```ts
141
- * mergeEnvironment({ TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
450
+ * killProcess(child, 'SIGTERM')
142
451
  * ```
143
452
  */
144
- function mergeEnvironment(base, override) {
145
- return {
146
- ...process.env,
147
- ...base,
148
- ...override
149
- };
453
+ function killProcess(child, signal) {
454
+ try {
455
+ if (process.platform === "win32" || child.pid === void 0) {
456
+ child.kill(signal);
457
+ return;
458
+ }
459
+ process.kill(-child.pid, signal);
460
+ } catch {}
150
461
  }
151
462
  /**
152
- * Builds one settled {@link RunResult} from a completed one-shot run's captured bytes and exit.
463
+ * Kills one Windows process tree through `taskkill`.
153
464
  *
154
465
  * @remarks
155
- * `failed` is derived: a run failed when it timed out, was ended by a signal, or exited with a
156
- * code other than `0` — a `null` code from a spawn fault is therefore a failure. Both output
157
- * buffers are byte-bounded by `limit` before decoding.
158
- *
159
- * @param command - The diagnostic command line
160
- * @param stdout - The captured standard output bytes
161
- * @param stderr - The captured standard error bytes
162
- * @param code - The exit code, or `null` when a signal or spawn fault ended the run
163
- * @param signal - The terminating signal, or `null`
164
- * @param timedOut - If `true`, the run's own timeout elapsed; if `false`, it did not
165
- * @param limit - The maximum captured bytes for each stream
466
+ * Windows has no process group a signal can reach, so the whole tree is ended by the host utility,
467
+ * addressed by its absolute `System32` path so a `PATH` override cannot substitute another program.
468
+ * The call is bounded: it is killed itself when `timeout` elapses, which is what keeps a termination
469
+ * from waiting on the host indefinitely. A tree is discoverable only while its root lives, so a
470
+ * descendant that outlives the root is beyond this mechanism.
471
+ *
472
+ * @param pid - The root process id of the tree to end
473
+ * @param timeout - The milliseconds the utility is given before it is killed
474
+ * @returns True when the utility reported success; false when it failed, was unavailable, or was cut off
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * await killTree(child.pid ?? 0, 5_000)
479
+ * ```
480
+ */
481
+ function killTree(pid, timeout) {
482
+ const settled = Promise.withResolvers();
483
+ const executable = (0, node_path.join)(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe");
484
+ const killer = (0, node_child_process.spawn)(executable, [
485
+ "/F",
486
+ "/T",
487
+ "/PID",
488
+ String(pid)
489
+ ], {
490
+ stdio: "ignore",
491
+ windowsHide: true,
492
+ timeout
493
+ });
494
+ killer.once("error", () => settled.resolve(false));
495
+ killer.once("close", (code) => settled.resolve(code === 0));
496
+ return settled.promise;
497
+ }
498
+ /**
499
+ * Waits for one child process's native exit, bounded by a deadline.
500
+ *
501
+ * @param child - The child boundary
502
+ * @param timeout - The milliseconds to wait before giving up
503
+ * @returns A promise that resolves on the native exit or when the deadline elapses, whichever comes first
504
+ *
505
+ * @example
506
+ * ```ts
507
+ * await waitForExit(child, 5_000)
508
+ * ```
509
+ */
510
+ function waitForExit(child, timeout) {
511
+ if (isExited(child)) return Promise.resolve();
512
+ const settled = Promise.withResolvers();
513
+ const timer = setTimeout(settled.resolve, timeout);
514
+ child.once("exit", () => {
515
+ clearTimeout(timer);
516
+ settled.resolve();
517
+ });
518
+ return settled.promise;
519
+ }
520
+ /**
521
+ * Terminates one child process tree and reports whether its native exit was observed.
522
+ *
523
+ * @remarks
524
+ * Windows ends the tree at once through {@link killTree}, falling back to a direct kill when the
525
+ * utility fails, because the host has no cooperative termination to offer. A POSIX host signals the
526
+ * process group `SIGTERM`, waits `grace`, then signals `SIGKILL`. No signal is initiated once the
527
+ * native exit is observed; the window between initiating a signal and the host delivering it belongs
528
+ * to the operating system. `confirm` bounds each awaited step rather than the call as a whole: on
529
+ * Windows it bounds the `taskkill` call and then the final wait, and on a POSIX host `grace` bounds
530
+ * the cooperative wait and `confirm` the final one. Never rejects.
531
+ *
532
+ * @param child - The child boundary
533
+ * @param grace - The cooperative POSIX window in milliseconds between `SIGTERM` and `SIGKILL`
534
+ * @param confirm - The milliseconds the native exit is awaited after the final kill
535
+ * @returns True when the native exit was observed; false when `confirm` elapsed without it
536
+ *
537
+ * @example
538
+ * ```ts
539
+ * const confirmed = await stopChild(child, 5_000, 5_000)
540
+ * ```
541
+ */
542
+ async function stopChild(child, grace, confirm) {
543
+ if (isExited(child)) return true;
544
+ if (process.platform === "win32") {
545
+ if (!(child.pid === void 0 ? false : await killTree(child.pid, confirm)) && !isExited(child)) killProcess(child, "SIGKILL");
546
+ } else {
547
+ killProcess(child, "SIGTERM");
548
+ await waitForExit(child, grace);
549
+ if (!isExited(child)) killProcess(child, "SIGKILL");
550
+ }
551
+ if (isExited(child)) return true;
552
+ await waitForExit(child, confirm);
553
+ return isExited(child);
554
+ }
555
+ /**
556
+ * Builds one settled {@link RunResult} from a completed run's captured bytes and terminal facts.
557
+ *
558
+ * @remarks
559
+ * `failed` is derived: a run failed when it timed out, was aborted, ended on a host fault, was ended
560
+ * by a signal, or exited with a code other than `0` — a `null` code from a spawn fault is therefore
561
+ * a failure. Both byte fields are bounded by `limit` on a code-point boundary here, which is the one
562
+ * place the captured bytes are decoded.
563
+ *
564
+ * @param input - The captured bytes, terminal state, and capture limit
166
565
  * @returns The frozen run outcome
167
566
  *
168
567
  * @example
169
568
  * ```ts
170
- * buildRunResult('git status', Buffer.from('ok'), Buffer.alloc(0), 0, null, false, 1024)
569
+ * buildRunResult({
570
+ * command: 'git status',
571
+ * stdout: Buffer.from('ok'),
572
+ * stderr: Buffer.alloc(0),
573
+ * code: 0,
574
+ * signal: null,
575
+ * expired: false,
576
+ * aborted: false,
577
+ * truncated: false,
578
+ * limit: 1_024,
579
+ * }).failed // false
171
580
  * ```
172
581
  */
173
- function buildRunResult(command, stdout, stderr, code, signal, timedOut, limit) {
582
+ function buildRunResult(input) {
174
583
  return Object.freeze({
175
- command,
176
- stdout: trimHead(stdout, limit).toString("utf8"),
177
- stderr: trimHead(stderr, limit).toString("utf8"),
178
- code,
179
- signal,
180
- failed: timedOut || signal !== null || code !== 0,
181
- timedOut
584
+ command: input.command,
585
+ stdout: trimHead(input.stdout, input.limit).toString("utf8"),
586
+ stderr: trimHead(input.stderr, input.limit).toString("utf8"),
587
+ code: input.code,
588
+ signal: input.signal,
589
+ failed: input.expired || input.aborted || input.cause !== void 0 || input.signal !== null || input.code !== 0,
590
+ expired: input.expired,
591
+ aborted: input.aborted,
592
+ truncated: input.truncated
182
593
  });
183
594
  }
184
595
  /**
185
596
  * Runs one command to completion, buffering its output, and settles with the outcome.
186
597
  *
187
598
  * @remarks
188
- * The child is spawned through the cross-platform command resolver ({@link requiresShell}) and, on
189
- * a POSIX host, detached so its whole process group can be terminated. Standard output and error
190
- * are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}). A positive `timeout` arms a
191
- * timer that terminates the child through `SIGTERM`, then `SIGKILL` after `grace`
192
- * (default {@link PROCESS_GRACE}); aborting `signal` terminates the same way. The child's
193
- * `environment` merges over the parent, then `options.environment` on top, and `options.input`
194
- * overrides `command.input`. Unless `reject` is `false`, a failed run rejects with a
195
- * {@link createRunError} carrying the {@link RunResult}.
599
+ * The executable is resolved through {@link buildSpawn}, so no run uses a shell. On a POSIX host the
600
+ * child is detached, which is what lets its whole process group be terminated. Standard output and
601
+ * error are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}) and `truncated` reports
602
+ * whether either stream exceeded it. A positive `timeout` and an aborting `signal` both terminate the
603
+ * child through the same bounded stop, and only the first of them is recorded: a timeout reports
604
+ * `expired` and an abort reports `aborted`, never both. After termination the outcome is awaited for
605
+ * a bounded window, so a descendant holding the child's stdio cannot keep the run pending. That bound
606
+ * covers a terminated run alone: a run with no `timeout` and no `signal` settles on stdio completion
607
+ * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
608
+ * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
609
+ * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
610
+ * overrides `command.input`. Unless `strict` is `false`, a failed run rejects with a
611
+ * {@link createRunError} carrying the {@link RunResult}. An invalid option or command string rejects
612
+ * before the child is spawned, because an async function cannot throw synchronously.
196
613
  *
197
614
  * @param command - The executable, arguments, and optional environment and input
198
- * @param options - Working directory, timeout, grace, signal, capture limit, and reject behavior
615
+ * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
199
616
  * @returns The settled run outcome
200
- * @throws A {@link ProcessError} carrying the {@link RunResult} when the run failed and `reject` is not `false`
617
+ * @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`
201
618
  *
202
619
  * @example
203
620
  * ```ts
@@ -205,137 +622,238 @@ function buildRunResult(command, stdout, stderr, code, signal, timedOut, limit)
205
622
  * ```
206
623
  */
207
624
  async function run(command, options) {
625
+ validateCommand(command);
626
+ validateEnvironment(options?.environment);
627
+ validateWorkspace(options?.workspace);
628
+ validateTimer(options?.timeout, "option 'timeout'");
629
+ validateTimer(options?.grace, "option 'grace'");
630
+ validateBytes(options?.limit, "option 'limit'", 0);
208
631
  const limit = options?.limit ?? _src_core.PROCESS_OUTPUT;
209
632
  const grace = options?.grace ?? _src_core.PROCESS_GRACE;
210
633
  const timeout = options?.timeout ?? 0;
211
- const reject = options?.reject ?? true;
212
- const input = options?.input ?? command.input;
213
- const line = commandLine(command);
634
+ const strict = options?.strict ?? true;
635
+ const text = options?.input ?? command.input;
636
+ const line = formatCommand(command);
637
+ const workspace = options?.workspace ?? process.cwd();
638
+ const environment = mergeEnvironment(command.isolated === true, command.environment, options?.environment);
639
+ const plan = buildSpawn(command, {
640
+ workspace,
641
+ environment
642
+ });
214
643
  const settled = Promise.withResolvers();
215
644
  const terminate = new AbortController();
216
645
  const cleanup = new AbortController();
217
- let stdout = node_buffer.Buffer.alloc(0);
218
- let stderr = node_buffer.Buffer.alloc(0);
219
- let timedOut = false;
220
- let killTimer;
646
+ const finish = new AbortController();
647
+ const outChunks = [];
648
+ const errChunks = [];
649
+ const outCounts = [0, 0];
650
+ const errCounts = [0, 0];
651
+ let spawned = false;
652
+ let expired = false;
653
+ let aborted = false;
654
+ let cause;
221
655
  let timeoutTimer;
222
- let done = false;
223
- const child = (0, node_child_process.spawn)(command.file, [...command.arguments], {
224
- cwd: options?.workspace ?? process.cwd(),
656
+ let confirmTimer;
657
+ const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
658
+ cwd: workspace,
225
659
  detached: process.platform !== "win32",
226
- env: mergeEnvironment(command.environment, options?.environment),
660
+ env: environment,
227
661
  stdio: [
228
662
  "pipe",
229
663
  "pipe",
230
664
  "pipe"
231
665
  ],
232
- shell: requiresShell(command.file),
233
- windowsHide: true
666
+ windowsHide: true,
667
+ windowsVerbatimArguments: plan.verbatim
234
668
  });
669
+ finish.signal.addEventListener("abort", () => {
670
+ clearTimeout(timeoutTimer);
671
+ clearTimeout(confirmTimer);
672
+ cleanup.abort();
673
+ settled.resolve(buildRunResult({
674
+ command: line,
675
+ stdout: node_buffer.Buffer.concat(outChunks),
676
+ stderr: node_buffer.Buffer.concat(errChunks),
677
+ code: child.exitCode,
678
+ signal: child.signalCode,
679
+ expired,
680
+ aborted,
681
+ truncated: (outCounts[0] ?? 0) > limit || (errCounts[0] ?? 0) > limit,
682
+ limit,
683
+ ...cause === void 0 ? {} : { cause }
684
+ }));
685
+ }, { once: true });
235
686
  terminate.signal.addEventListener("abort", () => {
236
- killProcess(child, "SIGTERM");
237
- killTimer = setTimeout(() => killProcess(child, "SIGKILL"), grace);
687
+ clearTimeout(timeoutTimer);
688
+ cleanup.abort();
689
+ stopChild(child, grace, _src_core.PROCESS_CONFIRMATION).then(() => {
690
+ if (finish.signal.aborted) return;
691
+ confirmTimer = setTimeout(() => finish.abort(), _src_core.PROCESS_CONFIRMATION);
692
+ });
238
693
  }, { once: true });
239
694
  child.stdin.on("error", () => void 0);
240
- child.stdout.on("data", (chunk) => {
241
- if (node_buffer.Buffer.isBuffer(chunk)) stdout = trimHead(node_buffer.Buffer.concat([stdout, chunk]), limit);
695
+ child.stdout.on("data", (chunk) => retainChunk(chunk, outChunks, outCounts, limit));
696
+ child.stderr.on("data", (chunk) => retainChunk(chunk, errChunks, errCounts, limit));
697
+ child.once("spawn", () => {
698
+ spawned = true;
242
699
  });
243
- child.stderr.on("data", (chunk) => {
244
- if (node_buffer.Buffer.isBuffer(chunk)) stderr = trimHead(node_buffer.Buffer.concat([stderr, chunk]), limit);
700
+ child.once("error", (error) => {
701
+ if (spawned) return;
702
+ cause = error;
703
+ finish.abort();
245
704
  });
246
- child.once("error", () => {
247
- if (done) return;
248
- done = true;
249
- cleanup.abort();
250
- clearTimeout(killTimer);
251
- clearTimeout(timeoutTimer);
252
- settled.resolve(buildRunResult(line, stdout, stderr, null, null, false, limit));
253
- });
254
- child.once("close", (code, signal) => {
255
- if (done) return;
256
- done = true;
257
- cleanup.abort();
258
- clearTimeout(killTimer);
259
- clearTimeout(timeoutTimer);
260
- settled.resolve(buildRunResult(line, stdout, stderr, code, signal, timedOut, limit));
261
- });
262
- if (input !== void 0) child.stdin.write(input);
705
+ child.once("close", () => finish.abort());
706
+ if (text !== void 0) child.stdin.write(text);
263
707
  child.stdin.end();
264
708
  if (timeout > 0) timeoutTimer = setTimeout(() => {
265
- timedOut = true;
709
+ expired = true;
266
710
  terminate.abort();
267
711
  }, timeout);
268
712
  if (options?.signal !== void 0) {
269
713
  const signal = options.signal;
270
- signal.addEventListener("abort", () => terminate.abort(), {
714
+ signal.addEventListener("abort", () => {
715
+ aborted = true;
716
+ terminate.abort();
717
+ }, {
271
718
  once: true,
272
719
  signal: cleanup.signal
273
720
  });
274
- if (signal.aborted) terminate.abort();
721
+ if (signal.aborted) {
722
+ aborted = true;
723
+ terminate.abort();
724
+ }
275
725
  }
276
726
  const result = await settled.promise;
277
- if (result.failed && reject) throw (0, _src_core.createRunError)(result);
727
+ if (result.failed && strict) throw (0, _src_core.createRunError)(result, cause);
278
728
  return result;
279
729
  }
280
730
  /**
281
731
  * Runs one command to completion synchronously, buffering its output, and returns the outcome.
282
732
  *
283
733
  * @remarks
284
- * The synchronous counterpart of {@link run}. The child is spawned through the same cross-platform
285
- * command resolver ({@link requiresShell}). Standard output and error are each byte-bounded by
286
- * `limit` (default {@link PROCESS_OUTPUT}). A positive `timeout` is enforced by the host, which
287
- * ends the child with `SIGKILL` when it elapses. The environment and input follow the same merge
288
- * as {@link run}. Unless `reject` is `false`, a failed run throws a {@link createRunError} carrying
289
- * the {@link RunResult}.
734
+ * The synchronous counterpart of {@link run}, spawned through the same resolver and never through a
735
+ * shell. The host offers no cooperative termination window and no in-flight cancellation, so this
736
+ * contract carries neither. A positive `timeout` and an output overflow both end the child with
737
+ * `SIGKILL`: an overflow reports `truncated` and `failed` together and trims the partial output to
738
+ * `limit`, where {@link run} keeps reading and reports `truncated` without failing. The environment
739
+ * and input follow the same merge as {@link run}. Unless `strict` is `false`, a failed run throws a
740
+ * {@link createRunError} carrying the {@link RunResult}.
290
741
  *
291
742
  * @param command - The executable, arguments, and optional environment and input
292
- * @param options - Working directory, timeout, signal, capture limit, and reject behavior
743
+ * @param options - Working directory, timeout, capture limit, and failure delivery
293
744
  * @returns The run outcome
294
- * @throws A {@link ProcessError} carrying the {@link RunResult} when the run failed and `reject` is not `false`
745
+ * @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`
295
746
  *
296
747
  * @example
297
748
  * ```ts
298
- * const result = runSync({ file: 'git', arguments: ['--version'] }, { reject: false })
749
+ * const result = runSync({ file: 'git', arguments: ['--version'] }, { strict: false })
299
750
  * ```
300
751
  */
301
752
  function runSync(command, options) {
753
+ validateCommand(command);
754
+ validateEnvironment(options?.environment);
755
+ validateWorkspace(options?.workspace);
756
+ validateTimer(options?.timeout, "option 'timeout'");
757
+ validateBytes(options?.limit, "option 'limit'", 0);
302
758
  const limit = options?.limit ?? _src_core.PROCESS_OUTPUT;
303
759
  const timeout = options?.timeout ?? 0;
304
- const reject = options?.reject ?? true;
305
- const input = options?.input ?? command.input;
306
- const line = commandLine(command);
307
- const outcome = (0, node_child_process.spawnSync)(command.file, [...command.arguments], {
308
- cwd: options?.workspace ?? process.cwd(),
309
- env: mergeEnvironment(command.environment, options?.environment),
760
+ const strict = options?.strict ?? true;
761
+ const text = options?.input ?? command.input;
762
+ const line = formatCommand(command);
763
+ const workspace = options?.workspace ?? process.cwd();
764
+ const environment = mergeEnvironment(command.isolated === true, command.environment, options?.environment);
765
+ const plan = buildSpawn(command, {
766
+ workspace,
767
+ environment
768
+ });
769
+ const outcome = (0, node_child_process.spawnSync)(plan.file, [...plan.arguments], {
770
+ cwd: workspace,
771
+ env: environment,
310
772
  encoding: "buffer",
311
773
  maxBuffer: limit,
312
- shell: requiresShell(command.file),
774
+ killSignal: "SIGKILL",
313
775
  windowsHide: true,
314
- ...input !== void 0 ? { input } : {},
315
- ...options?.signal !== void 0 ? { signal: options.signal } : {},
316
- ...timeout > 0 ? {
317
- timeout,
318
- killSignal: "SIGKILL"
319
- } : {}
776
+ windowsVerbatimArguments: plan.verbatim,
777
+ ...text !== void 0 ? { input: text } : {},
778
+ ...timeout > 0 ? { timeout } : {}
320
779
  });
321
780
  const error = outcome.error;
322
- const timedOut = error !== void 0 && "code" in error && error.code === "ETIMEDOUT";
323
- const result = buildRunResult(line, node_buffer.Buffer.isBuffer(outcome.stdout) ? outcome.stdout : node_buffer.Buffer.alloc(0), node_buffer.Buffer.isBuffer(outcome.stderr) ? outcome.stderr : node_buffer.Buffer.alloc(0), outcome.status, outcome.signal, timedOut, limit);
324
- if (result.failed && reject) throw (0, _src_core.createRunError)(result);
781
+ const fault = error !== void 0 && "code" in error && (0, _orkestrel_contract.isString)(error.code) ? error.code : "";
782
+ const result = buildRunResult({
783
+ command: line,
784
+ stdout: node_buffer.Buffer.isBuffer(outcome.stdout) ? outcome.stdout : node_buffer.Buffer.alloc(0),
785
+ stderr: node_buffer.Buffer.isBuffer(outcome.stderr) ? outcome.stderr : node_buffer.Buffer.alloc(0),
786
+ code: outcome.status,
787
+ signal: outcome.signal,
788
+ expired: fault === "ETIMEDOUT",
789
+ aborted: false,
790
+ truncated: fault === "ENOBUFS",
791
+ limit,
792
+ ...error === void 0 ? {} : { cause: error }
793
+ });
794
+ if (result.failed && strict) throw (0, _src_core.createRunError)(result, error);
325
795
  return result;
326
796
  }
797
+ /**
798
+ * Spawns one command as a detached process and returns without waiting for it.
799
+ *
800
+ * @remarks
801
+ * The child owns no stdio and is unreferenced, so it outlives this process and nothing here observes
802
+ * its outcome. The error listener is attached before the child is unreferenced, so a post-spawn host
803
+ * fault is swallowed rather than crashing the caller; an invalid command is refused before anything
804
+ * is spawned.
805
+ *
806
+ * @param command - The executable, arguments, and optional environment
807
+ * @param options - The working directory the detached child starts in
808
+ * @returns Nothing
809
+ * @throws A {@link ProcessError} coded `invalid` when the working directory or a command string is malformed
810
+ *
811
+ * @example
812
+ * ```ts
813
+ * detach({ file: 'node', arguments: ['daemon.js'] }, { workspace: process.cwd() })
814
+ * ```
815
+ */
816
+ function detach(command, options) {
817
+ validateCommand(command);
818
+ validateWorkspace(options?.workspace);
819
+ const workspace = options?.workspace ?? process.cwd();
820
+ const environment = mergeEnvironment(command.isolated === true, command.environment);
821
+ const plan = buildSpawn(command, {
822
+ workspace,
823
+ environment
824
+ });
825
+ const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
826
+ cwd: workspace,
827
+ detached: true,
828
+ env: environment,
829
+ stdio: "ignore",
830
+ windowsHide: true,
831
+ windowsVerbatimArguments: plan.verbatim
832
+ });
833
+ child.once("error", () => void 0);
834
+ child.unref();
835
+ }
327
836
  //#endregion
328
837
  //#region src/server/Process.ts
329
838
  /**
330
- * One supervised child process with eagerly framed output and bounded termination.
839
+ * One supervised child process with framed output, a bounded backlog, and bounded termination.
331
840
  *
332
841
  * @remarks
333
- * Standard output is drained eagerly through `readline`, so `exit` resolves and a late consumer of
334
- * `lines` still receives every framed line, including a final line written without a trailing
335
- * newline. Standard error is decoded and forwarded live as the `stderr` event while a byte-bounded
336
- * raw tail is retained as `evidence`. The typed `emitter` also carries the terminal `exit`,
337
- * alongside the `exit` promise. `stop` terminates the child through `SIGTERM`, then `SIGKILL` after
338
- * `grace`, and is idempotent; `destroy` stops the child and destroys the emitter last.
842
+ * Standard output is framed through `readline` into a single-consumer stream: each line goes to
843
+ * exactly one waiting iterator, so two iterators split the output rather than each receiving all of
844
+ * it. While no consumer has ever requested an iterator,
845
+ * stdout keeps draining so `exit` still resolves, and retention stops at the `backlog` mark: a
846
+ * consumer attaching later receives the retained head, a gap, then the live stream. Once an iterator
847
+ * has been requested, stdout pauses at the mark and resumes at half of it, so that consumer loses
848
+ * nothing and the child feels real backpressure. The mark is soft — the host delivers whole chunks,
849
+ * so the backlog overshoots it by up to one delivered chunk plus the drain that termination
850
+ * releases. Standard error is decoded and forwarded live as the `stderr` event while a byte-bounded
851
+ * raw tail is retained as `evidence`. The typed `emitter` also carries the child `error` cause on a
852
+ * spawn fault and the terminal `exit`, alongside the `exit` promise. `stop` ends the whole tree and
853
+ * reports whether the native exit was observed; `destroy` stops the child, destroys stdin so every
854
+ * pending `send` settles, and destroys the emitter last. `destroy` resolving does not imply the
855
+ * child's stdio has closed, so `exit` and `lines` can still be outstanding when a descendant holds
856
+ * an inherited pipe.
339
857
  *
340
858
  * @example
341
859
  * ```ts
@@ -344,7 +862,6 @@ function runSync(command, options) {
344
862
  * const child = new Process({
345
863
  * command: { file: 'node', arguments: ['--version'] },
346
864
  * workspace: process.cwd(),
347
- * grace: 5000,
348
865
  * })
349
866
  * const exit = await child.exit
350
867
  * await child.destroy()
@@ -355,45 +872,74 @@ var Process = class {
355
872
  #child;
356
873
  #reader;
357
874
  #grace;
358
- #limit;
875
+ #evidence;
876
+ #backlog;
359
877
  #decoder = new node_string_decoder.StringDecoder("utf8");
360
878
  #exit = Promise.withResolvers();
361
879
  #lines;
362
- #buffer = [];
880
+ #queue = [];
363
881
  #waiters = [];
364
882
  #signal;
365
883
  #abort;
366
884
  #tail = node_buffer.Buffer.alloc(0);
885
+ #head = 0;
886
+ #pending = 0;
887
+ #requested = false;
888
+ #paused = false;
889
+ #terminating = false;
367
890
  #closed = false;
368
891
  #ended = false;
369
892
  #stopping;
370
893
  #ending;
371
894
  /**
372
- * Spawn one child process and begin eager stream capture.
895
+ * Spawn one child process and begin stream capture.
373
896
  *
374
- * @param options - Command, workspace, termination, evidence, stdin, and observation settings
897
+ * @param options - Command, workspace, termination, capture, stdin, and observation settings
898
+ * @throws A {@link ProcessError} coded `invalid` when an option or command string is malformed
375
899
  */
376
900
  constructor(options) {
901
+ const command = options.command;
902
+ const workspace = options.workspace;
903
+ const grace = options.grace;
904
+ const evidence = options.evidence;
905
+ const backlog = options.backlog;
906
+ const writable = options.writable;
907
+ const signal = options.signal;
377
908
  const on = options.on;
378
909
  const error = options.error;
910
+ const isolated = command.isolated;
911
+ const overrides = command.environment;
912
+ const input = command.input;
913
+ validateCommand(command);
914
+ validateWorkspace(workspace);
915
+ validateTimer(grace, "option 'grace'");
916
+ validateBytes(evidence, "option 'evidence'", 0);
917
+ validateBytes(backlog, "option 'backlog'", 1);
379
918
  this.#emitter = new _orkestrel_emitter.Emitter({
380
919
  ...on === void 0 ? {} : { on },
381
920
  ...error === void 0 ? {} : { error }
382
921
  });
383
- this.#grace = options.grace;
384
- this.#limit = options.evidence ?? _src_core.PROCESS_EVIDENCE;
385
- this.#signal = options.signal;
922
+ this.#grace = grace ?? _src_core.PROCESS_GRACE;
923
+ this.#evidence = evidence ?? _src_core.PROCESS_EVIDENCE;
924
+ this.#backlog = backlog ?? _src_core.PROCESS_BACKLOG;
925
+ this.#signal = signal;
386
926
  this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
387
- this.#child = (0, node_child_process.spawn)(options.command.file, [...options.command.arguments], {
388
- cwd: options.workspace,
927
+ const environment = mergeEnvironment(isolated === true, overrides);
928
+ const plan = buildSpawn(command, {
929
+ workspace,
930
+ environment
931
+ });
932
+ this.#child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
933
+ cwd: workspace,
389
934
  detached: process.platform !== "win32",
390
- env: mergeEnvironment(options.command.environment),
935
+ env: environment,
391
936
  stdio: [
392
937
  "pipe",
393
938
  "pipe",
394
939
  "pipe"
395
940
  ],
396
- windowsHide: true
941
+ windowsHide: true,
942
+ windowsVerbatimArguments: plan.verbatim
397
943
  });
398
944
  this.#reader = (0, node_readline.createInterface)({
399
945
  input: this.#child.stdout,
@@ -401,23 +947,23 @@ var Process = class {
401
947
  });
402
948
  this.#reader.on("line", this.#push.bind(this));
403
949
  this.#reader.once("close", this.#finish.bind(this));
404
- this.#child.once("error", () => void 0);
950
+ this.#child.once("error", (cause) => this.#emitter.emit("error", cause));
405
951
  this.#child.once("close", this.#close.bind(this));
406
952
  this.#child.stdin.on("error", () => void 0);
407
953
  this.#child.stderr.on("data", this.#retain.bind(this));
408
- if (options.command.input !== void 0) this.#child.stdin.write(options.command.input);
409
- if (options.writable !== true) this.#child.stdin.end();
410
- if (this.#signal !== void 0) {
954
+ if (input !== void 0) this.#child.stdin.write(input);
955
+ if (writable !== true) this.#child.stdin.end();
956
+ if (signal !== void 0) {
411
957
  this.#abort = this.#terminate.bind(this);
412
- this.#signal.addEventListener("abort", this.#abort, { once: true });
413
- if (this.#signal.aborted) this.stop();
958
+ signal.addEventListener("abort", this.#abort, { once: true });
959
+ if (signal.aborted) this.stop();
414
960
  }
415
961
  }
416
962
  /** The typed lifecycle observation surface. */
417
963
  get emitter() {
418
964
  return this.#emitter;
419
965
  }
420
- /** The eagerly captured stdout lines, in arrival order, ending when the child's stdout closes. */
966
+ /** The captured stdout lines, in arrival order, ending when the child's stdout closes. */
421
967
  get lines() {
422
968
  return this.#lines;
423
969
  }
@@ -432,21 +978,35 @@ var Process = class {
432
978
  /**
433
979
  * Write one line to the open standard-input channel.
434
980
  *
981
+ * @remarks
982
+ * Never rejects. The promise settles when the host reports the line handled, so a line written to
983
+ * a child that is not reading stays pending until the child drains it or the channel closes. Every
984
+ * terminal teardown path destroys stdin, so a pending write always settles by teardown; a caller
985
+ * that needs its own deadline arms a timer and calls `stop`.
986
+ *
435
987
  * @param text - The line text without its trailing newline
436
- * @returns True when the channel accepted the line without backpressure; false otherwise
988
+ * @returns True when the line reached the host without error; false when the channel was closed, destroyed, ended, or the write failed
437
989
  */
438
990
  send(text) {
439
- if (this.#closed || this.#child.stdin.destroyed || this.#child.stdin.writableEnded) return false;
991
+ const stdin = this.#child.stdin;
992
+ if (this.#closed || !stdin.writable) return Promise.resolve(false);
993
+ const settled = Promise.withResolvers();
440
994
  try {
441
- return this.#child.stdin.write(`${text}\n`);
995
+ stdin.write(`${text}\n`, (error) => {
996
+ settled.resolve(error === void 0 || error === null);
997
+ });
442
998
  } catch {
443
- return false;
999
+ return Promise.resolve(false);
444
1000
  }
1001
+ return settled.promise;
445
1002
  }
446
1003
  /**
447
1004
  * Terminate the child process tree and await its observed exit.
448
1005
  *
449
- * @returns A promise that resolves after bounded termination
1006
+ * @remarks
1007
+ * Never rejects, and every call shares one termination.
1008
+ *
1009
+ * @returns True when the child's native exit was observed; false when the confirmation deadline elapsed without it
450
1010
  */
451
1011
  stop() {
452
1012
  if (this.#stopping !== void 0) return this.#stopping;
@@ -454,7 +1014,10 @@ var Process = class {
454
1014
  return this.#stopping;
455
1015
  }
456
1016
  /**
457
- * Stop the child and destroy the observation emitter.
1017
+ * Stop the child, close its standard-input channel, and destroy the observation emitter.
1018
+ *
1019
+ * @remarks
1020
+ * Always resolves, including when termination was never confirmed.
458
1021
  *
459
1022
  * @returns The stable barrier shared by every call
460
1023
  */
@@ -464,10 +1027,11 @@ var Process = class {
464
1027
  return this.#ending;
465
1028
  }
466
1029
  #iterate() {
1030
+ this.#requested = true;
467
1031
  return Object.freeze({ next: this.#next.bind(this) });
468
1032
  }
469
1033
  #next() {
470
- const line = this.#buffer.shift();
1034
+ const line = this.#take();
471
1035
  if (line !== void 0) return Promise.resolve(Object.freeze({
472
1036
  done: false,
473
1037
  value: line
@@ -480,21 +1044,52 @@ var Process = class {
480
1044
  this.#waiters.push(waiter);
481
1045
  return waiter.promise;
482
1046
  }
1047
+ #take() {
1048
+ if (this.#head >= this.#queue.length) return void 0;
1049
+ const line = this.#queue[this.#head];
1050
+ this.#queue[this.#head] = "";
1051
+ this.#head += 1;
1052
+ if (this.#head >= this.#queue.length) {
1053
+ this.#queue.length = 0;
1054
+ this.#head = 0;
1055
+ }
1056
+ if (line === void 0) return void 0;
1057
+ this.#pending -= node_buffer.Buffer.byteLength(line) + 1;
1058
+ this.#relieve();
1059
+ return line;
1060
+ }
483
1061
  #push(line) {
484
1062
  if (this.#ended) return;
485
1063
  const waiter = this.#waiters.shift();
486
- if (waiter === void 0) {
487
- this.#buffer.push(line);
1064
+ if (waiter !== void 0) {
1065
+ waiter.resolve(Object.freeze({
1066
+ done: false,
1067
+ value: line
1068
+ }));
488
1069
  return;
489
1070
  }
490
- waiter.resolve(Object.freeze({
491
- done: false,
492
- value: line
493
- }));
1071
+ const bytes = node_buffer.Buffer.byteLength(line) + 1;
1072
+ if (!this.#requested && this.#pending + bytes > this.#backlog) return;
1073
+ this.#queue.push(line);
1074
+ this.#pending += bytes;
1075
+ this.#restrain();
1076
+ }
1077
+ #restrain() {
1078
+ if (this.#paused || this.#terminating || !this.#requested) return;
1079
+ if (this.#pending < this.#backlog) return;
1080
+ this.#paused = true;
1081
+ this.#reader.pause();
1082
+ }
1083
+ #relieve() {
1084
+ if (!this.#paused || this.#ended) return;
1085
+ if (!this.#terminating && this.#pending > this.#backlog / 2) return;
1086
+ this.#paused = false;
1087
+ this.#reader.resume();
494
1088
  }
495
1089
  #finish() {
496
1090
  if (this.#ended) return;
497
1091
  this.#ended = true;
1092
+ this.#paused = false;
498
1093
  for (const waiter of this.#waiters) waiter.resolve(Object.freeze({
499
1094
  done: true,
500
1095
  value: void 0
@@ -503,7 +1098,7 @@ var Process = class {
503
1098
  }
504
1099
  #retain(chunk) {
505
1100
  if (!node_buffer.Buffer.isBuffer(chunk) || this.#closed) return;
506
- this.#tail = trimTail(node_buffer.Buffer.concat([this.#tail, chunk]), this.#limit);
1101
+ this.#tail = trimTail(node_buffer.Buffer.concat([this.#tail, chunk]), this.#evidence);
507
1102
  const text = this.#decoder.write(chunk);
508
1103
  if (text.length > 0) this.#emitter.emit("stderr", text);
509
1104
  }
@@ -525,15 +1120,11 @@ var Process = class {
525
1120
  this.stop();
526
1121
  }
527
1122
  async #kill() {
528
- if (this.#closed) return;
529
- killProcess(this.#child, "SIGTERM");
530
- const grace = Promise.withResolvers();
531
- const timer = setTimeout(grace.resolve, this.#grace);
532
- await Promise.race([this.#exit.promise, grace.promise]);
533
- clearTimeout(timer);
534
- if (this.#closed) return;
535
- killProcess(this.#child, "SIGKILL");
536
- await this.#exit.promise;
1123
+ this.#terminating = true;
1124
+ this.#relieve();
1125
+ const confirmed = await stopChild(this.#child, this.#grace, _src_core.PROCESS_CONFIRMATION);
1126
+ this.#child.stdin.destroy();
1127
+ return confirmed;
537
1128
  }
538
1129
  async #teardown() {
539
1130
  await this.stop();
@@ -547,9 +1138,15 @@ var Process = class {
547
1138
  *
548
1139
  * @remarks
549
1140
  * A child launched under an id joins the registry and emits `launch`; when it settles it removes
550
- * itself and emits `exit`, so `count` and `processes` reflect only live children. `launch` throws
551
- * a {@link createDuplicateError} when the id is already live — a spawn fault surfaces through the
552
- * returned child's `exit`, never from `launch`. `destroy` stops every child, then destroys the
1141
+ * itself and emits `exit`, so `count` and `processes` reflect only live children. The id is reserved
1142
+ * before the child is constructed and released when construction throws, so a refused launch never
1143
+ * strands its key. Eviction follows the child's own `exit` promise, which no listener can forge, so
1144
+ * it lands one microtask after the child's public `exit` event: a listener on that event still sees
1145
+ * the child registered. `launch` throws a {@link createDuplicateError} when the id is already live
1146
+ * and a {@link createProtocolError} after `destroy` has begun, including when the caller's own option
1147
+ * getter begins that teardown mid-construction — a spawn fault surfaces through the returned child's
1148
+ * `exit`, never from `launch`. `destroy` awaits every child's own teardown, which destroys each
1149
+ * child's observation emitter and leaves every subscription on it silently inert, then destroys the
553
1150
  * registry emitter last.
554
1151
  *
555
1152
  * @example
@@ -560,7 +1157,6 @@ var Process = class {
560
1157
  * const child = manager.launch('build', {
561
1158
  * command: { file: 'node', arguments: ['build.js'] },
562
1159
  * workspace: process.cwd(),
563
- * grace: 5000,
564
1160
  * })
565
1161
  * await child.exit
566
1162
  * await manager.destroy()
@@ -569,6 +1165,8 @@ var Process = class {
569
1165
  var ProcessManager = class {
570
1166
  #emitter;
571
1167
  #children = /* @__PURE__ */ new Map();
1168
+ #ids = /* @__PURE__ */ new Set();
1169
+ #destroying = false;
572
1170
  #ending;
573
1171
  /**
574
1172
  * Construct a process registry.
@@ -611,16 +1209,31 @@ var ProcessManager = class {
611
1209
  /**
612
1210
  * Spawn and register one child under `id`.
613
1211
  *
1212
+ * @remarks
1213
+ * {@link Process} reads every option before it spawns, so a caller's own option getter runs while
1214
+ * nothing has started and a throw from one strands no process. A getter that begins `destroy`
1215
+ * without throwing is the one remaining race, and it leaves a bounded residual: the child is
1216
+ * already spawned, so the launch is refused with a {@link createProtocolError} and that child is
1217
+ * torn down asynchronously, bounded by `grace` plus the confirmation window. The `destroy` barrier
1218
+ * settled before the refusal, so awaiting it does not cover that teardown.
1219
+ *
614
1220
  * @param id - The registry key, unique among live children
615
1221
  * @param options - The child construction options
616
1222
  * @returns The launched child
617
- * @throws A {@link ProcessError} coded `duplicate` when `id` is already live
1223
+ * @throws A {@link ProcessError} coded `duplicate` when `id` is already live, `protocol` when the registry is being destroyed, or `invalid` when an option or command string is malformed
618
1224
  */
619
1225
  launch(id, options) {
620
- if (this.#children.has(id)) throw (0, _src_core.createDuplicateError)(id);
621
- const child = new Process(options);
1226
+ if (this.#destroying) throw (0, _src_core.createProtocolError)(id);
1227
+ if (this.#ids.has(id)) throw (0, _src_core.createDuplicateError)(id);
1228
+ this.#ids.add(id);
1229
+ const child = this.#construct(id, options);
1230
+ if (this.#destroying) {
1231
+ this.#ids.delete(id);
1232
+ child.destroy();
1233
+ throw (0, _src_core.createProtocolError)(id);
1234
+ }
622
1235
  this.#children.set(id, child);
623
- child.emitter.on("exit", (exit) => this.#evict(id, child, exit));
1236
+ child.exit.then((exit) => this.#evict(id, child, exit));
624
1237
  this.#emitter.emit("launch", id);
625
1238
  return child;
626
1239
  }
@@ -632,32 +1245,48 @@ var ProcessManager = class {
632
1245
  /**
633
1246
  * Stop every child, then destroy the registry emitter last.
634
1247
  *
1248
+ * @remarks
1249
+ * Always resolves, and refuses a later `launch` with a {@link createProtocolError}. Each child is
1250
+ * destroyed rather than merely stopped, so its own observation emitter is destroyed too and every
1251
+ * subscription on it goes silently inert.
1252
+ *
635
1253
  * @returns The stable barrier shared by every call
636
1254
  */
637
1255
  destroy() {
638
1256
  if (this.#ending !== void 0) return this.#ending;
1257
+ this.#destroying = true;
639
1258
  this.#ending = this.#teardown();
640
1259
  return this.#ending;
641
1260
  }
1261
+ #construct(id, options) {
1262
+ try {
1263
+ return new Process(options);
1264
+ } catch (error) {
1265
+ this.#ids.delete(id);
1266
+ throw error;
1267
+ }
1268
+ }
642
1269
  #evict(id, child, exit) {
643
1270
  if (this.#children.get(id) !== child) return;
644
1271
  this.#children.delete(id);
1272
+ this.#ids.delete(id);
645
1273
  this.#emitter.emit("exit", id, exit);
646
1274
  }
647
1275
  async #stopOne(id) {
648
1276
  const child = this.#children.get(id);
649
1277
  if (child === void 0) return false;
650
- await child.stop();
651
- return true;
1278
+ return child.stop();
652
1279
  }
653
1280
  async #stopMany(ids) {
654
1281
  return (await Promise.all(ids.map((id) => this.#stopOne(id)))).every((ok) => ok);
655
1282
  }
656
1283
  async #stopAll() {
657
- await Promise.all([...this.#children.values()].map((child) => child.stop()));
1284
+ await Promise.allSettled([...this.#children.values()].map((child) => child.stop()));
658
1285
  }
659
1286
  async #teardown() {
660
- await Promise.all([...this.#children.values()].map((child) => child.destroy()));
1287
+ await Promise.allSettled([...this.#children.values()].map((child) => child.destroy()));
1288
+ this.#children.clear();
1289
+ this.#ids.clear();
661
1290
  this.#emitter.destroy();
662
1291
  }
663
1292
  };
@@ -703,15 +1332,31 @@ function createProcessManager(options) {
703
1332
  exports.Process = Process;
704
1333
  exports.ProcessManager = ProcessManager;
705
1334
  exports.buildRunResult = buildRunResult;
706
- exports.commandLine = commandLine;
1335
+ exports.buildSpawn = buildSpawn;
707
1336
  exports.createProcess = createProcess;
708
1337
  exports.createProcessManager = createProcessManager;
1338
+ exports.detach = detach;
1339
+ exports.formatCommand = formatCommand;
1340
+ exports.isExited = isExited;
1341
+ exports.isFile = isFile;
709
1342
  exports.killProcess = killProcess;
1343
+ exports.killTree = killTree;
710
1344
  exports.mergeEnvironment = mergeEnvironment;
711
- exports.requiresShell = requiresShell;
1345
+ exports.quoteArgument = quoteArgument;
1346
+ exports.readVariable = readVariable;
1347
+ exports.resolveExecutable = resolveExecutable;
1348
+ exports.retainChunk = retainChunk;
712
1349
  exports.run = run;
713
1350
  exports.runSync = runSync;
1351
+ exports.stopChild = stopChild;
714
1352
  exports.trimHead = trimHead;
715
1353
  exports.trimTail = trimTail;
1354
+ exports.validateBytes = validateBytes;
1355
+ exports.validateCommand = validateCommand;
1356
+ exports.validateEnvironment = validateEnvironment;
1357
+ exports.validateText = validateText;
1358
+ exports.validateTimer = validateTimer;
1359
+ exports.validateWorkspace = validateWorkspace;
1360
+ exports.waitForExit = waitForExit;
716
1361
 
717
1362
  //# sourceMappingURL=index.cjs.map