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