From 87e6a4bf77f10e30feecd021a66bcb02344133a9 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 2 Nov 2026 00:00:52 +0000 Subject: [PATCH 1/3] error printer: read the source of a code frame as bytes When the printer has no kept frame for a place, it asks for the text of the module to cut the source lines above the error. The module loader answered that with a print mode: it made a string of the whole file, and the printer turned the string back into bytes. `VirtualMachine::original_source_text` gives the printer the bytes: a virtual source, the body of a `data:` URL, or the file. It transpiles nothing and makes no string. `FetchFlags` and the arena flag of `zig_exception::Holder` have no user left and are gone. A line of the frame that is not UTF-8 becomes a string, so that it shows U+FFFD as before. The text above the frame is not looked at for that. --- bench/sourcemap/code-frame-bench.ts | 52 ++- src/bundler/options.rs | 11 + src/jsc/ConsoleObject.rs | 16 +- src/jsc/ModuleLoader.rs | 16 +- src/jsc/RuntimeTranspilerStore.rs | 1 - src/jsc/VirtualMachine.rs | 298 +++++++++------ src/jsc/ZigException.rs | 12 - src/runtime/jsc_hooks.rs | 135 ++----- test/js/bun/util/inspect-error.test.js | 483 +++++++++++++++++++++++++ 9 files changed, 756 insertions(+), 268 deletions(-) diff --git a/bench/sourcemap/code-frame-bench.ts b/bench/sourcemap/code-frame-bench.ts index d8d7e6c9f469..b9d18663c150 100644 --- a/bench/sourcemap/code-frame-bench.ts +++ b/bench/sourcemap/code-frame-bench.ts @@ -22,21 +22,34 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-frame-bench-")); process.on("exit", () => fs.rmSync(dir, { recursive: true, force: true })); type Places = { sites: (() => Error)[]; long: () => Error }; +type Shape = { + /** One line of padding, without its line break. */ + line?: string; + lineBreak?: string; + /** Where the places are, from 0 (the first lines) to 1 (the last lines). */ + at?: number; + /** Text before everything else. */ + head?: string; +}; // `sites[i]()` returns an error thrown on its own line. `long()` throws on a // line of 5000 bytes. Comment lines before and after them pad the source to -// `bytes`, so the places are in the middle of a large source. -function placesSource(bytes: number): string { - let places = "export const sites: (() => Error)[] = [\n"; +// `bytes`, so the places are in the middle of a large source unless `at` says +// otherwise. +function placesSource(bytes: number, shape: Shape = {}): string { + const { line = "// padding padding padding padding padding padding padding padding", lineBreak = "\n", at = 0.5 } = shape; + let places = "export const sites: (() => Error)[] = [" + lineBreak; for (let i = 0; i <= KEPT; i++) { - places += ` () => { try { throw new Error("site ${i}"); } catch (e) { return e as Error; } },\n`; + places += ` () => { try { throw new Error("site ${i}"); } catch (e) { return e as Error; } },` + lineBreak; } - places += "];\n"; - places += `export function long(): Error { try { throw new Error("long"); } catch (e) { return e as Error; } } // ${Buffer.alloc(5000, "p")}\n`; - const line = "// padding padding padding padding padding padding padding padding\n"; - const lines = Math.max(0, bytes - places.length) / 2 / line.length; - const padding = Buffer.alloc(Math.floor(lines) * line.length, line).toString(); - return padding + places + padding; + places += "];" + lineBreak; + places += `export function long(): Error { try { throw new Error("long"); } catch (e) { return e as Error; } } // ${Buffer.alloc(5000, "p")}${lineBreak}`; + const head = shape.head ?? ""; + const padding = line + lineBreak; + const lines = Math.floor(Math.max(0, bytes - places.length - head.length) / Buffer.byteLength(padding)); + const before = Math.floor(lines * at); + const repeat = (count: number) => Buffer.alloc(count * Buffer.byteLength(padding), padding).toString(); + return head + repeat(before) + places + repeat(lines - before); } // Microseconds per call of `fn(i)`. A pass is `callsPerPass` calls, and only @@ -98,3 +111,22 @@ for (const [size, bytes] of [ const bundle = path.join(dir, "out", `${size}-bundled.js`); report(`${size} bundle with an external source map`, bundle, fs.statSync(bundle + ".map").size); } + +// More shapes of a large module that bun transpiled. The printer reads the file +// and finds the lines that end at the error, so what comes before the error +// counts. Each print is of another place than the `KEPT` prints before it, so +// each one loads. +for (const [name, shape] of [ + ["places in the first lines", { at: 0 }], + ["places in the last lines", { at: 1 }], + ["tabs", { line: "//\tpadding padding padding padding\tpadding padding padding" }], + ["text that is not ASCII", { line: "// " + Buffer.alloc(64, "é").toString() }], + ["one character that is not ASCII, places in the last lines", { head: "// é\n", at: 1 }], + ["lines that end in CRLF", { lineBreak: "\r\n" }], +] as [string, Shape][]) { + const source = path.join(dir, `shape-${name.replace(/[^a-z]+/gi, "-")}.ts`); + fs.writeFileSync(source, placesSource(LARGE_BYTES, shape)); + const { sites } = require(source) as Places; + for (let i = 0; i <= KEPT; i++) Bun.inspect(sites[i]()); + console.log(`[${flag}] large module, ${name.padEnd(58)} ${measure(KEPT + 1, i => Bun.inspect(sites[i]())).padStart(16)}`); +} diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 457bc6993f04..3676dc6eb2ef 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -467,6 +467,17 @@ pub struct LoaderResult<'a> { pub package_json: Option<&'a PackageJSON>, } +impl LoaderResult<'_> { + /// The loader that `fetch_without_on_load_plugins` and the error printer take for a name that gives none. Not the default of a load through the module loader. + pub fn default_loader(&self) -> Loader { + if self.is_main { + Loader::Js + } else { + Loader::File + } + } +} + /// Vite's import path suffixes, as in `./icon.svg?raw`. #[derive(Clone, Copy)] pub enum ImportSuffix { diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 26986866b506..cf10fc196373 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1154,19 +1154,7 @@ pub fn write_trace(writer: &mut dyn bun_io::Write, global: &JSGlobalObject) { let err = global.create_error_instance(format_args!("trace output")); // `remap_zig_exception` populates `holder.zig_exception()` from `err`. - // `exception` and `&holder.need_to_clear_parser_arena_on_deinit` would be - // two simultaneous `&mut` into `holder`. Capture the flag in a local and - // write it back after. - let mut need_to_clear = holder.need_to_clear_parser_arena_on_deinit; - vm.remap_zig_exception( - holder.zig_exception(), - err, - None, - &mut need_to_clear, - &mut source_code, - false, - ); - holder.need_to_clear_parser_arena_on_deinit = need_to_clear; + vm.remap_zig_exception(holder.zig_exception(), err, None, &mut source_code, false); let mut adapter = DynWriteAdapter::new(writer); let _ = VirtualMachine::print_stack_trace( @@ -1174,8 +1162,6 @@ pub fn write_trace(writer: &mut dyn bun_io::Write, global: &JSGlobalObject) { &holder.zig_exception().stack, Output::enable_ansi_colors_stderr(), ); - - holder.deinit(vm); } // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/ModuleLoader.rs b/src/jsc/ModuleLoader.rs index 90716b12e75b..b45bb30183f5 100644 --- a/src/jsc/ModuleLoader.rs +++ b/src/jsc/ModuleLoader.rs @@ -1,4 +1,4 @@ -//! The `ModuleLoader` struct, `FetchFlags`, and the builtin-module lookup +//! The `ModuleLoader` struct and the builtin-module lookup //! helpers. `transpile_source_code` / `fetch_builtin_module` and the //! `Bun__transpile*` / `Bun__resolve*` C++ entry points live in //! `bun_runtime::jsc_hooks` (they reach into `node::fs`, the transpiler, and @@ -101,19 +101,6 @@ impl Drop for ArenaResetGuard { } } -/// Dumps the module source to a file in /tmp/bun-debug-src/{filepath} -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum FetchFlags { - Transpile, - PrintSource, -} - -impl FetchFlags { - pub const fn disable_transpiling(self) -> bool { - !matches!(self, FetchFlags::Transpile) - } -} - pub struct TranspileArgs<'a> { pub specifier: &'a [u8], pub referrer: &'a [u8], @@ -121,7 +108,6 @@ pub struct TranspileArgs<'a> { pub log: *mut bun_ast::Log, pub virtual_source: Option<&'a bun_ast::Source<'a>>, pub global_object: &'a JSGlobalObject, - pub flags: FetchFlags, /// Raw so the `.wasm` re-entry can mutate `loader` and recurse. pub extra: *mut TranspileExtra<'a>, } diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 633722d04121..22ea6d2bf422 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -604,7 +604,6 @@ impl TranspilerJob { &referrer, Some(loader), &mut log, - crate::module_loader::FetchFlags::Transpile, ); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index dd036d0a6076..7000d78f106f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -17,7 +17,7 @@ use bun_uws as uws; use crate::bun_string_jsc; use crate::counters::{Counters, Field as Counter}; use crate::event_loop::EventLoop; -use crate::module_loader::{self as ModuleLoader, FetchFlags}; +use crate::module_loader as ModuleLoader; use crate::rare_data::{CodeFrameRanges, CodeFrameSource, RareData}; use crate::saved_source_map::SavedSourceMap; use crate::{ @@ -4492,6 +4492,27 @@ extern "C" fn free_ref_string(str_: *mut crate::ref_string::RefString, _: *mut c unsafe { crate::ref_string::RefString::destroy(str_) }; } +/// If a line that `ranges` cut from `text` is not UTF-8: the lines one after the other, each as a string shows it (U+FFFD for what is not UTF-8), and `ranges` over them. +fn code_frame_lines_as_utf8(text: &[u8], ranges: &mut CodeFrameRanges) -> Option> { + use bun_core::strings::{LineRange, first_non_ascii, is_valid_utf8}; + let line = |range: &LineRange| &text[range.start as usize..range.end as usize]; + let is_utf8 = + |line: &[u8]| first_non_ascii(line).is_none_or(|at| is_valid_utf8(&line[at as usize..])); + if ranges.iter().all(|range| is_utf8(line(range))) { + return None; + } + let mut lines = Vec::new(); + for range in ranges.slice() { + let start = lines.len(); + lines.extend_from_slice(&bun_core::String::clone_utf8(line(range)).to_utf8()); + *range = LineRange { + start: start as u32, + end: lines.len() as u32, + }; + } + Some(lines) +} + impl VirtualMachine { /// Whether native addons (`process.dlopen`) are allowed (`--no-addons` disables them). #[unsafe(export_name = "Bun__VM__allowAddons")] @@ -5395,31 +5416,12 @@ impl VirtualMachine { } } - // Note: `flags` is a runtime arg — - // `FetchFlags` would need `ConstParamTy` (unstable derive on the enum's - // owning module) to be a const generic; the only branches are cheap - // equality tests so the runtime form is fine. - #[inline(never)] - pub(crate) fn fetch_without_on_load_plugins( + /// Runs `body` with what the loader makes of `specifier`: its path, its loader, and its text when it is not a file. + fn with_loader_result( jsc_vm: &mut VirtualMachine, - global_object: &JSGlobalObject, - specifier: &bun_core::String, - referrer: &bun_core::String, - loader: Option, - log: &mut bun_ast::Log, - flags: FetchFlags, - ) -> crate::CrateResult { - debug_assert!(VirtualMachine::is_loaded()); - - if let Some(resolved) = - ModuleLoader::__bun_fetch_builtin_module(jsc_vm, global_object, specifier) - { - return Ok(resolved); - } - - let specifier_clone = specifier.to_utf8(); - let referrer_clone = referrer.to_utf8(); - + specifier: &[u8], + body: impl FnOnce(&mut VirtualMachine, bun_bundler::options::LoaderResult<'_>) -> R, + ) -> Result { let mut virtual_source_to_use: Option = None; // The blob crosses // the bundler↔runtime boundary as an erased `OpaqueBlob`; deinit goes @@ -5443,72 +5445,140 @@ impl VirtualMachine { ) }; let mut blob_to_deinit = BlobDeinit(None, loader_ctx); - let lr = match bun_bundler::options::get_loader_and_virtual_source( - specifier_clone.slice(), + let lr = bun_bundler::options::get_loader_and_virtual_source( + specifier, &loader_ctx, &mut virtual_source_to_use, &mut blob_to_deinit.0, None, - ) { - Ok(lr) => lr, - Err(_) => return Err(crate::CrateError::ModuleNotFound), - }; - let module_type = lr - .package_json - .map(|pkg| pkg.module_type) - .unwrap_or(bun_bundler::options::ModuleType::Unknown); - - // A drop-guard so both the normal and error paths reset the arena on - // the right edge. - struct ArenaReset<'a>(&'a mut VirtualMachine, bool); - impl Drop for ArenaReset<'_> { - fn drop(&mut self) { - if self.1 { - let vm = std::ptr::from_mut::(self.0); - // SAFETY: `vm` is the live per-thread VM. - unsafe { ModuleLoader::ModuleLoader::reset_arena(&mut *vm) }; - } + )?; + Ok(body(jsc_vm, lr)) + // `blob_to_deinit` drop guard fires here. + } + + #[inline(never)] + pub(crate) fn fetch_without_on_load_plugins( + jsc_vm: &mut VirtualMachine, + global_object: &JSGlobalObject, + specifier: &bun_core::String, + referrer: &bun_core::String, + loader: Option, + log: &mut bun_ast::Log, + ) -> crate::CrateResult { + debug_assert!(VirtualMachine::is_loaded()); + + if let Some(resolved) = + ModuleLoader::__bun_fetch_builtin_module(jsc_vm, global_object, specifier) + { + return Ok(resolved); + } + + let specifier_clone = specifier.to_utf8(); + let referrer_clone = referrer.to_utf8(); + + Self::with_loader_result(jsc_vm, specifier_clone.slice(), |jsc_vm, lr| { + let module_type = lr + .package_json + .map(|pkg| pkg.module_type) + .unwrap_or(bun_bundler::options::ModuleType::Unknown); + + // Both the normal and error paths reset the arena on the right edge. + let _reset_arena = ModuleLoader::ArenaResetGuard::new(jsc_vm); + + let printer = SOURCE_CODE_PRINTER + .get() + .expect("source_code_printer not initialized"); + + // The §Dispatch shim takes these bundled as `TranspileExtra` behind `args.extra`. + let mut extra = ModuleLoader::TranspileExtra { + path: lr.path, + loader: loader.or(lr.loader).unwrap_or_else(|| lr.default_loader()), + module_type, + source_code_printer: printer.as_ptr(), + // `fetchWithoutOnLoadPlugins` forbids the async path. + promise_ptr: core::ptr::null_mut(), + }; + let args = ModuleLoader::TranspileArgs { + specifier: lr.specifier, + referrer: referrer_clone.slice(), + input_specifier: specifier, + log: std::ptr::from_mut::(log), + virtual_source: lr.virtual_source, + global_object, + extra: &raw mut extra, + }; + // SAFETY: `jsc_vm` is the live per-thread VM; `args.extra` points at + // the live `extra` above. + unsafe { ModuleLoader::__bun_transpile_source_code(jsc_vm, &args) } + }) + .unwrap_or(Err(crate::CrateError::ModuleNotFound)) + } + + /// The text of the module at `source_url` as it was written, for the code frame of a printed error. Empty if there is none to read. Nothing is transpiled. + #[inline(never)] + pub(crate) fn original_source_text( + jsc_vm: &mut VirtualMachine, + global: &JSGlobalObject, + source_url: &bun_core::String, + ) -> bun_core::Utf8Bytes<'static> { + use bun_resolver::node_fallbacks; + + if let Some(resolved) = ModuleLoader::__bun_fetch_builtin_module(jsc_vm, global, source_url) + { + return resolved.source_code.into_utf8(); + } + + let specifier = source_url.to_utf8(); + let text = Self::with_loader_result(jsc_vm, specifier.slice(), |jsc_vm, lr| { + let loader = lr.loader.unwrap_or_else(|| lr.default_loader()); + // A module of any other loader is not text that a frame can show. + if !(loader.is_javascript_like_or_json() || loader == bun_ast::Loader::Text) { + return None; + } + if lr.specifier.starts_with(node_fallbacks::IMPORT_PATH) + && let Some(code) = node_fallbacks::contents_from_path(lr.specifier) + { + return Some(code.to_vec()); } + jsc_vm.read_source_text(&lr.path, lr.virtual_source) + }); + match text { + // A longer text is one that a string refuses, and line ranges are `u32`. + Ok(Some(text)) if text.len() <= bun_core::String::max_length() => { + bun_core::Utf8Bytes::Owned(text) + } + _ => bun_core::Utf8Bytes::EMPTY, } - let mut guard = ArenaReset(jsc_vm, flags != FetchFlags::PrintSource); + } - let printer = SOURCE_CODE_PRINTER - .get() - .expect("source_code_printer not initialized"); - - // Note: the §Dispatch shim takes path/loader/module_type/printer/ - // promise_ptr bundled as `TranspileExtra` behind `args.extra` (see - // ModuleLoader.rs `TranspileArgs`). - let mut extra = ModuleLoader::TranspileExtra { - path: lr.path, - loader: loader.or(lr.loader).unwrap_or(if lr.is_main { - bun_ast::Loader::Js - } else { - bun_ast::Loader::File - }), - module_type, - source_code_printer: printer.as_ptr(), - // `fetchWithoutOnLoadPlugins` forbids the async path. - promise_ptr: core::ptr::null_mut(), - }; - let args = ModuleLoader::TranspileArgs { - specifier: lr.specifier, - referrer: referrer_clone.slice(), - input_specifier: specifier, - log: std::ptr::from_mut::(log), - virtual_source: lr.virtual_source, - global_object, - flags, - extra: &raw mut extra, - }; - // SAFETY: `guard.0` is the live per-thread VM; `args.extra` points at - // the live `extra` above. - let result = unsafe { ModuleLoader::__bun_transpile_source_code(guard.0, &args) }; - if result.is_err() && flags == FetchFlags::PrintSource { - guard.1 = true; // errdefer + /// The bytes that a load of `path` parses: `virtual_source`, the body of a `data:` URL, or the file. `None` if they cannot be read. + fn read_source_text( + &mut self, + path: &bun_paths::fs::Path<'_>, + virtual_source: Option<&bun_ast::Source<'_>>, + ) -> Option> { + use bun_resolver::cache::Contents; + use bun_resolver::data_url::DataURL; + + if let Some(source) = virtual_source { + return Some(source.contents.to_vec()); + } + if path.text.starts_with(b"data:") { + return DataURL::parse_without_check(path.text) + .ok()? + .decode_data() + .ok(); } - // `blob_to_deinit` drop guard fires here. - result + let fs = &mut self.transpiler.resolver.caches.fs; + let mut file = fs + .read_file_with_allocator(path.text, bun_sys::Fd::INVALID, false, None, None) + .ok()?; + // The reader leaves the file open for a watcher. Nothing watches this read. + file.close_fd(); + Some(match core::mem::take(&mut file.contents) { + Contents::Owned(bytes) => bytes, + contents => contents.as_slice().to_vec(), + }) } /// Note: `is_a_file_path` is a runtime @@ -6768,9 +6838,6 @@ impl VirtualMachine { if was_internal { if let Some(exception_) = exception { let mut holder = crate::zig_exception::Holder::init(); - // Note: `holder.deinit(self)` runs at the tail (for borrowck) - // — semantics unchanged because - // `need_to_clear_parser_arena_on_deinit` is false here. let zig_exception: &mut ZigException<'_> = holder.zig_exception(); exception_.get_stack_trace(global_ref, &mut zig_exception.stack); if zig_exception.stack.frames_len > 0 { @@ -6780,7 +6847,6 @@ impl VirtualMachine { let origin = self.is_from_devserver.then_some(&self.origin); zig_exception.add_to_error_list(list, self.top_level_dir(), origin); } - holder.deinit(self); } } } @@ -7043,7 +7109,6 @@ impl VirtualMachine { exception: &mut ZigException<'a>, error_instance: JSValue, exception_list: Option<&mut ExceptionList>, - must_reset_parser_arena_later: &mut bool, source_code: &'a mut bun_core::Utf8Bytes<'static>, allow_source_code_preview: bool, ) { @@ -7277,7 +7342,9 @@ impl VirtualMachine { frames[top].source_url = src.into(); } - let code: bun_core::Utf8Bytes<'static> = 'code: { + // Whether `code` is the text of a module that this print read. It is not known to be UTF-8. + let mut is_fresh_module_text = false; + let mut code: bun_core::Utf8Bytes<'static> = 'code: { if !enable_source_code_preview.get() { break 'code bun_core::Utf8Bytes::EMPTY; } @@ -7307,26 +7374,30 @@ impl VirtualMachine { break 'code text; } self.counters.mark(Counter::CodeFrameSourceLoads); - let mut log = bun_ast::Log::default(); - let Ok(original_source) = bun_removable_features::call_removable_or!( + is_fresh_module_text = true; + // Empty when the source is gone; the frames still get remapped below. + bun_removable_features::call_removable_or!( RuntimeTranspiler, ReturnDefault, { break 'code bun_core::Utf8Bytes::EMPTY }, - VirtualMachine::fetch_without_on_load_plugins, + VirtualMachine::original_source_text, self, global, &frames[top].source_url, - &bun_core::String::EMPTY, - None, - &mut log, - FetchFlags::PrintSource, - ) else { - // Source is gone; the frames still get remapped below. - break 'code bun_core::Utf8Bytes::EMPTY; - }; - *must_reset_parser_arena_later = true; - original_source.source_code.into_utf8() + ) }; + + const N: usize = crate::zig_exception::SOURCE_LINES_COUNT; + let mut fresh_ranges: Option = None; + if is_fresh_module_text { + let mut ranges = + bun_core::strings::index_of_line_ranges::(&code, last_line as u32); + if let Some(lines) = code_frame_lines_as_utf8(&code, &mut ranges) { + code = bun_core::Utf8Bytes::Owned(lines); + } + fresh_ranges = Some(ranges); + } + *source_code = code; let code: &'a [u8] = source_code.slice(); @@ -7341,10 +7412,9 @@ impl VirtualMachine { exception.remapped = true; frames[top].remapped = true; - const N: usize = crate::zig_exception::SOURCE_LINES_COUNT; // A kept frame and a fresh one go through the same fill below. let is_kept = kept_ranges.is_some(); - let ranges = kept_ranges.unwrap_or_else(|| { + let ranges = kept_ranges.or(fresh_ranges).unwrap_or_else(|| { bun_core::strings::index_of_line_ranges::(code, last_line as u32) }); if !ranges.is_empty() { @@ -7454,17 +7524,17 @@ impl VirtualMachine { // carries the union of all // branches' locals (every `pretty_write!` expands to two `write!`s). // More importantly, `remap_zig_exception` below calls - // `fetch_without_on_load_plugins` → the transpiler for source-line + // `original_source_text` → the resolver and the file read for source-line // preview, and on Windows that call tree stack-allocates `PathBuffer`s // (`MAX_PATH_BYTES = 98302` vs 4096 on Linux). One cycle can therefore // exceed the default 256 KB headroom `is_safe_to_recurse()` leaves, so // re-check here with an extra `MAX_PATH_BYTES * 3` of slack on Windows - // to cover the transpiler's nested path buffers — same parity-level + // to cover the nested path buffers — same parity-level // protection the Object path gets from C++ `forEachProperty`'s // `vm.isSafeToRecurse()`. let extra_headroom: usize = if cfg!(windows) { // 3× PathBuffer ≈ 288 KB — empirically enough for the - // `remap_zig_exception` → `transpile_source_code` chain on the + // `remap_zig_exception` → `original_source_text` chain on the // 16K-deep Error test (`bun-inspect.test.ts`). bun_paths::MAX_PATH_BYTES * 3 } else { @@ -7500,18 +7570,12 @@ impl VirtualMachine { // box it to keep the per-level recursion frame small enough for the // 16K-deep `bun-inspect.test.ts` Error chain on Windows debug. let mut exception_holder = Box::new(crate::zig_exception::Holder::init()); - // Note: reshaped for borrowck — `zig_exception()` returns a - // `&mut` into the holder; we need to also borrow - // `need_to_clear_parser_arena_on_deinit` disjointly. Route through a - // raw pointer (the holder is heap-pinned for the call). - let exception: *mut ZigException<'_> = exception_holder.zig_exception(); + let exception = exception_holder.zig_exception(); self.remap_zig_exception( - // SAFETY: `exception` points into stack-local `exception_holder`. - unsafe { &mut *exception }, + exception, error_instance, exception_list, - &mut exception_holder.need_to_clear_parser_arena_on_deinit, &mut source_code, formatter.error_display_level != crate::console_object::ErrorDisplayLevel::Warn, ); @@ -7519,8 +7583,7 @@ impl VirtualMachine { let mut recorded = false; let result = self.print_error_instance_body( - // SAFETY: see above. - unsafe { &mut *exception }, + exception, error_instance, None, // Note: `exception_list` was already // consumed by `remap_zig_exception` above (only writer). @@ -7534,7 +7597,6 @@ impl VirtualMachine { formatter.visited_remove(error_instance); } - exception_holder.deinit(self); result } diff --git a/src/jsc/ZigException.rs b/src/jsc/ZigException.rs index fdbb3921b694..e4b52d817e0c 100644 --- a/src/jsc/ZigException.rs +++ b/src/jsc/ZigException.rs @@ -5,8 +5,6 @@ use crate::exception_list; use bun_core::{String, StringOrView}; use bun_url::URL as ZigURL; -use crate::module_loader::ModuleLoader; -use crate::virtual_machine::VirtualMachine; use crate::{JSErrorCode, JSGlobalObject, JSRuntimeType, JSValue, ZigStackFrame, ZigStackTrace}; // SAFETY (safe fn): `JSValue` is a by-value scalar; `JSGlobalObject` is an @@ -91,7 +89,6 @@ pub struct Holder<'a> { source_lines: [StringOrView<'a>; SOURCE_LINES_COUNT], frames: [ZigStackFrame<'a>; FRAME_COUNT], pub(crate) zig_exception: Option>, - pub(crate) need_to_clear_parser_arena_on_deinit: bool, } impl<'a> Holder<'a> { @@ -101,7 +98,6 @@ impl<'a> Holder<'a> { source_line_numbers: [-1; SOURCE_LINES_COUNT], source_lines: core::array::from_fn(|_| StringOrView::EMPTY), zig_exception: None, - need_to_clear_parser_arena_on_deinit: false, } } @@ -109,14 +105,6 @@ impl<'a> Holder<'a> { Self::zero() } - /// `Drop` releases the strings; this resets the parser arena used while - /// collecting source lines, which needs `vm`. - pub(crate) fn deinit(&mut self, vm: &mut VirtualMachine) { - if core::mem::take(&mut self.need_to_clear_parser_arena_on_deinit) { - ModuleLoader::reset_arena(vm); - } - } - pub fn zig_exception(&mut self) -> &mut ZigException<'a> { self.zig_exception.get_or_insert_with(|| ZigException { r#type: JSErrorCode(255), diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index ed2ab28fbc57..eed78dcb96ee 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -24,7 +24,7 @@ use core::ptr; use bun_jsc::bun_string_jsc; use bun_jsc::js_promise::Status as PromiseStatus; -use bun_jsc::module_loader::{ArenaResetGuard, FetchFlags, TranspileArgs, TranspileExtra}; +use bun_jsc::module_loader::{ArenaResetGuard, TranspileArgs, TranspileExtra}; use bun_jsc::resolved_source::Bytecode; use bun_jsc::virtual_machine::{ CancelHotRunTimers, InitOptions, MainEntryPoint, RuntimeHooks, @@ -2280,6 +2280,8 @@ unsafe fn __bun_transpile_source_code( /// `vm.transpiler` while also touching `vm.module_loader` / `vm.bun_watcher`, /// which would alias under `&mut` (PORTING.md §Forbidden). Per-field deref via /// the raw ptr, mirroring `auto_tick` above. +// PERF: out of line, each load copies the `ResolvedSource` once more on return. +#[inline(always)] fn transpile_source_code_inner( jsc_vm: *mut VirtualMachine, args: &TranspileArgs<'_>, @@ -2326,26 +2328,11 @@ fn transpile_source_code_of_any_provenance( let (loader, module_type): (Loader, ModuleType) = unsafe { ((*extra).loader, (*extra).module_type) }; - let disable_transpilying = args.flags.disable_transpiling(); let specifier = args.specifier; let referrer = args.referrer; let input_specifier = &args.input_specifier; let global_object = args.global_object; - // ── disable_transpiling fast-path for non-JS-like loaders ─────────────── - if disable_transpilying - && !(loader.is_java_script_like() - || matches!( - loader, - L::Toml | L::Yaml | L::Json5 | L::Xml | L::Text | L::Json | L::Jsonc - )) - { - return Ok(ResolvedSource { - source_url: input_specifier.create_if_different(path.text), - ..Default::default() - }); - } - match loader { // ──────────────────────────────────────────────────────────────────── // JS-like + JSON/TOML/YAML/text/md — the parse→print path. @@ -2399,8 +2386,8 @@ fn transpile_source_code_of_any_provenance( let give_back_arena = true; // Note: a scopeguard so `?`-early-returns still run the cleanup. let mut arena_guard = scopeguard::guard( - (jsc_vm, arena, give_back_arena, args.flags), - |(jsc_vm, mut arena, give_back, flags)| { + (jsc_vm, arena, give_back_arena), + |(jsc_vm, mut arena, give_back)| { // SAFETY: `jsc_vm` is the live per-thread VM (closure runs // on the same thread, before the hook returns). let slot = unsafe { &mut (*jsc_vm).module_loader.transpile_source_code_arena }; @@ -2427,13 +2414,12 @@ fn transpile_source_code_of_any_provenance( return; } if slot.is_none() { - if flags != FetchFlags::PrintSource { - // SAFETY: per fn contract — `jsc_vm` is the live - // per-thread VM (closure runs on the same thread, - // before the hook returns). - if unsafe { (*jsc_vm).smol } { - arena.reset(); - } else { + // SAFETY: per fn contract — `jsc_vm` is the live + // per-thread VM (closure runs on the same thread, + // before the hook returns). + match unsafe { (*jsc_vm).smol } { + true => arena.reset(), + false => { // See `MimallocArena::reset_retain_with_limit` // for why this is a no-op-until-limit rather // than a bump-pointer reset (each fresh @@ -2590,7 +2576,7 @@ fn transpile_source_code_of_any_provenance( let mut input_file_fd = bun_sys::Fd::INVALID; // The deferred fd close is independent of `give_back_arena` // and must fire on every exit path: parse failure, JSON early - // return, `disable_transpilying`, already_bundled, empty `.cjs`, + // return, already_bundled, empty `.cjs`, // cache-hit, AsyncModule, the wasm recurse, and the print error. // Note: reshaped for borrowck — capture raw pointers so the // guard does not alias the parser's `file_fd_ptr` / @@ -2707,7 +2693,6 @@ fn transpile_source_code_of_any_provenance( allow_bytecode_cache: true, set_breakpoint_on_first_line, runtime_transpiler_cache: if !DATA_ONLY - && !disable_transpilying && !::disabled() { Some(&mut cache) @@ -2732,7 +2717,7 @@ fn transpile_source_code_of_any_provenance( // Both the file-only and transpiled arms hit the same // `parse_maybe_return_file_only_allow_shared_buffer` body, so // dispatch at runtime via the const-generic bool. - let return_file_only = disable_transpilying || loader == L::Json; + let return_file_only = loader == L::Json; let parse_result: Option = if DATA_ONLY { // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. let transpiler = unsafe { &mut (*jsc_vm).transpiler }; @@ -2759,19 +2744,17 @@ fn transpile_source_code_of_any_provenance( let Some(mut parse_result) = parse_result else { // Register with watcher even on parse failure. - if !disable_transpilying { - // SAFETY: see Note on `_fd_guard` — reborrow via - // the raw pointers so the guard stays valid. - maybe_watch_file( - jsc_vm, - unsafe { &mut *should_close_ptr }, - unsafe { *input_file_fd_ptr }, - is_node_override, - path, - hash, - package_json, - ); - } + // SAFETY: see Note on `_fd_guard` — reborrow via + // the raw pointers so the guard stays valid. + maybe_watch_file( + jsc_vm, + unsafe { &mut *should_close_ptr }, + unsafe { *input_file_fd_ptr }, + is_node_override, + path, + hash, + package_json, + ); arena_guard.2 = false; // give_back_arena = false // Node compile cache: record the failed module so exit-time // persist logs the "was not initialized" skip (Node parity). @@ -2804,26 +2787,23 @@ fn transpile_source_code_of_any_provenance( log: args.log, virtual_source: Some(&parse_result.source), global_object: args.global_object, - flags: args.flags, extra: &raw mut wasm_extra, }, ); } // Register with watcher on success too. - if !disable_transpilying { - // SAFETY: see Note on `_fd_guard` — reborrow via the - // raw pointers so the guard stays valid. - maybe_watch_file( - jsc_vm, - unsafe { &mut *should_close_ptr }, - unsafe { *input_file_fd_ptr }, - is_node_override, - path, - hash, - package_json, - ); - } + // SAFETY: see Note on `_fd_guard` — reborrow via the + // raw pointers so the guard stays valid. + maybe_watch_file( + jsc_vm, + unsafe { &mut *should_close_ptr }, + unsafe { *input_file_fd_ptr }, + is_node_override, + path, + hash, + package_json, + ); // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM; // `transpiler.log` was swapped to non-null `args.log` above. @@ -2839,13 +2819,13 @@ fn transpile_source_code_of_any_provenance( // Set before the early returns below (cache hit, `// @bun`, async queue): // every later module load is gated on this flag. - if is_main && !disable_transpilying { + if is_main { // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. unsafe { (*jsc_vm).has_loaded = true }; } // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. let is_bun_test = unsafe { (*jsc_vm).transpiler.options.rewrite_jest_for_tests }; - if (is_main || is_preload) && !disable_transpilying && is_bun_test { + if (is_main || is_preload) && is_bun_test { let imports_vitest = cache.imports_vitest || parse_result .ast @@ -2871,25 +2851,6 @@ fn transpile_source_code_of_any_provenance( }); } - // disable_transpiling: return raw source. - if disable_transpilying { - let source_code = match args.flags { - FetchFlags::PrintSource => { - // The file contents live in a Drop-carrying - // `source_contents_backing` on `parse_result`, so a - // borrow would dangle once `parse_result` drops on - // return. Clone instead. - bun_core::String::clone_utf8(&source.contents) - } - FetchFlags::Transpile => unreachable!(), - }; - return Ok(ResolvedSource { - source_code, - source_url: input_specifier.create_if_different(path.text), - ..Default::default() - }); - } - if matches!(loader, L::Text) && (DATA_ONLY || bun_jsc::compiled_program::compiled_program().is_some()) { @@ -3141,7 +3102,7 @@ fn transpile_source_code_of_any_provenance( } // Hand `arena` ownership to the queue (defuse the give-back guard). - let (_, arena, _, _) = scopeguard::ScopeGuard::into_inner(arena_guard); + let (_, arena, _) = scopeguard::ScopeGuard::into_inner(arena_guard); // Hand the `AstAlloc` state to the queue too: the queued // AST's small `AstVec`s live in its inline bump chunk. let ast_alloc_state = ast_alloc_scope.take_state(); @@ -3493,14 +3454,6 @@ fn transpile_source_code_of_any_provenance( // .html // ──────────────────────────────────────────────────────────────────── L::Html => { - if disable_transpilying { - use bun_jsc::resolved_source::Tag as ResolvedSourceTag; - return Ok(ResolvedSource { - source_url: input_specifier.create_if_different(path.text), - tag: ResolvedSourceTag::Esm, - ..Default::default() - }); - } let html_bundle = bun_removable_features::call_removable_or!( Bundler, Throw, @@ -3528,15 +3481,6 @@ fn transpile_source_code_of_any_provenance( // Everything else — file loader: `export default `. // ──────────────────────────────────────────────────────────────────── _ => { - if disable_transpilying { - use bun_jsc::resolved_source::Tag as ResolvedSourceTag; - return Ok(ResolvedSource { - source_url: input_specifier.create_if_different(path.text), - tag: ResolvedSourceTag::Esm, - ..Default::default() - }); - } - if args.virtual_source.is_none() { auto_watch_file(jsc_vm, path.text); } @@ -5135,7 +5079,6 @@ unsafe fn transpile_file( log: &raw mut *log, virtual_source: lr.virtual_source, global_object: global, - flags: FetchFlags::Transpile, extra: &raw mut extra, }; @@ -5230,7 +5173,6 @@ pub(crate) fn transpile_as_loaded( log: &raw mut log, virtual_source: Some(&virtual_source), global_object: global, - flags: FetchFlags::Transpile, extra: &raw mut extra, }; transpile_source_code_inner::(jsc_vm, &args).ok() @@ -5334,7 +5276,6 @@ pub(crate) extern "C" fn Bun__transpileVirtualModule( log: &raw mut log, virtual_source: Some(&virtual_source), global_object: global, - flags: FetchFlags::Transpile, extra: &raw mut extra, }; diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 08ad77dff73c..794bd18c777a 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -1517,3 +1517,486 @@ code();`; expect(stderr).toMatch(/error: boom\n +at code \(.*:3:9\)\n/); expect(exitCode).toBe(1); }); + +// The code frame of a printed error is cut from the text of the module as it was written. The printer takes +// that text from where a load of the module takes it: the file, or what stands in for one. +describe.concurrent("the text that a code frame is cut from", () => { + const padding = count => Array.from({ length: count }, (_, i) => `// line ${i + 1}\n`).join(""); + const fail = 'export function fail() {\n throw new Error("thrown");\n}\n'; + // Prints the error that `fail()` of `mod.js` throws: to stdout as a string, to stderr as the printer's bytes. + const main = ` + const { fail } = await import("./mod.js"); + try { + fail(); + } catch (error) { + process.stdout.write(Bun.inspect(error)); + console.error(error); + } + `; + + async function run(files, cmd = ["main.js"], stdin = "ignore") { + using dir = tempDir("inspect-error-source-text", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + env: { ...bunEnv, NO_COLOR: "1" }, + cwd: String(dir), + stdin, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.bytes(), proc.exited]); + return { stdout, stderr: Buffer.from(stderr), exitCode }; + } + // The lines of the frame, the caret and the message: what is above the stack. + const frameOf = text => text.slice(0, text.indexOf("\n at ")).split("\n"); + + const thrownOnLine = line => [ + `${line - 1} | export function fail() {`, + `${line} | throw new Error("thrown");`, + `${" ".repeat(String(line).length)} ^`, + "error: thrown", + ]; + + test.each([ + ["a UTF-8 byte order mark is not part of line 1", Buffer.from("\uFEFF" + fail), thrownOnLine(2)], + [ + "a UTF-16 file is shown as UTF-8", + Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(padding(2) + fail, "utf16le")]), + ["1 | // line 1", "2 | // line 2", ...thrownOnLine(4)], + ], + [ + "bytes that are not UTF-8 above the lines of the frame", + Buffer.concat([Buffer.from("// caf"), Buffer.from([0xe9]), Buffer.from(" is not UTF-8\n" + padding(6) + fail)]), + ["4 | // line 3", "5 | // line 4", "6 | // line 5", "7 | // line 6", ...thrownOnLine(9)], + ], + ["a file that ends without a line break", Buffer.from((padding(2) + fail).trimEnd()), ["1 | // line 1", "2 | // line 2", ...thrownOnLine(4)]], + ])("%s", async (_, mod, frame) => { + const { stdout, stderr, exitCode } = await run({ "mod.js": mod, "main.js": main }); + expect({ stdout: frameOf(stdout), stderr: frameOf(stderr.toString()), exitCode }).toEqual({ + stdout: frame, + stderr: frame, + exitCode: 0, + }); + }); + + // The printer writes a line's bytes as they are, and U+FFFD for the ones that are not UTF-8. + test.each([ + ["a byte that is not UTF-8", [0xe9], ["1 | // caf\xEF\xBF\xBD is not UTF-8"]], + ["a character that is cut off", [0xe2, 0x82], ["1 | // caf\xEF\xBF\xBD is not UTF-8"]], + ["a character of two bytes", [0xc3, 0xa9], ["1 | // caf\xC3\xA9 is not UTF-8"]], + ])("%s in a line of the frame", async (_, bytes, lines) => { + const mod = Buffer.concat([Buffer.from("// caf"), Buffer.from(bytes), Buffer.from(" is not UTF-8\n" + fail)]); + const { stderr, exitCode } = await run({ "mod.js": mod, "main.js": main }); + expect({ stderr: frameOf(stderr.toString("latin1")), exitCode }).toEqual({ + stderr: [...lines, ...thrownOnLine(3)], + exitCode: 0, + }); + }); + + // The printer keeps the lines of a frame for the next print of the same place: as it showed them. + test("a line that is not UTF-8 is the same in the next print", async () => { + const mod = Buffer.concat([Buffer.from("// caf"), Buffer.from([0xe9]), Buffer.from(" is not UTF-8\n" + fail)]); + const { stdout, exitCode } = await run({ + "mod.js": mod, + "main.js": ` + const { getCounters } = require("bun:internal-for-testing"); + const { fail } = await import("./mod.js"); + let error; + try { + fail(); + } catch (e) { + error = e; + } + const prints = [0, 1].map(() => { + const before = getCounters().codeFrame_sourceLoads; + const text = Bun.inspect(error); + return [getCounters().codeFrame_sourceLoads - before, text.slice(0, text.indexOf("\\n at ")).split("\\n")]; + }); + console.log(JSON.stringify(prints)); + `, + }); + const frame = ["1 | // caf\uFFFD is not UTF-8", ...thrownOnLine(3)]; + expect({ prints: JSON.parse(stdout), exitCode }).toEqual({ + prints: [ + [1, frame], + [0, frame], + ], + exitCode: 0, + }); + }); + + // A `\r` alone ends a line, and the line takes one byte more. That byte can be the first of a character. + test.each([ + ["a character of two bytes", [0xc3, 0xa9], "\xC3\xA9"], + ["a byte that is not UTF-8", [0xe9], "\xEF\xBF\xBD"], + ])("a carriage return alone before %s", async (_, bytes, shown) => { + const mod = Buffer.concat([Buffer.from("// line 1\n/* a\r"), Buffer.from(bytes), Buffer.from(" b */\n" + fail)]); + const { stderr, exitCode } = await run({ "mod.js": mod, "main.js": main }); + expect({ stderr: frameOf(stderr.toString("latin1")), exitCode }).toEqual({ + stderr: ["1 | // line 1", "2 | /* a\r\xEF\xBF\xBD", `3 | /* a\r${shown} b */`, ...thrownOnLine(5)], + exitCode: 0, + }); + }); + + // The reader takes the first 16384 bytes of a file in one read, and a longer file in two. The error is on + // the last line, which has no line break: the last byte that was read is the last of the frame. + test.each([16382, 16383, 16384, 16385, 16386, 40000])("a file of %d bytes", async size => { + const last = 'export function fail() { throw new Error("thrown"); }'; + const line = "// " + Buffer.alloc(60, "f").toString() + "\n"; + const lines = Math.floor((size - last.length) / line.length) - 1; + const rest = size - last.length - lines * line.length; + const mod = Buffer.alloc(lines * line.length, line).toString() + "//" + Buffer.alloc(rest - 3, "g").toString() + "\n" + last; + expect(mod.length).toBe(size); + const { stdout, exitCode } = await run({ "mod.js": mod, "main.js": main }); + expect({ stdout: frameOf(stdout).slice(-3), exitCode }).toEqual({ + stdout: [`${lines + 2} | ${last}`, " ".repeat(String(lines + 2).length + 38) + "^", "error: thrown"], + exitCode: 0, + }); + }); + + // With no text on the disk the printer has only what ran. The comment is in the file alone. + test.each([ + ["deleted", `fs.unlinkSync(path);`], + ["emptied", `fs.writeFileSync(path, "");`], + ])("a file that was %s after the module loaded", async (_, change) => { + const { stdout, exitCode } = await run({ + "mod.js": "// only on the disk\n" + fail, + "main.js": ` + const fs = require("node:fs"); + const path = require("node:path").join(import.meta.dir, "mod.js"); + const { fail } = await import("./mod.js"); + ${change} + try { + fail(); + } catch (error) { + process.stdout.write(Bun.inspect(error)); + } + `, + }); + const frame = frameOf(stdout); + expect({ message: frame.at(-1), fromDisk: frame.some(line => line.includes("only on the disk")), exitCode }).toEqual({ + message: "error: thrown", + fromDisk: false, + exitCode: 0, + }); + }); + + const virtual = '// not a file\nexport function fail() {\n throw new Error("thrown");\n}\n'; + const virtualFrame = ["1 | // not a file", ...thrownOnLine(3)]; + const printed = url => ` + const { fail } = await import(${url}); + try { + fail(); + } catch (error) { + process.stdout.write(Bun.inspect(error)); + } + `; + + test("a module of a blob: URL", async () => { + const url = `URL.createObjectURL(new Blob([${JSON.stringify(virtual)}], { type: "text/javascript" }))`; + const { stdout, exitCode } = await run({ "main.js": printed(url) }); + expect({ stdout: frameOf(stdout), exitCode }).toEqual({ stdout: virtualFrame, exitCode: 0 }); + }); + + test("a module of a blob: URL of a file", async () => { + const url = `URL.createObjectURL(Bun.file(require("node:path").join(import.meta.dir, "blobbed.js")))`; + const { stdout, exitCode } = await run({ "blobbed.js": virtual, "main.js": printed(url) }); + expect({ stdout: frameOf(stdout), exitCode }).toEqual({ stdout: virtualFrame, exitCode: 0 }); + }); + + // The printer, as on main, reads what follows the last "." of a `data:` URL as an extension. The module + // loader does not ("A `data:` URL has no file name", src/runtime/jsc_hooks.rs). This row reaches the + // printer's read of a `data:` URL. It is not the rule. + test("a module of a data: URL whose text ends in .js", async () => { + const url = `"data:text/javascript," + encodeURIComponent(${JSON.stringify(virtual + "//.js")})`; + const { stdout, exitCode } = await run({ "main.js": printed(url) }); + expect({ stdout: frameOf(stdout), exitCode }).toEqual({ stdout: virtualFrame, exitCode: 0 }); + }); + test.todo("a module of a data: URL shows its own text whatever the text ends in"); + test.each([ + ["a data: URL", `"data:text/javascript," + encodeURIComponent(${JSON.stringify(virtual)})`], + ["a base64 data: URL", `"data:text/javascript;base64," + btoa(${JSON.stringify(virtual)})`], + ])("the error of a module of %s", async (_, url) => { + const { stdout, exitCode } = await run({ "main.js": printed(url) }); + expect({ message: frameOf(stdout).at(-1), exitCode }).toEqual({ message: "error: thrown", exitCode: 0 }); + }); + + const script = 'function fail() {\n throw new Error("thrown");\n}\ntry {\n fail();\n} catch (error) {\n process.stdout.write(Bun.inspect(error));\n}\n'; + const scriptFrame = ["1 | function fail() {", '2 | throw new Error("thrown");', " ^", "error: thrown"]; + + test("the script of bun -e", async () => { + const { stdout, exitCode } = await run({}, ["-e", script]); + expect({ stdout: frameOf(stdout), exitCode }).toEqual({ stdout: scriptFrame, exitCode: 0 }); + }); + + test("the script of bun -", async () => { + const { stdout, exitCode } = await run({}, ["-"], new Blob([script])); + expect({ stdout: frameOf(stdout), exitCode }).toEqual({ stdout: scriptFrame, exitCode: 0 }); + }); + + // A name that gives no loader is JavaScript when it is the entry point. + test.each(["script", "script.unknown-extension"])("an entry point named %s", async name => { + const { stdout, exitCode } = await run({ [name]: script }, [`./${name}`]); + expect({ stdout: frameOf(stdout), exitCode }).toEqual({ stdout: scriptFrame, exitCode: 0 }); + }); + + test("a module that an import suffix makes", async () => { + const { stdout, exitCode } = await run({ + "worker.js": "postMessage(1);\n", + "main.js": ` + import Wrapper from "./worker.js?sharedworker"; + // The module calls this constructor. + globalThis.SharedWorker = undefined; + try { + Wrapper({}); + } catch (error) { + process.stdout.write(Bun.inspect(error)); + } + `, + }); + expect({ stdout: frameOf(stdout).map(line => line.replace(/"[^"]*worker\.js"/, '"worker.js"')), exitCode }).toEqual({ + stdout: [ + "1 | export default function WorkerWrapper(options) {", + '2 | return new SharedWorker("worker.js", { type: "module", name: options?.name });', + " ^", + "TypeError: undefined is not a constructor (evaluating 'new SharedWorker(\"worker.js\", { type: \"module\", name: options?.name })')", + ], + exitCode: 0, + }); + }); + + test("a node fallback module", async () => { + const { stdout, exitCode } = await run({ + "main.js": ` + const util = await import("/bun-vfs$$/node_modules/util/index.js"); + try { + util.promisify(3); + } catch (error) { + process.stdout.write(Bun.inspect(error)); + } + `, + }); + const frame = frameOf(stdout); + // The frame is of the fallback's own text, which is minified. + expect({ lines: frame.length, message: frame.at(-1), exitCode }).toEqual({ + lines: 8, + message: 'TypeError: The "original" argument must be of type Function', + exitCode: 0, + }); + expect(frame.slice(0, 6).join("\n")).toContain("isArray(ar){return Array.isArray(ar)}"); + }); + + // Code that runs in node:vm names its own file. Today the printer reads that file when it prints an + // error of the code, if the loader of the name is one of text (oven-sh/bun#41477 is about whether it + // should). These tests hold what it shows, so that a change of how it reads a source does not change it. + describe("node:vm code whose filename names a file", () => { + // The frame of an error on line `line` of code that names `name`. + async function vmFrameOf(name, content, line = 4) { + const { stdout, stderr, exitCode } = await run({ + ...(content === undefined ? {} : { [name]: content }), + "main.js": ` + const filename = require("node:path").join(import.meta.dir, ${JSON.stringify(name)}); + const code = "\\n".repeat(${line - 2}) + "function fail() {\\n throw new Error('thrown');\\n}\\nfail();"; + try { + require("node:vm").runInThisContext(code, { filename }); + } catch (error) { + process.stdout.write(Bun.inspect(error)); + } + `, + }); + return { frame: frameOf(stdout), stderr: stderr.toString(), exitCode }; + } + const caret = " ^"; + const numbered = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => `${from + i} | named line ${from + i}`); + const named = "named line 1\nnamed line 2\nnamed line 3\nnamed line 4\n"; + + test.each([ + ["data.json", numbered(1, 4)], + ["notes.txt", numbered(1, 4)], + ["module.ts", numbered(1, 4)], + ["notes.md", []], + ["style.css", []], + ["LICENSE", []], + ["not-there.ts", []], + ])("filename %s", async (name, lines) => { + expect(await vmFrameOf(name, name === "not-there.ts" ? undefined : named)).toEqual({ + frame: [...lines, ...(lines.length ? [caret] : []), "error: thrown"], + stderr: "", + exitCode: 0, + }); + }); + + // Bytes that are not UTF-8 beside a line break keep every line at its number: in the frame they are + // U+FFFD, and above it they change nothing. + test.each([ + ["a byte that is not UTF-8 before a line feed", [0xe9]], + ["a cut-off character before a line feed", [0xe2, 0x80]], + ])("%s", async (_, bytes) => { + const rest = Array.from({ length: 8 }, (_, i) => `named line ${i + 2}\n`).join(""); + const content = Buffer.concat([Buffer.from("caf"), Buffer.from(bytes), Buffer.from("\n" + rest)]); + const [inFrame, above] = await Promise.all([vmFrameOf("notes.txt", content, 4), vmFrameOf("notes.txt", content, 9)]); + expect({ inFrame, above }).toEqual({ + inFrame: { frame: ["1 | caf\uFFFD", ...numbered(2, 4), caret, "error: thrown"], stderr: "", exitCode: 0 }, + above: { frame: [...numbered(4, 9), caret, "error: thrown"], stderr: "", exitCode: 0 }, + }); + }); + + test("a cut-off character at the end of the file", async () => { + const content = Buffer.concat([Buffer.from("named line 1\nnamed line 2\nnamed line 3\ncaf"), Buffer.from([0xe2, 0x82])]); + expect(await vmFrameOf("notes.txt", content, 4)).toEqual({ + frame: [...numbered(1, 3), "4 | caf\uFFFD", caret, "error: thrown"], + stderr: "", + exitCode: 0, + }); + }); + + // A string cannot hold 2 GiB, and the printer shows no line of a text that a string cannot hold. The + // file is sparse. Reading it takes 2 GiB of memory. + test.skipIf(totalmem() < 10 * 1024 ** 3)( + "a file of 2 GiB", + async () => { + const { stdout, stderr, exitCode } = await run({ + "main.js": ` + const fs = require("node:fs"); + const filename = require("node:path").join(import.meta.dir, "big.txt"); + fs.writeFileSync(filename, "named line 1\\n"); + fs.truncateSync(filename, 2 ** 31); + try { + require("node:vm").runInThisContext("throw new Error('thrown');", { filename }); + } catch (error) { + process.stdout.write(Bun.inspect(error)); + } + `, + }); + expect({ frame: frameOf(stdout), stderr: stderr.toString(), exitCode }).toEqual({ + frame: ["error: thrown"], + stderr: "", + exitCode: 0, + }); + }, + 120_000, + ); + }); + + // An error whose `.stack` was read reaches the printer with frames that are already remapped. The printer + // then asks for the text of the module, and an executable gives its own. + test("an executable prints the code frame of an error whose stack was read", async () => { + using dir = tempDir("inspect-error-executable", { + "app.ts": `${script.replace("} catch (error) {", "} catch (error) {\n void error.stack;")}`, + }); + const result = await Bun.build({ + entrypoints: [join(String(dir), "app.ts")], + compile: { outfile: join(String(dir), "app") }, + }); + expect(result.success).toBe(true); + rmSync(join(String(dir), "app.ts")); + await using proc = Bun.spawn({ + cmd: [result.outputs.find(output => output.kind === "entry-point").path], + env: { ...bunEnv, NO_COLOR: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Without their numbers: the executable's text has lines above the module's. + const frame = frameOf(stdout).map(line => line.replace(/^\s*\d+ \| /, "")); + expect({ frame: frame.slice(-4), stderr, exitCode }).toEqual({ + frame: ["function fail() {", ' throw new Error("thrown");', " ^", "error: thrown"], + stderr: "", + exitCode: 0, + }); + // A debug build takes seconds to write an executable. + }, 60_000); + + // An error whose `.stack` was read has no kept frame, so each print of it reads the file: once, into one buffer. + test.each([ + ["ASCII", "p"], + ["not ASCII", "\u00e9"], + ])("a print makes no allocator heap and no second copy of a file that is %s", async (_, char) => { + const size = 1024 * 1024; + const line = Buffer.from("// " + char.repeat(30) + "\n"); + const mod = Buffer.concat([Buffer.from(fail), Buffer.alloc(size - fail.length - ((size - fail.length) % line.length), line)]); + const { stdout, stderr, exitCode } = await run({ + "mod.js": mod, + "main.js": ` + const { heapStats } = require("bun:jsc"); + const { getCounters } = require("bun:internal-for-testing"); + const { fail } = await import("./mod.js"); + let error; + try { + fail(); + } catch (e) { + error = e; + } + void error.stack; + const first = Bun.inspect(error); + // Something else can make a heap while one window runs, so the least of three counts. + const windows = []; + for (let i = 0; i < 3; i++) { + const before = heapStats().mimalloc; + const loads = getCounters().codeFrame_sourceLoads; + for (let print = 0; print < 50; print++) Bun.inspect(error); + const after = heapStats().mimalloc; + windows.push({ + loads: getCounters().codeFrame_sourceLoads - loads, + heaps: after.heaps.total - before.heaps.total, + requested: (after.malloc_requested - before.malloc_requested) / 50, + }); + } + console.log( + JSON.stringify({ + frame: first.slice(0, first.indexOf("\\n at ")).split("\\n"), + loads: windows.map(it => it.loads), + heaps: Math.min(...windows.map(it => it.heaps)), + requested: Math.min(...windows.map(it => it.requested)), + }), + ); + `, + }); + expect(stderr.toString()).toBe(""); + const { frame, loads, heaps, requested } = JSON.parse(stdout); + expect({ frame, loads, heaps, exitCode }).toEqual({ frame: thrownOnLine(2), loads: [50, 50, 50], heaps: 0, exitCode: 0 }); + // The file in one buffer of the allocator, or in none where another allocator gives it. + expect(requested).toBeLessThan(mod.length * 1.5); + }); + + // The reader leaves the file open for a watcher. The printer has none. + test.skipIf(isWindows)("prints leave no file open", async () => { + const { stdout, stderr, exitCode } = await run({ + "mod.js": fail, + "main.js": ` + const fs = require("node:fs"); + const { getCounters } = require("bun:internal-for-testing"); + const { fail } = await import("./mod.js"); + let error; + try { + fail(); + } catch (e) { + error = e; + } + // Each print of an error whose \`.stack\` was read reads the file. + void error.stack; + const before = fs.readdirSync("/dev/fd").length; + const loads = getCounters().codeFrame_sourceLoads; + const first = Bun.inspect(error); + for (let print = 0; print < 1000; print++) Bun.inspect(error); + const after = fs.readdirSync("/dev/fd").length; + console.log( + JSON.stringify({ + frame: first.slice(0, first.indexOf("\\n at ")).split("\\n"), + loads: getCounters().codeFrame_sourceLoads - loads, + opened: after - before, + }), + ); + `, + }); + expect(stderr.toString()).toBe(""); + expect({ ...JSON.parse(stdout), exitCode }).toEqual({ frame: thrownOnLine(2), loads: 1001, opened: 0, exitCode: 0 }); + }); +}); + +// Here, not at the top: the inline snapshots above hold the first lines of this file. +import { isWindows } from "harness"; +import { rmSync } from "node:fs"; +import { totalmem } from "node:os"; +import { join } from "node:path"; From 53647c11ce8729d45755822f74f275cf5b781b85 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:38:28 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/js/bun/util/inspect-error.test.js | 77 +++++++++++++++++++------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 794bd18c777a..ee4a2be5c783 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -1569,7 +1569,11 @@ describe.concurrent("the text that a code frame is cut from", () => { Buffer.concat([Buffer.from("// caf"), Buffer.from([0xe9]), Buffer.from(" is not UTF-8\n" + padding(6) + fail)]), ["4 | // line 3", "5 | // line 4", "6 | // line 5", "7 | // line 6", ...thrownOnLine(9)], ], - ["a file that ends without a line break", Buffer.from((padding(2) + fail).trimEnd()), ["1 | // line 1", "2 | // line 2", ...thrownOnLine(4)]], + [ + "a file that ends without a line break", + Buffer.from((padding(2) + fail).trimEnd()), + ["1 | // line 1", "2 | // line 2", ...thrownOnLine(4)], + ], ])("%s", async (_, mod, frame) => { const { stdout, stderr, exitCode } = await run({ "mod.js": mod, "main.js": main }); expect({ stdout: frameOf(stdout), stderr: frameOf(stderr.toString()), exitCode }).toEqual({ @@ -1645,7 +1649,8 @@ describe.concurrent("the text that a code frame is cut from", () => { const line = "// " + Buffer.alloc(60, "f").toString() + "\n"; const lines = Math.floor((size - last.length) / line.length) - 1; const rest = size - last.length - lines * line.length; - const mod = Buffer.alloc(lines * line.length, line).toString() + "//" + Buffer.alloc(rest - 3, "g").toString() + "\n" + last; + const mod = + Buffer.alloc(lines * line.length, line).toString() + "//" + Buffer.alloc(rest - 3, "g").toString() + "\n" + last; expect(mod.length).toBe(size); const { stdout, exitCode } = await run({ "mod.js": mod, "main.js": main }); expect({ stdout: frameOf(stdout).slice(-3), exitCode }).toEqual({ @@ -1674,7 +1679,11 @@ describe.concurrent("the text that a code frame is cut from", () => { `, }); const frame = frameOf(stdout); - expect({ message: frame.at(-1), fromDisk: frame.some(line => line.includes("only on the disk")), exitCode }).toEqual({ + expect({ + message: frame.at(-1), + fromDisk: frame.some(line => line.includes("only on the disk")), + exitCode, + }).toEqual({ message: "error: thrown", fromDisk: false, exitCode: 0, @@ -1721,8 +1730,14 @@ describe.concurrent("the text that a code frame is cut from", () => { expect({ message: frameOf(stdout).at(-1), exitCode }).toEqual({ message: "error: thrown", exitCode: 0 }); }); - const script = 'function fail() {\n throw new Error("thrown");\n}\ntry {\n fail();\n} catch (error) {\n process.stdout.write(Bun.inspect(error));\n}\n'; - const scriptFrame = ["1 | function fail() {", '2 | throw new Error("thrown");', " ^", "error: thrown"]; + const script = + 'function fail() {\n throw new Error("thrown");\n}\ntry {\n fail();\n} catch (error) {\n process.stdout.write(Bun.inspect(error));\n}\n'; + const scriptFrame = [ + "1 | function fail() {", + '2 | throw new Error("thrown");', + " ^", + "error: thrown", + ]; test("the script of bun -e", async () => { const { stdout, exitCode } = await run({}, ["-e", script]); @@ -1754,15 +1769,17 @@ describe.concurrent("the text that a code frame is cut from", () => { } `, }); - expect({ stdout: frameOf(stdout).map(line => line.replace(/"[^"]*worker\.js"/, '"worker.js"')), exitCode }).toEqual({ - stdout: [ - "1 | export default function WorkerWrapper(options) {", - '2 | return new SharedWorker("worker.js", { type: "module", name: options?.name });', - " ^", - "TypeError: undefined is not a constructor (evaluating 'new SharedWorker(\"worker.js\", { type: \"module\", name: options?.name })')", - ], - exitCode: 0, - }); + expect({ stdout: frameOf(stdout).map(line => line.replace(/"[^"]*worker\.js"/, '"worker.js"')), exitCode }).toEqual( + { + stdout: [ + "1 | export default function WorkerWrapper(options) {", + '2 | return new SharedWorker("worker.js", { type: "module", name: options?.name });', + " ^", + 'TypeError: undefined is not a constructor (evaluating \'new SharedWorker("worker.js", { type: "module", name: options?.name })\')', + ], + exitCode: 0, + }, + ); }); test("a node fallback module", async () => { @@ -1807,7 +1824,8 @@ describe.concurrent("the text that a code frame is cut from", () => { return { frame: frameOf(stdout), stderr: stderr.toString(), exitCode }; } const caret = " ^"; - const numbered = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => `${from + i} | named line ${from + i}`); + const numbered = (from, to) => + Array.from({ length: to - from + 1 }, (_, i) => `${from + i} | named line ${from + i}`); const named = "named line 1\nnamed line 2\nnamed line 3\nnamed line 4\n"; test.each([ @@ -1834,7 +1852,10 @@ describe.concurrent("the text that a code frame is cut from", () => { ])("%s", async (_, bytes) => { const rest = Array.from({ length: 8 }, (_, i) => `named line ${i + 2}\n`).join(""); const content = Buffer.concat([Buffer.from("caf"), Buffer.from(bytes), Buffer.from("\n" + rest)]); - const [inFrame, above] = await Promise.all([vmFrameOf("notes.txt", content, 4), vmFrameOf("notes.txt", content, 9)]); + const [inFrame, above] = await Promise.all([ + vmFrameOf("notes.txt", content, 4), + vmFrameOf("notes.txt", content, 9), + ]); expect({ inFrame, above }).toEqual({ inFrame: { frame: ["1 | caf\uFFFD", ...numbered(2, 4), caret, "error: thrown"], stderr: "", exitCode: 0 }, above: { frame: [...numbered(4, 9), caret, "error: thrown"], stderr: "", exitCode: 0 }, @@ -1842,7 +1863,10 @@ describe.concurrent("the text that a code frame is cut from", () => { }); test("a cut-off character at the end of the file", async () => { - const content = Buffer.concat([Buffer.from("named line 1\nnamed line 2\nnamed line 3\ncaf"), Buffer.from([0xe2, 0x82])]); + const content = Buffer.concat([ + Buffer.from("named line 1\nnamed line 2\nnamed line 3\ncaf"), + Buffer.from([0xe2, 0x82]), + ]); expect(await vmFrameOf("notes.txt", content, 4)).toEqual({ frame: [...numbered(1, 3), "4 | caf\uFFFD", caret, "error: thrown"], stderr: "", @@ -1915,7 +1939,10 @@ describe.concurrent("the text that a code frame is cut from", () => { ])("a print makes no allocator heap and no second copy of a file that is %s", async (_, char) => { const size = 1024 * 1024; const line = Buffer.from("// " + char.repeat(30) + "\n"); - const mod = Buffer.concat([Buffer.from(fail), Buffer.alloc(size - fail.length - ((size - fail.length) % line.length), line)]); + const mod = Buffer.concat([ + Buffer.from(fail), + Buffer.alloc(size - fail.length - ((size - fail.length) % line.length), line), + ]); const { stdout, stderr, exitCode } = await run({ "mod.js": mod, "main.js": ` @@ -1955,7 +1982,12 @@ describe.concurrent("the text that a code frame is cut from", () => { }); expect(stderr.toString()).toBe(""); const { frame, loads, heaps, requested } = JSON.parse(stdout); - expect({ frame, loads, heaps, exitCode }).toEqual({ frame: thrownOnLine(2), loads: [50, 50, 50], heaps: 0, exitCode: 0 }); + expect({ frame, loads, heaps, exitCode }).toEqual({ + frame: thrownOnLine(2), + loads: [50, 50, 50], + heaps: 0, + exitCode: 0, + }); // The file in one buffer of the allocator, or in none where another allocator gives it. expect(requested).toBeLessThan(mod.length * 1.5); }); @@ -1991,7 +2023,12 @@ describe.concurrent("the text that a code frame is cut from", () => { `, }); expect(stderr.toString()).toBe(""); - expect({ ...JSON.parse(stdout), exitCode }).toEqual({ frame: thrownOnLine(2), loads: 1001, opened: 0, exitCode: 0 }); + expect({ ...JSON.parse(stdout), exitCode }).toEqual({ + frame: thrownOnLine(2), + loads: 1001, + opened: 0, + exitCode: 0, + }); }); }); From 89bc1084bfbb190197490c58baaf058c5f9e51f4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:04:44 +0000 Subject: [PATCH 3/3] error printer: show no frame whose lines do not fit u32 ranges A line of the frame that is not UTF-8 grows when it becomes a string: U+FFFD is three bytes. If the lines then pass 4 GiB, their ranges would wrap, so the frame is not shown. Tests: the rows for a carriage return alone check that the printer writes UTF-8, not which bytes the line cutter puts in the line. The row for a text that a string cannot hold lowers the limit of a string in place of a 2 GiB file. The executable test runs alone, so it needs no timeout. --- src/jsc/VirtualMachine.rs | 5 + test/js/bun/util/inspect-error.test.js | 128 ++++++++++++++----------- 2 files changed, 76 insertions(+), 57 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 7000d78f106f..50efdd798d0e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4510,6 +4510,11 @@ fn code_frame_lines_as_utf8(text: &[u8], ranges: &mut CodeFrameRanges) -> Option end: lines.len() as u32, }; } + // U+FFFD is three bytes. Lines that no longer fit `u32` ranges are not shown. + if u32::try_from(lines.len()).is_err() { + ranges.clear(); + lines.clear(); + } Some(lines) } diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index ee4a2be5c783..be392a02acb1 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -1534,11 +1534,11 @@ describe.concurrent("the text that a code frame is cut from", () => { } `; - async function run(files, cmd = ["main.js"], stdin = "ignore") { + async function run(files, cmd = ["main.js"], stdin = "ignore", env = {}) { using dir = tempDir("inspect-error-source-text", files); await using proc = Bun.spawn({ cmd: [bunExe(), ...cmd], - env: { ...bunEnv, NO_COLOR: "1" }, + env: { ...bunEnv, NO_COLOR: "1", ...env }, cwd: String(dir), stdin, stdout: "pipe", @@ -1629,15 +1629,17 @@ describe.concurrent("the text that a code frame is cut from", () => { }); }); - // A `\r` alone ends a line, and the line takes one byte more. That byte can be the first of a character. + // The line cutter ends a line one byte after a `\r` alone. That byte can be the first of a character, and + // the printer must not write half of one. test.each([ - ["a character of two bytes", [0xc3, 0xa9], "\xC3\xA9"], - ["a byte that is not UTF-8", [0xe9], "\xEF\xBF\xBD"], - ])("a carriage return alone before %s", async (_, bytes, shown) => { + ["a character of two bytes", [0xc3, 0xa9]], + ["a byte that is not UTF-8", [0xe9]], + ])("a carriage return alone before %s", async (_, bytes) => { const mod = Buffer.concat([Buffer.from("// line 1\n/* a\r"), Buffer.from(bytes), Buffer.from(" b */\n" + fail)]); const { stderr, exitCode } = await run({ "mod.js": mod, "main.js": main }); - expect({ stderr: frameOf(stderr.toString("latin1")), exitCode }).toEqual({ - stderr: ["1 | // line 1", "2 | /* a\r\xEF\xBF\xBD", `3 | /* a\r${shown} b */`, ...thrownOnLine(5)], + expect({ isUtf8: isUtf8(stderr), frame: frameOf(stderr.toString()).slice(-4), exitCode }).toEqual({ + isUtf8: true, + frame: thrownOnLine(5), exitCode: 0, }); }); @@ -1874,63 +1876,34 @@ describe.concurrent("the text that a code frame is cut from", () => { }); }); - // A string cannot hold 2 GiB, and the printer shows no line of a text that a string cannot hold. The - // file is sparse. Reading it takes 2 GiB of memory. - test.skipIf(totalmem() < 10 * 1024 ** 3)( - "a file of 2 GiB", - async () => { - const { stdout, stderr, exitCode } = await run({ + // The printer shows no line of a text that a string cannot hold. The variable makes that 64 KiB. + test.each([ + ["under", 60_000, ["1 | named line 1", " ^", "error: thrown"]], + ["over", 70_000, ["error: thrown"]], + ])("a file %s the length that a string can have", async (_, size, frame) => { + const { stdout, stderr, exitCode } = await run( + { + "big.txt": "named line 1\n" + Buffer.alloc(size - 13, "// padding\n"), "main.js": ` - const fs = require("node:fs"); const filename = require("node:path").join(import.meta.dir, "big.txt"); - fs.writeFileSync(filename, "named line 1\\n"); - fs.truncateSync(filename, 2 ** 31); try { require("node:vm").runInThisContext("throw new Error('thrown');", { filename }); } catch (error) { process.stdout.write(Bun.inspect(error)); } `, - }); - expect({ frame: frameOf(stdout), stderr: stderr.toString(), exitCode }).toEqual({ - frame: ["error: thrown"], - stderr: "", - exitCode: 0, - }); - }, - 120_000, - ); - }); - - // An error whose `.stack` was read reaches the printer with frames that are already remapped. The printer - // then asks for the text of the module, and an executable gives its own. - test("an executable prints the code frame of an error whose stack was read", async () => { - using dir = tempDir("inspect-error-executable", { - "app.ts": `${script.replace("} catch (error) {", "} catch (error) {\n void error.stack;")}`, - }); - const result = await Bun.build({ - entrypoints: [join(String(dir), "app.ts")], - compile: { outfile: join(String(dir), "app") }, - }); - expect(result.success).toBe(true); - rmSync(join(String(dir), "app.ts")); - await using proc = Bun.spawn({ - cmd: [result.outputs.find(output => output.kind === "entry-point").path], - env: { ...bunEnv, NO_COLOR: "1" }, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // Without their numbers: the executable's text has lines above the module's. - const frame = frameOf(stdout).map(line => line.replace(/^\s*\d+ \| /, "")); - expect({ frame: frame.slice(-4), stderr, exitCode }).toEqual({ - frame: ["function fail() {", ' throw new Error("thrown");', " ^", "error: thrown"], - stderr: "", - exitCode: 0, + }, + undefined, + undefined, + { BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(64 * 1024) }, + ); + expect({ frame: frameOf(stdout), stderr: stderr.toString(), exitCode }).toEqual({ + frame, + stderr: "", + exitCode: 0, + }); }); - // A debug build takes seconds to write an executable. - }, 60_000); + }); // An error whose `.stack` was read has no kept frame, so each print of it reads the file: once, into one buffer. test.each([ @@ -2032,8 +2005,49 @@ describe.concurrent("the text that a code frame is cut from", () => { }); }); +// An error whose `.stack` was read reaches the printer with frames that are already remapped. The printer +// then asks for the text of the module, and an executable gives its own. +test("an executable prints the code frame of an error whose stack was read", async () => { + using dir = tempDir("inspect-error-executable", { + "app.ts": `function fail() { + throw new Error("thrown"); +} +try { + fail(); +} catch (error) { + void error.stack; + process.stdout.write(Bun.inspect(error)); +} +`, + }); + const result = await Bun.build({ + entrypoints: [join(String(dir), "app.ts")], + compile: { outfile: join(String(dir), "app") }, + }); + expect(result.success).toBe(true); + rmSync(join(String(dir), "app.ts")); + await using proc = Bun.spawn({ + cmd: [result.outputs.find(output => output.kind === "entry-point").path], + env: { ...bunEnv, NO_COLOR: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Without their numbers: the executable's text has lines above the module's. + const frame = stdout + .slice(0, stdout.indexOf("\n at ")) + .split("\n") + .map(line => line.replace(/^\s*\d+ \| /, "")); + expect({ frame: frame.slice(-4), stderr, exitCode }).toEqual({ + frame: ["function fail() {", ' throw new Error("thrown");', " ^", "error: thrown"], + stderr: "", + exitCode: 0, + }); +}); + // Here, not at the top: the inline snapshots above hold the first lines of this file. import { isWindows } from "harness"; +import { isUtf8 } from "node:buffer"; import { rmSync } from "node:fs"; -import { totalmem } from "node:os"; import { join } from "node:path";