diff --git a/cli/tests/integration/run_tests.rs b/cli/tests/integration/run_tests.rs index 67003a9498..99e122c69b 100644 --- a/cli/tests/integration/run_tests.rs +++ b/cli/tests/integration/run_tests.rs @@ -3460,6 +3460,12 @@ itest!(error_cause { exit_code: 1, }); +itest!(error_cause_recursive_aggregate { + args: "run error_cause_recursive_aggregate.ts", + output: "error_cause_recursive_aggregate.ts.out", + exit_code: 1, +}); + itest!(error_cause_recursive_tail { args: "run error_cause_recursive_tail.ts", output: "error_cause_recursive_tail.ts.out", diff --git a/cli/tests/testdata/error_cause_recursive_aggregate.ts b/cli/tests/testdata/error_cause_recursive_aggregate.ts new file mode 100644 index 0000000000..4bb2ae0645 --- /dev/null +++ b/cli/tests/testdata/error_cause_recursive_aggregate.ts @@ -0,0 +1,9 @@ +const foo = new Error("foo"); +const bar = new Error("bar", { cause: foo }); +foo.cause = bar; + +const qux = new Error("qux"); +const quux = new Error("quux", { cause: qux }); +qux.cause = quux; + +throw new AggregateError([bar, quux]); diff --git a/cli/tests/testdata/error_cause_recursive_aggregate.ts.out b/cli/tests/testdata/error_cause_recursive_aggregate.ts.out new file mode 100644 index 0000000000..bf1e45e51f --- /dev/null +++ b/cli/tests/testdata/error_cause_recursive_aggregate.ts.out @@ -0,0 +1,15 @@ +[WILDCARD] +error: Uncaught AggregateError + Error: bar + at file:///[WILDCARD]/error_cause_recursive_aggregate.ts:2:13 + Caused by: Error: foo + at file:///[WILDCARD]/error_cause_recursive_aggregate.ts:1:13 + Caused by: [Circular *1] + Error: quux + at file:///[WILDCARD]/error_cause_recursive_aggregate.ts:6:14 + Caused by: Error: qux + at file:///[WILDCARD]/error_cause_recursive_aggregate.ts:5:13 + Caused by: [Circular *2] +throw new AggregateError([bar, quux]); + ^ + at file:///[WILDCARD]/error_cause_recursive_aggregate.ts:9:7 diff --git a/cli/tests/testdata/error_cause_recursive_tail.ts.out b/cli/tests/testdata/error_cause_recursive_tail.ts.out index 6aaecdb803..48b65d3f40 100644 --- a/cli/tests/testdata/error_cause_recursive_tail.ts.out +++ b/cli/tests/testdata/error_cause_recursive_tail.ts.out @@ -3,10 +3,9 @@ error: Uncaught Error: baz const baz = new Error("baz", { cause: bar }); ^ at file:///[WILDCARD]/error_cause_recursive_tail.ts:3:13 -Caused by: Error: bar +Caused by: Error: bar at file:///[WILDCARD]/error_cause_recursive_tail.ts:2:13 Caused by: Error: foo at file:///[WILDCARD]/error_cause_recursive_tail.ts:1:13 -Caused by: Error: bar - at file:///[WILDCARD]/error_cause_recursive_tail.ts:2:13 +Caused by: [Circular *1] [WILDCARD] diff --git a/cli/tests/testdata/run/error_cause_recursive.ts.out b/cli/tests/testdata/run/error_cause_recursive.ts.out index ac729574d3..3ae440cf79 100644 --- a/cli/tests/testdata/run/error_cause_recursive.ts.out +++ b/cli/tests/testdata/run/error_cause_recursive.ts.out @@ -1,10 +1,8 @@ [WILDCARD] -error: Uncaught Error: bar +error: Uncaught Error: bar const y = new Error("bar", { cause: x }); ^ at file:///[WILDCARD]/error_cause_recursive.ts:2:11 Caused by: Error: foo at file:///[WILDCARD]/error_cause_recursive.ts:1:11 -Caused by: Error: bar - at file:///[WILDCARD]/error_cause_recursive.ts:2:11 -[WILDCARD] +Caused by: [Circular *1] diff --git a/core/error.rs b/core/error.rs index 7df5c1efa0..5ee3355c44 100644 --- a/core/error.rs +++ b/core/error.rs @@ -119,6 +119,8 @@ pub fn to_v8_error<'a>( /// A `JsError` represents an exception coming from V8, with stack frames and /// line numbers. The deno_cli crate defines another `JsError` type, which wraps /// the one defined here, that adds source map support and colorful formatting. +/// When updating this struct, also update errors_are_equal_without_cause() in +/// fmt_error.rs. #[derive(Debug, PartialEq, Clone, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct JsError { diff --git a/runtime/fmt_errors.rs b/runtime/fmt_errors.rs index aa1f60450e..e285d07b48 100644 --- a/runtime/fmt_errors.rs +++ b/runtime/fmt_errors.rs @@ -9,6 +9,37 @@ use deno_core::error::JsError; use deno_core::error::JsStackFrame; use std::fmt::Write as _; +/// Compares all properties of JsError, except for JsError::cause. +/// This function is used to detect that 2 JsError objects in a JsError::cause +/// chain are identical, ie. there is a recursive cause. +/// 02_console.js, which also detects recursive causes, can use JS object +/// comparisons to compare errors. We don't have access to JS object identity in +/// format_js_error(). +fn errors_are_equal_without_cause(a: &JsError, b: &JsError) -> bool { + a.name == b.name + && a.message == b.message + && a.stack == b.stack + // `a.cause == b.cause` omitted, because it is absent in recursive errors, + // despite the error being identical to a previously seen one. + && a.exception_message == b.exception_message + && a.frames == b.frames + && a.source_line == b.source_line + && a.source_line_frame_index == b.source_line_frame_index + && a.aggregated == b.aggregated +} + +#[derive(Debug, Clone)] +struct ErrorReference<'a> { + from: &'a JsError, + to: &'a JsError, +} + +#[derive(Debug, Clone)] +struct IndexedErrorReference<'a> { + reference: ErrorReference<'a>, + index: usize, +} + // Keep in sync with `/core/error.js`. pub fn format_location(frame: &JsStackFrame) -> String { let _internal = frame @@ -150,25 +181,90 @@ fn format_maybe_source_line( format!("\n{}{}\n{}{}", indent, source_line, indent, color_underline) } -fn format_js_error_inner(js_error: &JsError, is_child: bool) -> String { - let mut s = String::new(); - s.push_str(&js_error.exception_message); - if let Some(aggregated) = &js_error.aggregated { - for aggregated_error in aggregated { - let error_string = format_js_error_inner(aggregated_error, true); - for line in error_string.trim_start_matches("Uncaught ").lines() { - write!(s, "\n {}", line).unwrap(); - } +fn find_recursive_cause(js_error: &JsError) -> Option { + let mut history = Vec::<&JsError>::new(); + + let mut current_error: &JsError = js_error; + + while let Some(cause) = ¤t_error.cause { + history.push(current_error); + + if let Some(seen) = history + .iter() + .find(|&el| errors_are_equal_without_cause(el, cause.as_ref())) + { + return Some(ErrorReference { + from: current_error, + to: *seen, + }); + } else { + current_error = cause; } } + + None +} + +fn format_aggregated_error( + aggregated_errors: &Vec, + circular_reference_index: usize, +) -> String { + let mut s = String::new(); + let mut nested_circular_reference_index = circular_reference_index; + + for js_error in aggregated_errors { + let aggregated_circular = find_recursive_cause(js_error); + if aggregated_circular.is_some() { + nested_circular_reference_index += 1; + } + let error_string = format_js_error_inner( + js_error, + aggregated_circular.map(|reference| IndexedErrorReference { + reference, + index: nested_circular_reference_index, + }), + false, + ); + + for line in error_string.trim_start_matches("Uncaught ").lines() { + write!(s, "\n {}", line).unwrap(); + } + } + + s +} + +fn format_js_error_inner( + js_error: &JsError, + circular: Option, + include_source_code: bool, +) -> String { + let mut s = String::new(); + + s.push_str(&js_error.exception_message); + + if let Some(circular) = &circular { + if errors_are_equal_without_cause(js_error, circular.reference.to) { + write!(s, " {}", cyan(format!("", circular.index))).unwrap(); + } + } + + if let Some(aggregated) = &js_error.aggregated { + let aggregated_message = format_aggregated_error( + aggregated, + circular.as_ref().map_or(0, |circular| circular.index), + ); + s.push_str(&aggregated_message); + } + let column_number = js_error .source_line_frame_index .and_then(|i| js_error.frames.get(i).unwrap().column_number); s.push_str(&format_maybe_source_line( - if is_child { - None - } else { + if include_source_code { js_error.source_line.as_deref() + } else { + None }, column_number, true, @@ -178,7 +274,16 @@ fn format_js_error_inner(js_error: &JsError, is_child: bool) -> String { write!(s, "\n at {}", format_frame(frame)).unwrap(); } if let Some(cause) = &js_error.cause { - let error_string = format_js_error_inner(cause, true); + let is_caused_by_circular = circular.as_ref().map_or(false, |circular| { + errors_are_equal_without_cause(circular.reference.from, js_error) + }); + + let error_string = if is_caused_by_circular { + cyan(format!("[Circular *{}]", circular.unwrap().index)).to_string() + } else { + format_js_error_inner(cause, circular, false) + }; + write!( s, "\nCaused by: {}", @@ -191,7 +296,13 @@ fn format_js_error_inner(js_error: &JsError, is_child: bool) -> String { /// Format a [`JsError`] for terminal output. pub fn format_js_error(js_error: &JsError) -> String { - format_js_error_inner(js_error, false) + let circular = + find_recursive_cause(js_error).map(|reference| IndexedErrorReference { + reference, + index: 1, + }); + + format_js_error_inner(js_error, circular, true) } #[cfg(test)]