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

- Improve the error message that shows up when there is a race
  from doing require(esm) and import(esm) at the same time.
- Improve error message of ERR_REQUIRE_ASYNC_MODULE by showing
  parent and target file names, if available.

Drive-by: split the require(tla) tests since we are modifying
the tests already.

PR-URL: https://github.com/nodejs/node/pull/57126
Refs: https://github.com/fisker/prettier-issue-17139
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
This commit is contained in:
Joyee Cheung 2025-02-17 19:44:50 +01:00 committed by Node.js GitHub Bot
parent d601c7d104
commit 8d10bc7b09
13 changed files with 190 additions and 79 deletions

View File

@ -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) {

View File

@ -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;
if (job.module.async) {
throw new ERR_REQUIRE_ASYNC_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. `;
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(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
@ -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 };
}
/**

View File

@ -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;
@ -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
@ -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 };
}
}

View File

@ -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;
}
@ -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);

View File

@ -215,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") \
@ -248,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,

View File

@ -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,
@ -864,6 +875,7 @@ const common = {
escapePOSIXShell,
expectsError,
expectRequiredModule,
expectRequiredTLAError,
expectWarning,
getArrayBufferViews,
getBufferSources,

View File

@ -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: ''
});
}

View File

@ -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;
});

View File

@ -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: ''
});
}

View File

@ -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;
});

View File

@ -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;
});

View File

@ -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;
});

View File

@ -1,63 +0,0 @@
// Flags: --experimental-require-module
'use strict';
require('../common');
const assert = require('assert');
const { spawnSyncAndExit } = require('../common/child_process');
const fixtures = require('../common/fixtures');
const message = /require\(\) cannot be used on an ESM graph with top-level await/;
const code = 'ERR_REQUIRE_ASYNC_MODULE';
assert.throws(() => {
require('../fixtures/es-modules/tla/rejected.mjs');
}, { message, code });
assert.throws(() => {
require('../fixtures/es-modules/tla/unresolved.mjs');
}, { message, code });
assert.throws(() => {
require('../fixtures/es-modules/tla/resolved.mjs');
}, { message, code });
// Test TLA in inner graphs.
assert.throws(() => {
require('../fixtures/es-modules/tla/parent.mjs');
}, { message, code });
{
spawnSyncAndExit(process.execPath, [
'--experimental-require-module',
fixtures.path('es-modules/tla/require-execution.js'),
], {
signal: null,
status: 1,
stderr(output) {
assert.doesNotMatch(output, /I am executed/);
assert.match(output, message);
return true;
},
stdout: ''
});
}
{
spawnSyncAndExit(process.execPath, [
'--experimental-require-module',
'--experimental-print-required-tla',
fixtures.path('es-modules/tla/require-execution.js'),
], {
signal: null,
status: 1,
stderr(output) {
assert.match(output, /I am executed/);
assert.match(output, /Error: unexpected top-level await at.*execution\.mjs:3/);
assert.match(output, /await Promise\.resolve\('hi'\)/);
assert.match(output, message);
return true;
},
stdout: ''
});
}