Repository: nodejs/node
Pull request: module: correctly detect top-level await in ambiguous contexts

Fixes: https://github.com/nodejs/node/issues/58331

Many syntax errors are incorrectly treated as requiring ESM recompilation, but since they can't be clearly distinguished from top-level await-related errors, the current fix ends up handling them together.

From what I can tell by looking at the issues reported on V8, this appears to be a problem on the V8 side, so I will see if a fix can be made there.
https://issues.chromium.org/issues/40070895#comment1

That said, this patch will be necessary until the issue is resolved in V8.


<!--
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: src/node_contextify.cc
patch: @@ -1646,6 +1646,12 @@ static const auto throws_only_in_cjs_error_messages =
         "await is only valid in async functions and "
         "the top level bodies of modules"};
 
+static const auto maybe_top_level_await_errors =
+    std::array<std::string_view, 2>{
+        "missing ) after argument list",  // example: `func(await 1);`
+        "SyntaxError: Unexpected"         // example: `if(await 1)`
+    };
+
 // If cached_data is provided, it would be used for the compilation and
 // the on-disk compilation cache from NODE_COMPILE_CACHE (if configured)
 // would be ignored.
@@ -1876,6 +1882,16 @@ bool ShouldRetryAsESM(Realm* realm,
       break;
     }
   }
+
+  for (const auto& error_message : maybe_top_level_await_errors) {
+    if (message_view.find(error_message) != std::string_view::npos) {
+      // If the error message is related to top-level await, we can try to
+      // compile it as ESM.
+      maybe_valid_in_esm = true;
+      break;
+    }
+  }
+
   if (!maybe_valid_in_esm) {
     return false;
   }

filename: test/es-module/test-esm-tla-syntax-errors-not-recognized-as-tla-error.mjs
patch: @@ -0,0 +1,78 @@
+import { spawnPromisified } from '../common/index.mjs';
+import { describe, it } from 'node:test';
+import { strictEqual, match } from 'node:assert';
+
+describe('unusual top-level await syntax errors', () => {
+  const expressions = [
+    // string
+    { expression: '""' },
+    // number
+    { expression: '0' },
+    // boolean
+    { expression: 'true' },
+    // null
+    { expression: 'null' },
+    // undefined
+    { expression: 'undefined' },
+    // object
+    { expression: '{}' },
+    // array
+    { expression: '[]' },
+    // new
+    { expression: 'new Date()' },
+    // identifier
+    { initialize: 'const a = 2;', expression: 'a' },
+  ];
+  it('should not crash the process', async () => {
+    for (const { expression, initialize } of expressions) {
+      const wrapperExpressions = [
+        `function callAwait() {}; callAwait(await ${expression});`,
+        `if (await ${expression}) {}`,
+        `{ key: await ${expression} }`,
+        `[await ${expression}]`,
+        `(await ${expression})`,
+      ];
+      for (const wrapperExpression of wrapperExpressions) {
+        const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
+          '--eval',
+          `
+          ${initialize || ''}
+          ${wrapperExpression}
+          `,
+        ]);
+
+        strictEqual(stderr, '');
+        strictEqual(stdout, '');
+        strictEqual(code, 0);
+        strictEqual(signal, null);
+      }
+    }
+  });
+
+  it('should throw the error for unrelated syntax errors', async () => {
+    const expression = 'foo bar';
+    const wrapperExpressions = [
+      [`function callSyntaxError() {}; callSyntaxError(${expression});`, /missing \) after argument list/],
+      [`if (${expression}) {}`, /Unexpected identifier/],
+      [`{ key: ${expression} }`, /Unexpected identifier/],
+      [`[${expression}]`, /Unexpected identifier/],
+      [`(${expression})`, /Unexpected identifier/],
+      [`const ${expression} = 1;`, /Missing initializer in const declaration/],
+      [`console.log('PI: ' Math.PI);`, /missing \) after argument list/],
+      [`callAwait(await "" "");`, /missing \) after argument list/],
+    ];
+
+    for (const [wrapperExpression, error] of wrapperExpressions) {
+      const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
+        '--eval',
+        `
+          ${wrapperExpression}
+          `,
+      ]);
+      match(stderr, error);
+      strictEqual(stdout, '');
+      strictEqual(code, 1);
+      strictEqual(signal, null);
+    }
+  });
+});