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

180 lines
6.3 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/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})`
);
}
function assembleErrorAfterChildExitsWithErrors(exitCode, childErrors) {
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');
// 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 }) {
let abortTimeoutId;
try {
let childErrors = [];
let childExitCode = '(not set yet)';
const controller = new AbortController();
const { signal } = controller;
// 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(require.resolve('./child-fork-script'), [modulePath], {
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 (logOutputFromChildProcesses) {
child.stdout.on('data', function (data) {
console.log('Child printed something to stdout:', String(data));
});
child.stderr.on('data', function (data) {
console.log('Child printed something to stderr:', String(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);
// Stops the child process if it takes too long
if (timeout) {
abortTimeoutId = setTimeout(() => {
controller.abort();
}, timeout);
}
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
);
reject(childErrorSummary);
}
});
// When a problem occurs when spawning the process or gets aborted
child.on('error', (err) => {
if (err.name === 'AbortError') {
const childErrorSummary = assembleErrorAfterChildExitsWithErrors(
childExitCode,
childErrors
);
reject(
new RethrownError(
`Timed out while running ${modulePath} so we aborted the child process after ${timeout}ms. Any child errors? (${childErrors.length})`,
childErrorSummary
)
);
} else {
reject(err);
}
});
});
if (!returnedData) {
const childErrorSummary = assembleErrorAfterChildExitsWithErrors(childExitCode, childErrors);
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);