Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/build/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion scripts/update-parallel-allowlist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 42 additions & 3 deletions src/codegen/bundle-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -243,6 +243,22 @@ if (out.exitCode !== 0) {

mark("Bundle modules");

// 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 js2nativeSignature = getJS2NativeSignature();
const generation = new Bun.CryptoHasher("sha256")
.update(JSON.stringify([moduleList, nativeStartIndex]))
.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();

for (const entrypoint of bundledEntryPoints) {
Expand Down Expand Up @@ -279,9 +295,24 @@ 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 });
fs.writeFileSync(outputPath, captured);
// 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);
}

Expand Down Expand Up @@ -382,6 +413,14 @@ writeIfNotChanged(
`,
);

// 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(
Expand Down
15 changes: 15 additions & 0 deletions src/codegen/generate-js2native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ export function registerNativeCall(
return id;
}

/** The `$lazy` ID → native-function mapping registered so far, for the
* 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,
(path.isAbsolute(call.filename) ? path.relative(srcDir, call.filename) : call.filename).replaceAll(sep, "/"),
call.symbol,
]),
);
}

function symbol(call: Pick<NativeCall, "type" | "symbol" | "filename">) {
return call.type === "rust"
? `JS2Rust__${call.filename ? normalizeSymbolPathPrefix(call.filename) + "_" : ""}${call.symbol.replace(/[^A-Za-z]/g, "_")}`
Expand Down
13 changes: 13 additions & 0 deletions src/codegen/replacements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ for (let id = 0; id < jsclasses.length; id++) {
});
}

/** The replacement rules that bake a numeric ID into builtin JS
* (`$makeErrorWithCode(<error_i>, ...` and `$inherits(<id>, ...`), 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 {
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.
export const globalReplacements: ReplacementRule[] = [
{
Expand Down
40 changes: 40 additions & 0 deletions src/jsc/bindings/InternalModuleRegistry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
#include <JavaScriptCore/Debugger.h>
#include <utility>

#if OS(WINDOWS)
#include <stdlib.h> // _exit
#else
#include <unistd.h> // _exit
#endif

#include "InternalModuleRegistryConstants.h"
#include "wtf/Forward.h"

Expand Down Expand Up @@ -101,10 +107,44 @@ 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
// 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<uint8_t>& contents)
{
static constexpr char expected[] = "// @bun-internal-module-generation=" BUN_INTERNAL_MODULE_GENERATION;
static constexpr size_t expectedLength = sizeof(expected) - 1;

// 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)
{
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 {
Expand Down
113 changes: 113 additions & 0 deletions test/js/bun/internal-module-dev-reload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isDebug } from "harness";
import fs from "node:fs";
import { dirname, join } from "node:path";

// Non-CI debug builds hot-reload builtin JS from `<buildDir>/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 read the hot-reload dir. Codegen writes `<buildDir>/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 = isDebug && 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" + Buffer.alloc(16, "0").toString(),
);
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 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=" + Buffer.alloc(16, "0").toString() + "\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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
});

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 hot-reload support.
test.skipIf(hasDynamicJS)("builtin JS hot-reload not supported by this build", () => {
expect(hasDynamicJS).toBe(false);
});
3 changes: 2 additions & 1 deletion test/parallel-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"stats": {
"dirs": 285,
"files": 1692,
"excluded": 217
"excluded": 218
}
},
"dirs": [
Expand Down Expand Up @@ -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",
Expand Down
Loading