From 8b29e6a984ce743052eafccaff84153dc419254d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:43:16 +0000 Subject: [PATCH 1/6] debug builds: reject hot-reloaded builtin JS from a different codegen generation Non-CI debug builds load builtin JS from /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. --- src/codegen/bundle-modules.ts | 29 +++++- src/codegen/generate-js2native.ts | 11 +++ src/codegen/replacements.ts | 20 ++++ src/jsc/bindings/InternalModuleRegistry.cpp | 45 +++++++++ .../js/bun/internal-module-dev-reload.test.ts | 94 +++++++++++++++++++ 5 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 test/js/bun/internal-module-dev-reload.test.ts diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 324dd673fdaf..6e01627d3eea 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -15,10 +15,10 @@ import path from "path"; import jsclasses from "./../jsc/bindings/js_classes"; import { sliceSourceCode } from "./builtin-parser"; import { createAssertClientJS, createLogClientJS } from "./client-js"; -import { getJS2NativeCPP, getJS2NativeRust } from "./generate-js2native"; +import { getJS2NativeCPP, getJS2NativeRust, getJS2NativeSignature } from "./generate-js2native"; import { cap, checkAscii, writeIfNotChanged, writeIfNotChangedBinary } from "./helpers"; import { createInternalModuleRegistry } from "./internal-module-registry-scanner"; -import { define } from "./replacements"; +import { define, getNumericReplacementsSignature } from "./replacements"; const BASE = path.join(import.meta.dir, "../js"); const debug = process.argv[2] === "--debug=ON"; @@ -243,6 +243,25 @@ if (out.exitCode !== 0) { mark("Bundle modules"); +// In debug builds the files written to JS_DIR are re-read from disk 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. Stamp each file with a +// hash of all of those ID spaces; InternalModuleRegistry.cpp refuses files +// whose stamp doesn't match the one its build was generated with (a rebuild in +// flight, or an aborted one, would otherwise dispatch to the wrong native +// code). Module preprocessing has already registered every `$lazy` call a +// module file can contain, so the signature is complete at this point, and the +// hash doesn't cover file contents, so editing JS never invalidates it. +const generation = new Bun.CryptoHasher("sha256") + .update(JSON.stringify([moduleList, nativeStartIndex])) + .update(getJS2NativeSignature()) + .update(getNumericReplacementsSignature()) + .digest("hex") + .slice(0, 16); +const generationStamp = `// @bun-internal-module-generation=${generation}\n`; + const outputs = new Map(); for (const entrypoint of bundledEntryPoints) { @@ -281,7 +300,10 @@ for (const entrypoint of bundledEntryPoints) { const outputPath = path.join(JS_DIR, file_path); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, captured); + // The stamp is appended only to the on-disk copy (the embedded blob always + // matches the binary) and goes at the end so line numbers stay identical to + // the embedded sources. Its absence also marks a partially-written file. + fs.writeFileSync(outputPath, captured + generationStamp); outputs.set(file_path.replace(".js", ""), captured); } @@ -379,6 +401,7 @@ writeIfNotChanged( path.join(CODEGEN_DIR, "InternalModuleRegistry+numberOfModules.h"), `#define BUN_INTERNAL_MODULE_COUNT ${moduleList.length} #define BUN_NATIVE_MODULE_START_INDEX ${nativeStartIndex} +#define BUN_INTERNAL_MODULE_GENERATION "${generation}" `, ); diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index ccc9ce85d83d..504c69500f60 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -174,6 +174,17 @@ export function registerNativeCall( return id; } +/** + * Stable description of the `$lazy` ID space as registered so far. Module + * preprocessing registers every call site a module file can bake in, so when + * called after that phase this pins the ID → native-function mapping those + * files rely on. Part of the generation stamp bundle-modules.ts writes into + * each hot-reloadable JS file. + */ +export function getJS2NativeSignature(): string { + return JSON.stringify(nativeCalls.map(call => [call.id, call.type, call.filename, call.symbol])); +} + function symbol(call: Pick) { return call.type === "rust" ? `JS2Rust__${call.filename ? normalizeSymbolPathPrefix(call.filename) + "_" : ""}${call.symbol.replace(/[^A-Za-z]/g, "_")}` diff --git a/src/codegen/replacements.ts b/src/codegen/replacements.ts index ad089c28e00e..fa001cadba55 100644 --- a/src/codegen/replacements.ts +++ b/src/codegen/replacements.ts @@ -38,6 +38,26 @@ for (let id = 0; id < jsclasses.length; id++) { }); } +/** + * Stable description of the numeric ID spaces the replacement rules above bake + * into builtin JS: `$makeErrorWithCode(, ...)` (ErrorCode.ts order, + * including extra constructors) and `$inherits(, ...)` (js_classes order). + * Part of the generation stamp bundle-modules.ts writes into each + * hot-reloadable JS file. + */ +export function getNumericReplacementsSignature(): string { + const errorCtors: string[] = []; + for (let i = 0; i < NodeErrors.length; i++) { + const [code, _constructor, _name, ...other_constructors] = NodeErrors[i]; + errorCtors.push(code as string); + for (const con of other_constructors) { + if (con == null) continue; + errorCtors.push(`${code}_${con.name}`); + } + } + return JSON.stringify({ errorCtors, classes: jsclasses.map(c => c[0]) }); +} + // These rules are run on the entire file, including within strings. export const globalReplacements: ReplacementRule[] = [ { diff --git a/src/jsc/bindings/InternalModuleRegistry.cpp b/src/jsc/bindings/InternalModuleRegistry.cpp index f62c5ba050f9..3dfebf9e8821 100644 --- a/src/jsc/bindings/InternalModuleRegistry.cpp +++ b/src/jsc/bindings/InternalModuleRegistry.cpp @@ -9,6 +9,12 @@ #include #include +#if OS(WINDOWS) +#include // _exit +#else +#include // _exit +#endif + #include "InternalModuleRegistryConstants.h" #include "wtf/Forward.h" @@ -101,10 +107,49 @@ ALWAYS_INLINE JSC::JSValue generateNativeModule( } #ifdef BUN_DYNAMIC_JS_LOAD_PATH +// bundle-modules.ts ends every file in BUN_DYNAMIC_JS_LOAD_PATH with +// `// @bun-internal-module-generation=`. The hash covers every +// codegen-assigned numeric ID space those files bake in ($lazy native-call +// IDs, internal module registry indices, error-code and js_classes IDs), and +// BUN_INTERNAL_MODULE_GENERATION is the hash this binary's dispatch tables +// were generated with. A file with a different stamp came from a different +// codegen run (rebuild in flight, or an aborted one) and its IDs may dispatch +// to the wrong native code; a file with no stamp is mid-write. Editing JS +// doesn't change the stamp, so hot-reload keeps working. +static bool hasMatchingGenerationStamp(const Vector& contents) +{ + static constexpr char needle[] = "// @bun-internal-module-generation="; + static constexpr size_t needleLength = sizeof(needle) - 1; + static constexpr char expected[] = BUN_INTERNAL_MODULE_GENERATION; + static constexpr size_t expectedLength = sizeof(expected) - 1; + + // The stamp is the last line; scan only the tail (slack for the trailing + // newline, or \r\n if the checkout rewrote it). + static constexpr size_t tailLength = needleLength + expectedLength + 8; + size_t begin = contents.size() > tailLength ? contents.size() - tailLength : 0; + for (size_t i = begin; i + needleLength + expectedLength <= contents.size(); i++) { + if (memcmp(contents.span().data() + i, needle, needleLength) == 0) + return memcmp(contents.span().data() + i + needleLength, expected, expectedLength) == 0; + } + return false; +} + JSValue initializeInternalModuleFromDisk(JSGlobalObject* globalObject, VM& vm, const WTF::String& moduleName, WTF::String fileBase, const WTF::String& urlString) { WTF::String file = makeString(ASCIILiteral::fromLiteralUnsafe(BUN_DYNAMIC_JS_LOAD_PATH), "/"_s, WTF::move(fileBase)); if (auto contents = WTF::FileSystemImpl::readEntireFile(file)) { + if (!hasMatchingGenerationStamp(contents.value())) [[unlikely]] { + fprintf(stderr, + "\nFATAL: bun-debug hot-reloads builtin JS from disk, but \"%s\" was written by a different codegen generation than this binary (expected %s).\n" + "Codegen-assigned numeric IDs may have shifted, so loading it could dispatch to the wrong native bindings.\n" + "This usually means a build is in progress, a previous build stopped after codegen, or the file is mid-write.\n" + "Re-run `bun bd` (or let the in-flight build finish) and try again.\n\n", + file.utf8().span().data(), BUN_INTERNAL_MODULE_GENERATION); + fflush(nullptr); + // Deliberate clean exit instead of CRASH(): this is a build-state + // error, not a bug worth a panic report. + _exit(1); + } auto string = WTF::String::fromUTF8(contents.value()); return generateModule(globalObject, vm, string, moduleName, urlString); } else { diff --git a/test/js/bun/internal-module-dev-reload.test.ts b/test/js/bun/internal-module-dev-reload.test.ts new file mode 100644 index 000000000000..6db92e2a0e40 --- /dev/null +++ b/test/js/bun/internal-module-dev-reload.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import fs from "node:fs"; +import { dirname, join } from "node:path"; + +// Non-CI debug builds hot-reload builtin JS from `/js` +// (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), so a file written by a different +// codegen run than the one the binary was built from can dispatch to the wrong +// native binding. Each file carries a trailing `@bun-internal-module-generation` +// stamp that the loader checks; a mismatch must fail with an actionable error +// instead of loading misnumbered code. +// +// Only dev debug builds have the hot-reload dir, so these tests skip elsewhere +// (release, CI debug builds, USE_SYSTEM_BUN). +const jsDir = join(dirname(bunExe()), "js"); +const osJsPath = join(jsDir, "node", "os.js"); +const hasDynamicJS = fs.existsSync(osJsPath); + +const SKEW_MESSAGE = "different codegen generation"; + +async function requireOsInChild() { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "const os = require('node:os'); console.log(typeof os.freemem)"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe.skipIf(!hasDynamicJS)("builtin JS hot-reload generation guard", () => { + test("a file from a different codegen generation is rejected with an actionable error", async () => { + const original = fs.readFileSync(osJsPath); + try { + let tampered = original.toString("latin1"); + // Simulate a codegen run that renumbered native-call IDs: shift the os + // binding's $lazy ID by one (dispatches to a different native function) + // and stamp the file as belonging to another generation. + tampered = tampered.replace(/bound\(@lazy\((\d+)\)\)/, (_, id) => `bound(@lazy(${Number(id) + 1}))`); + tampered = tampered.replace(/(@bun-internal-module-generation=)[0-9a-f]+/, "$1" + "0".repeat(16)); + expect(tampered).not.toBe(original.toString("latin1")); + fs.writeFileSync(osJsPath, tampered, "latin1"); + + const { stdout, stderr, exitCode } = await requireOsInChild(); + expect(stderr).toContain(SKEW_MESSAGE); + expect(stdout).not.toContain("function"); + expect(exitCode).not.toBe(0); + } finally { + fs.writeFileSync(osJsPath, original); + } + }); + + test("an edited file with an intact generation stamp still hot-reloads", async () => { + const original = fs.readFileSync(osJsPath); + try { + const marker = "BUN_DEV_RELOAD_MARKER_1b2d"; + const edited = original + .toString("latin1") + .replace('"use strict";', `"use strict";console.error("${marker}");`); + expect(edited).not.toBe(original.toString("latin1")); + fs.writeFileSync(osJsPath, edited, "latin1"); + + const { stdout, stderr, exitCode } = await requireOsInChild(); + expect(stderr).toContain(marker); + expect(stdout).toContain("function"); + expect(exitCode).toBe(0); + } finally { + fs.writeFileSync(osJsPath, original); + } + }); + + test("a truncated file (codegen mid-write) is rejected, not misparsed", async () => { + const original = fs.readFileSync(osJsPath); + try { + // A partially-written file has no trailing generation stamp yet. + fs.writeFileSync(osJsPath, original.subarray(0, Math.floor(original.length / 2))); + + const { stdout, stderr, exitCode } = await requireOsInChild(); + expect(stderr).toContain(SKEW_MESSAGE); + expect(stdout).not.toContain("function"); + expect(exitCode).not.toBe(0); + } finally { + fs.writeFileSync(osJsPath, original); + } + }); +}); + +// Keep the file non-empty for runners without the hot-reload dir. +test.skipIf(hasDynamicJS)("builtin JS hot-reload dir not present (release or CI build)", () => { + expect(hasDynamicJS).toBe(false); +}); From 005d9a159f9d455b79ae523e1a98b58447528cc2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:45:46 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- test/js/bun/internal-module-dev-reload.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/bun/internal-module-dev-reload.test.ts b/test/js/bun/internal-module-dev-reload.test.ts index 6db92e2a0e40..2747a1da5845 100644 --- a/test/js/bun/internal-module-dev-reload.test.ts +++ b/test/js/bun/internal-module-dev-reload.test.ts @@ -57,9 +57,7 @@ describe.skipIf(!hasDynamicJS)("builtin JS hot-reload generation guard", () => { const original = fs.readFileSync(osJsPath); try { const marker = "BUN_DEV_RELOAD_MARKER_1b2d"; - const edited = original - .toString("latin1") - .replace('"use strict";', `"use strict";console.error("${marker}");`); + const edited = original.toString("latin1").replace('"use strict";', `"use strict";console.error("${marker}");`); expect(edited).not.toBe(original.toString("latin1")); fs.writeFileSync(osJsPath, edited, "latin1"); From 380232c347cdcb32cc05f1d5546de07c092529c9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:48:43 +0000 Subject: [PATCH 3/6] review: require the generation stamp to be the file's final line 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. --- src/codegen/bundle-modules.ts | 22 +++++------- src/codegen/generate-js2native.ts | 9 ++--- src/codegen/replacements.ts | 10 ++---- src/jsc/bindings/InternalModuleRegistry.cpp | 35 ++++++++----------- .../js/bun/internal-module-dev-reload.test.ts | 17 +++++++++ 5 files changed, 44 insertions(+), 49 deletions(-) diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 6e01627d3eea..76fabc89bb50 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -243,17 +243,12 @@ if (out.exitCode !== 0) { mark("Bundle modules"); -// In debug builds the files written to JS_DIR are re-read from disk 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. Stamp each file with a -// hash of all of those ID spaces; InternalModuleRegistry.cpp refuses files -// whose stamp doesn't match the one its build was generated with (a rebuild in -// flight, or an aborted one, would otherwise dispatch to the wrong native -// code). Module preprocessing has already registered every `$lazy` call a -// module file can contain, so the signature is complete at this point, and the -// hash doesn't cover file contents, so editing JS never invalidates it. +// Hash of every codegen-assigned numeric ID space baked into module JS +// ($lazy native-call IDs, module registry indices, error-code and js_classes +// IDs). InternalModuleRegistry.cpp refuses hot-reloading JS_DIR files whose +// stamp doesn't match the binary's, so a renumbering rebuild can't make +// $lazy(N) dispatch to the wrong native code. Content isn't hashed: editing +// JS never invalidates the stamp. const generation = new Bun.CryptoHasher("sha256") .update(JSON.stringify([moduleList, nativeStartIndex])) .update(getJS2NativeSignature()) @@ -300,9 +295,8 @@ for (const entrypoint of bundledEntryPoints) { const outputPath = path.join(JS_DIR, file_path); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - // The stamp is appended only to the on-disk copy (the embedded blob always - // matches the binary) and goes at the end so line numbers stay identical to - // the embedded sources. Its absence also marks a partially-written file. + // Stamp only the on-disk copy, at EOF so line numbers match the embedded + // sources (which always match the binary and don't need a stamp). fs.writeFileSync(outputPath, captured + generationStamp); outputs.set(file_path.replace(".js", ""), captured); } diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index 504c69500f60..2fe6ae1232d1 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -174,13 +174,8 @@ export function registerNativeCall( return id; } -/** - * Stable description of the `$lazy` ID space as registered so far. Module - * preprocessing registers every call site a module file can bake in, so when - * called after that phase this pins the ID → native-function mapping those - * files rely on. Part of the generation stamp bundle-modules.ts writes into - * each hot-reloadable JS file. - */ +/** The `$lazy` ID → native-function mapping registered so far, for the + * generation stamp bundle-modules.ts writes into hot-reloadable JS files. */ export function getJS2NativeSignature(): string { return JSON.stringify(nativeCalls.map(call => [call.id, call.type, call.filename, call.symbol])); } diff --git a/src/codegen/replacements.ts b/src/codegen/replacements.ts index fa001cadba55..b7ceee4383ff 100644 --- a/src/codegen/replacements.ts +++ b/src/codegen/replacements.ts @@ -38,13 +38,9 @@ for (let id = 0; id < jsclasses.length; id++) { }); } -/** - * Stable description of the numeric ID spaces the replacement rules above bake - * into builtin JS: `$makeErrorWithCode(, ...)` (ErrorCode.ts order, - * including extra constructors) and `$inherits(, ...)` (js_classes order). - * Part of the generation stamp bundle-modules.ts writes into each - * hot-reloadable JS file. - */ +/** The `$makeErrorWithCode(, ...)` and `$inherits(, ...)` ID + * orderings baked in by the rules above, for the generation stamp + * bundle-modules.ts writes into hot-reloadable JS files. */ export function getNumericReplacementsSignature(): string { const errorCtors: string[] = []; for (let i = 0; i < NodeErrors.length; i++) { diff --git a/src/jsc/bindings/InternalModuleRegistry.cpp b/src/jsc/bindings/InternalModuleRegistry.cpp index 3dfebf9e8821..b58d01772b4d 100644 --- a/src/jsc/bindings/InternalModuleRegistry.cpp +++ b/src/jsc/bindings/InternalModuleRegistry.cpp @@ -107,31 +107,24 @@ ALWAYS_INLINE JSC::JSValue generateNativeModule( } #ifdef BUN_DYNAMIC_JS_LOAD_PATH -// bundle-modules.ts ends every file in BUN_DYNAMIC_JS_LOAD_PATH with -// `// @bun-internal-module-generation=`. The hash covers every -// codegen-assigned numeric ID space those files bake in ($lazy native-call -// IDs, internal module registry indices, error-code and js_classes IDs), and -// BUN_INTERNAL_MODULE_GENERATION is the hash this binary's dispatch tables -// were generated with. A file with a different stamp came from a different -// codegen run (rebuild in flight, or an aborted one) and its IDs may dispatch -// to the wrong native code; a file with no stamp is mid-write. Editing JS -// doesn't change the stamp, so hot-reload keeps working. +// bundle-modules.ts stamps every file it writes to BUN_DYNAMIC_JS_LOAD_PATH +// with the generation hash of the codegen-assigned numeric IDs the file bakes +// in; BUN_INTERNAL_MODULE_GENERATION is the hash this binary was generated +// with. A different or missing stamp means the file's IDs may dispatch to the +// wrong native code (rebuild in flight, aborted build, or a mid-write file). static bool hasMatchingGenerationStamp(const Vector& contents) { - static constexpr char needle[] = "// @bun-internal-module-generation="; - static constexpr size_t needleLength = sizeof(needle) - 1; - static constexpr char expected[] = BUN_INTERNAL_MODULE_GENERATION; + static constexpr char expected[] = "// @bun-internal-module-generation=" BUN_INTERNAL_MODULE_GENERATION; static constexpr size_t expectedLength = sizeof(expected) - 1; - // The stamp is the last line; scan only the tail (slack for the trailing - // newline, or \r\n if the checkout rewrote it). - static constexpr size_t tailLength = needleLength + expectedLength + 8; - size_t begin = contents.size() > tailLength ? contents.size() - tailLength : 0; - for (size_t i = begin; i + needleLength + expectedLength <= contents.size(); i++) { - if (memcmp(contents.span().data() + i, needle, needleLength) == 0) - return memcmp(contents.span().data() + i + needleLength, expected, expectedLength) == 0; - } - return false; + // The stamp must be the last line: trim trailing whitespace, then require + // the exact suffix (a matching stamp earlier in the file doesn't count). + size_t end = contents.size(); + while (end > 0 && (contents[end - 1] == '\n' || contents[end - 1] == '\r' || contents[end - 1] == ' ')) + end--; + if (end < expectedLength) + return false; + return memcmp(contents.span().data() + end - expectedLength, expected, expectedLength) == 0; } JSValue initializeInternalModuleFromDisk(JSGlobalObject* globalObject, VM& vm, const WTF::String& moduleName, WTF::String fileBase, const WTF::String& urlString) diff --git a/test/js/bun/internal-module-dev-reload.test.ts b/test/js/bun/internal-module-dev-reload.test.ts index 2747a1da5845..843f7a78c0d4 100644 --- a/test/js/bun/internal-module-dev-reload.test.ts +++ b/test/js/bun/internal-module-dev-reload.test.ts @@ -70,6 +70,23 @@ describe.skipIf(!hasDynamicJS)("builtin JS hot-reload generation guard", () => { } }); + test("a valid stamp that is not the final line does not count", async () => { + const original = fs.readFileSync(osJsPath); + try { + // The valid stamp becomes a decoy: a foreign stamp follows it as the + // real last line, as if a different codegen run appended to the file. + const foreignStamp = "// @bun-internal-module-generation=" + "0".repeat(16) + "\n"; + fs.writeFileSync(osJsPath, Buffer.concat([original, Buffer.from(foreignStamp, "latin1")])); + + const { stdout, stderr, exitCode } = await requireOsInChild(); + expect(stderr).toContain(SKEW_MESSAGE); + expect(stdout).not.toContain("function"); + expect(exitCode).not.toBe(0); + } finally { + fs.writeFileSync(osJsPath, original); + } + }); + test("a truncated file (codegen mid-write) is rejected, not misparsed", async () => { const original = fs.readFileSync(osJsPath); try { From fa0704bcc140e16488a901958436f5a5fe943dcb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:25:53 +0000 Subject: [PATCH 4/6] review: keep the generation define off the PCH include chain 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 /js for release builds too, but only dev debug binaries read it. --- src/codegen/bundle-modules.ts | 9 ++++++++- src/codegen/generate-js2native.ts | 13 +++++++++++-- src/jsc/bindings/InternalModuleRegistry.cpp | 2 ++ test/js/bun/internal-module-dev-reload.test.ts | 13 +++++++------ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 76fabc89bb50..4ab9e059959a 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -395,10 +395,17 @@ writeIfNotChanged( path.join(CODEGEN_DIR, "InternalModuleRegistry+numberOfModules.h"), `#define BUN_INTERNAL_MODULE_COUNT ${moduleList.length} #define BUN_NATIVE_MODULE_START_INDEX ${nativeStartIndex} -#define BUN_INTERNAL_MODULE_GENERATION "${generation}" `, ); +// Included only by InternalModuleRegistry.cpp (not from any header): the hash +// changes on every ID renumbering, and keeping it off the PCH include chain +// keeps that a one-TU recompile. +writeIfNotChanged( + path.join(CODEGEN_DIR, "InternalModuleRegistry+generation.h"), + `#define BUN_INTERNAL_MODULE_GENERATION "${generation}"\n`, +); + // This code slice is used in InternalModuleRegistry.h for inlining the enum. I dont think we // actually use this enum but it's probably a good thing to include. writeIfNotChanged( diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index 2fe6ae1232d1..6a5e5adc7526 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -175,9 +175,18 @@ export function registerNativeCall( } /** The `$lazy` ID → native-function mapping registered so far, for the - * generation stamp bundle-modules.ts writes into hot-reloadable JS files. */ + * generation stamp bundle-modules.ts writes into hot-reloadable JS files. + * Filenames are relativized so the hash doesn't change with the checkout + * location (rust entries store absolute paths). */ export function getJS2NativeSignature(): string { - return JSON.stringify(nativeCalls.map(call => [call.id, call.type, call.filename, call.symbol])); + return JSON.stringify( + nativeCalls.map(call => [ + call.id, + call.type, + (path.isAbsolute(call.filename) ? path.relative(srcDir, call.filename) : call.filename).replaceAll(sep, "/"), + call.symbol, + ]), + ); } function symbol(call: Pick) { diff --git a/src/jsc/bindings/InternalModuleRegistry.cpp b/src/jsc/bindings/InternalModuleRegistry.cpp index b58d01772b4d..b5274649c8fe 100644 --- a/src/jsc/bindings/InternalModuleRegistry.cpp +++ b/src/jsc/bindings/InternalModuleRegistry.cpp @@ -107,6 +107,8 @@ ALWAYS_INLINE JSC::JSValue generateNativeModule( } #ifdef BUN_DYNAMIC_JS_LOAD_PATH +#include "InternalModuleRegistry+generation.h" + // bundle-modules.ts stamps every file it writes to BUN_DYNAMIC_JS_LOAD_PATH // with the generation hash of the codegen-assigned numeric IDs the file bakes // in; BUN_INTERNAL_MODULE_GENERATION is the hash this binary was generated diff --git a/test/js/bun/internal-module-dev-reload.test.ts b/test/js/bun/internal-module-dev-reload.test.ts index 843f7a78c0d4..018c76303cc6 100644 --- a/test/js/bun/internal-module-dev-reload.test.ts +++ b/test/js/bun/internal-module-dev-reload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isDebug } from "harness"; import fs from "node:fs"; import { dirname, join } from "node:path"; @@ -12,11 +12,12 @@ import { dirname, join } from "node:path"; // stamp that the loader checks; a mismatch must fail with an actionable error // instead of loading misnumbered code. // -// Only dev debug builds have the hot-reload dir, so these tests skip elsewhere -// (release, CI debug builds, USE_SYSTEM_BUN). +// Only dev debug builds read the hot-reload dir. Codegen writes `/js` +// for release builds too, so gate on the build flavor as well as the dir; CI +// debug builds and USE_SYSTEM_BUN have no dir next to the binary and skip. const jsDir = join(dirname(bunExe()), "js"); const osJsPath = join(jsDir, "node", "os.js"); -const hasDynamicJS = fs.existsSync(osJsPath); +const hasDynamicJS = isDebug && fs.existsSync(osJsPath); const SKEW_MESSAGE = "different codegen generation"; @@ -103,7 +104,7 @@ describe.skipIf(!hasDynamicJS)("builtin JS hot-reload generation guard", () => { }); }); -// Keep the file non-empty for runners without the hot-reload dir. -test.skipIf(hasDynamicJS)("builtin JS hot-reload dir not present (release or CI build)", () => { +// Keep the file non-empty for runners without hot-reload support. +test.skipIf(hasDynamicJS)("builtin JS hot-reload not supported by this build", () => { expect(hasDynamicJS).toBe(false); }); From c639ae251f73ce2820188c2671b41a4a74c39cb5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:38:31 +0000 Subject: [PATCH 5/6] review: derive the replacements signature from the built rules 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. --- scripts/build/codegen.ts | 1 + src/codegen/bundle-modules.ts | 17 ++++++++++++++++- src/codegen/replacements.ts | 23 ++++++++++------------- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts index 7aa6c2238db7..8e437aa2a096 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -718,6 +718,7 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { resolve(cfg.codegenDir, "InternalModuleRegistry+createInternalModuleById.h"), resolve(cfg.codegenDir, "InternalModuleRegistry+enum.h"), resolve(cfg.codegenDir, "InternalModuleRegistry+numberOfModules.h"), + resolve(cfg.codegenDir, "InternalModuleRegistry+generation.h"), resolve(cfg.codegenDir, "NativeModuleImpl.h"), resolve(cfg.codegenDir, "SyntheticModuleType.h"), resolve(cfg.codegenDir, "GeneratedJS2Native.h"), diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 4ab9e059959a..7b2c046fa56f 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -249,13 +249,15 @@ mark("Bundle modules"); // stamp doesn't match the binary's, so a renumbering rebuild can't make // $lazy(N) dispatch to the wrong native code. Content isn't hashed: editing // JS never invalidates the stamp. +const js2nativeSignature = getJS2NativeSignature(); const generation = new Bun.CryptoHasher("sha256") .update(JSON.stringify([moduleList, nativeStartIndex])) - .update(getJS2NativeSignature()) + .update(js2nativeSignature) .update(getNumericReplacementsSignature()) .digest("hex") .slice(0, 16); const generationStamp = `// @bun-internal-module-generation=${generation}\n`; +const stampedNativeCallCount = (JSON.parse(js2nativeSignature) as unknown[]).length; const outputs = new Map(); @@ -293,6 +295,19 @@ for (const entrypoint of bundledEntryPoints) { throw new Error(`Errors in ${entrypoint}:\n${errors.map(x => x[1]).join("\n")}`); } + // Guard rail: the stamp is only sound if every $lazy ID this file bakes was + // registered before the stamp was computed. Registration happens during + // module preprocessing, so this can only fire if the stamp computation gets + // moved above it. + for (const match of captured.matchAll(/@lazy\((\d+)\)/g)) { + if (Number(match[1]) >= stampedNativeCallCount) { + throw new Error( + `${file_path} bakes $lazy ID ${match[1]}, but only ${stampedNativeCallCount} native calls were ` + + `registered when the generation stamp was computed.`, + ); + } + } + const outputPath = path.join(JS_DIR, file_path); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); // Stamp only the on-disk copy, at EOF so line numbers match the embedded diff --git a/src/codegen/replacements.ts b/src/codegen/replacements.ts index b7ceee4383ff..2afad153f095 100644 --- a/src/codegen/replacements.ts +++ b/src/codegen/replacements.ts @@ -38,20 +38,17 @@ for (let id = 0; id < jsclasses.length; id++) { }); } -/** The `$makeErrorWithCode(, ...)` and `$inherits(, ...)` ID - * orderings baked in by the rules above, for the generation stamp - * bundle-modules.ts writes into hot-reloadable JS files. */ +/** The replacement rules that bake a numeric ID into builtin JS + * (`$makeErrorWithCode(, ...` and `$inherits(, ...`), for the + * generation stamp bundle-modules.ts writes into hot-reloadable JS files. + * Derived from the built rules themselves so the signature cannot drift from + * the numbering the rules actually emit. */ export function getNumericReplacementsSignature(): string { - const errorCtors: string[] = []; - for (let i = 0; i < NodeErrors.length; i++) { - const [code, _constructor, _name, ...other_constructors] = NodeErrors[i]; - errorCtors.push(code as string); - for (const con of other_constructors) { - if (con == null) continue; - errorCtors.push(`${code}_${con.name}`); - } - } - return JSON.stringify({ errorCtors, classes: jsclasses.map(c => c[0]) }); + return JSON.stringify( + replacements + .filter(rule => rule.to !== undefined && /\(\d+, $/.test(rule.to)) + .map(rule => [rule.from.source, rule.to]), + ); } // These rules are run on the entire file, including within strings. From 516d928a81cfaec385e9042cf4f2b2e4c6dcdbb2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:44:05 +0000 Subject: [PATCH 6/6] review: keep the dev-reload test out of the parallel bucket 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. --- scripts/update-parallel-allowlist.mjs | 3 ++- test/js/bun/internal-module-dev-reload.test.ts | 7 +++++-- test/parallel-allowlist.json | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/update-parallel-allowlist.mjs b/scripts/update-parallel-allowlist.mjs index b71c19c0ed1a..d604976cddb1 100644 --- a/scripts/update-parallel-allowlist.mjs +++ b/scripts/update-parallel-allowlist.mjs @@ -141,7 +141,8 @@ const slowest = file => { }; // bun install / link / global tests share the user-level bin and cache dirs; // run together they race on linking (EEXIST) even though each passes alone. -const sharedStatePrefixes = ["cli/install/"]; +// internal-module-dev-reload rewrites the build dir's hot-reload JS under test. +const sharedStatePrefixes = ["cli/install/", "js/bun/internal-module-dev-reload.test.ts"]; const sharedStateExempt = ["cli/install/hosted-git-info/", "cli/install/migration/"]; const isGood = file => { if (dockerPrefixes.some(prefix => file.startsWith(prefix)) || usesContainer(file)) return false; diff --git a/test/js/bun/internal-module-dev-reload.test.ts b/test/js/bun/internal-module-dev-reload.test.ts index 018c76303cc6..6d112fe2564e 100644 --- a/test/js/bun/internal-module-dev-reload.test.ts +++ b/test/js/bun/internal-module-dev-reload.test.ts @@ -41,7 +41,10 @@ describe.skipIf(!hasDynamicJS)("builtin JS hot-reload generation guard", () => { // binding's $lazy ID by one (dispatches to a different native function) // and stamp the file as belonging to another generation. tampered = tampered.replace(/bound\(@lazy\((\d+)\)\)/, (_, id) => `bound(@lazy(${Number(id) + 1}))`); - tampered = tampered.replace(/(@bun-internal-module-generation=)[0-9a-f]+/, "$1" + "0".repeat(16)); + tampered = tampered.replace( + /(@bun-internal-module-generation=)[0-9a-f]+/, + "$1" + Buffer.alloc(16, "0").toString(), + ); expect(tampered).not.toBe(original.toString("latin1")); fs.writeFileSync(osJsPath, tampered, "latin1"); @@ -76,7 +79,7 @@ describe.skipIf(!hasDynamicJS)("builtin JS hot-reload generation guard", () => { try { // The valid stamp becomes a decoy: a foreign stamp follows it as the // real last line, as if a different codegen run appended to the file. - const foreignStamp = "// @bun-internal-module-generation=" + "0".repeat(16) + "\n"; + const foreignStamp = "// @bun-internal-module-generation=" + Buffer.alloc(16, "0").toString() + "\n"; fs.writeFileSync(osJsPath, Buffer.concat([original, Buffer.from(foreignStamp, "latin1")])); const { stdout, stderr, exitCode } = await requireOsInChild(); diff --git a/test/parallel-allowlist.json b/test/parallel-allowlist.json index 8adc8e823d45..10f12cde3cd4 100644 --- a/test/parallel-allowlist.json +++ b/test/parallel-allowlist.json @@ -9,7 +9,7 @@ "stats": { "dirs": 285, "files": 1692, - "excluded": 217 + "excluded": 218 } }, "dirs": [ @@ -353,6 +353,7 @@ "internal/linear-fifo.test.ts", "js/bun/archive.test.ts", "js/bun/globals.test.js", + "js/bun/internal-module-dev-reload.test.ts", "js/bun/cron/cron-parse.test.ts", "js/bun/cron/in-process-cron.test.ts", "js/bun/crypto/wpt-webcrypto.generateKey.test.ts",