Skip to content

Reject hot-reloaded builtin JS from a different codegen generation in debug builds - #36942

Open
robobun wants to merge 6 commits into
mainfrom
farm/0d9fe1ab/fix-dev-js-reload-skew
Open

robobun wants to merge 6 commits into
mainfrom
farm/0d9fe1ab/fix-dev-js-reload-skew

Conversation

@robobun

@robobun robobun commented Aug 5, 2026 •

Copy link
Copy Markdown
Collaborator

Symptom

Debug builds intermittently died at node:os module load under concurrent process spawning:

[os] ASSERTION FAILED: obj[key] !== undefined
Missing freemem
  at symbolToStringify (node:os:150:10)

The failures looked like memory corruption (only under load, same fixture usually passed) and were first suspected to be the native freemem binding failing under memory pressure. The binding is fine.

Cause

Non-CI debug builds hot-reload builtin JS from <buildDir>/js at runtime (BUN_DYNAMIC_JS_LOAD_PATH) so src/js edits apply without relinking. Those files bake in codegen-assigned numeric IDs:

  • $lazy(N) native-call IDs ($cpp / $rust / $bindgenFn)
  • internal module registry indices
  • $makeErrorWithCode(N, ...) error-code IDs
  • $inherits(N, ...) js_classes IDs

All of these are assigned by codegen order, and the matching dispatch tables are compiled into the binary. A rebuild rewrites the JS files early, while the old binary keeps running and spawning children until the link finishes minutes later. If the numbering shifted, $lazy(N) in the fresh JS dispatches to a different native function in the old binary. node:os calls $lazy(96) for its binding; off by one it receives the node:path binding, which has no freemem, and the debug assert fires. Renumbering os.js's $lazy ID by hand reproduces the reported output byte for byte, including the node:os:150:10 frame (the line number matches the on-disk dev file). The "correlates with load" observation was the rebuild itself: it pegs the machine while it opens the skew window. A build that dies after codegen leaves the skew in place until the next successful build.

Fix

bundle-modules.ts hashes all of the numeric ID spaces above and appends the hash as a trailing comment to every file it writes to the hot-reload dir:

// @bun-internal-module-generation=9894f3ec51adfb1a

The same hash is compiled into the binary via InternalModuleRegistry+numberOfModules.h, and the debug loader refuses a file whose stamp doesn't match:

FATAL: bun-debug hot-reloads builtin JS from disk, but ".../js/node/os.js" was written by a different codegen generation than this binary (expected 9894f3ec51adfb1a).
Codegen-assigned numeric IDs may have shifted, so loading it could dispatch to the wrong native bindings.
This usually means a build is in progress, a previous build stopped after codegen, or the file is mid-write.
Re-run `bun bd` (or let the in-flight build finish) and try again.

Design notes:

  • The hash covers the ID mappings, not file contents, so editing builtin JS and reloading without a rebuild (the point of the feature) still works; only changes that renumber IDs invalidate it, and those require a relink for correctness anyway.
  • The stamp is appended at EOF so stack-trace line numbers stay identical to the embedded sources, and its absence also catches a file caught mid-write by codegen (previously a confusing Error parsing builtin: Unexpected end of script).
  • A clear fatal rather than a fallback: debug builds deliberately don't embed module sources (that's what keeps JS edits link-free), so there is nothing consistent to fall back to.
  • Release and CI builds are unchanged: the embedded blob doesn't get the stamp, and the check compiles away with BUN_DYNAMIC_JS_LOAD_PATH undefined.

Verification

test/js/bun/internal-module-dev-reload.test.ts (skips when the hot-reload dir doesn't exist, i.e. release, CI debug, USE_SYSTEM_BUN):

  • a file with a shifted $lazy ID and foreign stamp is rejected with the actionable error (on an unfixed build this test fails by reproducing the Missing freemem assert verbatim)
  • an edited file with an intact stamp still hot-reloads (feature regression guard)
  • a truncated file is rejected with the same error instead of a parse error

Also verified by hand: normal require('node:os') works after rebuild, all 194 files in the dir are stamped, and smoke runs of test/js/node/os, node:path, node:util, and test/js/web/fetch/fetch-http2-leak.test.ts pass.


[review] gate passed · iteration 0 · 8 files touched

fails on main (without fix)
ASAN without fix: 3 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/internal-module-dev-reload.test.ts
bun test v1.4.0 (4b50fcad3)

test/js/bun/internal-module-dev-reload.test.ts:
47 |       );
48 |       expect(tampered).not.toBe(original.toString("latin1"));
49 |       fs.writeFileSync(osJsPath, tampered, "latin1");
50 | 
51 |       const { stdout, stderr, exitCode } = await requireOsInChild();
52 |       expect(stderr).toContain(SKEW_MESSAGE);
                          ^
