UNPKG

10.6 kBPlain TextView Raw
1import childProcess from 'child_process';
2import os from 'os';
3import { fileURLToPath, URL } from 'url';
4
5import { FileDescriptions, StrykerOptions } from '@stryker-mutator/api/core';
6import { isErrnoException, Task, ExpirableTask, StrykerError } from '@stryker-mutator/util';
7import log4js from 'log4js';
8import { Disposable, InjectableClass, InjectionToken } from 'typed-inject';
9
10import { LoggingClientContext } from '../logging/index.js';
11import { objectUtils } from '../utils/object-utils.js';
12import { StringBuilder } from '../utils/string-builder.js';
13import { deserialize, padLeft, serialize } from '../utils/string-utils.js';
14
15import { ChildProcessCrashedError } from './child-process-crashed-error.js';
16import { InitMessage, ParentMessage, ParentMessageKind, WorkerMessage, WorkerMessageKind } from './message-protocol.js';
17import { OutOfMemoryError } from './out-of-memory-error.js';
18import { ChildProcessContext } from './child-process-proxy-worker.js';
19import { IdGenerator } from './id-generator.js';
20
21type Func<TS extends any[], R> = (...args: TS) => R;
22
23type PromisifiedFunc<TS extends any[], R> = (...args: TS) => Promise<R>;
24
25export 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
29const BROKEN_PIPE_ERROR_CODE = 'EPIPE';
30const IPC_CHANNEL_CLOSED_ERROR_CODE = 'ERR_IPC_CHANNEL_CLOSED';
31const TIMEOUT_FOR_DISPOSE = 2000;
32
33export 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 // Listen to `close`, not `exit`, see https://github.com/stryker-mutator/stryker-js/issues/1634
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 * @description Creates a proxy where each function of the object created using the constructorFunction arg is ran inside of a child process
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 // This proxy is a genuine javascript `Proxy` class
125 // More info: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
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 // Workaround, because of a race condition more prominent in native ESM node modules
168 // Fix has landed in v17.4.0 🎉, but we need this workaround for now.
169 // See https://github.com/nodejs/node/issues/41134
170 this.send(this.initMessage);
171 break;
172 case ParentMessageKind.Initialized:
173 this.initTask.resolve(undefined);
174 break;
175 case ParentMessageKind.CallResult:
176 // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
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}