From 7c32ec596c6e5b2741c814c18dd890f435612df7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:32:42 +0000 Subject: [PATCH 1/8] jsc: compare source strings in SourceCodeKey to prevent CodeCache collisions Under USE(BUN_JSC_ADDITIONS), SourceCodeKey::operator== dropped the source string comparison and relied only on the 24-bit StringImpl hash plus length/flags/host. Two distinct modules whose transpiled source has the same length and colliding 24-bit hash then share one cached UnlinkedModuleProgramCodeBlock. When bun feeds JSC a JSModuleRecord built from the second module's export names and CyclicModuleRecord::initializeEnvironment links it against the first module's moduleEnvironmentSymbolTable, the second module silently evaluates the first module's code, or (when the exported function name differs) getValue() in JSModuleNamespaceObject hits 'ASSERTION FAILED: iter != symbolTable->end(locker)' in debug and derefs a null SymbolTableEntry in release. The WebKit-side fix (oven-sh/WebKit#346) restores upstream's behavior: fast-path on UnlinkedSourceCode equality, fall back to a source string compare. This commit bumps WEBKIT_VERSION to the preview build of that PR and adds tests for both observable failure modes. --- scripts/build/deps/webkit.ts | 2 +- .../module-code-cache-collision.test.ts | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/resolve/module-code-cache-collision.test.ts diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 0b1c956da743..99b77cf713b1 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "f0f60fd2324817dae9656d8bf2fcae25ceaccc37"; +export const WEBKIT_VERSION = "autobuild-preview-pr-346-83153fb4"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/resolve/module-code-cache-collision.test.ts b/test/js/bun/resolve/module-code-cache-collision.test.ts new file mode 100644 index 000000000000..839772941846 --- /dev/null +++ b/test/js/bun/resolve/module-code-cache-collision.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// JSC's CodeCache keys UnlinkedModuleProgramCodeBlocks on a 24-bit StringImpl +// hash of the source text. Two distinct modules whose *transpiled* source +// happens to hash-collide must NOT share a code block: the second module would +// link bun's JSModuleRecord (which carries the second module's export/local +// names) against the first module's symbol table, producing wrong values or a +// null SymbolTableEntry lookup inside JSModuleNamespaceObject::getOwnPropertySlotCommon. +// +// The strings below are chosen so that bun's runtime transpiler emits them +// byte-for-byte unchanged, i.e. the collision is on the literal source and +// does not depend on transpiler formatting. + +test("modules with hash-colliding source evaluate their own code (const export)", async () => { + using dir = tempDir("codecache-collision-const", { + // RapidHash("export const tag = \"T004433\";\n") == RapidHash("export const tag = \"T004767\";\n") + "a.mjs": 'export const tag = "T004433";\n', + "b.mjs": 'export const tag = "T004767";\n', + "run.mjs": + "const a = await import('./a.mjs');\n" + + "const b = await import('./b.mjs');\n" + + "console.log(JSON.stringify({ a: a.tag, b: b.tag }));\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ a: "T004433", b: "T004767" }); + expect(exitCode).toBe(0); +}); + +test("modules with hash-colliding source do not crash on namespace default access", async () => { + using dir = tempDir("codecache-collision-fn", { + // RapidHash of these two transpiled sources collides; the differing + // function name means the second module's `default` local name is absent + // from the (incorrectly shared) symbol table, which previously segfaulted + // at SymbolTableEntry::scopeOffset() in release builds. + "a.mjs": "export default function fn_T000686() {}\n", + "b.mjs": "export default function fn_T004636() {}\n", + "run.mjs": + "const a = await import('./a.mjs');\n" + + "const b = await import('./b.mjs');\n" + + "console.log(JSON.stringify({ a: a.default.name, b: b.default.name }));\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ a: "fn_T000686", b: "fn_T004636" }); + expect(exitCode).toBe(0); +}); From 48614157b21cc425efc5bca601eb43f81b504c3e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:22:57 +0000 Subject: [PATCH 2/8] test: make concurrent; assert fixtures transpile to themselves --- .../module-code-cache-collision.test.ts | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/test/js/bun/resolve/module-code-cache-collision.test.ts b/test/js/bun/resolve/module-code-cache-collision.test.ts index 839772941846..1f8eda950b72 100644 --- a/test/js/bun/resolve/module-code-cache-collision.test.ts +++ b/test/js/bun/resolve/module-code-cache-collision.test.ts @@ -7,16 +7,29 @@ import { bunEnv, bunExe, tempDir } from "harness"; // link bun's JSModuleRecord (which carries the second module's export/local // names) against the first module's symbol table, producing wrong values or a // null SymbolTableEntry lookup inside JSModuleNamespaceObject::getOwnPropertySlotCommon. -// -// The strings below are chosen so that bun's runtime transpiler emits them -// byte-for-byte unchanged, i.e. the collision is on the literal source and -// does not depend on transpiler formatting. -test("modules with hash-colliding source evaluate their own code (const export)", async () => { +// Each pair below was mined so the *literal* file bytes collide under WTF's +// RapidHash (StringImpl::hash, masked to 24 bits). That only exercises the +// CodeCache bug if the runtime transpiler emits these sources unchanged; the +// precondition check fails loudly (rather than the tests going vacuous) when +// printer output drifts and the pairs need re-mining. +const transpiler = new Bun.Transpiler({ target: "bun" }); +function assertTranspilesToSelf(sources: readonly string[]) { + const diffs = sources.filter(src => transpiler.transformSync(src, "js") !== src); + expect(diffs, "runtime transpiler no longer emits these fixtures byte-for-byte; re-mine the collision pairs").toEqual( + [], + ); +} + +test.concurrent("modules with hash-colliding source evaluate their own code (const export)", async () => { + // RapidHash("export const tag = \"T004433\";\n") == RapidHash("export const tag = \"T004767\";\n") + const a = 'export const tag = "T004433";\n'; + const b = 'export const tag = "T004767";\n'; + assertTranspilesToSelf([a, b]); + using dir = tempDir("codecache-collision-const", { - // RapidHash("export const tag = \"T004433\";\n") == RapidHash("export const tag = \"T004767\";\n") - "a.mjs": 'export const tag = "T004433";\n', - "b.mjs": 'export const tag = "T004767";\n', + "a.mjs": a, + "b.mjs": b, "run.mjs": "const a = await import('./a.mjs');\n" + "const b = await import('./b.mjs');\n" + @@ -38,14 +51,18 @@ test("modules with hash-colliding source evaluate their own code (const export)" expect(exitCode).toBe(0); }); -test("modules with hash-colliding source do not crash on namespace default access", async () => { +test.concurrent("modules with hash-colliding source do not crash on namespace default access", async () => { + // The differing function name means the second module's `default` local name + // is absent from an incorrectly-shared symbol table, which previously hit + // `ASSERT(iter != symbolTable->end(locker))` in debug and segfaulted in + // release at SymbolTableEntry::scopeOffset(). + const a = "export default function fn_T000686() {}\n"; + const b = "export default function fn_T004636() {}\n"; + assertTranspilesToSelf([a, b]); + using dir = tempDir("codecache-collision-fn", { - // RapidHash of these two transpiled sources collides; the differing - // function name means the second module's `default` local name is absent - // from the (incorrectly shared) symbol table, which previously segfaulted - // at SymbolTableEntry::scopeOffset() in release builds. - "a.mjs": "export default function fn_T000686() {}\n", - "b.mjs": "export default function fn_T004636() {}\n", + "a.mjs": a, + "b.mjs": b, "run.mjs": "const a = await import('./a.mjs');\n" + "const b = await import('./b.mjs');\n" + From 45c93aceba3025d55c9a22a57c66ac0cb4fa49ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:26:01 +0000 Subject: [PATCH 3/8] test: assert mined collision pairs still collide via StringImpl::hash() Expose WTF's StringImpl::hash() (the 24-bit-masked value SourceCodeKey uses) through bun:internal-for-testing and have the test assert the fixture pairs actually collide, so a WebKit StringHasher change makes the test fail with a clear 're-mine the collision pair' message rather than silently go vacuous. --- src/js/internal-for-testing.ts | 9 +++++ src/jsc/bindings/BunString.cpp | 12 +++++++ src/jsc/bindings/BunString.h | 2 ++ .../module-code-cache-collision.test.ts | 36 ++++++++++++------- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 9b1fed3e91d9..c81048a58f8e 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -41,6 +41,15 @@ export const highwayStringsForTesting: ( arg: number | Uint8Array, ) => number = $newCppFunction("highway_strings_testing.cpp", "Bun__highwayStringsForTesting", 3); +// WTF StringImpl::hash() — the 24-bit-masked StringHasher value JSC's +// SourceCodeKey uses for CodeCache lookup. Lets a test assert that a mined +// hash-collision pair still collides after a WebKit bump. +export const stringImplHash: (s: string) => number = $newCppFunction( + "BunString.cpp", + "Bun__stringImplHashForTesting", + 1, +); + export const SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor"); export const patchInternals = { diff --git a/src/jsc/bindings/BunString.cpp b/src/jsc/bindings/BunString.cpp index 282d142f820f..65dfa2a382f5 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -958,3 +958,15 @@ bool BunString::isEmpty() const return true; } } + +// bun:internal-for-testing — expose WTF's StringImpl::hash() (the 24-bit-masked +// StringHasher result that SourceCodeKey uses for CodeCache lookup) so a test +// can assert a mined hash-collision pair still collides after a WebKit bump. +BUN_DEFINE_HOST_FUNCTION(Bun__stringImplHashForTesting, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + WTF::String str = callFrame->argument(0).toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return JSC::JSValue::encode(JSC::jsNumber(str.impl() ? str.impl()->hash() : 0)); +} diff --git a/src/jsc/bindings/BunString.h b/src/jsc/bindings/BunString.h index e03a8a9272ce..867a03725615 100644 --- a/src/jsc/bindings/BunString.h +++ b/src/jsc/bindings/BunString.h @@ -61,3 +61,5 @@ WTF::String toCrossThreadShareable(const WTF::String& string); Ref toCrossThreadShareable(Ref impl); } + +BUN_DECLARE_HOST_FUNCTION(Bun__stringImplHashForTesting); diff --git a/test/js/bun/resolve/module-code-cache-collision.test.ts b/test/js/bun/resolve/module-code-cache-collision.test.ts index 1f8eda950b72..cbf2af440a69 100644 --- a/test/js/bun/resolve/module-code-cache-collision.test.ts +++ b/test/js/bun/resolve/module-code-cache-collision.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; +import * as internalForTesting from "bun:internal-for-testing"; import { bunEnv, bunExe, tempDir } from "harness"; +const { stringImplHash } = internalForTesting as { stringImplHash?: (s: string) => number }; + // JSC's CodeCache keys UnlinkedModuleProgramCodeBlocks on a 24-bit StringImpl // hash of the source text. Two distinct modules whose *transpiled* source // happens to hash-collide must NOT share a code block: the second module would @@ -9,23 +12,32 @@ import { bunEnv, bunExe, tempDir } from "harness"; // null SymbolTableEntry lookup inside JSModuleNamespaceObject::getOwnPropertySlotCommon. // Each pair below was mined so the *literal* file bytes collide under WTF's -// RapidHash (StringImpl::hash, masked to 24 bits). That only exercises the -// CodeCache bug if the runtime transpiler emits these sources unchanged; the -// precondition check fails loudly (rather than the tests going vacuous) when -// printer output drifts and the pairs need re-mining. +// StringImpl::hash() (24-bit-masked StringHasher). That only exercises the bug +// while (a) the runtime transpiler emits these sources unchanged and (b) WTF's +// string hash produces the same collision. Both preconditions are asserted so +// the tests fail loudly (rather than go vacuous) when either drifts and the +// pairs need re-mining. const transpiler = new Bun.Transpiler({ target: "bun" }); -function assertTranspilesToSelf(sources: readonly string[]) { - const diffs = sources.filter(src => transpiler.transformSync(src, "js") !== src); - expect(diffs, "runtime transpiler no longer emits these fixtures byte-for-byte; re-mine the collision pairs").toEqual( - [], - ); +function assertCollidingPair(a: string, b: string) { + for (const src of [a, b]) { + expect( + transpiler.transformSync(src, "js"), + "runtime transpiler no longer emits this fixture byte-for-byte; re-mine the collision pair", + ).toBe(src); + } + expect(a).not.toBe(b); + if (stringImplHash) { + expect( + stringImplHash(a), + "WTF StringImpl::hash() changed; this pair no longer collides, re-mine the collision pair", + ).toBe(stringImplHash(b)); + } } test.concurrent("modules with hash-colliding source evaluate their own code (const export)", async () => { - // RapidHash("export const tag = \"T004433\";\n") == RapidHash("export const tag = \"T004767\";\n") const a = 'export const tag = "T004433";\n'; const b = 'export const tag = "T004767";\n'; - assertTranspilesToSelf([a, b]); + assertCollidingPair(a, b); using dir = tempDir("codecache-collision-const", { "a.mjs": a, @@ -58,7 +70,7 @@ test.concurrent("modules with hash-colliding source do not crash on namespace de // release at SymbolTableEntry::scopeOffset(). const a = "export default function fn_T000686() {}\n"; const b = "export default function fn_T004636() {}\n"; - assertTranspilesToSelf([a, b]); + assertCollidingPair(a, b); using dir = tempDir("codecache-collision-fn", { "a.mjs": a, From 0748164b782af018492f81e5111b14ce792469a8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:55:23 +0000 Subject: [PATCH 4/8] [autofix.ci] apply automated fixes --- test/js/bun/resolve/module-code-cache-collision.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/resolve/module-code-cache-collision.test.ts b/test/js/bun/resolve/module-code-cache-collision.test.ts index cbf2af440a69..9d62272e2f86 100644 --- a/test/js/bun/resolve/module-code-cache-collision.test.ts +++ b/test/js/bun/resolve/module-code-cache-collision.test.ts @@ -1,5 +1,5 @@ -import { expect, test } from "bun:test"; import * as internalForTesting from "bun:internal-for-testing"; +import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; const { stringImplHash } = internalForTesting as { stringImplHash?: (s: string) => number }; From 95b0401a610198b7018935d0dbb799317de429f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:04:09 +0000 Subject: [PATCH 5/8] trim comments on stringImplHash binding --- src/js/internal-for-testing.ts | 4 +--- src/jsc/bindings/BunString.cpp | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index c81048a58f8e..cf3d6a541e53 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -41,9 +41,7 @@ export const highwayStringsForTesting: ( arg: number | Uint8Array, ) => number = $newCppFunction("highway_strings_testing.cpp", "Bun__highwayStringsForTesting", 3); -// WTF StringImpl::hash() — the 24-bit-masked StringHasher value JSC's -// SourceCodeKey uses for CodeCache lookup. Lets a test assert that a mined -// hash-collision pair still collides after a WebKit bump. +// WTF StringImpl::hash() (24-bit-masked; what SourceCodeKey compares on). export const stringImplHash: (s: string) => number = $newCppFunction( "BunString.cpp", "Bun__stringImplHashForTesting", diff --git a/src/jsc/bindings/BunString.cpp b/src/jsc/bindings/BunString.cpp index 65dfa2a382f5..ac2907751aa3 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -959,9 +959,6 @@ bool BunString::isEmpty() const } } -// bun:internal-for-testing — expose WTF's StringImpl::hash() (the 24-bit-masked -// StringHasher result that SourceCodeKey uses for CodeCache lookup) so a test -// can assert a mined hash-collision pair still collides after a WebKit bump. BUN_DEFINE_HOST_FUNCTION(Bun__stringImplHashForTesting, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); From 865f9e4b5c880b1bfdadbe99425c7f3c5969f0cf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:17:47 +0000 Subject: [PATCH 6/8] test: make stringImplHash precondition unconditional --- .../resolve/module-code-cache-collision.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/test/js/bun/resolve/module-code-cache-collision.test.ts b/test/js/bun/resolve/module-code-cache-collision.test.ts index 9d62272e2f86..614a69034c6d 100644 --- a/test/js/bun/resolve/module-code-cache-collision.test.ts +++ b/test/js/bun/resolve/module-code-cache-collision.test.ts @@ -1,9 +1,7 @@ -import * as internalForTesting from "bun:internal-for-testing"; +import { stringImplHash } from "bun:internal-for-testing"; import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; -const { stringImplHash } = internalForTesting as { stringImplHash?: (s: string) => number }; - // JSC's CodeCache keys UnlinkedModuleProgramCodeBlocks on a 24-bit StringImpl // hash of the source text. Two distinct modules whose *transpiled* source // happens to hash-collide must NOT share a code block: the second module would @@ -26,12 +24,10 @@ function assertCollidingPair(a: string, b: string) { ).toBe(src); } expect(a).not.toBe(b); - if (stringImplHash) { - expect( - stringImplHash(a), - "WTF StringImpl::hash() changed; this pair no longer collides, re-mine the collision pair", - ).toBe(stringImplHash(b)); - } + expect( + stringImplHash(a), + "WTF StringImpl::hash() changed; this pair no longer collides, re-mine the collision pair", + ).toBe(stringImplHash(b)); } test.concurrent("modules with hash-colliding source evaluate their own code (const export)", async () => { From c1d679f55204fd2769bed19334a0307302083ef4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:24:09 +0000 Subject: [PATCH 7/8] test: mine collision pairs in-test; cover new Function, eval, vm.Script SourceCodeKey is the key for every CodeCache entry, not just modules, so the test now also checks new Function, indirect eval and vm.Script in process, plus an in-process ES module import; the different-names module case stays in a child because it used to segfault. Pairs are mined from stringImplHash at test time instead of being hardcoded, so a StringHasher or printer change no longer needs anyone to re-mine fixtures by hand. --- .../bun/resolve/code-cache-collision.test.ts | 95 +++++++++++++++++++ .../module-code-cache-collision.test.ts | 93 ------------------ 2 files changed, 95 insertions(+), 93 deletions(-) create mode 100644 test/js/bun/resolve/code-cache-collision.test.ts delete mode 100644 test/js/bun/resolve/module-code-cache-collision.test.ts diff --git a/test/js/bun/resolve/code-cache-collision.test.ts b/test/js/bun/resolve/code-cache-collision.test.ts new file mode 100644 index 000000000000..8f9ac66765c6 --- /dev/null +++ b/test/js/bun/resolve/code-cache-collision.test.ts @@ -0,0 +1,95 @@ +import { stringImplHash } from "bun:internal-for-testing"; +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; +import vm from "node:vm"; + +// JSC's CodeCache keys every compiled top-level unit (module, program (which is +// also what require() evaluates), eval, `new Function`) on SourceCodeKey, whose +// hash is the 24-bit StringImpl hash of the source text. Two distinct same-length +// sources that collide on it must not share a code block; when they did, the +// second one ran the first one's code, and for ES modules with different +// top-level names it segfaulted. +// +// Each test mines its own colliding pair: sources built from a fixed-width tag, +// so lengths are equal and the first repeated hash is a collision. The 24-bit +// space makes that land after a few thousand candidates. Returns the two tags. +function mineCollidingTags(sourceFor: (tag: string) => string, hashedTextFor = sourceFor): [string, string] { + const seen = new Map(); + for (let i = 0; i < 200_000; i++) { + const tag = String(i).padStart(6, "0"); + const hash = stringImplHash(hashedTextFor(tag)); + const prev = seen.get(hash); + if (prev !== undefined) return [prev, tag]; + seen.set(hash, tag); + } + throw new Error("no StringImpl::hash() collision in 200k fixed-width candidates"); +} + +// Files go through the runtime transpiler before JSC hashes them, so a mined +// fixture must come out of it byte-for-byte. +const transpiler = new Bun.Transpiler({ target: "bun" }); +function mineCollidingFiles(sourceFor: (tag: string) => string) { + const tags = mineCollidingTags(sourceFor); + const sources = tags.map(sourceFor); + for (const src of sources) expect(transpiler.transformSync(src, "js")).toBe(src); + return { tags, sources }; +} + +test("new Function: colliding bodies each run their own code", () => { + // CreateDynamicFunction compiles (and JSC hashes) the synthesized function + // source, not the bare body. + const body = (tag: string) => `return "${tag}"`; + const tags = mineCollidingTags(body, tag => `function anonymous(\n) {\n${body(tag)}\n}`); + const [fa, fb] = tags.map(tag => new Function(body(tag))); + expect(stringImplHash(fa.toString())).toBe(stringImplHash(fb.toString())); + expect([fa(), fb()]).toEqual(tags); +}); + +test("indirect eval and vm.Script: colliding sources each run their own code", () => { + // vm.Script compiles a program the same way require() does for a CommonJS + // file; it is used here because its source reaches JSC verbatim, whereas the + // CommonJS function wrapper the runtime prints around a file is not visible + // to the test, so a colliding pair of files cannot be mined. + const source = (tag: string) => `"${tag}"`; + const tags = mineCollidingTags(source); + const sources = tags.map(source); + expect(sources.map(src => (0, eval)(src))).toEqual(tags); + expect(sources.map(src => new vm.Script(src).runInThisContext())).toEqual(tags); +}); + +test("import: colliding ES modules each export their own value", async () => { + const { tags, sources } = mineCollidingFiles(tag => `export const tag = "${tag}";\n`); + using dir = tempDir("codecache-collision-esm", { "a.mjs": sources[0], "b.mjs": sources[1] }); + const modules = await Promise.all(["a.mjs", "b.mjs"].map(file => import(join(String(dir), file)))); + expect(modules.map(m => m.tag)).toEqual(tags); +}); + +test("import: colliding ES modules with different top-level names do not crash", async () => { + // With different local names the wrongly shared code block's symbol table has + // no entry for the second module's `default` binding; looking it up tripped + // an assertion in debug and segfaulted in release, so this case runs in a + // child process. + const { tags, sources } = mineCollidingFiles(tag => `export default function fn_${tag}() {}\n`); + using dir = tempDir("codecache-collision-esm-names", { + "a.mjs": sources[0], + "b.mjs": sources[1], + "run.mjs": + "const a = await import('./a.mjs');\n" + + "const b = await import('./b.mjs');\n" + + "console.log(JSON.stringify([a.default.name, b.default.name]));\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(tags.map(tag => `fn_${tag}`)); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/resolve/module-code-cache-collision.test.ts b/test/js/bun/resolve/module-code-cache-collision.test.ts deleted file mode 100644 index 614a69034c6d..000000000000 --- a/test/js/bun/resolve/module-code-cache-collision.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { stringImplHash } from "bun:internal-for-testing"; -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; - -// JSC's CodeCache keys UnlinkedModuleProgramCodeBlocks on a 24-bit StringImpl -// hash of the source text. Two distinct modules whose *transpiled* source -// happens to hash-collide must NOT share a code block: the second module would -// link bun's JSModuleRecord (which carries the second module's export/local -// names) against the first module's symbol table, producing wrong values or a -// null SymbolTableEntry lookup inside JSModuleNamespaceObject::getOwnPropertySlotCommon. - -// Each pair below was mined so the *literal* file bytes collide under WTF's -// StringImpl::hash() (24-bit-masked StringHasher). That only exercises the bug -// while (a) the runtime transpiler emits these sources unchanged and (b) WTF's -// string hash produces the same collision. Both preconditions are asserted so -// the tests fail loudly (rather than go vacuous) when either drifts and the -// pairs need re-mining. -const transpiler = new Bun.Transpiler({ target: "bun" }); -function assertCollidingPair(a: string, b: string) { - for (const src of [a, b]) { - expect( - transpiler.transformSync(src, "js"), - "runtime transpiler no longer emits this fixture byte-for-byte; re-mine the collision pair", - ).toBe(src); - } - expect(a).not.toBe(b); - expect( - stringImplHash(a), - "WTF StringImpl::hash() changed; this pair no longer collides, re-mine the collision pair", - ).toBe(stringImplHash(b)); -} - -test.concurrent("modules with hash-colliding source evaluate their own code (const export)", async () => { - const a = 'export const tag = "T004433";\n'; - const b = 'export const tag = "T004767";\n'; - assertCollidingPair(a, b); - - using dir = tempDir("codecache-collision-const", { - "a.mjs": a, - "b.mjs": b, - "run.mjs": - "const a = await import('./a.mjs');\n" + - "const b = await import('./b.mjs');\n" + - "console.log(JSON.stringify({ a: a.tag, b: b.tag }));\n", - }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), "run.mjs"], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ a: "T004433", b: "T004767" }); - expect(exitCode).toBe(0); -}); - -test.concurrent("modules with hash-colliding source do not crash on namespace default access", async () => { - // The differing function name means the second module's `default` local name - // is absent from an incorrectly-shared symbol table, which previously hit - // `ASSERT(iter != symbolTable->end(locker))` in debug and segfaulted in - // release at SymbolTableEntry::scopeOffset(). - const a = "export default function fn_T000686() {}\n"; - const b = "export default function fn_T004636() {}\n"; - assertCollidingPair(a, b); - - using dir = tempDir("codecache-collision-fn", { - "a.mjs": a, - "b.mjs": b, - "run.mjs": - "const a = await import('./a.mjs');\n" + - "const b = await import('./b.mjs');\n" + - "console.log(JSON.stringify({ a: a.default.name, b: b.default.name }));\n", - }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), "run.mjs"], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ a: "fn_T000686", b: "fn_T004636" }); - expect(exitCode).toBe(0); -}); From 063a22cb6c608082eea5e4153a02a682eb541f9f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:47:57 +0000 Subject: [PATCH 8/8] test: use a template literal so indirect eval reaches the code cache globalFuncEval runs JSON-shaped sources through LiteralParser and returns without creating an EvalExecutable, so the "NNNNNN" sources never hit the CodeCache and the eval half of the assertion passed without the fix. A template literal is rejected by the preparser and is compiled and cached by both indirect eval and vm.Script; both now return the first source's value on an unfixed build. --- test/js/bun/resolve/code-cache-collision.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/js/bun/resolve/code-cache-collision.test.ts b/test/js/bun/resolve/code-cache-collision.test.ts index 8f9ac66765c6..fd88c38cac81 100644 --- a/test/js/bun/resolve/code-cache-collision.test.ts +++ b/test/js/bun/resolve/code-cache-collision.test.ts @@ -51,11 +51,17 @@ test("indirect eval and vm.Script: colliding sources each run their own code", ( // file; it is used here because its source reaches JSC verbatim, whereas the // CommonJS function wrapper the runtime prints around a file is not visible // to the test, so a colliding pair of files cannot be mined. - const source = (tag: string) => `"${tag}"`; + // + // The source is a template literal rather than a string literal: indirect + // eval hands JSON-shaped sources to LiteralParser and never compiles (or + // caches) them, so a plain "..." would pass with or without the fix. + const source = (tag: string) => "`" + tag + "`"; const tags = mineCollidingTags(source); const sources = tags.map(source); - expect(sources.map(src => (0, eval)(src))).toEqual(tags); - expect(sources.map(src => new vm.Script(src).runInThisContext())).toEqual(tags); + expect({ + eval: sources.map(src => (0, eval)(src)), + script: sources.map(src => new vm.Script(src).runInThisContext()), + }).toEqual({ eval: tags, script: tags }); }); test("import: colliding ES modules each export their own value", async () => {