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