Skip to content

Disallow ESM-CJS-ESM-ESM cycles when using require(esm) #52145

Closed
@nicolo-ribaudo

Description

@nicolo-ribaudo

Version

Unreleased (5f7fad2)

Platform

n/a

Subsystem

esm

What steps will reproduce the bug?

// c.mjs
import { createRequire } from "module";
console.log("Start c");
createRequire(import.meta.url)("./d.mjs");
throw new Error("Error from c");
// d.mjs
import "./c.mjs";
console.log("Execute d");
// b.mjs
import "./c.mjs";
console.log("Execute b");
// a.mjs
try {
  await import("./b.mjs");
  console.log("Dynamic 1 didn't fail")
} catch (err) {
  console.log(err);
}

try {
  await import("./d.mjs");
  console.log("Dynamic 2 didn't fail")
} catch (err) {
  console.log(err);
}

How often does it reproduce? Is there a required condition?

Whenever there is an ES modules that triggers a require() of a separate ES module that imports the original ES module.

What is the expected behavior? Why is that the expected behavior?

  • d.mjs contains some code and then a throw statement at the top level, so import("./d.mjs") must always reject (either because of the "some code", of because of the throw)
  • c.mjs imports d.mjs, so import("./c.mjs") must also always throw
  • b.mjs imports c.mjs, so import("./b.mjs") must also always reject

Indeed, out/Release/node --experimental-require-module ./d.mjs throws

Start c
node:internal/modules/esm/loader:274
      return job.module.getNamespaceSync();
                        ^

Error: 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.

(which is a wrong error, but at least it is an error 😛) and out/Release/node --experimental-require-module ./c.mjs and out/Release/node --experimental-require-module ./c.mjs throw

Start c
Execute d
file:///.../c.mjs:5
throw new Error("Error from c");
      ^

Error: Error from c

What do you see instead?

However, if you import() first b.mjs and then d.mjs, the second import() does not reject! out/Release/node --experimental-require-module ./d.mjs outputs

(node:928766) ExperimentalWarning: Support for loading ES Module in require() is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Start c
Execute d
Error: Error from c
    at file://.../c.mjs:5:7
    at ModuleJob.run (node:internal/modules/esm/module_job:235:25)
    at async ModuleLoader.import (node:internal/modules/esm/loader:436:24)
    at async file://.../a.mjs:3:3
Dynamic 2 didn't fail

Additional information

I think the reason the second import() does not properly reject is because running two module evaluation algorithms at the same time messes up with the way V8 tracks cycles. Specifically, it violates the assumption made at step 11.c.ii of https://tc39.es/ecma262/#sec-innermoduleevaluation, causing them problems at step 16.

A solution to this would be, when doing require(esm), to check if the module or any of its transitive dependencies are currently being evaluated. Right now Node.js just checks if the module itself is being evaluated (and throws that top-level await error).

cc @joyeecheung (who is already aware of this, I'm just opening the issue so that it doesn't get forgotten)

Metadata

Metadata

Assignees

No one assigned

    Labels

    esmIssues and PRs related to the ECMAScript Modules implementation.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions