Skip to content

Commit 4101a73

Browse files
committed
vm: fix breakOnSigint race in Module.evaluate()
vm.Script's runInContext()/runInThisContext() temporarily remove the process's own SIGINT listeners while running with breakOnSigint: true, since the native watchdog installed for that call consumes the signal to interrupt execution, and the process's own listeners would race with it. vm.Module.prototype.evaluate() accepts the same breakOnSigint option and installs the same native watchdog, but never got the matching guard. Move the existing helper into a shared location and apply it to Module.prototype.evaluate() too. Verified the new test fails against the unfixed code (child process killed by raw SIGINT) and passes with the fix. Signed-off-by: agape1225 <49804691+agape1225@users.noreply.github.com>
1 parent 2bcc5dd commit 4101a73

4 files changed

Lines changed: 122 additions & 19 deletions

File tree

‎lib/internal/vm.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
'use strict';
22

33
const {
4+
ArrayPrototypeForEach,
45
FunctionPrototypeCall,
6+
ReflectApply,
57
Symbol,
68
} = primordials;
79

@@ -111,6 +113,34 @@ function registerImportModuleDynamically(referrer, importModuleDynamically) {
111113
});
112114
}
113115

116+
/**
117+
* Temporarily removes all SIGINT listeners before invoking `fn`, re-attaching
118+
* them afterwards. Used to run code with `breakOnSigint: true`, where a
119+
* native SigintWatchdog is installed to terminate execution on SIGINT: if the
120+
* process's own SIGINT listeners stayed attached, they would race with that
121+
* watchdog over the same signal instead of running normally once the
122+
* watchdog is gone.
123+
* @param {Function} fn - The function to invoke with SIGINT listeners removed.
124+
* @param {object} thisArg - The `this` value to invoke `fn` with.
125+
* @param {Array} argsArray - The arguments to invoke `fn` with.
126+
* @returns {any}
127+
*/
128+
function sigintHandlersWrap(fn, thisArg, argsArray) {
129+
const sigintListeners = process.rawListeners('SIGINT');
130+
131+
process.removeAllListeners('SIGINT');
132+
133+
try {
134+
return ReflectApply(fn, thisArg, argsArray);
135+
} finally {
136+
// Add using the public methods so that the `newListener` handler of
137+
// process can re-attach the listeners.
138+
ArrayPrototypeForEach(sigintListeners, (listener) => {
139+
process.addListener('SIGINT', listener);
140+
});
141+
}
142+
}
143+
114144
/**
115145
* Compiles a function from the given code string.
116146
* @param {string} code - The code string to compile.
@@ -234,4 +264,5 @@ module.exports = {
234264
makeContextifyScript,
235265
registerImportModuleDynamically,
236266
runScriptInThisContext,
267+
sigintHandlersWrap,
237268
};

‎lib/internal/vm/module.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const kContext = Symbol('kContext');
9292
const kPerContextModuleId = Symbol('kPerContextModuleId');
9393
const kLink = Symbol('kLink');
9494

95-
const { isContext } = require('internal/vm');
95+
const { isContext, sigintHandlersWrap } = require('internal/vm');
9696

9797
function isModule(object) {
9898
if (typeof object !== 'object' || object === null || !ObjectPrototypeHasOwnProperty(object, kWrap)) {
@@ -230,6 +230,10 @@ class Module {
230230
'must be one of linked, evaluated, or errored',
231231
);
232232
}
233+
if (breakOnSigint && process.listenerCount('SIGINT') > 0) {
234+
return sigintHandlersWrap(
235+
this[kWrap].evaluate, this[kWrap], [timeout, breakOnSigint]);
236+
}
233237
return this[kWrap].evaluate(timeout, breakOnSigint);
234238
} catch (e) {
235239
return PromiseReject(e);

‎lib/vm.js‎

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const {
6363
internalCompileFunction,
6464
isContext: _isContext,
6565
registerImportModuleDynamically,
66+
sigintHandlersWrap,
6667
} = require('internal/vm');
6768
const {
6869
vm_dynamic_import_main_context_default,
@@ -270,24 +271,6 @@ function createScript(code, options) {
270271
return new Script(code, options);
271272
}
272273

273-
// Remove all SIGINT listeners and re-attach them after the wrapped function
274-
// has executed, so that caught SIGINT are handled by the listeners again.
275-
function sigintHandlersWrap(fn, thisArg, argsArray) {
276-
const sigintListeners = process.rawListeners('SIGINT');
277-
278-
process.removeAllListeners('SIGINT');
279-
280-
try {
281-
return ReflectApply(fn, thisArg, argsArray);
282-
} finally {
283-
// Add using the public methods so that the `newListener` handler of
284-
// process can re-attach the listeners.
285-
ArrayPrototypeForEach(sigintListeners, (listener) => {
286-
process.addListener('SIGINT', listener);
287-
});
288-
}
289-
}
290-
291274
function runInContext(code, contextifiedObject, options) {
292275
validateContext(contextifiedObject);
293276
if (typeof options === 'string') {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict';
2+
const common = require('../common');
3+
if (common.isWindows) {
4+
// No way to send CTRL_C_EVENT to processes from JS right now.
5+
common.skip('platform not supported');
6+
}
7+
8+
// Tests that vm.Module.prototype.evaluate({ breakOnSigint: true }) does not
9+
// race with the process's own pre-existing SIGINT listeners, the same way
10+
// vm.Script's runInThisContext()/runInContext() already don't (see
11+
// test-vm-sigint-existing-handler.js).
12+
13+
const assert = require('assert');
14+
const vm = require('vm');
15+
const spawn = require('child_process').spawn;
16+
17+
if (process.argv[2] === 'child') {
18+
let firstHandlerCalled = 0;
19+
process.on('SIGINT', common.mustCall(() => {
20+
firstHandlerCalled++;
21+
// Handler attached _before_ execution.
22+
}, 2));
23+
24+
let onceHandlerCalled = 0;
25+
process.once('SIGINT', common.mustCall(() => {
26+
onceHandlerCalled++;
27+
// Handler attached _before_ execution.
28+
}));
29+
30+
(async () => {
31+
const context = vm.createContext({ process });
32+
const mod = new vm.SourceTextModule(
33+
'process.send("ready"); while (true) {}',
34+
{ context });
35+
await mod.link(() => {});
36+
37+
await assert.rejects(
38+
mod.evaluate({ breakOnSigint: true }),
39+
{ code: 'ERR_SCRIPT_EXECUTION_INTERRUPTED' },
40+
);
41+
assert.strictEqual(firstHandlerCalled, 0);
42+
assert.strictEqual(onceHandlerCalled, 0);
43+
44+
// Keep the process alive for a while so the second SIGINT can be caught.
45+
const timeout = setTimeout(() => {}, 1000);
46+
47+
let afterHandlerCalled = 0;
48+
process.on('SIGINT', common.mustCall(() => {
49+
// Handler attached _after_ execution.
50+
if (afterHandlerCalled++ === 0) {
51+
// The first time it just bounces back to check that the `once()`
52+
// handler is not called the second time.
53+
assert.strictEqual(firstHandlerCalled, 1);
54+
assert.strictEqual(onceHandlerCalled, 1);
55+
process.send('again');
56+
return;
57+
}
58+
59+
assert.strictEqual(onceHandlerCalled, 1);
60+
assert.strictEqual(firstHandlerCalled, 2);
61+
timeout.unref();
62+
}, 2));
63+
64+
process.send('again');
65+
})().then(common.mustCall());
66+
67+
return;
68+
}
69+
70+
const child = spawn(process.execPath, [
71+
'--experimental-vm-modules', __filename, 'child',
72+
], {
73+
stdio: [null, 'inherit', 'inherit', 'ipc'],
74+
});
75+
76+
child.on('message', common.mustCall(() => {
77+
// First kill() breaks the while(true) loop, second one invokes the real
78+
// signal handlers.
79+
process.kill(child.pid, 'SIGINT');
80+
}, 3));
81+
82+
child.on('close', common.mustCall((code, signal) => {
83+
assert.strictEqual(signal, null);
84+
assert.strictEqual(code, 0);
85+
}));

0 commit comments

Comments
 (0)