@orkestrel/process 0.0.2 → 0.0.4

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