renovate/lib/util/exec/common.ts

154 lines
4.3 KiB
TypeScript

import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import type { ExecErrorData } from './exec-error';
import { ExecError } from './exec-error';
import type { ExecResult, RawExecOptions } from './types';
// https://man7.org/linux/man-pages/man7/signal.7.html#NAME
// Non TERM/CORE signals
// The following is step 3. in https://github.com/renovatebot/renovate/issues/16197#issuecomment-1171423890
const NONTERM = [
'SIGCHLD',
'SIGCLD',
'SIGCONT',
'SIGSTOP',
'SIGTSTP',
'SIGTTIN',
'SIGTTOU',
'SIGURG',
'SIGWINCH',
];
const encoding = 'utf8';
function stringify(list: Buffer[]): string {
return Buffer.concat(list).toString(encoding);
}
function initStreamListeners(
cp: ChildProcess,
opts: RawExecOptions & { maxBuffer: number },
): [Buffer[], Buffer[]] {
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutLen = 0;
let stderrLen = 0;
cp.stdout?.on('data', (chunk: Buffer) => {
// process.stdout.write(data.toString());
const len = Buffer.byteLength(chunk, encoding);
stdoutLen += len;
if (stdoutLen > opts.maxBuffer) {
cp.emit('error', new Error('stdout maxBuffer exceeded'));
} else {
stdout.push(chunk);
}
});
cp.stderr?.on('data', (chunk: Buffer) => {
// process.stderr.write(data.toString());
const len = Buffer.byteLength(chunk, encoding);
stderrLen += len;
if (stderrLen > opts.maxBuffer) {
cp.emit('error', new Error('stderr maxBuffer exceeded'));
} else {
stderr.push(chunk);
}
});
return [stdout, stderr];
}
export function exec(cmd: string, opts: RawExecOptions): Promise<ExecResult> {
return new Promise((resolve, reject) => {
const maxBuffer = opts.maxBuffer ?? 10 * 1024 * 1024; // Set default max buffer size to 10MB
const cp = spawn(cmd, {
...opts,
// force detached on non WIN platforms
// https://github.com/nodejs/node/issues/21825#issuecomment-611328888
detached: process.platform !== 'win32',
shell: typeof opts.shell === 'string' ? opts.shell : true, // force shell
});
// handle streams
const [stdout, stderr] = initStreamListeners(cp, {
...opts,
maxBuffer,
});
// handle process events
cp.on('error', (error) => {
kill(cp, 'SIGTERM');
// rethrowing, use originally emitted error message
reject(new ExecError(error.message, rejectInfo(), error));
});
cp.on('exit', (code: number, signal: NodeJS.Signals) => {
if (NONTERM.includes(signal)) {
return;
}
if (signal) {
kill(cp, signal);
reject(
new ExecError(`Command failed: ${cmd}\nInterrupted by ${signal}`, {
...rejectInfo(),
signal,
}),
);
return;
}
if (code !== 0) {
reject(
new ExecError(`Command failed: ${cmd}\n${stringify(stderr)}`, {
...rejectInfo(),
exitCode: code,
}),
);
return;
}
resolve({
stderr: stringify(stderr),
stdout: stringify(stdout),
});
});
function rejectInfo(): ExecErrorData {
return {
cmd: cp.spawnargs.join(' '),
options: opts,
stdout: stringify(stdout),
stderr: stringify(stderr),
};
}
});
}
function kill(cp: ChildProcess, signal: NodeJS.Signals): boolean {
try {
if (cp.pid && process.env.RENOVATE_X_EXEC_GPID_HANDLE) {
/**
* If `pid` is negative, but not `-1`, signal shall be sent to all processes
* (excluding an unspecified set of system processes),
* whose process group ID (pgid) is equal to the absolute value of pid,
* and for which the process has permission to send a signal.
*/
return process.kill(-cp.pid, signal);
} else {
// destroying stdio is needed for unref to work
// https://nodejs.org/api/child_process.html#subprocessunref
// https://github.com/nodejs/node/blob/4d5ff25a813fd18939c9f76b17e36291e3ea15c3/lib/child_process.js#L412-L426
cp.stderr?.destroy();
cp.stdout?.destroy();
cp.unref();
return cp.kill(signal);
}
} catch {
// cp is a single node tree, therefore -pid is invalid as there is no such pgid,
return false;
}
}
export const rawExec: (
cmd: string,
opts: RawExecOptions,
) => Promise<ExecResult> = exec;