Skip to content

module: improve error message from asynchronicity in require(esm) #57126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1656,9 +1656,18 @@ E('ERR_QUIC_ENDPOINT_CLOSED', 'QUIC endpoint closed: %s (%d)', Error);
E('ERR_QUIC_OPEN_STREAM_FAILED', 'Failed to open QUIC stream', Error);
E('ERR_QUIC_TRANSPORT_ERROR', 'A QUIC transport error occurred. %d [%s]', Error);
E('ERR_QUIC_VERSION_NEGOTIATION_ERROR', 'The QUIC session requires version negotiation', Error);
E('ERR_REQUIRE_ASYNC_MODULE', 'require() cannot be used on an ESM ' +
E('ERR_REQUIRE_ASYNC_MODULE', function(filename, parentFilename) {
let message = 'require() cannot be used on an ESM ' +
'graph with top-level await. Use import() instead. To see where the' +
' top-level await comes from, use --experimental-print-required-tla.', Error);
' top-level await comes from, use --experimental-print-required-tla.';
if (parentFilename) {
message += `\n From ${parentFilename} `;
}
if (filename) {
message += `\n Requiring ${filename} `;
}
return message;
}, Error);
E('ERR_REQUIRE_CYCLE_MODULE', '%s', Error);
E('ERR_REQUIRE_ESM',
function(filename, hasEsmSyntax, parentPath = null, packageJsonPath = null) {
Expand Down
25 changes: 21 additions & 4 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,20 +338,37 @@ class ModuleLoader {
// TODO(joyeecheung): ensure that imported synchronous graphs are evaluated
// synchronously so that any previously imported synchronous graph is already
// 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.
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.
if (!job.module) {
let message = `Cannot require() ES Module ${filename} because it is not yet fully loaded. `;
Copy link
Member Author

@joyeecheung joyeecheung Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can hit this branch via https://github.com/fisker/prettier-issue-17139 though I don't think it's reliably testable because it comes down to a race. From prettier/prettier#17139 it seems tiny changes to the module graph can make the race disappear. So it's probably not worth adding to the test suite in case this becomes a flaky test.

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);
}
if (job.module.async) {
throw new ERR_REQUIRE_ASYNC_MODULE();
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) {
const parentFilename = urlToFilename(parent?.filename);
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);
}
return { wrap: job.module, namespace: job.module.getNamespaceSync() };
return { wrap: job.module, namespace: job.module.getNamespaceSync(filename, parentFilename) };
}
// 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 All @@ -363,7 +380,7 @@ class ModuleLoader {
job = new ModuleJobSync(this, url, kEmptyObject, wrap, isMain, inspectBrk);
this.loadCache.set(url, kImplicitTypeAttribute, job);
mod[kRequiredModuleSymbol] = job.module;
return { wrap: job.module, namespace: job.runSync().namespace };
return { wrap: job.module, namespace: job.runSync(parent).namespace };
}

