@orkestrel/process 0.0.1
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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/src/core/index.cjs +87 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +351 -0
- package/dist/src/core/index.d.ts +351 -0
- package/dist/src/core/index.js +80 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +717 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +379 -0
- package/dist/src/server/index.d.ts +379 -0
- package/dist/src/server/index.js +704 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +91 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
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";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import { Emitter } from "@orkestrel/emitter";
|
|
8
|
+
//#region src/server/helpers.ts
|
|
9
|
+
/**
|
|
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
|
+
* Trims a buffer to at most `limit` trailing bytes without splitting a UTF-8 sequence.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* Keeps the tail — the diagnostic end of a stderr stream. When the cut point lands inside a
|
|
41
|
+
* multibyte sequence, the start advances past the leading continuation bytes so the retained
|
|
42
|
+
* bytes always begin on a code-point boundary.
|
|
43
|
+
*
|
|
44
|
+
* @param buffer - The accumulated bytes
|
|
45
|
+
* @param limit - The maximum retained byte count
|
|
46
|
+
* @returns The trailing bytes, at most `limit` and never starting mid-sequence
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* trimTail(Buffer.from('hello'), 3) // <Buffer 6c 6c 6f>
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
function trimTail(buffer, limit) {
|
|
54
|
+
if (buffer.byteLength <= limit) return buffer;
|
|
55
|
+
let start = buffer.byteLength - limit;
|
|
56
|
+
while (start < buffer.byteLength) {
|
|
57
|
+
const byte = buffer[start];
|
|
58
|
+
if (byte === void 0 || (byte & 192) !== 128) break;
|
|
59
|
+
start += 1;
|
|
60
|
+
}
|
|
61
|
+
return Buffer.from(buffer.subarray(start));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Trims a buffer to at most `limit` leading bytes without splitting a UTF-8 sequence.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Keeps the head — the captured start of a one-shot run's output. When the cut point lands inside
|
|
68
|
+
* a multibyte sequence, the end retreats to the sequence's start so the retained bytes always end
|
|
69
|
+
* on a code-point boundary and decode without a replacement character.
|
|
70
|
+
*
|
|
71
|
+
* @param buffer - The accumulated bytes
|
|
72
|
+
* @param limit - The maximum retained byte count
|
|
73
|
+
* @returns The leading bytes, at most `limit` and never ending mid-sequence
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* trimHead(Buffer.from('hello'), 3) // <Buffer 68 65 6c>
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
function trimHead(buffer, limit) {
|
|
81
|
+
if (buffer.byteLength <= limit) return buffer;
|
|
82
|
+
let end = limit;
|
|
83
|
+
while (end > 0) {
|
|
84
|
+
const byte = buffer[end];
|
|
85
|
+
if (byte === void 0 || (byte & 192) !== 128) break;
|
|
86
|
+
end -= 1;
|
|
87
|
+
}
|
|
88
|
+
return Buffer.from(buffer.subarray(0, end));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Checks whether a command file must be spawned through a shell on the current host.
|
|
92
|
+
*
|
|
93
|
+
* @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.
|
|
99
|
+
*
|
|
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
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* requiresShell('git') // true on Windows, false on POSIX
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
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) === "";
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Renders one command into its diagnostic command line.
|
|
115
|
+
*
|
|
116
|
+
* @param command - The executable and its argument vector
|
|
117
|
+
* @returns The space-joined command line, for a {@link RunResult} and error messages
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* commandLine({ file: 'git', arguments: ['status'] }) // 'git status'
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
function commandLine(command) {
|
|
125
|
+
return [command.file, ...command.arguments].join(" ");
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Merges environment overrides over the current process environment.
|
|
129
|
+
*
|
|
130
|
+
* @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.
|
|
133
|
+
*
|
|
134
|
+
* @param base - The command's own environment overrides
|
|
135
|
+
* @param override - Per-invocation overrides applied last
|
|
136
|
+
* @returns The merged environment for a spawn
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* mergeEnvironment({ TOKEN: 'a' }, { TOKEN: undefined }) // TOKEN unset
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
function mergeEnvironment(base, override) {
|
|
144
|
+
return {
|
|
145
|
+
...process.env,
|
|
146
|
+
...base,
|
|
147
|
+
...override
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Builds one settled {@link RunResult} from a completed one-shot run's captured bytes and exit.
|
|
152
|
+
*
|
|
153
|
+
* @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
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```ts
|
|
169
|
+
* buildRunResult('git status', Buffer.from('ok'), Buffer.alloc(0), 0, null, false, 1024)
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
function buildRunResult(command, stdout, stderr, code, signal, timedOut, limit) {
|
|
173
|
+
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
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Runs one command to completion, buffering its output, and settles with the outcome.
|
|
185
|
+
*
|
|
186
|
+
* @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}.
|
|
195
|
+
*
|
|
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`
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```ts
|
|
203
|
+
* const result = await run({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
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);
|
|
213
|
+
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 killTimer;
|
|
220
|
+
let timeoutTimer;
|
|
221
|
+
let done = false;
|
|
222
|
+
const child = spawn(command.file, [...command.arguments], {
|
|
223
|
+
cwd: options?.workspace ?? process.cwd(),
|
|
224
|
+
detached: process.platform !== "win32",
|
|
225
|
+
env: mergeEnvironment(command.environment, options?.environment),
|
|
226
|
+
stdio: [
|
|
227
|
+
"pipe",
|
|
228
|
+
"pipe",
|
|
229
|
+
"pipe"
|
|
230
|
+
],
|
|
231
|
+
shell: requiresShell(command.file),
|
|
232
|
+
windowsHide: true
|
|
233
|
+
});
|
|
234
|
+
terminate.signal.addEventListener("abort", () => {
|
|
235
|
+
killProcess(child, "SIGTERM");
|
|
236
|
+
killTimer = setTimeout(() => killProcess(child, "SIGKILL"), grace);
|
|
237
|
+
}, { once: true });
|
|
238
|
+
child.stdin.on("error", () => void 0);
|
|
239
|
+
child.stdout.on("data", (chunk) => {
|
|
240
|
+
if (Buffer.isBuffer(chunk)) stdout = trimHead(Buffer.concat([stdout, chunk]), limit);
|
|
241
|
+
});
|
|
242
|
+
child.stderr.on("data", (chunk) => {
|
|
243
|
+
if (Buffer.isBuffer(chunk)) stderr = trimHead(Buffer.concat([stderr, chunk]), limit);
|
|
244
|
+
});
|
|
245
|
+
child.once("error", () => {
|
|
246
|
+
if (done) return;
|
|
247
|
+
done = true;
|
|
248
|
+
cleanup.abort();
|
|
249
|
+
clearTimeout(killTimer);
|
|
250
|
+
clearTimeout(timeoutTimer);
|
|
251
|
+
settled.resolve(buildRunResult(line, stdout, stderr, null, null, false, limit));
|
|
252
|
+
});
|
|
253
|
+
child.once("close", (code, signal) => {
|
|
254
|
+
if (done) return;
|
|
255
|
+
done = true;
|
|
256
|
+
cleanup.abort();
|
|
257
|
+
clearTimeout(killTimer);
|
|
258
|
+
clearTimeout(timeoutTimer);
|
|
259
|
+
settled.resolve(buildRunResult(line, stdout, stderr, code, signal, timedOut, limit));
|
|
260
|
+
});
|
|
261
|
+
if (input !== void 0) child.stdin.write(input);
|
|
262
|
+
child.stdin.end();
|
|
263
|
+
if (timeout > 0) timeoutTimer = setTimeout(() => {
|
|
264
|
+
timedOut = true;
|
|
265
|
+
terminate.abort();
|
|
266
|
+
}, timeout);
|
|
267
|
+
if (options?.signal !== void 0) {
|
|
268
|
+
const signal = options.signal;
|
|
269
|
+
signal.addEventListener("abort", () => terminate.abort(), {
|
|
270
|
+
once: true,
|
|
271
|
+
signal: cleanup.signal
|
|
272
|
+
});
|
|
273
|
+
if (signal.aborted) terminate.abort();
|
|
274
|
+
}
|
|
275
|
+
const result = await settled.promise;
|
|
276
|
+
if (result.failed && reject) throw createRunError(result);
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Runs one command to completion synchronously, buffering its output, and returns the outcome.
|
|
281
|
+
*
|
|
282
|
+
* @remarks
|
|
283
|
+
* The synchronous counterpart of {@link run}. The child is spawned through the same cross-platform
|
|
284
|
+
* command resolver ({@link requiresShell}). Standard output and error are each byte-bounded by
|
|
285
|
+
* `limit` (default {@link PROCESS_OUTPUT}). A positive `timeout` is enforced by the host, which
|
|
286
|
+
* ends the child with `SIGKILL` when it elapses. The environment and input follow the same merge
|
|
287
|
+
* as {@link run}. Unless `reject` is `false`, a failed run throws a {@link createRunError} carrying
|
|
288
|
+
* the {@link RunResult}.
|
|
289
|
+
*
|
|
290
|
+
* @param command - The executable, arguments, and optional environment and input
|
|
291
|
+
* @param options - Working directory, timeout, signal, capture limit, and reject behavior
|
|
292
|
+
* @returns The run outcome
|
|
293
|
+
* @throws A {@link ProcessError} carrying the {@link RunResult} when the run failed and `reject` is not `false`
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```ts
|
|
297
|
+
* const result = runSync({ file: 'git', arguments: ['--version'] }, { reject: false })
|
|
298
|
+
* ```
|
|
299
|
+
*/
|
|
300
|
+
function runSync(command, options) {
|
|
301
|
+
const limit = options?.limit ?? PROCESS_OUTPUT;
|
|
302
|
+
const timeout = options?.timeout ?? 0;
|
|
303
|
+
const reject = options?.reject ?? true;
|
|
304
|
+
const input = options?.input ?? command.input;
|
|
305
|
+
const line = commandLine(command);
|
|
306
|
+
const outcome = spawnSync(command.file, [...command.arguments], {
|
|
307
|
+
cwd: options?.workspace ?? process.cwd(),
|
|
308
|
+
env: mergeEnvironment(command.environment, options?.environment),
|
|
309
|
+
encoding: "buffer",
|
|
310
|
+
maxBuffer: limit,
|
|
311
|
+
shell: requiresShell(command.file),
|
|
312
|
+
windowsHide: true,
|
|
313
|
+
...input !== void 0 ? { input } : {},
|
|
314
|
+
...options?.signal !== void 0 ? { signal: options.signal } : {},
|
|
315
|
+
...timeout > 0 ? {
|
|
316
|
+
timeout,
|
|
317
|
+
killSignal: "SIGKILL"
|
|
318
|
+
} : {}
|
|
319
|
+
});
|
|
320
|
+
const error = outcome.error;
|
|
321
|
+
const timedOut = error !== void 0 && "code" in error && error.code === "ETIMEDOUT";
|
|
322
|
+
const result = buildRunResult(line, Buffer.isBuffer(outcome.stdout) ? outcome.stdout : Buffer.alloc(0), Buffer.isBuffer(outcome.stderr) ? outcome.stderr : Buffer.alloc(0), outcome.status, outcome.signal, timedOut, limit);
|
|
323
|
+
if (result.failed && reject) throw createRunError(result);
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/server/Process.ts
|
|
328
|
+
/**
|
|
329
|
+
* One supervised child process with eagerly framed output and bounded termination.
|
|
330
|
+
*
|
|
331
|
+
* @remarks
|
|
332
|
+
* Standard output is drained eagerly through `readline`, so `exit` resolves and a late consumer of
|
|
333
|
+
* `lines` still receives every framed line, including a final line written without a trailing
|
|
334
|
+
* newline. Standard error is decoded and forwarded live as the `stderr` event while a byte-bounded
|
|
335
|
+
* raw tail is retained as `evidence`. The typed `emitter` also carries the terminal `exit`,
|
|
336
|
+
* alongside the `exit` promise. `stop` terminates the child through `SIGTERM`, then `SIGKILL` after
|
|
337
|
+
* `grace`, and is idempotent; `destroy` stops the child and destroys the emitter last.
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```ts
|
|
341
|
+
* import { Process } from '@orkestrel/process/server'
|
|
342
|
+
*
|
|
343
|
+
* const child = new Process({
|
|
344
|
+
* command: { file: 'node', arguments: ['--version'] },
|
|
345
|
+
* workspace: process.cwd(),
|
|
346
|
+
* grace: 5000,
|
|
347
|
+
* })
|
|
348
|
+
* const exit = await child.exit
|
|
349
|
+
* await child.destroy()
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
var Process = class {
|
|
353
|
+
#emitter;
|
|
354
|
+
#child;
|
|
355
|
+
#reader;
|
|
356
|
+
#grace;
|
|
357
|
+
#limit;
|
|
358
|
+
#decoder = new StringDecoder("utf8");
|
|
359
|
+
#exit = Promise.withResolvers();
|
|
360
|
+
#lines;
|
|
361
|
+
#buffer = [];
|
|
362
|
+
#waiters = [];
|
|
363
|
+
#signal;
|
|
364
|
+
#abort;
|
|
365
|
+
#tail = Buffer.alloc(0);
|
|
366
|
+
#closed = false;
|
|
367
|
+
#ended = false;
|
|
368
|
+
#stopping;
|
|
369
|
+
#ending;
|
|
370
|
+
/**
|
|
371
|
+
* Spawn one child process and begin eager stream capture.
|
|
372
|
+
*
|
|
373
|
+
* @param options - Command, workspace, termination, evidence, stdin, and observation settings
|
|
374
|
+
*/
|
|
375
|
+
constructor(options) {
|
|
376
|
+
const on = options.on;
|
|
377
|
+
const error = options.error;
|
|
378
|
+
this.#emitter = new Emitter({
|
|
379
|
+
...on === void 0 ? {} : { on },
|
|
380
|
+
...error === void 0 ? {} : { error }
|
|
381
|
+
});
|
|
382
|
+
this.#grace = options.grace;
|
|
383
|
+
this.#limit = options.evidence ?? PROCESS_EVIDENCE;
|
|
384
|
+
this.#signal = options.signal;
|
|
385
|
+
this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
|
|
386
|
+
this.#child = spawn(options.command.file, [...options.command.arguments], {
|
|
387
|
+
cwd: options.workspace,
|
|
388
|
+
detached: process.platform !== "win32",
|
|
389
|
+
env: mergeEnvironment(options.command.environment),
|
|
390
|
+
stdio: [
|
|
391
|
+
"pipe",
|
|
392
|
+
"pipe",
|
|
393
|
+
"pipe"
|
|
394
|
+
],
|
|
395
|
+
windowsHide: true
|
|
396
|
+
});
|
|
397
|
+
this.#reader = createInterface({
|
|
398
|
+
input: this.#child.stdout,
|
|
399
|
+
crlfDelay: Infinity
|
|
400
|
+
});
|
|
401
|
+
this.#reader.on("line", this.#push.bind(this));
|
|
402
|
+
this.#reader.once("close", this.#finish.bind(this));
|
|
403
|
+
this.#child.once("error", () => void 0);
|
|
404
|
+
this.#child.once("close", this.#close.bind(this));
|
|
405
|
+
this.#child.stdin.on("error", () => void 0);
|
|
406
|
+
this.#child.stderr.on("data", this.#retain.bind(this));
|
|
407
|
+
if (options.command.input !== void 0) this.#child.stdin.write(options.command.input);
|
|
408
|
+
if (options.writable !== true) this.#child.stdin.end();
|
|
409
|
+
if (this.#signal !== void 0) {
|
|
410
|
+
this.#abort = this.#terminate.bind(this);
|
|
411
|
+
this.#signal.addEventListener("abort", this.#abort, { once: true });
|
|
412
|
+
if (this.#signal.aborted) this.stop();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** The typed lifecycle observation surface. */
|
|
416
|
+
get emitter() {
|
|
417
|
+
return this.#emitter;
|
|
418
|
+
}
|
|
419
|
+
/** The eagerly captured stdout lines, in arrival order, ending when the child's stdout closes. */
|
|
420
|
+
get lines() {
|
|
421
|
+
return this.#lines;
|
|
422
|
+
}
|
|
423
|
+
/** The decoded byte-bounded stderr tail. */
|
|
424
|
+
get evidence() {
|
|
425
|
+
return this.#tail.toString("utf8");
|
|
426
|
+
}
|
|
427
|
+
/** The terminal child state, observed once from the close event. */
|
|
428
|
+
get exit() {
|
|
429
|
+
return this.#exit.promise;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Write one line to the open standard-input channel.
|
|
433
|
+
*
|
|
434
|
+
* @param text - The line text without its trailing newline
|
|
435
|
+
* @returns True when the channel accepted the line without backpressure; false otherwise
|
|
436
|
+
*/
|
|
437
|
+
send(text) {
|
|
438
|
+
if (this.#closed || this.#child.stdin.destroyed || this.#child.stdin.writableEnded) return false;
|
|
439
|
+
try {
|
|
440
|
+
return this.#child.stdin.write(`${text}\n`);
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Terminate the child process tree and await its observed exit.
|
|
447
|
+
*
|
|
448
|
+
* @returns A promise that resolves after bounded termination
|
|
449
|
+
*/
|
|
450
|
+
stop() {
|
|
451
|
+
if (this.#stopping !== void 0) return this.#stopping;
|
|
452
|
+
this.#stopping = this.#kill();
|
|
453
|
+
return this.#stopping;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Stop the child and destroy the observation emitter.
|
|
457
|
+
*
|
|
458
|
+
* @returns The stable barrier shared by every call
|
|
459
|
+
*/
|
|
460
|
+
destroy() {
|
|
461
|
+
if (this.#ending !== void 0) return this.#ending;
|
|
462
|
+
this.#ending = this.#teardown();
|
|
463
|
+
return this.#ending;
|
|
464
|
+
}
|
|
465
|
+
#iterate() {
|
|
466
|
+
return Object.freeze({ next: this.#next.bind(this) });
|
|
467
|
+
}
|
|
468
|
+
#next() {
|
|
469
|
+
const line = this.#buffer.shift();
|
|
470
|
+
if (line !== void 0) return Promise.resolve(Object.freeze({
|
|
471
|
+
done: false,
|
|
472
|
+
value: line
|
|
473
|
+
}));
|
|
474
|
+
if (this.#ended) return Promise.resolve(Object.freeze({
|
|
475
|
+
done: true,
|
|
476
|
+
value: void 0
|
|
477
|
+
}));
|
|
478
|
+
const waiter = Promise.withResolvers();
|
|
479
|
+
this.#waiters.push(waiter);
|
|
480
|
+
return waiter.promise;
|
|
481
|
+
}
|
|
482
|
+
#push(line) {
|
|
483
|
+
if (this.#ended) return;
|
|
484
|
+
const waiter = this.#waiters.shift();
|
|
485
|
+
if (waiter === void 0) {
|
|
486
|
+
this.#buffer.push(line);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
waiter.resolve(Object.freeze({
|
|
490
|
+
done: false,
|
|
491
|
+
value: line
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
#finish() {
|
|
495
|
+
if (this.#ended) return;
|
|
496
|
+
this.#ended = true;
|
|
497
|
+
for (const waiter of this.#waiters) waiter.resolve(Object.freeze({
|
|
498
|
+
done: true,
|
|
499
|
+
value: void 0
|
|
500
|
+
}));
|
|
501
|
+
this.#waiters.length = 0;
|
|
502
|
+
}
|
|
503
|
+
#retain(chunk) {
|
|
504
|
+
if (!Buffer.isBuffer(chunk) || this.#closed) return;
|
|
505
|
+
this.#tail = trimTail(Buffer.concat([this.#tail, chunk]), this.#limit);
|
|
506
|
+
const text = this.#decoder.write(chunk);
|
|
507
|
+
if (text.length > 0) this.#emitter.emit("stderr", text);
|
|
508
|
+
}
|
|
509
|
+
#close(code, signal) {
|
|
510
|
+
if (this.#closed) return;
|
|
511
|
+
const suffix = this.#decoder.end();
|
|
512
|
+
if (suffix.length > 0) this.#emitter.emit("stderr", suffix);
|
|
513
|
+
this.#closed = true;
|
|
514
|
+
if (this.#signal !== void 0 && this.#abort !== void 0) this.#signal.removeEventListener("abort", this.#abort);
|
|
515
|
+
this.#reader.close();
|
|
516
|
+
const exit = Object.freeze({
|
|
517
|
+
code,
|
|
518
|
+
signal
|
|
519
|
+
});
|
|
520
|
+
this.#exit.resolve(exit);
|
|
521
|
+
this.#emitter.emit("exit", exit);
|
|
522
|
+
}
|
|
523
|
+
#terminate() {
|
|
524
|
+
this.stop();
|
|
525
|
+
}
|
|
526
|
+
async #kill() {
|
|
527
|
+
if (this.#closed) return;
|
|
528
|
+
killProcess(this.#child, "SIGTERM");
|
|
529
|
+
const grace = Promise.withResolvers();
|
|
530
|
+
const timer = setTimeout(grace.resolve, this.#grace);
|
|
531
|
+
await Promise.race([this.#exit.promise, grace.promise]);
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
if (this.#closed) return;
|
|
534
|
+
killProcess(this.#child, "SIGKILL");
|
|
535
|
+
await this.#exit.promise;
|
|
536
|
+
}
|
|
537
|
+
async #teardown() {
|
|
538
|
+
await this.stop();
|
|
539
|
+
this.#emitter.destroy();
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/server/ProcessManager.ts
|
|
544
|
+
/**
|
|
545
|
+
* A keyed registry of live supervised child processes.
|
|
546
|
+
*
|
|
547
|
+
* @remarks
|
|
548
|
+
* A child launched under an id joins the registry and emits `launch`; when it settles it removes
|
|
549
|
+
* itself and emits `exit`, so `count` and `processes` reflect only live children. `launch` throws
|
|
550
|
+
* a {@link createDuplicateError} when the id is already live — a spawn fault surfaces through the
|
|
551
|
+
* returned child's `exit`, never from `launch`. `destroy` stops every child, then destroys the
|
|
552
|
+
* registry emitter last.
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* ```ts
|
|
556
|
+
* import { ProcessManager } from '@orkestrel/process/server'
|
|
557
|
+
*
|
|
558
|
+
* const manager = new ProcessManager()
|
|
559
|
+
* const child = manager.launch('build', {
|
|
560
|
+
* command: { file: 'node', arguments: ['build.js'] },
|
|
561
|
+
* workspace: process.cwd(),
|
|
562
|
+
* grace: 5000,
|
|
563
|
+
* })
|
|
564
|
+
* await child.exit
|
|
565
|
+
* await manager.destroy()
|
|
566
|
+
* ```
|
|
567
|
+
*/
|
|
568
|
+
var ProcessManager = class {
|
|
569
|
+
#emitter;
|
|
570
|
+
#children = /* @__PURE__ */ new Map();
|
|
571
|
+
#ending;
|
|
572
|
+
/**
|
|
573
|
+
* Construct a process registry.
|
|
574
|
+
*
|
|
575
|
+
* @param options - Initial fleet-level observation hooks and listener-error handling
|
|
576
|
+
*/
|
|
577
|
+
constructor(options) {
|
|
578
|
+
const on = options?.on;
|
|
579
|
+
const error = options?.error;
|
|
580
|
+
this.#emitter = new Emitter({
|
|
581
|
+
...on === void 0 ? {} : { on },
|
|
582
|
+
...error === void 0 ? {} : { error }
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
/** The typed fleet-level observation surface. */
|
|
586
|
+
get emitter() {
|
|
587
|
+
return this.#emitter;
|
|
588
|
+
}
|
|
589
|
+
/** The number of live children. */
|
|
590
|
+
get count() {
|
|
591
|
+
return this.#children.size;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The live child under `id`, or `undefined` when none is.
|
|
595
|
+
*
|
|
596
|
+
* @param id - The registry key
|
|
597
|
+
* @returns The child, or `undefined`
|
|
598
|
+
*/
|
|
599
|
+
process(id) {
|
|
600
|
+
return this.#children.get(id);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* A snapshot of every live child.
|
|
604
|
+
*
|
|
605
|
+
* @returns The live children in launch order
|
|
606
|
+
*/
|
|
607
|
+
processes() {
|
|
608
|
+
return [...this.#children.values()];
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Spawn and register one child under `id`.
|
|
612
|
+
*
|
|
613
|
+
* @param id - The registry key, unique among live children
|
|
614
|
+
* @param options - The child construction options
|
|
615
|
+
* @returns The launched child
|
|
616
|
+
* @throws A {@link ProcessError} coded `duplicate` when `id` is already live
|
|
617
|
+
*/
|
|
618
|
+
launch(id, options) {
|
|
619
|
+
if (this.#children.has(id)) throw createDuplicateError(id);
|
|
620
|
+
const child = new Process(options);
|
|
621
|
+
this.#children.set(id, child);
|
|
622
|
+
child.emitter.on("exit", (exit) => this.#evict(id, child, exit));
|
|
623
|
+
this.#emitter.emit("launch", id);
|
|
624
|
+
return child;
|
|
625
|
+
}
|
|
626
|
+
stop(target) {
|
|
627
|
+
if (target === void 0) return this.#stopAll();
|
|
628
|
+
if (typeof target === "string") return this.#stopOne(target);
|
|
629
|
+
return this.#stopMany(target);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Stop every child, then destroy the registry emitter last.
|
|
633
|
+
*
|
|
634
|
+
* @returns The stable barrier shared by every call
|
|
635
|
+
*/
|
|
636
|
+
destroy() {
|
|
637
|
+
if (this.#ending !== void 0) return this.#ending;
|
|
638
|
+
this.#ending = this.#teardown();
|
|
639
|
+
return this.#ending;
|
|
640
|
+
}
|
|
641
|
+
#evict(id, child, exit) {
|
|
642
|
+
if (this.#children.get(id) !== child) return;
|
|
643
|
+
this.#children.delete(id);
|
|
644
|
+
this.#emitter.emit("exit", id, exit);
|
|
645
|
+
}
|
|
646
|
+
async #stopOne(id) {
|
|
647
|
+
const child = this.#children.get(id);
|
|
648
|
+
if (child === void 0) return false;
|
|
649
|
+
await child.stop();
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
async #stopMany(ids) {
|
|
653
|
+
return (await Promise.all(ids.map((id) => this.#stopOne(id)))).every((ok) => ok);
|
|
654
|
+
}
|
|
655
|
+
async #stopAll() {
|
|
656
|
+
await Promise.all([...this.#children.values()].map((child) => child.stop()));
|
|
657
|
+
}
|
|
658
|
+
async #teardown() {
|
|
659
|
+
await Promise.all([...this.#children.values()].map((child) => child.destroy()));
|
|
660
|
+
this.#emitter.destroy();
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/server/factories.ts
|
|
665
|
+
/**
|
|
666
|
+
* Creates one supervised child process.
|
|
667
|
+
*
|
|
668
|
+
* @param options - Command, workspace, termination, evidence, stdin, and observation settings
|
|
669
|
+
* @returns The launched {@link ProcessInterface}
|
|
670
|
+
*
|
|
671
|
+
* @example
|
|
672
|
+
* ```ts
|
|
673
|
+
* import { createProcess } from '@orkestrel/process/server'
|
|
674
|
+
*
|
|
675
|
+
* const child = createProcess({
|
|
676
|
+
* command: { file: 'node', arguments: ['--version'] },
|
|
677
|
+
* workspace: process.cwd(),
|
|
678
|
+
* grace: 5000,
|
|
679
|
+
* })
|
|
680
|
+
* ```
|
|
681
|
+
*/
|
|
682
|
+
function createProcess(options) {
|
|
683
|
+
return new Process(options);
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Creates one keyed registry of supervised child processes.
|
|
687
|
+
*
|
|
688
|
+
* @param options - Initial fleet-level observation hooks and listener-error handling
|
|
689
|
+
* @returns The {@link ProcessManagerInterface} registry
|
|
690
|
+
*
|
|
691
|
+
* @example
|
|
692
|
+
* ```ts
|
|
693
|
+
* import { createProcessManager } from '@orkestrel/process/server'
|
|
694
|
+
*
|
|
695
|
+
* const manager = createProcessManager()
|
|
696
|
+
* ```
|
|
697
|
+
*/
|
|
698
|
+
function createProcessManager(options) {
|
|
699
|
+
return new ProcessManager(options);
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
export { Process, ProcessManager, buildRunResult, commandLine, createProcess, createProcessManager, killProcess, mergeEnvironment, requiresShell, run, runSync, trimHead, trimTail };
|
|
703
|
+
|
|
704
|
+
//# sourceMappingURL=index.js.map
|