Skip to content

Commit de697ab

Browse files
committed
module: handle cached linked async jobs in require(esm)
This handles two cases caused by using Promise.all() with multiple dynamic import() that can make an asynchronously linked module job that has finished/failed linking but has not yet started actual evaluation appear in the load cache when another require request is in turn to handle it. - When the cached async job has finished linking but has not started its evaluation because the async run() task would be later in line, start the evaluation from require() with runSync(). - When the cached async job have already encountered a linking error that gets wrapped into a rejection, but is still later in line to throw on it, just unwrap and throw the linking error from require().
1 parent 8c2df73 commit de697ab

File tree

3 files changed

+100
-48
lines changed

3 files changed

+100
-48
lines changed

lib/internal/modules/esm/loader.js

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ const {
1818
kIsExecuting,
1919
kRequiredModuleSymbol,
2020
} = require('internal/modules/cjs/loader');
21-
2221
const { imported_cjs_symbol } = internalBinding('symbols');
2322

2423
const assert = require('internal/assert');
@@ -38,7 +37,13 @@ const {
3837
forceDefaultLoader,
3938
} = require('internal/modules/esm/utils');
4039
const { kImplicitTypeAttribute } = require('internal/modules/esm/assert');
41-
const { ModuleWrap, kEvaluating, kEvaluated } = internalBinding('module_wrap');
40+
const {
41+
ModuleWrap,
42+
kEvaluating,
43+
kEvaluated,
44+
kInstantiated,
45+
throwIfPromiseRejected,
46+
} = internalBinding('module_wrap');
4247
const {
4348
urlToFilename,
4449
} = require('internal/modules/helpers');
@@ -53,6 +58,10 @@ let defaultResolve, defaultLoad, defaultLoadSync, importMetaInitializer;
5358
const { tracingChannel } = require('diagnostics_channel');
5459
const onImport = tracingChannel('module.import');
5560

