1 | import childProcess from 'child_process';
|
2 | import os from 'os';
|
3 | import { fileURLToPath, URL } from 'url';
|
4 |
|
5 | import { FileDescriptions, StrykerOptions } from '@stryker-mutator/api/core';
|
6 | import { isErrnoException, Task, ExpirableTask, StrykerError } from '@stryker-mutator/util';
|
7 | import log4js from 'log4js';
|
8 | import { Disposable, InjectableClass, InjectionToken } from 'typed-inject';
|
9 |
|
10 | import { LoggingClientContext } from '../logging/index.js';
|
11 | import { objectUtils } from '../utils/object-utils.js';
|
12 | import { StringBuilder } from '../utils/string-builder.js';
|
13 | import { deserialize, padLeft, serialize } from '../utils/string-utils.js';
|
14 |
|
15 | import { ChildProcessCrashedError } from './child-process-crashed-error.js';
|
16 | import { InitMessage, ParentMessage, ParentMessageKind, WorkerMessage, WorkerMessageKind } from './message-protocol.js';
|
17 | import { OutOfMemoryError } from './out-of-memory-error.js';
|
18 | import { ChildProcessContext } from './child-process-proxy-worker.js';
|
19 | import { IdGenerator } from './id-generator.js';
|
20 |
|
21 | type Func<TS extends any[], R> = (...args: TS) => R;
|
22 |
|
23 | type PromisifiedFunc<TS extends any[], R> = (...args: TS) => Promise<R>;
|
24 |
|
25 | export type Promisified<T> = {
|
26 | [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]>;
|
27 | };
|
28 |
|
29 | const BROKEN_PIPE_ERROR_CODE = 'EPIPE';
|
30 | const IPC_CHANNEL_CLOSED_ERROR_CODE = 'ERR_IPC_CHANNEL_CLOSED';
|
31 | const TIMEOUT_FOR_DISPOSE = 2000;
|
32 |
|
33 | export class ChildProcessProxy<T> implements Disposable {
|
34 | public readonly proxy: Promisified<T>;
|
35 |
|
36 | private readonly worker: childProcess.ChildProcess;
|
37 | private readonly initTask: Task;
|
38 | private disposeTask: ExpirableTask | undefined;
|
39 | private fatalError: StrykerError | undefined;
|
40 | private readonly workerTasks: Task[] = [];
|
41 | private readonly log = log4js.getLogger(ChildProcessProxy.name);
|
42 | private readonly stdoutBuilder = new StringBuilder();
|
43 | private readonly stderrBuilder = new StringBuilder();
|
44 | private isDisposed = false;
|
45 | private readonly initMessage: InitMessage;
|
46 |
|
47 | private constructor(
|
48 | modulePath: string,
|
49 | namedExport: string,
|
50 | loggingContext: LoggingClientContext,
|
51 | options: StrykerOptions,
|
52 | fileDescriptions: FileDescriptions,
|
53 | pluginModulePaths: readonly string[],
|
54 | workingDirectory: string,
|
55 | execArgv: string[],
|
56 | idGenerator: IdGenerator
|
57 | ) {
|
58 | const workerId = idGenerator.next().toString();
|
59 | this.worker = childProcess.fork(fileURLToPath(new URL('./child-process-proxy-worker.js', import.meta.url)), {
|
60 | silent: true,
|
61 | execArgv,
|
62 | env: { STRYKER_MUTATOR_WORKER: workerId, ...process.env },
|
63 | });
|
64 | this.initTask = new Task();
|
65 | this.log.debug(
|
66 | 'Started %s in worker process %s with pid %s %s',
|
67 | namedExport,
|
68 | workerId,
|
69 | this.worker.pid,
|
70 | execArgv.length ? ` (using args ${execArgv.join(' ')})` : ''
|
71 | );
|
72 |
|
73 | this.worker.on('close', this.handleUnexpectedExit);
|
74 | this.worker.on('error', this.handleError);
|
75 |
|
76 | this.initMessage = {
|
77 | kind: WorkerMessageKind.Init,
|
78 | loggingContext,
|
79 | options,
|
80 | fileDescriptions,
|
81 | pluginModulePaths,
|
82 | namedExport: namedExport,
|
83 | modulePath: modulePath,
|
84 | workingDirectory,
|
85 | };
|
86 | this.listenForMessages();
|
87 | this.listenToStdoutAndStderr();
|
88 |
|
89 | this.proxy = this.initProxy();
|
90 | }
|
91 |
|
92 | |
93 |
|
94 |
|
95 | public static create<R, Tokens extends Array<InjectionToken<ChildProcessContext>>>(
|
96 | modulePath: string,
|
97 | loggingContext: LoggingClientContext,
|
98 | options: StrykerOptions,
|
99 | fileDescriptions: FileDescriptions,
|
100 | pluginModulePaths: readonly string[],
|
101 | workingDirectory: string,
|
102 | injectableClass: InjectableClass<ChildProcessContext, R, Tokens>,
|
103 | execArgv: string[],
|
104 | idGenerator: IdGenerator
|
105 | ): ChildProcessProxy<R> {
|
106 | return new ChildProcessProxy(
|
107 | modulePath,
|
108 | injectableClass.name,
|
109 | loggingContext,
|
110 | options,
|
111 | fileDescriptions,
|
112 | pluginModulePaths,
|
113 | workingDirectory,
|
114 | execArgv,
|
115 | idGenerator
|
116 | );
|
117 | }
|
118 |
|
119 | private send(message: WorkerMessage) {
|
120 | this.worker.send(serialize(message));
|
121 | }
|
122 |
|
123 | private initProxy(): Promisified<T> {
|
124 |
|
125 |
|
126 | const self = this;
|
127 | return new Proxy({} as Promisified<T>, {
|
128 | get(_, propertyKey) {
|
129 | if (typeof propertyKey === 'string') {
|
130 | return self.forward(propertyKey);
|
131 | } else {
|
132 | return undefined;
|
133 | }
|
134 | },
|
135 | });
|
136 | }
|
137 |
|
138 | private forward(methodName: string) {
|
139 | return async (...args: any[]) => {
|
140 | if (this.fatalError) {
|
141 | return Promise.reject(this.fatalError);
|
142 | } else {
|
143 | const workerTask = new Task<void>();
|
144 | const correlationId = this.workerTasks.push(workerTask) - 1;
|
145 | this.initTask.promise
|
146 | .then(() => {
|
147 | this.send({
|
148 | args,
|
149 | correlationId,
|
150 | kind: WorkerMessageKind.Call,
|
151 | methodName,
|
152 | });
|
153 | })
|
154 | .catch((error) => {
|
155 | workerTask.reject(error);
|
156 | });
|
157 | return workerTask.promise;
|
158 | }
|
159 | };
|
160 | }
|
161 |
|
162 | private listenForMessages() {
|
163 | this.worker.on('message', (serializedMessage: string) => {
|
164 | const message = deserialize<ParentMessage>(serializedMessage);
|
165 | switch (message.kind) {
|
166 | case ParentMessageKind.Ready:
|
167 |
|
168 |
|
169 |
|
170 | this.send(this.initMessage);
|
171 | break;
|
172 | case ParentMessageKind.Initialized:
|
173 | this.initTask.resolve(undefined);
|
174 | break;
|
175 | case ParentMessageKind.CallResult:
|
176 |
|
177 | this.workerTasks[message.correlationId].resolve(message.result);
|
178 | delete this.workerTasks[message.correlationId];
|
179 | break;
|
180 | case ParentMessageKind.CallRejection:
|
181 | this.workerTasks[message.correlationId].reject(new StrykerError(message.error));
|
182 | delete this.workerTasks[message.correlationId];
|
183 | break;
|
184 | case ParentMessageKind.DisposeCompleted:
|
185 | if (this.disposeTask) {
|
186 | this.disposeTask.resolve(undefined);
|
187 | }
|
188 | break;
|
189 | case ParentMessageKind.InitError:
|
190 | this.fatalError = new StrykerError(message.error);
|
191 | this.reportError(this.fatalError);
|
192 | void this.dispose();
|
193 | break;
|
194 | default:
|
195 | this.logUnidentifiedMessage(message);
|
196 | break;
|
197 | }
|
198 | });
|
199 | }
|
200 |
|
201 | private listenToStdoutAndStderr() {
|
202 | const handleData = (builder: StringBuilder) => (data: Buffer | string) => {
|
203 | const output = data.toString();
|
204 | builder.append(output);
|
205 | if (this.log.isTraceEnabled()) {
|
206 | this.log.trace(output);
|
207 | }
|
208 | };
|
209 |
|
210 | if (this.worker.stdout) {
|
211 | this.worker.stdout.on('data', handleData(this.stdoutBuilder));
|
212 | }
|
213 |
|
214 | if (this.worker.stderr) {
|
215 | this.worker.stderr.on('data', handleData(this.stderrBuilder));
|
216 | }
|
217 | }
|
218 |
|
219 | public get stdout(): string {
|
220 | return this.stdoutBuilder.toString();
|
221 | }
|
222 |
|
223 | public get stderr(): string {
|
224 | return this.stderrBuilder.toString();
|
225 | }
|
226 |
|
227 | private reportError(error: Error) {
|
228 | const onGoingWorkerTasks = this.workerTasks.filter((task) => !task.isCompleted);
|
229 | if (!this.initTask.isCompleted) {
|
230 | onGoingWorkerTasks.push(this.initTask);
|
231 | }
|
232 | if (onGoingWorkerTasks.length) {
|
233 | onGoingWorkerTasks.forEach((task) => task.reject(error));
|
234 | }
|
235 | }
|
236 |
|
237 | private readonly handleUnexpectedExit = (code: number, signal: string) => {
|
238 | this.isDisposed = true;
|
239 | const output = StringBuilder.concat(this.stderrBuilder, this.stdoutBuilder);
|
240 | if (processOutOfMemory()) {
|
241 | const oom = new OutOfMemoryError(this.worker.pid, code);
|
242 | this.fatalError = oom;
|
243 | this.log.warn(`Child process [pid ${oom.pid}] ran out of memory. Stdout and stderr are logged on debug level.`);
|
244 | this.log.debug(stdoutAndStderr());
|
245 | } else {
|
246 | this.fatalError = new ChildProcessCrashedError(
|
247 | this.worker.pid,
|
248 | `Child process [pid ${this.worker.pid}] exited unexpectedly with exit code ${code} (${signal || 'without signal'}). ${stdoutAndStderr()}`,
|
249 | code,
|
250 | signal
|
251 | );
|
252 | this.log.warn(this.fatalError.message, this.fatalError);
|
253 | }
|
254 |
|
255 | this.reportError(this.fatalError);
|
256 |
|
257 | function processOutOfMemory() {
|
258 | return output.includes('JavaScript heap out of memory') || output.includes('FatalProcessOutOfMemory');
|
259 | }
|
260 |
|
261 | function stdoutAndStderr() {
|
262 | if (output.length) {
|
263 | return `Last part of stdout and stderr was:${os.EOL}${padLeft(output)}`;
|
264 | } else {
|
265 | return 'Stdout and stderr were empty.';
|
266 | }
|
267 | }
|
268 | };
|
269 |
|
270 | private readonly handleError = (error: Error) => {
|
271 | if (this.innerProcessIsCrashed(error)) {
|
272 | this.log.warn(`Child process [pid ${this.worker.pid}] has crashed. See other warning messages for more info.`, error);
|
273 | this.reportError(
|
274 | new ChildProcessCrashedError(this.worker.pid, `Child process [pid ${this.worker.pid}] has crashed`, undefined, undefined, error)
|
275 | );
|
276 | } else {
|
277 | this.reportError(error);
|
278 | }
|
279 | };
|
280 |
|
281 | private innerProcessIsCrashed(error: Error) {
|
282 | return isErrnoException(error) && (error.code === BROKEN_PIPE_ERROR_CODE || error.code === IPC_CHANNEL_CLOSED_ERROR_CODE);
|
283 | }
|
284 |
|
285 | public async dispose(): Promise<void> {
|
286 | if (!this.isDisposed) {
|
287 | this.worker.removeListener('close', this.handleUnexpectedExit);
|
288 | this.isDisposed = true;
|
289 | this.log.debug('Disposing of worker process %s', this.worker.pid);
|
290 | this.disposeTask = new ExpirableTask(TIMEOUT_FOR_DISPOSE);
|
291 | this.send({ kind: WorkerMessageKind.Dispose });
|
292 | try {
|
293 | await this.disposeTask.promise;
|
294 | } finally {
|
295 | this.log.debug('Kill %s', this.worker.pid);
|
296 | await objectUtils.kill(this.worker.pid);
|
297 | }
|
298 | }
|
299 | }
|
300 |
|
301 | private logUnidentifiedMessage(message: never) {
|
302 | this.log.error(`Received unidentified message ${message}`);
|
303 | }
|
304 | }
|