From b233e851a2a67e294bc16204d04619edb7bbae49 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:24:16 +0000 Subject: [PATCH 1/4] Pin the WebKit preview of oven-sh/WebKit#451 The preview is Bun's pin 4d45755fed plus the two commits of that pull request. A later importer of a module whose fetch failed gets the error of that fetch, not a copy with the message alone. A rejected fetch of a load by name registers nothing. This commit lets CI run the rest of the branch. Replace the tag with the merged sha before this lands. --- scripts/build/deps/webkit.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 40b7caad211d..dea7504545c7 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,8 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "4d45755fed4aeef19e95beb90d3d0c884a2812bd"; +// Preview build of oven-sh/WebKit#451 (4d45755fed plus its two commits). Replace it with the merged sha before this lands. +export const WEBKIT_VERSION = "autobuild-preview-pr-451-5ba62c84"; /** * WebKit (JavaScriptCore) — the JS engine, with WTF and bmalloc. From cc426e42ab7db00499f2f57c056c23d937b7c9f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:24:16 +0000 Subject: [PATCH 2/4] Module loader: reject with one SyntaxError when Bun's parser refuses a file import(), require() and a static import of a file that Bun's parser refuses failed with a BuildMessage, or with an AggregateError of them for several diagnostics. A BuildMessage is not an Error: it has no stack, Error.isError() is false, and structuredClone() throws. process_fetch_log now makes one SyntaxError when the first error of the log is a Build diagnostic with a line. The error has own 1-based line and column, sourceURL, and a stack of one frame that names the file. It keeps the wrappers of up to 256 diagnostics under a private name. position, notes and level read the BuildMessage of the first error. The console, the uncaught printer, the test runner and the junit reporter print every diagnostic from the wrappers, so the code frames that they print stay as they were. A diagnostic without a line, a file that is nested too deeply for the parser, a file that cannot be read and a resolve failure still give a BuildMessage, a ResolveMessage or an AggregateError. The Worker error hook has no special case for this value. node:repl prints the location above the message, as Node does. A macro promise that rejects is marked as handled, because the macro runner reports it. Co-authored-by: Dylan Conway --- docs/runtime/globals.mdx | 2 +- packages/bun-types/globals.d.ts | 12 + src/js/builtins/BunBuiltinNames.h | 1 + src/js/internal/repl/node-shims.ts | 32 +- src/js_parser_jsc/Macro.rs | 6 + src/jsc/BuildMessage.rs | 142 +++++ src/jsc/ConsoleObject.rs | 14 + src/jsc/VirtualMachine.rs | 123 +++- src/jsc/bindings/LoaderSyntaxError.cpp | 84 +++ src/jsc/lib.rs | 2 +- src/jsc/web_worker.rs | 5 +- src/runtime/cli/test_command.rs | 49 +- src/runtime/test_runner/bun_test.rs | 2 +- src/runtime/test_runner/pretty_format.rs | 15 + ...-loaded-in-a-try-stops-no-test-run.test.ts | 2 +- ...computed-name-finds-stops-no-build.test.ts | 6 +- .../error-imported-text-not-parse-lists.mjs | 2 +- ...f-a-file-that-an-import-has-loaded.test.ts | 2 +- ...rs-from-texts-that-the-build-knows.test.ts | 6 +- test/bundler/transpiler/macro-test.test.ts | 38 ++ test/cli/run/transpiler-cache.test.ts | 4 +- test/js/bun/plugin/plugins.test.ts | 87 ++- test/js/bun/repl/repl.test.ts | 27 + test/js/bun/resolve/build-error.test.ts | 573 ++++++++++++++++-- test/js/bun/resolve/xml/xml.test.js | 8 +- test/js/bun/util/inspect-error.test.js | 79 ++- test/js/junit-reporter/junit.test.js | 16 +- .../js/node/module/node-module-module.test.js | 2 +- .../test/parallel/test-esm-error-cache.js | 28 + test/js/node/util/test-util-types.test.js | 11 +- .../worker_threads/worker_threads.test.ts | 31 + test/js/web/workers/worker.test.ts | 25 + 32 files changed, 1321 insertions(+), 115 deletions(-) create mode 100644 src/jsc/bindings/LoaderSyntaxError.cpp create mode 100644 test/js/node/test/parallel/test-esm-error-cache.js diff --git a/docs/runtime/globals.mdx b/docs/runtime/globals.mdx index 56b4e897fc24..4dfece9a6463 100644 --- a/docs/runtime/globals.mdx +++ b/docs/runtime/globals.mdx @@ -19,7 +19,7 @@ Bun implements the following globals. | [`__filename`](https://nodejs.org/api/globals.html#__filename) | Node.js | | | [`atob()`](https://developer.mozilla.org/en-US/docs/Web/API/atob) | Web | | | [`btoa()`](https://developer.mozilla.org/en-US/docs/Web/API/btoa) | Web | | -| `BuildMessage` | Bun | | +| `BuildMessage` | Bun | A diagnostic in the `logs` of [`Bun.build`](/bundler). A file that Bun's parser refuses fails `import` and `require()` with a `SyntaxError` that has `position`, `notes` and `level`. | | [`clearImmediate()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearImmediate) | Web | | | [`clearInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearInterval) | Web | | | [`clearTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearTimeout) | Web | | diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index c02e1080e7a1..74b44cc355ca 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -1057,6 +1057,18 @@ declare class ResolveMessage { toString(): string; } +/** + * A diagnostic of Bun's bundler and transpiler. {@link Bun.build} lists them + * in `logs`. {@link Bun.Transpiler} throws one, or an `AggregateError` of + * several. + * + * A file that Bun's parser refuses fails `import`, `import()` and `require()` + * with a `SyntaxError`, not with a `BuildMessage`. That `SyntaxError` has + * three more properties, with the values of its first error: `position` + * ({@link Position}), `notes` (an array of `BuildMessage`) and `level` + * (`"error"`). The `SyntaxError` of a file that JavaScriptCore's parser + * refuses, or of a `.json` file, does not have them. + */ declare class BuildMessage { readonly name: "BuildMessage"; readonly position: Position | null; diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index b2c59696dd05..3ee237e713e5 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -123,6 +123,7 @@ using namespace JSC; macro(lazy) \ macro(lightStandardStreams) \ macro(lineText) \ + macro(loadDiagnostics) \ macro(loadEsmIntoCjs) \ macro(main) \ macro(makeAbortError) \ diff --git a/src/js/internal/repl/node-shims.ts b/src/js/internal/repl/node-shims.ts index 6a555dd3b083..69c565da6938 100644 --- a/src/js/internal/repl/node-shims.ts +++ b/src/js/internal/repl/node-shims.ts @@ -13,7 +13,9 @@ const { RegExpPrototypeSymbolReplace, RegExpPrototypeSymbolSplit, StringPrototypeIncludes, + StringPrototypeSlice, StringPrototypeSplit, + StringPrototypeStartsWith, } = require("internal/repl/node-primordials"); // ---- internal/util ---------------------------------------------------- @@ -35,6 +37,32 @@ function SideEffectFreeRegExpPrototypeSymbolSplit(regexp: RegExp, str: string, l return RegExpPrototypeSymbolSplit(regexp, str, limit); } +// The error of a file that Bun's parser refuses names the file in the one frame of its `stack`, and the REPL removes +// the frames of a SyntaxError. Node's decorateErrorStack() puts the location above the message: the file and line, +// the source line, a caret. +function parseErrorLocation(err: object): string { + try { + if ((err as { name?: unknown }).name !== "SyntaxError") return ""; + const position = (err as { position?: { file?: unknown; line?: unknown; column?: unknown; lineText?: unknown } }) + .position; + if (typeof position?.file !== "string" || typeof position.line !== "number") return ""; + const { lineText, column } = position; + // Like Node, no source line when it is excessively long. + if ( + typeof lineText !== "string" || + lineText.length > 1024 || + typeof column !== "number" || + !(column >= 1 && column <= lineText.length + 1) + ) { + return `${position.file}:${position.line}\n`; + } + const indent = RegExpPrototypeSymbolReplace(/[^\t]/g, StringPrototypeSlice(lineText, 0, column - 1), " "); + return `${position.file}:${position.line}\n${lineText}\n${indent}^\n\n`; + } catch { + return ""; + } +} + function decorateErrorStack(err: E) { // JSC materializes stacks eagerly so Node's overrideStackTrace never runs; // reproduce it by normalizing " (loc)" frames and cutting at the @@ -47,7 +75,9 @@ function decorateErrorStack(er if (RegExpPrototypeExec(/^\s+at REPL\d*:\d+:\d+$/, lines[i]) !== null) anonIdx = i; } if (anonIdx !== -1) lines = ArrayPrototypeSlice(lines, 0, anonIdx); - const newStack = ArrayPrototypeJoin(lines, "\n"); + let newStack = ArrayPrototypeJoin(lines, "\n"); + const location = parseErrorLocation(err); + if (location !== "" && !StringPrototypeStartsWith(newStack, location)) newStack = location + newStack; if (newStack !== err.stack) { // Errors with a non-writable .stack (Object.freeze, getter-only) must // not turn into a TypeError that escapes the REPL's error handler. diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index a71b5e47e5f7..20da3f507183 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -692,6 +692,10 @@ impl<'a> Run<'a> { // SAFETY: `vm()` is the per-thread VM; uniquely accessed here. let _ = unsafe { (*self.macro_.vm()).uncaught_exception(self.global, value, false) }; + // The error of a file that does not parse fails the macro, as a `BuildMessage` does below. + if BuildMessage::load_diagnostics(self.global, value).is_some() { + return Err(MacroError::MacroFailed); + } return Ok(self.caller); } T::Undefined => { @@ -895,6 +899,8 @@ impl<'a> Run<'a> { } let promise = value.as_any_promise().expect("Unexpected promise type"); + // A rejection is reported below, not by the rejection tracker. + promise.set_handled(self.global.vm()); let _ = self.macro_.vm(); let vm = VirtualMachine::get(); diff --git a/src/jsc/BuildMessage.rs b/src/jsc/BuildMessage.rs index e9403ea71796..f86d9802fbbc 100644 --- a/src/jsc/BuildMessage.rs +++ b/src/jsc/BuildMessage.rs @@ -26,7 +26,149 @@ pub struct BuildMessage { pub(crate) logged: Cell, } +unsafe extern "C" { + fn Bun__createLoaderSyntaxError( + global: &JSGlobalObject, + message: *const u8, + message_length: usize, + source_url: *const u8, + source_url_length: usize, + line: u32, + column: u32, + stack: *const u8, + stack_length: usize, + first: JSValue, + all: JSValue, + ) -> JSValue; + safe fn Bun__loaderSyntaxErrorDiagnostic( + global: &JSGlobalObject, + value: JSValue, + index: u32, + ) -> JSValue; +} + +/// The diagnostics that the module loader keeps on the `SyntaxError` it makes for a file that +/// Bun's parser refuses. The error owns them: a diagnostic is borrowed from this holder, which +/// keeps the error visible to the collector. +pub struct LoadDiagnostics<'a> { + global: &'a JSGlobalObject, + error: JSValue, +} + +impl LoadDiagnostics<'_> { + fn get(&self, index: u32) -> Option<&bun_ast::Msg> { + let cell = Bun__loaderSyntaxErrorDiagnostic(self.global, self.error, index); + if cell.is_empty() { + return None; + } + // The wrappers stay reachable from the error: it holds them under a private name that + // script can neither read nor replace. + match cell.as_class_ref::() { + Some(build) => Some(&build.msg), + None => cell + .as_class_ref::() + .map(|resolve| &resolve.msg), + } + } + + /// In log order. + pub fn iter(&self) -> impl Iterator + '_ { + (0u32..).map_while(|index| self.get(index)) + } +} + +impl Drop for LoadDiagnostics<'_> { + fn drop(&mut self) { + self.error.ensure_still_alive(); + } +} + impl BuildMessage { + /// The diagnostics of `value`, when it is the `SyntaxError` of [`Self::to_syntax_error`]. + pub fn load_diagnostics( + global: &JSGlobalObject, + value: JSValue, + ) -> Option> { + if !value.is_cell() || value.js_type() != crate::JSType::ErrorInstance { + return None; + } + let diagnostics = LoadDiagnostics { + global, + error: value, + }; + diagnostics.get(0)?; + Some(diagnostics) + } + + /// Whether the module loader reports a log whose first error is `msg` as one `SyntaxError`: + /// the parser refused the source text at a line of it. A file that is nested too deeply for + /// the parser's stack is not refused for its syntax. + pub(crate) fn is_syntax_error(msg: &bun_ast::Msg) -> bool { + !matches!(msg.metadata, bun_ast::Metadata::Resolve(_)) + && msg + .data + .location + .as_ref() + .is_some_and(|location| location.line > 0) + && &*msg.data.text != b"Maximum call stack size exceeded" + } + + /// The `SyntaxError` that the module loader rejects with for a file whose first error is this + /// diagnostic, which [`Self::is_syntax_error`] accepts. `this_value` is the wrapper of `self`. + /// `all` is `JSValue::ZERO`, or an array of the wrappers of the diagnostics that the error + /// keeps. + /// + /// The position is the parser's, in the original source: own 1-based `line` and `column`. + pub(crate) fn to_syntax_error( + &self, + global: &JSGlobalObject, + this_value: JSValue, + all: JSValue, + ) -> JsResult { + let text: &[u8] = &self.msg.data.text; + let location = self + .msg + .data + .location + .as_ref() + .expect("a diagnostic that is_syntax_error accepts has a location"); + let file: &[u8] = &location.file; + let line = location.line.max(0) as u32; + let column = location.column.max(0) as u32; + + // The one frame has the form that a parse error of JavaScriptCore gets from `require()`. + // The error printer reads that form back from a `stack` that was copied to another error. + let mut stack = Vec::with_capacity(text.len() + file.len() + 56); + stack.extend_from_slice(b"SyntaxError: "); + stack.extend_from_slice(text); + stack.extend_from_slice(b"\n at ("); + stack.extend_from_slice(file); + write!(&mut stack, ":{line}").expect("infallible: in-memory write"); + if column > 0 { + write!(&mut stack, ":{column}").expect("infallible: in-memory write"); + } + stack.push(b')'); + + crate::host_fn::from_js_host_call(global, || { + // SAFETY: C++ copies the three byte ranges before it returns. + unsafe { + Bun__createLoaderSyntaxError( + global, + text.as_ptr(), + text.len(), + file.as_ptr(), + file.len(), + line, + column, + stack.as_ptr(), + stack.len(), + this_value, + all, + ) + } + }) + } + pub fn get_notes(&self, cx: &mut JsScope<'_>) -> JsResult { let global = cx.global(); JSValue::create_array_from_iter(global, self.msg.notes.iter(), |note| { diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index beb383fefd1e..6f808d62b928 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3864,6 +3864,20 @@ pub mod formatter { return Ok(()); } + // The error of a file that Bun's parser refuses prints as a `BuildMessage` does: the + // text of its diagnostics. + if let Some(diagnostics) = + crate::BuildMessage::load_diagnostics(self.global_this, value) + { + for (i, msg) in diagnostics.iter().enumerate() { + if i > 0 { + let _ = writer_.write_all(b"\n\n"); + } + let _ = msg.write_format::(&mut bun_io::AsFmt::new(&mut *writer_)); + } + return Ok(()); + } + let mut adapter = DynWriteAdapter::new(&mut *writer_); // SAFETY: per-thread VM. let vm = VirtualMachine::get().as_mut(); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 9c0bb8f37c40..4cd7be9ae927 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3946,9 +3946,16 @@ impl VirtualMachine { } } -/// Synthesize a JS `BuildMessage` / `ResolveMessage` / `AggregateError` from -/// the parser `log` for a failed fetch, so the import promise rejects with a -/// real Error instead of `undefined`. +/// The value a failed fetch rejects with, made from the transpile `log`. +/// +/// A log whose first error is [`BuildMessage::is_syntax_error`] gives one +/// `SyntaxError`, which keeps the wrappers of the diagnostics for the +/// printers. Any other log gives a `BuildMessage`, a `ResolveMessage`, or an +/// `AggregateError` of them. +/// +/// [`BuildMessage::is_syntax_error`]: crate::BuildMessage::is_syntax_error +#[cold] +#[inline(never)] pub fn process_fetch_log( global_this: &JSGlobalObject, specifier: &bun_core::String, @@ -3958,6 +3965,9 @@ pub fn process_fetch_log( ) -> JSValue { use crate::{BuildMessage, ResolveMessage}; + /// How many diagnostics of one log reach JS. + const MAX_DIAGNOSTICS: usize = 256; + // Helper: on error, swap in the pending exception value. let take = |r: JsResult| -> JSValue { r.unwrap_or_else(|e| global_this.take_exception(e)) }; @@ -3966,16 +3976,55 @@ pub fn process_fetch_log( // we must convert to UTF-8 here. let referrer_utf8 = referrer.to_utf8(); - let msg_to_js = |msg: bun_ast::Msg| -> JSValue { - take(match msg.metadata { + let wrap = |msg: bun_ast::Msg| -> JsResult { + match msg.metadata { bun_ast::Metadata::Build | bun_ast::Metadata::TypeScript { .. } => { BuildMessage::create(global_this, msg) } bun_ast::Metadata::Resolve(_) => { ResolveMessage::create(global_this, &msg, referrer_utf8.slice()) } - }) + } }; + let msg_to_js = |msg: bun_ast::Msg| -> JSValue { take(wrap(msg)) }; + + // A warning can precede the first error. + let first_error = log + .msgs + .iter() + .position(|msg| msg.kind == bun_ast::Kind::Err); + if let Some(first) = + first_error.filter(|&first| BuildMessage::is_syntax_error(&log.msgs[first])) + { + let syntax_error = |cell: JSValue, all: JSValue| -> JsResult { + cell.as_class_ref::() + .expect("a Build diagnostic is wrapped in a BuildMessage") + .to_syntax_error(global_this, cell, all) + }; + return take(if log.msgs.len() == 1 { + wrap(log.msgs.swap_remove(0)).and_then(|cell| syntax_error(cell, JSValue::ZERO)) + } else { + crate::MarkedArgumentBuffer::new(|cells| { + // The first error and the entries around it, in log order. + let mut first_cell = JSValue::ZERO; + let mut others = 0; + for (i, msg) in log.msgs.drain(..).enumerate() { + if i != first { + if others == MAX_DIAGNOSTICS - 1 { + continue; + } + others += 1; + } + let cell = wrap(msg)?; + cells.append(cell); + if i == first { + first_cell = cell; + } + } + syntax_error(first_cell, cells.construct_array(global_this)?) + }) + }); + } match log.msgs.len() { 0 => { @@ -4024,7 +4073,8 @@ pub fn process_fetch_log( // to that scan, so a GC triggered by a later `create` could sweep // the earlier cells and free their native // `BuildMessage`/`ResolveMessage` out from under us. - let mut errors_stack: [JSValue; 256] = [JSValue::default(); 256]; + let mut errors_stack: [JSValue; MAX_DIAGNOSTICS] = + [JSValue::default(); MAX_DIAGNOSTICS]; let len = log.msgs.len().min(errors_stack.len()); for (i, msg) in log.msgs.drain(..len).enumerate() { errors_stack[i] = msg_to_js(msg); @@ -6811,6 +6861,24 @@ impl VirtualMachine { } } + // The module loader's `SyntaxError` for a file that Bun's parser refuses is reported as + // a `BuildMessage` is: its diagnostics, then the frames of the throw. + if let Some(diagnostics) = crate::BuildMessage::load_diagnostics(self.global(), value) { + let gap = self.had_errors; + self.print_load_diagnostics( + &diagnostics, + exception_list.is_some(), + writer, + allow_ansi_color, + gap, + ); + if allow_side_effects { + self.had_errors = true; + bun_core::Output::flush(); + } + return true; + } + if let Err(err) = self.print_error_instance_js( value, exception_list, @@ -6835,6 +6903,37 @@ impl VirtualMachine { false } + /// Writes the diagnostics of the module loader's `SyntaxError`, a blank line between two. + /// `gap` asks for one before the first. Unlike a `BuildMessage`, the error has no `logged` + /// latch: the registry can hand the same object to a later importer, whose report is not a + /// repeat. + fn print_load_diagnostics( + &mut self, + diagnostics: &crate::LoadDiagnostics<'_>, + add_to_log: bool, + writer: &mut bun_core::io::Writer, + allow_ansi_color: bool, + mut gap: bool, + ) { + for msg in diagnostics.iter() { + if gap { + let _ = writer.write_all(b"\n"); + } + gap = true; + let _ = if allow_ansi_color { + msg.write_format::(&mut bun_io::AsFmt::new(writer)) + } else { + msg.write_format::(&mut bun_io::AsFmt::new(writer)) + }; + let _ = writer.write_all(b"\n"); + if add_to_log { + if let Some(log) = self.log_mut() { + let _ = log.add_msg(msg.clone()); + } + } + } + } + /// Note: takes a runtime bool + concrete writer. pub(crate) fn print_stack_trace( writer: &mut bun_core::io::Writer, @@ -7941,6 +8040,16 @@ impl VirtualMachine { let over_cap = formatter.depth > formatter.error_chain_max_depth(); let result: crate::CrateResult<()> = if over_cap { pretty_write!(writer, "[Error ...]").map_err(Into::into) + } else if let Some(diagnostics) = crate::BuildMessage::load_diagnostics(global_ref, err) + { + self.print_load_diagnostics( + &diagnostics, + exception_list.is_some(), + writer, + allow_ansi_color, + false, + ); + Ok(()) } else { self.print_error_instance_js( err, diff --git a/src/jsc/bindings/LoaderSyntaxError.cpp b/src/jsc/bindings/LoaderSyntaxError.cpp new file mode 100644 index 000000000000..37e50af1769e --- /dev/null +++ b/src/jsc/bindings/LoaderSyntaxError.cpp @@ -0,0 +1,84 @@ +#include "root.h" + +#include +#include +#include +#include +#include +#include +#include "BunBuiltinNames.h" +#include "BunClientData.h" +#include "helpers.h" + +namespace Bun { + +using namespace JSC; + +// What the module loader keeps on the SyntaxError of a file that Bun's parser refuses: the BuildMessage of the one +// diagnostic, or an InternalFieldTuple of the first error's BuildMessage and an array of every diagnostic in log order. +static JSValue loadDiagnostics(VM& vm, JSValue value) +{ + auto* error = dynamicDowncast(value); + if (!error || error->errorType() != ErrorType::SyntaxError) + return {}; + return error->getDirect(vm, WebCore::builtinNames(vm).loadDiagnosticsPrivateName()); +} + +// `position`, `notes` and `level` of the SyntaxError are those of the first error's BuildMessage, made when first read. +JSC_DEFINE_CUSTOM_GETTER(loaderSyntaxErrorDiagnosticGetter, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName propertyName)) +{ + VM& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue diagnostics = loadDiagnostics(vm, JSValue::decode(thisValue)); + if (!diagnostics) + return JSValue::encode(jsUndefined()); + if (auto* tuple = dynamicDowncast(diagnostics)) + diagnostics = tuple->getInternalField(0); + RELEASE_AND_RETURN(scope, JSValue::encode(diagnostics.get(globalObject, propertyName))); +} + +// `first` is the BuildMessage of the diagnostic that the error reports. `all` is empty, or an array of the wrappers of +// every diagnostic of the log. +extern "C" EncodedJSValue Bun__createLoaderSyntaxError(JSGlobalObject* globalObject, const unsigned char* messagePtr, size_t messageLength, const unsigned char* sourceURLPtr, size_t sourceURLLength, uint32_t line, uint32_t column, const unsigned char* stackPtr, size_t stackLength, EncodedJSValue encodedFirst, EncodedJSValue encodedAll) +{ + VM& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + String message = messageLength ? Zig::convertUTF8ToString(std::span { messagePtr, messageLength }) : emptyString(); + String sourceURL = sourceURLLength ? Zig::convertUTF8ToString(std::span { sourceURLPtr, sourceURLLength }) : String(); + String stack = Zig::convertUTF8ToString(std::span { stackPtr, stackLength }); + if (message.isNull() || stack.isNull() || (sourceURLLength && sourceURL.isNull())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + + JSValue first = JSValue::decode(encodedFirst); + JSValue all = JSValue::decode(encodedAll); + auto* error = ErrorInstance::create(globalObject, WTF::move(message), ErrorType::SyntaxError, { line, column }, WTF::move(sourceURL), WTF::move(stack)); + JSValue diagnostics = all ? JSValue(InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), first, all)) : first; + error->putDirect(vm, WebCore::builtinNames(vm).loadDiagnosticsPrivateName(), diagnostics, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); + + // Not an accessor: each reads as a writable data property, and an assignment replaces it. + auto* getter = CustomGetterSetter::create(vm, loaderSyntaxErrorDiagnosticGetter, nullptr); + unsigned attributes = PropertyAttribute::DontEnum | PropertyAttribute::CustomValue; + error->putDirectCustomAccessor(vm, Identifier::fromString(vm, "position"_s), getter, attributes); + error->putDirectCustomAccessor(vm, Identifier::fromString(vm, "notes"_s), getter, attributes); + error->putDirectCustomAccessor(vm, Identifier::fromString(vm, "level"_s), getter, attributes); + RELEASE_AND_RETURN(scope, JSValue::encode(error)); +} + +// The wrapper of diagnostic `index` of a SyntaxError that Bun__createLoaderSyntaxError made. Empty past the last one, and +// for any other value. +extern "C" EncodedJSValue Bun__loaderSyntaxErrorDiagnostic(JSGlobalObject* globalObject, EncodedJSValue value, uint32_t index) +{ + JSValue diagnostics = loadDiagnostics(getVM(globalObject), JSValue::decode(value)); + if (!diagnostics) + return {}; + auto* tuple = dynamicDowncast(diagnostics); + if (!tuple) + return index ? EncodedJSValue {} : JSValue::encode(diagnostics); + auto* all = uncheckedDowncast(tuple->getInternalField(1)); + return all->canGetIndexQuickly(index) ? JSValue::encode(all->getIndexQuickly(index)) : EncodedJSValue {}; +} + +} diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index f4d9c145c917..862b87c6c1e1 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -1376,7 +1376,7 @@ where // ────────────────────────────────────────────────────────────────────────── #[path = "BuildMessage.rs"] pub mod build_message; -pub use self::build_message::BuildMessage; +pub use self::build_message::{BuildMessage, LoadDiagnostics}; #[path = "ResolveMessage.rs"] pub mod resolve_message; diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 7ecb04aa2fea..0b574b4372bb 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1493,9 +1493,8 @@ fn on_unhandled_rejection( .to_error() .unwrap_or(error_instance_or_exception); - // A parse failure rejects with a BuildMessage, which doesn't survive structured - // clone. Node reports a SyntaxError; build a real one from the formatted parse - // error so the subtype reaches the parent intact. + // A BuildMessage doesn't survive structured clone: the parent gets a SyntaxError + // with its text. A file that does not parse is a SyntaxError already. if let Some(bm) = error_instance.as_::() { // SAFETY: as_ returned a live BuildMessage cell, read-only on the // worker (JS) thread that owns it. diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 7f6f844ec866..61a86f8e2688 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2405,29 +2405,38 @@ impl TestFailure { } } - /// A module that can't be found or parsed throws one of these two. They are printed as log messages, with no `ZigException`. + /// A module that can't be found throws a `ResolveMessage`, one that can't be read a `BuildMessage`, and one that + /// can't be parsed a `SyntaxError` that holds its diagnostics. They are printed as log messages, with no `ZigException`. #[optimize(size)] - pub(crate) fn record_log_message(slot: &mut Option, thrown: jsc::JSValue) { - let (name, msg): (&[u8], _) = - if let Some(error) = thrown.as_class_ref::() { - (b"ResolveMessage", &error.msg) - } else if let Some(error) = thrown.as_class_ref::() { - (b"BuildMessage", &error.msg) - } else { - return; - }; - let failure = slot.get_or_insert_default(); - if failure.name.is_empty() { - failure.name.extend_from_slice(name); - } - if failure.message.is_empty() { - failure.message.extend_from_slice(&msg.data.text); - } - if !failure.body.is_empty() { + pub(crate) fn record_log_message( + slot: &mut Option, + global: &jsc::JSGlobalObject, + thrown: jsc::JSValue, + ) { + let mut record = |name: &[u8], msg: &bun_ast::Msg, is_the_error: bool| { + let failure = slot.get_or_insert_default(); + if failure.name.is_empty() { + failure.name.extend_from_slice(name); + } + if is_the_error && failure.message.is_empty() { + failure.message.extend_from_slice(&msg.data.text); + } + if !failure.body.is_empty() { + failure.body.push(b'\n'); + } + let _ = msg.write_format::(&mut bun_io::AsFmt::new(&mut failure.body)); failure.body.push(b'\n'); + }; + if let Some(error) = thrown.as_class_ref::() { + record(b"ResolveMessage", &error.msg, true); + } else if let Some(error) = thrown.as_class_ref::() { + record(b"BuildMessage", &error.msg, true); + } else if let Some(diagnostics) = jsc::BuildMessage::load_diagnostics(global, thrown) { + // The error's message is its first error's. A warning can precede it. + for msg in diagnostics.iter() { + record(b"SyntaxError", msg, msg.kind == bun_ast::Kind::Err); + } } - let _ = msg.write_format::(&mut bun_io::AsFmt::new(&mut failure.body)); - failure.body.push(b'\n'); } /// VirtualMachine::on_print_error_zig_exception thunk. diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index ece763c71f91..73ca989c6dec 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -1656,7 +1656,7 @@ impl BunTest { // SAFETY: `failure_ctx` is one of the two `Option` above, and the callback is done with it. let slot: &mut Option = unsafe { &mut *failure_ctx.cast() }; - crate::cli::test_command::TestFailure::record_log_message(slot, exception); + crate::cli::test_command::TestFailure::record_log_message(slot, global_this, exception); if let Some(failure) = slot && handle_status == HandleUncaughtExceptionResult::ShowHandledError { diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index c799f840e9f9..e611bf28843a 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -1382,6 +1382,21 @@ impl<'a> Formatter<'a> { } } Tag::Error => { + // The error of a file that Bun's parser refuses prints as a `BuildMessage` + // does below: the text of its diagnostics. + if let Some(diagnostics) = + crate::api::BuildMessage::load_diagnostics(self.global_this, value) + { + for (i, msg) in diagnostics.iter().enumerate() { + if i > 0 { + writer.write_all(b"\n\n"); + } + let mut bridge = AsFmt::new(&mut *writer.ctx); + let _ = msg.write_format::(&mut bridge); + } + return Ok(()); + } + let classname = value.get_class_name(self.global_this)?; let mut message_string = bun_core::String::EMPTY; diff --git a/test/aot/a-file-that-the-transpiler-refuses-loaded-in-a-try-stops-no-test-run.test.ts b/test/aot/a-file-that-the-transpiler-refuses-loaded-in-a-try-stops-no-test-run.test.ts index fe46e58e3720..ea2f1428cbc3 100644 --- a/test/aot/a-file-that-the-transpiler-refuses-loaded-in-a-try-stops-no-test-run.test.ts +++ b/test/aot/a-file-that-the-transpiler-refuses-loaded-in-a-try-stops-no-test-run.test.ts @@ -45,7 +45,7 @@ test.each(["", "TestRunLayers:0", "TestRunLayers:2,TestRunPartFiles:1"])( const folder = String(dir); const plain = await runTests(folder, []); expect(plain).toMatchObject({ counts: [` ${Object.keys(refused).length + 1} pass`, " 0 fail"], exitCode: 0 }); - expect(plain.printed).toContain("require mixes.cjs BuildMessage"); + expect(plain.printed).toContain("require mixes.cjs SyntaxError"); expect(await runTests(folder, ["--aot"], words)).toEqual(plain); }, 240_000, diff --git a/test/aot/a-file-with-a-mistake-that-only-a-computed-name-finds-stops-no-build.test.ts b/test/aot/a-file-with-a-mistake-that-only-a-computed-name-finds-stops-no-build.test.ts index 5232ca6a5c5e..017ee4708c7d 100644 --- a/test/aot/a-file-with-a-mistake-that-only-a-computed-name-finds-stops-no-build.test.ts +++ b/test/aot/a-file-with-a-mistake-that-only-a-computed-name-finds-stops-no-build.test.ts @@ -62,13 +62,13 @@ test("the program builds, and runs as under bun run", async () => { label => label + " threw SyntaxError: JSON Parse error: Unrecognized token '/'", ), ...["broken.js", "broken.js once more", "broken.js required"].map( - label => label + " threw BuildMessage: Unexpected ;", + label => label + " threw SyntaxError: Unexpected ;", ), ...["broken.ts", "broken.ts once more", "broken.ts required"].map( - label => label + " threw BuildMessage: Unexpected =", + label => label + " threw SyntaxError: Unexpected =", ), ...["broken.toml", "broken.toml once more", "broken.toml required"].map( - label => label + " threw BuildMessage: Unterminated array; expected ']'", + label => label + " threw SyntaxError: Unterminated array; expected ']'", ), "of a package threw SyntaxError: JSON Parse error: Unrecognized token '/'", "of a package, required threw SyntaxError: JSON Parse error: Unrecognized token '/'", diff --git a/test/aot/parallel/modules/error-imported-text-not-parse-lists.mjs b/test/aot/parallel/modules/error-imported-text-not-parse-lists.mjs index 20c9da3195c1..23d4253e4ee3 100644 --- a/test/aot/parallel/modules/error-imported-text-not-parse-lists.mjs +++ b/test/aot/parallel/modules/error-imported-text-not-parse-lists.mjs @@ -1,4 +1,4 @@ -// The text of a data: URL does not parse, and the loader rejects the import with an AggregateError. Its stack begins with the function that called import(). +// The text of a data: URL does not parse, and the loader rejects the import with a SyntaxError. Its stack has one frame: the position in the text, wherever import() was called. const show = where => error => console.log( where + ":", diff --git a/test/aot/require-of-a-file-that-an-import-has-loaded.test.ts b/test/aot/require-of-a-file-that-an-import-has-loaded.test.ts index 03fe57f866e3..40177d343d51 100644 --- a/test/aot/require-of-a-file-that-an-import-has-loaded.test.ts +++ b/test/aot/require-of-a-file-that-an-import-has-loaded.test.ts @@ -74,7 +74,7 @@ test("require() of a file that nothing names the kind of answers what an import using directory = tempDir("require-of-a-file-that-an-import-has-loaded", files); const plain = await seenIn(String(directory), undefined); expect(plain.seen).toContain(`SEEN w require behind the statement | {"default":"w1.png"}`); - expect(plain.seen).toContain(`SEEN w require alone | throws BuildMessage`); + expect(plain.seen).toContain(`SEEN w require alone | throws SyntaxError`); expect(plain.seen).toContain(`SEEN u require of code under an ending that nothing names | 7`); expect(plain.seen).toHaveLength(33); expect(await seenIn(String(directory), "TestRunLayers:0", "--aot")).toEqual(plain); diff --git a/test/aot/workers-from-texts-that-the-build-knows.test.ts b/test/aot/workers-from-texts-that-the-build-knows.test.ts index 87164a8939c4..f2fcb17b4227 100644 --- a/test/aot/workers-from-texts-that-the-build-knows.test.ts +++ b/test/aot/workers-from-texts-that-the-build-knows.test.ts @@ -16,7 +16,7 @@ const compiledOnly = { ...bunEnv, [runtimeEnvironment.forbidsInterpreter]: "1" } const listen = ` worker.onmessage = event => (console.log("message", event.data), worker.terminate()); - worker.onerror = event => (console.log("error", /^[A-Za-z]*Error: .*|Cannot find module/m.exec(event.message)?.[0]), event.preventDefault()); + worker.onerror = event => (console.log("error", /^[A-Za-z]*Error: .*|^error: (?!Cannot find module).*|Cannot find module/m.exec(event.message)?.[0]), event.preventDefault()); `; async function build(folder: string, ...entries: string[]) { @@ -274,8 +274,8 @@ new Worker(new URL("./second.ts", import.meta.url)).on("message", message => con ); test.each([ - ["a syntax error", "postMessage((", "TS1109", "SyntaxError: Unexpected end of file"], - ["words that are no program", "this is no program", "TS1005", "undefined"], + ["a syntax error", "postMessage((", "TS1109", "error: Unexpected end of file"], + ["words that are no program", "this is no program", "TS1005", 'error: Expected ";" but found "is"'], ["an import that is not found", `import "./no-such-file.js"; postMessage(1)`, "TS100150", "Cannot find module"], ])( "a text with %s is left out, and the build says so", diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 16a41fa0d250..35472e4b7a61 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -142,6 +142,44 @@ test("ireturnapromise", async () => { expect(await ireturnapromise()).toEqual("aaa"); }); +// The macro runner reports the error and fails the macro. For a rejection, the rejection tracker must not report it again. +test.concurrent.each([ + [ + "rejects with an Error", + `export async function load() {\n await Promise.resolve();\n throw new Error("macro rejected");\n}\n`, + ["error: macro rejected"], + ], + [ + "rejects with the SyntaxError of a file that does not parse", + `export async function load() {\n await import("./bad.js");\n}\n`, + ['error: The constant "x" must be initialized', 'error: The constant "y" must be initialized'], + ], + [ + "returns the SyntaxError of a file that does not parse", + `export function load() {\n try {\n import.meta.require("./bad.js");\n } catch (e) {\n return e;\n }\n}\n`, + ['error: The constant "x" must be initialized', 'error: The constant "y" must be initialized'], + ], +])("a macro that %s is reported once", async (_, macro, reported) => { + using dir = tempDir("macro-failure-report", { + "bad.js": `const x;\nconst y;\n`, + "macro.ts": macro, + "main.ts": `import { load } from "./macro.ts" with { type: "macro" };\nconsole.log(load());\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr.split("\n").filter(line => line.startsWith("error: "))).toEqual([ + ...reported, + "error: macro threw exception", + ]); + expect(exitCode).toBe(1); +}); + // This file used `assert` for its own imports until TypeScript 7 made that a syntax error (TS2880). test("the `assert { type: 'macro' }` spelling works for every form of import", async () => { using dir = tempDir("macro-import-assertion", { diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index b569ec4ac6e7..2c89c3beef0c 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -111,8 +111,8 @@ describe("transpiler cache", () => { } console.log(JSON.stringify(out));`, ); - const mixed = ["BuildMessage", "Cannot use import statement with CommonJS-only features"]; - const first = ["BuildMessage", 'Invalid identifier: "0foo"']; + const mixed = ["SyntaxError", "Cannot use import statement with CommonJS-only features"]; + const first = ["SyntaxError", 'Invalid identifier: "0foo"']; const expected = JSON.stringify({ "import ./mixed.js": mixed, "require ./mixed.js": mixed, diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 9cddf1b4e3bf..70c58f63ab41 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -525,11 +525,15 @@ describe("errors", () => { }); it("async transpiler errors work", async () => { - expect(async () => { - globalThis.asyncOnLoad = `const x: string = -NaNAn../!!;`; - await import("async:fail"); - throw -1; - }).toThrow('4 errors building "async:fail"'); + globalThis.asyncOnLoad = `const x: string = -NaNAn../!!;`; + const error = await import("async:fail").then( + () => -1, + error => error, + ); + expect(error).toBeInstanceOf(SyntaxError); + // The first of the four. + expect(error.message).toBe('Expected ";" but found ":"'); + expect(Bun.inspect(error).match(/^error: /gm)).toHaveLength(4); }); it("onLoad returns the rejected promise", async () => { @@ -2857,13 +2861,78 @@ it.concurrent( stderr: stderr.replaceAll(String(dir), "").replaceAll("\\", "/").split("\n\nBun v")[0], exitCode, }).toEqual({ - stdout: "BuildMessage Unexpected ; 1 17\n", + stdout: "SyntaxError Unexpected ; 1 17\n", stderr: `1 | export default (;\n ^\nerror: Unexpected ;\n at /syntax-error.ts:1:17`, exitCode: 1, }); }, ); +// JavaScriptCore ties the error of a failed load to the module that failed. When an async onLoad callback rejected +// with such an error of another module, JavaScriptCore kept the error on a registry entry for the plugin's module +// that nothing fetches, and a static importer of the plugin's module waited for that entry forever. A rejected fetch +// registers nothing: each later load fetches the module again. +it.concurrent( + "a module whose onLoad callback rejects with the load error of another module is loaded again by each importer", + async () => { + using dir = tempDir("plugin-foreign-load-error", { + "bad-1.js": `const x;\n`, + "bad-2.js": `const x;\nconst y;\n`, + "bad-3.js": `throw new Error("evaluation failed");\n`, + ...Object.fromEntries( + [1, 2, 3].flatMap(n => [ + [`imports-bad-${n}.js`, `import "./bad-${n}.js";\n`], + [`plugin-${n}.virtual`, ``], + [`imports-plugin-${n}.js`, `import "./plugin-${n}.virtual";\n`], + ]), + ), + "entry.js": ` + const calls = { 1: 0, 2: 0, 3: 0 }; + Bun.plugin({ + name: "plugin", + setup(build) { + build.onLoad({ filter: /\\.virtual$/ }, async ({ path }) => { + const n = /plugin-(\\d)\\.virtual$/.exec(path)[1]; + calls[n]++; + await import("./imports-bad-" + n + ".js"); + return { contents: "", loader: "js" }; + }); + }, + }); + const out = {}; + for (const n of [1, 2, 3]) { + const loads = []; + for (const specifier of ["./plugin-N.virtual", "./plugin-N.virtual", "./imports-plugin-N.js"]) { + loads.push(await import(specifier.replace("N", n)).then(() => "loaded", error => error.name + ": " + error.message)); + } + out[n] = { loads, calls: calls[n] }; + } + console.log(JSON.stringify(out)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const refused = 'SyntaxError: The constant "x" must be initialized'; + const threw = "Error: evaluation failed"; + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: + JSON.stringify({ + 1: { loads: [refused, refused, refused], calls: 3 }, + 2: { loads: [refused, refused, refused], calls: 3 }, + 3: { loads: [threw, threw, threw], calls: 3 }, + }) + "\n", + stderr: "", + exitCode: 0, + }); + }, +); + describe.concurrent("`contents` from a plugin is loaded like a file with the same contents", () => { const object = { a: 1, b: { c: 2 } }; // [loader, file extension, contents, import() result, require() result] @@ -2947,8 +3016,8 @@ describe.concurrent("`contents` from a plugin is loaded like a file with the sam "toml", "toml", `\na = = 1`, - "BuildMessage: Expected a value but found '=' at TOML-that-is-not-valid.toml:2:5", - "BuildMessage: Expected a value but found '=' at TOML-that-is-not-valid.toml:2:5", + "SyntaxError: Expected a value but found '=' at TOML-that-is-not-valid.toml:2:5", + "SyntaxError: Expected a value but found '=' at TOML-that-is-not-valid.toml:2:5", ], "YAML": ["yaml", "yaml", `a: 1\nb:\n c: 2\n`, { ...object, default: object }, object], "a YAML sequence": ["yaml", "yaml", `- 1\n- 2\n`, { __esModule: true, default: [1, 2] }, [1, 2]], @@ -3231,7 +3300,7 @@ describe.concurrent("`contents` from a plugin is loaded like a file with the sam ); const files = { ...extra, "plugin.ts": plugin, "entry.ts": entry }; expect(await run(files, ["--preload", "./plugin.ts", "entry.ts"])).toEqual({ - stdout: JSON.stringify([{ a: 1 }, '{"a":1}', 1, 1, '{"a":1}', 1, "BuildMessage"]) + "\n", + stdout: JSON.stringify([{ a: 1 }, '{"a":1}', 1, 1, '{"a":1}', 1, "SyntaxError"]) + "\n", stderr: "", exitCode: 0, }); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 6f3ecb50cd83..afb02cf4fb50 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -2157,6 +2157,33 @@ describe.concurrent("--interactive", () => { }, interactiveTimeout, ); + + // The REPL removes the frames of a SyntaxError, and the one frame of this error is what names + // the file. Node prints the location above the message. + test( + "a file that does not parse is reported with its location, the source line and a caret", + async () => { + using dir = tempDir("repl-syntax-error", { + "config.js": `module.exports = {\n a: 1\n b: 2,\n};\n`, + "bad.mjs": `const x;\n`, + }); + const { stdout, stderr, exitCode } = await runInteractive( + [], + `require("./config.js")\nawait import("./bad.mjs")\n`, + { cwd: String(dir) }, + ); + const at = (file: string) => path.join(String(dir), file); + expect(stdout).toContain( + `> ${at("config.js")}:3\n b: 2,\n ^\n\nUncaught SyntaxError: Expected "}" but found "b"\n`, + ); + expect(stdout).toContain( + `${at("bad.mjs")}:1\nconst x;\n ^\n\nUncaught SyntaxError: The constant "x" must be initialized\n`, + ); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }, + interactiveTimeout, + ); }); // ts-node does `require("repl")` at import time but only touches diff --git a/test/js/bun/resolve/build-error.test.ts b/test/js/bun/resolve/build-error.test.ts index 693efe66b363..9455cf485a4b 100644 --- a/test/js/bun/resolve/build-error.test.ts +++ b/test/js/bun/resolve/build-error.test.ts @@ -1,50 +1,110 @@ import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "node:path"; -test("BuildError is modifiable", async () => { +test("the error of a file that does not parse is modifiable", async () => { + let error: any; try { await import("../util/inspect-error-fixture-bad.js" as string); expect.unreachable(); } catch (e) { - var error: BuildMessage = e as BuildMessage; - if (error.name !== "BuildMessage") { - throw new Error("Expected BuildMessage, got " + error.name); + error = e; + } + expect(error.name).toBe("SyntaxError"); + + const message = error.message; + expect(() => (error.message = "new message")).not.toThrow(); + expect(error.message).toBe("new message"); + expect(error.message).not.toBe(message); + + // Each reads as a writable data property that holds the value of the first error's BuildMessage. + for (const key of ["position", "notes", "level"]) { + const descriptor = Object.getOwnPropertyDescriptor(error, key)!; + expect(descriptor).toEqual({ value: error[key], writable: true, enumerable: false, configurable: true }); + error[key] = "replaced"; + expect(error[key]).toBe("replaced"); + } +}); + +// `toBe` prints the received value as the console does, `toEqual` as a snapshot does. +test("a failed matcher prints the error of a file that does not parse with its code frame", async () => { + const error = await import("../util/inspect-error-fixture-bad.js" as string).then( + () => "loaded", + e => e, + ); + expect(error.name).toBe("SyntaxError"); + for (const check of [() => expect(error).toBe(1), () => expect(error).toEqual(1)]) { + let failure = "the matcher passed"; + try { + check(); + } catch (e) { + failure = Bun.stripANSI((e as Error).message).replaceAll("\\", "/"); } + expect(failure).toContain("2 | const duplicateConstDecl = 456;"); + expect(failure).toContain('error: "duplicateConstDecl" has already been declared'); + expect(failure).toContain("/inspect-error-fixture-bad.js:2:7"); + } +}); + +test("a BuildMessage is modifiable and has a JSON form", () => { + let error: any; + try { + new Bun.Transpiler().transformSync("const x;"); + expect.unreachable(); + } catch (e) { + error = e; } + expect(error).toBeInstanceOf(BuildMessage); + expect(error.toJSON()).toEqual({ + name: "BuildMessage", + position: { + lineText: "const x;", + file: "input.jsx", + namespace: "file", + line: 1, + column: 7, + length: 1, + offset: 6, + }, + message: 'The constant "x" must be initialized', + level: "error", + }); - const message = error!.message; - // @ts-ignore - expect(() => (error!.message = "new message")).not.toThrow(); - expect(error!.message).toBe("new message"); - expect(error!.message).not.toBe(message); + expect(() => (error.message = "new message")).not.toThrow(); + expect(error.message).toBe("new message"); }); -test("import with many build errors keeps AggregateError entries alive across GC", async () => { - // process_fetch_log accumulated the BuildMessage wrappers in a heap Vec - // while creating the next ones; the conservative GC stack scan never saw - // them, so a collection triggered mid-loop swept the earlier cells and - // freed their native BuildMessage (use-after-free found by fuzzing). - // 257 duplicate declarations produce 256 log messages, maximizing the - // number of allocations between the first wrapper and the AggregateError. - const dupes = Array.from({ length: 257 }, (_, i) => `const dup = ${i};`).join("\n"); +// process_fetch_log accumulated the BuildMessage wrappers in a heap Vec while +// creating the next ones; the conservative GC stack scan never saw them, so a +// collection triggered mid-loop swept the earlier cells and freed their native +// BuildMessage (use-after-free found by fuzzing). Each input gives more log +// messages than the 256 that reach JS, maximizing the number of allocations +// between the first wrapper and the error that keeps them. +test.each([ + { + keeper: "the SyntaxError of a file that does not parse", + // 301 duplicate declarations produce 300 log messages. + bad: Array.from({ length: 301 }, (_, i) => `const dup = ${i};`).join("\n"), + // The printer reads every wrapper. + count: `e => Bun.inspect(e).split('error: "dup" has already been declared').length - 1`, + }, + { + keeper: "the AggregateError of a file whose first error is a resolve error", + // Each call of the macro that is not found produces two log messages. + bad: + `import { nope } from "./does-not-exist" with { type: "macro" };\n` + + Array.from({ length: 200 }, (_, i) => `nope(${i});`).join("\n"), + count: `e => e.errors.filter(error => typeof error.message === "string").length`, + }, +])("import with many build errors keeps every diagnostic alive across GC: $keeper", async ({ bad, count }) => { using dir = tempDir("build-error-gc", { - "bad.js": dupes, + "bad.js": bad, "main.js": ` const jobs = []; for (let i = 0; i < 16; i++) { jobs.push( - import("./bad.js?v=" + i).then( - () => { - throw new Error("expected rejection"); - }, - e => { - let n = 0; - for (const err of e.errors ?? []) { - if (typeof err.message === "string") n++; - } - return n; - }, - ), + import("./bad.js?v=" + i).then(() => { + throw new Error("expected rejection"); + }, ${count}), ); } const counts = await Promise.all(jobs); @@ -109,7 +169,7 @@ test.concurrent( const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); if (exitCode !== 0) console.error(stderr); - const expected = ["BuildMessage", "Cannot use import statement with CommonJS-only features"]; + const expected = ["SyntaxError", "Cannot use import statement with CommonJS-only features"]; expect(JSON.parse(stdout)).toEqual({ import: expected, require: expected }); expect(exitCode).toBe(0); }, @@ -286,7 +346,7 @@ test.concurrent("a lexer error on the first token rejects import() and require() const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - const expected = ["BuildMessage", 'Invalid identifier: "0foo"']; + const expected = ["SyntaxError", 'Invalid identifier: "0foo"']; expect(JSON.parse(stdout)).toEqual({ "import ./bad.js": expected, "require ./bad.js": expected, @@ -320,7 +380,7 @@ test("BuildMessage finalize frees with the same allocator it was created with", // A virtual source's path is a view of the UTF-8 copy of the specifier that the // native fetch makes when the specifier is not 8-bit ASCII, and frees when it // returns. The location of a YAML or JSON5 error used to point into that copy. -test.concurrent("a BuildMessage's position.file outlives the fetch that made it", async () => { +test.concurrent("the position.file of a parse error outlives the fetch that made it", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -348,7 +408,7 @@ test.concurrent("a BuildMessage's position.file outlives the fetch that made it" } catch (e) { error = e; } - const files = [error?.position.file, error?.toJSON().position.file]; + const files = [error?.position.file, error?.notes.length, error?.sourceURL]; console.log(label, error?.name, JSON.stringify(files.map(file => (file === specifier ? "the specifier" : file)))); } @@ -370,8 +430,451 @@ test.concurrent("a BuildMessage's position.file outlives the fetch that made it" if (exitCode !== 0) console.error(stderr); expect(stdout.trim().split("\n")).toEqual( ["blob", "node fallback", "node fallback import", "node fallback require", "plugin"].map( - label => `${label} BuildMessage ["the specifier","the specifier"]`, + label => `${label} SyntaxError ["the specifier",0,"the specifier"]`, ), ); expect(exitCode).toBe(0); }); + +// When Bun's own parser refuses a file, import() rejects with a SyntaxError and require() throws +// one, as in Node and as when JavaScriptCore's parser is the one that refuses the file. It used to +// be a BuildMessage, or an AggregateError of them for a file with several diagnostics. +describe.concurrent("a file that Bun's parser refuses fails to load with one SyntaxError", () => { + // Paths read "/file" on every platform. + const shape = /* js */ ` + const path = text => text.replaceAll(import.meta.dir, "").replaceAll("\\\\", "/"); + const shape = e => ({ + constructor: e.constructor.name, + name: e.name, + prototype: Object.getPrototypeOf(e) === SyntaxError.prototype, + isError: Error.isError(e), + isNativeError: require("node:util").types.isNativeError(e), + tag: Object.prototype.toString.call(e), + message: e.message, + stack: path(e.stack), + line: e.line, + column: e.column, + sourceURL: path(e.sourceURL), + position: e.position && { ...e.position, file: path(e.position.file) }, + level: e.level, + notes: e.notes?.map(note => note.constructor.name + ": " + note.message), + ownKeys: Reflect.ownKeys(e).sort(), + enumerable: Object.keys(e), + hasErrors: "errors" in e, + }); + `; + const syntaxError = { + constructor: "SyntaxError", + name: "SyntaxError", + prototype: true, + isError: true, + isNativeError: true, + tag: "[object Error]", + level: "error", + ownKeys: ["column", "level", "line", "message", "notes", "position", "sourceURL", "stack"], + enumerable: [], + hasErrors: false, + }; + const constX = { + ...syntaxError, + message: 'The constant "x" must be initialized', + line: 1, + column: 7, + notes: [], + }; + const frame = (file: string, text: string, line: number, name: string) => + `${line} | ${text}\n ^\nerror: The constant "${name}" must be initialized\n at /${file}:${line}:7`; + + async function run(files: Record, cmd = ["main.js"]) { + using dir = tempDir("build-error-syntax-error", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + stdout, + stderr: stderr.replaceAll(String(dir), "").replaceAll("\\", "/").split("\n\nBun v")[0], + exitCode, + }; + } + + test("import() and require() of a file with one diagnostic", async () => { + const { stdout, stderr, exitCode } = await run({ + "bad.js": `const x;\n`, + "main.js": /* js */ ` + ${shape} + const out = {}; + out.import = await import("./bad.js").then(() => "loaded", shape); + try { + require("./bad.js"); + out.require = "loaded"; + } catch (e) { + out.require = shape(e); + } + console.log(JSON.stringify(out)); + `, + }); + expect(stderr).toBe(""); + const expected = { + ...constX, + stack: 'SyntaxError: The constant "x" must be initialized\n at (/bad.js:1:7)', + sourceURL: "/bad.js", + position: { + lineText: "const x;", + file: "/bad.js", + namespace: "file", + line: 1, + column: 7, + length: 1, + offset: 6, + }, + }; + expect(JSON.parse(stdout)).toEqual({ import: expected, require: expected }); + expect(exitCode).toBe(0); + }); + + test("a file with several diagnostics gives the first error, and every diagnostic is printed", async () => { + const files = { + "two.js": `const x;\nconst y;\n`, + "static.js": `import "./two.js";\n`, + "main.js": /* js */ ` + ${shape} + const out = {}; + out.import = await import("./two.js").then(() => "loaded", e => ({ ...shape(e), inspected: path(Bun.inspect(e)) })); + try { + require("./two.js"); + out.require = "loaded"; + } catch (e) { + out.require = { ...shape(e), inspected: path(Bun.inspect(e)) }; + } + console.log(JSON.stringify(out)); + `, + }; + const blocks = frame("two.js", "const x;", 1, "x") + "\n\n" + frame("two.js", "const y;", 2, "y"); + const expected = { + ...constX, + stack: 'SyntaxError: The constant "x" must be initialized\n at (/two.js:1:7)', + sourceURL: "/two.js", + position: { + lineText: "const x;", + file: "/two.js", + namespace: "file", + line: 1, + column: 7, + length: 1, + offset: 6, + }, + inspected: blocks, + }; + const [caught, entryPoint, staticImport] = await Promise.all([ + run(files), + run(files, ["two.js"]), + run(files, ["static.js"]), + ]); + expect(caught.stderr).toBe(""); + expect(JSON.parse(caught.stdout)).toEqual({ import: expected, require: expected }); + expect(caught.exitCode).toBe(0); + expect(entryPoint).toEqual({ stdout: "", stderr: blocks, exitCode: 1 }); + expect(staticImport).toEqual({ stdout: "", stderr: blocks, exitCode: 1 }); + }); + + test("the notes of the first error, and a file that a data loader refuses", async () => { + const { stdout, stderr, exitCode } = await run({ + "redeclared.js": `const a = 1;\nconst a = 2;\n`, + "bad.toml": `a = [1, 2\n`, + "main.js": /* js */ ` + ${shape} + const out = {}; + for (const file of ["./redeclared.js", "./bad.toml"]) { + const { message, stack, line, column, notes, position } = await import(file).then(() => "loaded", shape); + out[file] = { message, stack, line, column, notes, lineText: position.lineText }; + } + console.log(JSON.stringify(out)); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "./redeclared.js": { + message: '"a" has already been declared', + stack: 'SyntaxError: "a" has already been declared\n at (/redeclared.js:2:7)', + line: 2, + column: 7, + notes: ['BuildMessage: "a" was originally declared here'], + lineText: "const a = 2;", + }, + "./bad.toml": { + message: "Expected ',' or ']' in an array but found end of file", + stack: + "SyntaxError: Expected ',' or ']' in an array but found end of file\n at (/bad.toml:1:10)", + line: 1, + column: 10, + notes: [], + lineText: "a = [1, 2", + }, + }); + expect(exitCode).toBe(0); + }); + + test("a warning before the first error", async () => { + const { stdout, stderr, exitCode } = await run({ + // The parser warns about the legacy HTML comment on line 2. + "warned.js": `x = 1;\n--> comment\nconst y;\n`, + "main.js": /* js */ ` + ${shape} + const error = await import("./warned.js").then(() => "loaded", e => e); + const { constructor, message, stack, line, column, level, position } = shape(error); + const printed = Bun.inspect(error).split("\\n").filter(line => /^(warn|error): /.test(line)); + console.log(JSON.stringify({ constructor, message, stack, line, column, level, lineText: position.lineText, printed })); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + constructor: "SyntaxError", + message: 'The constant "y" must be initialized', + stack: 'SyntaxError: The constant "y" must be initialized\n at (/warned.js:3:7)', + line: 3, + column: 7, + level: "error", + lineText: "const y;", + printed: [ + 'warn: Treating "-->" as the start of a legacy HTML single-line comment', + 'error: The constant "y" must be initialized', + ], + }); + expect(exitCode).toBe(0); + }); + + // A diagnostic without a line does not say where the parser refused the text. A file that is + // nested too deeply is not refused for its syntax: JavaScriptCore and Node report a RangeError. + test("a diagnostic without a line, and a file that is nested too deeply, stay a BuildMessage", async () => { + const depth = 30_000; + const { stdout, stderr, exitCode } = await run({ + // The position of an error in a transcoded document is not a position in the file. + "no-line.xml": Buffer.from(`\n\xE9\n`, "latin1"), + "deep.js": `export const count = ${Buffer.alloc(depth, "(").toString()}1${Buffer.alloc(depth, ")").toString()};\n`, + "main.js": /* js */ ` + const out = {}; + for (const file of ["./no-line.xml", "./deep.js"]) { + const error = await import(file).then(() => "loaded", e => e); + out[file] = [error.constructor.name, Error.isError(error), error.message, error.position.line > 0]; + } + console.log(JSON.stringify(out)); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "./no-line.xml": ["BuildMessage", false, "Expected closing tag but found ", false], + "./deep.js": ["BuildMessage", false, "Maximum call stack size exceeded", true], + }); + expect(exitCode).toBe(0); + }); + + test("structuredClone() copies it as any SyntaxError", async () => { + const { stdout, stderr, exitCode } = await run({ + "two.js": `const x;\nconst y;\n`, + "main.js": /* js */ ` + ${shape} + const error = await import("./two.js").then(() => "loaded", e => e); + const { constructor, message, stack, line, column, sourceURL, ownKeys } = shape(structuredClone(error)); + console.log(JSON.stringify({ constructor, message, stack, line, column, sourceURL, ownKeys })); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + constructor: "SyntaxError", + message: 'The constant "x" must be initialized', + stack: 'SyntaxError: The constant "x" must be initialized\n at (/two.js:1:7)', + line: 1, + column: 7, + sourceURL: "/two.js", + ownKeys: ["column", "line", "message", "sourceURL", "stack"], + }); + expect(exitCode).toBe(0); + }); + + test("every way to load the file", async () => { + const { stdout, stderr, exitCode } = await run({ + "bad.js": `const x;\n`, + "bad.cjs": `const x;\n`, + "bad.mjs": `export const a = 1;\nconst x;\n`, + "bad.ts": `const x: number;\nexport {};\n`, + "imports-bad.mjs": `import "./bad.mjs";\n`, + "from-plugin.virtual": ``, + "main.js": /* js */ ` + import Module from "node:module"; + import { join } from "node:path"; + Bun.plugin({ + name: "bad contents", + setup(build) { + build.onLoad({ filter: /\\.virtual$/ }, () => ({ contents: "const x;", loader: "js" })); + build.module("virtual:bad", () => ({ contents: "const x;", loader: "js" })); + }, + }); + const ways = { + "import()": () => import("./bad.js"), + "import() of a TypeScript file": () => import("./bad.ts"), + "require()": () => require("./bad.cjs"), + "require() of an ES module": () => require("./bad.mjs"), + "require() of a module that imports it": () => require("./imports-bad.mjs"), + "import.meta.require()": () => import.meta.require("./bad.js"), + "Module._extensions": () => Module._extensions[".js"](new Module("bad"), join(import.meta.dir, "bad.cjs")), + "a data: URL": () => import("data:text/javascript,const x;"), + "a blob: URL": () => import(URL.createObjectURL(new Blob(["const x;"], { type: "text/javascript" }))), + "contents from an onLoad plugin": () => import("./from-plugin.virtual"), + "contents from build.module()": () => import("virtual:bad"), + }; + const out = {}; + for (const [way, load] of Object.entries(ways)) { + try { + await load(); + out[way] = "loaded"; + } catch (e) { + out[way] = [e.constructor.name, Error.isError(e), e.message, e.position.line, typeof e.stack]; + } + } + console.log(JSON.stringify(out)); + `, + }); + expect(stderr).toBe(""); + const syntaxError = ["SyntaxError", true, 'The constant "x" must be initialized', 1, "string"]; + expect(JSON.parse(stdout)).toEqual({ + "import()": syntaxError, + "import() of a TypeScript file": syntaxError, + "require()": syntaxError, + "require() of an ES module": [...syntaxError.slice(0, 3), 2, "string"], + "require() of a module that imports it": [...syntaxError.slice(0, 3), 2, "string"], + "import.meta.require()": syntaxError, + "Module._extensions": syntaxError, + "a data: URL": syntaxError, + "a blob: URL": syntaxError, + "contents from an onLoad plugin": syntaxError, + "contents from build.module()": syntaxError, + }); + expect(exitCode).toBe(0); + }); + + // The module registry keeps the error of a module whose fetch failed, and settles with it every + // module that imports that module afterwards. Each of them gets the error itself, not a copy + // that has the message alone. + test("modules that import the file get the error that the registry keeps", async () => { + const { stdout, stderr, exitCode } = await run({ + "one.js": `const x;\n`, + "two.js": `const x;\nconst y;\n`, + ...Object.fromEntries( + ["one", "two"].flatMap(file => + Array.from({ length: 8 }, (_, i) => [`${file}-importer-${i}.js`, `import "./${file}.js";\n`]), + ), + ), + "main.js": /* js */ ` + ${shape} + const out = {}; + for (const file of ["one", "two"]) { + const importer = i => import("./" + file + "-importer-" + i + ".js").then(() => "loaded", e => e); + // One after the other, then at once. + const errors = [await importer(0), await importer(1), ...(await Promise.all([2, 3, 4, 5, 6, 7].map(importer)))]; + out[file] = { + same: errors.map(error => error === errors[0]), + stack: [...new Set(errors.map(error => path(String(error.stack))))], + inspected: new Set(errors.map(error => Bun.inspect(error))).size, + }; + } + console.log(JSON.stringify(out)); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + one: { + same: Array.from({ length: 8 }, () => true), + stack: ['SyntaxError: The constant "x" must be initialized\n at (/one.js:1:7)'], + inspected: 1, + }, + two: { + same: Array.from({ length: 8 }, () => true), + stack: ['SyntaxError: The constant "x" must be initialized\n at (/two.js:1:7)'], + inspected: 1, + }, + }); + expect(exitCode).toBe(0); + }); + + // import() and require() of the file itself read it again, and drop the error that the registry + // kept: each gets an error of its own. + test("each import() and require() of the file gets a new error", async () => { + const { stdout, stderr, exitCode } = await run({ + "bad.js": `const x;\n`, + "importer-0.js": `import "./bad.js";\n`, + "importer-1.js": `import "./bad.js";\n`, + "main.js": /* js */ ` + const imported = specifier => import(specifier).then(() => "loaded", e => e); + const required = () => { + try { + require("./bad.js"); + return "loaded"; + } catch (e) { + return e; + } + }; + const importer = await imported("./importer-0.js"); + const direct = [await imported("./bad.js"), await imported("./bad.js"), required(), required()]; + const laterImporter = await imported("./importer-1.js"); + const errors = [importer, ...direct, laterImporter]; + console.log(JSON.stringify({ + names: [...new Set(errors.map(error => error.constructor.name))], + distinct: new Set(errors).size, + theFirstImporterKeepsItsError: (await imported("./importer-0.js")) === importer, + })); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ names: ["SyntaxError"], distinct: 6, theFirstImporterKeepsItsError: true }); + expect(exitCode).toBe(0); + }); + + // The printers of "bun test" and of the console read the diagnostics that the error keeps. + test("a resolve error after the first error is printed with it", async () => { + const { stdout, stderr, exitCode } = await run({ + "mixed.js": `import { nope } from "./does-not-exist" with { type: "macro" };\nconst a = 1;\na = 2;\nnope();\n`, + "main.js": /* js */ ` + const error = await import("./mixed.js").then(() => "loaded", e => e); + const printed = Bun.inspect(error).split("\\n").filter(line => /^(note|error): /.test(line)); + console.log(JSON.stringify({ name: error.constructor.name, message: error.message, line: error.line, printed })); + `, + }); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + name: "SyntaxError", + message: 'Cannot assign to "a" because it is a constant', + line: 3, + printed: [ + 'error: Cannot assign to "a" because it is a constant', + 'note: The symbol "a" was declared a constant here:', + 'error: Macro "./does-not-exist" not found', + 'error: "MacroNotFound" error in macro', + ], + }); + expect(exitCode).toBe(0); + }); + + // mocha replaces the error of a module that a test file imports with a copy like this one. + test("an error that takes the stack is printed with the file's code frame", async () => { + const { stdout, stderr, exitCode } = await run({ + "bad.js": `const x;\n`, + "main.js": /* js */ ` + ${shape} + const error = await import("./bad.js").then(() => "loaded", e => e); + const copy = new SyntaxError(error.message); + copy.stack = error.stack; + console.log(path(Bun.inspect(copy))); + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe( + '1 | const x;\n ^\nSyntaxError: The constant "x" must be initialized\n at (/bad.js:1:7)\n\n', + ); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/resolve/xml/xml.test.js b/test/js/bun/resolve/xml/xml.test.js index 87c3ba9636b5..16a2a249a143 100644 --- a/test/js/bun/resolve/xml/xml.test.js +++ b/test/js/bun/resolve/xml/xml.test.js @@ -61,19 +61,19 @@ it("files in the encodings XML requires are decoded before parsing", async () => expect((await import("./xml-latin1.xml")).default).toEqual({ doc: { "@lang": "français", "#text": "café" } }); }); -it("a malformed file is a build error with the parser's message and location", async () => { +it("a malformed file is a SyntaxError with the parser's message and location", async () => { let error; try { await import("./xml-malformed.xml"); } catch (e) { error = e; } - expect(error?.name).toBe("BuildMessage"); + expect(error?.name).toBe("SyntaxError"); expect(error.message).toBe("Expected closing tag but found "); expect(error.position).toMatchObject({ line: 3, column: 13, lineText: " 8080" }); }); -it("a document nested too deeply to parse is a build error, not a crash or a missing module", async () => { +it("a document nested too deeply to parse is a SyntaxError, not a crash or a missing module", async () => { // Deep enough to overflow any native stack, whatever the frame size. const depth = 2_000_000; using dir = tempDir("xml-deep", { @@ -85,6 +85,6 @@ it("a document nested too deeply to parse is a build error, not a crash or a mis } catch (e) { error = e; } - expect(error?.name).toBe("BuildMessage"); + expect(error?.name).toBe("SyntaxError"); expect(error.message).toBe("Nesting is too deep"); }); diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 5020cdb87ca6..effd26660427 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -51,16 +51,16 @@ error: my message `); }); -test("BuildMessage", async () => { +test("the SyntaxError of a file that does not parse", async () => { try { await import("./inspect-error-fixture-bad.js"); expect.unreachable(); } catch (e) { - expect( - Bun.inspect(e) - .replaceAll("\\", "/") - .replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]"), - ).toMatchInlineSnapshot(` + // The same text every time, not only the first. + const text = Bun.inspect(e); + expect([Bun.inspect(e), Bun.inspect({ e })]).toEqual([text, "{\n e: " + text + ",\n}"]); + expect(text.replaceAll("\\", "/").replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]")) + .toMatchInlineSnapshot(` "2 | const duplicateConstDecl = 456; ^ error: "duplicateConstDecl" has already been declared @@ -578,33 +578,42 @@ describe.concurrent("AggregateError whose errors cannot be walked", () => { expect(exitCode).toBe(1); }); - // Without any user code: a module with two or more build errors rejects its - // first load with an AggregateError of BuildMessages. JSC settles every later - // load of it from the module registry with a copy made by - // JSModuleLoader::duplicateError, which keeps the error type and message but - // not the `errors` property. + // Without any user code: a module with build errors rejects its first load + // with a SyntaxError that keeps every diagnostic, and the module registry + // settles every later importer of the module with that same error. It used + // to be an AggregateError of BuildMessages, and a later importer got a copy + // of it that had the message and no `errors`. const broken = { "broken.ts": "export function f() {\n const v = {b: {},),r,};\n}\n", }; - - test("the error a second load of a module that failed to build rejects with", async () => { + const brokenErrors = [ + 'error: Expected identifier but found ")"', + 'error: Expected "}" but found "r"', + 'error: Expected identifier but found "}"', + "error: This constant must be initialized", + ]; + const errorsPrinted = text => text.split("\n").filter(line => line.startsWith("error: ")); + + test("the error a second importer of a module that failed to build rejects with", async () => { const { stdout, stderr, exitCode } = await run(["main.ts"], { ...broken, + "a.ts": `import "./broken.ts";`, + "b.ts": `import "./broken.ts";`, "main.ts": ` const seen = []; - for (let i = 0; i < 2; i++) { + for (const importer of ["./a.ts", "./b.ts"]) { try { - await import("./broken.ts"); + await import(importer); } catch (e) { seen.push(e.constructor.name); - if (i === 1) console.error(e); + console.error(e); } } console.log(JSON.stringify(seen)); `, }); - expect(stdout).toBe('["AggregateError","AggregateError"]\n'); - expect(stderr).toContain("broken.ts"); + expect(stdout).toBe('["SyntaxError","SyntaxError"]\n'); + expect(errorsPrinted(stderr)).toEqual([...brokenErrors, ...brokenErrors]); expect(exitCode).toBe(0); }); @@ -618,6 +627,8 @@ describe.concurrent("AggregateError whose errors cannot be walked", () => { }); expect(stderr).toContain("a.test.ts:"); expect(stderr).toContain("b.test.ts:"); + // The report of each file has every diagnostic. + expect(errorsPrinted(stderr)).toEqual([...brokenErrors, ...brokenErrors]); expect(stderr).toContain("across 2 files"); expect(exitCode).toBe(1); }); @@ -708,3 +719,35 @@ code();`; expect(stderr).toMatch(/error: boom\n +at code \(.*:3:9\)\n/); expect(exitCode).toBe(1); }); + +// The console prints the code frame of a file that Bun's parser refuses wherever its error is: as +// a cause, as a property of another error, and as a member of an AggregateError. +test.concurrent("the SyntaxError of a file that does not parse is printed in full inside another error", async () => { + using dir = tempDir("inspect-error-load-error", { + "bad.js": "const x;\n", + "bad-too.js": "let y = ;\n", + "main.js": `const load = file => import(file).then(() => "loaded", e => e); +const [bad, badToo] = [await load("./bad.js"), await load("./bad-too.js")]; +const withCause = new Error("with a cause", { cause: bad }); +const withProperty = new Error("with a property"); +withProperty.inner = bad; +const text = value => Bun.inspect(value).replaceAll(import.meta.dir, "").replaceAll("\\\\", "/"); +console.log(JSON.stringify([withCause, withProperty, new AggregateError([bad, badToo], "two files")].map(text))); +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + 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]); + expect(stderr).toBe(""); + const frame = '1 | const x;\n ^\nerror: The constant "x" must be initialized\n at /bad.js:1:7\n'; + const [cause, property, members] = JSON.parse(stdout); + expect(cause).toEndWith("error: with a cause\n at /main.js:3:23\n\n" + frame); + expect(property).toEndWith("error: with a property\n at /main.js:4:26\n\n" + frame); + expect(members).toBe(frame + "1 | let y = ;\n ^\nerror: Unexpected ;\n at /bad-too.js:1:9\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index ccaee74e0edb..3d3fc17b7bff 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -855,13 +855,25 @@ describe("junit reporter", () => { "a test file has a syntax error", [], `import { test } from "bun:test";\nconst half = ;\ntest("never registered", () => {});`, - { type: "BuildMessage", message: "Unexpected ;", at: "broken.test.js:2:14" }, + { type: "SyntaxError", message: "Unexpected ;", at: "broken.test.js:2:14" }, ], [ "a module that a test file imports has a syntax error", [], `import { test } from "bun:test";\nimport "./half.js";\ntest("never registered", () => {});`, - { type: "BuildMessage", message: "Unexpected ;", at: "half.js:1:14" }, + { type: "SyntaxError", message: "Unexpected ;", at: "half.js:1:14" }, + ], + [ + "a test file has two syntax errors", + [], + `import { test } from "bun:test";\nconst one;\nconst two;\ntest("never registered", () => {});`, + { type: "SyntaxError", message: 'The constant "one" must be initialized', at: "broken.test.js:3:7" }, + ], + [ + "a test file has a warning before its syntax error", + [], + `const { test } = require("bun:test");\n--> a legacy comment\nconst half;\ntest("never registered", () => {});`, + { type: "SyntaxError", message: 'The constant "half" must be initialized', at: "broken.test.js:3:7" }, ], ])("reports a failed test case when %s", async (_, args, source, { type, message, at }) => { await using tmpDir = tempDir("junit-unhandled", { diff --git a/test/js/node/module/node-module-module.test.js b/test/js/node/module/node-module-module.test.js index 7f7bee6f3e06..e93c1f2a5835 100644 --- a/test/js/node/module/node-module-module.test.js +++ b/test/js/node/module/node-module-module.test.js @@ -1122,7 +1122,7 @@ console.log("survived", require("./late.js"));`, nullThis: { name: "dep" }, relative: { name: "dep" }, builtin: true, - failedLoad: "threw BuildMessage", + failedLoad: "threw SyntaxError", leftInCache: false, }); expect(exitCode).toBe(0); diff --git a/test/js/node/test/parallel/test-esm-error-cache.js b/test/js/node/test/parallel/test-esm-error-cache.js new file mode 100644 index 000000000000..e39ddb895acc --- /dev/null +++ b/test/js/node/test/parallel/test-esm-error-cache.js @@ -0,0 +1,28 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +const file = '../fixtures/syntax/bad_syntax.mjs'; + +let error; +(async () => { + try { + await import(file); + } catch (e) { + assert.strictEqual(e.name, 'SyntaxError'); + error = e; + } + + assert(error); + + await assert.rejects( + () => import(file), + (e) => { + // The module may be compiled again and a new SyntaxError would be thrown but + // with the same content. + assert.deepStrictEqual(error, e); + return true; + } + ); +})().then(common.mustCall()); diff --git a/test/js/node/util/test-util-types.test.js b/test/js/node/util/test-util-types.test.js index d9df4fb32c66..b16e9698e3aa 100644 --- a/test/js/node/util/test-util-types.test.js +++ b/test/js/node/util/test-util-types.test.js @@ -369,9 +369,18 @@ export default /BADD~!!!!; `; const blob = new Blob([badCode], { type: "application/javascript" }); const url = URL.createObjectURL(blob); - let buildError; + let syntaxError; try { require(url); + } catch (e) { + syntaxError = e; + } + expect(types.isNativeError(syntaxError)).toBeTrue(); + expect(syntaxError.constructor.name).toBe("SyntaxError"); + + let buildError; + try { + new Bun.Transpiler().transformSync(badCode); } catch (e) { buildError = e; } diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 5ce241425f37..6b1e95eb275d 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -342,6 +342,37 @@ test("support worker eval that throws", async () => { await worker.terminate(); }); +// The worker's module loader rejects with a SyntaxError that has the position. It used to be a +// BuildMessage, which reached the parent as a SyntaxError with the message alone, and for a file +// with two diagnostics as an Error that said "2 errors building". +test("a file that does not parse reaches the parent as a SyntaxError with its position", async () => { + using dir = tempDir("worker-syntax-error", { + "one.js": `const x;\n`, + "two.js": `const x;\nconst y;\n`, + "imports-one.mjs": `import "./one.js";\n`, + }); + const at = (file: string) => join(String(dir), file); + const reported = async (file: string) => { + const worker = new Worker(at(file)); + const [error] = await once(worker, "error"); + const { name, message, line, column, sourceURL, stack } = error; + return { name, message, line, column, sourceURL, stack }; + }; + const expected = (file: string) => ({ + name: "SyntaxError", + message: 'The constant "x" must be initialized', + line: 1, + column: 7, + sourceURL: at(file), + stack: `SyntaxError: The constant "x" must be initialized\n at (${at(file)}:1:7)`, + }); + expect(await Promise.all(["one.js", "two.js", "imports-one.mjs"].map(reported))).toEqual([ + expected("one.js"), + expected("two.js"), + expected("one.js"), + ]); +}); + describe("execArgv option", async () => { // this needs to be a subprocess to ensure that the parent's execArgv is not empty // otherwise we could not distinguish between the worker inheriting the parent's execArgv diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 8c0066d16f16..93f89a41b3da 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -116,6 +116,31 @@ describe("web worker", () => { }); }); + // The message is what the console prints for the error, as for every other error: here the code + // frame of each diagnostic, with the file and the position. + test("the error event of a file that does not parse has the code frame of each diagnostic", async () => { + using dir = tempDir("web-worker-syntax-error", { + "one.js": `const x;\n`, + "two.js": `const x;\nconst y;\n`, + }); + const at = (file: string) => path.join(String(dir), file); + const messages = ["one.js", "two.js"].map(file => { + const worker = new Worker(at(file)); + const { resolve, promise } = Promise.withResolvers(); + worker.onerror = e => { + e.preventDefault(); + resolve(e.message); + }; + return promise; + }); + const frame = (file: string, line: number, name: string) => + `${line} | const ${name};\n ^\nerror: The constant "${name}" must be initialized\n at ${at(file)}:${line}:7`; + expect(await Promise.all(messages)).toEqual([ + frame("one.js", 1, "x"), + frame("two.js", 1, "x") + "\n\n" + frame("two.js", 2, "y"), + ]); + }); + test("worker", done => { const worker = new Worker(new URL("worker-fixture.js", import.meta.url).href, { smol: true, From c50751ce0889979b1143d811aa80a2c163553f48 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:01:25 +0000 Subject: [PATCH 3/4] test: nest the file deep enough to overflow the parser on every platform --- test/js/bun/resolve/build-error.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/js/bun/resolve/build-error.test.ts b/test/js/bun/resolve/build-error.test.ts index 9455cf485a4b..e7d09546ceec 100644 --- a/test/js/bun/resolve/build-error.test.ts +++ b/test/js/bun/resolve/build-error.test.ts @@ -651,7 +651,8 @@ describe.concurrent("a file that Bun's parser refuses fails to load with one Syn // A diagnostic without a line does not say where the parser refused the text. A file that is // nested too deeply is not refused for its syntax: JavaScriptCore and Node report a RangeError. test("a diagnostic without a line, and a file that is nested too deeply, stay a BuildMessage", async () => { - const depth = 30_000; + // Deep enough to overflow the parser's stack on every platform, whatever the size of a frame. + const depth = 2_000_000; const { stdout, stderr, exitCode } = await run({ // The position of an error in a transcoded document is not a position in the file. "no-line.xml": Buffer.from(`\n\xE9\n`, "latin1"), From a04a8abca61551ccc73b7f0396c62344d827bc86 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:59:57 +0000 Subject: [PATCH 4/4] Keep each new comment to one line --- src/js/internal/repl/node-shims.ts | 4 +--- src/jsc/BuildMessage.rs | 21 +++++---------------- src/jsc/ConsoleObject.rs | 3 +-- src/jsc/VirtualMachine.rs | 15 ++------------- src/jsc/bindings/LoaderSyntaxError.cpp | 9 +++------ src/jsc/web_worker.rs | 3 +-- src/runtime/cli/test_command.rs | 3 +-- src/runtime/test_runner/pretty_format.rs | 3 +-- 8 files changed, 15 insertions(+), 46 deletions(-) diff --git a/src/js/internal/repl/node-shims.ts b/src/js/internal/repl/node-shims.ts index 69c565da6938..f279912002d9 100644 --- a/src/js/internal/repl/node-shims.ts +++ b/src/js/internal/repl/node-shims.ts @@ -37,9 +37,7 @@ function SideEffectFreeRegExpPrototypeSymbolSplit(regexp: RegExp, str: string, l return RegExpPrototypeSymbolSplit(regexp, str, limit); } -// The error of a file that Bun's parser refuses names the file in the one frame of its `stack`, and the REPL removes -// the frames of a SyntaxError. Node's decorateErrorStack() puts the location above the message: the file and line, -// the source line, a caret. +// Node's decorateErrorStack() puts the file, the source line and a caret above the message of a parse error. function parseErrorLocation(err: object): string { try { if ((err as { name?: unknown }).name !== "SyntaxError") return ""; diff --git a/src/jsc/BuildMessage.rs b/src/jsc/BuildMessage.rs index f86d9802fbbc..2820f6a8b0c3 100644 --- a/src/jsc/BuildMessage.rs +++ b/src/jsc/BuildMessage.rs @@ -47,9 +47,7 @@ unsafe extern "C" { ) -> JSValue; } -/// The diagnostics that the module loader keeps on the `SyntaxError` it makes for a file that -/// Bun's parser refuses. The error owns them: a diagnostic is borrowed from this holder, which -/// keeps the error visible to the collector. +/// The diagnostics of the module loader's `SyntaxError`, borrowed from this holder, which keeps the error alive. pub struct LoadDiagnostics<'a> { global: &'a JSGlobalObject, error: JSValue, @@ -61,8 +59,7 @@ impl LoadDiagnostics<'_> { if cell.is_empty() { return None; } - // The wrappers stay reachable from the error: it holds them under a private name that - // script can neither read nor replace. + // The error holds the wrappers under a private name, so they live as long as the error does. match cell.as_class_ref::() { Some(build) => Some(&build.msg), None => cell @@ -100,9 +97,7 @@ impl BuildMessage { Some(diagnostics) } - /// Whether the module loader reports a log whose first error is `msg` as one `SyntaxError`: - /// the parser refused the source text at a line of it. A file that is nested too deeply for - /// the parser's stack is not refused for its syntax. + /// Whether `msg`, the first error of a log, is a syntax error: it has a line, and it is not a stack overflow. pub(crate) fn is_syntax_error(msg: &bun_ast::Msg) -> bool { !matches!(msg.metadata, bun_ast::Metadata::Resolve(_)) && msg @@ -113,12 +108,7 @@ impl BuildMessage { && &*msg.data.text != b"Maximum call stack size exceeded" } - /// The `SyntaxError` that the module loader rejects with for a file whose first error is this - /// diagnostic, which [`Self::is_syntax_error`] accepts. `this_value` is the wrapper of `self`. - /// `all` is `JSValue::ZERO`, or an array of the wrappers of the diagnostics that the error - /// keeps. - /// - /// The position is the parser's, in the original source: own 1-based `line` and `column`. + /// The loader's `SyntaxError` for this diagnostic. `all` is `ZERO`, or an array of the wrappers that it keeps. pub(crate) fn to_syntax_error( &self, global: &JSGlobalObject, @@ -136,8 +126,7 @@ impl BuildMessage { let line = location.line.max(0) as u32; let column = location.column.max(0) as u32; - // The one frame has the form that a parse error of JavaScriptCore gets from `require()`. - // The error printer reads that form back from a `stack` that was copied to another error. + // The frame has the form of a JavaScriptCore parse error from `require()`, which the error printer reads back. let mut stack = Vec::with_capacity(text.len() + file.len() + 56); stack.extend_from_slice(b"SyntaxError: "); stack.extend_from_slice(text); diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 6f808d62b928..34941f3d18ab 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3864,8 +3864,7 @@ pub mod formatter { return Ok(()); } - // The error of a file that Bun's parser refuses prints as a `BuildMessage` does: the - // text of its diagnostics. + // The module loader's `SyntaxError` prints as a `BuildMessage` does: the text of its diagnostics. if let Some(diagnostics) = crate::BuildMessage::load_diagnostics(self.global_this, value) { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 4cd7be9ae927..41c6f08ce7e3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3947,13 +3947,6 @@ impl VirtualMachine { } /// The value a failed fetch rejects with, made from the transpile `log`. -/// -/// A log whose first error is [`BuildMessage::is_syntax_error`] gives one -/// `SyntaxError`, which keeps the wrappers of the diagnostics for the -/// printers. Any other log gives a `BuildMessage`, a `ResolveMessage`, or an -/// `AggregateError` of them. -/// -/// [`BuildMessage::is_syntax_error`]: crate::BuildMessage::is_syntax_error #[cold] #[inline(never)] pub fn process_fetch_log( @@ -6861,8 +6854,7 @@ impl VirtualMachine { } } - // The module loader's `SyntaxError` for a file that Bun's parser refuses is reported as - // a `BuildMessage` is: its diagnostics, then the frames of the throw. + // The module loader's `SyntaxError` is reported as a `BuildMessage` is: its diagnostics, then the throw's frames. if let Some(diagnostics) = crate::BuildMessage::load_diagnostics(self.global(), value) { let gap = self.had_errors; self.print_load_diagnostics( @@ -6903,10 +6895,7 @@ impl VirtualMachine { false } - /// Writes the diagnostics of the module loader's `SyntaxError`, a blank line between two. - /// `gap` asks for one before the first. Unlike a `BuildMessage`, the error has no `logged` - /// latch: the registry can hand the same object to a later importer, whose report is not a - /// repeat. + /// Prints on every report, with no `logged` latch: a later importer can get the same error object. fn print_load_diagnostics( &mut self, diagnostics: &crate::LoadDiagnostics<'_>, diff --git a/src/jsc/bindings/LoaderSyntaxError.cpp b/src/jsc/bindings/LoaderSyntaxError.cpp index 37e50af1769e..454cc65a8555 100644 --- a/src/jsc/bindings/LoaderSyntaxError.cpp +++ b/src/jsc/bindings/LoaderSyntaxError.cpp @@ -14,8 +14,7 @@ namespace Bun { using namespace JSC; -// What the module loader keeps on the SyntaxError of a file that Bun's parser refuses: the BuildMessage of the one -// diagnostic, or an InternalFieldTuple of the first error's BuildMessage and an array of every diagnostic in log order. +// The SyntaxError keeps one BuildMessage, or an InternalFieldTuple of the first error's BuildMessage and an array of all. static JSValue loadDiagnostics(VM& vm, JSValue value) { auto* error = dynamicDowncast(value); @@ -37,8 +36,7 @@ JSC_DEFINE_CUSTOM_GETTER(loaderSyntaxErrorDiagnosticGetter, (JSGlobalObject * gl RELEASE_AND_RETURN(scope, JSValue::encode(diagnostics.get(globalObject, propertyName))); } -// `first` is the BuildMessage of the diagnostic that the error reports. `all` is empty, or an array of the wrappers of -// every diagnostic of the log. +// `first` is the BuildMessage that the error reports. `all` is empty, or an array of the wrappers of every diagnostic. extern "C" EncodedJSValue Bun__createLoaderSyntaxError(JSGlobalObject* globalObject, const unsigned char* messagePtr, size_t messageLength, const unsigned char* sourceURLPtr, size_t sourceURLLength, uint32_t line, uint32_t column, const unsigned char* stackPtr, size_t stackLength, EncodedJSValue encodedFirst, EncodedJSValue encodedAll) { VM& vm = getVM(globalObject); @@ -67,8 +65,7 @@ extern "C" EncodedJSValue Bun__createLoaderSyntaxError(JSGlobalObject* globalObj RELEASE_AND_RETURN(scope, JSValue::encode(error)); } -// The wrapper of diagnostic `index` of a SyntaxError that Bun__createLoaderSyntaxError made. Empty past the last one, and -// for any other value. +// The wrapper of diagnostic `index` of the module loader's SyntaxError. Empty past the last one and for any other value. extern "C" EncodedJSValue Bun__loaderSyntaxErrorDiagnostic(JSGlobalObject* globalObject, EncodedJSValue value, uint32_t index) { JSValue diagnostics = loadDiagnostics(getVM(globalObject), JSValue::decode(value)); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 0b574b4372bb..0ee1f5ff4490 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1493,8 +1493,7 @@ fn on_unhandled_rejection( .to_error() .unwrap_or(error_instance_or_exception); - // A BuildMessage doesn't survive structured clone: the parent gets a SyntaxError - // with its text. A file that does not parse is a SyntaxError already. + // A BuildMessage doesn't survive structured clone: the parent gets a SyntaxError with its text. if let Some(bm) = error_instance.as_::() { // SAFETY: as_ returned a live BuildMessage cell, read-only on the // worker (JS) thread that owns it. diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 61a86f8e2688..bf7b72eb3b43 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2405,8 +2405,7 @@ impl TestFailure { } } - /// A module that can't be found throws a `ResolveMessage`, one that can't be read a `BuildMessage`, and one that - /// can't be parsed a `SyntaxError` that holds its diagnostics. They are printed as log messages, with no `ZigException`. + /// A module that can't be found, read or parsed throws a value that is printed as log messages, with no `ZigException`. #[optimize(size)] pub(crate) fn record_log_message( slot: &mut Option, diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index e611bf28843a..b0d20f91e2e5 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -1382,8 +1382,7 @@ impl<'a> Formatter<'a> { } } Tag::Error => { - // The error of a file that Bun's parser refuses prints as a `BuildMessage` - // does below: the text of its diagnostics. + // The module loader's `SyntaxError` prints as a `BuildMessage` does below: the text of its diagnostics. if let Some(diagnostics) = crate::api::BuildMessage::load_diagnostics(self.global_this, value) {