node:module: throw instead of crashing when a preload sets runMain to a non-callable, and report a throwing override - #38119
node:module: throw instead of crashing when a preload sets runMain to a non-callable, and report a throwing override#38119robobun wants to merge 5 commits into
Conversation
…able runMain, and report a throwing override
Assigning a non-function to require("module").runMain in a preload made
the entry point call it anyway (segfault in release, callable assertion
in debug), and an override that threw was reported only as
"Error occurred loading entry point: JSError".
Store whatever is assigned in a WriteBarrier<Unknown> slot, like Node's
plain data property, and check callability when the entry point calls
it, throwing "Module.runMain is not a function". A thrown exception now
comes back to reload_entry_point as a handled rejected entry promise,
so the run command prints it and consults uncaughtException handlers
with origin "uncaughtException", as Node does for its synchronous
bootstrap call. The override is also called with Module as `this`.
…rrides live elsewhere
|
Warning Review limit reached
Next review available in: 3 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 (4)
Comment |
|
Status: fix is up for review. Reproduced on the released build (1.4.0-canary.1, linux x64) with
With this branch each case exits 1 with the real error ( |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks entry-point failure reporting across the C++/Rust FFI boundary and adds a new GC-visited slot on the global object, a human look would still be worthwhile.
Checked: the new WriteBarrier<Unknown> is declared in FOR_EACH_GLOBALOBJECT_GC_MEMBER so it's visited; the removed hasOverriddenModuleRunMain bool has no remaining references; JSInternalPromise is an alias for JSPromise so the Err arm's &mut JSPromise coerces to the annotated *mut JSInternalPromise. Verified the C++ signature switch to EncodedJSValue matches Rust's #[repr(transparent)] JSValue, and that from_js_host_call's zero-is-throw contract is satisfied by RETURN_IF_EXCEPTION(scope, {}). Confirmed reject(.., Err(JsError::Thrown)) takes the pending exception (left set by call_zero_is_throw) and set_handled() before reject prevents a duplicate unhandled-rejection report.
Extended reasoning...
Overview
Fixes a segfault when a preload assigns a non-callable to Module.runMain, and makes a throwing override report through uncaughtException instead of a bare JSError name. Touches four files: ZigGlobalObject.h (new WriteBarrier<Unknown> m_moduleRunMainOverride, retyped m_moduleRunMainFunction to LazyProperty<JSFunction>, dropped hasOverriddenModuleRunMain), NodeModuleModule.cpp (getter/setter now round-trip any JSValue via the new slot; callOverriddenRunMain uses JSC::call(..., message) with a throw scope and the Module object as this; signature moved to EncodedJSValue), VirtualMachine.rs (the Err path now creates a handled+rejected JSPromise and sets evaluated_as_cjs so the run command reports it via uncaught_exception with origin uncaughtException), and 11 new subprocess tests.
Security risks
None. This is Node-compat error handling for a preload's runMain override; no auth, network, filesystem, or untrusted-input parsing. The change turns a crash into a catchable error.
Level of scrutiny
Medium-high. The individual edits are small and follow the existing Error.prepareStackTrace pattern (builtin in a LazyProperty, user value in a WriteBarrier<Unknown>), but they sit on the entry-point bootstrap path across a C++↔Rust FFI boundary with GC-visited state and promise-rejection-tracker semantics. A regression here would affect every process that preloads a runMain wrapper.
Other factors
- Verified
JSInternalPromiseis a type alias forJSPromise(src/jsc/lib.rs:206), so theErrmatch arm's&mut JSPromisecoerces to the annotated*mut JSInternalPromiseand is a validpending_internal_promisevalue; the caller (run_command.rs:1441) reads the promise's rejection through theAnyPromise/status_ptrpath that dispatches on the sharedJSPromiselayout. - Verified
from_js_host_call=call_zero_is_throw, which leaves the exception pending and returnsErr(JsError::Thrown);JSPromise::reject(.., Err(Thrown))thentry_take_exception()s it, so the exception is consumed exactly once. hasOverriddenModuleRunMainhas no remaining references anywhere insrc/;has_patched_run_mainon the VM (set byBun__VirtualMachine__setOverrideModuleRunMain) is whatreload_entry_pointstill keys on, and the setter still calls it unconditionally.- The C++ signature change from by-value
JSValuetoEncodedJSValuealigns with the Rust declaration (argv1: JSValue,#[repr(transparent)]overusize). - Tests cover the full variant matrix (object/string crash cases, primitive silently-ignored cases, throwing function, class,
uncaughtExceptionlistener + origin, read-back, restore-original,this === Module) and are subprocess-isolated with pipes drained concurrently. - Deferring rather than approving because entry-point bootstrap + new GC-visited global-object state is the kind of change a maintainer should sign off on, not because I found anything wrong.
|
Updated 9:44 AM PT - Aug 13th, 2026
✅ @robobun, your commit fb874b035c6505c0b573ff8084e32caa9f336771 passed in 🧪 To try this PR locally: bunx bun-pr 38119That installs a local version of the PR into your bun-38119 --bun |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. It touches the entry-point load path in VirtualMachine::reload_entry_point and adds a GC-visited slot on ZigGlobalObject, so a human look would still be worthwhile.
What was reviewed:
- Verified
m_moduleRunMainOverrideis declared underFOR_EACH_GLOBALOBJECT_GC_MEMBERso it is visited, andhasOverriddenModuleRunMainhas no remaining references. - Checked the FFI shape: Rust
JSValueis#[repr(transparent)]over the encoded word, matching the C++ side's switch toJSC::EncodedJSValue;from_js_host_callmatches the new zero-is-throw contract. - Confirmed
JSInternalPromisealiasesJSPromise, so theErrarm'sJSPromise::createresult unifies with the*mut JSInternalPromisebinding;set_handled()is called beforereject()as intended. - Checked
JSC::call(..., errorMessage)throws for non-callables and thatRETURN_IF_EXCEPTIONcovers the call; the setter now stores every value (including primitives) and clears the override when the builtin is reassigned.
Extended reasoning...
Overview
This PR fixes a segfault when a preload assigns a non-callable to require("module").runMain, and makes a throwing override report its actual exception (and reach uncaughtException listeners) instead of a bare JSError. It touches four files: VirtualMachine.rs (entry-point load path), ZigGlobalObject.h (a new WriteBarrier<Unknown> slot for the override, retyping the builtin slot to LazyProperty<JSFunction>, and dropping the now-unused hasOverriddenModuleRunMain bool), NodeModuleModule.cpp (getter/setter/call rewritten around the new slot, using JSC::call with an error message and passing the Module object as this), and node-module-module.test.js (11 new subprocess/worker tests).
Security risks
None identified. The change validates a user-supplied override at call time and turns a crash into a catchable TypeError; no new inputs are trusted.
Level of scrutiny
Moderate-to-high. reload_entry_point is on the startup path of every bun run, and ZigGlobalObject.h adds GC-visited state. The changes follow the existing Error.prepareStackTrace pattern (builtin in a LazyProperty, user value in a WriteBarrier<Unknown>), and the C++ exception-scope discipline (DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION) is correct. The Rust side's new Err arm creates a promise, marks it handled, then rejects it — matching how the module loader pre-marks its own entry promises. I confirmed JSInternalPromise is a re-export of JSPromise (src/jsc/lib.rs), so the mixed types in the match unify.
Other factors
- The C++
argv1parameter changed fromJSValue(a class by value acrossextern "C") toJSC::EncodedJSValue, which now exactly matches the Rust extern's#[repr(transparent)]JSValue— a small ABI hardening. - The call now passes the Module object as
thisinstead of the global object; this is a behavioral change (matching Node) covered by a new test. - The comment-cop bot left six automated notes about multi-line comments; those are style-linter noise rather than correctness issues, but the author may want to trim the longer one in the
Errarm. - Test coverage is thorough: non-callable variants, throwing function, class,
uncaughtExceptionorigin, worker preloads, and round-trip read-back — all subprocess-isolated with pipes drained viaPromise.all.
Given the critical-path surface (VM entry-point loading, a new GC-visited member on the global object), I'm deferring rather than auto-approving.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Since it reworks error handling on the entry-point load path in VirtualMachine::reload_entry_point and adds a new GC-visited slot on ZigGlobalObject, a human look would still be worthwhile.
What was reviewed:
hasOverriddenModuleRunMainhas no remaining references; removal is safe.JSInternalPromiseis a re-export ofJSPromise(src/jsc/lib.rs), soJSPromise::createcoercing to*mut JSInternalPromisein theErrarm type-checks and stores correctly inpending_internal_promise.from_js_host_callleaves the exception pending onErr(Thrown), andJSPromise::reject(.., Err(Thrown))takes it viatry_take_exceptionbefore rejecting — the exception is not lost or double-reported.m_moduleRunMainOverrideis declared insideFOR_EACH_GLOBALOBJECT_GC_MEMBER, so it is visited automatically.
Extended reasoning...
Overview
This PR fixes a segfault when a preload assigns a non-callable to Module.runMain, and improves error reporting when a runMain override throws. It touches four files: ZigGlobalObject.h (adds WriteBarrier<Unknown> m_moduleRunMainOverride, retypes m_moduleRunMainFunction to LazyProperty<JSFunction>, removes the unused hasOverriddenModuleRunMain bool), NodeModuleModule.cpp (getter/setter now round-trip any JSValue through the new slot; callOverriddenRunMain uses JSC::call with a TypeError message and follows the zero-is-throw convention; this is now the Module object), VirtualMachine.rs (on exception, wraps it in a handled rejected promise stored as pending_internal_promise with evaluated_as_cjs = true so the run command / worker report it via the normal uncaught-exception path), and 13 new tests covering non-callables, throwing overrides, uncaughtException dispatch, Worker preloads, and value round-tripping.
Security risks
None identified. This is Node-compat error handling for a niche override hook (Module.runMain), only reached when a preload script explicitly reassigns it. No new user-controlled input reaches native parsing, and the change replaces an unchecked downcast+call with a type-checked JSC::call — strictly safer than before.
Level of scrutiny
Moderate-to-high. The C++ side is straightforward (mirrors the existing Error.prepareStackTrace builtin+override pattern on the same global object), but the Rust side alters control flow in reload_entry_point, which every bun run and every Worker startup goes through. The Err arm creates a JSPromise, marks it handled before rejecting, and stores it as the entry promise — I traced this against the callers (load_entry_point, hot-reload reporting, worker startup) and it looks consistent with how the existing paths store and inspect pending_internal_promise, but a maintainer familiar with that machinery should confirm the interaction with --hot and pending_internal_promise_reported_at.
Other factors
The comment-cop bot flagged verbose comments earlier; those were trimmed in fb874b0 and all threads are resolved. Test coverage is thorough (variant matrix over non-callable types, throwing function vs class, uncaughtException listener, Worker preload). CI build #94499 was still running at review time. The PR description notes BUN_JSC_validateExceptionChecks=1 was run against the repro scripts. No prior human review; no CODEOWNERS I could see for these paths.
Problem
require("module").runMaincrashes when Bun goes to run the entry point:panic(main thread): Segmentation fault at address 0x4in release builds (ASSERTION FAILED: Expected object to be callable but received 0in debug builds). Repro:echo 'require("module").runMain = {}' > preload.cjs; bun -r ./preload.cjs main.js. A string (runMain = "x") crashes the same way. Node exits 1 withTypeError: ... runMain is not a function.runMain = undefined/null/5were silently ignored: the property still read back as the builtin and main ran. Node stores the value and then fails with the same TypeError.new) is reported only asError occurred loading entry point: JSError, exit 1. The exception itself is never printed andprocess.on("uncaughtException")listeners are not consulted. Node prints the error / runs the listeners.preloadoption: a non-callablerunMainthere segfaults the whole process, and a throwing one closes the worker with exit code 1 but never fires itserrorevent.setModuleRunMain(src/jsc/modules/NodeModuleModule.cpp:832) stores any cell intom_moduleRunMainFunctionand drops non-cells;NodeModuleModule__callOverriddenRunMain(:824) downcasts the stored cell toJSObjectand calls it without checking that it is callable.reload_entry_point(src/jsc/VirtualMachine.rs:2705) maps any exception from that call toCrateError::JSError, andentry_point_load_failed(src/runtime/cli/run_command.rs:1653) prints only the error name.Fix
WriteBarrier<JSC::Unknown>slot (m_moduleRunMainOverride), empty while the property holds the builtin. The setter stores any value there (assigning the builtin back clears it), the getter returns it, andm_moduleRunMainFunctiononly ever holds the builtin (typedLazyProperty<JSFunction>accordingly). The unusedhasOverriddenModuleRunMainbool is removed; the slot being non-empty is that state.NodeModuleModule__callOverriddenRunMaincalls the current value throughJSC::call(..., "Module.runMain is not a function"_s), which throws that TypeError for anything without call data and otherwise propagates what the override throws. It now follows the zero-means-threw convention, and passes the Module object asthis, which is how Node's bootstrap calls it (Module.runMain(main)); previouslythiswas the global object.pending_internal_promiseand returned, so the run command reports it exactly like an entry module that throws (prints it, or consultsuncaughtExceptionlisteners,--hotkeeps running), and a worker's startup code reports it as that worker'serrorevent.evaluated_as_cjsis set so the origin passed to listeners isuncaughtException, matching Node, where this throw happens synchronously in the bootstrap. The success path (use the promise the override stored by calling the builtin, else wrap the return value) is unchanged, so anasyncoverride that rejects is still reported as a rejection with originunhandledRejection, also as in Node.Module.runMainthere is a plain data property, so assignment always succeeds and reads back, and the failure happens when the bootstrap calls it, as an ordinary uncaught exception. Checking callability at call time (not in the setter) also keeps code working that assigns a placeholder and restores the function before main runs. Marking the rejected promise handled up front is what the module loader does for its own entry promises, so the failure is reported once, by the caller, and never also as an unhandled rejection.test/js/node/module/node-module-module.test.js: non-callable values ({}, a string,undefined,null,5) exit 1 with the TypeError and main does not run; a throwing function and a class report the actual error; both failure kinds reachprocess.on("uncaughtException")with originuncaughtException; assigned values read back and restoring the builtin works; an override is called withModuleasthisand can call through to the builtin; a throwing and a non-callable override in a Worker'spreloadsurface as that worker'serrorevent with exit code 1. All 13 fail on the released build (the object/string rows crash the child) and pass with this change; the two existingModule.runMaintests still pass.test/cli/run/preload-test.test.js,test/config/bunfig/preload.test.ts,test/js/bun/resolve/bun-main-entry-point.test.ts,test/internal/source-lints/, and the repro scripts underBUN_JSC_validateExceptionChecks=1.Module._resolveFilenameand deliberately leftrunMainto this change because of the reporting half; the two touch adjacent lines ofZigGlobalObject.hand are otherwise independent.Background
-r/--require/ bunfigpreloadscripts run before the entry point.require("module").runMainis a custom accessor on thenode:moduleobject; when a preload assigns it, the setter tells the VM (has_patched_run_main) andreload_entry_pointthen runs the entry point by calling the assigned value instead of loadingbun:mainitself, mirroring Node, whose bootstrap ends withModule.runMain(mainPath)so that preloads can wrap it. The builtinrunMainloads the entry and hands its promise to the VM viasetOverrideModuleRunMainPromise.reload_entry_pointreturns the entry point's promise to the run command (run_command.rs) or the worker startup code. If that promise is already rejected, they report the rejection throughuncaught_exception, which prints the error or dispatches it toprocess.on("uncaughtException")listeners with an origin string chosen fromentry_point_result.evaluated_as_cjs(uncaughtExceptionfor a synchronously thrown CJS entry,unhandledRejectionotherwise). ReturningErrinstead goes toentry_point_load_failed, which only knows the error's name.LazyProperty<T>is a create-on-first-use slot that can only hold aT*cell, which is why the old setter had to drop primitives.WriteBarrier<Unknown>is a GC-visited slot holding anyJSValue; members declared inFOR_EACH_GLOBALOBJECT_GC_MEMBERare visited automatically.Error.prepareStackTraceon the same global object already uses this builtin-in-LazyProperty plus user-value-in-WriteBarrier arrangement.JSC::call(globalObject, callee, thisValue, args, message)looks up the callee'sCallData(functions, bound functions and callable proxies qualify; primitives, plain objects and strings do not), throws a TypeError withmessageif there is none, and otherwise performs the call. Returning an emptyEncodedJSValuewith an exception pending is the conventionfrom_js_host_callchecks on the Rust side.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/module/node-module-module.test.js