220 lines
7.8 KiB
JavaScript
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);
|