Skip to content

Crash in cjs_lexer::Parse since v24.14.0: FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal #63323

@makimaki

Description

@makimaki

Version

v24.14.0

Platform

We have only reproduced this on GitHub Actions ubuntu-latest runners so far (Linux x64, in CI).

Subsystem

No response

What steps will reproduce the bug?

We don't have a deterministic single-file repro: the empty MaybeLocal only surfaces under heavy concurrent ESM→CJS preparse, and we couldn't isolate which specific allocation fails. The original environment is a Vite + Ladle dev server pair started by Playwright's webServer option, where each child process imports a large dependency graph on cold start.

The script below is the smallest standalone reproducer we could build that runs node only, no third-party deps. It is probabilistic — on our machine it aborts within a few thousand iterations, on a faster machine you may need to raise N_WORKERS / N_FILES. It exercises the same code path (cjsPreparseModuleExportscjs_lexer.parse) under similar pressure to the real failure.

// repro.mjs — run: `node repro.mjs` on Node >= v24.14.0
// No third-party deps. Aborts probabilistically with the same FATAL.
import { Worker, isMainThread, workerData } from 'node:worker_threads';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

const N_FILES = 500;
const N_WORKERS = 16;

if (isMainThread) {
  const dir = mkdtempSync(join(tmpdir(), 'cjs-lexer-repro-'));
  const files = [];
  for (let i = 0; i < N_FILES; i++) {
    const p = join(dir, `m${i}.cjs`);
    // Many exports per file, mixing ASCII and non-ASCII to exercise both
    // CreateString branches (NewFromOneByte / NewFromUtf8).
    const body = Array.from({ length: 64 }, (_, k) =>
      `exports.a${i}_${k} = ${k};\nexports.u${i}_${k} = 'あ${k}';`,
    ).join('\n');
    writeFileSync(p, body);
    files.push(pathToFileURL(p).href);
  }
  for (let w = 0; w < N_WORKERS; w++) {
    new Worker(new URL(import.meta.url), { workerData: { files } });
  }
} else {
  await Promise.all(workerData.files.map((f) => import(f)));
}

If a maintainer can point us at a way to force String::NewFromUtf8(..., kInternalized) or Set::Add() to return empty MaybeLocal from user JS (e.g. via prototype accessors, à la #56531), we'd be happy to turn this into a deterministic regression test.

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

Probabilistic.

  • Real environment (Vite dev server + Ladle dev server started concurrently by Playwright webServer): ~1 in 5–10 full playwright test runs aborts during cold start.
  • Standalone reproducer above: irregular, requires high N_WORKERS × N_FILES. May need tuning on faster machines.

Required conditions seem to be:

Pinning to Node v24.13.1 makes the crash disappear in the real environment.

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

If V8 string creation or Set::Add returns an empty MaybeLocal (pending exception on the isolate, or allocation failure), cjs_lexer::Parse should propagate that as a JavaScript exception instead of aborting the process.

Parse is invoked from cjsPreparseModuleExports in lib/internal/modules/esm/translators.js, which sits on a normal JS frame and can handle a thrown exception — at worst it would surface as a load failure for the affected module, which is recoverable, debuggable, and contained.

The current behavior — OnFatalError aborting the whole process — is too aggressive for this binding: it is on the hot path of every ESM import of a CJS module, and gives user code no recovery path. The previous WASM-based cjs-module-lexer did not have an equivalent fatal route here, so this is a regression in observable behavior, not only an implementation detail.

What do you see instead?

The process aborts immediately. No JS-level error is thrown, no uncaughtException / unhandledRejection / process.on('exit') handler fires. The child exit is observed only by the parent process (Playwright, in our case).

Output:

FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal
----- Native stack trace -----

 1: 0x73f778 node::OnFatalError(char const*, char const*) [node]
 2: 0xc06584  [node]
 3: 0x8e7b85 node::cjs_lexer::Parse(v8::FunctionCallbackInfo<v8::Value> const&) [node]
 4: 0x7fb0abdcf08d

----- JavaScript stack trace -----

1: cjsPreparseModuleExports (node:internal/modules/esm/translators:393:44)
2: createCJSModuleWrap (node:internal/modules/esm/translators:212:35)
3: commonjsStrategy (node:internal/modules/esm/translators:349:10)
4: #translate (node:internal/modules/esm/loader:451:20)
5: afterLoad (node:internal/modules/esm/loader:507:29)
6: loadAndTranslate (node:internal/modules/esm/loader:512:12)
7: #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:555:36)
8: afterResolve (node:internal/modules/esm/loader:603:52)
9: getOrCreateModuleJob (node:internal/modules/esm/loader:609:12)
10: syncLink (node:internal/modules/esm/module_job:162:33)

Additional information

The native frame node::cjs_lexer::Parse corresponds to [src/node_cjs_lexer.cc](https://github.com/nodejs/node/blob/main/src/node_cjs_lexer.cc), introduced in #61456 / [68da144](68da144b4e) (replaced cjs-module-lexer with merve). Both CreateString and Parse contain unguarded .ToLocalChecked() calls:

  • L34: String::NewFromOneByte(...).ToLocalChecked() (in CreateString)
  • L41: String::NewFromUtf8(...).ToLocalChecked() (in CreateString)
  • L73: exports_set->Add(context, CreateString(...)).ToLocalChecked() (in Parse)

All three return empty MaybeLocal whenever the isolate has a pending exception or string/handle allocation fails. The current code unconditionally dereferences via ToLocalChecked(), which calls OnFatalError and aborts.

Suggested fix

Have CreateString return MaybeLocal<String> and propagate failure from Parse. Empty MaybeLocal becomes a JS exception (the pending one on the isolate, in most cases) rather than a fatal abort:

template <typename T>
inline MaybeLocal<String> CreateString(Isolate* isolate, const T& str) {
  std::string_view sv = lexer::get_string_view(str);
  if (simdutf::validate_ascii(sv.data(), sv.size())) {
    return String::NewFromOneByte(
        isolate,
        reinterpret_cast<const uint8_t*>(sv.data()),
        NewStringType::kInternalized,
        static_cast<int>(sv.size()));
  }
  return String::NewFromUtf8(isolate,
                             sv.data(),
                             NewStringType::kInternalized,
                             static_cast<int>(sv.size()));
}

void Parse(const FunctionCallbackInfo<Value>& args) {
  // ... (head unchanged) ...

  Local<Set> exports_set = Set::New(isolate);
  for (const auto& exp : analysis.exports) {
    Local<String> exp_str;
    if (!CreateString(isolate, exp).ToLocal(&exp_str)) return;
    Local<Set> next_set;
    if (!exports_set->Add(context, exp_str).ToLocal(&next_set)) return;
    exports_set = next_set;
  }

  LocalVector<Value> reexports_vec(isolate);
  reexports_vec.reserve(analysis.re_exports.size());
  for (const auto& reexp : analysis.re_exports) {
    Local<String> reexp_str;
    if (!CreateString(isolate, reexp).ToLocal(&reexp_str)) return;
    reexports_vec.push_back(reexp_str);
  }

  Local<Value> result_elements[] = {
      exports_set,
      Array::New(isolate, reexports_vec.data(), reexports_vec.size())};
  args.GetReturnValue().Set(Array::New(isolate, result_elements, 2));
}

Happy to send a PR with this change plus a regression test, once we agree on the expected behavior (throw vs. silently return empty results) and a way to deterministically trigger empty MaybeLocal from JS for the test.

Workaround

Pin to Node v24.13.x (last release before #61456). This makes the crash disappear in our environment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions