Skip to content

require.extensions: assignments from a repeated statement no longer bypass the loader table - #38854

Open
robobun wants to merge 5 commits into
mainfrom
farm/275e085d/require-extensions-put-ic
Open

robobun wants to merge 5 commits into
mainfrom
farm/275e085d/require-extensions-put-ic

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Assigning to require.extensions[ext] (alias Module._extensions) only reaches the loader the first time a given statement runs. Once the statement is cached, the new handler shows up on the JS object but require() keeps dispatching to whatever the statement stored last time it was not cached. With a literal key (extensions[".js"] = fn in a loop) the interpreter caches the second execution already; with a variable key (the extensions[ext] = fn loop that pirates, @babel/register and ts-node use to install and revert hooks) the cache appears once the helper has been JIT compiled, so it hits programs that install and revert hooks repeatedly.
  • Observable shapes, all reproducible on bun 1.4.0 and on main (Node honors every one):
    • override .js in a loop: iteration 1 invokes the override, iterations 2+ load the file with the builtin loader and never call it (this is the scenario from the handoff, which attributed it to a failed load; the failure is incidental, a non-throwing override misbehaves the same way).
    • restore the builtin through a helper after installing a second override: require.extensions[".js"] === original reads true, but the second override is still what loads .js files.
    • register a custom extension through a helper three times: the third handler is never used, the second one keeps being dispatched to.
    • variable-key install(ext, fn) / restore(ext, prev) helpers called ~200 times and then used for real: every later install and restore is ignored, and the handler from the warm-up iteration at which the helper got compiled is dispatched to forever (for .js files too, while Module._extensions[".js"] === original reads true).
  • Cause: JSCommonJSExtensions (src/jsc/bindings/JSCommonJSExtensions.h) overrides put/deleteProperty to mirror each assignment into the native extension table (onAssign -> NodeModuleModule__onRequireExtensionModify), and its put ends in Base::put, which fills the PutPropertySlot as a cacheable store. JSC then caches the store for that statement (LLInt put_by_id metadata; baseline/DFG put_by_id and put_by_val ICs via tryCachePutBy; DFG's static PutByStatus::computeFor). All of those check Structure::propertyAccessesAreCacheable(), none of them look at OverridesPut, so the next execution of the statement writes straight into the object and put never runs. The native table, which is what Bun__transpileFile consults on require(), is left holding the previous handler.

Fix

  • Add JSC::ProhibitsPropertyCaching to JSCommonJSExtensions::StructureFlags.
  • Why this is correct: propertyAccessesAreCacheable() is the single predicate consulted by every put and delete caching path in JSC (LLInt, baseline/DFG ICs for both put_by_id and put_by_val, delete ICs, and PutByStatus::computeFor, which can compile a direct store from a known structure even when no IC exists). Clearing it makes every data-property store and delete on this one object take the slow path, which is the only path that calls put/deleteProperty, so the mirroring those methods do runs on every assignment instead of only on the first one per statement. Calling slot.disableCaching() inside put would only cover the IC paths, not PutByStatus::computeFor. JSSharedEnvMap (the SHARE_ENV process.env), NodeVM and NodeSqlite objects in this tree already use the flag for the same reason.
  • Scope: this PR only makes the existing put/deleteProperty run every time. What they do once reached is unchanged, including two pre-existing gaps that are tracked separately and are not made better or worse here: put/defineOwnProperty update the native table before Base::put/Base::defineOwnProperty decide whether the store is allowed (a rejected store on a frozen require.extensions still registers the handler), and an accessor descriptor passed to defineProperty is mirrored as "unregister" because it carries no value (the Module._extensions compat work in require.extensions support #15795 / node:module: route CJS entrypoint and CJS-via-ESM-import through Module._extensions #35774 covers that area).
  • Cost: reads and writes of require.extensions properties take the slow path (a structure lookup). The loader itself never reads this object per require(), it uses the native table, so only user code touching require.extensions directly is affected.
  • Sibling deliberately left out of this PR: the main-thread process.env object (JSEnvironmentVariableMap, the one remaining OverridesPut class in src/jsc/bindings/ without the flag) has the same bypass, process.env.X = 123 run three times from one statement stores the number on the third run. It is not the same one-token fix there: process.env is built so that a variable becomes a plain data property after its first read precisely so that reads get inline cached (JSEnvironmentVariableMap.cpp, the CustomValue comment near the bottom of the file), and ProhibitsPropertyCaching would also turn off the get caches for every process.env.NODE_ENV read in the JIT tiers. The fix there has to pick between that, slot.disableCaching() in put (keeps reads fast, leaves the PutByStatus::computeFor path open), or a store-only variant of the flag added in oven-sh/WebKit; it is tracked separately and nothing in this PR depends on it.
  • Verified with test/js/node/module/require-extensions.test.ts: five new tests. Four in-process ones with literal keys (re-override of a builtin extension from a loop, a throwing override being invoked again on every require, a shared restore helper, a custom extension registered three times through a helper) pin the interpreter cache; one spawned test with variable-key install/restore helpers, warmed up 200 times under BUN_JSC_useConcurrentJIT=0 and BUN_JSC_jitPolicyScale=0.05, with noInline on both helpers and an assertion that numberOfDFGCompiles() is at least 1 for each before the checked rounds, pins the JIT ICs, and runs in about 0.8 s on a debug build. On a debug build of main 4b02e10 with the header reverted: 21 pass / 5 fail; with this change: 26 pass (main added 11 tests to the file since this PR was opened). The spawned test's fixture fails 10/10 on 1.4.0.
  • Also ran with the debug build: test/js/node/module/, test/regression/issue/require-extensions-override.test.ts, test/regression/issue/22929-module-extensions-asi.test.ts, test/js/bun/resolve/builtin-esm-lazy-exports.test.ts, test/cli/run/run-cjs.test.ts, test/cli/run/transpiler-cache.test.ts, Node's test-module-multi-extensions.js and test-require-extensions-main.js: all pass.

Background

  • require.extensions is a single JSCommonJSExtensions object per global. The .js/.json/.ts/... properties on it are only a mirror; the table require() actually dispatches on lives on the native side (commonjs_custom_extensions in src/jsc/NodeModuleModule.rs, read by Bun__transpileFile). JSCommonJSExtensions::put/defineOwnProperty/deleteProperty are what keeps the two in sync, so a store that skips them leaves the loader on the old handler.
  • Property inline cache: after a property store succeeds, JSC records (per bytecode statement) the object's structure and the slot offset it wrote to. The next time that statement runs on an object with the same structure, the store is done inline without calling any C++ method. OverridesPut only makes the uncached slow path call the subclass's put; whether the result may be cached is decided by the PutPropertySlot the slow path fills in and by the structure's ProhibitsPropertyCaching flag.
  • put_by_id vs put_by_val: obj["lit"] = v is compiled to put_by_id, which the interpreter itself caches; obj[key] = v is put_by_val, which has no interpreter cache and is only cached once the function reaches the baseline JIT. That is why the literal-key tests fail on their second iteration while the variable-key test needs a warm-up loop.
  • PutByStatus::computeFor(StructureSet): the DFG's way of compiling a store when it already knows the receiver's structure from profiling, without going through an IC. It bails out when propertyAccessesAreCacheable() is false, which is why the structure flag is used rather than a per-call disableCaching().
Repro
// mod.js: exports.foo = 1;
const file = require("path").join(__dirname, "mod.js");
const original = require.extensions[".js"];
for (let i = 0; i < 3; i++) {
  let invoked = false;
  require.extensions[".js"] = (m, f) => { invoked = true; original(m, f); };
  require(file);
  console.log(i, invoked);
  require.extensions[".js"] = original;
  delete require.cache[file];
}

bun 1.4.0 prints 0 true, 1 false, 2 false. Node, and bun with this change, print true three times. Unrolling the loop so each assignment is its own statement makes 1.4.0 print true three times as well, which is what points at the per-statement cache.

The variable-key fixture in the new spawned test prints, on 1.4.0 with BUN_JSC_useConcurrentJIT=0, warm-js-50 / warm-hooked-50 as the exports of every file in every round (the handlers stored by the warm-up iteration at which the helpers were compiled) together with restored: true; with this change it prints js-0, builtin restored-0, hooked-0, and so on.


[human-review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/module/require-extensions.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/module/require-extensions.test.ts:
(pass) require.extensions shape makes sense [4.40ms]
(pass) custom require extension 1 [26.00ms]
(pass) custom require extension overwrite default loader [10.78ms]
(pass) custom require extension overwrite default loader with other default loader [7.49ms]
(pass) test that assigning properties weirdly wont do anything bad [2.33ms]
(pass) wrapping an existing extension with no logic [6.56ms]
(pass) wrapping an existing extension with mutated compile function [9.86ms]
(pass) wrapping an existing extension with mutated compile function ts [11.52ms]
(pass) wrapping an existing extension but it's secretly sync esm [16.85ms]
191 |         require.extensions[".js"] = original;
192 |       }
193 |     } finally {
194 |       require.extensions[".js"] = original;
195 |     }
196 |     expect(rounds).toEqual([
                         ^
error: expect(received).toEqual(expected)

  [
    {
      "exports": "a",
      "file": "
... (truncated)

release without fix: 6 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/module/require-extensions.test.ts:
(pass) require.extensions shape makes sense [0.06ms]
(pass) custom require extension 1 [0.68ms]
(pass) custom require extension overwrite default loader [0.20ms]
(pass) custom require extension overwrite default loader with other default loader [0.15ms]
(pass) test that assigning properties weirdly wont do anything bad [0.03ms]
(pass) wrapping an existing extension with no logic [0.11ms]
(pass) wrapping an existing extension with mutated compile function [0.22ms]
(pass) wrapping an existing extension with mutated compile function ts [0.19ms]
(pass) wrapping an existing extension but it's secretly sync esm [0.22ms]
191 |         require.extensions[".js"] = original;
192 |       }
193 |     } finally {
194 |       require.extensions[".js"] = original;
195 |     }
196 |     expect(rounds).toEqual([
                         ^
error: expect(received).toEqual(expected)

  [
    {
      "exports": "a",
      "file": "a.js",
      "invoked": true,
    },
    {
      "exports": "b",
      "file": "b.js",
-     "invoked": true,
+     "invoked": false,
    },
    {
      "exports": "c",
     
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/module/require-extensions.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/module/require-extensions.test.ts:
(pass) require.extensions shape makes sense [4.64ms]
(pass) custom require extension 1 [26.63ms]
(pass) custom require extension overwrite default loader [11.21ms]
(pass) custom require extension overwrite default loader with other default loader [7.81ms]
(pass) test that assigning properties weirdly wont do anything bad [2.43ms]
(pass) wrapping an existing extension with no logic [7.64ms]
(pass) wrapping an existing extension with mutated compile function [11.01ms]
(pass) wrapping an existing extension with mutated compile function ts [12.82ms]
(pass) wrapping an existing extension but it's secretly sync esm [18.01ms]
(pass) assigning require.extensions repeatedly from one statement > a builtin extension can be overridden again after it was restored [40.35ms]
(pass) assigning require.extensions repeatedly from one statement > an override that throws is invoked again by the next require of the same file [15.25ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 6431ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[2/8] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[3/8] cxx obj/unified/UnifiedSource-src_jsc_modules-0.cpp.o
[4/8] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/codegen/GeneratedSSLConfig.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/deps/libbun_jsc-3687dde034820260.rlib(bun_jsc-3687dde034820260.bun_jsc.a197853160ed3415-cgu.0.rcgu.o at 221590)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/codegen/GeneratedSocketConfig.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/deps/libbun_jsc-3687dde034820260.rlib(bun_jsc-3687dde034820260.bun_jsc.a197853160ed3415-cgu.0.rcgu.o at 221590)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/codegen/GeneratedSocketConfigHandlers.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-l
... (truncated)
diff hotspot
src/jsc/bindings/JSCommonJSExtensions.h        |   3 +-
 test/js/node/module/require-extensions.test.ts | 202 ++++++++++++++++++++++++-
 2 files changed, 203 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                            reads  edits  tests
src/jsc/bindings/JSCommonJSExtensions.h             0      0      4
test/js/node/module/require-extensions.test.ts      1      1      4

root cause · written by the author bot

The root cause was that JSCommonJSExtensions lacked JSC::ProhibitsPropertyCaching in its StructureFlags, so the JIT could inline-cache property stores on the require.extensions object and repeated assignments to a key like .js skipped the custom put path that updates the native loader table, leaving stale loaders in effect. The fix adds that flag so every assignment, whether by literal or variable key, always reaches the loader table instead of a cached fast path. New tests cover repeated overrides, restoration, custom extensions, failed loads, and a JIT-warmed assignment loop to …

@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 4b5170f4-40f5-47bf-b4e5-b6c0634e717f

📥 Commits

Reviewing files that changed from the base of the PR and between 4b02e10 and 2823860.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSCommonJSExtensions.h
  • test/js/node/module/require-extensions.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

The change adds JSC::ProhibitsPropertyCaching to JSCommonJSExtensions::StructureFlags and adds tests for repeated require.extensions assignments, restoration, custom extensions, and failed module loads.

Changes

Require extension loader synchronization

Layer / File(s) Summary
Loader assignment behavior
src/jsc/bindings/JSCommonJSExtensions.h, test/js/node/module/require-extensions.test.ts
The structure flags prohibit property caching. Tests cover repeated .js overrides, restoration, custom extension registration, failed loads, and subprocess dispatch.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to ff61b

Repeated assignments to require.extensions now reliably reach the loader, and new tests cover literal-key and variable-key cases. No merge-blocking risk is evident.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main fix: repeated require.extensions assignments no longer bypass the loader table.
Description check ✅ Passed The description explains the problem, root cause, fix, scope, limitations, and verification results. It provides the information required by the repository template, although it uses equivalent headin…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.3-canary.1 (367d939). A helper function install(fn) { Module._extensions[".foo"] = fn; } that runs four times gives A->A B->B C->B D->B: the third and fourth hook are never called. Node v26.3.0 and this branch give A->A B->B C->C D->D.
  • The variable-key script from the comment of 09-13 skips the custom loader in 181 of 200 iterations on that build, and in 0 of 200 on this branch.
  • test/js/node/module/require-extensions.test.ts on a debug build of main 4b02e10: 21 pass and 5 fail with the header from main, 26 pass with this branch. The 5 failures are the 5 tests this PR adds.
  • Ready for review. The diff is the one structure flag and the tests. The review nit on the JIT fixture is addressed in 06726d8. The remaining CI failures are on lanes this diff does not touch (a LeakSanitizer ptrace warning in setTimeout.test.js on x64-asan, and install and grpc tests that passed on retry). They are reported separately.
  • The same bypass in the main-thread process.env object is not in this PR. process.env: coerce to string on every execution of an assignment, not just the first #38871 covers it.

Comment thread src/jsc/bindings/JSCommonJSExtensions.h
@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:55 AM PT - Oct 1st, 2026

❌ @robobun, your commit 06726d8 has 1 failures in Build #122387 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38854

That installs a local version of the PR into your bun-38854 executable, so you can run:

bun-38854 --bun

Comment thread src/jsc/bindings/JSCommonJSExtensions.h Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The one-flag production change matches the existing ProhibitsPropertyCaching pattern in NodeVM, JSSharedEnvMap, and NodeSqlite, and the JSEnvironmentVariableMap sibling I flagged earlier is now explicitly called out as an intentional exclusion with a stated read-IC trade-off, which satisfies the fix-the-whole-class rule.

What was reviewed:

  • Confirmed the flag is the same predicate every JSC put/delete caching path consults, and that three in-tree classes already use it for the same reason.
  • Checked the five new tests: interpreter-cache (literal key) and JIT-IC (variable key under BUN_JSC_useConcurrentJIT=0) paths both covered; tempDir/using/bunEnv spread/concurrent pipe drain/exit-code-last conventions followed; every test restores .js in finally.
  • Verified the header comment was trimmed to one line per the comment-cop feedback.
Extended reasoning...

Overview

The production change is a single token: JSC::ProhibitsPropertyCaching added to JSCommonJSExtensions::StructureFlags in src/jsc/bindings/JSCommonJSExtensions.h, plus a one-line comment. The rest of the diff is five new tests in the existing test/js/node/module/require-extensions.test.ts file.

The bug: JSCommonJSExtensions::put() mirrors every assignment to require.extensions[ext] into the native loader table, then tail-calls Base::put(..., slot), which fills the PutPropertySlot as a cacheable Replace. Once the LLInt/baseline/DFG cache the store for that statement, put() is bypassed and the native table goes stale. ProhibitsPropertyCaching clears propertyAccessesAreCacheable(), which is the predicate every put/delete caching path (LLInt metadata, tryCachePutBy, PutByStatus::computeFor) consults, so every store takes the slow path and put() runs every time.

Security risks

None. This tightens behavior (an override that should run now runs on every assignment). No new user-controlled input is parsed; the flag only forces the existing slow path.

Level of scrutiny

Low-to-medium. The mechanism is a well-understood JSC structure flag already used for exactly this purpose in three other classes in src/jsc/bindings/ (NodeVM.h:121, JSSharedEnvMap at JSEnvironmentVariableMap.cpp:603, NodeSqlite.h:735). require.extensions is not a hot path — the loader reads the native table, not this JS object — so the get-cache cost of the flag is irrelevant here. The alternative (slot.disableCaching()) is correctly rejected in the description because it would not cover PutByStatus::computeFor.

Other factors

  • Prior review threads resolved. My earlier inline comment asked that JSEnvironmentVariableMap (the one remaining OverridesPut site without the flag) be either fixed or explicitly excluded. The author added a paragraph to the PR description explaining why it is intentionally excluded (ProhibitsPropertyCaching would also disable get ICs for process.env.NODE_ENV reads, which that object is designed around; the fix there needs a separate decision). REVIEW.md's rule is satisfied by stating the exclusion. The comment-cop bot's objection to the multi-line header comment was also addressed — it is now one line.
  • Tests. Five new tests, all in the existing file. Four in-process literal-key tests pin the interpreter cache; one spawned variable-key test warmed 200× under BUN_JSC_useConcurrentJIT=0 pins the baseline/DFG put_by_val ICs (the pirates/ts-node shape). All follow harness conventions: tempDir with using, {...bunEnv, ...}, Promise.all([stdout, stderr, exited]), stderr/stdout asserted before exit code, cleanup in finally registered before assertions. The author verified 10 pass / 5 fail with the header reverted, 15 pass with the fix.
  • No CODEOWNERS cover the changed paths.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

A second, independent report of this bug arrived. This PR already fixes it, so no other PR is open for it.

The report

  • Module._extensions[ext] = fn (variable key, one store site) works for the first few dozen executions. After the baseline JIT compiles the store site, require() keeps the previous loader and gives no error.
  • On 1.4.3-canary.1+b99371011 the script below skips the custom loader in 180 of 200 iterations. The first miss is at iteration 20. With Reflect.defineProperty, with BUN_JSC_useJIT=0, and on Node v26.3.0 the count is 0 of 200.
  • The report measured pirates: addHook() does this store from one source line, and the 37th addHook() call in a process is the first one that Bun ignores. Files then load without the transform.

Verification on current main

  • I applied the StructureFlags line from this PR to main at 09bb546 and built a debug binary.
  • The script below gives 0 of 200 misses. The result is the same with BUN_JSC_useConcurrentJIT=0 and with low DFG and FTL tier-up thresholds. The unfixed debug binary gives 181 of 200.
  • test/js/node/module/require-extensions.test.ts from this branch passes 15 of 15 on that build.

Merge conflict

  • The branch conflicts with main since Remove the unused function-slot vector from JSCommonJSExtensions #41405 (814fa03). That commit removed ~JSCommonJSExtensions() and m_registeredFunctions, the two lines below the changed line in JSCommonJSExtensions.h.
  • The conflict is only in the context lines. To resolve it, keep the header from main and apply the StructureFlags line. The test file merges without a conflict.
Script from the report
'use strict';
const Module = require('module');
const fs = require('fs'), path = require('path'), os = require('os');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-put-ic-'));
process.on('exit', () => fs.rmSync(dir, { recursive: true, force: true }));
const file = path.join(dir, 'a.js');
fs.writeFileSync(file, 'module.exports = 1;\n');
const variant = process.argv[2] || 'assign';
const native = Module._extensions['.js'];
function install(ext, loader) {
  if (variant === 'define') Reflect.defineProperty(Module._extensions, ext, { value: loader, writable: true, enumerable: true, configurable: true });
  else Module._extensions[ext] = loader;
}
let misses = 0, first = -1;
for (let i = 0; i < 200; i++) {
  let called = false;
  install('.js', function hook(mod, filename) { called = true; return native(mod, filename); });
  delete require.cache[file];
  require(file);
  install('.js', native);
  if (!called) { misses++; if (first < 0) first = i; }
}
console.log(`variant=${variant}: custom loader NOT called in ${misses}/200 iterations${first >= 0 ? ', first miss at iteration ' + first : ''}`);

Run it as bun repro.cjs assign and bun repro.cjs define.

…ches the loader table

JSCommonJSExtensions::put and deleteProperty mirror each assignment into
the native extension table. JSObject::put marks the PutPropertySlot as
cacheable, so the second assignment executed from the same statement was
served by a property inline cache and never reached put(); the JS object
showed the new handler while the loader kept dispatching to the old one.
Setting ProhibitsPropertyCaching on the structure makes every put and
delete take the slow path, the same as the shared process.env map and
the node:sqlite objects.
…tion

Literal-key stores are cached by the interpreter, so the existing tests
fail on their second iteration. Variable-key stores (put_by_val) are only
cached once the helper is JIT compiled, so this spawns a fixture that
warms install/restore helpers up under BUN_JSC_useConcurrentJIT=0 and
then checks that later installs and restores still reach the loader.
@robobun
robobun force-pushed the farm/275e085d/require-extensions-put-ic branch from 9b484be to 2823860 Compare October 1, 2026 11:04

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

LGTM: a one-token structure-flag change that matches the existing in-tree precedent (NodeVM, JSSharedEnvMap), and the PR now states why the main-thread process.env sibling is deliberately left out, which closes the whole-class concern from the earlier review.

Extended reasoning...

The native change adds JSC::ProhibitsPropertyCaching to JSCommonJSExtensions::StructureFlags in src/jsc/bindings/JSCommonJSExtensions.h so put()/deleteProperty() (which mirror assignments into the native commonjs_custom_extensions table that require() actually dispatches on) run on every store instead of only the first per statement. It touches no security-sensitive surface; the only cost is slow-path property access on require.extensions itself, which the loader does not read per require. The flag's semantics are the standard JSC mechanism behind propertyAccessesAreCacheable, two sibling classes in the same directory already use it for the same reason, and the five new tests exercise interpreter and JIT caching paths with proper pipe draining and cleanup. The one inline finding is a nit about warm-up robustness, and the changed files are not covered by CODEOWNERS, so a human look is not required.

Comment thread test/js/node/module/require-extensions.test.ts
…G compiled

The JIT fixture relied on the default tier-up thresholds and asserted
nothing about compilation. Pin the policy with BUN_JSC_jitPolicyScale,
mark the helpers noInline, and check numberOfDFGCompiles() after the
warm-up loop, so the test cannot pass in the interpreter by accident.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant