From dc2eb48e687dcc4619f8bce246b3ae96a040e0c3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:58:42 +0000 Subject: [PATCH 01/26] runtime: attach ModuleInfo to ESM transpiles so type-only re-exports resolve export { SomeType } from './mod' in a .ts file produced SyntaxError: export 'SomeType' not found in './mod' at runtime because the re-export survived transpilation while the type declaration in the target module was erased, and JSC's own module analyzer has no way to know the missing binding was a TypeScript type. record that encodes every import/export and marks the potentially-elided bindings as ImportEntryType::SingleTypeScript plus m_isTypeScript on the JSModuleRecord, which JSC already honours at link time. That record was only attached under bun test --isolate (for the isolation source-provider cache) and for --compile standalone bytecode. Attach it on every runtime ESM transpile of a js/ts file (both the sync jsc_hooks path and the async RuntimeTranspilerStore path), gated by a new BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO escape hatch. Bump the on-disk transpiler cache version so pre-existing entries without an esm_record are invalidated. test/js/bun/typescript/type-export.test.ts: un-skip the 18 'run' mode tests that #15758 left behind a TODO for, add the #7384 repro covering both transpile paths, and fix two pre-existing debug-build issues (compile tests timing out behind the 4-slot semaphore, and ~18 x ~1 GB standalone binaries leaking into the temp dir per run). Fixes #7384 --- src/bun_core/env_var.rs | 5 + src/jsc/RuntimeTranspilerCache.rs | 6 +- src/jsc/RuntimeTranspilerStore.rs | 19 +-- src/jsc/VirtualMachine.rs | 18 +++ src/runtime/jsc_hooks.rs | 14 +- test/js/bun/typescript/type-export.test.ts | 159 ++++++++++++++------- 6 files changed, 156 insertions(+), 65 deletions(-) diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 9ca6d375d233..bb73fcd56b1a 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -220,6 +220,11 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG, "BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); + // Skip building ModuleInfo alongside runtime transpiles. With module_info + // attached, JSC reads imports/exports from Bun's printer output instead of + // re-parsing the transpiled source, which is what lets a TypeScript file + // re-export a type-only name without `export 'X' not found` (#7384). + new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO, "BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE, "BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO", {}); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 3373c1402e99..e45c66f318ec 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,7 +51,11 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -const EXPECTED_VERSION: u32 = 25; +/// Version 26: ModuleInfo is attached on every runtime ESM transpile (not only +/// under --isolate). Older entries have `esm_record_byte_length == 0`, so a +/// cache HIT would fall back to JSC's own analyze and re-raise the type-only +/// re-export error this version fixes (#7384). +const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 609a8c9b87f1..2e088ed58840 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -973,11 +973,15 @@ impl TranspilerJob { } // SAFETY: leaf scalar field read; see `vm` note above. Inlined - // `VirtualMachine::use_isolation_source_provider_cache` to avoid forming + // `VirtualMachine::use_module_info_for_esm` to avoid forming // `&VirtualMachine`. - let use_isolation_source_provider_cache = unsafe { (*vm).test_isolation_enabled } - && !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE::get() - .unwrap_or(false); + let test_isolation_enabled = unsafe { (*vm).test_isolation_enabled }; + let use_module_info_for_esm = + !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() + .unwrap_or(false) + || (test_isolation_enabled + && !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE::get() + .unwrap_or(false)); if let Some(entry_ptr) = cache.entry.take() { // SAFETY: `entry` was boxed by `JSC_PARSER_CACHE_VTABLE.get` from a @@ -1000,7 +1004,7 @@ impl TranspilerJob { dump_source_string(vm, specifier, entry.output_code.byte_slice()); } - let module_info: *mut c_void = if use_isolation_source_provider_cache + let module_info: *mut c_void = if use_module_info_for_esm && entry.metadata.module_type != CacheModuleType::Cjs && !entry.esm_record.is_empty() { @@ -1117,10 +1121,7 @@ impl TranspilerJob { let is_commonjs_module = parse_result.ast.has_commonjs_export_names || parse_result.ast.exports_kind == ExportsKind::Cjs; let mut module_info: Option> = - if use_isolation_source_provider_cache - && !is_commonjs_module - && loader.is_java_script_like() - { + if use_module_info_for_esm && !is_commonjs_module && loader.is_java_script_like() { Some(analyze_transpiled_module::ModuleInfo::create( loader.is_type_script(), )) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 0ee8a9c4075e..a41c7379e6ef 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4801,6 +4801,24 @@ impl VirtualMachine { .unwrap_or(false) } + /// Whether to attach `ModuleInfo` to runtime-transpiled ESM so JSC builds + /// the `JSModuleRecord` from Bun's printer output instead of re-parsing. + /// Always on (opt-out via `BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO`) + /// because a TypeScript re-export of a type-only name is only resolvable + /// through the `m_isTypeScript` / `SingleTypeScript` entries this record + /// carries; JSC's own analyze of the transpiled text has no way to know the + /// missing binding was a type. + pub fn use_module_info_for_esm(&self) -> bool { + if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() + .unwrap_or(false) + { + // Still honored under --isolate so the isolation source-provider + // cache keeps working when the flag is set. + return self.use_isolation_source_provider_cache(); + } + true + } + /// Resets entry-point state and re-loads `entry_path` for the test runner, returning the load promise. pub(crate) fn reload_entry_point_for_test_runner( &mut self, diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 60b81f6a0412..b2eb11242f73 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -3012,12 +3012,11 @@ fn transpile_source_code_inner( list: core::mem::take(&mut entry.sourcemap).into_vec(), }, ); - // Rebuild the cached ESM record for the - // isolation source-provider cache (same shape as - // `RuntimeTranspilerStore`). + // Rebuild the cached ESM record so JSC can skip its own + // analyze pass (same shape as `RuntimeTranspilerStore`). // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. let module_info: *mut core::ffi::c_void = if unsafe { &*jsc_vm } - .use_isolation_source_provider_cache() + .use_module_info_for_esm() && entry.metadata.module_type != CacheModuleType::Cjs && !entry.esm_record.is_empty() { @@ -3176,12 +3175,13 @@ fn transpile_source_code_inner( let is_commonjs_module = parse_result.ast.has_commonjs_export_names || parse_result.ast.exports_kind == bun_ast::ExportsKind::Cjs; - // Collect the ESM record while printing, for the isolation - // source-provider cache (same shape as `RuntimeTranspilerStore`). + // Collect the ESM record while printing so JSC builds the + // JSModuleRecord from Bun's output instead of re-parsing (same + // shape as `RuntimeTranspilerStore`). // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. let mut module_info: Option< Box, - > = if unsafe { &*jsc_vm }.use_isolation_source_provider_cache() + > = if unsafe { &*jsc_vm }.use_module_info_for_esm() && !is_commonjs_module && loader.is_java_script_like() { diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 3cf51510070b..abbb8465429b 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isDebug, isWindows, tempDir, tempDirWithFiles } from "harness"; const ext = isWindows ? ".exe" : ""; +// `bun build --compile --bytecode` reads + rewrites a full standalone +// executable (~1 GB under debug+ASAN) and the 18 compile cases queue behind a +// 4-slot semaphore, so the tail tests' wall clock is (queue depth × per-compile +// time) and easily clears the 5s default. +const compileTimeout = isDebug ? 180_000 : undefined; async function run(cmd: string[], cwd: string) { await using proc = Bun.spawn({ @@ -38,14 +43,21 @@ async function withCompileSlot(fn: () => Promise): Promise { async function compileAndRun(dir: string, entrypoint: string) { const outfile = dir + `/compiled${ext}`; return await withCompileSlot(async () => { - const buildResult = await run( - [bunExe(), "build", "--compile", "--bytecode", "--format=esm", entrypoint, "--outfile", outfile], - dir, - ); - expect(buildResult.stderr).toBe(""); - expect(buildResult.exitCode).toBe(0); - - return run([outfile], dir); + try { + const buildResult = await run( + [bunExe(), "build", "--compile", "--bytecode", "--format=esm", entrypoint, "--outfile", outfile], + dir, + ); + expect(buildResult.stderr).toBe(""); + expect(buildResult.exitCode).toBe(0); + + return await run([outfile], dir); + } finally { + // A debug+ASAN standalone executable is ~1 GB; 18 of them exhaust disk. + await Bun.file(outfile) + .delete() + .catch(() => {}); + } }); } @@ -117,29 +129,29 @@ for (const b_file of b_files) { }); describe.each(["run", "compile", "build"])("%s", mode => { - // TODO: "run" is skipped until ESM module_info is enabled in the runtime transpiler. - // Currently module_info is only generated for standalone ESM bytecode (--compile). - // Once enabled, flip this to include "run". - const testFn = mode === "run" ? test.skip : test.concurrent; - testFn("works", async () => { - let result: { stdout: string; stderr: string; exitCode: number }; - if (mode === "compile") { - result = await compileAndRun(dir, dir + "/c.ts"); - } else if (mode === "build") { - const build_result = await Bun.build({ - entrypoints: [dir + "/c.ts"], - outdir: dir + "/dist", - }); - expect(build_result.success).toBe(true); - result = await run([bunExe(), "run", dir + "/dist/c.js"], dir); - } else { - result = await run([bunExe(), "run", "c.ts"], dir); - } - - const parsedOutput = JSON.parse(result.stdout.trim()); - expect(parsedOutput).toEqual({ my_value: "2", my_only: "3" }); - expect(result.exitCode).toBe(0); - }); + test.concurrent( + "works", + async () => { + let result: { stdout: string; stderr: string; exitCode: number }; + if (mode === "compile") { + result = await compileAndRun(dir, dir + "/c.ts"); + } else if (mode === "build") { + const build_result = await Bun.build({ + entrypoints: [dir + "/c.ts"], + outdir: dir + "/dist", + }); + expect(build_result.success).toBe(true); + result = await run([bunExe(), "run", dir + "/dist/c.js"], dir); + } else { + result = await run([bunExe(), "run", "c.ts"], dir); + } + + const parsedOutput = JSON.parse(result.stdout.trim()); + expect(parsedOutput).toEqual({ my_value: "2", my_only: "3" }); + expect(result.exitCode).toBe(0); + }, + mode === "compile" ? compileTimeout : undefined, + ); }); }); } @@ -305,16 +317,18 @@ describe("check ownkeys from a star import", () => { }; describe.each(["run", "compile"] as const)("%s", mode => { - const testFn = mode === "run" ? test.skip : test.concurrent; - - testFn("works", async () => { - const result = - mode === "compile" ? await compileAndRun(dir, dir + "/main.ts") : await run([bunExe(), "main.ts"], dir); - - expect(result.stderr.trim()).toBe(""); - expect(JSON.parse(result.stdout.trim())).toEqual(expected); - expect(result.exitCode).toBe(0); - }); + test.concurrent( + "works", + async () => { + const result = + mode === "compile" ? await compileAndRun(dir, dir + "/main.ts") : await run([bunExe(), "main.ts"], dir); + + expect(result.stderr.trim()).toBe(""); + expect(JSON.parse(result.stdout.trim())).toEqual(expected); + expect(result.exitCode).toBe(0); + }, + mode === "compile" ? compileTimeout : undefined, + ); }); }); @@ -430,14 +444,63 @@ describe("import only used in decorator (#8439)", () => { }); describe.each(["run", "compile"] as const)("%s", mode => { - const testFn = mode === "run" ? test.skip : test.concurrent; + test.concurrent( + "works", + async () => { + const result = + mode === "compile" ? await compileAndRun(dir, dir + "/index.ts") : await run([bunExe(), "index.ts"], dir); + + expect(result.stderr.trim()).toBe(""); + expect(result.exitCode).toBe(0); + }, + mode === "compile" ? compileTimeout : undefined, + ); + }); +}); + +// https://github.com/oven-sh/bun/issues/7384 +describe("re-export of type alongside value at runtime (#7384)", () => { + const dir = tempDirWithFiles("reexport-type-7384", { + "EventTypes.ts": ` + export type ValueOf = T[keyof T]; + export const BUEvents = { A: "a", B: "b" } as const; + `, + "utils.ts": `export { ValueOf, BUEvents } from "./EventTypes";`, + "index.ts": ` + import { ValueOf, BUEvents } from "./utils"; + type X = ValueOf; + const x: X = BUEvents.A; + console.log(JSON.stringify({ x, keys: Object.keys(BUEvents).sort() })); + `, + }); - testFn("works", async () => { - const result = - mode === "compile" ? await compileAndRun(dir, dir + "/index.ts") : await run([bunExe(), "index.ts"], dir); + // BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER routes through the sync + // transpile in jsc_hooks.rs; without it the async RuntimeTranspilerStore path + // is taken. Both must attach module_info. + for (const disableAsync of [false, true]) { + test.concurrent(disableAsync ? "sync transpiler" : "async transpiler", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: disableAsync ? { ...bunEnv, BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER: "1" } : bunEnv, + cwd: dir, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr.trim()).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual({ x: "a", keys: ["A", "B"] }); + expect(exitCode).toBe(0); + }); + } - expect(result.stderr.trim()).toBe(""); - expect(result.exitCode).toBe(0); + test.concurrent("BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO restores the old error", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO: "1" }, + cwd: dir, + stdio: ["inherit", "pipe", "pipe"], }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("export 'ValueOf' not found"); + expect(exitCode).toBe(1); }); }); From 3a14030b917b53af2f514c9b5e27f9cd0b92d02a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:24:24 +0000 Subject: [PATCH 02/26] Skip ModuleInfo when the parser emitted errors On the async transpile path, a parser-level error like 'Multiple exports with the same name' is written to the log but the parse still returns an AST. ModuleInfo built from that AST silently dedupes the conflicting export entry, so in release builds (where JSC does not re-parse) the error surfaced later as an unrelated link-time 'export not found'. Fall back to JSC's own analyze when log.has_errors() so the real syntax error is reported, matching the sync path which already bails on log.errors. --- src/jsc/RuntimeTranspilerStore.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 2e088ed58840..15c270ef9b45 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -1120,8 +1120,18 @@ impl TranspilerJob { let is_commonjs_module = parse_result.ast.has_commonjs_export_names || parse_result.ast.exports_kind == ExportsKind::Cjs; + // When the parser emitted errors but still produced an AST (e.g. + // `Multiple exports with the same name`), the printed ModuleInfo would + // silently dedupe the conflicting entry and the error would surface + // later as an unrelated link-time failure. Leave `module_info` unset so + // JSC's own analyze re-parses the output and reports the real syntax + // error, matching the sync path which bails on `log.errors > 0`. let mut module_info: Option> = - if use_module_info_for_esm && !is_commonjs_module && loader.is_java_script_like() { + if use_module_info_for_esm + && !is_commonjs_module + && loader.is_java_script_like() + && !log.has_errors() + { Some(analyze_transpiled_module::ModuleInfo::create( loader.is_type_script(), )) From 5a122c868a81f83cf9be92396b6293bb3fc1f5e0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:27:23 +0000 Subject: [PATCH 03/26] Address review: features_hash, cache-hit test, pin silenced-typo behavior - Mix BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO into the transpiler cache features_hash so toggling the flag cannot leave v24 entries without an esm_record that then reinstate the error on a warm run. - Cover the on-disk cache HIT path (pad utils.ts past the 4 KiB floor, enable BUN_RUNTIME_TRANSPILER_CACHE_PATH, run twice) on both the sync and async transpile paths. - Pin the consequence of m_isTypeScript for every .ts re-exporter: a barrel re-exporting a genuinely missing value name links via 'import * as ns' with the key absent, and still errors via a direct named import. Matches bun build / --compile today. - Trim feature-flag and helper doc comments. - .gitignore the astro fixture .astro/ directory so running the test locally stops dirtying the worktree. --- src/bun_core/env_var.rs | 6 +- src/jsc/RuntimeTranspilerCache.rs | 6 ++ src/jsc/RuntimeTranspilerStore.rs | 9 +-- src/jsc/VirtualMachine.rs | 9 +-- test/js/bun/typescript/type-export.test.ts | 74 +++++++++++++++++++ test/js/third_party/astro/fixtures/.gitignore | 1 + 6 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 test/js/third_party/astro/fixtures/.gitignore diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index bb73fcd56b1a..edfa04b0929b 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -220,10 +220,8 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG, "BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); - // Skip building ModuleInfo alongside runtime transpiles. With module_info - // attached, JSC reads imports/exports from Bun's printer output instead of - // re-parsing the transpiled source, which is what lets a TypeScript file - // re-export a type-only name without `export 'X' not found` (#7384). + // Skip attaching ModuleInfo to runtime ESM transpiles so JSC falls back to + // its own analyze pass. Disables the #7384 fix. new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO, "BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE, "BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE", {}); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index e45c66f318ec..be422ac5e085 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -966,6 +966,12 @@ impl RuntimeTranspilerCache { let mut features_hasher = Wyhash::init(SEED); parser_options.hash_for_runtime_transpiler(&mut features_hasher, used_jsx); + // Whether the printer serializes an esm_record into this entry depends + // on this flag, so flag-on and flag-off runs must not share cache files. + features_hasher.update(&[u8::from( + bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() + .unwrap_or(false), + )]); self.features_hash = Some(features_hasher.final_()); self.entry = match Self::from_file( diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 15c270ef9b45..210890778ff1 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -1120,12 +1120,9 @@ impl TranspilerJob { let is_commonjs_module = parse_result.ast.has_commonjs_export_names || parse_result.ast.exports_kind == ExportsKind::Cjs; - // When the parser emitted errors but still produced an AST (e.g. - // `Multiple exports with the same name`), the printed ModuleInfo would - // silently dedupe the conflicting entry and the error would surface - // later as an unrelated link-time failure. Leave `module_info` unset so - // JSC's own analyze re-parses the output and reports the real syntax - // error, matching the sync path which bails on `log.errors > 0`. + // `!log.has_errors()`: a duplicate-export or similar parser error leaves + // an AST whose ModuleInfo would mask the real syntax error. Fall back to + // JSC's analyze (mirrors the sync path's `log.errors > 0` bail). let mut module_info: Option> = if use_module_info_for_esm && !is_commonjs_module diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index a41c7379e6ef..35b98fb24079 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4803,17 +4803,12 @@ impl VirtualMachine { /// Whether to attach `ModuleInfo` to runtime-transpiled ESM so JSC builds /// the `JSModuleRecord` from Bun's printer output instead of re-parsing. - /// Always on (opt-out via `BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO`) - /// because a TypeScript re-export of a type-only name is only resolvable - /// through the `m_isTypeScript` / `SingleTypeScript` entries this record - /// carries; JSC's own analyze of the transpiled text has no way to know the - /// missing binding was a type. + /// The record carries `m_isTypeScript` / `SingleTypeScript` entries that + /// let a TypeScript re-export of a type-only name resolve (#7384). pub fn use_module_info_for_esm(&self) -> bool { if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() .unwrap_or(false) { - // Still honored under --isolate so the isolation source-provider - // cache keeps working when the flag is set. return self.use_isolation_source_provider_cache(); } true diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index abbb8465429b..361b5b8d441a 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -503,4 +503,78 @@ describe("re-export of type alongside value at runtime (#7384)", () => { expect(stderr).toContain("export 'ValueOf' not found"); expect(exitCode).toBe(1); }); + + // The on-disk RuntimeTranspilerCache stores the serialized ModuleInfo as + // esm_record. bunEnv disables the cache and the fixtures above are under the + // 4 KiB minimum, so cover the cache-HIT path explicitly: pad utils.ts past + // the floor, point BUN_RUNTIME_TRANSPILER_CACHE_PATH at a real dir, and run + // twice per transpile path so the second run hits create_from_cached_record. + const padding = Array.from({ length: 400 }, (_, i) => `const pad_${i} = ${i};`).join("\n"); + const cacheDir = tempDirWithFiles("reexport-type-7384-cache", { + "EventTypes.ts": ` + export type ValueOf = T[keyof T]; + export const BUEvents = { A: "a", B: "b" } as const; + `, + "utils.ts": `${padding}\nexport { ValueOf, BUEvents } from "./EventTypes";`, + "index.ts": ` + import { ValueOf, BUEvents } from "./utils"; + const x: ValueOf = BUEvents.A; + console.log(JSON.stringify({ x, keys: Object.keys(BUEvents).sort() })); + `, + ".cache/.keep": "", + }); + for (const disableAsync of [false, true]) { + test(`${disableAsync ? "sync" : "async"} transpiler (runtime transpiler cache hit)`, async () => { + const env = { + ...bunEnv, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: `${cacheDir}/.cache`, + BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1", + ...(disableAsync ? { BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER: "1" } : {}), + }; + for (const which of ["miss", "hit"]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env, + cwd: cacheDir, + stdio: ["inherit", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ which, stderr: stderr.trim(), out: JSON.parse(stdout.trim() || "null"), exitCode }).toEqual({ + which, + stderr: "", + out: { x: "a", keys: ["A", "B"] }, + exitCode: 0, + }); + } + }); + } +}); + +// Marking the JSModuleRecord m_isTypeScript means *every* unresolved indirect +// export in a .ts file is tolerated at link time, not just type-only ones: the +// re-exporting file has no local signal for which is which. A direct import of +// the missing name still errors; a namespace import just omits the key. This +// matches what `bun build` / `--compile` already produced and what +// ts-node/tsx do. Pin it so the trade-off is explicit. +test.concurrent("ts barrel re-exporting a missing value name links without error", async () => { + const dir = tempDirWithFiles("reexport-missing-value", { + "lib.ts": "export const foo = 1;", + "barrel.ts": `export { foo, fooo } from "./lib";`, + "via-ns.ts": ` + import * as b from "./barrel"; + console.log(JSON.stringify({ keys: Object.keys(b).sort(), fooo: (b as any).fooo })); + `, + "via-named.ts": `import { fooo } from "./barrel"; console.log(fooo);`, + }); + { + const ns = await run([bunExe(), "via-ns.ts"], dir); + expect(ns.stderr.trim()).toBe(""); + expect(JSON.parse(ns.stdout.trim())).toEqual({ keys: ["foo"], fooo: undefined }); + expect(ns.exitCode).toBe(0); + } + { + const named = await run([bunExe(), "via-named.ts"], dir); + expect(named.stderr).toContain("Export named 'fooo' not found"); + expect(named.exitCode).toBe(1); + } }); diff --git a/test/js/third_party/astro/fixtures/.gitignore b/test/js/third_party/astro/fixtures/.gitignore new file mode 100644 index 000000000000..97e663b1777c --- /dev/null +++ b/test/js/third_party/astro/fixtures/.gitignore @@ -0,0 +1 @@ +.astro/ From f4fd98500a69edc2355daa8fc32658ce85d871cc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:08:50 +0000 Subject: [PATCH 04/26] test: give each cache-hit variant its own cache dir The sync and async cache-hit tests shared one BUN_RUNTIME_TRANSPILER_CACHE_PATH, so the sync 'miss' iteration was actually a hit on the entry the async test wrote (BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER does not participate in features_hash). --- test/js/bun/typescript/type-export.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 361b5b8d441a..7e67e573e89f 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -521,13 +521,14 @@ describe("re-export of type alongside value at runtime (#7384)", () => { const x: ValueOf = BUEvents.A; console.log(JSON.stringify({ x, keys: Object.keys(BUEvents).sort() })); `, - ".cache/.keep": "", + ".cache-async/.keep": "", + ".cache-sync/.keep": "", }); for (const disableAsync of [false, true]) { test(`${disableAsync ? "sync" : "async"} transpiler (runtime transpiler cache hit)`, async () => { const env = { ...bunEnv, - BUN_RUNTIME_TRANSPILER_CACHE_PATH: `${cacheDir}/.cache`, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: `${cacheDir}/.cache-${disableAsync ? "sync" : "async"}`, BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1", ...(disableAsync ? { BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER: "1" } : {}), }; From 7148b7997a6354077739e67e18be8ddb21d97daa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:09:39 +0000 Subject: [PATCH 05/26] test: inspector breakpoints must resolve in runtime-transpiled ESM With ModuleInfo attached, every runtime ES module reaches JSC as a BunTranspiledModule SourceProvider. JSC's DebuggerParseData only gathers pause positions for Program/Module, so Debugger.setBreakpoint replies 'Could not resolve breakpoint' and setBreakpointByUrl resolves nothing until the WebKit side (oven-sh/WebKit#345) lands. This pins the expected behaviour; it fails on this branch until WEBKIT_VERSION is bumped past that change and passes on main today (plain Module providers). --- .../inspect-module-breakpoints.test.ts | 111 ++++++++++++++++++ test/js/bun/typescript/type-export.test.ts | 2 +- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 test/cli/inspect/inspect-module-breakpoints.test.ts diff --git a/test/cli/inspect/inspect-module-breakpoints.test.ts b/test/cli/inspect/inspect-module-breakpoints.test.ts new file mode 100644 index 000000000000..3593c9b0b849 --- /dev/null +++ b/test/cli/inspect/inspect-module-breakpoints.test.ts @@ -0,0 +1,111 @@ +// Runtime-transpiled ESM is handed to JSC as a SourceProvider tagged +// BunTranspiledModule (Bun's pre-computed module record replaces JSC's analyze +// pass). JSC's debugger has to treat that tag exactly like Module, otherwise +// `Debugger.setBreakpoint` on every user module replies "Could not resolve +// breakpoint" and `Debugger.setBreakpointByUrl` resolves to no locations. +// Requires the WebKit side of the fix (oven-sh/WebKit#345). +import { spawn } from "bun"; +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("--inspect: breakpoints resolve in a runtime-transpiled ES module", async () => { + using dir = tempDir("inspect-module-breakpoints", { + "mod.ts": [ + `export const x: number = 1;`, + `const y = x + 1;`, + `console.log(y);`, + `setInterval(() => {}, 1000);`, + ``, + ].join("\n"), + }); + + await using proc = spawn({ + cmd: [bunExe(), "--inspect-wait=127.0.0.1:0", "mod.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + + let url: URL | undefined; + let stderr = ""; + const decoder = new TextDecoder(); + for await (const chunk of proc.stderr as ReadableStream) { + stderr += decoder.decode(chunk, { stream: true }); + for (const line of stderr.split("\n")) { + try { + const candidate = new URL(line.trim()); + if (candidate.protocol === "ws:") { + url = candidate; + break; + } + } catch {} + } + if (url) break; + } + if (!url) throw new Error(`inspector URL not found in stderr: ${JSON.stringify(stderr)}`); + + const ws = new WebSocket(url); + try { + const failed = new Promise((_, reject) => { + ws.addEventListener("error", cause => reject(new Error("WebSocket error", { cause }))); + ws.addEventListener("close", cause => reject(new Error("WebSocket closed", { cause }))); + proc.exited.then(code => reject(new Error(`inspectee exited early (${code})`))); + }); + failed.catch(() => {}); + await Promise.race([new Promise(resolve => ws.addEventListener("open", () => resolve())), failed]); + + const pending = new Map void>(); + const { promise: scriptParsed, resolve: resolveScriptParsed } = Promise.withResolvers(); + ws.addEventListener("message", ({ data }) => { + const msg = JSON.parse(String(data)); + if (typeof msg.id === "number") { + pending.get(msg.id)?.(msg); + pending.delete(msg.id); + } else if (msg.method === "Debugger.scriptParsed" && String(msg.params?.url ?? "").endsWith("mod.ts")) { + resolveScriptParsed(msg.params); + } + }); + let nextId = 0; + const send = (method: string, params: Record = {}) => + Promise.race([ + new Promise(resolve => { + const id = ++nextId; + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params })); + }), + failed, + ]); + + await Promise.all([send("Inspector.enable"), send("Debugger.enable")]); + send("Inspector.initialized").catch(() => {}); + const script = await Promise.race([scriptParsed, failed]); + + const [byId, byUrl] = await Promise.all([ + send("Debugger.setBreakpoint", { + location: { scriptId: script.scriptId, lineNumber: 1, columnNumber: 0 }, + }), + send("Debugger.setBreakpointByUrl", { url: script.url, lineNumber: 2, columnNumber: 0 }), + ]); + + expect({ scriptType: script.scriptType, byId, byUrl }).toEqual({ + scriptType: "module", + byId: { + id: expect.any(Number), + result: { + breakpointId: expect.any(String), + actualLocation: { scriptId: script.scriptId, lineNumber: 1, columnNumber: expect.any(Number) }, + }, + }, + byUrl: { + id: expect.any(Number), + result: { + breakpointId: expect.any(String), + locations: [{ scriptId: script.scriptId, lineNumber: 2, columnNumber: expect.any(Number) }], + }, + }, + }); + } finally { + ws.close(); + } +}); diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 7e67e573e89f..4beef0a24b30 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -558,7 +558,7 @@ describe("re-export of type alongside value at runtime (#7384)", () => { // matches what `bun build` / `--compile` already produced and what // ts-node/tsx do. Pin it so the trade-off is explicit. test.concurrent("ts barrel re-exporting a missing value name links without error", async () => { - const dir = tempDirWithFiles("reexport-missing-value", { + await using dir = tempDir("reexport-missing-value", { "lib.ts": "export const foo = 1;", "barrel.ts": `export { foo, fooo } from "./lib";`, "via-ns.ts": ` From 9e602825dc3198384db5af64dac8684f34e88b07 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:10:59 +0000 Subject: [PATCH 06/26] Shorten transpiler cache version note --- src/jsc/RuntimeTranspilerCache.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index be422ac5e085..3b11b40e51a1 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,10 +51,8 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -/// Version 26: ModuleInfo is attached on every runtime ESM transpile (not only -/// under --isolate). Older entries have `esm_record_byte_length == 0`, so a -/// cache HIT would fall back to JSC's own analyze and re-raise the type-only -/// re-export error this version fixes (#7384). +/// Version 26: ModuleInfo is written for every runtime ESM transpile, not only +/// under --isolate; older entries have an empty esm_record (#7384). const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk From b2ee4c023fc4f4116a97c492808cc23d666bad73 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:18:25 +0000 Subject: [PATCH 07/26] Share the ModuleInfo decision between the VM and the transpiler worker --- src/jsc/RuntimeTranspilerStore.rs | 11 ++--------- src/jsc/VirtualMachine.rs | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 210890778ff1..56b81dbcad69 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -972,16 +972,9 @@ impl TranspilerJob { } } - // SAFETY: leaf scalar field read; see `vm` note above. Inlined - // `VirtualMachine::use_module_info_for_esm` to avoid forming - // `&VirtualMachine`. - let test_isolation_enabled = unsafe { (*vm).test_isolation_enabled }; + // SAFETY: leaf scalar field read; see `vm` note above. let use_module_info_for_esm = - !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() - .unwrap_or(false) - || (test_isolation_enabled - && !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE::get() - .unwrap_or(false)); + VirtualMachine::use_module_info_for_esm_with(unsafe { (*vm).test_isolation_enabled }); if let Some(entry_ptr) = cache.entry.take() { // SAFETY: `entry` was boxed by `JSC_PARSER_CACHE_VTABLE.get` from a diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 35b98fb24079..2fa47996ef3e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4796,7 +4796,11 @@ impl VirtualMachine { /// Whether the per-test-isolation source provider cache is active. #[unsafe(export_name = "Bun__VM__useIsolationSourceProviderCache")] pub extern "C" fn use_isolation_source_provider_cache(&self) -> bool { - self.test_isolation_enabled + Self::use_isolation_source_provider_cache_with(self.test_isolation_enabled) + } + + fn use_isolation_source_provider_cache_with(test_isolation_enabled: bool) -> bool { + test_isolation_enabled && !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE::get() .unwrap_or(false) } @@ -4806,12 +4810,15 @@ impl VirtualMachine { /// The record carries `m_isTypeScript` / `SingleTypeScript` entries that /// let a TypeScript re-export of a type-only name resolve (#7384). pub fn use_module_info_for_esm(&self) -> bool { - if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() + Self::use_module_info_for_esm_with(self.test_isolation_enabled) + } + + /// Scalar form of [`Self::use_module_info_for_esm`] for the transpiler + /// worker, which must not form a `&VirtualMachine`. + pub fn use_module_info_for_esm_with(test_isolation_enabled: bool) -> bool { + !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() .unwrap_or(false) - { - return self.use_isolation_source_provider_cache(); - } - true + || Self::use_isolation_source_provider_cache_with(test_isolation_enabled) } /// Resets entry-point state and re-loads `entry_path` for the test runner, returning the load promise. From 05c547f4f7f6d0828a9a06bd7b3e03b6763c25e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:33:36 +0000 Subject: [PATCH 08/26] Make the ModuleInfo escape hatch process-wide so the cache key can hash it With BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO set, the decision used to fall back to the per-VM isolation-cache setting, but the transpiler cache key only hashed the flag, so an isolated and a non-isolated run could share an entry while disagreeing on whether it should carry an esm_record. The flag now disables ModuleInfo everywhere (under --isolate the provider is a plain Module, which the isolation cache already supports), and the cache hashes the one decision function directly. --- src/bun_core/env_var.rs | 4 ++-- src/jsc/RuntimeTranspilerCache.rs | 6 ++---- src/jsc/RuntimeTranspilerStore.rs | 4 +--- src/jsc/VirtualMachine.rs | 18 +++++------------- src/runtime/jsc_hooks.rs | 29 +++++++++++++---------------- 5 files changed, 23 insertions(+), 38 deletions(-) diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index edfa04b0929b..ce8281246de1 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -220,8 +220,8 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG, "BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); - // Skip attaching ModuleInfo to runtime ESM transpiles so JSC falls back to - // its own analyze pass. Disables the #7384 fix. + // Never attach ModuleInfo to runtime ESM transpiles (also under + // `bun test --isolate`); JSC runs its own analyze pass. Disables the #7384 fix. new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO, "BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE, "BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE", {}); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 3b11b40e51a1..306d4072c49d 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -964,11 +964,9 @@ impl RuntimeTranspilerCache { let mut features_hasher = Wyhash::init(SEED); parser_options.hash_for_runtime_transpiler(&mut features_hasher, used_jsx); - // Whether the printer serializes an esm_record into this entry depends - // on this flag, so flag-on and flag-off runs must not share cache files. + // Decides whether the entry carries an esm_record. features_hasher.update(&[u8::from( - bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() - .unwrap_or(false), + crate::virtual_machine::VirtualMachine::use_module_info_for_esm(), )]); self.features_hash = Some(features_hasher.final_()); diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 56b81dbcad69..177aca6a2cf1 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -972,9 +972,7 @@ impl TranspilerJob { } } - // SAFETY: leaf scalar field read; see `vm` note above. - let use_module_info_for_esm = - VirtualMachine::use_module_info_for_esm_with(unsafe { (*vm).test_isolation_enabled }); + let use_module_info_for_esm = VirtualMachine::use_module_info_for_esm(); if let Some(entry_ptr) = cache.entry.take() { // SAFETY: `entry` was boxed by `JSC_PARSER_CACHE_VTABLE.get` from a diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2fa47996ef3e..818842df0b85 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4796,11 +4796,7 @@ impl VirtualMachine { /// Whether the per-test-isolation source provider cache is active. #[unsafe(export_name = "Bun__VM__useIsolationSourceProviderCache")] pub extern "C" fn use_isolation_source_provider_cache(&self) -> bool { - Self::use_isolation_source_provider_cache_with(self.test_isolation_enabled) - } - - fn use_isolation_source_provider_cache_with(test_isolation_enabled: bool) -> bool { - test_isolation_enabled + self.test_isolation_enabled && !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE::get() .unwrap_or(false) } @@ -4809,16 +4805,12 @@ impl VirtualMachine { /// the `JSModuleRecord` from Bun's printer output instead of re-parsing. /// The record carries `m_isTypeScript` / `SingleTypeScript` entries that /// let a TypeScript re-export of a type-only name resolve (#7384). - pub fn use_module_info_for_esm(&self) -> bool { - Self::use_module_info_for_esm_with(self.test_isolation_enabled) - } - - /// Scalar form of [`Self::use_module_info_for_esm`] for the transpiler - /// worker, which must not form a `&VirtualMachine`. - pub fn use_module_info_for_esm_with(test_isolation_enabled: bool) -> bool { + /// + /// Process-wide (no VM state) so the transpiler cache can hash the same + /// answer into its key: entries written with it off carry no esm_record. + pub fn use_module_info_for_esm() -> bool { !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() .unwrap_or(false) - || Self::use_isolation_source_provider_cache_with(test_isolation_enabled) } /// Resets entry-point state and re-loads `entry_path` for the test runner, returning the load promise. diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index b2eb11242f73..2c1113199b4a 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -3014,20 +3014,18 @@ fn transpile_source_code_inner( ); // Rebuild the cached ESM record so JSC can skip its own // analyze pass (same shape as `RuntimeTranspilerStore`). - // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. - let module_info: *mut core::ffi::c_void = if unsafe { &*jsc_vm } - .use_module_info_for_esm() - && entry.metadata.module_type != CacheModuleType::Cjs - && !entry.esm_record.is_empty() - { - bun_bundler::analyze_transpiled_module::ModuleInfoDeserialized::create_from_cached_record( - &entry.esm_record, - ) - .map(|b| bun_core::heap::into_raw(b).cast()) - .unwrap_or(core::ptr::null_mut()) - } else { - core::ptr::null_mut() - }; + let module_info: *mut core::ffi::c_void = + if VirtualMachine::use_module_info_for_esm() + && entry.metadata.module_type != CacheModuleType::Cjs + && !entry.esm_record.is_empty() + { + use bun_bundler::analyze_transpiled_module::ModuleInfoDeserialized; + ModuleInfoDeserialized::create_from_cached_record(&entry.esm_record) + .map(|b| bun_core::heap::into_raw(b).cast()) + .unwrap_or(core::ptr::null_mut()) + } else { + core::ptr::null_mut() + }; let is_commonjs_module = entry.metadata.module_type == CacheModuleType::Cjs; // Node compile cache hook (transpiler-cache-hit path); must // read `output_code` before it is consumed below. UTF-16 @@ -3178,10 +3176,9 @@ fn transpile_source_code_inner( // Collect the ESM record while printing so JSC builds the // JSModuleRecord from Bun's output instead of re-parsing (same // shape as `RuntimeTranspilerStore`). - // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. let mut module_info: Option< Box, - > = if unsafe { &*jsc_vm }.use_module_info_for_esm() + > = if VirtualMachine::use_module_info_for_esm() && !is_commonjs_module && loader.is_java_script_like() { From 61db16a21da60c479160a927bfb8d9e707cfbdbd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:44:48 +0000 Subject: [PATCH 09/26] Pin WebKit to the oven-sh/WebKit#345 preview build autobuild-preview-pr-345-b7c69395 adds the BunTranspiledModule arms to DebuggerParseData, CachedTypes and Completion on top of 447082ab, which is what lets inspect-module-breakpoints.test.ts pass now that every runtime ES module is a BunTranspiledModule provider. To be repinned to the merge sha's autobuild before this lands. --- scripts/build/deps/webkit.ts | 2 +- src/bun_core/env_var.rs | 3 +-- src/jsc/VirtualMachine.rs | 10 +++------- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 37386d50099e..96a7381c7f7d 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 = "447082ab6897278727b44e1ba3c326ae6e1504c3"; +export const WEBKIT_VERSION = "autobuild-preview-pr-345-b7c69395"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index ce8281246de1..191aa48a3e32 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -220,8 +220,7 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG, "BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); - // Never attach ModuleInfo to runtime ESM transpiles (also under - // `bun test --isolate`); JSC runs its own analyze pass. Disables the #7384 fix. + // Escape hatch for the #7384 fix: never attach ModuleInfo to runtime ESM. new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO, "BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE, "BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE", {}); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 818842df0b85..8c1adcc52c1f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4801,13 +4801,9 @@ impl VirtualMachine { .unwrap_or(false) } - /// Whether to attach `ModuleInfo` to runtime-transpiled ESM so JSC builds - /// the `JSModuleRecord` from Bun's printer output instead of re-parsing. - /// The record carries `m_isTypeScript` / `SingleTypeScript` entries that - /// let a TypeScript re-export of a type-only name resolve (#7384). - /// - /// Process-wide (no VM state) so the transpiler cache can hash the same - /// answer into its key: entries written with it off carry no esm_record. + /// Attach `ModuleInfo` to runtime-transpiled ESM so JSC builds the module + /// record from Bun's output (keeps TypeScript type-only re-exports linkable, + /// #7384). Process-wide so `RuntimeTranspilerCache` can hash it into its key. pub fn use_module_info_for_esm() -> bool { !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_RUNTIME_MODULE_INFO::get() .unwrap_or(false) From 0d8bb6282dc93da16fa099f82e48c04143fd120e Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 10 Aug 2026 13:48:04 -0700 Subject: [PATCH 10/26] fix requiring esm graphs with a shared dependency --- src/js_printer/lib.rs | 4 +++ .../bindings/BunAnalyzeTranspiledModule.cpp | 19 +++++-------- test/js/bun/typescript/type-export.test.ts | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index ebd6fda572c4..fb2df0dcb447 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -620,6 +620,10 @@ pub mod analyze_transpiled_module { self.record_kinds[idx] = RecordKind::ImportInfoSingleTypeScript; } } + // Build-time indexes only; the runtime keeps this struct alive for the + // SourceProvider's lifetime, so drop them now. + self.strings_map = HashMap::default(); + self.exported_names = HashMap::default(); self.finalized = true; Ok(()) } diff --git a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp index ee7b03a1c6b6..3936ff544058 100644 --- a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp +++ b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp @@ -13,7 +13,6 @@ #include "ZigSourceProvider.h" #include "ZigGlobalObject.h" #include "headers-handwritten.h" -#include "IsolatedModuleCache.h" #include "BunAnalyzeTranspiledModule.h" // ref: JSModuleLoader.cpp @@ -170,20 +169,16 @@ extern "C" EncodedJSValue Bun__analyzeTranspiledModule(JSGlobalObject* globalObj auto provider = static_cast(sourceCode.provider()); - if (provider->m_resolvedSource.module_info == nullptr) { - dataLog("[note] module_info is null for module: ", moduleKey.utf8(), "\n"); - RELEASE_AND_RETURN(scope, JSValue::encode(rejectWithError(createError(globalObject, WTF::String::fromLatin1("module_info is null"))))); - } + // module_info stays on the provider until ~SourceProvider: JSC analyzes the + // same JSSourceCode more than once (require(esm) sync replay re-issues + // makeModule on an already-fetched entry; --isolate reuses providers across + // globals), and every call must produce the same record. + ASSERT_WITH_MESSAGE(provider->m_resolvedSource.module_info, "BunTranspiledModule provider without module_info: %s", moduleKey.utf8().data()); + if (provider->m_resolvedSource.module_info == nullptr) [[unlikely]] + RELEASE_AND_RETURN(scope, fallbackParse(globalObject, moduleKey, sourceCode, promise)); auto* moduleInfo = static_cast(provider->m_resolvedSource.module_info); auto moduleRecord = zig__ModuleInfoDeserialized__toJSModuleRecord(globalObject, vm, moduleKey, sourceCode, moduleInfo); - // Under --isolate the same SourceProvider is reused across globals via the - // IsolatedModuleCache, so module_info must remain alive on the provider; - // ~SourceProvider frees it. Otherwise, free now. - if (!Bun::IsolatedModuleCache::canUse(vm, uncheckedDowncast(globalObject)->bunVM())) { - zig__ModuleInfoDeserialized__deinit(moduleInfo); - provider->m_resolvedSource.module_info = nullptr; - } if (moduleRecord == nullptr) { RELEASE_AND_RETURN(scope, JSValue::encode(rejectWithError(createError(globalObject, WTF::String::fromLatin1("parseFromSourceCode failed"))))); } diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 4beef0a24b30..5ed18c879c29 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -579,3 +579,31 @@ test.concurrent("ts barrel re-exporting a missing value name links without error expect(named.exitCode).toBe(1); } }); + +// require(esm) replays part of the load synchronously and JSC hands the same +// fetched source to the module analyzer a second time. The prebuilt module +// record attached to runtime ESM must survive that second call; when it was +// freed after the first one, the shared dependency `a` failed with +// "module_info is null" for the whole graph. +describe.each([ + { name: "js", esm: "mjs", cjs: "cjs" }, + { name: "ts", esm: "ts", cjs: "cts" }, +])("cjs entry requiring an esm graph with a shared dep ($name)", ({ esm, cjs }) => { + test.concurrent("loads and evaluates the shared dep once", async () => { + await using dir = tempDir("require-esm-diamond", { + [`entry.${cjs}`]: `require("./app.${esm}");`, + [`app.${esm}`]: ` + import * as P from "./a.${esm}"; + import Q from "./mid.${cjs}"; + console.log(JSON.stringify({ P: P.value, Q, aEval: globalThis.aEval })); + `, + [`mid.${cjs}`]: `const y = require("./y.${esm}"); module.exports = { mid: y.y };`, + [`y.${esm}`]: `import { value } from "./a.${esm}"; export const y = "y:" + value;`, + [`a.${esm}`]: `export const value = "a"; globalThis.aEval = (globalThis.aEval ?? 0) + 1;`, + }); + const result = await run([bunExe(), `entry.${cjs}`], String(dir)); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout.trim())).toEqual({ P: "a", Q: { mid: "y:a" }, aEval: 1 }); + expect(result.exitCode).toBe(0); + }); +}); From 0de84ef3d168702b5232d9d00a1b90bf173f8dbe Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 10 Aug 2026 14:18:42 -0700 Subject: [PATCH 11/26] record namespaced plugin specifiers as printed --- src/js_printer/lib.rs | 52 +++++++++++++++++++----------- test/js/bun/plugin/plugins.test.ts | 39 +++++++++++++++++++++- 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index fb2df0dcb447..238219e5d64c 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -5178,7 +5178,9 @@ pub(crate) mod __gated_printer { self.print_whitespacer(ws!(b"from ")); } - let irp = &self.import_record(s.import_record_index as usize).path.text; + let irp = Self::printed_import_record_path( + self.import_record(s.import_record_index as usize), + ); self.print_import_record_path( self.import_record(s.import_record_index as usize), ); @@ -5186,7 +5188,7 @@ pub(crate) mod __gated_printer { if Self::MAY_HAVE_MODULE_INFO { if let Some(mi) = self.module_info() { - let irp_id = mi.str(irp); + let irp_id = mi.str(&irp); mi.request_module( irp_id, analyze_transpiled_module::FetchParameters::None, @@ -5362,7 +5364,7 @@ pub(crate) mod __gated_printer { } self.print_whitespacer(ws!(b"} from ")); - let irp = &import_record.path.text; + let irp = Self::printed_import_record_path(import_record); self.print_import_record_path(import_record); self.print_semicolon_after_statement(); @@ -5371,7 +5373,7 @@ pub(crate) mod __gated_printer { // `name_for_symbol` (which needs `&mut self`) can run between uses. let irp_id = { let mi = self.module_info().expect("infallible: module_info enabled"); - let id = mi.str(irp); + let id = mi.str(&irp); mi.request_module(id, analyze_transpiled_module::FetchParameters::None); id }; @@ -5884,11 +5886,11 @@ pub(crate) mod __gated_printer { // reshaped for borrowck — `module_info()` borrows `&mut self`, // so we re-borrow it between `name_for_symbol` calls instead of holding // a single long-lived `mi` across the whole block. `irp_id` is Copy. - let import_record_path = &record.path.text; + let import_record_path = Self::printed_import_record_path(record); use analyze_transpiled_module::FetchParameters as FP; let (irp_id, fetch_parameters) = { let mi = self.module_info().expect("infallible: module_info enabled"); - let irp_id = mi.str(import_record_path); + let irp_id = mi.str(&import_record_path); let fetch_parameters: FP = if IS_BUN_PLATFORM { if let Some(loader) = record.loader { use bun_ast::Loader; @@ -6064,27 +6066,41 @@ pub(crate) mod __gated_printer { Ok(()) } + fn prints_namespace_in_path(import_record: &ImportRecord) -> bool { + import_record + .flags + .contains(ImportRecordFlags::PRINT_NAMESPACE_IN_PATH) + && !import_record.path.is_file() + } + + /// The module specifier exactly as `print_import_record_path` writes it, + /// so the ModuleInfo record names the same module JSC will request. + fn printed_import_record_path(import_record: &ImportRecord) -> std::borrow::Cow<'_, [u8]> { + if Self::prints_namespace_in_path(import_record) { + let path = &import_record.path; + let mut out = Vec::with_capacity(path.namespace.len() + 1 + path.text.len()); + out.extend_from_slice(path.namespace); + out.push(b':'); + out.extend_from_slice(path.text); + std::borrow::Cow::Owned(out) + } else { + std::borrow::Cow::Borrowed(import_record.path.text) + } + } + pub(crate) fn print_import_record_path(&mut self, import_record: &ImportRecord) { if IS_JSON { unreachable!(); } let quote = best_quote_char_for_string(import_record.path.text, false); - if import_record - .flags - .contains(ImportRecordFlags::PRINT_NAMESPACE_IN_PATH) - && !import_record.path.is_file() - { - self.print(quote); + self.print(quote); + if Self::prints_namespace_in_path(import_record) { self.print_string_characters_utf8(import_record.path.namespace, quote); self.print(b":"); - self.print_string_characters_utf8(import_record.path.text, quote); - self.print(quote); - } else { - self.print(quote); - self.print_string_characters_utf8(import_record.path.text, quote); - self.print(quote); } + self.print_string_characters_utf8(import_record.path.text, quote); + self.print(quote); } #[inline] diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 3b1f99a29cf4..ddc4c929cf09 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -1,7 +1,7 @@ /// import { plugin } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { resolve } from "path"; declare global { @@ -342,6 +342,43 @@ export default Hello; expect(body).toBe("

