Skip to content

Commit

Permalink
module: handle cached linked async jobs in require(esm)
Browse files Browse the repository at this point in the history
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().
  • Loading branch information
joyeecheung committed Feb 23, 2025
1 parent 8c2df73 commit de697ab
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 48 deletions.
74 changes: 53 additions & 21 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const {
kIsExecuting,
kRequiredModuleSymbol,
} = require('internal/modules/cjs/loader');

const { imported_cjs_symbol } = internalBinding('symbols');

const assert = require('internal/assert');
Expand All @@ -38,7 +37,13 @@ const {
forceDefaultLoader,
} = require('internal/modules/esm/utils');
const { kImplicitTypeAttribute } = require('internal/modules/esm/assert');
const { ModuleWrap, kEvaluating, kEvaluated } = internalBinding('module_wrap');
const {
ModuleWrap,
kEvaluating,
kEvaluated,
kInstantiated,
throwIfPromiseRejected,
} = internalBinding('module_wrap');
const {
urlToFilename,
} = require('internal/modules/helpers');
Expand All @@ -53,6 +58,10 @@ let defaultResolve, defaultLoad, defaultLoadSync, importMetaInitializer;
const { tracingChannel } = require('diagnostics_channel');
const onImport = tracingChannel('module.import');

let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
debug = fn;
});

