addons/shared/ejeca.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {ChildProcess, IOType, Serializable, SpawnOptions} from 'node:child_process';
b69ab319import type {Stream} from 'node:stream';
b69ab3110
b69ab3111import getStream from 'get-stream';
b69ab3112import {spawn} from 'node:child_process';
b69ab3113import {Readable} from 'node:stream';
b69ab3114import os from 'os';
b69ab3115import {truncate} from './utils';
b69ab3116
b69ab3117const LF = '\n';
b69ab3118const LF_BINARY = LF.codePointAt(0);
b69ab3119const CR = '\r';
b69ab3120const CR_BINARY = CR.codePointAt(0);
b69ab3121
b69ab3122function maybeStripFinalNewline<T extends string | Uint8Array>(input: T, strip: boolean): T {
b69ab3123 if (!strip) {
b69ab3124 return input;
b69ab3125 }
b69ab3126 const isString = typeof input === 'string';
b69ab3127 const LF = isString ? '\n' : '\n'.codePointAt(0);
b69ab3128 const CR = isString ? '\r' : '\r'.codePointAt(0);
b69ab3129 if (typeof input === 'string') {
b69ab3130 const stripped = input.at(-1) === LF ? input.slice(0, input.at(-2) === CR ? -2 : -1) : input;
b69ab3131 return stripped as T;
b69ab3132 }
b69ab3133
b69ab3134 const stripped =
b69ab3135 input.at(-1) === LF_BINARY ? input.subarray(0, input.at(-2) === CR_BINARY ? -2 : -1) : input;
b69ab3136
b69ab3137 return stripped as T;
b69ab3138}
b69ab3139
b69ab3140export interface EjecaOptions {
b69ab3141 /**
b69ab3142 * Current working directory of the child process.
b69ab3143 * @default process.cwd()
b69ab3144 */
b69ab3145 readonly cwd?: string;
b69ab3146
b69ab3147 /**
b69ab3148 * Environment key-value pairs. Extends automatically if `process.extendEnv` is set to true.
b69ab3149 * @default process.env
b69ab3150 */
b69ab3151 readonly env?: NodeJS.ProcessEnv;
b69ab3152
b69ab3153 /**
b69ab3154 * Set to `false` if you don't want to extend the environment variables when providing the `env` property.
b69ab3155 * @default true
b69ab3156 */
b69ab3157 readonly extendEnv?: boolean;
b69ab3158
b69ab3159 /**
b69ab3160 * Feeds its contents as the standard input of the binary.
b69ab3161 */
b69ab3162 readonly input?: string | Buffer | ReadableStream;
b69ab3163
b69ab3164 /**
b69ab3165 * Setting this to `false` resolves the promise with the error instead of rejecting it.
b69ab3166 * @default true
b69ab3167 */
b69ab3168 readonly reject?: boolean;
b69ab3169
b69ab3170 /**
b69ab3171 * Same options as [`stdio`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#optionsstdio).
b69ab3172 * @default 'pipe'
b69ab3173 */
b69ab3174 readonly stdin?: IOType | Stream | number | null | undefined;
b69ab3175
b69ab3176 /**
b69ab3177 * Same options as [`stdio`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#optionsstdio).
b69ab3178 * @default 'pipe'
b69ab3179 */
b69ab3180 readonly stdout?: IOType | Stream | number | null | undefined;
b69ab3181
b69ab3182 /**
b69ab3183 * Same options as [`stdio`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#optionsstdio).
b69ab3184 * @default 'pipe'
b69ab3185 */
b69ab3186 readonly stderr?: IOType | Stream | number | null | undefined;
b69ab3187
b69ab3188 /**
b69ab3189 * Strip the final newline character from the (awaitable) output.
b69ab3190 * @default true
b69ab3191 */
b69ab3192 readonly stripFinalNewline?: boolean;
b69ab3193
b69ab3194 /**
b69ab3195 * 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.
b69ab3196 * @default false
b69ab3197 */
b69ab3198 readonly ipc?: boolean;
b69ab3199}
b69ab31100
b69ab31101interface KillOptions {
b69ab31102 /**
b69ab31103 * Milliseconds to wait for the child process to terminate before sending `SIGKILL`.
b69ab31104 * Can be disabled with `false`.
b69ab31105 * @default 5000
b69ab31106 */
b69ab31107 forceKillAfterTimeout?: number | boolean;
b69ab31108}
b69ab31109
b69ab31110type KillParam = number | NodeJS.Signals | undefined;
b69ab31111const DEFAULT_FORCE_KILL_TIMEOUT = 1000 * 5;
b69ab31112
b69ab31113function spawnedKill(
b69ab31114 kill: ChildProcess['kill'],
b69ab31115 signal: KillParam = 'SIGTERM',
b69ab31116 options: KillOptions = {},
b69ab31117): boolean {
b69ab31118 const killResult = kill(signal);
b69ab31119
b69ab31120 if (shouldForceKill(signal, options, killResult)) {
b69ab31121 const timeout = getForceKillAfterTimeout(options);
b69ab31122 setTimeout(() => {
b69ab31123 kill('SIGKILL');
b69ab31124 }, timeout);
b69ab31125 }
b69ab31126
b69ab31127 return killResult;
b69ab31128}
b69ab31129
b69ab31130function getForceKillAfterTimeout({forceKillAfterTimeout = true}: KillOptions): number {
b69ab31131 if (typeof forceKillAfterTimeout !== 'number') {
b69ab31132 return DEFAULT_FORCE_KILL_TIMEOUT;
b69ab31133 }
b69ab31134
b69ab31135 if (!Number.isFinite(forceKillAfterTimeout) || forceKillAfterTimeout < 0) {
b69ab31136 throw new TypeError(
b69ab31137 `Expected the \`forceKillAfterTimeout\` option to be a non-negative integer, got \`${forceKillAfterTimeout}\` (${typeof forceKillAfterTimeout})`,
b69ab31138 );
b69ab31139 }
b69ab31140
b69ab31141 return forceKillAfterTimeout;
b69ab31142}
b69ab31143
b69ab31144function shouldForceKill(
b69ab31145 signal: KillParam,
b69ab31146 {forceKillAfterTimeout}: KillOptions,
b69ab31147 killResult: boolean,
b69ab31148): boolean {
b69ab31149 const isSigTerm = signal === os.constants.signals.SIGTERM || signal == 'SIGTERM';
b69ab31150 return isSigTerm && forceKillAfterTimeout !== false && killResult;
b69ab31151}
b69ab31152
b69ab31153export interface EjecaReturn {
b69ab31154 /**
b69ab31155 * The exit code if the child exited on its own.
b69ab31156 */
b69ab31157 exitCode: number;
b69ab31158
b69ab31159 /**
b69ab31160 * The signal by which the child process was terminated, `undefined` if the process was not killed.
b69ab31161 *
b69ab31162 * Essentially obtained through `signal` on the `exit` event from [`ChildProcess`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#event-exit)
b69ab31163 */
b69ab31164 signal?: string;
b69ab31165
b69ab31166 /**
b69ab31167 * The file and arguments that were run, escaped. Useful for logging.
b69ab31168 */
b69ab31169 escapedCommand: string;
b69ab31170
b69ab31171 /**
b69ab31172 * The output of the process on stdout.
b69ab31173 */
b69ab31174 stdout: string;
b69ab31175
b69ab31176 /**
b69ab31177 * The output of the process on stderr.
b69ab31178 */
b69ab31179 stderr: string;
b69ab31180
b69ab31181 /**
b69ab31182 * Whether the process was killed.
b69ab31183 */
b69ab31184 killed: boolean;
b69ab31185}
b69ab31186
b69ab31187interface EjecaChildPromise {
b69ab31188 catch<ResultType = never>(
b69ab31189 onRejected?: (reason: EjecaError) => ResultType | PromiseLike<ResultType>,
b69ab31190 ): Promise<EjecaReturn | ResultType>;
b69ab31191
b69ab31192 /**
b69ab31193 * Essentially the same as [`subprocess.kill`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#subprocesskillsignal), but
b69ab31194 * with the caveat of having the processes SIGKILL'ed after a few seconds if the original signal
b69ab31195 * didn't successfully terminate the process. This behavior is configurable through the `options` option.
b69ab31196 */
b69ab31197 kill(signal?: KillParam, options?: KillOptions): boolean;
b69ab31198
b69ab31199 getOneMessage(): Promise<Serializable>;
b69ab31200}
b69ab31201
b69ab31202export type EjecaChildProcess = ChildProcess & EjecaChildPromise & Promise<EjecaReturn>;
b69ab31203
b69ab31204// The return value is a mixin of `childProcess` and `Promise`
b69ab31205function getMergePromise(
b69ab31206 spawned: ChildProcess,
b69ab31207 promise: Promise<EjecaReturn>,
b69ab31208): ChildProcess & Promise<EjecaReturn> {
b69ab31209 const s2 = Object.create(spawned);
b69ab31210 // @ts-expect-error: we are doing some good old monkey patching here
b69ab31211 s2.then = (...args) => {
b69ab31212 return promise.then(...args);
b69ab31213 };
b69ab31214 // @ts-expect-error: we are doing some good old monkey patching here
b69ab31215 s2.catch = (...args) => {
b69ab31216 return promise.catch(...args);
b69ab31217 };
b69ab31218 // @ts-expect-error: we are doing some good old monkey patching here
b69ab31219 s2.finally = (...args) => {
b69ab31220 return promise.finally(...args);
b69ab31221 };
b69ab31222
b69ab31223 return s2 as unknown as ChildProcess & Promise<EjecaReturn>;
b69ab31224}
b69ab31225
b69ab31226function escapedCmd(file: string, args: readonly string[]): string {
b69ab31227 const allargs = [file, ...args.map(arg => `"${arg.replace(/"/g, '\\"')}"`)];
b69ab31228 return allargs.join(' ');
b69ab31229}
b69ab31230
b69ab31231// Use promises instead of `child_process` events
b69ab31232function getSpawnedPromise(
b69ab31233 spawned: ChildProcess,
b69ab31234 escapedCommand: string,
b69ab31235 options?: EjecaOptions,
b69ab31236): Promise<EjecaReturn> {
b69ab31237 const {stdout, stderr} = spawned;
b69ab31238 const spawnedPromise = new Promise<{exitCode: number; signal?: string}>((resolve, reject) => {
b69ab31239 spawned.on('exit', (exitCode, signal) => {
b69ab31240 resolve({exitCode: exitCode ?? -1, signal: signal ?? undefined});
b69ab31241 });
b69ab31242
b69ab31243 spawned.on('error', error => {
b69ab31244 reject(error);
b69ab31245 });
b69ab31246
b69ab31247 if (spawned.stdin) {
b69ab31248 spawned.stdin.on('error', error => {
b69ab31249 reject(error);
b69ab31250 });
b69ab31251 }
b69ab31252 });
b69ab31253
b69ab31254 return Promise.all([spawnedPromise, getStreamPromise(stdout), getStreamPromise(stderr)]).then(
b69ab31255 values => {
b69ab31256 const [{exitCode, signal}, stdout, stderr] = values;
b69ab31257 const stripfinalNl = options?.stripFinalNewline ?? true;
b69ab31258 const ret: EjecaReturn = {
b69ab31259 exitCode,
b69ab31260 signal,
b69ab31261 stdout: maybeStripFinalNewline(stdout, stripfinalNl),
b69ab31262 stderr: maybeStripFinalNewline(stderr, stripfinalNl),
b69ab31263 killed: false,
b69ab31264 escapedCommand,
b69ab31265 };
b69ab31266 if (exitCode !== 0 || signal != undefined) {
b69ab31267 throw new EjecaError(ret);
b69ab31268 }
b69ab31269 return ret;
b69ab31270 },
b69ab31271 );
b69ab31272}
b69ab31273
b69ab31274export class EjecaError extends Error implements EjecaReturn {
b69ab31275 escapedCommand: string;
b69ab31276 exitCode: number;
b69ab31277 signal?: string;
b69ab31278 stdout: string;
b69ab31279 stderr: string;
b69ab31280 killed: boolean;
b69ab31281
b69ab31282 constructor(info: EjecaReturn) {
b69ab31283 const message =
b69ab31284 `Command \`${truncate(info.escapedCommand, 50)}\` ` +
b69ab31285 (info.signal != null ? 'was killed' : 'exited with non-zero status') +
b69ab31286 (info.signal != null ? ` with signal ${info.signal}` : ` with exit code ${info.exitCode}`);
b69ab31287 super(message);
b69ab31288
b69ab31289 this.exitCode = info.exitCode;
b69ab31290 this.signal = info.signal;
b69ab31291 this.stdout = info.stdout;
b69ab31292 this.stderr = info.stderr;
b69ab31293 this.killed = info.killed;
b69ab31294 this.escapedCommand = info.escapedCommand;
b69ab31295 }
b69ab31296
b69ab31297 toString() {
b69ab31298 return `${this.message}\n${JSON.stringify(this, undefined, 2)}\n`;
b69ab31299 }
b69ab31300}
b69ab31301
b69ab31302function getStreamPromise(origStream: Stream | null): Promise<string> {
b69ab31303 const stream = origStream ?? new Readable({read() {}});
b69ab31304 return getStream(stream, {encoding: 'utf8'});
b69ab31305}
b69ab31306
b69ab31307function commonToSpawnOptions(options?: EjecaOptions): SpawnOptions {
b69ab31308 const env = options?.env
b69ab31309 ? (options.extendEnv ?? true)
b69ab31310 ? {...process.env, ...options.env}
b69ab31311 : options.env
b69ab31312 : process.env;
b69ab31313 const stdin = options?.stdin ?? 'pipe';
b69ab31314 const stdout = options?.stdout ?? 'pipe';
b69ab31315 const stderr = options?.stderr ?? 'pipe';
b69ab31316 return {
b69ab31317 cwd: options?.cwd || process.cwd(),
b69ab31318 env,
b69ab31319 stdio: options?.ipc ? [stdin, stdout, stderr, 'ipc'] : [stdin, stdout, stderr],
b69ab31320 windowsHide: true,
b69ab31321 };
b69ab31322}
b69ab31323
b69ab31324/**
b69ab31325 * Essentially a wrapper for [`child_process.spawn`](https://nodejs.org/docs/latest-v18.x/api/child_process.html#child_processspawncommand-args-options), which
b69ab31326 * additionally makes the result awaitable through `EjecaChildPromise`. `_file`, `_args` and `_options`
b69ab31327 * are essentially the same as the args for `child_process.spawn`.
b69ab31328 *
b69ab31329 * It also has a couple of additional features:
b69ab31330 * - Adds a forced timeout kill for `child_process.kill` through `EjecaChildPromise.kill`
b69ab31331 * - Allows feeding to stdin through `_options.input`
b69ab31332 */
b69ab31333export function ejeca(
b69ab31334 file: string,
b69ab31335 args: readonly string[],
b69ab31336 options?: EjecaOptions,
b69ab31337): EjecaChildProcess {
b69ab31338 const spawned = spawn(file, args, commonToSpawnOptions(options));
b69ab31339 const spawnedPromise = getSpawnedPromise(spawned, escapedCmd(file, args), options);
b69ab31340 const mergedPromise = getMergePromise(spawned, spawnedPromise);
b69ab31341
b69ab31342 // TODO: Handle streams
b69ab31343 if (options && options.input) {
b69ab31344 mergedPromise.stdin?.end(options.input);
b69ab31345 }
b69ab31346
b69ab31347 const ecp = Object.create(mergedPromise);
b69ab31348 ecp.kill = (p: KillParam, o?: KillOptions) => {
b69ab31349 return spawnedKill(s => mergedPromise.kill(s), p, o);
b69ab31350 };
b69ab31351
b69ab31352 if (options && options.ipc) {
b69ab31353 ecp._ipcMessagesQueue = [];
b69ab31354 ecp._ipcPendingPromises = [];
b69ab31355 mergedPromise.on('message', message => {
b69ab31356 if (ecp._ipcPendingPromises.length > 0) {
b69ab31357 const resolve = ecp._ipcPendingPromises.shift()[0];
b69ab31358 resolve(message);
b69ab31359 } else {
b69ab31360 ecp._ipcMessagesQueue.push(message);
b69ab31361 }
b69ab31362 });
b69ab31363 mergedPromise.on('error', error => {
b69ab31364 while (ecp._ipcPendingPromises.length > 0) {
b69ab31365 const reject = ecp._ipcPendingPromises.shift()[1];
b69ab31366 reject(error);
b69ab31367 }
b69ab31368 });
b69ab31369 mergedPromise.on('exit', (_exitCode, _signal) => {
b69ab31370 while (ecp._ipcPendingPromises.length > 0) {
b69ab31371 const reject = ecp._ipcPendingPromises.shift()[1];
b69ab31372 reject(new Error('IPC channel closed before receiving a message'));
b69ab31373 }
b69ab31374 });
b69ab31375
b69ab31376 ecp.getOneMessage = () => {
b69ab31377 return new Promise<string>((resolve, reject) => {
b69ab31378 if (ecp._ipcMessagesQueue.length > 0) {
b69ab31379 resolve(ecp._ipcMessagesQueue.shift());
b69ab31380 } else {
b69ab31381 ecp._ipcPendingPromises.push([resolve, reject]);
b69ab31382 }
b69ab31383 });
b69ab31384 };
b69ab31385 } else {
b69ab31386 ecp.getOneMessage = () => {
b69ab31387 throw new Error('IPC not enabled');
b69ab31388 };
b69ab31389 }
b69ab31390
b69ab31391 return ecp as unknown as EjecaChildProcess;
b69ab31392}
b69ab31393
b69ab31394/**
b69ab31395 * Extract the actually useful stderr part of the Ejeca Error, to avoid the long command args being printed first.
b69ab31396 */
b69ab31397export function simplifyEjecaError(error: EjecaError): Error {
b69ab31398 return new Error(error.stderr.trim() || error.message);
b69ab31399}