From 286ebecb7f2fb60e53ef4a9f7c2ed046649f2afa Mon Sep 17 00:00:00 2001 From: Haled Odat <8566042+HalidOdat@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:20:48 +0000 Subject: [PATCH] Implement Error.prototype.stack accessor property This commit implements the Error.prototype.stack property as specified in the TC39 Error Stacks proposal (https://tc39.es/proposal-error-stacks). --- core/engine/src/builtins/error/mod.rs | 123 +++++++++++++++++++--- core/engine/src/error.rs | 129 +++++++---------------- core/engine/src/value/display.rs | 4 +- core/engine/src/vm/mod.rs | 12 +++ core/engine/src/vm/shadow_stack.rs | 145 ++++++++++++++++++++------ core/engine/src/vm/source_info/mod.rs | 10 -- 6 files changed, 275 insertions(+), 148 deletions(-) diff --git a/core/engine/src/builtins/error/mod.rs b/core/engine/src/builtins/error/mod.rs index 9f093fe67e0..96119e7ca60 100644 --- a/core/engine/src/builtins/error/mod.rs +++ b/core/engine/src/builtins/error/mod.rs @@ -10,6 +10,8 @@ //! [spec]: https://tc39.es/ecma262/#sec-error-objects //! [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error +use std::fmt::Write; + use crate::{ Context, JsArgs, JsData, JsResult, JsString, JsValue, builtins::BuiltInObject, @@ -20,7 +22,10 @@ use crate::{ property::Attribute, realm::Realm, string::StaticJsStrings, - vm::shadow_stack::ShadowEntry, + vm::{ + NativeSourceInfo, + shadow_stack::{ErrorStack, ShadowEntry}, + }, }; use boa_gc::{Finalize, Trace}; use boa_macros::js_str; @@ -136,44 +141,67 @@ pub struct Error { // The position of where the Error was created does not affect equality check. #[unsafe_ignore_trace] - pub(crate) position: IgnoreEq>, + pub(crate) stack: IgnoreEq, } impl Error { /// Create a new [`Error`]. #[inline] #[must_use] + #[cfg_attr(feature = "native-backtrace", track_caller)] pub fn new(tag: ErrorKind) -> Self { Self { tag, - position: IgnoreEq(None), + stack: IgnoreEq(ErrorStack::Position(ShadowEntry::Native { + function_name: None, + source_info: NativeSourceInfo::caller(), + })), } } - /// Create a new [`Error`] with the given optional [`ShadowEntry`]. - pub(crate) fn with_shadow_entry(tag: ErrorKind, entry: Option) -> Self { + /// Create a new [`Error`] with the given [`ErrorStack`]. + pub(crate) fn with_stack(tag: ErrorKind, location: ErrorStack) -> Self { Self { tag, - position: IgnoreEq(entry), + stack: IgnoreEq(location), } } /// Get the position from the last called function. pub(crate) fn with_caller_position(tag: ErrorKind, context: &Context) -> Self { + let limit = context.runtime_limits().backtrace_limit(); + let backtrace = context.vm.shadow_stack.caller_position(limit); Self { tag, - position: IgnoreEq(context.vm.shadow_stack.caller_position()), + stack: IgnoreEq(ErrorStack::Backtrace(backtrace)), } } } impl IntrinsicObject for Error { fn init(realm: &Realm) { - let attribute = Attribute::WRITABLE | Attribute::NON_ENUMERABLE | Attribute::CONFIGURABLE; + let property_attribute = + Attribute::WRITABLE | Attribute::NON_ENUMERABLE | Attribute::CONFIGURABLE; + let accessor_attribute = Attribute::NON_ENUMERABLE | Attribute::CONFIGURABLE; + + let get_stack = BuiltInBuilder::callable(realm, Self::get_stack) + .name(js_string!("get stack")) + .build(); + + let set_stack = BuiltInBuilder::callable(realm, Self::set_stack) + .name(js_string!("set stack")) + .build(); + let builder = BuiltInBuilder::from_standard_constructor::(realm) - .property(js_string!("name"), Self::NAME, attribute) - .property(js_string!("message"), js_string!(), attribute) - .method(Self::to_string, js_string!("toString"), 0); + .property(js_string!("name"), Self::NAME, property_attribute) + .property(js_string!("message"), js_string!(), property_attribute) + .method(Self::to_string, js_string!("toString"), 0) + .accessor( + js_string!("stack"), + Some(get_stack), + Some(set_stack), + accessor_attribute, + ); #[cfg(feature = "experimental")] let builder = builder.static_method(Error::is_error, js_string!("isError"), 1); @@ -192,7 +220,7 @@ impl BuiltInObject for Error { impl BuiltInConstructor for Error { const CONSTRUCTOR_ARGUMENTS: usize = 1; - const PROTOTYPE_STORAGE_SLOTS: usize = 3; + const PROTOTYPE_STORAGE_SLOTS: usize = 5; const CONSTRUCTOR_STORAGE_SLOTS: usize = 1; const STANDARD_CONSTRUCTOR: fn(&StandardConstructors) -> &StandardConstructor = @@ -263,6 +291,77 @@ impl Error { Ok(()) } + /// `get Error.prototype.stack` + /// + /// The accessor property of Error instances represents the stack trace + /// when the error was created. + /// + /// More information: + /// - [Proposal][spec] + /// + /// [spec]: https://tc39.es/proposal-error-stacks/ + #[allow(clippy::unnecessary_wraps)] + fn get_stack(this: &JsValue, _: &[JsValue], _context: &mut Context) -> JsResult { + // 1. Let E be the this value. + // 2. If E is not an Object, return undefined. + let Some(e) = this.as_object() else { + return Ok(JsValue::undefined()); + }; + + // 3. Let errorData be the value of the [[ErrorData]] internal slot of E. + // 4. If errorData is undefined, return undefined. + let Some(error_data) = e.downcast_ref::() else { + return Ok(JsValue::undefined()); + }; + + // 5. Let stackString be an implementation-defined String value representing the call stack. + // 6. Return stackString. + if let Some(backtrace) = error_data.stack.0.backtrace() { + let stack_string = backtrace + .iter() + .rev() + .fold(String::new(), |mut output, entry| { + let _ = writeln!(&mut output, " at {}", entry.display(true)); + output + }); + return Ok(js_string!(stack_string).into()); + } + + // 7. If no stack trace is available, return undefined. + Ok(JsValue::undefined()) + } + + /// `set Error.prototype.stack` + /// + /// The setter for the stack property. + /// + /// More information: + /// - [Proposal][spec] + /// + /// [spec]: https://tc39.es/proposal-error-stacks/ + fn set_stack(this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + // 1. Let E be the this value. + // 2. If Type(E) is not Object, throw a TypeError exception. + let e = this.as_object().ok_or_else(|| { + JsNativeError::typ() + .with_message("Error.prototype.stack setter requires that 'this' be an Object") + })?; + + // 3. Let numberOfArgs be the number of arguments passed to this function call. + // 4. If numberOfArgs is 0, throw a TypeError exception. + let Some(value) = args.first() else { + return Err(JsNativeError::typ() + .with_message( + "Error.prototype.stack setter requires at least 1 argument, but only 0 were passed", + ) + .into()); + }; + + // 5. Return ? CreateDataPropertyOrThrow(E, "stack", value). + e.create_data_property_or_throw(js_string!("stack"), value.clone(), context) + .map(Into::into) + } + /// `Error.prototype.toString()` /// /// The `toString()` method returns a string representing the specified Error object. diff --git a/core/engine/src/error.rs b/core/engine/src/error.rs index 4c24ad51107..88dcc44f324 100644 --- a/core/engine/src/error.rs +++ b/core/engine/src/error.rs @@ -12,15 +12,11 @@ use crate::{ realm::Realm, vm::{ NativeSourceInfo, - shadow_stack::{Backtrace, ShadowEntry}, + shadow_stack::{Backtrace, ErrorStack, ShadowEntry}, }, }; use boa_gc::{Finalize, Trace, custom_trace}; -use std::{ - borrow::Cow, - error, - fmt::{self}, -}; +use std::{borrow::Cow, error, fmt}; use thiserror::Error; /// Create an error object from a value or string literal. Optionally the @@ -75,80 +71,80 @@ use thiserror::Error; macro_rules! js_error { (Error: $value: literal) => { $crate::JsError::from_native( - $crate::JsNativeError::ERROR.with_message($value) + $crate::JsNativeError::error().with_message($value) ) }; (Error: $value: literal $(, $args: expr)* $(,)?) => { $crate::JsError::from_native( - $crate::JsNativeError::ERROR + $crate::JsNativeError::error() .with_message(format!($value $(, $args)*)) ) }; (TypeError: $value: literal) => { $crate::JsError::from_native( - $crate::JsNativeError::TYP.with_message($value) + $crate::JsNativeError::typ().with_message($value) ) }; (TypeError: $value: literal $(, $args: expr)* $(,)?) => { $crate::JsError::from_native( - $crate::JsNativeError::TYP + $crate::JsNativeError::typ() .with_message(format!($value $(, $args)*)) ) }; (SyntaxError: $value: literal) => { $crate::JsError::from_native( - $crate::JsNativeError::SYNTAX.with_message($value) + $crate::JsNativeError::syntax().with_message($value) ) }; (SyntaxError: $value: literal $(, $args: expr)* $(,)?) => { $crate::JsError::from_native( - $crate::JsNativeError::SYNTAX.with_message(format!($value $(, $args)*)) + $crate::JsNativeError::syntax().with_message(format!($value $(, $args)*)) ) }; (RangeError: $value: literal) => { $crate::JsError::from_native( - $crate::JsNativeError::RANGE.with_message($value) + $crate::JsNativeError::range().with_message($value) ) }; (RangeError: $value: literal $(, $args: expr)* $(,)?) => { $crate::JsError::from_native( - $crate::JsNativeError::RANGE.with_message(format!($value $(, $args)*)) + $crate::JsNativeError::range().with_message(format!($value $(, $args)*)) ) }; (EvalError: $value: literal) => { $crate::JsError::from_native( - $crate::JsNativeError::EVAL.with_message($value) + $crate::JsNativeError::eval().with_message($value) ) }; (EvalError: $value: literal $(, $args: expr)* $(,)?) => { $crate::JsError::from_native( - $crate::JsNativeError::EVAL.with_message(format!($value $(, $args)*)) + $crate::JsNativeError::eval().with_message(format!($value $(, $args)*)) ) }; (ReferenceError: $value: literal) => { $crate::JsError::from_native( - $crate::JsNativeError::REFERENCE.with_message($value) + $crate::JsNativeError::reference().with_message($value) ) }; (ReferenceError: $value: literal $(, $args: expr)* $(,)?) => { $crate::JsError::from_native( - $crate::JsNativeError::REFERENCE.with_message(format!($value $(, $args)*)) + $crate::JsNativeError::reference().with_message(format!($value $(, $args)*)) ) }; (URIError: $value: literal) => { $crate::JsError::from_native( - $crate::JsNativeError::URI.with_message($value) + $crate::JsNativeError::uri().with_message($value) ) }; (URIError: $value: literal $(, $args: expr)* $(,)?) => { $crate::JsError::from_native( - $crate::JsNativeError::URI.with_message(format!($value $(, $args)*)) + $crate::JsNativeError::uri().with_message(format!($value $(, $args)*)) ) }; @@ -208,7 +204,6 @@ macro_rules! js_error { #[boa_gc(unsafe_no_drop)] pub struct JsError { inner: Repr, - pub(crate) backtrace: Option, } @@ -503,7 +498,7 @@ impl JsError { let cause = try_get_property(js_string!("cause"), "cause", context)?; - let position = error_data.position.clone(); + let location = error_data.stack.clone(); let kind = match error_data.tag { ErrorKind::Error => JsNativeErrorKind::Error, ErrorKind::Eval => JsNativeErrorKind::Eval, @@ -558,7 +553,7 @@ impl JsError { message, cause: cause.map(|v| Box::new(Self::from_opaque(v))), realm: Some(realm), - position, + stack: location, }) } } @@ -613,6 +608,16 @@ impl JsError { } } + /// Gets the inner [`JsNativeError`] if the error is a native + /// error, or `None` otherwise. + #[must_use] + pub const fn as_native_mut(&mut self) -> Option<&mut JsNativeError> { + match &mut self.inner { + Repr::Native(e) => Some(e), + Repr::Opaque(_) | Repr::Engine(_) => None, + } + } + /// Gets the inner [`JsNativeError`] if the error is an engine /// error, or `None` otherwise. #[must_use] @@ -764,54 +769,7 @@ impl fmt::Display for JsError { if let Some(shadow_stack) = &self.backtrace { for entry in shadow_stack.iter().rev() { - write!(f, "\n at ")?; - match entry { - ShadowEntry::Native { - function_name, - source_info, - } => { - if let Some(function_name) = function_name { - write!(f, "{}", function_name.to_std_string_escaped())?; - } else { - f.write_str("")?; - } - - if let Some(loc) = source_info.as_location() { - write!( - f, - " (native at {}:{}:{})", - loc.file(), - loc.line(), - loc.column() - )?; - } else { - f.write_str(" (native)")?; - } - } - ShadowEntry::Bytecode { pc, source_info } => { - let has_function_name = !source_info.function_name().is_empty(); - if has_function_name { - write!(f, "{}", source_info.function_name().to_std_string_escaped(),)?; - } else { - f.write_str("")?; - } - - f.write_str(" (")?; - source_info.map().path().fmt(f)?; - - if let Some(position) = source_info.map().find(*pc) { - write!( - f, - ":{}:{}", - position.line_number(), - position.column_number() - )?; - } else { - f.write_str(":?:?")?; - } - f.write_str(")")?; - } - } + write!(f, "\n at {}", entry.display(true))?; } } Ok(()) @@ -871,7 +829,7 @@ pub struct JsNativeError { #[source] cause: Option>, realm: Option, - position: IgnoreEq>, + pub(crate) stack: IgnoreEq, } impl fmt::Display for JsNativeError { @@ -883,8 +841,8 @@ impl fmt::Display for JsNativeError { write!(f, ": {message}")?; } - if let Some(position) = &self.position.0 { - position.fmt(f)?; + if let Some(entry) = self.stack.0.position() { + write!(f, "{}", entry.display(false))?; } Ok(()) @@ -911,23 +869,6 @@ impl fmt::Debug for JsNativeError { } impl JsNativeError { - /// Default `AggregateError` kind `JsNativeError`. - pub const AGGREGATE: Self = Self::aggregate(Vec::new()); - /// Default `Error` kind `JsNativeError`. - pub const ERROR: Self = Self::error(); - /// Default `EvalError` kind `JsNativeError`. - pub const EVAL: Self = Self::eval(); - /// Default `RangeError` kind `JsNativeError`. - pub const RANGE: Self = Self::range(); - /// Default `ReferenceError` kind `JsNativeError`. - pub const REFERENCE: Self = Self::reference(); - /// Default `SyntaxError` kind `JsNativeError`. - pub const SYNTAX: Self = Self::syntax(); - /// Default `error` kind `JsNativeError`. - pub const TYP: Self = Self::typ(); - /// Default `UriError` kind `JsNativeError`. - pub const URI: Self = Self::uri(); - /// Creates a new `JsNativeError` from its `kind`, `message` and (optionally) its `cause`. #[cfg_attr(feature = "native-backtrace", track_caller)] const fn new( @@ -945,7 +886,7 @@ impl JsNativeError { message, cause, realm: None, - position: IgnoreEq(Some(ShadowEntry::Native { + stack: IgnoreEq(ErrorStack::Position(ShadowEntry::Native { function_name: None, source_info: NativeSourceInfo::caller(), })), @@ -1271,7 +1212,7 @@ impl JsNativeError { message, cause, realm, - position, + stack, } = self; let constructors = realm.as_ref().map_or_else( || context.intrinsics().constructors(), @@ -1299,7 +1240,7 @@ impl JsNativeError { let o = JsObject::from_proto_and_data_with_shared_shape( context.root_shape(), prototype, - Error::with_shadow_entry(tag, position.0.clone()), + Error::with_stack(tag, stack.0.clone()), ) .upcast(); diff --git a/core/engine/src/value/display.rs b/core/engine/src/value/display.rs index 6a33df760e2..0e2f8ac0a57 100644 --- a/core/engine/src/value/display.rs +++ b/core/engine/src/value/display.rs @@ -257,8 +257,8 @@ pub(crate) fn log_value_to( .downcast_ref::() .expect("already checked object type"); - if let Some(position) = &data.position.0 { - write!(f, "{position}")?; + if let Some(entry) = data.stack.0.position() { + write!(f, "{}", entry.display(false))?; } Ok(()) } else if let Some(promise) = v.downcast_ref::() { diff --git a/core/engine/src/vm/mod.rs b/core/engine/src/vm/mod.rs index b9d328c6391..341320c3075 100644 --- a/core/engine/src/vm/mod.rs +++ b/core/engine/src/vm/mod.rs @@ -12,6 +12,7 @@ use crate::{ object::JsFunction, realm::Realm, script::Script, + vm::shadow_stack::ErrorStack, }; use boa_gc::{Finalize, Gc, Trace, custom_trace}; use shadow_stack::ShadowStack; @@ -699,6 +700,17 @@ impl Context { return ControlFlow::Break(CompletionRecord::Throw(err)); } + if let Some(native) = err.as_native_mut() + && let ErrorStack::Position(position) = &mut native.stack.0 + { + let backtrace = self.vm.shadow_stack.take_and_push( + self.vm.runtime_limits.backtrace_limit(), + self.vm.frame.pc, + position.clone(), + ); + native.stack.0 = ErrorStack::Backtrace(backtrace); + } + // Note: -1 because we increment after fetching the opcode. let pc = self.vm.frame().pc.saturating_sub(1); if self.vm.handle_exception_at(pc) { diff --git a/core/engine/src/vm/shadow_stack.rs b/core/engine/src/vm/shadow_stack.rs index 6b27541e03a..1ec8f37ae66 100644 --- a/core/engine/src/vm/shadow_stack.rs +++ b/core/engine/src/vm/shadow_stack.rs @@ -1,4 +1,4 @@ -use std::fmt::{Display, Write}; +use std::fmt::{self, Display}; use boa_gc::{Finalize, Trace}; use boa_string::JsString; @@ -19,6 +19,28 @@ impl Backtrace { } } +#[derive(Debug, Clone, Trace, Finalize)] +pub(crate) enum ErrorStack { + Position(#[unsafe_ignore_trace] ShadowEntry), + Backtrace(#[unsafe_ignore_trace] Backtrace), +} + +impl ErrorStack { + pub(crate) fn backtrace(&self) -> Option<&Backtrace> { + match self { + Self::Backtrace(bt) => Some(bt), + Self::Position(_) => None, + } + } + + pub(crate) fn position(&self) -> Option<&ShadowEntry> { + match self { + Self::Position(position) => Some(position), + Self::Backtrace(bt) => bt.iter().next(), + } + } +} + #[derive(Debug, Clone)] pub(crate) enum ShadowEntry { Native { @@ -31,42 +53,77 @@ pub(crate) enum ShadowEntry { }, } -impl Display for ShadowEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { +impl ShadowEntry { + /// Create a display wrapper for this entry. + /// + /// # Arguments + /// + /// * `show_function_name` - Whether to include the function name in the output. + pub(crate) fn display(&self, show_function_name: bool) -> DisplayShadowEntry<'_> { + DisplayShadowEntry { + entry: self, + show_function_name, + } + } +} + +/// Helper struct to format a shadow entry for display. +pub(crate) struct DisplayShadowEntry<'a> { + entry: &'a ShadowEntry, + show_function_name: bool, +} + +impl Display for DisplayShadowEntry<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.entry { ShadowEntry::Native { function_name, source_info, } => { - if function_name.is_some() || source_info.as_location().is_some() { - f.write_str(" (native")?; + if self.show_function_name { if let Some(function_name) = function_name { - write!(f, " {}", function_name.to_std_string_escaped())?; + write!(f, "{}", function_name.to_std_string_escaped())?; + } else { + f.write_str("")?; } - if let Some(location) = source_info.as_location() { - write!(f, " at {location}")?; - } - f.write_char(')')?; + } + + if let Some(loc) = source_info.as_location() { + write!( + f, + " (native at {}:{}:{})", + loc.file(), + loc.line(), + loc.column() + )?; + } else { + f.write_str(" (native)")?; } } ShadowEntry::Bytecode { pc, source_info } => { - let path = source_info.map().path(); - let position = source_info.map().find(*pc); - - if path.is_some() || position.is_some() { - write!(f, " ({}", source_info.map().path())?; - - if let Some(position) = position { - write!( - f, - ":{}:{}", - position.line_number(), - position.column_number() - )?; + if self.show_function_name { + let has_function_name = !source_info.function_name().is_empty(); + if has_function_name { + write!(f, "{}", source_info.function_name().to_std_string_escaped())?; + } else { + f.write_str("
")?; } - - f.write_char(')')?; } + f.write_str(" (")?; + + source_info.map().path().fmt(f)?; + + if let Some(position) = source_info.map().find(*pc) { + write!( + f, + ":{}:{}", + position.line_number(), + position.column_number() + )?; + } else { + f.write_str(":?:?")?; + } + f.write_str(")")?; } } Ok(()) @@ -131,10 +188,38 @@ impl ShadowStack { Backtrace { stack } } - pub(crate) fn caller_position(&self) -> Option { - // NOTE: We push the function that is currently execution, so the second last is the caller. - let index = self.stack.len().checked_sub(2)?; - self.stack.get(index).cloned() + pub(crate) fn take_and_push(&self, n: usize, last_pc: u32, value: ShadowEntry) -> Backtrace { + let mut stack = self + .stack + .iter() + .rev() + .take(n) + .rev() + .cloned() + .chain(std::iter::once(value)) + .collect::>(); + + let last = stack.len() - 2; + if let Some(ShadowEntry::Bytecode { pc, .. }) = stack.get_mut(last) { + // NOTE: pc points to the next opcode, so we offset by -1 to put it within range. + *pc = last_pc.saturating_sub(1); + } + Backtrace { stack } + } + + pub(crate) fn caller_position(&self, n: usize) -> Backtrace { + // NOTE: We push the function that is currently executing, so skip the last one. + let stack = self + .stack + .iter() + .rev() + .skip(1) + .take(n) + .rev() + .cloned() + .collect::>(); + + Backtrace { stack } } #[cfg(feature = "native-backtrace")] diff --git a/core/engine/src/vm/source_info/mod.rs b/core/engine/src/vm/source_info/mod.rs index 17b44be595d..7b297ed7aba 100644 --- a/core/engine/src/vm/source_info/mod.rs +++ b/core/engine/src/vm/source_info/mod.rs @@ -187,16 +187,6 @@ impl Display for SourcePath { } } -impl SourcePath { - pub(crate) fn is_none(&self) -> bool { - matches!(self, Self::None) - } - - pub(crate) fn is_some(&self) -> bool { - !self.is_none() - } -} - /// A struct containing information about native source code. /// /// # Note