Skip to content

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

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 (cjsPreparseModuleExports → cjs_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.

Activity

  1. makimaki commented on May 18, 2026

    @makimaki
    Author

    Follow-up: I want to correct the repro framing in the original report.

    We've since identified that the actual trigger in our environment was specifically vite-node's eager preparse pattern during cold start — not the parallel webServer setup I described. Switching the relevant script from vite-node to tsx (which delegates module resolution to Node's standard loader rather than running its own analysis pass) eliminated the crash: a previously near-100% failure rate across 5 parallel CI test shards became all-green.

    So the original "multiple webServers in parallel" framing was a red herring — that's just where our setup happened to surface the condition, not the underlying trigger. The actual trigger appears to be the volume / timing of CJS preparse calls that vite-node concentrates during startup.

    The source-level diagnosis still stands, however: src/node_cjs_lexer.cc contains unguarded ToLocalChecked() calls (CreateString L34/L41, Parse L73) that will abort the process on any v8 string/handle allocation failure or pending exception, regardless of which workload is driving the preparse. The suggested fix (return MaybeLocal<String> from CreateString, propagate via ToLocal from Parse) still applies and would convert future occurrences from fatal aborts into recoverable JS exceptions for any caller.

    I no longer have a reliable repro since we moved off vite-node, so I can't offer a deterministic regression test. Happy to leave this open if the defensive fix is still considered worthwhile, or to close if a definitive repro is required.

  2. alvins82 commented on May 30, 2026

    @alvins82

    Also faced this issue. Downgraded node as a result to avoid it.

  3. Valodim commented on Jun 8, 2026

    @Valodim

    Same issue, happens on a wxt prepare for us. Downgrade as suggested helps.

  4. skylerwshaw commented on Jun 12, 2026

    @skylerwshaw

    We're experiencing this issue at Zapier on a few projects. Thank you @makimaki for the detailed report and mitigation.

  5. guybedford commented on Jun 12, 2026

    @guybedford
    Contributor

    //cc @anonrig for visibility.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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