11.8 KB400 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {ChildProcess, IOType, Serializable, SpawnOptions} from 'node:child_process';
9import type {Stream} from 'node:stream';
10
11import getStream from 'get-stream';
12import {spawn} from 'node:child_process';
13import {Readable} from 'node:stream';
14import os from 'os';
15import {truncate} from './utils';
16
17const LF = '\n';
18const LF_BINARY = LF.codePointAt(0);
19const CR = '\r';
20const CR_BINARY = CR.codePointAt(0);
21
22function maybeStripFinalNewline<T extends string | Uint8Array>(input: T, strip: boolean): T {
23 if (!strip) {
24 return input;
25 }
26 const isString = typeof input === 'string';
27 const LF = isString ? '\n' : '\n'.codePointAt(0);
28 const CR = isString ? '\r' : '\r'.codePointAt(0);
29 if (typeof input === 'string') {
30 const stripped = input.at(-1) === LF ? input.slice(0, input.at(-2) === CR ? -2 : -1) : input;
31 return stripped as T;
32 }
33
34 const stripped =
35 input.at(-1) === LF_BINARY ? input.subarray(0, input.at(-2) === CR_BINARY ? -2 : -1) : input;
36
37 return stripped as T;
38}
39
40export interface EjecaOptions {
41 /**
42 * Current working directory of the child process.
43 * @default process.cwd()
44 */
45 readonly cwd?: string;
46
47 /**
48 * Environment key-value pairs. Extends automatically if `process.extendEnv` is set to true.
49 * @default process.env
50 */
51 readonly env?: NodeJS.ProcessEnv;
52
53 /**
54 * Set to `false` if you don't want to extend the environment variables when providing the `env` property.
55 * @default true
56 */
57 readonly extendEnv?: boolean;
58
59 /**
60 * Feeds its contents as the standard input of the binary.
61 */
62 readonly input?: string | Buffer | ReadableStream;
63
64 /**
65 * Setting this to `false` resolves the promise with the error instead of rejecting it.
66 * @default true
67 */
68 readonly reject?: boolean;
69
70 /**
71 * Same options as [`stdio`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#optionsstdio).
72 * @default 'pipe'
73 */
74 readonly stdin?: IOType | Stream | number | null | undefined;
75
76 /**
77 * Same options as [`stdio`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#optionsstdio).
78 * @default 'pipe'
79 */
80 readonly stdout?: IOType | Stream | number | null | undefined;
81
82 /**
83 * Same options as [`stdio`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#optionsstdio).
84 * @default 'pipe'
85 */
86 readonly stderr?: IOType | Stream | number | null | undefined;
87
88 /**
89 * Strip the final newline character from the (awaitable) output.
90 * @default true
91 */
92 readonly stripFinalNewline?: boolean;
93
94 /**
95 * Whether a NodeIPC channel should be open. See the docs about [`stdio`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#optionsstdio) for more info.
96 * @default false
97 */
98 readonly ipc?: boolean;
99}
100
101interface KillOptions {
102 /**
103 * Milliseconds to wait for the child process to terminate before sending `SIGKILL`.
104 * Can be disabled with `false`.
105 * @default 5000
106 */
107 forceKillAfterTimeout?: number | boolean;
108}
109
110type KillParam = number | NodeJS.Signals | undefined;
111const DEFAULT_FORCE_KILL_TIMEOUT = 1000 * 5;
112
113function spawnedKill(
114 kill: ChildProcess['kill'],
115 signal: KillParam = 'SIGTERM',
116 options: KillOptions = {},
117): boolean {
118 const killResult = kill(signal);
119
120 if (shouldForceKill(signal, options, killResult)) {
121 const timeout = getForceKillAfterTimeout(options);
122 setTimeout(() => {
123 kill('SIGKILL');
124 }, timeout);
125 }
126
127 return killResult;
128}
129
130function getForceKillAfterTimeout({forceKillAfterTimeout = true}: KillOptions): number {
131 if (typeof forceKillAfterTimeout !== 'number') {
132 return DEFAULT_FORCE_KILL_TIMEOUT;
133 }
134
135 if (!Number.isFinite(forceKillAfterTimeout) || forceKillAfterTimeout < 0) {
136 throw new TypeError(
137 `Expected the \`forceKillAfterTimeout\` option to be a non-negative integer, got \`${forceKillAfterTimeout}\` (${typeof forceKillAfterTimeout})`,
138 );
139 }
140
141 return forceKillAfterTimeout;
142}
143
144function shouldForceKill(
145 signal: KillParam,
146 {forceKillAfterTimeout}: KillOptions,
147 killResult: boolean,
148): boolean {
149 const isSigTerm = signal === os.constants.signals.SIGTERM || signal == 'SIGTERM';
150 return isSigTerm && forceKillAfterTimeout !== false && killResult;
151}
152
153export interface EjecaReturn {
154 /**
155 * The exit code if the child exited on its own.
156 */
157 exitCode: number;
158
159 /**
160 * The signal by which the child process was terminated, `undefined` if the process was not killed.
161 *
162 * Essentially obtained through `signal` on the `exit` event from [`ChildProcess`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#event-exit)
163 */
164 signal?: string;
165
166 /**
167 * The file and arguments that were run, escaped. Useful for logging.
168 */
169 escapedCommand: string;
170
171 /**
172 * The output of the process on stdout.
173 */
174 stdout: string;
175
176 /**
177 * The output of the process on stderr.
178 */
179 stderr: string;
180
181 /**
182 * Whether the process was killed.
183 */
184 killed: boolean;
185}
186
187interface EjecaChildPromise {
188 catch<ResultType = never>(
189 onRejected?: (reason: EjecaError) => ResultType | PromiseLike<ResultType>,
190 ): Promise<EjecaReturn | ResultType>;
191
192 /**
193 * Essentially the same as [`subprocess.kill`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#subprocesskillsignal), but
194 * with the caveat of having the processes SIGKILL'ed after a few seconds if the original signal
195 * didn't successfully terminate the process. This behavior is configurable through the `options` option.
196 */
197 kill(signal?: KillParam, options?: KillOptions): boolean;
198
199 getOneMessage(): Promise<Serializable>;
200}
201
202export type EjecaChildProcess = ChildProcess & EjecaChildPromise & Promise<EjecaReturn>;
203
204// The return value is a mixin of `childProcess` and `Promise`
205function getMergePromise(
206 spawned: ChildProcess,
207 promise: Promise<EjecaReturn>,
208): ChildProcess & Promise<EjecaReturn> {
209 const s2 = Object.create(spawned);
210 // @ts-expect-error: we are doing some good old monkey patching here
211 s2.then = (...args) => {
212 return promise.then(...args);
213 };
214 // @ts-expect-error: we are doing some good old monkey patching here
215 s2.catch = (...args) => {
216 return promise.catch(...args);
217 };
218 // @ts-expect-error: we are doing some good old monkey patching here
219 s2.finally = (...args) => {
220 return promise.finally(...args);
221 };
222
223 return s2 as unknown as ChildProcess & Promise<EjecaReturn>;
224}
225
226function escapedCmd(file: string, args: readonly string[]): string {
227 const allargs = [file, ...args.map(arg => `"${arg.replace(/"/g, '\\"')}"`)];
228 return allargs.join(' ');
229}
230
231// Use promises instead of `child_process` events
232function getSpawnedPromise(
233 spawned: ChildProcess,
234 escapedCommand: string,
235 options?: EjecaOptions,
236): Promise<EjecaReturn> {
237 const {stdout, stderr} = spawned;
238 const spawnedPromise = new Promise<{exitCode: number; signal?: string}>((resolve, reject) => {
239 spawned.on('exit', (exitCode, signal) => {
240 resolve({exitCode: exitCode ?? -1, signal: signal ?? undefined});
241 });
242
243 spawned.on('error', error => {
244 reject(error);
245 });
246
247 if (spawned.stdin) {
248 spawned.stdin.on('error', error => {
249 reject(error);
250 });
251 }
252 });
253
254 return Promise.all([spawnedPromise, getStreamPromise(stdout), getStreamPromise(stderr)]).then(
255 values => {
256 const [{exitCode, signal}, stdout, stderr] = values;
257 const stripfinalNl = options?.stripFinalNewline ?? true;
258 const ret: EjecaReturn = {
259 exitCode,
260 signal,
261 stdout: maybeStripFinalNewline(stdout, stripfinalNl),
262 stderr: maybeStripFinalNewline(stderr, stripfinalNl),
263 killed: false,
264 escapedCommand,
265 };
266 if (exitCode !== 0 || signal != undefined) {
267 throw new EjecaError(ret);
268 }
269 return ret;
270 },
271 );
272}
273
274export class EjecaError extends Error implements EjecaReturn {
275 escapedCommand: string;
276 exitCode: number;
277 signal?: string;
278 stdout: string;
279 stderr: string;
280 killed: boolean;
281
282 constructor(info: EjecaReturn) {
283 const message =
284 `Command \`${truncate(info.escapedCommand, 50)}\` ` +
285 (info.signal != null ? 'was killed' : 'exited with non-zero status') +
286 (info.signal != null ? ` with signal ${info.signal}` : ` with exit code ${info.exitCode}`);
287 super(message);
288
289 this.exitCode = info.exitCode;
290 this.signal = info.signal;
291 this.stdout = info.stdout;
292 this.stderr = info.stderr;
293 this.killed = info.killed;
294 this.escapedCommand = info.escapedCommand;
295 }
296
297 toString() {
298 return `${this.message}\n${JSON.stringify(this, undefined, 2)}\n`;
299 }
300}
301
302function getStreamPromise(origStream: Stream | null): Promise<string> {
303 const stream = origStream ?? new Readable({read() {}});
304 return getStream(stream, {encoding: 'utf8'});
305}
306
307function commonToSpawnOptions(options?: EjecaOptions): SpawnOptions {
308 const env = options?.env
309 ? (options.extendEnv ?? true)
310 ? {...process.env, ...options.env}
311 : options.env
312 : process.env;
313 const stdin = options?.stdin ?? 'pipe';
314 const stdout = options?.stdout ?? 'pipe';
315 const stderr = options?.stderr ?? 'pipe';
316 return {
317 cwd: options?.cwd || process.cwd(),
318 env,
319 stdio: options?.ipc ? [stdin, stdout, stderr, 'ipc'] : [stdin, stdout, stderr],
320 windowsHide: true,
321 };
322}
323
324/**
325 * Essentially a wrapper for [`child_process.spawn`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#child_processspawncommand-args-options), which
326 * additionally makes the result awaitable through `EjecaChildPromise`. `_file`, `_args` and `_options`
327 * are essentially the same as the args for `child_process.spawn`.
328 *
329 * It also has a couple of additional features:
330 * - Adds a forced timeout kill for `child_process.kill` through `EjecaChildPromise.kill`
331 * - Allows feeding to stdin through `_options.input`
332 */
333export function ejeca(
334 file: string,
335 args: readonly string[],
336 options?: EjecaOptions,
337): EjecaChildProcess {
338 const spawned = spawn(file, args, commonToSpawnOptions(options));
339 const spawnedPromise = getSpawnedPromise(spawned, escapedCmd(file, args), options);
340 const mergedPromise = getMergePromise(spawned, spawnedPromise);
341
342 // TODO: Handle streams
343 if (options && options.input) {
344 mergedPromise.stdin?.end(options.input);
345 }
346
347 const ecp = Object.create(mergedPromise);
348 ecp.kill = (p: KillParam, o?: KillOptions) => {
349 return spawnedKill(s => mergedPromise.kill(s), p, o);
350 };
351
352 if (options && options.ipc) {
353 ecp._ipcMessagesQueue = [];
354 ecp._ipcPendingPromises = [];
355 mergedPromise.on('message', message => {
356 if (ecp._ipcPendingPromises.length > 0) {
357 const resolve = ecp._ipcPendingPromises.shift()[0];
358 resolve(message);
359 } else {
360 ecp._ipcMessagesQueue.push(message);
361 }
362 });
363 mergedPromise.on('error', error => {
364 while (ecp._ipcPendingPromises.length > 0) {
365 const reject = ecp._ipcPendingPromises.shift()[1];
366 reject(error);
367 }
368 });
369 mergedPromise.on('exit', (_exitCode, _signal) => {
370 while (ecp._ipcPendingPromises.length > 0) {
371 const reject = ecp._ipcPendingPromises.shift()[1];
372 reject(new Error('IPC channel closed before receiving a message'));
373 }
374 });
375
376 ecp.getOneMessage = () => {
377 return new Promise<string>((resolve, reject) => {
378 if (ecp._ipcMessagesQueue.length > 0) {
379 resolve(ecp._ipcMessagesQueue.shift());
380 } else {
381 ecp._ipcPendingPromises.push([resolve, reject]);
382 }
383 });
384 };
385 } else {
386 ecp.getOneMessage = () => {
387 throw new Error('IPC not enabled');
388 };
389 }
390
391 return ecp as unknown as EjecaChildProcess;
392}
393
394/**
395 * Extract the actually useful stderr part of the Ejeca Error, to avoid the long command args being printed first.
396 */
397export function simplifyEjecaError(error: EjecaError): Error {
398 return new Error(error.stderr.trim() || error.message);
399}
400