/**
* @typedef {import('./hooks.js').HooksProxy} HooksProxy
* @typedef {import('./module_job.js').ModuleJobBase} ModuleJobBase
Expand Down Expand Up @@ -340,35 +349,58 @@ class ModuleLoader {
// evaluated at this point.
// TODO(joyeecheung): add something similar to CJS loader's requireStack to help
// debugging the the problematic links in the graph for import.
debug('importSyncForRequire', parent?.filename, '->', filename, job);
if (job !== undefined) {
mod[kRequiredModuleSymbol] = job.module;
const parentFilename = urlToFilename(parent?.filename);
// TODO(node:55782): this race may stop to happen when the ESM resolution and loading become synchronous.
let raceMessage = `Cannot require() ES Module ${filename} because it is not yet fully loaded. `;
raceMessage += 'This may be caused by a race condition if the module is simultaneously dynamically ';
raceMessage += 'import()-ed via Promise.all(). Try await-ing the import() sequentially in a loop instead.';
if (parentFilename) {
raceMessage += ` (from ${parentFilename})`;
}
if (!job.module) {
let message = `Cannot require() ES Module ${filename} because it is not yet fully loaded. `;
message += 'This may be caused by a race condition if the module is simultaneously dynamically ';
message += 'import()-ed via Promise.all(). Try await-ing the import() sequentially in a loop instead.';
if (parentFilename) {
message += ` (from ${parentFilename})`;
}
assert(job.module, message);
assert(job.module, raceMessage);
}
if (job.module.async) {
throw new ERR_REQUIRE_ASYNC_MODULE(filename, parentFilename);
}
// job.module may be undefined if it's asynchronously loaded. Which means
// there is likely a cycle.
if (job.module.getStatus() !== kEvaluated) {
let message = `Cannot require() ES Module ${filename} in a cycle.`;
if (parentFilename) {
message += ` (from ${parentFilename})`;
}
message += 'A cycle involving require(esm) is disallowed to maintain ';
message += 'invariants madated by the ECMAScript specification';
message += 'Try making at least part of the dependency in the graph lazily loaded.';
throw new ERR_REQUIRE_CYCLE_MODULE(message);
const status = job.module.getStatus();
debug('Module status', filename, status);
if (status === kEvaluated) {
return { wrap: job.module, namespace: job.module.getNamespaceSync(filename, parentFilename) };
} else if (status === kInstantiated) {
// When it's an async job cached by another import request,
// which has finished linking but has not started its
// evaluation because the async run() task would be later
// in line. Then start the evaluation now with runSync(), which
// is guaranteed to finish by the time the other run() get to it,
// and the other task would just get the cached evaluation results,
// similar to what would happen when both are async.
mod[kRequiredModuleSymbol] = job.module;
const { namespace } = job.runSync(parent);
return { wrap: job.module, namespace: namespace || job.module.getNamespace() };
}
// 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().
if (job.instantiated) {
throwIfPromiseRejected(job.instantiated);
}
return { wrap: job.module, namespace: job.module.getNamespaceSync(filename, parentFilename) };
if (status !== kEvaluating) {
assert.fail(`Unexpected module status ${status}. ` + raceMessage);
}
let message = `Cannot require() ES Module ${filename} in a cycle.`;
if (parentFilename) {
message += ` (from ${parentFilename})`;
}
message += 'A cycle involving require(esm) is disallowed to maintain ';
message += 'invariants madated by the ECMAScript specification';
message += 'Try making at least part of the dependency in the graph lazily loaded.';
throw new ERR_REQUIRE_CYCLE_MODULE(message);

}
// TODO(joyeecheung): refactor this so that we pre-parse in C++ and hit the
// cache here, or use a carrier object to carry the compiled module script
Expand Down
10 changes: 5 additions & 5 deletions lib/internal/modules/esm/module_job.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,19 +246,19 @@ class ModuleJob extends ModuleJobBase {
}
}

runSync() {
runSync(parent) {
assert(this.module instanceof ModuleWrap);
if (this.instantiated !== undefined) {
return { __proto__: null, module: this.module };
}

this.module.instantiate();
this.instantiated = PromiseResolve();
const timeout = -1;
const breakOnSigint = false;
setHasStartedUserESMExecution();
this.module.evaluate(timeout, breakOnSigint);
return { __proto__: null, module: this.module };
const filename = urlToFilename(this.url);
const parentFilename = urlToFilename(parent?.filename);
const namespace = this.module.evaluateSync(filename, parentFilename);
return { __proto__: null, module: this.module, namespace };
}

async run(isEntryPoint = false) {
Expand Down
64 changes: 42 additions & 22 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ using v8::Int32;
using v8::Integer;
using v8::Isolate;
using v8::Just;
using v8::JustVoid;
using v8::Local;
using v8::LocalVector;
using v8::Maybe;
Expand All @@ -46,6 +47,7 @@ using v8::MicrotaskQueue;
using v8::Module;
using v8::ModuleRequest;
using v8::Name;
using v8::Nothing;
using v8::Null;
using v8::Object;
using v8::ObjectTemplate;
Expand Down Expand Up @@ -648,6 +650,43 @@ void ModuleWrap::InstantiateSync(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(module->IsGraphAsync());
}

Maybe<void> ThrowIfPromiseRejected(Realm* realm, Local<Promise> promise) {
Isolate* isolate = realm->isolate();
Local<Context> context = realm->context();
if (promise->State() != Promise::PromiseState::kRejected) {
return JustVoid();
}
// The rejected promise is created by V8, so we don't get a chance to mark
// it as resolved before the rejection happens from evaluation. But we can
// tell the promise rejection callback to treat it as a promise rejected
// before handler was added which would remove it from the unhandled
// rejection handling, since we are converting it into an error and throw
// from here directly.
Local<Value> type =
Integer::New(isolate,
static_cast<int32_t>(
PromiseRejectEvent::kPromiseHandlerAddedAfterReject));
Local<Value> args[] = {type, promise, Undefined(isolate)};
if (realm->promise_reject_callback()
->Call(context, Undefined(isolate), arraysize(args), args)
.IsEmpty()) {
return Nothing<void>();
}
Local<Value> exception = promise->Result();
Local<Message> message = Exception::CreateMessage(isolate, exception);
AppendExceptionLine(
realm->env(), exception, message, ErrorHandlingMode::MODULE_ERROR);
isolate->ThrowException(exception);
return Nothing<void>();
}

void ThrowIfPromiseRejected(const FunctionCallbackInfo<Value>& args) {
if (!args[0]->IsPromise()) {
return;
}
ThrowIfPromiseRejected(Realm::GetCurrent(args), args[0].As<Promise>());
}

void ModuleWrap::EvaluateSync(const FunctionCallbackInfo<Value>& args) {
Realm* realm = Realm::GetCurrent(args);
Isolate* isolate = args.GetIsolate();
Expand All @@ -672,28 +711,7 @@ void ModuleWrap::EvaluateSync(const FunctionCallbackInfo<Value>& args) {

CHECK(result->IsPromise());
Local<Promise> promise = result.As<Promise>();
if (promise->State() == Promise::PromiseState::kRejected) {
// The rejected promise is created by V8, so we don't get a chance to mark
// it as resolved before the rejection happens from evaluation. But we can
// tell the promise rejection callback to treat it as a promise rejected
// before handler was added which would remove it from the unhandled
// rejection handling, since we are converting it into an error and throw
// from here directly.
Local<Value> type =
Integer::New(isolate,
static_cast<int32_t>(
PromiseRejectEvent::kPromiseHandlerAddedAfterReject));
Local<Value> args[] = {type, promise, Undefined(isolate)};
if (env->promise_reject_callback()
->Call(context, Undefined(isolate), arraysize(args), args)
.IsEmpty()) {
return;
}
Local<Value> exception = promise->Result();
Local<Message> message = Exception::CreateMessage(isolate, exception);
AppendExceptionLine(
env, exception, message, ErrorHandlingMode::MODULE_ERROR);
isolate->ThrowException(exception);
if (ThrowIfPromiseRejected(realm, promise).IsNothing()) {
return;
}

Expand Down Expand Up @@ -1137,6 +1155,7 @@ void ModuleWrap::CreatePerIsolateProperties(IsolateData* isolate_data,
target,
"createRequiredModuleFacade",
CreateRequiredModuleFacade);
SetMethod(isolate, target, "throwIfPromiseRejected", ThrowIfPromiseRejected);
}

void ModuleWrap::CreatePerContextProperties(Local<Object> target,
Expand Down Expand Up @@ -1181,6 +1200,7 @@ void ModuleWrap::RegisterExternalReferences(

registry->Register(SetImportModuleDynamicallyCallback);
registry->Register(SetInitializeImportMetaObjectCallback);
registry->Register(ThrowIfPromiseRejected);
}
} // namespace loader
} // namespace node
Expand Down

0 comments on commit de697ab

Please sign in to comment.