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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions