diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 1c5082acfb23..7e78480af73c 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -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", {}); diff --git a/src/bundler/analyze_transpiled_module.rs b/src/bundler/analyze_transpiled_module.rs index 2820164bd771..5fec2832556f 100644 --- a/src/bundler/analyze_transpiled_module.rs +++ b/src/bundler/analyze_transpiled_module.rs @@ -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 { diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index a465407cf3f4..8f9eb8d45d6b 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -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. + 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. + 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; + } } } @@ -5248,7 +5318,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), ); @@ -5256,7 +5328,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, @@ -5432,7 +5504,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(); @@ -5441,7 +5513,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 }; @@ -5954,11 +6026,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; @@ -6134,27 +6206,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/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/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 30345f1578a6..851c550010bf 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -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). +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 @@ -951,6 +953,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( diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 640ad4635a6c..763816914f34 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -499,8 +499,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 `release_queued_jobs_for_teardown`. /// /// Note: `HiveArrayFallback::put` runs `drop_in_place` on the slot (see /// hive_array.rs note), so the Drop-carrying fields — `OwnedString` ×2, @@ -979,12 +979,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 @@ -1007,7 +1002,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() { @@ -1123,10 +1118,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). let mut module_info: Option> = - 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(), diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index fc224cc8f479..c1eea2a7d046 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4834,6 +4834,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. + 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, diff --git a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp index ee7b03a1c6b6..683c2c47e35c 100644 --- a/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp +++ b/src/jsc/bindings/BunAnalyzeTranspiledModule.cpp @@ -157,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) { @@ -170,20 +191,21 @@ 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 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)); 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; - } + // 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"))))); } @@ -191,7 +213,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 } @@ -208,12 +230,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) { @@ -238,7 +264,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/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/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 5c412ae16422..76d9ec967e58 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/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b5252a9fbe43..bb368099e28a 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" @@ -3956,7 +3957,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); } @@ -3974,6 +3977,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 diff --git a/src/jsc/bindings/ZigSourceProvider.cpp b/src/jsc/bindings/ZigSourceProvider.cpp index 8729da46df75..66e763f26c55 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 edb355d3b465..e894af7185a6 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -3017,23 +3017,20 @@ 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`). - // 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() - && 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() - }; + // Rebuild the cached ESM record so JSC can skip its own + // analyze pass (same shape as `RuntimeTranspilerStore`). + 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 @@ -3181,12 +3178,12 @@ 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`). - // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + // Collect the ESM record while printing so JSC builds the + // JSModuleRecord from Bun's output instead of re-parsing (same + // shape as `RuntimeTranspilerStore`). let mut module_info: Option< Box, - > = if unsafe { &*jsc_vm }.use_isolation_source_provider_cache() + > = if VirtualMachine::use_module_info_for_esm() && !is_commonjs_module && loader.is_java_script_like() { @@ -3276,10 +3273,7 @@ fn transpile_source_code_inner( // `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). + // (ownership note in ResolvedSource.rs). let module_info: *mut core::ffi::c_void = module_info .map(|mi| { use bun_bundler::analyze_transpiled_module::ModuleInfoExt; 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..5fb569c381d4 --- /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#405, merged as 723cea6c). +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/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 01d569bb15ab..e9813e67429f 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -294,27 +294,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; diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 3b1f99a29cf4..152b211378af 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 { @@ -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"; @@ -342,6 +341,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", () => { diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 3cf51510070b..5ed18c879c29 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,166 @@ 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() })); + `, + }); + + // 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); + }); + } - testFn("works", async () => { - const result = - mode === "compile" ? await compileAndRun(dir, dir + "/index.ts") : await run([bunExe(), "index.ts"], dir); + 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); + }); + + // 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-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-${disableAsync ? "sync" : "async"}`, + 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 () => { + await using dir = tempDir("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); + } +}); - expect(result.stderr.trim()).toBe(""); - expect(result.exitCode).toBe(0); +// 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); }); }); 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/ diff --git a/test/js/web/workers/worker-terminate-lifetime.test.ts b/test/js/web/workers/worker-terminate-lifetime.test.ts index 88ab85eea2f4..134f86a2c897 100644 --- a/test/js/web/workers/worker-terminate-lifetime.test.ts +++ b/test/js/web/workers/worker-terminate-lifetime.test.ts @@ -522,6 +522,64 @@ 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, +); + // A worker's own Bun.serve() listener kept dispatching requests // into the fetch handler for the rest of the loop tick after process.exit() // had stopped the VM. Building the Request for a VM whose termination had