Skip to content

Commit 38bf955

Browse files
joyeecheungnodejs-github-bot
authored andcommitted
vm: make vm.Module.evaluate() conditionally synchronous
- Make sure that the vm.Module.evaluate() method is conditionally synchronous based on the specification. Previously, the returned promise is unconditionally pending (even for synthetic modules and source text modules without top-level await) instead of immediately fulfilled, making it harder to debug as it deviates from the specified behavior. - Clarify the synchronicity of this method in the documentation - Add more tests for the synchronicity of this method. PR-URL: #60205 Refs: #59656 Refs: #37648 Reviewed-By: Chengzhong Wu <[email protected]>
1 parent 8b52c89 commit 38bf955

File tree

6 files changed

+390
-30
lines changed

6 files changed

+390
-30
lines changed

doc/api/vm.md

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -618,19 +618,47 @@ in the ECMAScript specification.
618618
work after that. **Default:** `false`.
619619
* Returns: {Promise} Fulfills with `undefined` upon success.
620620

621-
Evaluate the module.
621+
Evaluate the module and its depenendencies. Corresponds to the [Evaluate() concrete method][] field of
622+
[Cyclic Module Record][]s in the ECMAScript specification.
622623

623-
This must be called after the module has been linked; otherwise it will reject.
624-
It could be called also when the module has already been evaluated, in which
625-
case it will either do nothing if the initial evaluation ended in success
626-
(`module.status` is `'evaluated'`) or it will re-throw the exception that the
627-
initial evaluation resulted in (`module.status` is `'errored'`).
624+
If the module is a `vm.SourceTextModule`, `evaluate()` must be called after the module has been instantiated;
625+
otherwise `evaluate()` will return a rejected promise.
628626

629-
This method cannot be called while the module is being evaluated
630-
(`module.status` is `'evaluating'`).
627+
For a `vm.SourceTextModule`, the promise returned by `evaluate()` may be fulfilled either
628+
synchronously or asynchronously:
631629

632-
Corresponds to the [Evaluate() concrete method][] field of [Cyclic Module
633-
Record][]s in the ECMAScript specification.
630+
1. If the `vm.SourceTextModule` has no top-level `await` in itself or any of its dependencies, the promise will be
631+
fulfilled _synchronously_ after the module and all its dependencies have been evaluated.
632+
1. If the evaluation succeeds, the promise will be _synchronously_ resolved to `undefined`.
633+
2. If the evaluation results in an exception, the promise will be _synchronously_ rejected with the exception
634+
that causes the evaluation to fail, which is the same as `module.error`.
635+
2. If the `vm.SourceTextModule` has top-level `await` in itself or any of its dependencies, the promise will be
636+
fulfilled _asynchronously_ after the module and all its dependencies have been evaluated.
637+
1. If the evaluation succeeds, the promise will be _asynchronously_ resolved to `undefined`.
638+
2. If the evaluation results in an exception, the promise will be _asynchronously_ rejected with the exception
639+
that causes the evaluation to fail.
640+
641+
If the module is a `vm.SyntheticModule`, `evaluate()` always returns a promise that fulfills synchronously, see
642+
the specification of [Evaluate() of a Synthetic Module Record][]:
643+
644+
1. If the `evaluateCallback` passed to its constructor throws an exception synchronously, `evaluate()` returns
645+
a promise that will be synchronously rejected with that exception.
646+
2. If the `evaluateCallback` does not throw an exception, `evaluate()` returns a promise that will be
647+
synchronously resolved to `undefined`.
648+
649+
The `evaluateCallback` of a `vm.SyntheticModule` is executed synchronously within the `evaluate()` call, and its
650+
return value is discarded. This means if `evaluateCallback` is an asynchronous function, the promise returned by
651+
`evaluate()` will not reflect its asynchronous behavior, and any rejections from an asynchronous
652+
`evaluateCallback` will be lost.
653+
654+
`evaluate()` could also be called again after the module has already been evaluated, in which case:
655+
656+
1. If the initial evaluation ended in success (`module.status` is `'evaluated'`), it will do nothing
657+
and return a promise that resolves to `undefined`.
658+
2. If the initial evaluation resulted in an exception (`module.status` is `'errored'`), it will re-reject
659+
the exception that the initial evaluation resulted in.
660+
661+
This method cannot be called while the module is being evaluated (`module.status` is `'evaluating'`).
634662

