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 <anna@addaleax.net>
This commit is contained in:
Eric Rannaud 2025-09-19 07:47:31 -07:00 committed by Node.js GitHub Bot
parent cb5c8ecc5a
commit c81b1dff65
3 changed files with 167 additions and 0 deletions

View File

@ -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

View File

@ -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());

View File

@ -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();
}