Skip to content

Commit d5aa34c

Browse files
committed
fix(node-runtime-worker-thread): Better clean-up for the child process spawner method
1 parent 01c9104 commit d5aa34c

File tree

6 files changed

+100
-47
lines changed

6 files changed

+100
-47
lines changed

packages/node-runtime-worker-thread/src/child-process-proxy.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/* istanbul ignore file */
2+
/* ^^^ we test the dist directly, so isntanbul can't calculate the coverage correctly */
3+
14
/**
25
* This proxy is needed as a workaround for the old electron verison "bug" where
36
* due to the electron runtime being a chromium, not just node (even with

packages/node-runtime-worker-thread/src/index.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import chai, { expect } from 'chai';
22
import sinon from 'sinon';
33
import sinonChai from 'sinon-chai';
4-
import chaiAsPromised from 'chai-as-promised';
54
import { startTestServer } from '../../../testing/integration-testing-hooks';
65
import { WorkerRuntime } from '../dist/index';
76

87
chai.use(sinonChai);
9-
chai.use(chaiAsPromised);
108

119
describe('WorkerRuntime', () => {
1210
describe('evaluate', () => {

packages/node-runtime-worker-thread/src/index.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { once } from 'events';
1+
/* istanbul ignore file */
2+
/* ^^^ we test the dist directly, so isntanbul can't calculate the coverage correctly */
3+
24
import { ChildProcess } from 'child_process';
35
import { MongoClientOptions } from '@mongosh/service-provider-core';
46
import { Runtime } from '@mongosh/browser-runtime-core';
57
import { EvaluationListener } from '@mongosh/shell-evaluator';
6-
import spawnChildFromSource from './spawn-child-from-source';
8+
import spawnChildFromSource, { kill } from './spawn-child-from-source';
79
import { Caller, createCaller, exposeAll, WithClose } from './rpc';
810
import type { WorkerRuntime as WorkerThreadWorkerRuntime } from './worker-runtime';
911
import childProcessProxySrc from 'inline-entry-loader!./child-process-proxy';
@@ -104,13 +106,7 @@ class WorkerRuntime implements Runtime {
104106

105107
async terminate() {
106108
await this.initWorkerPromise;
107-
this.childProcess.kill('SIGTERM');
108-
if (
109-
this.childProcess.exitCode === null &&
110-
this.childProcess.signalCode === null
111-
) {
112-
await once(this.childProcess, 'exit');
113-
}
109+
await kill(this.childProcess);
114110
}
115111
}
116112

packages/node-runtime-worker-thread/src/spawn-child-from-source.spec.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,69 @@
1-
import chai, { expect } from 'chai';
2-
import chaiAsPromised from 'chai-as-promised';
3-
import childProcess from 'child_process';
1+
import { expect } from 'chai';
2+
import childProcess, { ChildProcess } from 'child_process';
43
import { once } from 'events';
54

6-
import spawnChildFromSource from './spawn-child-from-source';
7-
8-
chai.use(chaiAsPromised);
5+
import spawnChildFromSource, { kill } from './spawn-child-from-source';
96

