Repository: nodejs/node
Pull request: module: throw error when re-runing errored module jobs

Re-evaluating an errored ESM should lead to rejecting the rejection again - this is also the case when importing it twice. In the case of retrying with require after import, just throw the cached error.

Drive-by: add some debug logs.

Fixes: https://github.com/nodejs/node/issues/58945
Refs: https://github.com/nodejs/node/issues/52697

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: lib/internal/modules/esm/loader.js
patch: @@ -43,6 +43,7 @@ const {
   kEvaluating,
   kEvaluationPhase,
   kInstantiated,
+  kErrored,
   kSourcePhase,
   throwIfPromiseRejected,
 } = internalBinding('module_wrap');
@@ -402,6 +403,9 @@ class ModuleLoader {
         mod[kRequiredModuleSymbol] = job.module;
         const { namespace } = job.runSync(parent);
         return { wrap: job.module, namespace: namespace || job.module.getNamespace() };
+      } else if (status === kErrored) {
+        // If the module was previously imported and errored, throw the error.
+        throw job.module.getError();
       }
       // When the cached async job have already encountered a linking
       // error that gets wrapped into a rejection, but is still later

filename: lib/internal/modules/esm/module_job.js
patch: @@ -323,7 +323,7 @@ class ModuleJob extends ModuleJobBase {
     assert(this.module instanceof ModuleWrap);
     let status = this.module.getStatus();
 
-    debug('ModuleJob.runSync', this.module);
+    debug('ModuleJob.runSync()', status, this.module);
     // FIXME(joyeecheung): this cannot fully handle < kInstantiated. Make the linking
     // fully synchronous instead.
     if (status === kUninstantiated) {
@@ -358,6 +358,7 @@ class ModuleJob extends ModuleJobBase {
   }
 
   async run(isEntryPoint = false) {
+    debug('ModuleJob.run()', this.module);
     assert(this.phase === kEvaluationPhase);
     await this.#instantiate();
     if (isEntryPoint) {
@@ -461,7 +462,11 @@ class ModuleJobSync extends ModuleJobBase {
     assert(this.phase === kEvaluationPhase);
     // This path is hit by a require'd module that is imported again.
     const status = this.module.getStatus();
-    if (status > kInstantiated) {
+    debug('ModuleJobSync.run()', status, this.module);
+    // If the module was previously required and errored, reject from import() again.
+    if (status === kErrored) {
+      throw this.module.getError();
+    } else if (status > kInstantiated) {
       if (this.evaluationPromise) {
         await this.evaluationPromise;
       }
@@ -482,6 +487,7 @@ class ModuleJobSync extends ModuleJobBase {
   }
 
   runSync(parent) {
+    debug('ModuleJobSync.runSync()', this.module);
     assert(this.phase === kEvaluationPhase);
     // TODO(joyeecheung): add the error decoration logic from the async instantiate.
     this.module.async = this.module.instantiateSync();

filename: test/es-module/test-import-module-retry-require-errored.js
patch: @@ -0,0 +1,17 @@
+// This tests that after failing to import an ESM that rejects,
+// retrying with require() still throws.
+
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+
+(async () => {
+  await assert.rejects(import('../fixtures/es-modules/throw-error.mjs'), {
+    message: 'test',
+  });
+  assert.throws(() => {
+    require('../fixtures/es-modules/throw-error.mjs');
+  }, {
+    message: 'test',
+  });
+})().catch(common.mustNotCall());

filename: test/es-module/test-require-module-retry-import-errored-2.js
patch: @@ -0,0 +1,16 @@
+// This tests that after failing to require an ESM that throws,
+// retrying with import() still rejects.
+
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+
+assert.throws(() => {
+  require('../fixtures/es-modules/throw-error.mjs');
+}, {
+  message: 'test',
+});
+
+assert.rejects(import('../fixtures/es-modules/throw-error.mjs'), {
+  message: 'test',
+}).catch(common.mustNotCall());