Hello world!

"); }); + + // The printer writes a namespaced virtual specifier as "ns:path"; the module + // record handed to JSC must request that same string, not the bare path. + it("static import and export-star of a namespaced virtual module", async () => { + using dir = tempDir("plugin-ns-static-import", { + "preload.ts": ` + import { plugin } from "bun"; + plugin({ + name: "virt", + setup(b) { + b.onResolve({ filter: /.*/, namespace: "virt" }, args => ({ path: args.path, namespace: "virt" })); + b.onLoad({ filter: /.*/, namespace: "virt" }, args => ({ + contents: "export const name = " + JSON.stringify(args.path) + ";", + loader: "ts", + })); + }, + }); + `, + "entry.ts": ` + import { name } from "virt:thing"; + export * from "virt:other"; + import * as self from "./entry.ts"; + console.log(JSON.stringify({ name, star: self.name })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preload", "./preload.ts", "entry.ts"], + 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.trim())).toEqual({ name: "thing", star: "other" }); + expect(exitCode).toBe(0); + }); }); describe("errors", () => { From 03606ced6e67071143e20c06c868e3a1e6727618 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 10 Aug 2026 14:22:18 -0700 Subject: [PATCH 12/26] free the module record when a load is abandoned --- src/bundler/analyze_transpiled_module.rs | 2 +- src/jsc/ResolvedSource.rs | 18 ++++++++++++++---- src/jsc/bindings/ModuleLoader.cpp | 16 +++++++++++++--- src/jsc/bindings/ZigSourceProvider.cpp | 3 +++ src/runtime/jsc_hooks.rs | 8 ++------ 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/bundler/analyze_transpiled_module.rs b/src/bundler/analyze_transpiled_module.rs index 413969bcc749..0d5caa845c8c 100644 --- a/src/bundler/analyze_transpiled_module.rs +++ b/src/bundler/analyze_transpiled_module.rs @@ -222,7 +222,7 @@ impl ModuleInfoDeserialized { /// # Safety /// `this` must have been produced by [`Self::create`] (heap box) or by /// [`ModuleInfoExt::into_deserialized`]. - pub(crate) unsafe fn deinit(this: *mut ModuleInfoDeserialized) { + pub unsafe fn deinit(this: *mut ModuleInfoDeserialized) { // SAFETY: caller contract — see fn doc above. unsafe { match (*this).owner { diff --git a/src/jsc/ResolvedSource.rs b/src/jsc/ResolvedSource.rs index cd97bbcfc0ac..a4f8b954494f 100644 --- a/src/jsc/ResolvedSource.rs +++ b/src/jsc/ResolvedSource.rs @@ -88,10 +88,10 @@ impl Default for ResolvedSource { // the raw `ResolvedSource` for FFI is `into_ffi()` (consumes, forgets). If the // owner is dropped instead, every contained `BunString` is `deref()`d. // -// The `module_info` pointer (a `Box` leaked via -// `heap::into_raw`) is intentionally NOT freed here — its ownership protocol -// is separate (C++ calls `Bun__free_module_info` on success; on Rust-side drop -// it would still leak today, tracked separately). +// `module_info` (a `Box` leaked via `heap::into_raw`) +// follows the same rule: `into_ffi()` hands it to C++ (adopted by +// `Zig::SourceProvider::create`, or freed by `ResolvedSourceCodeHolder`), and a +// Rust-side drop frees it here. // ────────────────────────────────────────────────────────────────────────── #[repr(transparent)] #[derive(Default)] @@ -142,5 +142,15 @@ impl Drop for OwnedResolvedSource { self.0.specifier.deref(); self.0.source_url.deref(); self.0.bytecode_origin_path.deref(); + if !self.0.module_info.is_null() { + // SAFETY: non-null `module_info` is always the `heap::into_raw` of a + // `Box` that nothing else has adopted yet. + unsafe { + bun_bundler::analyze_transpiled_module::ModuleInfoDeserialized::deinit( + self.0.module_info.cast(), + ) + }; + self.0.module_info = core::ptr::null_mut(); + } } } diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 7c26804e8329..30f2bdd920d6 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -12,6 +12,7 @@ #include #include "ZigSourceProvider.h" +#include "BunAnalyzeTranspiledModule.h" #include #include @@ -57,9 +58,18 @@ class ResolvedSourceCodeHolder { ~ResolvedSourceCodeHolder() { - if (res->success && res->result.value.source_code.tag == BunStringTag::WTFStringImpl && res->result.value.needsDeref) { - res->result.value.needsDeref = false; - res->result.value.source_code.impl.wtf->deref(); + if (!res->success) + return; + auto& value = res->result.value; + if (value.source_code.tag == BunStringTag::WTFStringImpl && value.needsDeref) { + value.needsDeref = false; + value.source_code.impl.wtf->deref(); + } + // Non-null only if no SourceProvider adopted it (early return before + // Zig::SourceProvider::create). + if (value.module_info) { + zig__ModuleInfoDeserialized__deinit(static_cast(value.module_info)); + value.module_info = nullptr; } } diff --git a/src/jsc/bindings/ZigSourceProvider.cpp b/src/jsc/bindings/ZigSourceProvider.cpp index 50074091373e..8ad2bbacd343 100644 --- a/src/jsc/bindings/ZigSourceProvider.cpp +++ b/src/jsc/bindings/ZigSourceProvider.cpp @@ -140,6 +140,9 @@ Ref SourceProvider::create( }; auto provider = getProvider(); + // The provider now owns module_info (freed in ~SourceProvider); clear the + // caller's copy so ResolvedSourceCodeHolder does not free it again. + resolvedSource.module_info = nullptr; if (shouldGenerateCodeCoverage) { ByteRangeMapping__generate(Bun::toString(provider->sourceURL()), Bun::toString(provider->source().toStringWithoutCopying()), provider->asID()); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 2c1113199b4a..f5092b14e73d 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -3271,12 +3271,8 @@ fn transpile_source_code_inner( unsafe { (*jsc_vm).has_loaded = true }; } - // `module_info.asDeserialized()`: finalize the - // printer-filled record into the FFI shape consumed by C++ - // (freed by C++ `~SourceProvider` via - // `zig__ModuleInfoDeserialized__deinit` — ZigSourceProvider.cpp; - // `ResolvedSource`/`OwnedResolvedSource` never free it, see the - // ownership note in ResolvedSource.rs). + // Finalize the printer-filled record into the FFI shape consumed by + // C++ (ownership: see the note on `OwnedResolvedSource`). let module_info: *mut core::ffi::c_void = module_info .map(|mi| { use bun_bundler::analyze_transpiled_module::ModuleInfoExt; From 24a39361d118df21bb2f82b0e0ac0610ece7bd35 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 10 Aug 2026 14:26:59 -0700 Subject: [PATCH 13/26] only intern exported names into the module record --- src/js_printer/lib.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 238219e5d64c..ae79708da928 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -4735,8 +4735,8 @@ pub(crate) mod __gated_printer { // reshaped for borrowck — fetch name before borrowing module_info. let local_name = self.name_for_symbol(b.r#ref); if let Some(mi) = self.module_info() { - let name_id = mi.str(local_name); if tlm.is_export { + let name_id = mi.str(local_name); mi.add_export_info_local(name_id, name_id); } } @@ -4850,8 +4850,9 @@ pub(crate) mod __gated_printer { { if Self::MAY_HAVE_MODULE_INFO { if let Some(mi) = self.module_info() { - let name_id = mi.str(str.slice8()); if tlm.is_export { + let name_id = + mi.str(str.slice8()); mi.add_export_info_local( name_id, name_id, ); @@ -4885,8 +4886,8 @@ pub(crate) mod __gated_printer { // reshaped for borrowck — bump access first. let str8 = str.slice(self.bump); if let Some(mi) = self.module_info() { - let name_id = mi.str(str8); if tlm.is_export { + let name_id = mi.str(str8); mi.add_export_info_local( name_id, name_id, ); @@ -4999,8 +5000,8 @@ pub(crate) mod __gated_printer { if Self::MAY_HAVE_MODULE_INFO { if let Some(mi) = self.module_info() { - let name_id = mi.str(local_name); if s.func.flags.contains(G::FnFlags::IsExport) { + let name_id = mi.str(local_name); mi.add_export_info_local(name_id, name_id); } } @@ -5030,8 +5031,8 @@ pub(crate) mod __gated_printer { if Self::MAY_HAVE_MODULE_INFO { if let Some(mi) = self.module_info() { - let name_id = mi.str(name_str); if s.is_export { + let name_id = mi.str(name_str); mi.add_export_info_local(name_id, name_id); } } From 93f7c547c02705bef7f724cb139859400b82f8a2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:40:59 +0000 Subject: [PATCH 14/26] [autofix.ci] apply automated fixes --- test/js/bun/plugin/plugins.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index ddc4c929cf09..152b211378af 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -198,7 +198,6 @@ plugin({ }); // This is to test that it works when imported from a separate file -import { tempDir } from "harness"; import { render as svelteRender } from "svelte/server"; import "../../third_party/svelte"; import "./module-plugins"; From 26f99d11b93831473097abf7f7063f646bef82c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:54:13 +0000 Subject: [PATCH 15/26] Release the rest of a transpile batch when the VM terminates mid-batch RuntimeTranspilerStore::run_from_js_thread pops a whole batch of finished jobs off the queue, then returns as soon as draining microtasks between jobs reports termination. The jobs it had not reached yet were already off the queue, so release_queued_jobs_for_teardown never saw them and their transpiled source, log, promise and ModuleInfo were never dropped. Release them in place instead. Bun__analyzeTranspiledModule / fallbackParse also returned the null promise that rejectWithCaughtException yields under termination; JSModuleLoader's BunTranspiledModule arm downcasts the returned value before its caller checks the throw scope, which UBSan reports as a member call on a null JSCell. Return the still-pending promise with the exception left in place, and fulfill() the record like JSC's own makeModule does instead of resolve(). Regression test: terminate a worker while it has dynamic imports in flight, with LeakSanitizer enabled in the child. On the previous commit it reports ~12 KB of leaked ModuleInfo per run plus the UBSan error under the debug cross-check; with this it exits 0 with empty stderr. --- src/jsc/RuntimeTranspilerStore.rs | 41 +++++++++---- .../bindings/BunAnalyzeTranspiledModule.cpp | 18 ++++-- .../workers/worker-terminate-lifetime.test.ts | 58 +++++++++++++++++++ 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 177aca6a2cf1..52db17e8d9b7 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -249,11 +249,23 @@ impl RuntimeTranspilerStore { } // SAFETY: a live job popped from the intrusive queue; this thread // owns it now (its worker-thread part finished before `close()`). - unsafe { - (*job).promise.deinit(); - (*job).reset_for_pool(); - self.store.put(job); - } + unsafe { self.release_job(job) }; + } + } + + /// Release a popped job without running its completion: drops the + /// transpiled source (with its ModuleInfo), log and module promise, and + /// recycles the slot. + /// + /// # Safety + /// `job` was popped from `self.queue` on the JS thread and nothing else + /// references it. + unsafe fn release_job(&mut self, job: *mut TranspilerJob) { + // SAFETY: per fn contract. + unsafe { + (*job).promise.deinit(); + (*job).reset_for_pool(); + self.store.put(job); } } @@ -276,6 +288,7 @@ impl RuntimeTranspilerStore { if let Err(err) = unsafe { (*first).run_from_js_thread() } { global.report_uncaught_exception_from_error(err); } + let mut terminated = false; loop { let job = iter.next(); if job.is_null() { @@ -283,10 +296,18 @@ impl RuntimeTranspilerStore { } // if there are more, we need to drain the microtasks from the previous run // SAFETY: `event_loop` is the VM's live event-loop self-pointer. - if unsafe { (*event_loop.as_ptr()).drain_microtasks_with_global(global, jsc_vm) } - .is_err() + if !terminated + && unsafe { (*event_loop.as_ptr()).drain_microtasks_with_global(global, jsc_vm) } + .is_err() { - return; + terminated = true; + } + if terminated { + // The rest of the batch is already off the queue, so teardown's + // `release_queued_jobs_for_teardown` would never see it. + // SAFETY: `job` is a live job popped from the intrusive queue. + unsafe { self.release_job(job) }; + continue; } // SAFETY: `job` is a live job popped from the intrusive queue. if let Err(err) = unsafe { (*job).run_from_js_thread() } { @@ -484,8 +505,8 @@ fn tls_get_or_leak( impl TranspilerJob { /// Kept as a private inherent fn (not `impl Drop`) because the - /// slot is recycled into the HiveArray via `store.put(this)`. Only caller is - /// `run_from_js_thread`. + /// slot is recycled into the HiveArray via `store.put(this)`. Callers are + /// `run_from_js_thread` and `RuntimeTranspilerStore::release_job`. /// /// Note: `HiveArrayFallback::put` runs `drop_in_place` on the slot (see /// hive_array.rs note), so the Drop-carrying fields — `OwnedString` ×2, diff --git a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp index 3936ff544058..0892a95722d8 100644 --- a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp +++ b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp @@ -179,6 +179,10 @@ extern "C" EncodedJSValue Bun__analyzeTranspiledModule(JSGlobalObject* globalObj auto* moduleInfo = static_cast(provider->m_resolvedSource.module_info); auto moduleRecord = zig__ModuleInfoDeserialized__toJSModuleRecord(globalObject, vm, moduleKey, sourceCode, moduleInfo); + // On a pending exception (worker termination) hand back the still-pending + // promise: JSModuleLoader::makeModule downcasts our return value before its + // caller consults the throw scope, so it must never be a null cell. + RETURN_IF_EXCEPTION(scope, JSValue::encode(promise)); if (moduleRecord == nullptr) { RELEASE_AND_RETURN(scope, JSValue::encode(rejectWithError(createError(globalObject, WTF::String::fromLatin1("parseFromSourceCode failed"))))); } @@ -186,7 +190,7 @@ extern "C" EncodedJSValue Bun__analyzeTranspiledModule(JSGlobalObject* globalObj #if BUN_DEBUG RELEASE_AND_RETURN(scope, fallbackParse(globalObject, moduleKey, sourceCode, promise, moduleRecord)); #else - promise->resolve(globalObject, vm, moduleRecord); + promise->fulfill(vm, moduleRecord); RELEASE_AND_RETURN(scope, JSValue::encode(promise)); #endif } @@ -203,12 +207,16 @@ static EncodedJSValue fallbackParse(JSGlobalObject* globalObject, const Identifi std::unique_ptr moduleProgramNode = parseRootNode( vm, sourceCode, ImplementationVisibility::Public, JSParserBuiltinMode::NotBuiltin, StrictModeLexicallyScopedFeature, JSParserScriptMode::Module, SourceParseMode::ModuleAnalyzeMode, error); - if (error.isValid()) - RELEASE_AND_RETURN(scope, JSValue::encode(rejectWithError(error.toErrorObject(globalObject, sourceCode)))); + if (error.isValid()) { + auto* errorObject = error.toErrorObject(globalObject, sourceCode); + RETURN_IF_EXCEPTION(scope, JSValue::encode(promise)); + RELEASE_AND_RETURN(scope, JSValue::encode(rejectWithError(errorObject))); + } ASSERT(moduleProgramNode); ModuleAnalyzer moduleAnalyzer(globalObject, moduleKey, sourceCode, moduleProgramNode->features()); - RETURN_IF_EXCEPTION(scope, JSValue::encode(promise->rejectWithCaughtException(vm, scope))); + // See Bun__analyzeTranspiledModule: never return a null cell to makeModule. + RETURN_IF_EXCEPTION(scope, JSValue::encode(promise)); auto result = moduleAnalyzer.analyze(*moduleProgramNode); if (!result) { @@ -233,7 +241,7 @@ static EncodedJSValue fallbackParse(JSGlobalObject* globalObject, const Identifi } scope.release(); - promise->resolve(globalObject, vm, resultValue == nullptr ? moduleRecord : resultValue); + promise->fulfill(vm, resultValue == nullptr ? moduleRecord : resultValue); return JSValue::encode(promise); } diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 181f59bfaef1..f9f71f081fdd 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -521,3 +521,61 @@ test( }, timeout, ); + +// Regression: terminate() while the worker was still loading ES modules. +// Transpile jobs that had finished on the thread pool but not yet been handed +// to JSC were abandoned in two places: RuntimeTranspilerStore's batch runner +// returned early once microtask draining reported termination (dropping the +// rest of the popped batch on the floor), and a released job never freed the +// ModuleInfo attached to its transpiled source. Both leak the per-module +// record (ASAN reports it; the WTF strings leaked alongside are invisible to +// LSAN), and the debug-only record cross-check returned a null promise to +// JSC's module loader under termination (UBSan: member call on null JSCell). +test.skipIf(!isASAN)( + "terminate() while ES modules are still being transpiled does not leak or crash", + async () => { + const moduleCount = 24; + const workers = 4; + const files: Record = { + "worker.ts": [ + "const pending: Promise[] = [];", + `for (let i = 0; i < ${moduleCount}; i++) pending.push(import(\`./m\${i}.ts\`));`, + // Posted only after every import has been kicked off, so terminate() + // always lands with transpile jobs in flight. + 'postMessage("loading");', + "await Promise.all(pending);", + "", + ].join("\n"), + "main.ts": ` + for (let i = 0; i < ${workers}; i++) { + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + await new Promise(resolve => { + worker.onmessage = () => { + worker.terminate(); + resolve(); + }; + }); + } + console.log("done"); + `, + }; + for (let i = 0; i < moduleCount; i++) { + files[`m${i}.ts`] = `export const value${i}: number = ${i};\nexport function get${i}() { return value${i}; }\n`; + } + using dir = tempDir("worker-terminate-while-transpiling", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "done", stderr: "", exitCode: 0 }); + }, + timeout, +); From 875178fc5fd4ca6db04f879051f75e24366c0b6e Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 10 Aug 2026 16:00:38 -0700 Subject: [PATCH 16/26] bump webkit --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 96a7381c7f7d..e4a2432fba87 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 = "autobuild-preview-pr-345-b7c69395"; +export const WEBKIT_VERSION = "723cea6c8c6c439f9322d45b429a32c75d3a6cc7"; /** * WebKit (JavaScriptCore) — the JS engine. From 288691595eb7d6203b28c3b2c0fa38cf977f871e Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 10 Aug 2026 16:49:43 -0700 Subject: [PATCH 17/26] free the module record once a module is linked --- src/js_printer/lib.rs | 12 +++++-- .../bindings/BunAnalyzeTranspiledModule.cpp | 31 ++++++++++++++++--- src/jsc/bindings/BunAnalyzeTranspiledModule.h | 14 +++++++++ src/jsc/bindings/ZigGlobalObject.cpp | 6 +++- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index ae79708da928..52b09c12f14f 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -620,10 +620,18 @@ pub mod analyze_transpiled_module { self.record_kinds[idx] = RecordKind::ImportInfoSingleTypeScript; } } - // Build-time indexes only; the runtime keeps this struct alive for the - // SourceProvider's lifetime, so drop them now. + // Build-time indexes only; the runtime may keep this struct alive until + // the module is evaluated, so drop them and trim the rest now. self.strings_map = HashMap::default(); self.exported_names = HashMap::default(); + self.requested_modules.index = HashMap::default(); + self.strings_buf.shrink_to_fit(); + self.strings_lens.shrink_to_fit(); + self.buffer.shrink_to_fit(); + self.record_kinds.shrink_to_fit(); + self.requested_modules.keys.shrink_to_fit(); + self.requested_modules.values.shrink_to_fit(); + self.requested_modules.phases.shrink_to_fit(); self.finalized = true; Ok(()) } diff --git a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp index 0892a95722d8..683c2c47e35c 100644 --- a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp +++ b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp @@ -13,6 +13,7 @@ #include "ZigSourceProvider.h" #include "ZigGlobalObject.h" #include "headers-handwritten.h" +#include "IsolatedModuleCache.h" #include "BunAnalyzeTranspiledModule.h" // ref: JSModuleLoader.cpp @@ -156,6 +157,27 @@ extern "C" void JSC_JSModuleRecord__addImportEntryNamespaceDefer(JSModuleRecord* }); } +} // namespace JSC + +void Bun::releaseModuleInfoAfterLink(JSC::VM& vm, Zig::GlobalObject* globalObject, JSC::JSValue moduleRecordValue) +{ + auto* moduleRecord = dynamicDowncast(moduleRecordValue); + if (!moduleRecord) + return; + auto* provider = moduleRecord->sourceCode().provider(); + if (!provider || provider->sourceType() != JSC::SourceProviderSourceType::BunTranspiledModule) + return; + if (Bun::IsolatedModuleCache::canUse(vm, globalObject->bunVM())) + return; + auto& resolvedSource = static_cast(provider)->m_resolvedSource; + if (resolvedSource.module_info) { + zig__ModuleInfoDeserialized__deinit(static_cast(resolvedSource.module_info)); + resolvedSource.module_info = nullptr; + } +} + +namespace JSC { + static EncodedJSValue fallbackParse(JSGlobalObject* globalObject, const Identifier& moduleKey, const SourceCode& sourceCode, JSPromise* promise, JSModuleRecord* resultValue = nullptr); extern "C" EncodedJSValue Bun__analyzeTranspiledModule(JSGlobalObject* globalObject, const Identifier& moduleKey, const SourceCode& sourceCode, JSPromise* promise) { @@ -169,10 +191,11 @@ extern "C" EncodedJSValue Bun__analyzeTranspiledModule(JSGlobalObject* globalObj auto provider = static_cast(sourceCode.provider()); - // module_info stays on the provider until ~SourceProvider: JSC analyzes the - // same JSSourceCode more than once (require(esm) sync replay re-issues - // makeModule on an already-fetched entry; --isolate reuses providers across - // globals), and every call must produce the same record. + // module_info stays on the provider until the module links (or, under + // --isolate, until ~SourceProvider): JSC analyzes the same JSSourceCode more + // than once (require(esm) sync replay re-issues makeModule on an entry whose + // modulePromise is still pending; --isolate reuses providers across globals), + // and every call must produce the same record. See releaseModuleInfoAfterLink. ASSERT_WITH_MESSAGE(provider->m_resolvedSource.module_info, "BunTranspiledModule provider without module_info: %s", moduleKey.utf8().data()); if (provider->m_resolvedSource.module_info == nullptr) [[unlikely]] RELEASE_AND_RETURN(scope, fallbackParse(globalObject, moduleKey, sourceCode, promise)); diff --git a/src/jsc/bindings/BunAnalyzeTranspiledModule.h b/src/jsc/bindings/BunAnalyzeTranspiledModule.h index 34fcb810df0d..66cb59501fba 100644 --- a/src/jsc/bindings/BunAnalyzeTranspiledModule.h +++ b/src/jsc/bindings/BunAnalyzeTranspiledModule.h @@ -1,2 +1,16 @@ struct bun_ModuleInfoDeserialized; extern "C" void zig__ModuleInfoDeserialized__deinit(bun_ModuleInfoDeserialized* info); + +namespace JSC { +class VM; +class JSValue; +} +namespace Zig { +class GlobalObject; +} +namespace Bun { +// Once a module has linked, JSC never asks for its record again unless the +// provider is shared across globals (--isolate). Free it before evaluation so a +// plain `bun run` does not carry one per loaded module. +void releaseModuleInfoAfterLink(JSC::VM&, Zig::GlobalObject*, JSC::JSValue moduleRecordValue); +} diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 3022c09cafe3..01985cb56cd4 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -175,6 +175,7 @@ #include "webcrypto/JSSubtleCrypto.h" #include "ZigGeneratedClasses.h" #include "ZigSourceProvider.h" +#include "BunAnalyzeTranspiledModule.h" #include "UtilInspect.h" #include "Base64Helpers.h" #include "wtf/text/OrdinalNumber.h" @@ -3939,7 +3940,9 @@ JSC::JSValue GlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGlobalObj JSValue moduleRecordValue, RefPtr scriptFetcher, JSValue sentValue, JSValue resumeMode) { - noteModuleEvaluation(defaultGlobalObject(lexicalGlobalObject), moduleLoader); + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + noteModuleEvaluation(globalObject, moduleLoader); + Bun::releaseModuleInfoAfterLink(globalObject->vm(), globalObject, moduleRecordValue); return moduleLoader->evaluateNonVirtual(lexicalGlobalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); } @@ -3957,6 +3960,7 @@ JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGloba auto scope = DECLARE_THROW_SCOPE(vm); noteModuleEvaluation(globalObject, moduleLoader); + Bun::releaseModuleInfoAfterLink(vm, globalObject, moduleRecordValue); JSC::JSValue result = moduleLoader->evaluateNonVirtual(lexicalGlobalObject, key, moduleRecordValue, WTF::move(scriptFetcher), sentValue, resumeMode); // The new C++ loader propagates the module body's throw out of From 7ffa0b4b91ba085e522e4d38277122c381aa4e1a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:16:54 +0000 Subject: [PATCH 18/26] Emit local exports last and in name order in the module record JSC keeps export entries in insertion order and std::sort()s them every time it builds a module namespace object. Its own ModuleAnalyzer inserts local exports in hash-table order; the printer-built ModuleInfo inserted them in source order, and on modules like the 10k `export const name` fixture in require-cache.test.ts that order makes introsort fall back to heapsort, so import() of such a module got slower than letting JSC parse it (release-asan: ~45 ms per import on a 10k-export module, __adjust_heap at 13% of samples). Move local export records after everything else and sort them by export name in ModuleInfo::finalize, which is the sort's best case; the other records keep their relative order. With this the Bun-built record is faster than JSC's own analysis on that fixture. The transpiler cache test decodes the stored record and pins the order. --- src/js_printer/lib.rs | 62 ++++++++++++ test/cli/run/transpiler-cache.test.ts | 140 ++++++++++++++++++++++---- 2 files changed, 182 insertions(+), 20 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 52b09c12f14f..5ba9620e54ea 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -620,6 +620,7 @@ pub mod analyze_transpiled_module { self.record_kinds[idx] = RecordKind::ImportInfoSingleTypeScript; } } + self.move_local_exports_last_in_name_order(); // Build-time indexes only; the runtime may keep this struct alive until // the module is evaluated, so drop them and trim the rest now. self.strings_map = HashMap::default(); @@ -635,6 +636,67 @@ pub mod analyze_transpiled_module { self.finalized = true; Ok(()) } + + /// JSC keeps export entries in insertion order and `std::sort`s them when + /// it builds the namespace object. Its own analyzer inserts local exports + /// in hash-table order; the printer sees them in source order, and on + /// inputs like `a0, a1, ..., a9999` introsort falls back to heapsort, + /// making `import()` of a wide module measurably slower than JSC's own + /// analysis. Hand JSC the sort's best case instead: local exports after + /// every other record, already in byte order of their export name. The + /// remaining records keep their relative order, so which unresolvable + /// indirect export gets reported first is unchanged. + fn move_local_exports_last_in_name_order(&mut self) { + let is_local = |k: &RecordKind| *k == RecordKind::ExportInfoLocal; + if self.record_kinds.iter().filter(|k| is_local(k)).count() < 2 { + return; + } + + let mut record_offsets: Vec = Vec::with_capacity(self.record_kinds.len()); + let mut offset = 0usize; + for k in &self.record_kinds { + record_offsets.push(offset); + offset += k.len(); + } + + let mut string_offsets: Vec = Vec::with_capacity(self.strings_lens.len() + 1); + let mut string_end = 0usize; + string_offsets.push(string_end); + for &len in &self.strings_lens { + string_end += len as usize; + string_offsets.push(string_end); + } + let strings_buf = &self.strings_buf; + let name = |id: StringID| -> &[u8] { + match ( + string_offsets.get(id.0 as usize), + string_offsets.get(id.0 as usize + 1), + ) { + (Some(&start), Some(&end)) => &strings_buf[start..end], + _ => &[], + } + }; + + let kinds = &self.record_kinds; + let buffer = &self.buffer; + // Export name is the first slot of every export record. + let export_name = |record: usize| name(buffer[record_offsets[record]]); + let mut locals: Vec = + (0..kinds.len()).filter(|&r| is_local(&kinds[r])).collect(); + locals.sort_by(|&a, &b| export_name(a).cmp(export_name(b))); + + let mut new_kinds: Vec = Vec::with_capacity(kinds.len()); + let mut new_buffer: Vec = Vec::with_capacity(buffer.len()); + let others = (0..kinds.len()).filter(|&r| !is_local(&kinds[r])); + for record in others.chain(locals.iter().copied()) { + let kind = kinds[record]; + let start = record_offsets[record]; + new_kinds.push(kind); + new_buffer.extend_from_slice(&buffer[start..start + kind.len()]); + } + self.record_kinds = new_kinds; + self.buffer = new_buffer; + } } } diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 2b6561b37bb2..e5110375b78d 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -251,27 +251,127 @@ describe("transpiler cache", () => { }); }); -test("rejects cached module records containing out-of-range string indices", () => { - // When test isolation is enabled, the runtime transpiler cache stores a - // serialized ES module record ("esm_record") alongside the transpiled - // output. The string indices inside that record are used to index an - // identifier table when the record is converted back into a JSC module - // record, so any index beyond the table length (other than the reserved - // *-default / *-namespace sentinels near u32::MAX) must be rejected. - // - // Cache entry layout (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): - // 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, - // then twelve u64 fields; esm_record_byte_offset @ 78, - // esm_record_byte_length @ 86, esm_record_hash @ 94. Payload follows @ 102. - // Serialized module record layout (src/bundler/analyze_transpiled_module.rs, - // serialize()): - // [record_kinds_len u32][record_kinds, 1 byte each][pad to 4] - // [buffer_len u32][buffer: u32 string index x buffer_len] ... - const ESM_RECORD_BYTE_OFFSET_AT = 78; - const ESM_RECORD_BYTE_LENGTH_AT = 86; - const ESM_RECORD_HASH_AT = 94; - const METADATA_SIZE = 102; +// The runtime transpiler cache stores a serialized ES module record +// ("esm_record") alongside the transpiled output of every ES module. +// +// Cache entry layout (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): +// 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, +// then twelve u64 fields; esm_record_byte_offset @ 78, +// esm_record_byte_length @ 86, esm_record_hash @ 94. Payload follows @ 102. +// Serialized module record layout (src/js_printer/lib.rs, +// ModuleInfoDeserialized::serialize): +// [record_kinds_len u32][record_kinds, 1 byte each][pad to 4] +// [buffer_len u32][buffer: u32 string index x buffer_len] +// [requested_modules_len u32][keys u32 x n][values u32 x n][phases u8 x n][pad to 4] +// [flags u8][pad 3] +// [strings_len u32][string byte lengths u32 x strings_len][string bytes] +const ESM_RECORD_BYTE_OFFSET_AT = 78; +const ESM_RECORD_BYTE_LENGTH_AT = 86; +const ESM_RECORD_HASH_AT = 94; +const METADATA_SIZE = 102; + +// src/js_printer/lib.rs RecordKind discriminants and payload lengths. +const RECORD_KIND = { + ImportInfoSingle: 0, + ImportInfoSingleTypeScript: 1, + ImportInfoNamespace: 2, + ExportInfoIndirect: 3, + ExportInfoLocal: 4, + ExportInfoNamespace: 5, + ExportInfoStar: 6, + ImportInfoNamespaceDefer: 7, +} as const; +const RECORD_LEN = [4, 4, 4, 4, 4, 3, 2, 4] as const; +const RECORD_KIND_NAME = Object.fromEntries(Object.entries(RECORD_KIND).map(([name, kind]) => [kind, name])); + +function readModuleRecord(file: string): { kind: string; name: string }[] | null { + const data = readFileSync(file); + if (data.length < METADATA_SIZE) return null; + const esmOff = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_OFFSET_AT)); + const esmLen = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_LENGTH_AT)); + if (esmLen === 0) return null; + const record = data.subarray(esmOff, esmOff + esmLen); + + let off = 0; + const recordKindsLen = record.readUInt32LE(off); + off += 4; + const kinds = record.subarray(off, off + recordKindsLen); + off += recordKindsLen + ((4 - (recordKindsLen % 4)) % 4); + const bufferLen = record.readUInt32LE(off); + off += 4; + const buffer = record.subarray(off, off + bufferLen * 4); + off += bufferLen * 4; + const requestedModulesLen = record.readUInt32LE(off); + off += 4 + requestedModulesLen * 4 * 2 + requestedModulesLen; + off += (4 - (requestedModulesLen % 4)) % 4; + off += 4; // flags + padding + const stringsLen = record.readUInt32LE(off); + off += 4; + const strings: string[] = []; + let stringOff = off + stringsLen * 4; + for (let i = 0; i < stringsLen; i++) { + const len = record.readUInt32LE(off + i * 4); + strings.push(record.toString("utf8", stringOff, stringOff + len)); + stringOff += len; + } + + // The first slot of every record names what it declares: the module + // specifier for imports and star exports, the export name otherwise. + const records: { kind: string; name: string }[] = []; + let slot = 0; + for (const kind of kinds) { + records.push({ kind: RECORD_KIND_NAME[kind], name: strings[buffer.readUInt32LE(slot * 4)] }); + slot += RECORD_LEN[kind]; + } + return records; +} +test("module records list local exports last, in export name order", async () => { + // JSC sorts the export entries by name every time it builds a module + // namespace object, and its sort degrades badly when the entries arrive in + // source order (see ModuleInfo::finalize in src/js_printer/lib.rs). The + // printer therefore canonicalizes the record: everything else stays in + // source order, local exports follow sorted by name. + const filler = ("// " + "x".repeat(120) + "\n").repeat(40); + writeFileSync( + join(temp_dir, "lib.js"), + `import { join } from "node:path"; +export const zeta = 1; +export { join as pathJoin }; +export function alpha() {} +export { basename as renamedBasename } from "node:path"; +export let mid = 2; +export default 3; +${filler}`, + ); + writeFileSync( + join(temp_dir, "main.js"), + `import * as lib from "./lib.js";\nconsole.log(Object.keys(lib).join(","));`, + ); + + expect(await bunRun(join(temp_dir, "main.js"), env)).toSpawn("alpha,default,mid,pathJoin,renamedBasename,zeta"); + + const records = readdirSync(cache_dir) + .map(name => readModuleRecord(join(cache_dir, name))) + .filter(records => records !== null); + expect(records).toEqual([ + [ + { kind: "ImportInfoSingle", name: "node:path" }, + { kind: "ExportInfoIndirect", name: "pathJoin" }, + { kind: "ExportInfoIndirect", name: "renamedBasename" }, + { kind: "ExportInfoLocal", name: "alpha" }, + { kind: "ExportInfoLocal", name: "default" }, + { kind: "ExportInfoLocal", name: "mid" }, + { kind: "ExportInfoLocal", name: "zeta" }, + ], + ]); +}); + +test("rejects cached module records containing out-of-range string indices", () => { + // The string indices inside the record are used to index an identifier + // table when the record is converted back into a JSC module record, so any + // index beyond the table length (other than the reserved *-default / + // *-namespace sentinels near u32::MAX) must be rejected. function corruptModuleRecordStringIndices(file: string): boolean { const data = readFileSync(file); if (data.length < METADATA_SIZE) return false; From e184bf8cec6bf1a9dfa1506b1ae8d084758ea678 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:37:56 +0000 Subject: [PATCH 19/26] Point the inspector test at the merged WebKit change and shorten the record-order note --- src/js_printer/lib.rs | 14 +++++--------- .../cli/inspect/inspect-module-breakpoints.test.ts | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 5ba9620e54ea..0564cb3e7ab7 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -637,15 +637,11 @@ pub mod analyze_transpiled_module { Ok(()) } - /// JSC keeps export entries in insertion order and `std::sort`s them when - /// it builds the namespace object. Its own analyzer inserts local exports - /// in hash-table order; the printer sees them in source order, and on - /// inputs like `a0, a1, ..., a9999` introsort falls back to heapsort, - /// making `import()` of a wide module measurably slower than JSC's own - /// analysis. Hand JSC the sort's best case instead: local exports after - /// every other record, already in byte order of their export name. The - /// remaining records keep their relative order, so which unresolvable - /// indirect export gets reported first is unchanged. + /// JSC `std::sort`s the export entries, in insertion order, every time it + /// builds a namespace object; source order (`a0, a1, ..., a9999`) drives + /// that sort into its heapsort fallback, pre-sorted input is its best + /// case. The record order is not observable otherwise: the non-local + /// records keep their relative order, so error reporting is unchanged. fn move_local_exports_last_in_name_order(&mut self) { let is_local = |k: &RecordKind| *k == RecordKind::ExportInfoLocal; if self.record_kinds.iter().filter(|k| is_local(k)).count() < 2 { diff --git a/test/cli/inspect/inspect-module-breakpoints.test.ts b/test/cli/inspect/inspect-module-breakpoints.test.ts index 3593c9b0b849..5fb569c381d4 100644 --- a/test/cli/inspect/inspect-module-breakpoints.test.ts +++ b/test/cli/inspect/inspect-module-breakpoints.test.ts @@ -3,7 +3,7 @@ // pass). JSC's debugger has to treat that tag exactly like Module, otherwise // `Debugger.setBreakpoint` on every user module replies "Could not resolve // breakpoint" and `Debugger.setBreakpointByUrl` resolves to no locations. -// Requires the WebKit side of the fix (oven-sh/WebKit#345). +// Requires the WebKit side of the fix (oven-sh/WebKit#405, merged as 723cea6c). import { spawn } from "bun"; import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; From 1cdcee13e4faa301a127b4bbf7efe88a7caa33a1 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 11 Aug 2026 13:11:36 -0700 Subject: [PATCH 20/26] bump webkit again --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index e4a2432fba87..621d98326de9 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 = "723cea6c8c6c439f9322d45b429a32c75d3a6cc7"; +export const WEBKIT_VERSION = "3997b59485daeea728155fffc5b4607027d4ea21"; /** * WebKit (JavaScriptCore) — the JS engine. From e876220edf642a422d7142803bdd7a557e8371df Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:27:42 +0000 Subject: [PATCH 21/26] Include ObjectPrototypeInlines.h for objectPrototypeToString The WebKit bump to 3997b59485da restores ALWAYS_INLINE to always_inline (oven-sh/WebKit#403), so release archives no longer carry an out-of-line copy of objectPrototypeToString and every link failed with an undefined symbol. Pull in the inline definition where it is called. --- src/jsc/modules/NodeUtilTypesModule.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/jsc/modules/NodeUtilTypesModule.cpp b/src/jsc/modules/NodeUtilTypesModule.cpp index 8f5c7df09a6c..1f5bf8da55c1 100644 --- a/src/jsc/modules/NodeUtilTypesModule.cpp +++ b/src/jsc/modules/NodeUtilTypesModule.cpp @@ -12,6 +12,9 @@ #include #include #include +// objectPrototypeToString is ALWAYS_INLINE in ObjectPrototypeInlines.h; release +// WebKit archives no longer carry an out-of-line copy to link against. +#include #include #include "JSEventTarget.h" #include "JavaScriptCore/TopExceptionScope.h" From 12caa5af12d3e1fbcf7bab3ddd681e2a5cf4ac01 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:29:53 +0000 Subject: [PATCH 22/26] Shorten the inlines include comment --- src/jsc/modules/NodeUtilTypesModule.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jsc/modules/NodeUtilTypesModule.cpp b/src/jsc/modules/NodeUtilTypesModule.cpp index 1f5bf8da55c1..261905691052 100644 --- a/src/jsc/modules/NodeUtilTypesModule.cpp +++ b/src/jsc/modules/NodeUtilTypesModule.cpp @@ -12,9 +12,7 @@ #include #include #include -// objectPrototypeToString is ALWAYS_INLINE in ObjectPrototypeInlines.h; release -// WebKit archives no longer carry an out-of-line copy to link against. -#include +#include // objectPrototypeToString is ALWAYS_INLINE, no out-of-line copy to link #include #include "JSEventTarget.h" #include "JavaScriptCore/TopExceptionScope.h" From f3adb37955c9fc284e9c7f70a030950ffec4f062 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:41:45 +0000 Subject: [PATCH 23/26] Accept the WebKit size increase [skip size check] Restoring ALWAYS_INLINE (oven-sh/WebKit#403, in the 3997b59485da pin) re-inlines JSC hot paths that the December stopgap had left out of line, adding roughly 3 MB per target relative to main's older pin. From ce4ccdc3e00943694bfaaec8d5d6621f199f317b Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 11 Aug 2026 16:03:45 -0700 Subject: [PATCH 24/26] put the safety comment right above the unsafe block --- src/jsc/RuntimeTranspilerStore.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 52db17e8d9b7..8e46e29b68a9 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -295,12 +295,11 @@ impl RuntimeTranspilerStore { break; } // if there are more, we need to drain the microtasks from the previous run - // SAFETY: `event_loop` is the VM's live event-loop self-pointer. - if !terminated - && unsafe { (*event_loop.as_ptr()).drain_microtasks_with_global(global, jsc_vm) } - .is_err() - { - terminated = true; + if !terminated { + // SAFETY: `event_loop` is the VM's live event-loop self-pointer. + let drained = + unsafe { (*event_loop.as_ptr()).drain_microtasks_with_global(global, jsc_vm) }; + terminated = drained.is_err(); } if terminated { // The rest of the batch is already off the queue, so teardown's From facf3e1c5801f02c8865561a5bf6884454b14763 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:22:02 +0000 Subject: [PATCH 25/26] Accept the WebKit size increase [skip size check] The ALWAYS_INLINE restoration in the 3997b59485da pin adds roughly 3 MB per target relative to main's older pin; the tag has to be on the head commit, and ce4ccdc3 replaced the previously tagged one. From b0aa2f57040e33b7cd82aa358b14d5f084270aeb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:17:59 +0000 Subject: [PATCH 26/26] ci: retrigger after the darwin test lanes expired in queue [skip size check]