node:module: throw instead of crashing when _resolveFilename is set to a non-callable - #38089
node:module: throw instead of crashing when _resolveFilename is set to a non-callable#38089robobun wants to merge 1 commit into
Conversation
…o a non-callable The _resolveFilename setter stored any cell in the slot that the builtin function lives in and flagged it as an override, and the require() path then called it without checking that it is callable. Assigning an object or a string and calling require() segfaulted (debug: ASSERTION FAILED: overrideHandler->isCallable()). Values that are not cells were silently dropped. Keep the builtin in its own slot and store whatever was assigned in a separate WriteBarrier<Unknown>, so the property behaves like Node's plain data property: any value can be assigned and reads back, and require(), require.resolve() and createRequire() requires throw "TypeError: Module._resolveFilename is not a function" when the stored value cannot be called, which is Node's behavior.
|
Warning Review limit reached
Next review available in: 25 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 and test pushed, waiting on CI. Reproduced on the released 1.4.0 canary (da3851e) and on a debug build of main (04148c8): const Module = require("module");
Module._resolveFilename = {}; // or "a string"
require("./anything"); // release: Segmentation fault at address 0x5; debug: ASSERTION FAILED: overrideHandler->isCallable()
The equivalent problem with |
There was a problem hiding this comment.
Beyond the inline nit, I also checked: the new m_moduleResolveFilenameOverride slot is GC-visited via the visitGlobalObjectMember(WriteBarrier<T>&) overload wired through FOR_EACH_GLOBALOBJECT_GC_MEMBER; and the LazyProperty<JSCell> → LazyProperty<JSFunction> retype has no other consumers (only the getter and initLater reference m_moduleResolveFilenameFunction).
Extended reasoning...
The inline nit about Module.runMain covers the one same-class site; beyond that I verified the two memory-safety-adjacent concerns a reviewer would check first on a change that adds a field to Zig::GlobalObject: (1) the new WriteBarrier<Unknown> is declared inside FOR_EACH_GLOBALOBJECT_GC_MEMBER, and ZigGlobalObject.cpp has a visitGlobalObjectMember(Visitor&, WriteBarrier<T>&) overload that appends it, so the assigned override value is kept alive; (2) grepping the tree for m_moduleResolveFilenameFunction shows only the getter and the initLater in NodeModuleModule.cpp reference it, both updated for the JSFunction type, so nothing else is broken by the tightened element type.
|
Agreed that |
|
Heads up: #38099 adds |
|
Updated 5:35 AM PT - Aug 13th, 2026
❌ @robobun, your commit 8c280f3 has some failures in 🧪 To try this PR locally: bunx bun-pr 38089That installs a local version of the PR into your bun-38089 --bun |
Problem
require("module")._resolveFilename = {}(or a string, or any other non-callable object) followed byrequire("./x")orrequire.resolve("./x")crashes the process:panic(main thread): Segmentation fault at address 0x5in release builds,ASSERTION FAILED: overrideHandler->isCallable()atImportMetaObject.cpp:231(functionImportMeta__resolveSyncPrivate) in debug builds.TypeError: Module._resolveFilename is not a functionfrom therequire()call instead; the assignment itself is allowed and the property reads back as whatever was assigned.setNodeModuleResolveFilename(src/jsc/modules/NodeModuleModule.cpp:439) stores any cell intom_moduleResolveFilenameFunction, theLazyPropertythat otherwise holds the builtin, and setshasOverriddenModuleResolveFilenameFunction. Therequire()path (src/jsc/bindings/ImportMetaObject.cpp:228) then downcasts the stored cell to aJSObjectand calls it, trusting the assertion that it is callable. The same setter silently dropped non-cell values (undefined,null, numbers), soModule._resolveFilename = undefinedleft the builtin in place, unlike Node.Fix
WriteBarrier<JSC::Unknown>slot (m_moduleResolveFilenameOverride) on the global object for the user-assigned value. The setter stores whatever was assigned there (any value, as a plain data property would) and flags the override; assigning the builtin back clears both.m_moduleResolveFilenameFunctionnow only ever holds the builtin and is typedLazyProperty<JSFunction>accordingly. The getter returns the override while one is set.Zig::GlobalObjectbecause the rest of this feature's per-realm state (the builtin's slot andhasOverriddenModuleResolveFilenameFunction) already does, and it sits next to them; theModuleconstructor object itself is a plainInternalFunctionwith no fields of its own, so holding the value there would mean splitting the state across two objects.hasOverriddenModuleResolveFilenameFunctionis kept as the fast-path check (it is also whatbun:internal-for-testingexposes and what bun test --isolate: opt-in global reuse fast path #36871 snapshots).functionImportMeta__resolveSyncPrivatelooks up the override'sCallDataonce; when there is none it throwsTypeError: Module._resolveFilename is not a function, otherwise it calls through as before. Callable non-functions (e.g. aProxyaround a function) keep working because the check isgetCallData, not aJSFunctiontype check._resolveFilenamethere is a plain data property, so assignment always succeeds and reads back, and onlyModule._loadcalling it throws;require(),require.resolve()andcreateRequire()requires all reach that one call, and in Bun all three reach this one native function, so a single check covers them. ESM resolution does not consult_resolveFilenamein either runtime and is unchanged (the check is inside the existing!isESMbranch).test/js/node/module/node-module-module.test.js("Overwriting _resolveFilename with a non-callable makes require() throw like Node"), which runs a child that assigns{}, a string,undefined,null, a number and a symbol and checks that each reads back and thatrequire,require.resolveand acreateRequirerequire throw Node's message, that a callableProxyoverride is honored, and that restoring the original makesrequirework again. The child's expected output was generated by running the same script under Node v26.3.0. The test crashes the child on the released binary and passes with this change.test/js/bun/resolve/import-meta.test.js(covers the slow-path flag toggling),test/js/bun/resolve/builtin-esm-lazy-exports.test.ts(reads the getter when building thenode:modulenamespace) andtest/js/node/module/module-resolve-filename-paths.test.jswith the debug build; all pass.ImportMetaObject.cppis also being reworked by node:module: pass Node-compatible arguments to an overridden Module._resolveFilename #34102 (which changes the arguments passed to the override and keeps the assertion this PR removes); the two changes are independent, whichever lands second needs a small rebase of that hunk.Module.runMain(setModuleRunMain/NodeModuleModule__callOverriddenRunMain, same file) has the same setter and call-site pattern, reachable only when a preload assigns it. It is left out of this PR because the Rust entry-point loader currently reports any exception from a runMain override as a bareError occurred loading entry point: JSError, so a TypeError thrown there would be swallowed; the crash and that reporting need to be fixed together and are tracked as a separate change.Background
Module._resolveFilenamein Bun is a custom accessor on thenode:moduleconstructor rather than a data property. Assigning it runssetNodeModuleResolveFilename; reading it runs the matching getter. The nativerequire()fast path skips the JS-visible property entirely and only consults the override whenhasOverriddenModuleResolveFilenameFunctionis set, which is why the setter has to record the override somewhere native code can see it.LazyProperty<T>is JSC's "create on first access" slot; it can only hold aT*cell, which is why the old setter could not store primitives.WriteBarrier<Unknown>is a GC-visited slot that holds anyJSValue. Members ofZig::GlobalObjectdeclared throughFOR_EACH_GLOBALOBJECT_GC_MEMBERare visited by the garbage collector automatically, so the new slot keeps the assigned value alive.Error.prepareStackTracealready uses this same two-slot arrangement (builtin in aLazyProperty, user value in aWriteBarrier<Unknown>).JSC::getCallData(value)returnsCallData::Type::Nonefor anything that cannot be called (primitives, plain objects, strings) and a usableCallDatafor functions, bound functions and callable proxies;profiledCalltakes thatCallData, so computing it once serves both the check and the call.