@procedures/shell 0.0.0-stage → 0.1.0
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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +421 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/command/index.d.ts +18 -0
- package/dist/command/index.d.ts.map +1 -0
- package/dist/command/index.js +17 -0
- package/dist/command/index.js.map +1 -0
- package/dist/command/output.d.ts +22 -0
- package/dist/command/output.d.ts.map +1 -0
- package/dist/command/output.js +67 -0
- package/dist/command/output.js.map +1 -0
- package/dist/command/processes.d.ts +89 -0
- package/dist/command/processes.d.ts.map +1 -0
- package/dist/command/processes.js +223 -0
- package/dist/command/processes.js.map +1 -0
- package/dist/command/run.d.ts +48 -0
- package/dist/command/run.d.ts.map +1 -0
- package/dist/command/run.js +89 -0
- package/dist/command/run.js.map +1 -0
- package/dist/command/spawn.d.ts +26 -0
- package/dist/command/spawn.d.ts.map +1 -0
- package/dist/command/spawn.js +28 -0
- package/dist/command/spawn.js.map +1 -0
- package/dist/command/stream.d.ts +42 -0
- package/dist/command/stream.d.ts.map +1 -0
- package/dist/command/stream.js +158 -0
- package/dist/command/stream.js.map +1 -0
- package/dist/command/tree-kill.d.ts +26 -0
- package/dist/command/tree-kill.d.ts.map +1 -0
- package/dist/command/tree-kill.js +122 -0
- package/dist/command/tree-kill.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +13 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +79 -0
- package/dist/module.js.map +1 -0
- package/dist/procedures/shell/exec.d.ts +21 -0
- package/dist/procedures/shell/exec.d.ts.map +1 -0
- package/dist/procedures/shell/exec.js +45 -0
- package/dist/procedures/shell/exec.js.map +1 -0
- package/dist/procedures/shell/index.d.ts +5 -0
- package/dist/procedures/shell/index.d.ts.map +1 -0
- package/dist/procedures/shell/index.js +5 -0
- package/dist/procedures/shell/index.js.map +1 -0
- package/dist/procedures/shell/run.d.ts +14 -0
- package/dist/procedures/shell/run.d.ts.map +1 -0
- package/dist/procedures/shell/run.js +40 -0
- package/dist/procedures/shell/run.js.map +1 -0
- package/dist/procedures/shell/stream.d.ts +20 -0
- package/dist/procedures/shell/stream.d.ts.map +1 -0
- package/dist/procedures/shell/stream.js +39 -0
- package/dist/procedures/shell/stream.js.map +1 -0
- package/dist/procedures/shell/which.d.ts +10 -0
- package/dist/procedures/shell/which.d.ts.map +1 -0
- package/dist/procedures/shell/which.js +21 -0
- package/dist/procedures/shell/which.js.map +1 -0
- package/dist/register.d.ts +8 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +14 -0
- package/dist/register.js.map +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +89 -0
- package/dist/types.js.map +1 -0
- package/package.json +78 -3
- package/src/command/index.ts +24 -0
- package/src/command/output.ts +67 -0
- package/src/command/processes.ts +271 -0
- package/src/command/run.ts +127 -0
- package/src/command/spawn.ts +44 -0
- package/src/command/stream.ts +186 -0
- package/src/command/tree-kill.ts +121 -0
- package/src/index.ts +53 -0
- package/src/module.ts +102 -0
- package/src/procedures/shell/exec.ts +55 -0
- package/src/procedures/shell/index.ts +4 -0
- package/src/procedures/shell/run.ts +47 -0
- package/src/procedures/shell/stream.ts +43 -0
- package/src/procedures/shell/which.ts +26 -0
- package/src/register.ts +17 -0
- package/src/types.ts +172 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry of long-running processes (deep dive WRP-4, WRP-10).
|
|
3
|
+
*
|
|
4
|
+
* Some procedures start a process and return while it runs: `vitest.watch`, `node.spawn`,
|
|
5
|
+
* `vite.dev`. Before, they started it detached and forgot it, so nothing could stop it, and it
|
|
6
|
+
* kept running after the host ended. The registry gives each process:
|
|
7
|
+
* - a record (id, pid, status, exit code), which `list` and `get` give;
|
|
8
|
+
* - a `stop` that kills the whole process tree;
|
|
9
|
+
* - the end of its output (the last 64 KiB), which also keeps its pipes from filling;
|
|
10
|
+
* - an end with the host: the host does not wait for the process, and kills it when it ends.
|
|
11
|
+
*
|
|
12
|
+
* The registry keeps the records of ended processes, up to `keepExited` of them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChildProcess } from "node:child_process";
|
|
16
|
+
import { killTree } from "./tree-kill.js";
|
|
17
|
+
import { OutputCollector } from "./output.js";
|
|
18
|
+
import { startChild, startError, type SpawnOptions } from "./spawn.js";
|
|
19
|
+
|
|
20
|
+
export interface ManagedProcessInfo {
|
|
21
|
+
id: string;
|
|
22
|
+
pid: number;
|
|
23
|
+
command: string;
|
|
24
|
+
args: string[];
|
|
25
|
+
cwd?: string;
|
|
26
|
+
/** The kind of process, set by the procedure that started it (for example "vitest"). */
|
|
27
|
+
group?: string;
|
|
28
|
+
/** A name for people. */
|
|
29
|
+
label?: string;
|
|
30
|
+
status: "running" | "exited" | "error";
|
|
31
|
+
startedAt: string;
|
|
32
|
+
exitedAt?: string;
|
|
33
|
+
exitCode?: number;
|
|
34
|
+
signal?: string;
|
|
35
|
+
/** The error that kept the program from starting. */
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StartProcessOptions extends SpawnOptions {
|
|
40
|
+
group?: string | undefined;
|
|
41
|
+
label?: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StopResult {
|
|
45
|
+
/** True when this call ended a running process. */
|
|
46
|
+
stopped: boolean;
|
|
47
|
+
exitCode?: number;
|
|
48
|
+
signal?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The end of the output that the registry keeps for each process. */
|
|
52
|
+
const OUTPUT_TAIL_BYTES = 64 * 1024;
|
|
53
|
+
/** How long `stop` waits after the first kill, before it kills again with SIGKILL. */
|
|
54
|
+
const STOP_GRACE_MS = 5000;
|
|
55
|
+
|
|
56
|
+
// The CSI and OSC escape sequences of terminals (colors, styles, links)
|
|
57
|
+
const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
58
|
+
|
|
59
|
+
/** This function removes the ANSI escape sequences from a text. */
|
|
60
|
+
export function stripAnsi(text: string): string {
|
|
61
|
+
return text.replace(ANSI, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Entry {
|
|
65
|
+
info: ManagedProcessInfo;
|
|
66
|
+
child: ChildProcess;
|
|
67
|
+
output: OutputCollector;
|
|
68
|
+
/** Called after each output chunk and at the end. */
|
|
69
|
+
watchers: Set<() => void>;
|
|
70
|
+
done: Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class ProcessRegistry {
|
|
74
|
+
private readonly entries = new Map<string, Entry>();
|
|
75
|
+
private readonly keepExited: number;
|
|
76
|
+
private counter = 0;
|
|
77
|
+
|
|
78
|
+
constructor(options: { keepExited?: number } = {}) {
|
|
79
|
+
this.keepExited = options.keepExited ?? 50;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** This function starts a program and records it. The program inherits no stdin. */
|
|
83
|
+
start(command: string, options: StartProcessOptions = {}): ManagedProcessInfo {
|
|
84
|
+
const child = startChild(command, options, "ignore");
|
|
85
|
+
const id = `${options.group ?? "process"}-${Date.now().toString(36)}-${++this.counter}`;
|
|
86
|
+
const info: ManagedProcessInfo = {
|
|
87
|
+
id,
|
|
88
|
+
pid: child.pid ?? 0,
|
|
89
|
+
command,
|
|
90
|
+
args: [...(options.args ?? [])],
|
|
91
|
+
status: "running",
|
|
92
|
+
startedAt: new Date().toISOString(),
|
|
93
|
+
};
|
|
94
|
+
if (options.cwd !== undefined) info.cwd = options.cwd;
|
|
95
|
+
if (options.group !== undefined) info.group = options.group;
|
|
96
|
+
if (options.label !== undefined) info.label = options.label;
|
|
97
|
+
|
|
98
|
+
const output = new OutputCollector(OUTPUT_TAIL_BYTES, "tail");
|
|
99
|
+
const watchers = new Set<() => void>();
|
|
100
|
+
const changed = (): void => {
|
|
101
|
+
for (const watcher of [...watchers]) watcher();
|
|
102
|
+
};
|
|
103
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
104
|
+
output.add(chunk);
|
|
105
|
+
changed();
|
|
106
|
+
});
|
|
107
|
+
child.stderr!.on("data", (chunk: Buffer) => {
|
|
108
|
+
output.add(chunk);
|
|
109
|
+
changed();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const done = new Promise<void>((resolve) => {
|
|
113
|
+
let finished = false;
|
|
114
|
+
const finish = (): void => {
|
|
115
|
+
if (finished) return;
|
|
116
|
+
finished = true;
|
|
117
|
+
info.exitedAt = new Date().toISOString();
|
|
118
|
+
resolve();
|
|
119
|
+
changed();
|
|
120
|
+
this.reap();
|
|
121
|
+
};
|
|
122
|
+
child.on("error", (error) => {
|
|
123
|
+
info.error = startError(error);
|
|
124
|
+
if (child.pid === undefined) {
|
|
125
|
+
info.status = "error";
|
|
126
|
+
finish();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.on("close", (code, signal) => {
|
|
130
|
+
info.status = info.error ? "error" : "exited";
|
|
131
|
+
if (code !== null) info.exitCode = code;
|
|
132
|
+
if (signal) info.signal = signal;
|
|
133
|
+
finish();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// The host does not wait for the process: it ends when the host ends (see tree-kill.ts)
|
|
138
|
+
child.unref();
|
|
139
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
140
|
+
(stream as unknown as { unref?: () => void } | null)?.unref?.();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.entries.set(id, { info, child, output, watchers, done });
|
|
144
|
+
this.reap();
|
|
145
|
+
return { ...info };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** This function gives a copy of the record of a process. */
|
|
149
|
+
get(id: string): ManagedProcessInfo | undefined {
|
|
150
|
+
const entry = this.entries.get(id);
|
|
151
|
+
return entry ? { ...entry.info } : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** This function gives copies of the records, oldest first. */
|
|
155
|
+
list(filter: { group?: string | undefined } = {}): ManagedProcessInfo[] {
|
|
156
|
+
const result: ManagedProcessInfo[] = [];
|
|
157
|
+
for (const entry of this.entries.values()) {
|
|
158
|
+
if (filter.group !== undefined && entry.info.group !== filter.group) continue;
|
|
159
|
+
result.push({ ...entry.info });
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** This function gives the end of the output of a process (stdout and stderr together). */
|
|
165
|
+
output(id: string): string {
|
|
166
|
+
return this.entries.get(id)?.output.text() ?? "";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** This function waits until the process ends, then gives its record. */
|
|
170
|
+
async exited(id: string): Promise<ManagedProcessInfo | undefined> {
|
|
171
|
+
const entry = this.entries.get(id);
|
|
172
|
+
if (!entry) return undefined;
|
|
173
|
+
await entry.done;
|
|
174
|
+
return { ...entry.info };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* This function waits until the output of the process matches `pattern`, and gives the match.
|
|
179
|
+
* The pattern sees the output without ANSI escape codes: a program can color its output when
|
|
180
|
+
* the environment has `FORCE_COLOR`. It rejects when the process ends first, or after
|
|
181
|
+
* `timeout` milliseconds.
|
|
182
|
+
*/
|
|
183
|
+
waitFor(id: string, pattern: RegExp, timeout = 30_000): Promise<RegExpMatchArray> {
|
|
184
|
+
const entry = this.entries.get(id);
|
|
185
|
+
if (!entry) return Promise.reject(new Error(`No process ${id}`));
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const check = (): void => {
|
|
188
|
+
const match = stripAnsi(entry.output.text()).match(pattern);
|
|
189
|
+
if (match) {
|
|
190
|
+
stop();
|
|
191
|
+
resolve(match);
|
|
192
|
+
} else if (entry.info.status !== "running" || entry.info.exitedAt) {
|
|
193
|
+
stop();
|
|
194
|
+
const code = entry.info.exitCode ?? entry.info.signal ?? entry.info.error;
|
|
195
|
+
reject(new Error(`Process ${id} exited (${code}) before its output matched ${pattern}`));
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const timer = setTimeout(() => {
|
|
199
|
+
stop();
|
|
200
|
+
reject(new Error(`Timeout after ${timeout} ms: the output of process ${id} did not match ${pattern}`));
|
|
201
|
+
}, timeout);
|
|
202
|
+
const stop = (): void => {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
entry.watchers.delete(check);
|
|
205
|
+
};
|
|
206
|
+
entry.watchers.add(check);
|
|
207
|
+
check();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* This function kills the process tree and waits for the end. When the process does not end
|
|
213
|
+
* within the grace time, it kills again with SIGKILL.
|
|
214
|
+
*/
|
|
215
|
+
async stop(id: string, signal: NodeJS.Signals = "SIGTERM"): Promise<StopResult> {
|
|
216
|
+
const entry = this.entries.get(id);
|
|
217
|
+
if (!entry || entry.info.exitedAt) {
|
|
218
|
+
return entry ? this.stopResult(entry, false) : { stopped: false };
|
|
219
|
+
}
|
|
220
|
+
killTree(entry.child, signal);
|
|
221
|
+
if (!(await this.endsWithin(entry, STOP_GRACE_MS))) {
|
|
222
|
+
killTree(entry.child, "SIGKILL");
|
|
223
|
+
await this.endsWithin(entry, STOP_GRACE_MS);
|
|
224
|
+
}
|
|
225
|
+
return this.stopResult(entry, true);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** This function stops every running process of the registry (or of one group). */
|
|
229
|
+
async stopAll(filter: { group?: string | undefined } = {}): Promise<number> {
|
|
230
|
+
const running = this.list(filter).filter((info) => info.status === "running");
|
|
231
|
+
await Promise.all(running.map((info) => this.stop(info.id)));
|
|
232
|
+
return running.length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** This function removes the record of an ended process. It keeps the record of a running one. */
|
|
236
|
+
forget(id: string): boolean {
|
|
237
|
+
const entry = this.entries.get(id);
|
|
238
|
+
if (!entry || !entry.info.exitedAt) return false;
|
|
239
|
+
return this.entries.delete(id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** This function removes the oldest records of ended processes past `keepExited`. */
|
|
243
|
+
reap(): number {
|
|
244
|
+
const ended = [...this.entries.values()].filter((entry) => entry.info.exitedAt);
|
|
245
|
+
const excess = ended.length - this.keepExited;
|
|
246
|
+
for (let i = 0; i < excess; i++) this.entries.delete(ended[i]!.info.id);
|
|
247
|
+
return Math.max(0, excess);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private stopResult(entry: Entry, stopped: boolean): StopResult {
|
|
251
|
+
const result: StopResult = { stopped };
|
|
252
|
+
if (entry.info.exitCode !== undefined) result.exitCode = entry.info.exitCode;
|
|
253
|
+
if (entry.info.signal !== undefined) result.signal = entry.info.signal;
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private async endsWithin(entry: Entry, ms: number): Promise<boolean> {
|
|
258
|
+
let timer: NodeJS.Timeout | undefined;
|
|
259
|
+
const ended = await Promise.race([
|
|
260
|
+
entry.done.then(() => true),
|
|
261
|
+
new Promise<boolean>((resolve) => {
|
|
262
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
263
|
+
}),
|
|
264
|
+
]);
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
return ended;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The registry of the host process. The procedures of the wrapper packages use it. */
|
|
271
|
+
export const processes: ProcessRegistry = new ProcessRegistry();
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runCommand: run a program to its end and give its output (roadmap 2.2).
|
|
3
|
+
*
|
|
4
|
+
* This is the one way the wrapper packages run a program. Each wrapper had its own way: shell
|
|
5
|
+
* strings (`docker`, `pnpm`), synchronous calls that blocked the event loop and ignored the
|
|
6
|
+
* signal (`git`, `cue`), and `spawn` calls that killed only the direct child.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { killTree } from "./tree-kill.js";
|
|
10
|
+
import { DEFAULT_MAX_OUTPUT_BYTES, OutputCollector } from "./output.js";
|
|
11
|
+
import { startChild, startError, type SpawnOptions } from "./spawn.js";
|
|
12
|
+
|
|
13
|
+
export interface CommandOptions extends SpawnOptions {
|
|
14
|
+
/** Text or bytes to write to stdin. Without it, stdin is empty (the program reads its end at once). */
|
|
15
|
+
input?: string | Buffer | undefined;
|
|
16
|
+
/** After this many milliseconds, the process tree is killed. */
|
|
17
|
+
timeout?: number | undefined;
|
|
18
|
+
/** When the signal aborts, the process tree is killed. An aborted signal starts nothing. */
|
|
19
|
+
signal?: AbortSignal | undefined;
|
|
20
|
+
/** The most bytes kept of each output stream (default: 64 MiB). */
|
|
21
|
+
maxOutputBytes?: number | undefined;
|
|
22
|
+
/** Past the limit, keep the first bytes ("head", the default) or the last bytes ("tail"). */
|
|
23
|
+
keep?: "head" | "tail" | undefined;
|
|
24
|
+
/** The encoding of the output (default: utf8). */
|
|
25
|
+
encoding?: BufferEncoding | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CommandResult {
|
|
29
|
+
/** The exit code. It is 1 when the process ended on a signal or did not start. */
|
|
30
|
+
exitCode: number;
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
/** True when the exit code is 0. */
|
|
34
|
+
success: boolean;
|
|
35
|
+
/** The time from the start to the end, in milliseconds. */
|
|
36
|
+
duration: number;
|
|
37
|
+
/** The signal that ended the process. When the helper killed the tree, it is "SIGTERM". */
|
|
38
|
+
signal?: string;
|
|
39
|
+
/** True when the timeout killed the process. */
|
|
40
|
+
timedOut?: boolean;
|
|
41
|
+
/** True when the signal aborted the command. */
|
|
42
|
+
aborted?: boolean;
|
|
43
|
+
/** Present when an output stream was longer than `maxOutputBytes`: which one lost bytes. */
|
|
44
|
+
truncated?: { stdout: boolean; stderr: boolean };
|
|
45
|
+
/** The error that kept the program from starting, for example `spawn x ENOENT`. */
|
|
46
|
+
error?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** This function runs `command` with `options.args`. It does not throw: a failure is in the result. */
|
|
50
|
+
export function runCommand(command: string, options: CommandOptions = {}): Promise<CommandResult> {
|
|
51
|
+
const started = Date.now();
|
|
52
|
+
if (options.signal?.aborted) {
|
|
53
|
+
return Promise.resolve({ exitCode: 1, stdout: "", stderr: "", success: false, duration: 0, aborted: true });
|
|
54
|
+
}
|
|
55
|
+
const encoding = options.encoding ?? "utf8";
|
|
56
|
+
const limit = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
57
|
+
const stdout = new OutputCollector(limit, options.keep);
|
|
58
|
+
const stderr = new OutputCollector(limit, options.keep);
|
|
59
|
+
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const child = startChild(command, options, options.input !== undefined ? "pipe" : "ignore");
|
|
62
|
+
let timedOut = false;
|
|
63
|
+
let aborted = false;
|
|
64
|
+
let settled = false;
|
|
65
|
+
|
|
66
|
+
const timer = options.timeout !== undefined
|
|
67
|
+
? setTimeout(() => {
|
|
68
|
+
timedOut = true;
|
|
69
|
+
killTree(child);
|
|
70
|
+
}, options.timeout)
|
|
71
|
+
: undefined;
|
|
72
|
+
const onAbort = (): void => {
|
|
73
|
+
aborted = true;
|
|
74
|
+
killTree(child);
|
|
75
|
+
};
|
|
76
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
|
|
78
|
+
const finish = (result: CommandResult): void => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
if (timer) clearTimeout(timer);
|
|
82
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
83
|
+
resolve(result);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
child.stdout!.on("data", (chunk: Buffer) => stdout.add(chunk));
|
|
87
|
+
child.stderr!.on("data", (chunk: Buffer) => stderr.add(chunk));
|
|
88
|
+
if (options.input !== undefined) {
|
|
89
|
+
// A program that ends before it reads its input closes the pipe: that is not an error here
|
|
90
|
+
child.stdin!.on("error", () => {});
|
|
91
|
+
child.stdin!.end(options.input);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
child.on("error", (error) => {
|
|
95
|
+
// The program did not start: no "close" follows
|
|
96
|
+
if (child.pid === undefined) {
|
|
97
|
+
finish({
|
|
98
|
+
exitCode: 1,
|
|
99
|
+
stdout: "",
|
|
100
|
+
stderr: startError(error),
|
|
101
|
+
success: false,
|
|
102
|
+
duration: Date.now() - started,
|
|
103
|
+
error: startError(error),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
child.on("close", (code, signal) => {
|
|
109
|
+
const result: CommandResult = {
|
|
110
|
+
exitCode: code ?? 1,
|
|
111
|
+
stdout: stdout.text(encoding),
|
|
112
|
+
stderr: stderr.text(encoding),
|
|
113
|
+
success: code === 0 && !timedOut && !aborted,
|
|
114
|
+
duration: Date.now() - started,
|
|
115
|
+
};
|
|
116
|
+
// On Windows, taskkill ends the process with an exit code and no signal
|
|
117
|
+
const endSignal = signal ?? (timedOut || aborted ? "SIGTERM" : undefined);
|
|
118
|
+
if (endSignal) result.signal = endSignal;
|
|
119
|
+
if (timedOut) result.timedOut = true;
|
|
120
|
+
if (aborted) result.aborted = true;
|
|
121
|
+
if (stdout.truncated || stderr.truncated) {
|
|
122
|
+
result.truncated = { stdout: stdout.truncated, stderr: stderr.truncated };
|
|
123
|
+
}
|
|
124
|
+
finish(result);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a child process the one way the helpers allow: an argument list, no shell, no window.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
|
+
import { OWN_PROCESS_GROUP, track } from "./tree-kill.js";
|
|
7
|
+
|
|
8
|
+
export interface SpawnOptions {
|
|
9
|
+
/** The arguments. Each one goes to the program as it is: no shell reads it. */
|
|
10
|
+
args?: readonly string[] | undefined;
|
|
11
|
+
/** The working directory (default: the current directory of the host). */
|
|
12
|
+
cwd?: string | undefined;
|
|
13
|
+
/** Environment variables to add to the environment of the host. */
|
|
14
|
+
env?: Record<string, string | undefined> | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* On Windows, pass the arguments without quotes. Only `shell.exec` sets it, for the command
|
|
17
|
+
* line of `cmd.exe`.
|
|
18
|
+
*/
|
|
19
|
+
windowsVerbatimArguments?: boolean | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* This function starts `command` with `shell: false` and `windowsHide`. On POSIX the child gets
|
|
24
|
+
* its own process group, so `killTree` reaches its descendants. The child is recorded until it
|
|
25
|
+
* ends, so the end of the host kills it.
|
|
26
|
+
*/
|
|
27
|
+
export function startChild(command: string, options: SpawnOptions, stdin: "pipe" | "ignore"): ChildProcess {
|
|
28
|
+
const child = spawn(command, [...(options.args ?? [])], {
|
|
29
|
+
cwd: options.cwd,
|
|
30
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
31
|
+
shell: false,
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
detached: OWN_PROCESS_GROUP,
|
|
34
|
+
stdio: [stdin, "pipe", "pipe"],
|
|
35
|
+
...(options.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
36
|
+
});
|
|
37
|
+
track(child);
|
|
38
|
+
return child;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The text of an error that kept a program from starting, for example `spawn x ENOENT`. */
|
|
42
|
+
export function startError(error: unknown): string {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* streamCommand: run a program and give each output line while it runs.
|
|
3
|
+
*
|
|
4
|
+
* The queue between the program and the reader has a limit (deep dive WRP-9). When the queue
|
|
5
|
+
* is full, the helper stops reading the pipes of the program. The pipes fill, and the program
|
|
6
|
+
* waits in its next write. When the reader takes items again, the helper reads again. Before,
|
|
7
|
+
* the queue had no limit: a program with a large output filled the memory of the host.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { StringDecoder } from "node:string_decoder";
|
|
11
|
+
import { killTree } from "./tree-kill.js";
|
|
12
|
+
import { startChild, type SpawnOptions } from "./spawn.js";
|
|
13
|
+
|
|
14
|
+
/** One item of a stream: an output line, or the exit of the program (the last item). */
|
|
15
|
+
export type CommandStreamItem =
|
|
16
|
+
| { type: "stdout" | "stderr"; line: string }
|
|
17
|
+
| {
|
|
18
|
+
type: "exit";
|
|
19
|
+
exitCode: number;
|
|
20
|
+
duration: number;
|
|
21
|
+
signal?: string;
|
|
22
|
+
timedOut?: boolean;
|
|
23
|
+
aborted?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface StreamCommandOptions extends SpawnOptions {
|
|
27
|
+
/** Text or bytes to write to stdin. Without it, stdin is empty. */
|
|
28
|
+
input?: string | Buffer | undefined;
|
|
29
|
+
/** After this many milliseconds, the process tree is killed. */
|
|
30
|
+
timeout?: number | undefined;
|
|
31
|
+
/** When the signal aborts, the process tree is killed. */
|
|
32
|
+
signal?: AbortSignal | undefined;
|
|
33
|
+
/** The most items that wait for the reader before the helper stops reading (default: 1000). */
|
|
34
|
+
highWaterMark?: number | undefined;
|
|
35
|
+
/** A longer line arrives in parts of this many characters (default: 1 MiB). */
|
|
36
|
+
maxLineLength?: number | undefined;
|
|
37
|
+
/** The encoding of the output (default: utf8). */
|
|
38
|
+
encoding?: BufferEncoding | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_HIGH_WATER_MARK = 1000;
|
|
42
|
+
const DEFAULT_MAX_LINE_LENGTH = 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function runs `command` and yields each line of stdout and stderr, then one exit item.
|
|
46
|
+
* When the reader stops early, or the signal aborts, the process tree is killed. A program that
|
|
47
|
+
* cannot start makes the generator throw.
|
|
48
|
+
*/
|
|
49
|
+
export async function* streamCommand(
|
|
50
|
+
command: string,
|
|
51
|
+
options: StreamCommandOptions = {},
|
|
52
|
+
): AsyncGenerator<CommandStreamItem, void, undefined> {
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
const highWaterMark = Math.max(1, options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK);
|
|
55
|
+
const lowWaterMark = Math.floor(highWaterMark / 2);
|
|
56
|
+
const maxLineLength = Math.max(1, options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH);
|
|
57
|
+
const encoding = options.encoding ?? "utf8";
|
|
58
|
+
|
|
59
|
+
const items: CommandStreamItem[] = [];
|
|
60
|
+
let ended = false;
|
|
61
|
+
let failure: { error: Error } | undefined;
|
|
62
|
+
let wake: (() => void) | undefined;
|
|
63
|
+
const notify = (): void => {
|
|
64
|
+
const resume = wake;
|
|
65
|
+
wake = undefined;
|
|
66
|
+
resume?.();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (options.signal?.aborted) {
|
|
70
|
+
yield { type: "exit", exitCode: 1, duration: 0, aborted: true };
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const child = startChild(command, options, options.input !== undefined ? "pipe" : "ignore");
|
|
75
|
+
let paused = false;
|
|
76
|
+
let timedOut = false;
|
|
77
|
+
let aborted = false;
|
|
78
|
+
|
|
79
|
+
const pause = (): void => {
|
|
80
|
+
if (paused) return;
|
|
81
|
+
paused = true;
|
|
82
|
+
child.stdout!.pause();
|
|
83
|
+
child.stderr!.pause();
|
|
84
|
+
};
|
|
85
|
+
const resume = (): void => {
|
|
86
|
+
if (!paused) return;
|
|
87
|
+
paused = false;
|
|
88
|
+
child.stdout!.resume();
|
|
89
|
+
child.stderr!.resume();
|
|
90
|
+
};
|
|
91
|
+
const push = (item: CommandStreamItem): void => {
|
|
92
|
+
items.push(item);
|
|
93
|
+
if (items.length >= highWaterMark) pause();
|
|
94
|
+
notify();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// A decoder for each stream keeps a character whose bytes arrive in two chunks (deep dive WRP-2)
|
|
98
|
+
const partial = { stdout: "", stderr: "" };
|
|
99
|
+
const decoders = { stdout: new StringDecoder(encoding), stderr: new StringDecoder(encoding) };
|
|
100
|
+
const emit = (type: "stdout" | "stderr", text: string): void => {
|
|
101
|
+
if (text.length <= maxLineLength) {
|
|
102
|
+
push({ type, line: text });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (let at = 0; at < text.length; at += maxLineLength) {
|
|
106
|
+
push({ type, line: text.slice(at, at + maxLineLength) });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const onData = (type: "stdout" | "stderr") => (data: Buffer): void => {
|
|
110
|
+
const lines = (partial[type] + decoders[type].write(data)).split(/\r?\n/);
|
|
111
|
+
let rest = lines.pop() ?? "";
|
|
112
|
+
for (const line of lines) emit(type, line);
|
|
113
|
+
// A line without its end yet: give the full parts now, keep the remainder
|
|
114
|
+
if (rest.length >= maxLineLength) {
|
|
115
|
+
const whole = rest.length - (rest.length % maxLineLength);
|
|
116
|
+
emit(type, rest.slice(0, whole));
|
|
117
|
+
rest = rest.slice(whole);
|
|
118
|
+
}
|
|
119
|
+
partial[type] = rest;
|
|
120
|
+
};
|
|
121
|
+
child.stdout!.on("data", onData("stdout"));
|
|
122
|
+
child.stderr!.on("data", onData("stderr"));
|
|
123
|
+
|
|
124
|
+
if (options.input !== undefined) {
|
|
125
|
+
child.stdin!.on("error", () => {});
|
|
126
|
+
child.stdin!.end(options.input);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const timer = options.timeout !== undefined
|
|
130
|
+
? setTimeout(() => {
|
|
131
|
+
timedOut = true;
|
|
132
|
+
killTree(child);
|
|
133
|
+
}, options.timeout)
|
|
134
|
+
: undefined;
|
|
135
|
+
const onAbort = (): void => {
|
|
136
|
+
aborted = true;
|
|
137
|
+
killTree(child);
|
|
138
|
+
};
|
|
139
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
140
|
+
|
|
141
|
+
child.on("error", (error) => {
|
|
142
|
+
if (child.pid === undefined) {
|
|
143
|
+
failure = { error };
|
|
144
|
+
notify();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
child.on("close", (code, signal) => {
|
|
148
|
+
for (const type of ["stdout", "stderr"] as const) {
|
|
149
|
+
const rest = partial[type] + decoders[type].end();
|
|
150
|
+
if (rest) emit(type, rest);
|
|
151
|
+
}
|
|
152
|
+
const exit: CommandStreamItem = { type: "exit", exitCode: code ?? 1, duration: Date.now() - started };
|
|
153
|
+
const endSignal = signal ?? (timedOut || aborted ? "SIGTERM" : undefined);
|
|
154
|
+
if (endSignal) exit.signal = endSignal;
|
|
155
|
+
if (timedOut) exit.timedOut = true;
|
|
156
|
+
if (aborted) exit.aborted = true;
|
|
157
|
+
items.push(exit);
|
|
158
|
+
ended = true;
|
|
159
|
+
notify();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
for (;;) {
|
|
164
|
+
if (items.length > 0) {
|
|
165
|
+
const item = items.shift()!;
|
|
166
|
+
if (items.length <= lowWaterMark) resume();
|
|
167
|
+
yield item;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (failure) throw failure.error;
|
|
171
|
+
if (ended) return;
|
|
172
|
+
await new Promise<void>((resolve) => {
|
|
173
|
+
wake = resolve;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
if (timer) clearTimeout(timer);
|
|
178
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
179
|
+
// The reader stopped early: end the program and its descendants. The pipes read again, so
|
|
180
|
+
// they can close.
|
|
181
|
+
if (!ended) {
|
|
182
|
+
resume();
|
|
183
|
+
killTree(child);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|