node:module: check for an exception after wrapping a runMain override's return value - #38113
node:module: check for an exception after wrapping a runMain override's return value#38113robobun wants to merge 1 commit into
Conversation
…'s return value JSC__JSInternalPromise__resolvedPromise goes through promiseResolve, which can throw (a returned promise whose constructor getter throws) and returns null when it does. The has_patched_run_main branch in reload_entry_point stored that result unchecked: a debug build aborts under BUN_JSC_validateExceptionChecks for every override that does not call the original runMain, and a release build dereferences the null promise. Mark the binding [[ZIG_EXPORT(check_slow)]] and consume the generated wrapper, propagating the throw the same way as the override call itself.
|
Status: fix and tests pushed, waiting on CI. Reproduced on the unfixed build two ways:
With the fix, |
|
Warning Review limit reached
Next review available in: 4 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
There was a problem hiding this comment.
LGTM — targeted exception-check fix that mirrors the sibling __resolve/__reject bindings.
What was reviewed:
- Confirmed
JSC__JSInternalPromise__resolvedPromise's only caller is the one updated inreload_entry_point; the removed hand-written extern has no other users. - Verified the generated
check_slowwrapper's return type (JsResult<*mut JSPromise>) is compatible withpending_internal_promise: Option<*mut JSInternalPromise>—JSInternalPromiseis a re-export alias ofJSPromise(src/jsc/lib.rs:206). - Error mapping to
CrateError::JSErrormatches the override call two lines above; the throwing case now exits 1 instead of null-derefing. - New tests drain both pipes concurrently, assert the combined
{stdout, stderr, exitCode}object, and cover eachpromiseResolveoutcome including the throwing.constructorgetter.
Extended reasoning...
Overview
Three files changed. In bindings.cpp, JSC__JSInternalPromise__resolvedPromise gains [[ZIG_EXPORT(check_slow)]] (plus a two-line comment), so cppbind.ts generates a crate::cpp:: wrapper that opens a top_scope!, calls the extern, and returns Err(JsError::Thrown) if an exception is pending. In VirtualMachine.rs, the hand-written extern "C" declaration is removed and the single call site in the has_patched_run_main branch of reload_entry_point switches to the generated wrapper with .map_err(|_| CrateError::JSError)?. In node-module-module.test.js, the two existing Module.runMain subprocess tests now set BUN_JSC_validateExceptionChecks=1 and assert empty stderr, and a new five-entry test.each covers plain values, native promises, fulfilling/rejecting thenables, and a promise with a throwing constructor getter.
Security risks
None. The change adds an exception check where one was missing; no new user-controlled data reaches native code, and no validation is loosened.
Level of scrutiny
Low-to-moderate. The fix is mechanical: it applies the same check_slow attribute already on the adjacent JSC__JSInternalPromise__resolve / __reject bindings (bindings.cpp:4032, 4066) and routes through the codegen'd wrapper the way src/jsc/cpp.rs documents. The only behaviour change is that a pending exception after promiseResolve now returns CrateError::JSError instead of storing a null pointer that later panics in opaque_deref. I checked that JSInternalPromise is pub use JSPromise as JSInternalPromise (src/jsc/lib.rs:206), so the generated *mut JSPromise return type is assignable to pending_internal_promise without a cast. The removed extern had exactly one caller (grep confirms only bindings.cpp, headers.h, and this call site reference the symbol).
Other factors
The tests follow harness conventions well: tempDir for fixtures, await using on the spawned process, concurrent Promise.all drain of stdout/stderr/exited, and a single combined-object assertion. The validateExceptions env is a no-op on release builds (per the comment and PR description), so release CI still exercises the throwing-constructor case as a real behaviour change while debug lanes additionally guard against validator regressions. The PR description explains why check_slow was chosen over null_is_throw (matches JSC's own callers of resolvedPromise, and covers termination exceptions), which was the one non-obvious choice.
|
Updated 7:45 AM PT - Aug 13th, 2026
✅ @robobun, your commit 71f5d7b07e6048532f95300e893d3e0170e4b0ad passed in 🧪 To try this PR locally: bunx bun-pr 38113That installs a local version of the PR into your bun-38113 --bun |
Problem
--requirepreload that replacesModule.runMainwith a function that does not call the original makes bun wrap the override's return value in a promise. That wrap is never checked for an exception. On a debug build this aborts under JSC's exception-check validator with the existing test fixtures as-is:constructorgetter throws givespanic: opaque_deref: null FFI handle(bun 1.4.0 canary, linux x64), because the null result is stored as the entry point promise and then dereferenced inload_entry_point/Run::start.JSC__JSInternalPromise__resolvedPromise(src/jsc/bindings/bindings.cpp:4072) is a bare wrapper overJSC::JSPromise::resolvedPromise, which runspromiseResolveand returns null if it throws. Its only caller, thehas_patched_run_mainbranch ofreload_entry_point(src/jsc/VirtualMachine.rs:2714), used the result directly. The override call two lines above it is wrapped infrom_js_host_call_generic; this call was not.test/js/node/module/node-module-module.test.js(test/no-validate-exceptions.txt), and the Node fixturemonkey-patch-run-main.jscalls through to the originalrunMain, which takes the other branch.Fix
JSC__JSInternalPromise__resolvedPromise[[ZIG_EXPORT(check_slow)]], matching theJSC__JSInternalPromise__resolve/__rejectbindings next to it, and havereload_entry_pointcall the generatedcrate::cpp::wrapper, which opens a scope around the call and returnsErrwhen an exception is pending. The hand-writtenextern "C"declaration goes away (src/jsc/cpp.rsasks new code to use the generated wrappers instead of redeclaring).CrateError::JSError, exactly like the override call on the line above, so a throw while adopting the return value is reported the same way as a throw inside the override (Error occurred loading entry point, exit 1) instead of a crash. Making that report print the underlying error, and a non-callablerunMainoverride crashing, are separate defects being handled separately; both sites will pick up whatever that change does.check_slowrather thannull_is_throw: JSC's own callers ofJSPromise::resolvedPromisedoRETURN_IF_EXCEPTIONand then trust the pointer (JSPromiseConstructor.cpp, JSWebAssembly.cpp).check_slowis that exact shape, and it also covers the case wherepromiseResolvereturns a promise but leaves a termination exception pending. Once the wrapper returnsOk, the pointer is non-null by the same contract JSC relies on.test/js/node/module/node-module-module.test.js:Module.runMain/Module.runMain 2now run their fixtures underBUN_JSC_validateExceptionChecks=1and assert empty stderr.Module.runMain override returning ...table covers each waypromiseResolvecan go: a plain value, a pending native promise, a fulfilling thenable, a rejecting thenable (error reported, exit 1), and a promise whoseconstructorgetter throws (exit 1, no crash).constructorgetter case crashes). Fixed debug build: all 7 pass, whole file 44 pass.test/js/node/test/parallel/test-module-run-main-monkey-patch.jspasses on the fixed build, with and without the validator.test/no-validate-exceptions.txt: running it under the validator still trips theModule.wrapsite owned by node:module: check exceptions from jsString/jsSubstring in Module.wrap and new Module() #34745 (and node:module: check exceptions from the parent lookup in the native require resolver #38099 covers the_resolveFilenamepath). Once those land the entry can be dropped.BUN_JSC_validateExceptionChecksis a no-op on release builds, so there the new table only adds theconstructorgetter case as a before/after difference.Background
Module.runMainsetsvm.has_patched_run_main.reload_entry_pointthen calls the override instead of loading the entry itself. If the override called the originalrunMain, that stored the module promise aspending_internal_promise; otherwise bun adopts the override's return value with Promise.resolve semantics and stores that, so an async override's rejection is reported as the entry point failing.promiseResolve: the spec's PromiseResolve. For a promise argument it reads.constructor(observable user code, can throw) to decide whether to return the promise as-is; a thenable is adopted via a microtask; anything else becomes a fulfilled promise. A throwingthengetter does not throw here (it rejects the new promise); theconstructorgetter is the throwing case.BUN_JSC_validateExceptionChecks=1, debug builds only): every JSC throw scope records on destruction that its caller owes an exception check; creating the next scope before that check happens aborts and prints both locations. That is why even the non-throwing plain-value fixture aborts: nothing between the wrap and the next scope ever looked at the exception state.[[ZIG_EXPORT(mode)]]: attribute read bysrc/codegen/cppbind.ts, which generates a Rust wrapper inbun_jsc::cppfor each annotated C++ function.check_slowmeans the return value says nothing about whether the call threw, so the wrapper opens aTopExceptionScope, makes the call, and converts a pending exception intoErr(JsError::Thrown);null_is_throwinstead asserts that a null return and a pending exception always coincide.Behaviour of each return shape on the unfixed build (debug + validator unless noted)
overwrite-module-run-main-3.cjs)promiseResolveasync () => { await 0; ... }(pending native promise)thenruns, exit 0constructorgetterpanic: opaque_deref: null FFI handle(release too)Error occurred loading entry point, exit 1thengetter throwsload_entry_pointreturns early on an already-rejected promise and the reporting path checks the exception state)runMain(overwrite-module-run-main-1.cjs, Node'smonkey-patch-run-main.js)