Skip to content

Commit 5bf21a4

Browse files
ericrannaudaduh95
authored andcommitted
vm: explain how to share promises between contexts w/ afterEvaluate
PR-URL: #59801 Fixes: #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 <[email protected]>
1 parent 312b33a commit 5bf21a4

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed

doc/api/vm.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,6 +1815,68 @@ inside a `vm.Context`, functions passed to them will be added to global queues,
18151815
which are shared by all contexts. Therefore, callbacks passed to those functions
18161816
are not controllable through the timeout either.
18171817

1818+
### When `microtaskMode` is `'afterEvaluate'`, beware sharing Promises between Contexts
1819+
1820+
In `'afterEvaluate'` mode, the `Context` has its own microtask queue, separate
1821+
from the global microtask queue used by the outer (main) context. While this
1822+
mode is necessary to enforce `timeout` and enable `breakOnSigint` with
1823+
asynchronous tasks, it also makes sharing promises between contexts challenging.
1824+
1825+
In the example below, a promise is created in the inner context and shared with
1826+
the outer context. When the outer context `await` on the promise, the execution
1827+
flow of the outer context is disrupted in a surprising way: the log statement
1828+
is never executed.
1829+
1830+
```mjs
1831+
import * as vm from 'node:vm';
1832+
1833+
const inner_context = vm.createContext({}, { microtaskMode: 'afterEvaluate' });
1834+
1835+
// runInContext() returns a Promise created in the inner context.
1836+
const inner_promise = vm.runInContext(
1837+
'Promise.resolve()',
1838+
context,
1839+
);
1840+
1841+
// As part of performing `await`, the JavaScript runtime must enqueue a task
1842+
// on the microtask queue of the context where `inner_promise` was created.
1843+
// A task is added on the inner microtask queue, but **it will not be run
1844+
// automatically**: this task will remain pending indefinitely.
1845+
//
1846+
// Since the outer microtask queue is empty, execution in the outer module
1847+
// falls through, and the log statement below is never executed.
1848+
await inner_promise;
1849+
1850+
console.log('this will NOT be printed');
1851+
```
1852+
1853+
To successfully share promises between contexts with different microtask queues,
1854+
it is necessary to ensure that tasks on the inner microtask queue will be run
1855+
**whenever** the outer context enqueues a task on the inner microtask queue.
1856+
1857+
The tasks on the microtask queue of a given context are run whenever
1858+
`runInContext()` or `SourceTextModule.evaluate()` are invoked on a script or
1859+
module using this context. In our example, the normal execution flow can be
1860+
restored by scheduling a second call to `runInContext()` _before_ `await
1861+
inner_promise`.
1862+
1863+
```mjs
1864+
// Schedule `runInContext()` to manually drain the inner context microtask
1865+
// queue; it will run after the `await` statement below.
1866+
setImmediate(() => {
1867+
vm.runInContext('', context);
1868+
});
1869+
1870+
await inner_promise;
1871+
1872+
console.log('OK');
1873+
```
1874+
1875+
**Note:** Strictly speaking, in this mode, `node:vm` departs from the letter of
1876+
the ECMAScript specification for [enqueing jobs][], by allowing asynchronous
1877+
tasks from different contexts to run in a different order than they were
1878+
enqueued.
1879+
18181880
## Support of dynamic `import()` in compilation APIs
18191881

