From c81b1dff65e427cbbb89bcb1b4ac5e5a0cc34b51 Mon Sep 17 00:00:00 2001 From: Eric Rannaud Date: Fri, 19 Sep 2025 07:47:31 -0700 Subject: [PATCH] vm: explain how to share promises between contexts w/ afterEvaluate PR-URL: https://github.com/nodejs/node/pull/59801 Fixes: https://github.com/nodejs/node/issues/59541 Refs: https://issues.chromium.org/issues/441679231 Refs: https://groups.google.com/g/v8-dev/c/YIeRg8CUNS8/m/rEQdFuNZAAAJ Refs: https://tc39.es/ecma262/#sec-newpromiseresolvethenablejob Reviewed-By: Anna Henningsen --- doc/api/vm.md | 63 ++++++++++++++++++ .../parallel/test-vm-module-after-evaluate.js | 38 +++++++++++ .../parallel/test-vm-script-after-evaluate.js | 66 +++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 test/parallel/test-vm-script-after-evaluate.js diff --git a/doc/api/vm.md b/doc/api/vm.md index 81f8c9d1f7..89bca257e4 100644 --- a/doc/api/vm.md +++ b/doc/api/vm.md @@ -1946,6 +1946,68 @@ inside a `vm.Context`, functions passed to them will be added to global queues, which are shared by all contexts. Therefore, callbacks passed to those functions are not controllable through the timeout either. +### When `microtaskMode` is `'afterEvaluate'`, beware sharing Promises between Contexts + +In `'afterEvaluate'` mode, the `Context` has its own microtask queue, separate +from the global microtask queue used by the outer (main) context. While this +mode is necessary to enforce `timeout` and enable `breakOnSigint` with +asynchronous tasks, it also makes sharing promises between contexts challenging. + +In the example below, a promise is created in the inner context and shared with +the outer context. When the outer context `await` on the promise, the execution +flow of the outer context is disrupted in a surprising way: the log statement +is never executed. + +```mjs +import * as vm from 'node:vm'; + +const inner_context = vm.createContext({}, { microtaskMode: 'afterEvaluate' }); + +// runInContext() returns a Promise created in the inner context. +const inner_promise = vm.runInContext( + 'Promise.resolve()', + context, +); + +// As part of performing `await`, the JavaScript runtime must enqueue a task +// on the microtask queue of the context where `inner_promise` was created. +// A task is added on the inner microtask queue, but **it will not be run +// automatically**: this task will remain pending indefinitely. +// +// Since the outer microtask queue is empty, execution in the outer module +// falls through, and the log statement below is never executed. +await inner_promise; + +console.log('this will NOT be printed'); +``` + +To successfully share promises between contexts with different microtask queues, +it is necessary to ensure that tasks on the inner microtask queue will be run +**whenever** the outer context enqueues a task on the inner microtask queue. + +The tasks on the microtask queue of a given context are run whenever +`runInContext()` or `SourceTextModule.evaluate()` are invoked on a script or +module using this context. In our example, the normal execution flow can be +restored by scheduling a second call to `runInContext()` _before_ `await +inner_promise`. + +```mjs +// Schedule `runInContext()` to manually drain the inner context microtask +// queue; it will run after the `await` statement below. +setImmediate(() => { + vm.runInContext('', context); +}); + +await inner_promise; + +console.log('OK'); +``` + +**Note:** Strictly speaking, in this mode, `node:vm` departs from the letter of +the ECMAScript specification for [enqueing jobs][], by allowing asynchronous +tasks from different contexts to run in a different order than they were +enqueued. + ## Support of dynamic `import()` in compilation APIs The following APIs support an `importModuleDynamically` option to enable dynamic @@ -2183,6 +2245,7 @@ const { Script, SyntheticModule } = require('node:vm'); [`vm.runInContext()`]: #vmrunincontextcode-contextifiedobject-options [`vm.runInThisContext()`]: #vmruninthiscontextcode-options [contextified]: #what-does-it-mean-to-contextify-an-object +[enqueing jobs]: https://tc39.es/ecma262/#sec-hostenqueuepromisejob [global object]: https://tc39.es/ecma262/#sec-global-object [indirect `eval()` call]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#direct_and_indirect_eval [origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin diff --git a/test/parallel/test-vm-module-after-evaluate.js b/test/parallel/test-vm-module-after-evaluate.js index 3441048218..b3cde67a50 100644 --- a/test/parallel/test-vm-module-after-evaluate.js +++ b/test/parallel/test-vm-module-after-evaluate.js @@ -14,6 +14,7 @@ const microtaskMode = 'afterEvaluate'; (async () => { const mustNotCall1 = common.mustNotCall(); + const mustNotCall2 = common.mustNotCall(); const mustCall1 = common.mustCall(); const inner = {}; @@ -30,4 +31,41 @@ const microtaskMode = 'afterEvaluate'; // Prior to the fix for Issue 59541, the next statement was never executed. mustCall1(); + + await inner.promise; + + // This is expected: the await statement above enqueues a (thenable job) task + // onto the inner context microtask queue, but it will not be checkpointed, + // therefore we never make progress. + mustNotCall2(); +})().then(common.mustNotCall()); + +(async () => { + const mustNotCall1 = common.mustNotCall(); + const mustCall1 = common.mustCall(); + const mustCall2 = common.mustCall(); + const mustCall3 = common.mustCall(); + + const inner = {}; + + const context = vm.createContext({ inner }, { microtaskMode }); + + const module = new vm.SourceTextModule( + 'inner.promise = Promise.resolve();', + { context }, + ); + + await module.link(mustNotCall1); + await module.evaluate(); + mustCall1(); + + setImmediate(() => { + mustCall2(); + // This will checkpoint the inner context microtask queue, and allow the + // promise from the inner context to be resolved in the outer context. + module.evaluate(); + }); + + await inner.promise; + mustCall3(); })().then(common.mustCall()); diff --git a/test/parallel/test-vm-script-after-evaluate.js b/test/parallel/test-vm-script-after-evaluate.js new file mode 100644 index 0000000000..2d14619bae --- /dev/null +++ b/test/parallel/test-vm-script-after-evaluate.js @@ -0,0 +1,66 @@ +'use strict'; + +// https://github.com/nodejs/node/issues/59541 +// +// Promises created in a context using microtaskMode: "aferEvaluate" (meaning +// it has its own microtask queue), when resolved in the surrounding context, +// will schedule a task back onto the inner context queue. This test checks that +// the async execution progresses normally. + +const common = require('../common'); +const vm = require('vm'); + +const microtaskMode = 'afterEvaluate'; + +(async () => { + const mustNotCall1 = common.mustNotCall(); + + await vm.runInNewContext( + `Promise.resolve()`, + {}, { microtaskMode }); + + // Expected behavior: resolving an promise created in the inner context, from + // the outer context results in the execution flow falling through, unless the + // inner context microtask queue is manually drained, which we don't do here. + mustNotCall1(); +})().then(common.mustNotCall()); + +(async () => { + const mustCall1 = common.mustCall(); + const mustCall2 = common.mustCall(); + const mustCall3 = common.mustCall(); + + // Create a new context. + const context = vm.createContext({}, { microtaskMode }); + + setImmediate(() => { + // This will drain the context microtask queue, after the `await` statement + // below, and allow the promise from the inner context, created below, to be + // resolved in the outer context. + vm.runInContext('', context); + mustCall2(); + }); + + const inner_promise = vm.runInContext( + `Promise.resolve()`, + context); + mustCall1(); + + await inner_promise; + mustCall3(); +})().then(common.mustCall()); + +{ + const mustNotCall1 = common.mustNotCall(); + const mustCall1 = common.mustCall(); + + const context = vm.createContext({ setImmediate, mustNotCall1 }, { microtaskMode }); + + // setImmediate() will be run after runInContext() returns, and since the + // anonymous function passed to `then` is defined in the inner context, the + // thenable job task will be enqueued on the inner context microtask queue, + // but at this point, it will not be drained automatically. + vm.runInContext(`new Promise(setImmediate).then(() => mustNotCall1())`, context); + + mustCall1(); +}