Skip to content
11 changes: 10 additions & 1 deletion core/engine/src/builtins/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::{
property::Attribute,
realm::Realm,
string::StaticJsStrings,
vm::shadow_stack::ShadowEntry,
vm::shadow_stack::{Backtrace, ShadowEntry},
};
use boa_gc::{Finalize, Trace};
use boa_macros::js_str;
Expand Down Expand Up @@ -137,6 +137,12 @@ pub struct Error {
// The position of where the Error was created does not affect equality check.
#[unsafe_ignore_trace]
pub(crate) position: IgnoreEq<Option<ShadowEntry>>,

// The backtrace captured when this error was thrown. Stored here so it
// survives the JsError → JsValue → JsError round-trip through promise
// rejection. Does not affect equality checks.
#[unsafe_ignore_trace]
pub(crate) backtrace: IgnoreEq<Option<Backtrace>>,
}

impl Error {
Expand All @@ -147,6 +153,7 @@ impl Error {
Self {
tag,
position: IgnoreEq(None),
backtrace: IgnoreEq(None),
}
}

Expand All @@ -155,6 +162,7 @@ impl Error {
Self {
tag,
position: IgnoreEq(entry),
backtrace: IgnoreEq(None),
}
}

Expand All @@ -163,6 +171,7 @@ impl Error {
Self {
tag,
position: IgnoreEq(context.vm.shadow_stack.caller_position()),
backtrace: IgnoreEq(None),
}
}
}
Expand Down
39 changes: 35 additions & 4 deletions core/engine/src/error.rs → core/engine/src/error/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
//! Error-related types and conversions.

#[cfg(test)]
mod tests;

