Repository navigation
Conversation
…the store JSCommonJSExtensions::put and defineOwnProperty updated the native loader table before asking the base class to apply the operation and ignored its result, so a store refused by a frozen require.extensions, a non-writable or non-configurable entry, or a non-extensible object was still registered and require() dispatched to a handler the object never accepted. An attribute-only defineProperty (what Object.freeze performs on every entry) carried no value and was mirrored as an unregister, dropping every custom hook. All three mutators now apply the base operation first and mirror the entry only when its own value actually changed, reading the value back from the object.
|
Warning Review limit reached
Next review available in: 8 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 (2)
Comment |
|
Status: reproduced and fixed, waiting on CI. Reproduced on bun 1.4.0 ( |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The change is small and well-reasoned, but it rewires the JSC method-table hooks (put/defineOwnProperty/deleteProperty) that keep the native require() dispatch table in sync, so a maintainer familiar with JSCommonJSExtensions / NodeModuleModule should give it a look.
What was reviewed:
mutateExtensioncontrol flow: refused ops return false before the secondgetDirect, so a pending TypeError from strict-modeBase::put/defineOwnPropertyis propagated without touching the table.isExtensionName'spublicName()null-check subsumes the oldisSymbol()guard; non-.keys still gate onisAllowedToMutateExtensionsbefore falling through to the base op, matching the old order.- The
after != beforeshort-circuit means a same-value re-assignment (andObject.freeze's attribute-only redefines) no longer callonAssign— the PR argues this is required so freeze doesn't flip builtin.jsto the JSX-less loader; the newclassic-jsx.jsfixture pins that. - Accessor descriptors:
getDirectreturns the GetterSetter cell,getCallDataon it is None, so it still unregisters — same limitation as before, no regression.
Extended reasoning...
Overview
The PR touches one C++ file (src/jsc/bindings/JSCommonJSExtensions.cpp) and adds three tests to test/js/node/module/require-extensions.test.ts. It fixes two related bugs: (1) a store to require.extensions that the object refuses (frozen, non-writable, non-extensible) still updated the native dispatch table, so require() would call a handler that never became a property of the object; (2) an attribute-only defineProperty (as Object.freeze performs on every key) was treated as an unregister because descriptor.value() is empty. The fix collapses put/defineOwnProperty/deleteProperty into a shared mutateExtension helper that reads the own value with getDirect before and after the base-class operation and only mirrors into the native table when the operation succeeded and the stored value actually changed.
Security risks
None identified. No untrusted input parsing, no allocation, no path handling. The change narrows when the native table is updated; it cannot cause a handler to be registered that wasn't before.
Level of scrutiny
Medium-high. The diff is ~30 net lines, but it sits on the JSC method-table hooks for a per-global object whose native mirror is what CommonJS require() actually consults. Subtleties I checked: exception propagation (a throwing Base::put returns false, so we bail before the second getDirect and never call onAssign with an exception pending — matches the author's BUN_JSC_validateExceptionChecks=1 claim), empty-vs-empty JSValue equality (two default JSValue() compare equal, so deleting a non-existent key or a rejected new-key store is a no-op), and the uncheckedDowncast<JSCommonJSExtensions>(cell) used to get a JSObject* for getDirect (safe: these static hooks are only reached on cells of this class, and Base = JSDestructibleObject is a JSObject).
Other factors
The one behavior change beyond the headline fix is that after != before now skips onAssign when the same value is re-assigned. The PR description argues convincingly that this is required (otherwise Object.freeze would re-register every builtin as an explicit loader and switch .js from the default JSX-accepting loader to the strict JS loader), and the new test's classic-jsx.js fixture pins it. Existing tests that restore require.extensions['.js'] = original after overriding it still trigger onAssign because before (the mock) ≠ after (the builtin), so no regression there. Test coverage is thorough across the variant matrix (frozen / non-writable / non-configurable / non-extensible, strict throw vs sloppy silent vs Reflect.set false, in-process and spawned), and the author reports the full test/js/node/module/ suite plus the two related regression tests pass. Given the subtlety of the JSC-bindings surface and the intentional secondary behavior change, a human sign-off from someone who owns this code is still worthwhile.
|
Confirming the one behavior change the review calls out is intentional: a store that leaves the entry holding the same value (a same-value re-assignment, or the attribute-only redefines Object.freeze performs) no longer touches the native table. That is what keeps freezing the object from re-registering the builtin entries as explicit handlers; the frozen-object test pins it with the JSX fixture. |
|
Updated 7:05 AM PT - Aug 15th, 2026
❌ @robobun, your commit 522c0ff has some failures in 🧪 To try this PR locally: bunx bun-pr 38991That installs a local version of the PR into your bun-38991 --bun |
|
Updated 10:12 AM PT - Aug 15th, 2026
✅ @robobun, your commit 522c0ff8808eb6b40065c070569e1920db3652f9 passed in 🧪 To try this PR locally: bunx bun-pr 38991That installs a local version of the PR into your bun-38991 --bun |
Problem
require.extensions/Module._extensionsthat the object itself refuses still registers the handler with the loader, sorequire()dispatches to a function that never became an entry of the object. Refused stores that reproduce this on bun 1.4.0 and on main: assignment to a frozenrequire.extensionsor to an entry made non-writable (silently refused in sloppy mode,TypeErrorin strict mode),Reflect.setreturning false,Object.defineProperty/Reflect.definePropertyon a non-configurable entry, and assignment ordefinePropertyof a new entry on a non-extensible object. Node dispatches to the entry the object actually holds in every one of these cases.definePropertycalls that only change attributes as an unregister, because the descriptor carries no value.Object.freeze(require.extensions)performs exactly such a call for every entry, so on 1.4.0 freezing the object silently drops every custom hook registered before it (a.hookedhandler registered and then frozen in place is ignored and the file is loaded as JavaScript);Object.defineProperty(require.extensions, ".x", { writable: false })drops the.xhook the same way.JSCommonJSExtensions::putandJSCommonJSExtensions::defineOwnProperty(src/jsc/bindings/JSCommonJSExtensions.cpp) calledonAssign, which writes the native table, beforeBase::put/Base::defineOwnPropertydecided whether the operation is allowed, and ignored the result.defineOwnPropertyadditionally mirroreddescriptor.value(), which is empty for attribute-only descriptors.deletePropertyalready did it the other way round.Fix
put,defineOwnPropertyanddeletePropertynow share one helper: read the object's own value for the key, apply the base class operation, and if it succeeded and the own value is now a different value, mirror that value (or its absence) into the native table.require()in Node readsModule._extensions[ext]at load time, so what it dispatches on is, by definition, the entry the object holds. Bun dispatches on a native table instead, and the table is only correct while it tracks the entries the object holds. Reading the entry back after the base class has applied the operation makes the table follow the object whatever the outcome: a refused store leaves the entry unchanged and is not mirrored, an attribute-only define leaves the value unchanged and is not mirrored, a delete or an accepted store changes it and is mirrored. Mirroring the assigned value on success would have been enough for refused stores, butObject.freezewould then have re-registered every builtin entry as an explicit handler, and an explicitly registered.jshandler loads files with the JSX-less loader (test/regression/issue/require-extensions-override.test.tspins that), so freezing would have changed how.jsfiles load. The read-back keeps freeze a no-op for dispatch, as it is in Node.isAllowedToMutateExtensionsstill runs first for every key), and accessor descriptors (still mirrored as an unregister, the limitation tracked with theModule._extensionswork in node:module: route CJS entrypoint and CJS-via-ESM-import through Module._extensions #35774). Keys that do not start with.go straight to the base class as before.put; this PR changes whatputdoes once reached). The two were built together locally and both test files pass.test/js/node/module/require-extensions.test.ts: three new tests. One in-process test makes a custom entry non-writable, checks it stays registered, and checks that a throwing assignment and a false-returningReflect.setdo not change whatrequire()dispatches to; one spawned sloppy-mode test freezes the object and checks that refused stores to a builtin entry, a custom entry and a new entry are not dispatched to, that the hook registered before the freeze survives it, and that a.jsfile using JSX still loads afterwards; one spawned test covers a non-configurable entry (rejecteddefineProperty,Reflect.defineProperty, assignment and delete) and a non-extensible object (rejected assignment anddefinePropertyof new entries). On 1.4.0 all three fail (the refused handlers are dispatched to and the frozen hook is dropped); with this change the file passes 13/13. The fixtures of the two spawned tests print the same values under Node (without the JSX file).test/js/node/module/,test/regression/issue/require-extensions-override.test.ts,test/regression/issue/22929-module-extensions-asi.test.ts, Node'stest-module-multi-extensions.js,test-require-extensions-main.jsandtest-module-main-extension-lookup.js, and the new tests underBUN_JSC_validateExceptionChecks=1: all pass.Background
require.extensionsis oneJSCommonJSExtensionsobject per global. Its properties are only a mirror: the tablerequire()actually consults iscommonjs_custom_extensionsinsrc/jsc/NodeModuleModule.rs, written throughonAssign->NodeModuleModule__onRequireExtensionModify/...NonFunctionand read byBun__transpileFile. Builtin entries are not in the table until someone assigns them; assigning one of the builtin handler functions registers the corresponding fixed loader (the.jshandler registers the JS loader, while an untouched.jsentry uses the default loader for.jsfiles, which also accepts JSX).put/defineOwnProperty/deletePropertyare JSC's per-class hooks for[[Set]],[[DefineOwnProperty]]and[[Delete]]. The base class versions implement the language rules (non-writable, non-configurable, non-extensible) and return false, or throw when the caller asked for that, when the operation is refused.getDirectreads an own property straight out of the object's storage without running any JavaScript, which is why it is safe to call before and after the operation.Object.freeze(obj)is[[PreventExtensions]]followed by a[[DefineOwnProperty]]on every own property with{ configurable: false, writable: false }, so it reachesdefineOwnPropertyonce per entry with a descriptor that has no value.Repro on 1.4.0