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.
Version
v24.14.0
Platform
We have only reproduced this on GitHub Actions
ubuntu-latestrunners 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
MaybeLocalonly 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'swebServeroption, 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
nodeonly, 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 raiseN_WORKERS/N_FILES. It exercises the same code path (cjsPreparseModuleExports→cjs_lexer.parse) under similar pressure to the real failure.If a maintainer can point us at a way to force
String::NewFromUtf8(..., kInternalized)orSet::Add()to return emptyMaybeLocalfrom 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.
webServer): ~1 in 5–10 fullplaywright testruns aborts during cold start.N_WORKERS×N_FILES. May need tuning on faster machines.Required conditions seem to be:
>= v24.14.0(the version that switched fromcjs-module-lexer(WASM) tomerve(native) in build,deps: replace cjs-module-lexer with merve #61456 / 68da144). Not reproducible onv24.13.x.Pinning to Node
v24.13.1makes the crash disappear in the real environment.What is the expected behavior? Why is that the expected behavior?
If V8 string creation or
Set::Addreturns an emptyMaybeLocal(pending exception on the isolate, or allocation failure),cjs_lexer::Parseshould propagate that as a JavaScript exception instead of aborting the process.Parseis invoked fromcjsPreparseModuleExportsinlib/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 —
OnFatalErroraborting 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-basedcjs-module-lexerdid 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:
Additional information
The native frame
node::cjs_lexer::Parsecorresponds to[src/node_cjs_lexer.cc](https://github.com/nodejs/node/blob/main/src/node_cjs_lexer.cc), introduced in #61456 / [68da144](68da144b4e) (replacedcjs-module-lexerwithmerve). BothCreateStringandParsecontain unguarded.ToLocalChecked()calls:String::NewFromOneByte(...).ToLocalChecked()(inCreateString)String::NewFromUtf8(...).ToLocalChecked()(inCreateString)exports_set->Add(context, CreateString(...)).ToLocalChecked()(inParse)All three return empty
MaybeLocalwhenever the isolate has a pending exception or string/handle allocation fails. The current code unconditionally dereferences viaToLocalChecked(), which callsOnFatalErrorand aborts.Suggested fix
Have
CreateStringreturnMaybeLocal<String>and propagate failure fromParse. EmptyMaybeLocalbecomes a JS exception (the pending one on the isolate, in most cases) rather than a fatal abort: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
MaybeLocalfrom JS for the test.Workaround
Pin to Node
v24.13.x(last release before #61456). This makes the crash disappear in our environment.