use crate::{
Context, JsResult, JsString, JsValue,
builtins::{
Expand Down Expand Up @@ -386,10 +389,16 @@ impl JsError {
/// assert!(error.as_opaque().is_some());
/// ```
#[must_use]
pub const fn from_opaque(value: JsValue) -> Self {
pub fn from_opaque(value: JsValue) -> Self {
// Recover the backtrace from the Error object if present,
// so it survives the JsError → JsValue → JsError round-trip.
let backtrace = value.as_object().and_then(|obj| {
let error = obj.downcast_ref::<Error>()?;
error.backtrace.0.clone()
});
Self {
inner: Repr::Opaque(value),
backtrace: None,
backtrace,
}
}

Expand Down Expand Up @@ -421,8 +430,30 @@ impl JsError {
/// ```
pub fn into_opaque(self, context: &mut Context) -> JsResult<JsValue> {
match self.inner {
Repr::Native(e) => Ok(e.into_opaque(context).into()),
Repr::Opaque(v) => Ok(v.clone()),
Repr::Native(e) => {
let obj = e.into_opaque(context);
// Store the backtrace in the Error object so it survives the
// JsError → JsValue → JsError round-trip through promise
// rejection.
if let Some(backtrace) = self.backtrace
&& let Some(mut error) = obj.downcast_mut::<Error>()
{
error.backtrace = IgnoreEq(Some(backtrace));
}
Ok(obj.into())
}
Repr::Opaque(v) => {
// Store the backtrace in the Error object for opaque errors
// too (e.g. explicit `throw new Error(...)`).
if let Some(backtrace) = self.backtrace
&& let Some(obj) = v.as_object()
&& let Some(mut error) = obj.downcast_mut::<Error>()
&& error.backtrace.0.is_none()
{
error.backtrace = IgnoreEq(Some(backtrace));
}
Ok(v.clone())
}
Repr::Engine(_) => Err(self),
}
}
Expand Down
178 changes: 178 additions & 0 deletions core/engine/src/error/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
use std::path::Path;

use crate::{
Context, JsError, Source,
builtins::promise::PromiseState,
module::Module,
vm::{shadow_stack::ShadowEntry, source_info::SourcePath},
};
use indoc::indoc;

/// Helper to extract backtrace entries from a rejected module promise.
fn get_backtrace_from_rejection(
context: &mut Context,
js_code: &[u8],
path: &str,
) -> Vec<ShadowEntry> {
let source = Source::from_bytes(js_code).with_path(Path::new(path));
let module = Module::parse(source, None, context).unwrap();
let promise = module.load_link_evaluate(context);
context.run_jobs().unwrap();

match promise.state() {
PromiseState::Rejected(err) => {
let js_error = JsError::from_opaque(err);
js_error
.backtrace
.as_ref()
.expect("error should have a backtrace")
.iter()
.cloned()
.collect()
}
PromiseState::Fulfilled(_) => panic!("Module should have thrown an error"),
PromiseState::Pending => panic!("Module evaluation should not be pending"),
}
}

/// Assert that a `ShadowEntry::Bytecode` frame matches expected function name, path, line, and column.
#[track_caller]
fn assert_bytecode_frame(
entry: &ShadowEntry,
expected_fn: &str,
expected_path: &Path,
expected_line: u32,
expected_col: u32,
) {
match entry {
ShadowEntry::Bytecode { pc, source_info } => {
assert_eq!(
source_info.function_name().to_std_string_escaped(),
expected_fn,
"function name mismatch"
);
assert_eq!(
source_info.map().path(),
&SourcePath::Path(expected_path.into()),
"path mismatch"
);
let pos = source_info
.map()
.find(*pc)
.expect("should have a source position");
assert_eq!(pos.line_number(), expected_line, "line number mismatch");
assert_eq!(pos.column_number(), expected_col, "column number mismatch");
}
ShadowEntry::Native { .. } => panic!("expected Bytecode frame, got Native"),
}
}

/// Assert that a `ShadowEntry` is a `Native` frame.
#[track_caller]
fn assert_native_frame(entry: &ShadowEntry) {
assert!(
matches!(entry, ShadowEntry::Native { .. }),
"expected Native frame, got Bytecode"
);
}

/// Test that errors caught by internal handlers (e.g. async module evaluation)
/// preserve their backtrace through promise rejection (`JsError` -> `JsValue` -> `JsError`).
#[test]
fn backtrace_preserved_through_promise_rejection() {
let mut context = Context::default();
let entries = get_backtrace_from_rejection(
&mut context,
indoc! {br#"
let x = undefined;
x()
"#},
"test.js",
);

let path = Path::new("test.js");

// Backtrace stored bottom-up: [Native (call site), Bytecode (<main>)]
assert_eq!(entries.len(), 2, "expected 2 backtrace entries");
assert_native_frame(&entries[0]);
assert_bytecode_frame(&entries[1], "<main>", path, 2, 2);
}

/// Test that nested call frames produce a full backtrace through the
/// promise rejection round-trip.
#[test]
fn nested_backtrace_preserved_through_promise_rejection() {
let mut context = Context::default();
let entries = get_backtrace_from_rejection(
&mut context,
indoc! {br#"
function foo() {
function baz() {
import.meta.non_existent()
}
baz()
}

foo()
"#},
"test.js",
);

let path = Path::new("test.js");

// Backtrace stored bottom-up: [Native, <main>, foo, baz]
assert_eq!(entries.len(), 4, "expected 4 backtrace entries");
assert_native_frame(&entries[0]);
assert_bytecode_frame(&entries[1], "<main>", path, 8, 4);
assert_bytecode_frame(&entries[2], "foo", path, 5, 8);
assert_bytecode_frame(&entries[3], "baz", path, 3, 33);
}

/// Test that an explicit `throw new Error(...)` inside a module also preserves
/// the backtrace through the promise rejection round-trip.
#[test]
fn explicit_throw_backtrace_preserved_through_promise_rejection() {
let mut context = Context::default();
let entries = get_backtrace_from_rejection(
&mut context,
indoc! {br#"
function foo() {
throw new Error("test")
}
foo()
"#},
"test.js",
);

let path = Path::new("test.js");

// Backtrace stored bottom-up: [Native, <main>, foo]
assert_eq!(entries.len(), 3, "expected 3 backtrace entries");
assert_native_frame(&entries[0]);
assert_bytecode_frame(&entries[1], "<main>", path, 4, 4);
assert_bytecode_frame(&entries[2], "foo", path, 2, 11);
}

/// Sanity check: `context.eval()` errors include a backtrace (relates to
/// <https://github.com/boa-dev/boa/discussions/4475>).
#[test]
fn eval_error_has_backtrace() {
let mut context = Context::default();
let code = indoc! {br#"
const a = 0;
iWillCauseAnError
const b = a + 1;
"#};
let source = Source::from_reader(code.as_slice(), Some(Path::new("test.js")));
match context.eval(source) {
Ok(_) => panic!("Should have thrown a ReferenceError"),
Err(e) => {
assert!(e.backtrace.is_some(), "eval error should have a backtrace");
let entries: Vec<_> = e.backtrace.as_ref().unwrap().iter().collect();
assert!(
!entries.is_empty(),
"backtrace should have at least one entry"
);
}
}
}
19 changes: 11 additions & 8 deletions core/engine/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -667,17 +667,20 @@ impl Context {
}

fn handle_error(&mut self, mut err: JsError) -> ControlFlow<CompletionRecord> {
// Capture the backtrace early, before any exception handler check,
// so that errors caught by internal handlers (e.g. async module
// evaluation) still carry source position information.
if err.backtrace.is_none() {
err.backtrace = Some(
self.vm
.shadow_stack
.take(self.vm.runtime_limits.backtrace_limit(), self.vm.frame.pc),
);
}

// If we hit the execution step limit, bubble up the error to the
// (Rust) caller instead of trying to handle as an exception.
if !err.is_catchable() {
if err.backtrace.is_none() {
err.backtrace = Some(
self.vm
.shadow_stack
.take(self.vm.runtime_limits.backtrace_limit(), self.vm.frame.pc),
);
}

let mut frame = None;
let mut env_fp = self.vm.frame.environments.len();
loop {
Expand Down
Loading