/**
Expand Down
9 changes: 6 additions & 3 deletions lib/internal/modules/esm/module_job.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const assert = require('internal/assert');
const resolvedPromise = PromiseResolve();
const {
setHasStartedUserESMExecution,
urlToFilename,
} = require('internal/modules/helpers');
const { getOptionValue } = require('internal/options');
const noop = FunctionPrototype;
Expand Down Expand Up @@ -380,7 +381,7 @@ class ModuleJobSync extends ModuleJobBase {
`Status = ${status}`);
}

runSync() {
runSync(parent) {
// TODO(joyeecheung): add the error decoration logic from the async instantiate.
this.module.async = this.module.instantiateSync();
// If --experimental-print-required-tla is true, proceeds to evaluation even
Expand All @@ -389,11 +390,13 @@ class ModuleJobSync extends ModuleJobBase {
// TODO(joyeecheung): track the asynchroniticy using v8::Module::HasTopLevelAwait()
// and we'll be able to throw right after compilation of the modules, using acron
// to find and print the TLA.
const parentFilename = urlToFilename(parent?.filename);
const filename = urlToFilename(this.url);
if (this.module.async && !getOptionValue('--experimental-print-required-tla')) {
throw new ERR_REQUIRE_ASYNC_MODULE();
throw new ERR_REQUIRE_ASYNC_MODULE(filename, parentFilename);
}
setHasStartedUserESMExecution();
const namespace = this.module.evaluateSync();
const namespace = this.module.evaluateSync(filename, parentFilename);
return { __proto__: null, module: this.module, namespace };
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ void ModuleWrap::EvaluateSync(const FunctionCallbackInfo<Value>& args) {
FPrintF(stderr, "%s\n", reason);
}
}
THROW_ERR_REQUIRE_ASYNC_MODULE(env);
THROW_ERR_REQUIRE_ASYNC_MODULE(env, args[0], args[1]);
return;
}

Expand Down Expand Up @@ -740,7 +740,7 @@ void ModuleWrap::GetNamespaceSync(const FunctionCallbackInfo<Value>& args) {
}

if (module->IsGraphAsync()) {
return THROW_ERR_REQUIRE_ASYNC_MODULE(realm->env());
return THROW_ERR_REQUIRE_ASYNC_MODULE(realm->env(), args[0], args[1]);
}
Local<Value> result = module->GetModuleNamespace();
args.GetReturnValue().Set(result);
Expand Down
38 changes: 33 additions & 5 deletions src/node_errors.h
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,21 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details);
V(ERR_WORKER_INIT_FAILED, Error) \
V(ERR_PROTO_ACCESS, Error)

// If the macros are used as ERR_*(isolate, message) or
// THROW_ERR_*(isolate, message) with a single string argument, do run
// formatter on the message, and allow the caller to pass in a message
// directly with characters that would otherwise need escaping if used
// as format string unconditionally.
#define V(code, type) \
template <typename... Args> \
inline v8::Local<v8::Object> code( \
v8::Isolate* isolate, const char* format, Args&&... args) { \
std::string message = SPrintF(format, std::forward<Args>(args)...); \
std::string message; \
if (sizeof...(Args) == 0) { \
message = format; \
} else { \
message = SPrintF(format, std::forward<Args>(args)...); \
} \
v8::Local<v8::String> js_code = FIXED_ONE_BYTE_STRING(isolate, #code); \
v8::Local<v8::String> js_msg = \
v8::String::NewFromUtf8(isolate, \
Expand Down Expand Up @@ -205,10 +215,6 @@ ERRORS_WITH_CODE(V)
"creating Workers") \
V(ERR_NON_CONTEXT_AWARE_DISABLED, \
"Loading non context-aware native addons has been disabled") \
V(ERR_REQUIRE_ASYNC_MODULE, \
"require() cannot be used on an ESM graph with top-level await. Use " \
"import() instead. To see where the top-level await comes from, use " \
"--experimental-print-required-tla.") \
V(ERR_SCRIPT_EXECUTION_INTERRUPTED, \
"Script execution was interrupted by `SIGINT`") \
V(ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED, "Failed to set PSK identity hint") \
Expand Down Expand Up @@ -238,6 +244,28 @@ inline void THROW_ERR_SCRIPT_EXECUTION_TIMEOUT(Environment* env,
THROW_ERR_SCRIPT_EXECUTION_TIMEOUT(env, message.str().c_str());
}

inline void THROW_ERR_REQUIRE_ASYNC_MODULE(
Environment* env,
v8::Local<v8::Value> filename,
v8::Local<v8::Value> parent_filename) {
static constexpr const char* prefix =
"require() cannot be used on an ESM graph with top-level await. Use "
"import() instead. To see where the top-level await comes from, use "
"--experimental-print-required-tla.";
std::string message = prefix;
if (!parent_filename.IsEmpty() && parent_filename->IsString()) {
Utf8Value utf8(env->isolate(), parent_filename);
message += "\n From ";
message += utf8.out();
}
if (!filename.IsEmpty() && filename->IsString()) {
Utf8Value utf8(env->isolate(), filename);
message += "\n Requiring ";
message += +utf8.out();
}
THROW_ERR_REQUIRE_ASYNC_MODULE(env, message.c_str());
}

inline v8::Local<v8::Object> ERR_BUFFER_TOO_LARGE(v8::Isolate* isolate) {
char message[128];
snprintf(message,
Expand Down
12 changes: 12 additions & 0 deletions test/common/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,17 @@ function expectRequiredModule(mod, expectation, checkESModule = true) {
assert.deepStrictEqual(clone, { ...expectation });
}

function expectRequiredTLAError(err) {
const message = /require\(\) cannot be used on an ESM graph with top-level await/;
if (typeof err === 'string') {
assert.match(err, /ERR_REQUIRE_ASYNC_MODULE/);
assert.match(err, message);
} else {
assert.strictEqual(err.code, 'ERR_REQUIRE_ASYNC_MODULE');
assert.match(err.message, message);
}
}

const common = {
allowGlobals,
buildType,
Expand All @@ -864,6 +875,7 @@ const common = {
escapePOSIXShell,
expectsError,
expectRequiredModule,
expectRequiredTLAError,
expectWarning,
getArrayBufferViews,
getBufferSources,
Expand Down
26 changes: 26 additions & 0 deletions test/es-module/test-require-module-tla-execution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

// Tests that require(esm) with top-level-await throws before execution starts
// if --experimental-print-required-tla is not enabled.

const common = require('../common');
const assert = require('assert');
const { spawnSyncAndExit } = require('../common/child_process');
const fixtures = require('../common/fixtures');

{
spawnSyncAndExit(process.execPath, [
fixtures.path('es-modules/tla/require-execution.js'),
], {
signal: null,
status: 1,
stderr(output) {
assert.doesNotMatch(output, /I am executed/);
common.expectRequiredTLAError(output);
assert.match(output, /From .*require-execution\.js/);
assert.match(output, /Requiring .*execution\.mjs/);
return true;
},
stdout: ''
});
}
15 changes: 15 additions & 0 deletions test/es-module/test-require-module-tla-nested.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

// Tests that require(esm) throws for top-level-await in inner graphs.

const common = require('../common');
const assert = require('assert');

assert.throws(() => {
require('../fixtures/es-modules/tla/parent.mjs');
}, (err) => {
common.expectRequiredTLAError(err);
assert.match(err.message, /From .*test-require-module-tla-nested\.js/);
assert.match(err.message, /Requiring .*parent\.mjs/);
return true;
});
29 changes: 29 additions & 0 deletions test/es-module/test-require-module-tla-print-execution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';

// Tests that require(esm) with top-level-await throws after execution
// if --experimental-print-required-tla is enabled.

const common = require('../common');
const assert = require('assert');
const { spawnSyncAndExit } = require('../common/child_process');
const fixtures = require('../common/fixtures');

{
spawnSyncAndExit(process.execPath, [
'--experimental-print-required-tla',
fixtures.path('es-modules/tla/require-execution.js'),
], {
signal: null,
status: 1,
stderr(output) {
assert.match(output, /I am executed/);
common.expectRequiredTLAError(output);
assert.match(output, /Error: unexpected top-level await at.*execution\.mjs:3/);
assert.match(output, /await Promise\.resolve\('hi'\)/);
assert.match(output, /From .*require-execution\.js/);
assert.match(output, /Requiring .*execution\.mjs/);
return true;
},
stdout: ''
});
}
15 changes: 15 additions & 0 deletions test/es-module/test-require-module-tla-rejected.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

// Tests that require(esm) throws for rejected top-level await.

const common = require('../common');
const assert = require('assert');

assert.throws(() => {
require('../fixtures/es-modules/tla/rejected.mjs');
}, (err) => {
common.expectRequiredTLAError(err);
assert.match(err.message, /From .*test-require-module-tla-rejected\.js/);
assert.match(err.message, /Requiring .*rejected\.mjs/);
return true;
});
15 changes: 15 additions & 0 deletions test/es-module/test-require-module-tla-resolved.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

// Tests that require(esm) throws for resolved top-level-await.

const common = require('../common');
const assert = require('assert');

assert.throws(() => {
require('../fixtures/es-modules/tla/resolved.mjs');
}, (err) => {
common.expectRequiredTLAError(err);
assert.match(err.message, /From .*test-require-module-tla-resolved\.js/);
assert.match(err.message, /Requiring .*resolved\.mjs/);
return true;
});
15 changes: 15 additions & 0 deletions test/es-module/test-require-module-tla-unresolved.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';

// Tests that require(esm) throws for unresolved top-level-await.

const common = require('../common');
const assert = require('assert');

assert.throws(() => {
require('../fixtures/es-modules/tla/unresolved.mjs');
}, (err) => {
common.expectRequiredTLAError(err);
assert.match(err.message, /From .*test-require-module-tla-unresolved\.js/);
assert.match(err.message, /Requiring .*unresolved\.mjs/);
return true;
});
63 changes: 0 additions & 63 deletions test/es-module/test-require-module-tla.js

This file was deleted.

Loading