18201882
The following APIs support an `importModuleDynamically` option to enable dynamic
@@ -2048,6 +2110,7 @@ const { Script, SyntheticModule } = require('node:vm');
20482110
[`vm.runInContext()`]: #vmrunincontextcode-contextifiedobject-options
20492111
[`vm.runInThisContext()`]: #vmruninthiscontextcode-options
20502112
[contextified]: #what-does-it-mean-to-contextify-an-object
2113+
[enqueing jobs]: https://tc39.es/ecma262/#sec-hostenqueuepromisejob
20512114
[global object]: https://tc39.es/ecma262/#sec-global-object
20522115
[indirect `eval()` call]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#direct_and_indirect_eval
20532116
[origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin

test/parallel/test-vm-module-after-evaluate.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const microtaskMode = 'afterEvaluate';
1414

1515
(async () => {
1616
const mustNotCall1 = common.mustNotCall();
17+
const mustNotCall2 = common.mustNotCall();
1718
const mustCall1 = common.mustCall();
1819

1920
const inner = {};
@@ -30,4 +31,41 @@ const microtaskMode = 'afterEvaluate';
3031

3132
// Prior to the fix for Issue 59541, the next statement was never executed.
3233
mustCall1();
34+
35+
await inner.promise;
36+
37+
// This is expected: the await statement above enqueues a (thenable job) task
38+
// onto the inner context microtask queue, but it will not be checkpointed,
39+
// therefore we never make progress.
40+
mustNotCall2();
41+
})().then(common.mustNotCall());
42+
43+
(async () => {
44+
const mustNotCall1 = common.mustNotCall();
45+
const mustCall1 = common.mustCall();
46+
const mustCall2 = common.mustCall();
47+
const mustCall3 = common.mustCall();
48+
49+
const inner = {};
50+
51+
const context = vm.createContext({ inner }, { microtaskMode });
52+
53+
const module = new vm.SourceTextModule(
54+
'inner.promise = Promise.resolve();',
55+
{ context },
56+
);
57+
58+
await module.link(mustNotCall1);
59+
await module.evaluate();
60+
mustCall1();
61+
62+
setImmediate(() => {
63+
mustCall2();
64+
// This will checkpoint the inner context microtask queue, and allow the
65+
// promise from the inner context to be resolved in the outer context.
66+
module.evaluate();
67+
});
68+
69+
await inner.promise;
70+
mustCall3();
3371
})().then(common.mustCall());
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use strict';
2+
3+
// https://github.com/nodejs/node/issues/59541
4+
//
5+
// Promises created in a context using microtaskMode: "aferEvaluate" (meaning
6+
// it has its own microtask queue), when resolved in the surrounding context,
7+
// will schedule a task back onto the inner context queue. This test checks that
8+
// the async execution progresses normally.
9+
10+
const common = require('../common');
11+
const vm = require('vm');
12+
13+
const microtaskMode = 'afterEvaluate';
14+
15+
(async () => {
16+
const mustNotCall1 = common.mustNotCall();
17+
18+
await vm.runInNewContext(
19+
`Promise.resolve()`,
20+
{}, { microtaskMode });
21+
22+
// Expected behavior: resolving an promise created in the inner context, from
23+
// the outer context results in the execution flow falling through, unless the
24+
// inner context microtask queue is manually drained, which we don't do here.
25+
mustNotCall1();
26+
})().then(common.mustNotCall());
27+
28+
(async () => {
29+
const mustCall1 = common.mustCall();
30+
const mustCall2 = common.mustCall();
31+
const mustCall3 = common.mustCall();
32+
33+
// Create a new context.
34+
const context = vm.createContext({}, { microtaskMode });
35+
36+
setImmediate(() => {
37+
// This will drain the context microtask queue, after the `await` statement
38+
// below, and allow the promise from the inner context, created below, to be
39+
// resolved in the outer context.
40+
vm.runInContext('', context);
41+
mustCall2();
42+
});
43+
44+
const inner_promise = vm.runInContext(
45+
`Promise.resolve()`,
46+
context);
47+
mustCall1();
48+
49+
await inner_promise;
50+
mustCall3();
51+
})().then(common.mustCall());
52+
53+
{
54+
const mustNotCall1 = common.mustNotCall();
55+
const mustCall1 = common.mustCall();
56+
57+
const context = vm.createContext({ setImmediate, mustNotCall1 }, { microtaskMode });
58+
59+
// setImmediate() will be run after runInContext() returns, and since the
60+
// anonymous function passed to `then` is defined in the inner context, the
61+
// thenable job task will be enqueued on the inner context microtask queue,
62+
// but at this point, it will not be drained automatically.
63+
vm.runInContext(`new Promise(setImmediate).then(() => mustNotCall1())`, context);
64+
65+
mustCall1();
66+
}

0 commit comments

Comments
 (0)