error: expect(received).toContain(expected)

Expected to contain: "different codegen generation"
Received: "[os] ASSERTION FAILED: obj[key] !== undefined\nMissing freemem\n  at symbolToStringify (node:os:150:10)\n\nAssertionError: obj[key] !== undefined\n\nBun v1.4.0-debug+4b50fcad3 (Linux x64)\n"

      at <anonymous> (/workspace/bun/test/js/bun/internal-module-dev-reload.test.ts:52:22)
(fail) builtin JS hot-reload generation guard > a file from a different codegen generation is rejected with an actionable error [334.62ms]
(pass) builtin JS hot-reload generation guard > an edited file with an intact generation stamp sti
... (truncated)

release without fix: 4 skipped
bun test v1.4.0-canary.1 (b66764ff3)

test/js/bun/internal-module-dev-reload.test.ts:
(skip) builtin JS hot-reload generation guard > a file from a different codegen generation is rejected with an actionable error
(skip) builtin JS hot-reload generation guard > an edited file with an intact generation stamp still hot-reloads
(skip) builtin JS hot-reload generation guard > a valid stamp that is not the final line does not count
(skip) builtin JS hot-reload generation guard > a truncated file (codegen mid-write) is rejected, not misparsed
(pass) builtin JS hot-reload not supported by this build [0.02ms]

 1 pass
 4 skip
 0 fail
 1 expect() calls
Ran 5 tests across 1 file. [127.00ms]
__F:0:S:4
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/internal-module-dev-reload.test.ts
bun test v1.4.0 (4b50fcad3)

test/js/bun/internal-module-dev-reload.test.ts:
(pass) builtin JS hot-reload generation guard > a file from a different codegen generation is rejected with an actionable error [293.41ms]
(pass) builtin JS hot-reload generation guard > an edited file with an intact generation stamp still hot-reloads [306.14ms]
(pass) builtin JS hot-reload generation guard > a valid stamp that is not the final line does not count [259.07ms]
(pass) builtin JS hot-reload generation guard > a truncated file (codegen mid-write) is rejected, not misparsed [258.25ms]
(skip) builtin JS hot-reload not supported by this build

 4 pass
 1 skip
 0 fail
 14 expect() calls
Ran 5 tests across 1 file. [3.12s]
__F:0:S:1

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 679ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/24] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/24] gen cpp.rs (cppbind)
[3/24] gen JS modules (bundle-modules)
Preprocess modules (8843ms)
Bundle modules (74ms)
Postprocesss modules (49ms)
Bundle Functions (608ms)
Generate Code (17ms)

[9.60s] Bundled "src/js" for production
  2573 kb
  194 internal modules
  13 native modules
  84 internal functions across 17 files
