Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dc2eb48
runtime: attach ModuleInfo to ESM transpiles so type-only re-exports …
robobun Jul 25, 2026
3a14030
Skip ModuleInfo when the parser emitted errors
robobun Jul 25, 2026
5a122c8
Address review: features_hash, cache-hit test, pin silenced-typo beha…
robobun Jul 25, 2026
f4fd985
test: give each cache-hit variant its own cache dir
robobun Jul 25, 2026
7148b79
test: inspector breakpoints must resolve in runtime-transpiled ESM
robobun Aug 10, 2026
9e60282
Shorten transpiler cache version note
robobun Aug 10, 2026
b2ee4c0
Share the ModuleInfo decision between the VM and the transpiler worker
robobun Aug 10, 2026
05c547f
Make the ModuleInfo escape hatch process-wide so the cache key can ha…
robobun Aug 10, 2026
61db16a
Pin WebKit to the oven-sh/WebKit#345 preview build
robobun Aug 10, 2026
0d8bb62
fix requiring esm graphs with a shared dependency
alii Aug 10, 2026
0de84ef
record namespaced plugin specifiers as printed
alii Aug 10, 2026
03606ce
free the module record when a load is abandoned
alii Aug 10, 2026
24a3936
only intern exported names into the module record
alii Aug 10, 2026
93f7c54
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 10, 2026
26f99d1
Release the rest of a transpile batch when the VM terminates mid-batch
robobun Aug 10, 2026
875178f
bump webkit
alii Aug 10, 2026
2886915
free the module record once a module is linked
alii Aug 10, 2026
66dc26b
Merge branch 'main' into farm/2014f036/runtime-module-info-typescript…
alii Aug 11, 2026
7ffa0b4
Emit local exports last and in name order in the module record
robobun Aug 11, 2026
e184bf8
Point the inspector test at the merged WebKit change and shorten the …
robobun Aug 11, 2026
1cdcee1
bump webkit again
alii Aug 11, 2026
e876220
Include ObjectPrototypeInlines.h for objectPrototypeToString
robobun Aug 11, 2026
12caa5a
Shorten the inlines include comment
robobun Aug 11, 2026
f3adb37
Accept the WebKit size increase [skip size check]
robobun Aug 11, 2026
ce4ccdc
put the safety comment right above the unsafe block
alii Aug 11, 2026
facf3e1
Accept the WebKit size increase [skip size check]
robobun Aug 11, 2026
5bb571d
merge main [skip size check]
alii Aug 11, 2026
0c60ee7
Merge main [skip size check]
robobun Aug 12, 2026
b0aa2f5
ci: retrigger after the darwin test lanes expired in queue [skip size…
robobun Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +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", {});
// 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", {});
new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO", {});
Expand Down
2 changes: 1 addition & 1 deletion src/bundler/analyze_transpiled_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,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 {
Expand Down
122 changes: 104 additions & 18 deletions src/js_printer/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,9 +681,79 @@ 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.
Comment thread
robobun marked this conversation as resolved.
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(())
}

/// 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.
Comment thread
robobun marked this conversation as resolved.
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<usize> = 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<usize> = 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<usize> =
(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<RecordKind> = Vec::with_capacity(kinds.len());
let mut new_buffer: Vec<StringID> = 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;
}
}
}

Expand Down Expand Up @@ -5227,15 +5297,17 @@ 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),
);
self.print_semicolon_after_statement();

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,
Expand Down Expand Up @@ -5411,7 +5483,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();

Expand All @@ -5420,7 +5492,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
};
Expand Down Expand Up @@ -5933,11 +6005,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;
Expand Down Expand Up @@ -6113,27 +6185,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.
Comment thread
robobun marked this conversation as resolved.
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]
Expand Down
18 changes: 14 additions & 4 deletions src/jsc/ResolvedSource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModuleInfoDeserialized>` 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<ModuleInfoDeserialized>` 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.
Comment thread
robobun marked this conversation as resolved.
// ──────────────────────────────────────────────────────────────────────────
#[repr(transparent)]
#[derive(Default)]
Expand Down Expand Up @@ -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<ModuleInfoDeserialized>` 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();
}
}
}
8 changes: 7 additions & 1 deletion src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ 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 written for every runtime ESM transpile, not only
/// under --isolate; older entries have an empty esm_record (#7384).
Comment thread
robobun marked this conversation as resolved.
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
Expand Down Expand Up @@ -962,6 +964,10 @@ impl RuntimeTranspilerCache {

let mut features_hasher = Wyhash::init(SEED);
parser_options.hash_for_runtime_transpiler(&mut features_hasher, used_jsx);
// Decides whether the entry carries an esm_record.
features_hasher.update(&[u8::from(
crate::virtual_machine::VirtualMachine::use_module_info_for_esm(),
)]);
self.features_hash = Some(features_hasher.final_());

self.entry = match Self::from_file(
Expand Down
59 changes: 39 additions & 20 deletions src/jsc/RuntimeTranspilerStore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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);
}
}

Expand All @@ -276,17 +288,25 @@ 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() {
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 unsafe { (*event_loop.as_ptr()).drain_microtasks_with_global(global, jsc_vm) }
.is_err()
{
return;
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
// `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() } {
Expand Down Expand Up @@ -484,8 +504,8 @@ fn tls_get_or_leak<T>(

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`.
Comment thread
robobun marked this conversation as resolved.
///
/// Note: `HiveArrayFallback::put` runs `drop_in_place` on the slot (see
/// hive_array.rs note), so the Drop-carrying fields — `OwnedString` ×2,
Expand Down Expand Up @@ -972,12 +992,7 @@ impl TranspilerJob {
}
}

// SAFETY: leaf scalar field read; see `vm` note above. Inlined
// `VirtualMachine::use_isolation_source_provider_cache` 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 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
Expand All @@ -1000,7 +1015,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()
{
Expand Down Expand Up @@ -1116,10 +1131,14 @@ impl TranspilerJob {

let is_commonjs_module = parse_result.ast.has_commonjs_export_names
|| parse_result.ast.exports_kind == ExportsKind::Cjs;
// `!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).
Comment thread
robobun marked this conversation as resolved.
let mut module_info: Option<Box<analyze_transpiled_module::ModuleInfo>> =
if use_isolation_source_provider_cache
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(),
Expand Down
8 changes: 8 additions & 0 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4791,6 +4791,14 @@ impl VirtualMachine {
.unwrap_or(false)
}

/// 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.
Comment thread
robobun marked this conversation as resolved.
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)
}

/// 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,
Expand Down
Loading