Skip to content
Open
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
11 changes: 11 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2707,6 +2707,17 @@ This error has been deprecated since `require()` now supports loading synchronou
ES modules. When `require()` encounters an ES module that contains top-level
`await`, it will throw [`ERR_REQUIRE_ASYNC_MODULE`][] instead.

<a id="ERR_REQUIRE_ESM_RACE_CONDITION"></a>

### `ERR_REQUIRE_ESM_RACE_CONDITION`

<!-- YAML
added: REPLACEME
-->

An attempt was made to `require()` an [ES Module][] while another `import()` call
was already in progress to load it asynchronously.

<a id="ERR_SCRIPT_EXECUTION_INTERRUPTED"></a>

### `ERR_SCRIPT_EXECUTION_INTERRUPTED`
Expand Down
9 changes: 9 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,15 @@ E('ERR_REQUIRE_ESM',
'all ES modules instead).\n';
return msg;
}, Error);
E('ERR_REQUIRE_ESM_RACE_CONDITION', (filename, parentFilename, isForAsyncLoaderHookWorker) => {
let raceMessage = `Cannot require() ES Module ${filename} because it is not yet fully loaded.\n`;
raceMessage += 'This may be caused by a race condition if the module is simultaneously dynamically ';
raceMessage += 'import()-ed via Promise.all().\n';
raceMessage += 'Try await-ing the import() sequentially in a loop instead.\n';
raceMessage += ` (From ${parentFilename ? `${parentFilename} in ` : ' '}`;
raceMessage += `${isForAsyncLoaderHookWorker ? 'loader hook worker thread' : 'non-loader-hook thread'})`;
return raceMessage;
}, Error);
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
'Script execution was interrupted by `SIGINT`', Error);
E('ERR_SERVER_ALREADY_LISTEN',
Expand Down
28 changes: 6 additions & 22 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const {
ERR_REQUIRE_ASYNC_MODULE,
ERR_REQUIRE_CYCLE_MODULE,
ERR_REQUIRE_ESM,
ERR_REQUIRE_ESM_RACE_CONDITION,
ERR_UNKNOWN_MODULE_FORMAT,
} = require('internal/errors').codes;
const { getOptionValue } = require('internal/options');
Expand All @@ -49,6 +50,7 @@ const {
kEvaluating,
kEvaluationPhase,
kInstantiated,
kUninstantiated,
kErrored,
kSourcePhase,
throwIfPromiseRejected,
Expand Down Expand Up @@ -102,24 +104,6 @@ const { translators } = require('internal/modules/esm/translators');
const { defaultResolve } = require('internal/modules/esm/resolve');
const { defaultLoadSync, throwUnknownModuleFormat } = require('internal/modules/esm/load');

/**
* Generate message about potential race condition caused by requiring a cached module that has started
* async linking.
* @param {string} filename Filename of the module being required.
* @param {string|undefined} parentFilename Filename of the module calling require().
* @param {boolean} isForAsyncLoaderHookWorker Whether this is for the async loader hook worker.
* @returns {string} Error message.
*/
function getRaceMessage(filename, parentFilename, isForAsyncLoaderHookWorker) {
let raceMessage = `Cannot require() ES Module ${filename} because it is not yet fully loaded.\n`;
raceMessage += 'This may be caused by a race condition if the module is simultaneously dynamically ';
raceMessage += 'import()-ed via Promise.all().\n';
raceMessage += 'Try await-ing the import() sequentially in a loop instead.\n';
raceMessage += ` (From ${parentFilename ? `${parentFilename} in ` : ' '}`;
raceMessage += `${isForAsyncLoaderHookWorker ? 'loader hook worker thread' : 'non-loader-hook thread'})`;
return raceMessage;
}

/**
* @typedef {import('../cjs/loader.js').Module} CJSModule
*/
Expand Down Expand Up @@ -306,7 +290,7 @@ class ModuleLoader {
const parentFilename = urlToFilename(parent?.filename);
// This race should only be possible on the loader hook thread. See https://github.com/nodejs/node/issues/59666
if (!job.module) {
assert.fail(getRaceMessage(filename, parentFilename), this.isForAsyncLoaderHookWorker);
throw new ERR_REQUIRE_ESM_RACE_CONDITION(filename, parentFilename, this.isForAsyncLoaderHookWorker);
}
const status = job.module.getStatus();
debug('Module status', job, status);
Expand Down Expand Up @@ -339,8 +323,8 @@ class ModuleLoader {
throwIfPromiseRejected(job.instantiated);
}
if (status !== kEvaluating) {
assert.fail(`Unexpected module status ${status}. ` +
getRaceMessage(filename, parentFilename));
assert(status === kUninstantiated, `Unexpected module status ${status}`);
throw new ERR_REQUIRE_ESM_RACE_CONDITION(filename, parentFilename, false);
}
let message = `Cannot require() ES Module ${filename} in a cycle.`;
if (parentFilename) {
Expand Down Expand Up @@ -376,7 +360,7 @@ class ModuleLoader {
#checkCachedJobForRequireESM(specifier, url, parentURL, job) {
// This race should only be possible on the loader hook thread. See https://github.com/nodejs/node/issues/59666
if (!job.module) {
assert.fail(getRaceMessage(url, parentURL, this.isForAsyncLoaderHookWorker));
throw new ERR_REQUIRE_ESM_RACE_CONDITION(url, parentURL, this.isForAsyncLoaderHookWorker);
}
// This module is being evaluated, which means it's imported in a previous link
// in a cycle.
Expand Down
4 changes: 3 additions & 1 deletion lib/internal/modules/esm/module_job.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const { getOptionValue } = require('internal/options');
const noop = FunctionPrototype;
const {
ERR_REQUIRE_ASYNC_MODULE,
ERR_REQUIRE_ESM_RACE_CONDITION,
} = require('internal/errors').codes;
let hasPausedEntry = false;

Expand Down Expand Up @@ -420,7 +421,8 @@ class ModuleJob extends ModuleJobBase {
// always handle CJS using the CJS loader to eliminate the quirks.
return { __proto__: null, module: this.module, namespace: this.module.getNamespace() };
}
assert.fail(`Unexpected module status ${status}.`);
assert(status === kUninstantiated, `Unexpected module status ${status}.`);
throw new ERR_REQUIRE_ESM_RACE_CONDITION();
}

async run(isEntryPoint = false) {
Expand Down
9 changes: 9 additions & 0 deletions test/es-module/test-esm-require-race-condition.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use strict';
require('../common');
const fixtures = require('../common/fixtures');
const assert = require('node:assert');

assert.throws(
() => require(fixtures.path('import-require-cycle/race-condition.cjs')),
{ code: 'ERR_REQUIRE_ESM_RACE_CONDITION' },
Copy link
Copy Markdown
Member

@joyeecheung joyeecheung Mar 27, 2026

Choose a reason for hiding this comment

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

This should be moved to known-issues if we want to formulate this as a "FIXME" instead of "working as intended"

);

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions test/fixtures/import-require-cycle/race-condition.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import("dual-pkg");
require("cjs-pkg");
Loading