@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
232 lines • 10.1 kB
JavaScript
import childProcess from 'child_process';
import os from 'os';
import { fileURLToPath, URL } from 'url';
import { isErrnoException, Task, ExpirableTask, StrykerError, } from '@stryker-mutator/util';
import { objectUtils } from '../utils/object-utils.js';
import { StringBuilder } from '../utils/string-builder.js';
import { deserialize, padLeft, serialize } from '../utils/string-utils.js';
import { ChildProcessCrashedError } from './child-process-crashed-error.js';
import { ParentMessageKind, WorkerMessageKind, } from './message-protocol.js';
import { OutOfMemoryError } from './out-of-memory-error.js';
const BROKEN_PIPE_ERROR_CODE = 'EPIPE';
const IPC_CHANNEL_CLOSED_ERROR_CODE = 'ERR_IPC_CHANNEL_CLOSED';
const TIMEOUT_FOR_DISPOSE = 2000;
export class ChildProcessProxy {
proxy;
worker;
initTask;
disposeTask;
fatalError;
workerTasks = new Map();
workerTaskCounter = 0;
log;
stdoutBuilder = new StringBuilder();
stderrBuilder = new StringBuilder();
isDisposed = false;
initMessage;
constructor(modulePath, namedExport, loggingServerAddress, options, fileDescriptions, pluginModulePaths, workingDirectory, logger, execArgv, idGenerator) {
const workerId = idGenerator.next().toString();
this.worker = childProcess.fork(fileURLToPath(new URL('./child-process-proxy-worker.js', import.meta.url)), {
silent: true,
execArgv,
env: { STRYKER_MUTATOR_WORKER: workerId, ...process.env },
});
this.initTask = new Task();
this.log = logger;
this.log.debug('Started %s in worker process %s with pid %s %s', namedExport, workerId, this.worker.pid, execArgv.length ? ` (using args ${execArgv.join(' ')})` : '');
// Listen to `close`, not `exit`, see https://github.com/stryker-mutator/stryker-js/issues/1634
this.worker.on('close', this.handleUnexpectedExit);
this.worker.on('error', this.handleError);
this.initMessage = {
kind: WorkerMessageKind.Init,
loggingServerAddress,
options,
fileDescriptions,
pluginModulePaths,
namedExport: namedExport,
modulePath: modulePath,
workingDirectory,
};
this.listenForMessages();
this.listenToStdoutAndStderr();
this.proxy = this.initProxy();
}
/**
* @description Creates a proxy where each function of the object created using the constructorFunction arg is ran inside of a child process
*/
static create(modulePath, loggingServerAddress, options, fileDescriptions, pluginModulePaths, workingDirectory, injectableClass, execArgv, getLogger, idGenerator) {
return new ChildProcessProxy(modulePath, injectableClass.name, loggingServerAddress, options, fileDescriptions, pluginModulePaths, workingDirectory, getLogger(ChildProcessProxy.name), execArgv, idGenerator);
}
send(message) {
this.worker.send(serialize(message));
}
initProxy() {
// This proxy is a genuine javascript `Proxy` class
// More info: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
return new Proxy({}, {
get: (_, propertyKey) => {
if (typeof propertyKey === 'string') {
return this.forward(propertyKey);
}
else {
return undefined;
}
},
});
}
forward(methodName) {
return async (...args) => {
if (this.fatalError) {
return Promise.reject(this.fatalError);
}
else {
const workerTask = new Task();
const correlationId = this.workerTaskCounter++;
this.workerTasks.set(correlationId, workerTask);
this.initTask.promise
.then(() => {
this.send({
args,
correlationId,
kind: WorkerMessageKind.Call,
methodName,
});
})
.catch((error) => {
workerTask.reject(error);
});
return workerTask.promise;
}
};
}
listenForMessages() {
this.worker.on('message', (serializedMessage) => {
const message = deserialize(serializedMessage);
switch (message.kind) {
case ParentMessageKind.Ready:
// Workaround, because of a race condition more prominent in native ESM node modules
// Fix has landed in v17.4.0 🎉, but we need this workaround for now.
// See https://github.com/nodejs/node/issues/41134
this.send(this.initMessage);
break;
case ParentMessageKind.Initialized:
this.initTask.resolve(undefined);
break;
case ParentMessageKind.CallResult:
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.workerTasks.get(message.correlationId).resolve(message.result);
this.workerTasks.delete(message.correlationId);
break;
case ParentMessageKind.CallRejection:
this.workerTasks
.get(message.correlationId)
.reject(new StrykerError(message.error));
this.workerTasks.delete(message.correlationId);
break;
case ParentMessageKind.DisposeCompleted:
if (this.disposeTask) {
this.disposeTask.resolve(undefined);
}
break;
case ParentMessageKind.InitError:
this.fatalError = new StrykerError(message.error);
this.reportError(this.fatalError);
void this.dispose();
break;
default:
this.logUnidentifiedMessage(message);
break;
}
});
}
listenToStdoutAndStderr() {
const handleData = (builder) => (data) => {
const output = data.toString();
builder.append(output);
this.log.trace(output);
};
if (this.worker.stdout) {
this.worker.stdout.on('data', handleData(this.stdoutBuilder));
}
if (this.worker.stderr) {
this.worker.stderr.on('data', handleData(this.stderrBuilder));
}
}
get stdout() {
return this.stdoutBuilder.toString();
}
get stderr() {
return this.stderrBuilder.toString();
}
reportError(error) {
const onGoingWorkerTasks = [...this.workerTasks.values()].filter((task) => !task.isCompleted);
if (!this.initTask.isCompleted) {
onGoingWorkerTasks.push(this.initTask);
}
if (onGoingWorkerTasks.length) {
onGoingWorkerTasks.forEach((task) => task.reject(error));
}
}
handleUnexpectedExit = (code, signal) => {
this.isDisposed = true;
const output = StringBuilder.concat(this.stderrBuilder, this.stdoutBuilder);
if (processOutOfMemory()) {
const oom = new OutOfMemoryError(this.worker.pid, code);
this.fatalError = oom;
this.log.warn(`Child process [pid ${oom.pid}] ran out of memory. Stdout and stderr are logged on debug level.`);
this.log.debug(stdoutAndStderr());
}
else {
this.fatalError = new ChildProcessCrashedError(this.worker.pid, `Child process [pid ${this.worker.pid}] exited unexpectedly with exit code ${code} (${signal || 'without signal'}). ${stdoutAndStderr()}`, code, signal);
this.log.warn(this.fatalError.message, this.fatalError);
}
this.reportError(this.fatalError);
function processOutOfMemory() {
return (output.includes('JavaScript heap out of memory') ||
output.includes('FatalProcessOutOfMemory'));
}
function stdoutAndStderr() {
if (output.length) {
return `Last part of stdout and stderr was:${os.EOL}${padLeft(output)}`;
}
else {
return 'Stdout and stderr were empty.';
}
}
};
handleError = (error) => {
if (this.innerProcessIsCrashed(error)) {
this.log.warn(`Child process [pid ${this.worker.pid}] has crashed. See other warning messages for more info.`, error);
this.reportError(new ChildProcessCrashedError(this.worker.pid, `Child process [pid ${this.worker.pid}] has crashed`, undefined, undefined, error));
}
else {
this.reportError(error);
}
};
innerProcessIsCrashed(error) {
return (isErrnoException(error) &&
(error.code === BROKEN_PIPE_ERROR_CODE ||
error.code === IPC_CHANNEL_CLOSED_ERROR_CODE));
}
async dispose() {
if (!this.isDisposed) {
this.worker.removeListener('close', this.handleUnexpectedExit);
this.isDisposed = true;
this.log.debug('Disposing of worker process %s', this.worker.pid);
this.disposeTask = new ExpirableTask(TIMEOUT_FOR_DISPOSE);
this.send({ kind: WorkerMessageKind.Dispose });
try {
await this.disposeTask.promise;
}
finally {
this.log.debug('Kill %s', this.worker.pid);
await objectUtils.kill(this.worker.pid);
}
}
}
logUnidentifiedMessage(message) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
this.log.error(`Received unidentified message ${message}`);
}
}
//# sourceMappingURL=child-process-proxy.js.map