635663
### `module.identifier`
636664

@@ -2221,6 +2249,7 @@ const { Script, SyntheticModule } = require('node:vm');
22212249
[Cyclic Module Record]: https://tc39.es/ecma262/#sec-cyclic-module-records
22222250
[ECMAScript Module Loader]: esm.md#modules-ecmascript-modules
22232251
[Evaluate() concrete method]: https://tc39.es/ecma262/#sec-moduleevaluation
2252+
[Evaluate() of a Synthetic Module Record]: https://tc39.es/ecma262/#sec-smr-Evaluate
22242253
[FinishLoadingImportedModule]: https://tc39.es/ecma262/#sec-FinishLoadingImportedModule
22252254
[GetModuleNamespace]: https://tc39.es/ecma262/#sec-getmodulenamespace
22262255
[HostLoadImportedModule]: https://tc39.es/ecma262/#sec-HostLoadImportedModule

lib/internal/vm/module.js

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const {
1313
ObjectPrototypeHasOwnProperty,
1414
ObjectSetPrototypeOf,
1515
PromisePrototypeThen,
16+
PromiseReject,
1617
PromiseResolve,
1718
ReflectApply,
1819
SafePromiseAllReturnArrayLike,
@@ -208,27 +209,31 @@ class Module {
208209
this[kWrap].instantiate();
209210
}
210211

211-
async evaluate(options = kEmptyObject) {
212-
validateThisInternalField(this, kWrap, 'Module');
213-
validateObject(options, 'options');
214-
215-
let timeout = options.timeout;
216-
if (timeout === undefined) {
217-
timeout = -1;
218-
} else {
219-
validateUint32(timeout, 'options.timeout', true);
220-
}
221-
const { breakOnSigint = false } = options;
222-
validateBoolean(breakOnSigint, 'options.breakOnSigint');
223-
const status = this[kWrap].getStatus();
224-
if (status !== kInstantiated &&
225-
status !== kEvaluated &&
226-
status !== kErrored) {
227-
throw new ERR_VM_MODULE_STATUS(
228-
'must be one of linked, evaluated, or errored',
229-
);
212+
evaluate(options = kEmptyObject) {
213+
try {
214+
validateThisInternalField(this, kWrap, 'Module');
215+
validateObject(options, 'options');
216+
217+
let timeout = options.timeout;
218+
if (timeout === undefined) {
219+
timeout = -1;
220+
} else {
221+
validateUint32(timeout, 'options.timeout', true);
222+
}
223+
const { breakOnSigint = false } = options;
224+
validateBoolean(breakOnSigint, 'options.breakOnSigint');
225+
const status = this[kWrap].getStatus();
226+
if (status !== kInstantiated &&
227+
status !== kEvaluated &&
228+
status !== kErrored) {
229+
throw new ERR_VM_MODULE_STATUS(
230+
'must be one of linked, evaluated, or errored',
231+
);
232+
}
233+
return this[kWrap].evaluate(timeout, breakOnSigint);
234+
} catch (e) {
235+
return PromiseReject(e);
230236
}
231-
await this[kWrap].evaluate(timeout, breakOnSigint);
232237
}
233238

234239
[customInspectSymbol](depth, options) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Flags: --experimental-vm-modules
2+
'use strict';
3+
4+
// This tests the result of evaluating a vm.SourceTextModule.
5+
const common = require('../common');
6+
7+
const assert = require('assert');
8+
// To make testing easier we just use the public inspect API. If the output format
9+
// changes, update this test accordingly.
10+
const { inspect } = require('util');
11+
const vm = require('vm');
12+
13+
globalThis.callCount = {};
14+
common.allowGlobals(globalThis.callCount);
15+
16+
// Synchronous error during evaluation results in a synchronously rejected promise.
17+
{
18+
globalThis.callCount.syncError = 0;
19+
const mod = new vm.SourceTextModule(`
20+
globalThis.callCount.syncError++;
21+
throw new Error("synchronous source text module");
22+
export const a = 1;
23+
`);
24+
mod.linkRequests([]);
25+
mod.instantiate();
26+
const promise = mod.evaluate();
27+
assert.strictEqual(globalThis.callCount.syncError, 1);
28+
assert.match(inspect(promise), /rejected/);
29+
assert(mod.error, 'Expected mod.error to be set');
30+
assert.strictEqual(mod.error.message, 'synchronous source text module');
31+
32+
promise.catch(common.mustCall((err) => {
33+
assert.strictEqual(err, mod.error);
34+
// Calling evaluate() again results in the same rejection synchronously.
35+
const promise2 = mod.evaluate();
36+
assert.match(inspect(promise2), /rejected/);
37+
promise2.catch(common.mustCall((err2) => {
38+
assert.strictEqual(err, err2);
39+
// The module is only evaluated once.
40+
assert.strictEqual(globalThis.callCount.syncError, 1);
41+
}));
42+
}));
43+
}
44+
45+
// Successful evaluation of a module without top-level await results in a
46+
// promise synchronously resolved to undefined.
47+
{
48+
globalThis.callCount.syncNamedExports = 0;
49+
const mod = new vm.SourceTextModule(`
50+
globalThis.callCount.syncNamedExports++;
51+
export const a = 1, b = 2;
52+
`);
53+
mod.linkRequests([]);
54+
mod.instantiate();
55+
const promise = mod.evaluate();
56+
assert.match(inspect(promise), /Promise { undefined }/);
57+
assert.strictEqual(mod.namespace.a, 1);
58+
assert.strictEqual(mod.namespace.b, 2);
59+
assert.strictEqual(globalThis.callCount.syncNamedExports, 1);
60+
promise.then(common.mustCall((value) => {
61+
assert.strictEqual(value, undefined);
62+
63+
// Calling evaluate() again results in the same resolved promise synchronously.
64+
const promise2 = mod.evaluate();
65+
assert.match(inspect(promise2), /Promise { undefined }/);
66+
assert.strictEqual(mod.namespace.a, 1);
67+
assert.strictEqual(mod.namespace.b, 2);
68+
promise2.then(common.mustCall((value) => {
69+
assert.strictEqual(value, undefined);
70+
// The module is only evaluated once.
71+
assert.strictEqual(globalThis.callCount.syncNamedExports, 1);
72+
}));
73+
}));
74+
}
75+
76+
{
77+
globalThis.callCount.syncDefaultExports = 0;
78+
// Modules with either named and default exports have the same behaviors.
79+
const mod = new vm.SourceTextModule(`
80+
globalThis.callCount.syncDefaultExports++;
81+
export default 42;
82+
`);
83+
mod.linkRequests([]);
84+
mod.instantiate();
85+
const promise = mod.evaluate();
86+
assert.match(inspect(promise), /Promise { undefined }/);
87+
assert.strictEqual(mod.namespace.default, 42);
88+
assert.strictEqual(globalThis.callCount.syncDefaultExports, 1);
89+
90+
promise.then(common.mustCall((value) => {
91+
assert.strictEqual(value, undefined);
92+
93+
// Calling evaluate() again results in the same resolved promise synchronously.
94+
const promise2 = mod.evaluate();
95+
assert.match(inspect(promise2), /Promise { undefined }/);
96+
assert.strictEqual(mod.namespace.default, 42);
97+
promise2.then(common.mustCall((value) => {
98+
assert.strictEqual(value, undefined);
99+
// The module is only evaluated once.
100+
assert.strictEqual(globalThis.callCount.syncDefaultExports, 1);
101+
}));
102+
}));
103+
}
104+
105+
// Successful evaluation of a module with top-level await results in a promise
106+
// that is fulfilled asynchronously with undefined.
107+
{
108+
globalThis.callCount.asyncEvaluation = 0;
109+
const mod = new vm.SourceTextModule(`
110+
globalThis.callCount.asyncEvaluation++;
111+
await Promise.resolve();
112+
export const a = 1;
113+
`);
114+
mod.linkRequests([]);
115+
mod.instantiate();
116+
const promise = mod.evaluate();
117+
assert.match(inspect(promise), /<pending>/);
118+
// Accessing the namespace before the promise is fulfilled throws ReferenceError.
119+
assert.throws(() => mod.namespace.a, { name: 'ReferenceError' });
120+
assert.strictEqual(globalThis.callCount.asyncEvaluation, 1);
121+
promise.then(common.mustCall((value) => {
122+
assert.strictEqual(value, undefined);
123+
assert.strictEqual(globalThis.callCount.asyncEvaluation, 1);
124+
125+
// Calling evaluate() again results in a promise synchronously resolved to undefined.
126+
const promise2 = mod.evaluate();
127+
assert.match(inspect(promise2), /Promise { undefined }/);
128+
assert.strictEqual(mod.namespace.a, 1);
129+
promise2.then(common.mustCall((value) => {
130+
assert.strictEqual(value, undefined);
131+
// The module is only evaluated once.
132+
assert.strictEqual(globalThis.callCount.asyncEvaluation, 1);
133+
}));
134+
}));
135+
}
136+
137+
// Rejection of a top-level await promise results in a promise that is
138+
// rejected asynchronously with the same reason.
139+
{
140+
globalThis.callCount.asyncRejection = 0;
141+
const mod = new vm.SourceTextModule(`
142+
globalThis.callCount.asyncRejection++;
143+
await Promise.reject(new Error("asynchronous source text module"));
144+
export const a = 1;
145+
`);
146+
mod.linkRequests([]);
147+
mod.instantiate();
148+
const promise = mod.evaluate();
149+
assert.match(inspect(promise), /<pending>/);
150+
// Accessing the namespace before the promise is fulfilled throws ReferenceError.
151+
assert.throws(() => mod.namespace.a, { name: 'ReferenceError' });
152+
promise.catch(common.mustCall((err) => {
153+
assert.strictEqual(err, mod.error);
154+
assert.strictEqual(err.message, 'asynchronous source text module');
155+
assert.strictEqual(globalThis.callCount.asyncRejection, 1);
156+
157+
// Calling evaluate() again results in a promise synchronously rejected
158+
// with the same reason.
159+
const promise2 = mod.evaluate();
160+
assert.match(inspect(promise2), /rejected/);
161+
promise2.catch(common.mustCall((err2) => {
162+
assert.strictEqual(err, err2);
163+
// The module is only evaluated once.
164+
assert.strictEqual(globalThis.callCount.asyncRejection, 1);
165+
}));
166+
}));
167+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Flags: --experimental-vm-modules
2+
'use strict';
3+
4+
// This tests the result of evaluating a vm.SyntheticModule with an async rejection
5+
// in the evaluation step.
6+
7+
const common = require('../common');
8+
9+
const assert = require('assert');
10+
// To make testing easier we just use the public inspect API. If the output format
11+
// changes, update this test accordingly.
12+
const { inspect } = require('util');
13+
const vm = require('vm');
14+
15+
// The promise _synchronously_ resolves to undefined, because for a synthethic module,
16+
// the evaluation operation can only either resolve or reject immediately.
17+
// In this case, the asynchronously rejected promise can't be handled from the outside,
18+
// so we'll catch it with the isolate-level unhandledRejection handler.
19+
// See https://tc39.es/ecma262/#sec-smr-Evaluate
20+
process.on('unhandledRejection', common.mustCall((err) => {
21+
assert.strictEqual(err.message, 'asynchronous source text module');
22+
}));
23+
24+
const mod = new vm.SyntheticModule(['a'], common.mustCall(async () => {
25+
throw new Error('asynchronous source text module');
26+
}));
27+
28+
const promise = mod.evaluate();
29+
assert.match(inspect(promise), /Promise { undefined }/);
30+
// Accessing the uninitialized export of a synthetic module returns undefined.
31+
assert.strictEqual(mod.namespace.a, undefined);
32+
33+
promise.then(common.mustCall((value) => {
34+
assert.strictEqual(value, undefined);
35+
}));
36+
37+
// Calling evaluate() again results in a promise _synchronously_ resolved to undefined again.
38+
const promise2 = mod.evaluate();
39+
assert.match(inspect(promise2), /Promise { undefined }/);
40+
promise2.then(common.mustCall((value) => {
41+
assert.strictEqual(value, undefined);
42+
}));

0 commit comments

Comments
 (0)