matrix-public-archive/server/child-process-runner/run-in-child-process.js

220 lines
7.8 KiB
JavaScript

'use strict';
// Generic `child_process` runner that handles running the given module with the
// given `runArguments` and returning the async result. Handles the complexity
// error handling, passing large argument objects, and timeouts.
//
// Error handling includes main-line errors seen while waiting the async result,
// as well as keeping track of out of band `uncaughtException` and
// `unhandledRejection` to give more context if the process exits with code 1
// (error) or timesout.
const assert = require('assert');
const { fork } = require('child_process');
const RethrownError = require('../lib/errors/rethrown-error');
const { traceFunction } = require('../tracing/trace-utilities');
const config = require('../lib/config');
const logOutputFromChildProcesses = config.get('logOutputFromChildProcesses');
if (!logOutputFromChildProcesses) {
console.warn(
`Silencing logs from child processes (config.logOutputFromChildProcesses = ${logOutputFromChildProcesses})`
);
}
const resolvedChildForkScriptPath = require.resolve('./child-fork-script');
class RunInChildProcessTimeoutAbortError extends RethrownError {
// ...
}
function assembleErrorAfterChildExitsWithErrors(exitCode, childErrors, childStdErr) {
assert(childErrors);
let extraErrorsMessage = '';
if (childErrors.length > 1) {
extraErrorsMessage = ` (somehow we saw ${
childErrors.length
} errors but we really always expect 1 error)\n${childErrors
.map((childError, index) => `${index}. ${childError.stack}`)
.join('\n')}`;
}
let childErrorToDisplay;
if (childErrors.length === 0) {
childErrorToDisplay = new Error(
`No child errors but there might be something in stderr=${childStdErr}`
);
// Clear the stack trace part of the stack string out because this is just a
// note about the lack of errors, not an actual error and is just noisy with
// that extra fluff.
childErrorToDisplay.stack = childErrorToDisplay.message;
} else if (childErrors.length === 1) {
childErrorToDisplay = childErrors[0];
} else {
childErrorToDisplay = new Error('Multiple child errors listed above ^');
// Clear the stack trace part of the stack string out because this is just a
// note about the other errors, not an actual error and is just noisy with
// that extra fluff.
childErrorToDisplay.stack = childErrorToDisplay.message;
}
const childErrorSummary = new RethrownError(
`Child process exited with code ${exitCode}${extraErrorsMessage}`,
childErrorToDisplay
);
return childErrorSummary;
}
async function runInChildProcess(
modulePath,
runArguments,
{ timeout, abortSignal: externalAbortSignal }
) {
let abortTimeoutId;
try {
let childErrors = [];
let childExitCode = '(not set yet)';
let childStdErr = '';
const abortController = new AbortController();
// Stops the child process if it takes too long
if (timeout) {
abortTimeoutId = setTimeout(() => {
const childErrorSummary = assembleErrorAfterChildExitsWithErrors(
childExitCode,
childErrors,
childStdErr
);
abortController.abort(
new RunInChildProcessTimeoutAbortError(
`Timed out while running ${modulePath} so we aborted the child process after ${timeout}ms. Any child errors? (${childErrors.length})`,
childErrorSummary
)
);
}, timeout);
}
// Stop the child process if we get an external signal to stop (like if the whole
// express route that caused this call times out)
if (externalAbortSignal) {
if (externalAbortSignal.aborted) {
// Abort for good measure in case we sneak past this somehow
abortController.abort(externalAbortSignal.reason);
// Throw an error and exit early if we already aborted before we even started
throw externalAbortSignal.reason;
}
externalAbortSignal.addEventListener('abort', () => {
abortController.abort(externalAbortSignal.reason);
});
}
// We use a child_process because we want to be able to exit the process
// after we receive the results. We use `fork` instead of `exec`/`spawn` so
// that we can pass a module instead of running a command.
const child = fork(resolvedChildForkScriptPath, [modulePath], {
signal: abortController.signal,
// Default to silencing logs from the child process. We already have
// proper instrumentation of any errors that might occur.
//
// This also makes `child.stderr` and `child.stdout` available
silent: true,
//cwd: process.cwd(),
});
// Since we have to use the `silent` option for the `stderr` stuff below, we
// should also print out the `stdout` to our main console if we want to see what's going on.
child.stdout.on('data', function (data) {
if (logOutputFromChildProcesses) {
console.log('Child printed something to stdout:', String(data));
}
});
child.stderr.on('data', function (data) {
if (logOutputFromChildProcesses) {
console.log('Child printed something to stderr:', String(data));
}
childStdErr += data;
});
// Pass the runArguments to the child by sending instead of via argv because
// we will run into `Error: spawn E2BIG` and `Error: spawn ENAMETOOLONG`
// with argv.
child.send(runArguments);
const returnedData = await new Promise((resolve, reject) => {
let data = '';
// Collect the data passed back by the child
child.on('message', function (result) {
if (result.error) {
// De-serialize the error
const childError = new Error();
childError.name = result.name;
childError.message = result.message;
childError.stack = result.stack;
// When an error happens while running the module, we only expect one
// error to come through here from the main-line to run the module.
// But it's possible to get multiple errors from async out of context
// places since we also listen to `uncaughtException` and
// `unhandledRejection`.
childErrors.push(childError);
} else {
data += result.data;
}
});
child.on('close', (exitCode) => {
childExitCode = exitCode;
// Exited successfully
if (exitCode === 0) {
resolve(data);
} else {
const childErrorSummary = assembleErrorAfterChildExitsWithErrors(
childExitCode,
childErrors,
childStdErr
);
reject(childErrorSummary);
}
});
// When a problem occurs when spawning the process or gets aborted
child.on('error', (err) => {
// We should be able to just `reject(err)` without any special-case handling
// here since ideally, we expect the error to be whatever `signal.reason` we
// aborted with but `child_process.fork(...)` doesn't seem play nicely, see
// https://github.com/nodejs/node/issues/47814
if (err.name === 'AbortError') {
reject(abortController.signal.reason || err);
} else {
reject(err);
}
});
});
if (!returnedData) {
const childErrorSummary = assembleErrorAfterChildExitsWithErrors(
childExitCode,
childErrors,
childStdErr
);
throw new RethrownError(
`No \`returnedData\` sent from child process while running the module (${modulePath}). Any child errors? (${childErrors.length})`,
childErrorSummary
);
}
return returnedData;
} finally {
// We don't have to add a undefined/null check here because `clearTimeout`
// works with any value you give it and doesn't throw an error.
clearTimeout(abortTimeoutId);
}
}
module.exports = traceFunction(runInChildProcess);