61+
let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
62+
debug = fn;
63+
});
64+
5665
/**
5766
* @typedef {import('./hooks.js').HooksProxy} HooksProxy
5867
* @typedef {import('./module_job.js').ModuleJobBase} ModuleJobBase
@@ -340,35 +349,58 @@ class ModuleLoader {
340349
// evaluated at this point.
341350
// TODO(joyeecheung): add something similar to CJS loader's requireStack to help
342351
// debugging the the problematic links in the graph for import.
352+
debug('importSyncForRequire', parent?.filename, '->', filename, job);
343353
if (job !== undefined) {
344354
mod[kRequiredModuleSymbol] = job.module;
345355
const parentFilename = urlToFilename(parent?.filename);
346356
// TODO(node:55782): this race may stop to happen when the ESM resolution and loading become synchronous.
357+
let raceMessage = `Cannot require() ES Module ${filename} because it is not yet fully loaded. `;
358+
raceMessage += 'This may be caused by a race condition if the module is simultaneously dynamically ';
359+
raceMessage += 'import()-ed via Promise.all(). Try await-ing the import() sequentially in a loop instead.';
360+
if (parentFilename) {
361+
raceMessage += ` (from ${parentFilename})`;
362+
}
347363
if (!job.module) {
348-
let message = `Cannot require() ES Module ${filename} because it is not yet fully loaded. `;
349-
message += 'This may be caused by a race condition if the module is simultaneously dynamically ';
350-
message += 'import()-ed via Promise.all(). Try await-ing the import() sequentially in a loop instead.';
351-
if (parentFilename) {
352-
message += ` (from ${parentFilename})`;
353-
}
354-
assert(job.module, message);
364+
assert(job.module, raceMessage);
355365
}
356366
if (job.module.async) {
357367
throw new ERR_REQUIRE_ASYNC_MODULE(filename, parentFilename);
358368
}
359-
// job.module may be undefined if it's asynchronously loaded. Which means
360-
// there is likely a cycle.
361-
if (job.module.getStatus() !== kEvaluated) {
362-
let message = `Cannot require() ES Module ${filename} in a cycle.`;
363-
if (parentFilename) {
364-
message += ` (from ${parentFilename})`;
365-
}
366-
message += 'A cycle involving require(esm) is disallowed to maintain ';
367-
message += 'invariants madated by the ECMAScript specification';
368-
message += 'Try making at least part of the dependency in the graph lazily loaded.';
369-
throw new ERR_REQUIRE_CYCLE_MODULE(message);
369+
const status = job.module.getStatus();
370+
debug('Module status', filename, status);
371+
if (status === kEvaluated) {
372+
return { wrap: job.module, namespace: job.module.getNamespaceSync(filename, parentFilename) };
373+
} else if (status === kInstantiated) {
374+
// When it's an async job cached by another import request,
375+
// which has finished linking but has not started its
376+
// evaluation because the async run() task would be later
377+
// in line. Then start the evaluation now with runSync(), which
378+
// is guaranteed to finish by the time the other run() get to it,
379+
// and the other task would just get the cached evaluation results,
380+
// similar to what would happen when both are async.
381+
mod[kRequiredModuleSymbol] = job.module;
382+
const { namespace } = job.runSync(parent);
383+
return { wrap: job.module, namespace: namespace || job.module.getNamespace() };
384+
}
385+
// When the cached async job have already encountered a linking
386+
// error that gets wrapped into a rejection, but is still later
387+
// in line to throw on it, just unwrap and throw the linking error
388+
// from require().
389+
if (job.instantiated) {
390+
throwIfPromiseRejected(job.instantiated);
370391
}
371-
return { wrap: job.module, namespace: job.module.getNamespaceSync(filename, parentFilename) };
392+
if (status !== kEvaluating) {
393+
assert.fail(`Unexpected module status ${status}. ` + raceMessage);
394+
}
395+
let message = `Cannot require() ES Module ${filename} in a cycle.`;
396+
if (parentFilename) {
397+
message += ` (from ${parentFilename})`;
398+
}
399+
message += 'A cycle involving require(esm) is disallowed to maintain ';
400+
message += 'invariants madated by the ECMAScript specification';
401+
message += 'Try making at least part of the dependency in the graph lazily loaded.';
402+
throw new ERR_REQUIRE_CYCLE_MODULE(message);
403+
372404
}
373405
// TODO(joyeecheung): refactor this so that we pre-parse in C++ and hit the
374406
// cache here, or use a carrier object to carry the compiled module script

lib/internal/modules/esm/module_job.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,19 +246,19 @@ class ModuleJob extends ModuleJobBase {
246246
}
247247
}
248248

249-
runSync() {
249+
runSync(parent) {
250250
assert(this.module instanceof ModuleWrap);
251251
if (this.instantiated !== undefined) {
252252
return { __proto__: null, module: this.module };
253253
}
254254

255255
this.module.instantiate();
256256
this.instantiated = PromiseResolve();
257-
const timeout = -1;
258-
const breakOnSigint = false;
259257
setHasStartedUserESMExecution();
260-
this.module.evaluate(timeout, breakOnSigint);
261-
return { __proto__: null, module: this.module };
258+
const filename = urlToFilename(this.url);
259+
const parentFilename = urlToFilename(parent?.filename);
260+
const namespace = this.module.evaluateSync(filename, parentFilename);
261+
return { __proto__: null, module: this.module, namespace };
262262
}
263263

264264
async run(isEntryPoint = false) {

src/module_wrap.cc

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ using v8::Int32;
3636
using v8::Integer;
3737
using v8::Isolate;
3838
using v8::Just;
39+
using v8::JustVoid;
3940
using v8::Local;
4041
using v8::LocalVector;
4142
using v8::Maybe;
@@ -46,6 +47,7 @@ using v8::MicrotaskQueue;
4647
using v8::Module;
4748
using v8::ModuleRequest;
4849
using v8::Name;
50+
using v8::Nothing;
4951
using v8::Null;
5052
using v8::Object;
5153
using v8::ObjectTemplate;
@@ -648,6 +650,43 @@ void ModuleWrap::InstantiateSync(const FunctionCallbackInfo<Value>& args) {
648650
args.GetReturnValue().Set(module->IsGraphAsync());
649651
}
650652

653+
Maybe<void> ThrowIfPromiseRejected(Realm* realm, Local<Promise> promise) {
654+
Isolate* isolate = realm->isolate();
655+
Local<Context> context = realm->context();
656+
if (promise->State() != Promise::PromiseState::kRejected) {
657+
return JustVoid();
658+
}
659+
// The rejected promise is created by V8, so we don't get a chance to mark
660+
// it as resolved before the rejection happens from evaluation. But we can
661+
// tell the promise rejection callback to treat it as a promise rejected
662+
// before handler was added which would remove it from the unhandled
663+
// rejection handling, since we are converting it into an error and throw
664+
// from here directly.
665+
Local<Value> type =
666+
Integer::New(isolate,
667+
static_cast<int32_t>(
668+
PromiseRejectEvent::kPromiseHandlerAddedAfterReject));
669+
Local<Value> args[] = {type, promise, Undefined(isolate)};
670+
if (realm->promise_reject_callback()
671+
->Call(context, Undefined(isolate), arraysize(args), args)
672+
.IsEmpty()) {
673+
return Nothing<void>();
674+
}
675+
Local<Value> exception = promise->Result();
676+
Local<Message> message = Exception::CreateMessage(isolate, exception);
677+
AppendExceptionLine(
678+
realm->env(), exception, message, ErrorHandlingMode::MODULE_ERROR);
679+
isolate->ThrowException(exception);
680+
return Nothing<void>();
681+
}
682+
683+
void ThrowIfPromiseRejected(const FunctionCallbackInfo<Value>& args) {
684+
if (!args[0]->IsPromise()) {
685+
return;
686+
}
687+
ThrowIfPromiseRejected(Realm::GetCurrent(args), args[0].As<Promise>());
688+
}
689+
651690
void ModuleWrap::EvaluateSync(const FunctionCallbackInfo<Value>& args) {
652691
Realm* realm = Realm::GetCurrent(args);
653692
Isolate* isolate = args.GetIsolate();
@@ -672,28 +711,7 @@ void ModuleWrap::EvaluateSync(const FunctionCallbackInfo<Value>& args) {
672711

673712
CHECK(result->IsPromise());
674713
Local<Promise> promise = result.As<Promise>();
675-
if (promise->State() == Promise::PromiseState::kRejected) {
676-
// The rejected promise is created by V8, so we don't get a chance to mark
677-
// it as resolved before the rejection happens from evaluation. But we can
678-
// tell the promise rejection callback to treat it as a promise rejected
679-
// before handler was added which would remove it from the unhandled
680-
// rejection handling, since we are converting it into an error and throw
681-
// from here directly.
682-
Local<Value> type =
683-
Integer::New(isolate,
684-
static_cast<int32_t>(
685-
PromiseRejectEvent::kPromiseHandlerAddedAfterReject));
686-
Local<Value> args[] = {type, promise, Undefined(isolate)};
687-
if (env->promise_reject_callback()
688-
->Call(context, Undefined(isolate), arraysize(args), args)
689-
.IsEmpty()) {
690-
return;
691-
}
692-
Local<Value> exception = promise->Result();
693-
Local<Message> message = Exception::CreateMessage(isolate, exception);
694-
AppendExceptionLine(
695-
env, exception, message, ErrorHandlingMode::MODULE_ERROR);
696-
isolate->ThrowException(exception);
714+
if (ThrowIfPromiseRejected(realm, promise).IsNothing()) {
697715
return;
698716
}
699717

@@ -1137,6 +1155,7 @@ void ModuleWrap::CreatePerIsolateProperties(IsolateData* isolate_data,
11371155
target,
11381156
"createRequiredModuleFacade",
11391157
CreateRequiredModuleFacade);
1158+
SetMethod(isolate, target, "throwIfPromiseRejected", ThrowIfPromiseRejected);
11401159
}
11411160

11421161
void ModuleWrap::CreatePerContextProperties(Local<Object> target,
@@ -1181,6 +1200,7 @@ void ModuleWrap::RegisterExternalReferences(
11811200

11821201
registry->Register(SetImportModuleDynamicallyCallback);
11831202
registry->Register(SetInitializeImportMetaObjectCallback);
1203+
registry->Register(ThrowIfPromiseRejected);
11841204
}
11851205
} // namespace loader
11861206
} // namespace node

0 commit comments

Comments
 (0)