107
describe('spawnChildFromSource', () => {
8+
let spawned: ChildProcess;
9+
10+
afterEach(async() => {
11+
if (spawned) {
12+
await kill(spawned, 'SIGKILL');
13+
spawned = null;
14+
}
15+
});
16+
1117
it('should resolve with a child process', async() => {
12-
const spawned = await spawnChildFromSource('');
18+
spawned = await spawnChildFromSource('');
1319
expect(spawned).to.be.instanceof((childProcess as any).ChildProcess);
14-
spawned.kill('SIGTERM');
1520
});
1621

1722
it('should spawn a process with an ipc channel open', async() => {
18-
const spawned = await spawnChildFromSource(
23+
spawned = await spawnChildFromSource(
1924
'process.on("message", (data) => process.send(data))'
2025
);
2126
spawned.send('Hi!');
2227
const [message] = await once(spawned, 'message');
2328
expect(message).to.equal('Hi!');
24-
spawned.kill('SIGTERM');
2529
});
2630

27-
it('should fail if process exited before successfully starting', () => {
28-
return expect(
29-
spawnChildFromSource(
31+
it('should fail if process exited before successfully starting', async() => {
32+
let err: Error;
33+
34+
try {
35+
spawned = await spawnChildFromSource(
3036
'throw new Error("Whoops!")',
3137
{},
3238
undefined,
3339
'ignore',
3440
'ignore'
35-
)
36-
).to.eventually.be.rejectedWith(
41+
);
42+
} catch (e) {
43+
err = e;
44+
}
45+
46+
expect(err).to.be.instanceof(Error);
47+
expect(err.message).to.match(
3748
/Child process exited with error before starting/
3849
);
3950
});
4051

41-
it('should fail if a timeout exceeded before the process is "ready"', () => {
42-
return expect(
43-
spawnChildFromSource('while(true){}', {}, 200)
44-
).to.eventually.be.rejectedWith(
52+
it('should fail if a timeout exceeded before the process is "ready"', async() => {
53+
let err: Error;
54+
55+
try {
56+
spawned = await spawnChildFromSource(
57+
'let i = 0; while(++i < 10000000000){};',
58+
{},
59+
10
60+
);
61+
} catch (e) {
62+
err = e;
63+
}
64+
65+
expect(err).to.be.instanceof(Error);
66+
expect(err.message).to.match(
4567
/Timed out while waiting for child process to start/
4668
);
4769
});

packages/node-runtime-worker-thread/src/spawn-child-from-source.ts

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,26 @@ import {
66
StdioNull,
77
StdioPipe
88
} from 'child_process';
9+
import { once } from 'events';
10+
11+
export async function kill(
12+
childProcess: ChildProcess,
13+
code: NodeJS.Signals | number = 'SIGTERM'
14+
) {
15+
childProcess.kill(code);
16+
if (childProcess.exitCode === null && childProcess.signalCode === null) {
17+
await once(childProcess, 'exit');
18+
}
19+
}
920

1021
export default function spawnChildFromSource(
1122
src: string,
1223
spawnOptions: SpawnOptionsWithoutStdio = {},
1324
timeoutMs?: number,
1425
_stdout: StdioNull | StdioPipe = 'inherit',
15-
_stderr: StdioNull | StdioPipe = 'inherit',
26+
_stderr: StdioNull | StdioPipe = 'inherit'
1627
): Promise<ChildProcess> {
17-
return new Promise((resolve, reject) => {
28+
return new Promise(async(resolve, reject) => {
1829
const readyToken = Date.now().toString(32);
1930

2031
const childProcess = spawn(process.execPath, {
@@ -23,6 +34,8 @@ export default function spawnChildFromSource(
2334
});
2435

2536
if (!childProcess.stdin) {
37+
await kill(childProcess);
38+
2639
return reject(
2740
new Error("Can't write src to the spawned process, missing stdin")
2841
);
@@ -31,38 +44,56 @@ export default function spawnChildFromSource(
3144
// eslint-disable-next-line prefer-const
3245
let timeoutId: NodeJS.Timeout | null;
3346

34-
const onExit = (exitCode: number | null) => {
47+
function cleanupListeners() {
48+
if (timeoutId) {
49+
clearTimeout(timeoutId);
50+
}
51+
if (childProcess.stdin) {
52+
childProcess.stdin.off('error', onWriteError);
53+
}
54+
childProcess.off('message', onMessage);
55+
childProcess.off('exit', onExit);
56+
}
57+
58+
async function onExit(exitCode: number | null) {
3559
if (exitCode && exitCode > 0) {
60+
cleanupListeners();
3661
reject(new Error('Child process exited with error before starting'));
3762
}
38-
};
63+
}
3964

40-
const onMessage = (data: Serializable) => {
65+
async function onWriteError(error: Error) {
66+
cleanupListeners();
67+
await kill(childProcess);
68+
reject(error);
69+
}
70+
71+
async function onTimeout() {
72+
cleanupListeners();
73+
await kill(childProcess);
74+
reject(
75+
new Error('Timed out while waiting for child process to start')
76+
);
77+
}
78+
79+
function onMessage(data: Serializable) {
4180
if (data === readyToken) {
42-
if (timeoutId) {
43-
clearTimeout(timeoutId);
44-
}
45-
childProcess.off('exit', onExit);
81+
cleanupListeners();
4682
resolve(childProcess);
4783
}
48-
};
84+
}
4985

5086
childProcess.on('message', onMessage);
51-
5287
childProcess.on('exit', onExit);
88+
childProcess.stdin.on('error', onWriteError);
5389

5490
childProcess.stdin.write(src);
5591
childProcess.stdin.write(`;process.send(${JSON.stringify(readyToken)})`);
5692
childProcess.stdin.end();
5793

5894
timeoutId =
5995
timeoutMs !== undefined
60-
? setTimeout(() => {
61-
reject(
62-
new Error('Timed out while waiting for child process to start')
63-
);
64-
childProcess.kill('SIGTERM');
65-
}, timeoutMs)
96+
? setTimeout(onTimeout, timeoutMs)
6697
: null;
6798
});
6899
}

packages/node-runtime-worker-thread/src/worker-runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/* istanbul ignore file */
2+
/* ^^^ we test the dist directly, so isntanbul can't calculate the coverage correctly */
3+
14
import { parentPort, isMainThread } from 'worker_threads';
25
import { Runtime } from '@mongosh/browser-runtime-core';
36
import { ElectronRuntime } from '@mongosh/browser-runtime-electron';

0 commit comments

Comments
 (0)