Skip to content

Commit 80b044a

Browse files
nicojssimondel
authored andcommitted
feat(child process): Make all child processes silent (#1039)
Make all test runner and transpiler child processes silent. The standard out and standard error (stdout and stderr) are now only visible when `loglevel: 'trace'`. If a child process crashes, the last 10 messages received are logged as warning. This is also a refactoring of the way we spawn child processes. Instead of having 2 similar implementations (one for transpiler and one for test runners), they are both consolidated in one coherent `ChildProcessProxy` abstraction. Also clean up the test runner decorator pattern. Timeouts and retries are now implemented only once. Recognizing that the child process crashed is done by validating that the error is an instance of `ChildProcessCrashedError`. No process specifics other than the name of the error is known from the outside. The `Task` class is also refactored. Instead of relying on a custom implementation, it uses the `Promise.race` method for timeout functionality. Fixes #1038 #976
1 parent efa0242 commit 80b044a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1103
-998
lines changed
Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
{
2-
"files": {
3-
"exclude": {
4-
".git": "",
5-
".tscache": "",
6-
"**/*.js": {
7-
"when": "$(basename).ts"
8-
},
9-
"**/*.d.ts": true,
10-
"**/*.map": {
11-
"when": "$(basename)"
12-
}
2+
"files.exclude": {
3+
".git": true,
4+
".tscache": true,
5+
"**/*.js": {
6+
"when": "$(basename).ts"
7+
},
8+
"**/*.d.ts": true,
9+
"**/*.map": {
10+
"when": "$(basename)"
1311
}
1412
}
1513
}

packages/stryker/src/Sandbox.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { RunResult, RunnerOptions } from 'stryker-api/test_runner';
66
import { File } from 'stryker-api/core';
77
import { TestFramework } from 'stryker-api/test_framework';
88
import { wrapInClosure, normalizeWhiteSpaces } from './utils/objectUtils';
9-
import TestRunnerDecorator from './isolated-runner/TestRunnerDecorator';
10-
import ResilientTestRunnerFactory from './isolated-runner/ResilientTestRunnerFactory';
9+
import TestRunnerDecorator from './test-runner/TestRunnerDecorator';
10+
import ResilientTestRunnerFactory from './test-runner/ResilientTestRunnerFactory';
1111
import { TempFolder } from './utils/TempFolder';
1212
import { writeFile, findNodeModules, symlinkJunction } from './utils/fileUtils';
1313
import TestableMutant, { TestSelectionResult } from './TestableMutant';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import StrykerError from '../utils/StrykerError';
2+
3+
export default class ChildProcessCrashedError extends StrykerError {
4+
constructor(
5+
public readonly pid: number,
6+
message: string,
7+
public readonly exitCode?: number,
8+
public readonly signal?: string,
9+
innerError?: Error) {
10+
super(message, innerError);
11+
Error.captureStackTrace(this, ChildProcessCrashedError);
12+
// TS recommendation: https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
13+
Object.setPrototypeOf(this, ChildProcessCrashedError.prototype);
14+
}
15+
}

packages/stryker/src/child-proxy/ChildProcessProxy.ts

Lines changed: 156 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,114 @@
1+
import * as os from 'os';
12
import { fork, ChildProcess } from 'child_process';
23
import { File } from 'stryker-api/core';
34
import { getLogger } from 'stryker-api/logging';
45
import { WorkerMessage, WorkerMessageKind, ParentMessage, autoStart, ParentMessageKind } from './messageProtocol';
5-
import { serialize, deserialize } from '../utils/objectUtils';
6-
import Task from '../utils/Task';
6+
import { serialize, deserialize, kill, isErrnoException, padLeft } from '../utils/objectUtils';
7+
import { Task, ExpirableTask } from '../utils/Task';
78
import LoggingClientContext from '../logging/LoggingClientContext';
9+
import ChildProcessCrashedError from './ChildProcessCrashedError';
10+
import OutOfMemoryError from './OutOfMemoryError';
11+
import StringBuilder from '../utils/StringBuilder';
812

9-
export type ChildProxy<T> = {
10-
[K in keyof T]: (...args: any[]) => Promise<any>;
13+
interface Func<TS extends any[], R> {
14+
(...args: TS): R;
15+
}
16+
interface PromisifiedFunc<TS extends any[], R> {
17+
(...args: TS): Promise<R>;
18+
}
19+
interface Constructor<T, TS extends any[]> {
20+
new (...args: TS): T;
21+
}
22+
export type Promisified<T> = {
23+
[K in keyof T]: T[K] extends PromisifiedFunc<any, any> ? T[K] : T[K] extends Func<infer TS, infer R> ? PromisifiedFunc<TS, R> : () => Promise<T[K]>;
1124
};
1225

26+
const BROKEN_PIPE_ERROR_CODE = 'EPIPE';
27+
const IPC_CHANNEL_CLOSED_ERROR_CODE = 'ERR_IPC_CHANNEL_CLOSED';
28+
const TIMEOUT_FOR_DISPOSE = 2000;
29+
1330
export default class ChildProcessProxy<T> {
14-
readonly proxy: ChildProxy<T> = {} as ChildProxy<T>;
31+
readonly proxy: Promisified<T>;
1532

1633
private worker: ChildProcess;
1734
private initTask: Task;
18-
private disposeTask: Task<void>;
35+
private disposeTask: ExpirableTask<void> | undefined;
36+
private currentError: ChildProcessCrashedError | undefined;
1937
private workerTasks: Task<any>[] = [];
2038
private log = getLogger(ChildProcessProxy.name);
39+
private stdoutAndStderrBuilder = new StringBuilder();
40+
private isDisposed = false;
2141

22-
private constructor(requirePath: string, loggingContext: LoggingClientContext, plugins: string[], private constructorFunction: { new(...params: any[]): T }, constructorParams: any[]) {
23-
this.worker = fork(require.resolve('./ChildProcessProxyWorker'), [autoStart], { silent: false, execArgv: [] });
42+
private constructor(requirePath: string, loggingContext: LoggingClientContext, plugins: string[], workingDirectory: string, constructorParams: any[]) {
43+
this.worker = fork(require.resolve('./ChildProcessProxyWorker'), [autoStart], { silent: true, execArgv: [] });
2444
this.initTask = new Task();
45+
this.log.debug('Starting %s in child process %s', requirePath, this.worker.pid);
2546
this.send({
2647
kind: WorkerMessageKind.Init,
2748
loggingContext,
2849
plugins,
2950
requirePath,
30-
constructorArgs: constructorParams
51+
constructorArgs: constructorParams,
52+
workingDirectory
3153
});
32-
this.listenToWorkerMessages();
33-
this.initProxy();
54+
this.listenForMessages();
55+
this.listenToStdoutAndStderr();
56+
// This is important! Be sure to bind to `this`
57+
this.handleUnexpectedExit = this.handleUnexpectedExit.bind(this);
58+
this.handleError = this.handleError.bind(this);
59+
this.worker.on('exit', this.handleUnexpectedExit);
60+
this.worker.on('error', this.handleError);
61+
this.proxy = this.initProxy();
3462
}
3563

3664
/**
3765
* Creates a proxy where each function of the object created using the constructorFunction arg is ran inside of a child process
3866
*/
39-
static create<T, P1>(requirePath: string, loggingContext: LoggingClientContext, plugins: string[], constructorFunction: { new(arg: P1): T }, arg: P1): ChildProcessProxy<T>;
40-
/**
41-
* Creates a proxy where each function of the object created using the constructorFunction arg is ran inside of a child process
42-
*/
43-
static create<T, P1, P2>(requirePath: string, loggingContext: LoggingClientContext, plugins: string[], constructorFunction: { new(arg: P1, arg2: P2): T }, arg1: P1, arg2: P2): ChildProcessProxy<T>;
44-
/**
45-
* Creates a proxy where each function of the object created using the constructorFunction arg is ran inside of a child process
46-
*/
47-
static create<T>(requirePath: string, loggingContext: LoggingClientContext, plugins: string[], constructorFunction: { new(...params: any[]): T }, ...constructorArgs: any[]) {
48-
return new ChildProcessProxy(requirePath, loggingContext, plugins, constructorFunction, constructorArgs);
67+
static create<T, TS extends any[]>(requirePath: string, loggingContext: LoggingClientContext, plugins: string[], workingDirectory: string, _: Constructor<T, TS>, ...constructorArgs: TS):
68+
ChildProcessProxy<T> {
69+
return new ChildProcessProxy(requirePath, loggingContext, plugins, workingDirectory, constructorArgs);
4970
}
5071

5172
private send(message: WorkerMessage) {
5273
this.worker.send(serialize(message));
5374
}
5475

55-
private initProxy() {
56-
Object.keys(this.constructorFunction.prototype).forEach(methodName => {
57-
this.proxyMethod(methodName as keyof T);
76+
private initProxy(): Promisified<T> {
77+
// This proxy is a genuine javascript `Proxy` class
78+
// More info: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
79+
const self = this;
80+
return new Proxy({} as Promisified<T>, {
81+
get(_, propertyKey) {
82+
if (typeof propertyKey === 'string') {
83+
return self.forward(propertyKey);
84+
} else {
85+
return undefined;
86+
}
87+
}
5888
});
5989
}
6090

61-
private proxyMethod(methodName: any) {
62-
this.proxy[(methodName as keyof T)] = (...args: any[]) => {
63-
const workerTask = new Task<any>();
64-
this.initTask.promise.then(() => {
91+
private forward(methodName: string) {
92+
return (...args: any[]) => {
93+
if (this.currentError) {
94+
return Promise.reject(this.currentError);
95+
} else {
96+
const workerTask = new Task<any>();
6597
const correlationId = this.workerTasks.push(workerTask) - 1;
66-
this.send({
67-
kind: WorkerMessageKind.Work,
68-
correlationId,
69-
methodName,
70-
args
98+
this.initTask.promise.then(() => {
99+
this.send({
100+
kind: WorkerMessageKind.Call,
101+
correlationId,
102+
methodName,
103+
args
104+
});
71105
});
72-
});
73-
return workerTask.promise;
106+
return workerTask.promise;
107+
}
74108
};
75109
}
76110

77-
private listenToWorkerMessages() {
111+
private listenForMessages() {
78112
this.worker.on('message', (serializedMessage: string) => {
79113
const message: ParentMessage = deserialize(serializedMessage, [File]);
80114
switch (message.kind) {
@@ -83,12 +117,16 @@ export default class ChildProcessProxy<T> {
83117
break;
84118
case ParentMessageKind.Result:
85119
this.workerTasks[message.correlationId].resolve(message.result);
120+
delete this.workerTasks[message.correlationId];
86121
break;
87122
case ParentMessageKind.Rejection:
88123
this.workerTasks[message.correlationId].reject(new Error(message.error));
124+
delete this.workerTasks[message.correlationId];
89125
break;
90126
case ParentMessageKind.DisposeCompleted:
91-
this.disposeTask.resolve(undefined);
127+
if (this.disposeTask) {
128+
this.disposeTask.resolve(undefined);
129+
}
92130
break;
93131
default:
94132
this.logUnidentifiedMessage(message);
@@ -97,12 +135,88 @@ export default class ChildProcessProxy<T> {
97135
});
98136
}
99137

138+
private listenToStdoutAndStderr() {
139+
const handleData = (data: Buffer | string) => {
140+
const output = data.toString();
141+
this.stdoutAndStderrBuilder.append(output);
142+
if (this.log.isTraceEnabled()) {
143+
this.log.trace(output);
144+
}
145+
};
146+
147+
if (this.worker.stdout) {
148+
this.worker.stdout.on('data', handleData);
149+
}
150+
151+
if (this.worker.stderr) {
152+
this.worker.stderr.on('data', handleData);
153+
}
154+
}
155+
156+
private reportError(error: Error) {
157+
this.workerTasks
158+
.filter(task => !task.isCompleted)
159+
.forEach(task => task.reject(error));
160+
}
161+
162+
private handleUnexpectedExit(code: number, signal: string) {
163+
this.isDisposed = true;
164+
const output = this.stdoutAndStderrBuilder.toString();
165+
166+
if (processOutOfMemory()) {
167+
this.currentError = new OutOfMemoryError(this.worker.pid, code);
168+
this.log.warn(`Child process [pid ${this.currentError.pid}] ran out of memory. Stdout and stderr are logged on debug level.`);
169+
this.log.debug(stdoutAndStderr());
170+
} else {
171+
this.currentError = new ChildProcessCrashedError(this.worker.pid, `Child process [pid ${this.worker.pid}] exited unexpectedly with exit code ${code} (${signal || 'without signal'}). ${stdoutAndStderr()}`, code, signal);
172+
this.log.warn(this.currentError.message, this.currentError);
173+
}
174+
175+
this.reportError(this.currentError);
176+
177+
function processOutOfMemory() {
178+
return output.indexOf('JavaScript heap out of memory') >= 0;
179+
}
180+
181+
function stdoutAndStderr() {
182+
if (output.length) {
183+
return `Last part of stdout and stderr was:${os.EOL}${padLeft(output)}`;
184+
} else {
185+
return 'Stdout and stderr were empty.';
186+
}
187+
}
188+
}
189+
190+
private handleError(error: Error) {
191+
if (this.innerProcessIsCrashed(error)) {
192+
this.log.warn(`Child process [pid ${this.worker.pid}] has crashed. See other warning messages for more info.`, error);
193+
this.reportError(new ChildProcessCrashedError(this.worker.pid, `Child process [pid ${this.worker.pid}] has crashed`, undefined, undefined, error));
194+
} else {
195+
this.reportError(error);
196+
}
197+
}
198+
199+
private innerProcessIsCrashed(error: any) {
200+
return isErrnoException(error) && (error.code === BROKEN_PIPE_ERROR_CODE || error.code === IPC_CHANNEL_CLOSED_ERROR_CODE);
201+
}
202+
100203
public dispose(): Promise<void> {
101-
this.disposeTask = new Task();
102-
this.send({ kind: WorkerMessageKind.Dispose });
103-
return this.disposeTask.promise
104-
.then(() => this.worker.kill())
105-
.catch(() => this.worker.kill());
204+
this.worker.removeListener('exit', this.handleUnexpectedExit);
205+
if (this.isDisposed) {
206+
return Promise.resolve();
207+
} else {
208+
this.log.debug('Disposing of worker process %s', this.worker.pid);
209+
const killWorker = () => {
210+
this.log.debug('Kill %s', this.worker.pid);
211+
kill(this.worker.pid);
212+
this.isDisposed = true;
213+
};
214+
this.disposeTask = new ExpirableTask(TIMEOUT_FOR_DISPOSE);
215+
this.send({ kind: WorkerMessageKind.Dispose });
216+
return this.disposeTask.promise
217+
.then(killWorker)
218+
.catch(killWorker);
219+
}
106220
}
107221

108222
private logUnidentifiedMessage(message: never) {

0 commit comments

Comments
 (0)