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