[3/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_http v0.0.0 (/workspace/bun/src/http)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m
... (truncated)
diff hotspot
scripts/build/codegen.ts                       |   1 +
 scripts/update-parallel-allowlist.mjs          |   4 +
 src/codegen/bundle-modules.ts                  |  45 +++++++++-
 src/codegen/generate-js2native.ts              |  15 ++++
 src/codegen/replacements.ts                    |  13 +++
 src/jsc/bindings/InternalModuleRegistry.cpp    |  40 +++++++++
 test/js/bun/internal-module-dev-reload.test.ts | 113 +++++++++++++++++++++++++
 test/parallel-allowlist.json                   |   3 +-
 8 files changed, 230 insertions(+), 4 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                            reads  edits  tests
scripts/build/codegen.ts                            2      1      0
scripts/update-parallel-allowlist.mjs               1      2      0
src/codegen/bundle-modules.ts                       3      8      0
src/codegen/generate-js2native.ts                   2      4      0
src/codegen/replacements.ts                         2      3      0
src/jsc/bindings/InternalModuleRegistry.cpp         2      6      0
test/js/bun/internal-module-dev-reload.test.ts      1      5      0
test/parallel-allowlist.json                        0      0      0

@github-actions github-actions Bot added the claude label Aug 5, 2026
Comment thread src/codegen/bundle-modules.ts Outdated
Comment thread src/codegen/bundle-modules.ts Outdated
Comment thread src/codegen/generate-js2native.ts Outdated
Comment thread src/codegen/replacements.ts Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 63910a9b-7eca-453a-b893-fafbbaa13e2e

📥 Commits

Reviewing files that changed from the base of the PR and between 8326d1b and 516d928.

📒 Files selected for processing (8)
  • scripts/build/codegen.ts
  • scripts/update-parallel-allowlist.mjs
  • src/codegen/bundle-modules.ts
  • src/codegen/generate-js2native.ts
  • src/codegen/replacements.ts
  • src/jsc/bindings/InternalModuleRegistry.cpp
  • test/js/bun/internal-module-dev-reload.test.ts
  • test/parallel-allowlist.json

Walkthrough

The code generator derives a generation identifier from module and code-generation inputs, emits a generated header, and stamps bundled module files. The runtime validates the stamp before dynamic module evaluation. Tests cover valid, missing, mismatched, non-final, and truncated stamps.

Changes

Internal module generation

Layer / File(s) Summary
Generation signature and stamp emission
src/codegen/bundle-modules.ts, src/codegen/generate-js2native.ts, src/codegen/replacements.ts, scripts/build/codegen.ts
The generator serializes JS-to-native and numeric replacement inputs, hashes them with module registry data, and writes the generation identifier to bundled modules and InternalModuleRegistry+generation.h.
Runtime stamp validation
src/jsc/bindings/InternalModuleRegistry.cpp
Dynamic module loading checks the final generation marker, rejects missing or mismatched stamps before evaluation, flushes output, and exits with status 1.
Reload tests and scheduling metadata
test/js/bun/internal-module-dev-reload.test.ts, scripts/update-parallel-allowlist.mjs, test/parallel-allowlist.json
Tests cover unsupported builds, mismatched stamps, preserved stamps, non-final stamps, and truncated files. The test is excluded from parallel execution, and allowlist metadata is updated.
🚥 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 describes the main change: rejecting hot-reloaded builtin JavaScript from a different codegen generation in debug builds.
Description check ✅ Passed The description explains the problem, cause, fix, and verification steps, including evidence that the tests pass with the change.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/InternalModuleRegistry.cpp`:
- Around line 127-129: Update the marker validation loop in
InternalModuleRegistry to remove one trailing newline (either \n or \r\n), then
compare the remaining content suffix directly against the complete expected
marker instead of accepting the first matching occurrence. Add a regression case
covering an expected-marker decoy followed by a different final stamp.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c9bbe42d-2729-45e0-9590-6f3b49b188b5

📥 Commits

Reviewing files that changed from the base of the PR and between 0f53bd0 and b0b0f3c.

📒 Files selected for processing (5)
  • src/codegen/bundle-modules.ts
  • src/codegen/generate-js2native.ts
  • src/codegen/replacements.ts
  • src/jsc/bindings/InternalModuleRegistry.cpp
  • test/js/bun/internal-module-dev-reload.test.ts

Comment thread src/jsc/bindings/InternalModuleRegistry.cpp Outdated
Comment thread src/codegen/bundle-modules.ts
Comment thread src/codegen/bundle-modules.ts
Comment thread src/codegen/generate-js2native.ts Outdated
Comment thread src/codegen/replacements.ts Outdated
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp
Comment thread src/jsc/bindings/InternalModuleRegistry.cpp
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the failures in build 89116 are unrelated to this change. test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts is a pre-existing leak report on main (boringssl RSA keygen via node_crypto_binding, reported separately); the rest (node-dns, run-crash-handler, napi, inspect-error-leak, dlopen-duplicate-load) are known-flaky lanes that passed on retry. The new test in this PR runs on dev debug builds (it needs the hot-reload dir next to the binary) and passes there; CI builds skip it by design.

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/codegen/bundle-modules.ts:401-404 — Writing BUN_INTERNAL_MODULE_GENERATION into InternalModuleRegistry+numberOfModules.h puts it on the PCH path (via InternalModuleRegistry.h → ZigGlobalObject.h → root-pch.h), so any change to the generation hash — e.g. adding an ErrorCode.ts entry or a new $rust/$cpp call — now invalidates the PCH and forces a full C++ rebuild instead of recompiling one TU. Since the macro is only consumed inside InternalModuleRegistry.cpp under #ifdef BUN_DYNAMIC_JS_LOAD_PATH, emit it to a separate generated header (e.g. InternalModuleRegistry+generation.h) included only from that .cpp.

    Extended reasoning...

    What the issue is

    The new #define BUN_INTERNAL_MODULE_GENERATION "<hash>" is written into the generated header InternalModuleRegistry+numberOfModules.h. That header sits on the precompiled-header include chain:

    • InternalModuleRegistry+numberOfModules.h is included at src/jsc/bindings/InternalModuleRegistry.h:6
    • InternalModuleRegistry.h is included at src/jsc/bindings/ZigGlobalObject.h:62
    • ZigGlobalObject.h is included at src/jsc/bindings/root-pch.h:20, which is compiled as the PCH

    The comment in root-pch.h itself notes that editing anything reachable from it "already triggers a near-full rebuild via depfiles". Yet the only consumer of BUN_INTERNAL_MODULE_GENERATION is inside InternalModuleRegistry.cpp, and only inside #ifdef BUN_DYNAMIC_JS_LOAD_PATH — it has no need for header-wide (let alone PCH-wide) visibility.

    Why the hash is more volatile than the header's previous contents

    Before this PR, InternalModuleRegistry+numberOfModules.h contained only BUN_INTERNAL_MODULE_COUNT and BUN_NATIVE_MODULE_START_INDEX, which change only when a builtin module is added or removed. The generation hash folds in significantly more:

    • getNumericReplacementsSignature() — the full ordered list of ErrorCode.ts entries (including extra constructors) and the js_classes order
    • getJS2NativeSignature() — every registered $rust/$cpp/$bindgenFn call, including call.filename, which for rust-type calls is path.join(srcDir, ...) — an absolute path, so the hash also changes if the repo is checked out at a different location
    • [moduleList, nativeStartIndex]

    So while some of these inputs (js_classes) already invalidate the PCH via other generated headers, several are new PCH-invalidation triggers: appending an error code to ErrorCode.ts, adding a $cpp(...) call in a src/js/ module, or building from a differently-located worktree.

    Step-by-step proof

    1. Developer appends one entry to the end of src/jsc/bindings/ErrorCode.ts. No existing IDs shift.
    2. bun bd runs bundle-modules.ts. getNumericReplacementsSignature() now returns a longer errorCtors array → SHA-256 input changes → generation changes.
    3. writeIfNotChanged compares the new content of InternalModuleRegistry+numberOfModules.h against the on-disk file; the BUN_INTERNAL_MODULE_GENERATION line differs, so it rewrites the file.
    4. Ninja's depfile for the PCH lists this header (it is transitively included by root-pch.h), sees its mtime changed, and rebuilds the PCH.
    5. Every C++ TU that uses the PCH (essentially all of src/jsc/bindings/) is now stale and recompiles — several minutes on a cold machine — for a change that, before this PR, would have touched only ErrorCode.cpp and friends.

    Previously, adding a $rust call only touched GeneratedJS2Native.h, which is included solely by JS2Native.cpp — a single-TU rebuild. Now it also rewrites this PCH-reachable header.

    Impact

    This is a local-dev build-time regression only — release/CI builds don't define BUN_DYNAMIC_JS_LOAD_PATH but still compile the PCH, so they pay the rebuild cost too whenever the hash inputs change between commits. It doesn't affect runtime correctness. But this PR is itself about improving the dev-rebuild experience, and the fix is trivial, so it seems worth addressing here.

    Fix

    Emit the macro to its own generated header and include it only from the one TU that uses it:

    // bundle-modules.ts
    writeIfNotChanged(
      path.join(CODEGEN_DIR, "InternalModuleRegistry+generation.h"),
      `#define BUN_INTERNAL_MODULE_GENERATION "${generation}"\n`,
    );

    and in InternalModuleRegistry.cpp, inside the #ifdef BUN_DYNAMIC_JS_LOAD_PATH block:

    #ifdef BUN_DYNAMIC_JS_LOAD_PATH
    #include "InternalModuleRegistry+generation.h"
    ...

    Then a hash change recompiles exactly one file. (Optionally also strip srcDir from call.filename in getJS2NativeSignature() so the hash is checkout-location-independent, but that's a separate nicety.)

Comment thread test/js/bun/internal-module-dev-reload.test.ts Outdated
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Both points addressed in 4e9c267: the generation define now lives in its own generated header (InternalModuleRegistry+generation.h) included only by InternalModuleRegistry.cpp, so a hash change recompiles one TU instead of invalidating the PCH, and the js2native signature relativizes the rust filenames so the hash no longer depends on the checkout location. The test now also gates on isDebug since codegen writes /js for release builds that never read it.

Comment thread src/codegen/bundle-modules.ts
Comment thread src/codegen/generate-js2native.ts
Comment thread src/codegen/bundle-modules.ts
Comment thread src/codegen/replacements.ts

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/codegen/bundle-modules.ts:395-399 — Writing BUN_INTERNAL_MODULE_GENERATION into InternalModuleRegistry+numberOfModules.h puts it in the PCH chain (InternalModuleRegistry.h:6 → ZigGlobalObject.h:62 → root-pch.h:20), so any $rust/$cpp/$bindgenFn add/remove now triggers a full C++ rebuild instead of the previous ~1-file recompile. Also, getJS2NativeSignature() serializes call.filename, which for $rust is path.join(srcDir, ...) — an absolute repo path — so the hash (and thus numberOfModules.h content) differs between checkouts, defeating the CCACHE_BASEDIR/CCACHE_NOHASHDIR path-independence configured in scripts/build/configure.ts:202-203. Consider emitting the define into a separate header that only InternalModuleRegistry.cpp includes, and relativizing rust filenames in the signature.

    Extended reasoning...

    What the bug is

    The new line at bundle-modules.ts:398 writes #define BUN_INTERNAL_MODULE_GENERATION "<hash>" into InternalModuleRegistry+numberOfModules.h. That header sits in the precompiled-header dependency chain:

    • src/jsc/bindings/InternalModuleRegistry.h:6 → #include "InternalModuleRegistry+numberOfModules.h"
    • src/jsc/bindings/ZigGlobalObject.h:62 → #include "InternalModuleRegistry.h"
    • src/jsc/bindings/root-pch.h:20 → #include "ZigGlobalObject.h"

    The PCH rule in scripts/build/compile.ts emits a depfile (-MD -MF $out.d), so ninja tracks numberOfModules.h as a PCH input. When its content changes, the PCH rebuilds and every C++ TU that consumes the PCH recompiles.

    Why the content now changes on common edits

    The generation hash is fed by getJS2NativeSignature() (added at generate-js2native.ts:178), which serializes nativeCalls.map(call => [call.id, call.type, call.filename, call.symbol]). Every $rust(...) / $cpp(...) / $bindgenFn(...) call in src/js/** registers an entry in nativeCalls in encounter order, so adding, removing, or reordering any native call changes the signature → changes the hash → changes numberOfModules.h. writeIfNotChanged doesn't help because the content genuinely differs.

    Before this PR, that same edit only rewrote GeneratedJS2Native.h (whose sole #include is in src/jsc/bindings/JS2Native.cpp) plus InternalModuleRegistryConstants.h (only included by InternalModuleRegistry.cpp) — a ~1-2 TU recompile. After this PR it cascades to the entire C++ tree.

    Secondary issue: checkout-path-dependent hash

    getJS2NativeSignature() includes call.filename. For call_type === "rust", resolveNativeFileId (generate-js2native.ts:118) returns path.join(srcDir, relative) where srcDir = path.join(import.meta.dir, "../") — an absolute repo path. So two checkouts of the same commit at /home/a/bun and /home/b/bun produce different BUN_INTERNAL_MODULE_GENERATION values, and therefore different numberOfModules.h content.

    scripts/build/configure.ts:202-203 explicitly sets CCACHE_BASEDIR and CCACHE_NOHASHDIR so worktrees at different filesystem locations share .o cache entries (see the comment at scripts/build/compile.ts:126, and scripts/build/unified.ts:272 which goes out of its way to keep absolute paths out of generated source for exactly this reason). This PR defeats that for every TU reached through the PCH — ccache hashes preprocessed source content, and the absolute-path-derived hash literal now appears in it.

    Step-by-step proof

    1. Developer has a debug build of commit X. They add one $rust("foo.rs", "bar") call to src/js/node/foo.ts and run bun bd.
    2. bundle-modules.ts preprocesses modules; registerNativeCall pushes a new entry into nativeCalls.
    3. getJS2NativeSignature() returns a different string → generation is a different 16-hex-char value → writeIfNotChanged sees numberOfModules.h content differ and rewrites it.
    4. Ninja's depfile for the PCH lists numberOfModules.h; its mtime changed → PCH rebuilds → every cxx rule that depends on the PCH re-runs. What was a ~1-file C++ recompile is now a full C++ rebuild.

    For the ccache issue: at the same commit, worktree A at /home/a/bun computes a signature containing "/home/a/bun/src/runtime/node/node_os.rs"; worktree B at /home/b/bun computes one containing "/home/b/bun/src/...". Different hashes → different #define string literal → different preprocessed PCH source → ccache miss on every downstream TU when switching between worktrees, despite CCACHE_BASEDIR being set precisely to avoid this.

    Impact

    Neither is a runtime correctness bug — the hash is only compared against itself within one build tree, so the shipped binary is unaffected. But it's a concrete build-performance regression on a dev-loop path this PR is specifically about (editing builtin JS), and it undoes an explicitly-configured build-system property (cross-worktree ccache sharing).

    Fix

    The macro is only ever read inside the #ifdef BUN_DYNAMIC_JS_LOAD_PATH block in InternalModuleRegistry.cpp, so:

    1. Emit #define BUN_INTERNAL_MODULE_GENERATION into a separate generated header (e.g. InternalModuleRegistry+generation.h) and #include it only from InternalModuleRegistry.cpp, keeping it out of the InternalModuleRegistry.h → ZigGlobalObject.h → PCH chain.
    2. In getJS2NativeSignature(), relativize rust filenames (e.g. path.relative(srcDir, call.filename)) so the hash is checkout-path-independent.

    Both are one-liners with zero runtime effect.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Both findings were already addressed in 4e9c267 (this review ran against the earlier revision): the define moved to InternalModuleRegistry+generation.h, included only by InternalModuleRegistry.cpp and declared as a codegen output in f751c2b, and getJS2NativeSignature relativizes rust filenames so the hash is checkout-location-independent.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/internal-module-dev-reload.test.ts`:
- Line 79: Update the foreignStamp construction in the internal module reload
test to replace the "0".repeat(16) expression with Buffer.alloc(16,
"0").toString(), preserving the resulting 16-character zero string and newline.
- Around line 74-87: Update test/parallel-allowlist.json to add
test/js/bun/internal-module-dev-reload.test.ts to the excludeFiles list, keeping
it out of parallel execution while preserving the existing allowlist behavior
for other js/bun tests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9d2baf06-0547-4a25-9b1e-0fa9a829fe26

📥 Commits

Reviewing files that changed from the base of the PR and between 7831b3f and f751c2b.

📒 Files selected for processing (6)
  • scripts/build/codegen.ts
  • src/codegen/bundle-modules.ts
  • src/codegen/generate-js2native.ts
  • src/codegen/replacements.ts
  • src/jsc/bindings/InternalModuleRegistry.cpp
  • test/js/bun/internal-module-dev-reload.test.ts

Comment thread test/js/bun/internal-module-dev-reload.test.ts
Comment thread test/js/bun/internal-module-dev-reload.test.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/parallel-allowlist.json`:
- Line 15: Regenerate test/parallel-allowlist.json using
scripts/update-parallel-allowlist.mjs with the same inputs, rather than editing
entries manually; commit the generator’s exact output so stats.files reflects
the eligible-file count and all ordering changes are applied consistently.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f10d038f-ea50-495e-99ef-c1879419f0b7

📥 Commits

Reviewing files that changed from the base of the PR and between f751c2b and 640b0c7.

📒 Files selected for processing (3)
  • scripts/update-parallel-allowlist.mjs
  • test/js/bun/internal-module-dev-reload.test.ts
  • test/parallel-allowlist.json

Comment thread test/parallel-allowlist.json Outdated
Comment thread src/http/h2_client/ClientSession.rs
Comment thread src/http/h3_client/ClientSession.rs
Comment thread src/runtime/api/BunObject.rs
Comment thread src/runtime/api/BunObject.rs
Comment thread src/runtime/api/BunObject.rs
Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/runtime/webcore/streams.rs
@robobun
robobun force-pushed the farm/0d9fe1ab/fix-dev-js-reload-skew branch from 14669f3 to 4b50fca Compare August 5, 2026 08:51
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on 4b50fca (build 89134): every red lane is unrelated to this change. test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts and test/js/bun/util/filesystem_router.test.ts are pre-existing failures on main (both reported separately); the rest are known-flaky lanes that passed on retry. This change has no runtime effect on CI builds at all: the stamp check only compiles in when BUN_DYNAMIC_JS_LOAD_PATH is defined (local dev debug builds), and the new test skips without that. The diff is ready to review.

@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.

No issues found this pass; my earlier note about the isDebug skip gate was addressed in 4e9c267. Deferring to a human on the overall design — this introduces a new cross-language contract (codegen-written stamp ↔ C++ suffix check with _exit(1)) and touches build-infra that every debug binary depends on.

Reviewed: the hasMatchingGenerationStamp suffix scan (bounds-checked before memcmp, trailing-whitespace trim is correct); the generation hash covers all four numeric ID spaces named in the description and is checkout-location-independent; the new header is declared as a codegen output and stays off the PCH chain; the test's try/finally restores os.js on every path and the file is excluded from the parallel bucket via both the allowlist and the generator's mutatesSharedState set.

Extended reasoning...

Overview

The PR fixes a debug-build-only skew bug where hot-reloaded builtin JS from <buildDir>/js (via BUN_DYNAMIC_JS_LOAD_PATH) can dispatch $lazy(N) to the wrong native binding when an in-flight rebuild has renumbered codegen-assigned IDs. The fix hashes the four numeric ID spaces (js2native $lazy IDs, module-registry indices, $makeErrorWithCode error IDs, $inherits jsclass IDs), appends the hash as a trailing comment to every file written to the hot-reload dir, compiles the same hash into the binary via a new one-TU header, and has the debug loader _exit(1) with an actionable message on mismatch. Eight files touched: three codegen scripts, the ninja codegen output list, InternalModuleRegistry.cpp, a new test, and the parallel-allowlist plus its generator.

Security risks

None. The change is entirely debug-build developer tooling; the check and the new header include compile away when BUN_DYNAMIC_JS_LOAD_PATH is undefined (release and CI). The stamp is a build-provenance marker, not a trust boundary — the hot-reload dir is already a developer's own build tree.

Level of scrutiny

Moderate-to-high. Although the runtime effect is debug-only, the change threads through bundle-modules.ts (runs on every build for every module), adds a generated header the C++ side depends on, and introduces a new hard-exit path in InternalModuleRegistry.cpp. The design choices — stamp at EOF to preserve line numbers, hashing ID mappings not content so plain JS edits still hot-reload, a dedicated header to keep the volatile hash off the PCH include chain, _exit rather than CRASH() for a build-state error — are all reasonable and well-argued in the PR body, but they are architectural decisions on build infrastructure that a maintainer should sign off on rather than a bot.

Other factors

The PR has already been through several review rounds: my prior isDebug && hasDynamicJS gate concern, CodeRabbit's parallel-bucket exclusion, the Buffer.alloc nit, and the allowlist ordering/count are all addressed and resolved. The bug-hunting pass found nothing. I checked the C++ suffix comparison for off-by-one and unsigned-underflow (end < expectedLength guards the subtraction; end is bounded by contents.size()), confirmed getJS2NativeSignature relativizes the absolute rust filenames so the hash is stable across checkouts, confirmed the guard-rail regex @lazy\((\d+)\) matches the post-__intrinsic__→@ form in captured, and confirmed the test restores the mutated os.js in finally before assertions can leak state. The excludeFiles count (162) was independently verified by CodeRabbit against the actual array length. Given the scope — codegen infra + a new C++ failure mode — this should get a human look even though I found nothing wrong.

robobun and others added 6 commits August 16, 2026 10:32
… generation

Non-CI debug builds load builtin JS from <buildDir>/js at runtime
(BUN_DYNAMIC_JS_LOAD_PATH) so src/js edits apply without relinking. Those
files bake in codegen-assigned numeric IDs ($lazy native-call IDs, internal
module registry indices, error-code IDs, js_classes IDs) that must match the
dispatch tables compiled into the binary. A rebuild regenerates the files
early while the old binary keeps running (and spawning children) until the
link finishes, so a renumbering change makes $lazy(N) dispatch to the wrong
native function. For node:os this surfaced as an intermittent debug assert at
module load:

  [os] ASSERTION FAILED: obj[key] !== undefined
  Missing freemem
    at symbolToStringify (node:os:150:10)

because the os binding's $lazy ID resolved to a different module's binding
object.

bundle-modules.ts now hashes all of those ID spaces and appends the hash as a
trailing comment to every file it writes to the hot-reload dir, and compiles
the same hash into the binary. The debug loader refuses files whose stamp
does not match (or is absent, which also catches files caught mid-write) with
an error that says to finish or re-run the build. The stamp does not cover
file contents, so editing builtin JS still hot-reloads, and the embedded
sources used by release and CI builds are unchanged.
Replace the tail scan with an exact suffix match (after trimming trailing
whitespace) so a matching stamp earlier in the file cannot satisfy the check
when a different stamp follows it. Add a regression test for that case and
trim the explanatory comments.
InternalModuleRegistry+numberOfModules.h is reachable from root-pch.h, so
carrying the generation hash there would rebuild the PCH (and most of the C++)
whenever an ID space changes. Emit the define into its own header included
only by InternalModuleRegistry.cpp, and relativize the rust filenames in the
js2native signature so the hash doesn't depend on the checkout location.

Also gate the test on isDebug: codegen writes <buildDir>/js for release
builds too, but only dev debug binaries read it.
getNumericReplacementsSignature duplicated the error_i numbering walk, so an
edit to the rule-building loop could silently strand the signature, which is
the drift class the stamp exists to catch. Serialize the numeric rules from
the replacements array itself instead, matching how the js2native signature
derives from the nativeCalls registry.

Also assert at write time that every $lazy ID a module file bakes was
registered before the stamp was computed (the stamp's completeness currently
rests on statement order in bundle-modules.ts), and declare
InternalModuleRegistry+generation.h as a codegen output so ninja recompiles
its consumer in the same build that rewrites it.
The test mutates the build dir's hot-reload JS, so files running in parallel
on the same machine could observe the tampered window. Exclude it in the
allowlist and teach the generator so regeneration preserves the exclusion.
Use Buffer.alloc for the repeated-string literals per test conventions.
@robobun
robobun force-pushed the farm/0d9fe1ab/fix-dev-js-reload-skew branch from 4b50fca to 516d928 Compare August 16, 2026 11:00
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (516d928). One conflict, in the parallel-allowlist pair: main regenerated test/parallel-allowlist.json and added its own shared-state mechanism to scripts/update-parallel-allowlist.mjs (sharedStatePrefixes). Resolved by dropping this PR's separate mutatesSharedState set and adding the dev-reload test to main's sharedStatePrefixes list instead, and by inserting the single excludeFiles entry into main's regenerated JSON. The earlier ordering-only commit became redundant and was dropped. Rebuilt and re-ran test/js/bun/internal-module-dev-reload.test.ts (4 pass, 1 skip) and test/internal/parallel-allowlist.test.ts (pass); the diff is still the same 8 files.

@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.

I reviewed this PR and didn't find any bugs. It introduces a new cross-cutting mechanism (generation-hash stamping between codegen and the debug C++ loader) spanning build infrastructure, codegen scripts, and InternalModuleRegistry.cpp, so a maintainer familiar with the codegen pipeline should sign off on the design.

What was reviewed:

  • hasMatchingGenerationStamp bounds/suffix check — trims trailing whitespace, guards end < expectedLength before memcmp, correct.
  • Hash inputs — getJS2NativeSignature is called after module preprocessing registers all native calls (guard-rail check enforces this), rust filenames relativized so the hash is checkout-location-independent.
  • Release/CI unaffected — the check compiles out without BUN_DYNAMIC_JS_LOAD_PATH; the generation header is included only by InternalModuleRegistry.cpp (off the PCH chain); the test gates on isDebug && fs.existsSync(...) and is excluded from the parallel bucket via sharedStatePrefixes.
Extended reasoning...

Overview

This PR fixes a debug-only developer-experience bug where a rebuild rewrites the hot-reload JS directory (<buildDir>/js) before the new binary finishes linking, causing an old bun-debug process to load JS with renumbered $lazy(N) IDs and dispatch to the wrong native binding. The fix hashes the four codegen-assigned numeric ID spaces ($lazy native-call IDs, module registry indices, $makeErrorWithCode and $inherits IDs), appends the hash as a trailing comment to every hot-reload JS file, compiles the same hash into the binary via a new InternalModuleRegistry+generation.h, and has the debug loader _exit(1) with an actionable message on mismatch.

Files touched: bundle-modules.ts (hash + stamp + guard rail), generate-js2native.ts and replacements.ts (signature helpers), InternalModuleRegistry.cpp (suffix check under #ifdef BUN_DYNAMIC_JS_LOAD_PATH), scripts/build/codegen.ts (declare the new header as a codegen output), a new test, and the parallel-allowlist pair.

Security risks

None. This is a debug-build-only guard that compares a compile-time string constant against the tail of a file the build itself wrote; release and CI binaries compile the check out entirely. No user input, network, or auth surface.

Level of scrutiny

Medium. Runtime impact is confined to local dev debug builds, but the change threads through the codegen pipeline (a new generated header, a hash whose inputs must stay in lockstep with every ID space baked into module JS, a codegen-order invariant guarded by a regex check). The design choice — hash the ID mappings rather than file contents, stamp at EOF, fatal-exit rather than fall back — is well-reasoned in the PR description, but it's a new contract between three components that a maintainer should ratify.

Other factors

The PR has been through several review rounds and every raised point is addressed and marked resolved: my earlier note on gating the test on isDebug (release build trees also get <buildDir>/js), the PCH-invalidation concern (define moved to a standalone header included only by the one .cpp), checkout-location-dependent hashing (rust filenames relativized), parallel-test isolation (added to sharedStatePrefixes and excludeFiles), and the Buffer.alloc nit. The PR body includes fails-on-main / passes-on-PR evidence showing the unfixed build reproduces the reported Missing freemem assert byte-for-byte. It was rebased on 2026-08-16 to adopt main's sharedStatePrefixes mechanism in place of the PR's earlier separate exclusion set; the author reports the test and test/internal/parallel-allowlist.test.ts pass after rebase. No outstanding unresolved comments.

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