Skip to content

Commit cca5813

Browse files
authored
BevyError: Bevy's new catch-all error type (#18144)
## Objective Fixes #18092 Bevy's current error type is a simple type alias for `Box<dyn Error + Send + Sync + 'static>`. This largely works as a catch-all error, but it is missing a critical feature: the ability to capture a backtrace at the point that the error occurs. The best way to do this is `anyhow`-style error handling: a new error type that takes advantage of the fact that the `?` `From` conversion happens "inline" to capture the backtrace at the point of the error. ## Solution This PR adds a new `BevyError` type (replacing our old `std::error::Error` type alias), which uses the "from conversion backtrace capture" approach: ```rust fn oh_no() -> Result<(), BevyError> { // this fails with Rust's built in ParseIntError, which // is converted into the catch-all BevyError type let number: usize = "hi".parse()?; println!("parsed {number}"); Ok(()) } ``` This also updates our exported `Result` type alias to default to `BevyError`, meaning you can write this instead: ```rust fn oh_no() -> Result { let number: usize = "hi".parse()?; println!("parsed {number}"); Ok(()) } ``` When a BevyError is encountered in a system, it will use Bevy's default system error handler (which panics by default). BevyError does custom "backtrace filtering" by default, meaning we can cut out the _massive_ amount of "rust internals", "async executor internals", and "bevy system scheduler internals" that show up in backtraces. It also trims out the first generally-unnecssary `From` conversion backtrace lines that make it harder to locate the real error location. The result is a blissfully simple backtrace by default: ![image](https://github.com/user-attachments/assets/7a5f5c9b-ea70-4176-af3b-d231da31c967) The full backtrace can be shown by setting the `BEVY_BACKTRACE=full` environment variable. Non-BevyError panics still use the default Rust backtrace behavior. One issue that prevented the truly noise-free backtrace during panics that you see above is that Rust's default panic handler will print the unfiltered (and largely unhelpful real-panic-point) backtrace by default, in _addition_ to our filtered BevyError backtrace (with the helpful backtrace origin) that we capture and print. To resolve this, I have extended Bevy's existing PanicHandlerPlugin to wrap the default panic handler. If we panic from the result of a BevyError, we will skip the default "print full backtrace" panic handler. This behavior can be enabled and disabled using the new `error_panic_hook` cargo feature in `bevy_app` (which is enabled by default). One downside to _not_ using `Box<dyn Error>` directly is that we can no longer take advantage of the built-in `Into` impl for strings to errors. To resolve this, I have added the following: ```rust // Before Err("some error")? // After Err(BevyError::message("some error"))? ``` We can discuss adding shorthand methods or macros for this (similar to anyhow's `anyhow!("some error")` macro), but I'd prefer to discuss that later. I have also added the following extension method: ```rust // Before some_option.ok_or("some error")?; // After some_option.ok_or_message("some error")?; ``` I've also moved all of our existing error infrastructure from `bevy_ecs::result` to `bevy_ecs::error`, as I think that is the better home for it ## Why not anyhow (or eyre)? The biggest reason is that `anyhow` needs to be a "generically useful error type", whereas Bevy is a much narrower scope. By using our own error, we can be significantly more opinionated. For example, anyhow doesn't do the extensive (and invasive) backtrace filtering that BevyError does because it can't operate on Bevy-specific context, and needs to be generically useful. Bevy also has a lot of operational context (ex: system info) that could be useful to attach to errors. If we have control over the error type, we can add whatever context we want to in a structured way. This could be increasingly useful as we add more visual / interactive error handling tools and editor integrations. Additionally, the core approach used is simple and requires almost no code. anyhow clocks in at ~2500 lines of code, but the impl here uses 160. We are able to boil this down to exactly what we need, and by doing so we improve our compile times and the understandability of our code.
1 parent 8f85d4e commit cca5813

26 files changed

+417
-146
lines changed

crates/bevy_app/Cargo.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ license = "MIT OR Apache-2.0"
99
keywords = ["bevy"]
1010

1111
[features]
12-
default = ["std", "bevy_reflect", "bevy_tasks", "bevy_ecs/default"]
12+
default = [
13+
"std",
14+
"bevy_reflect",
15+
"bevy_tasks",
16+
"bevy_ecs/default",
17+
"error_panic_hook",
18+
]
1319

1420
# Functionality
1521

@@ -36,6 +42,10 @@ trace = ["dep:tracing"]
3642
## other debug operations which can help with diagnosing certain behaviors.
3743
bevy_debug_stepping = []
3844

45+
## Will set the BevyError panic hook, which gives cleaner filtered backtraces when
46+
## a BevyError is hit.
47+
error_panic_hook = []
48+
3949
# Platform Compatibility
4050

4151
## Allows access to the `std` crate. Enabling this feature will prevent compilation

crates/bevy_app/src/app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ use alloc::{
1010
pub use bevy_derive::AppLabel;
1111
use bevy_ecs::{
1212
component::RequiredComponentsError,
13+
error::{BevyError, SystemErrorContext},
1314
event::{event_update_system, EventCursor},
1415
intern::Interned,
1516
prelude::*,
16-
result::{Error, SystemErrorContext},
1717
schedule::{ScheduleBuildSettings, ScheduleLabel},
1818
system::{IntoObserverSystem, SystemId, SystemInput},
1919
};
@@ -1280,7 +1280,7 @@ impl App {
12801280
/// for more information.
12811281
pub fn set_system_error_handler(
12821282
&mut self,
1283-
error_handler: fn(Error, SystemErrorContext),
1283+
error_handler: fn(BevyError, SystemErrorContext),
12841284
) -> &mut Self {
12851285
self.main_mut().set_system_error_handler(error_handler);
12861286
self

crates/bevy_app/src/panic_handler.rs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,28 @@ pub struct PanicHandlerPlugin;
3939

4040
impl Plugin for PanicHandlerPlugin {
4141
fn build(&self, _app: &mut App) {
42-
#[cfg(target_arch = "wasm32")]
42+
#[cfg(feature = "std")]
4343
{
44-
console_error_panic_hook::set_once();
45-
}
46-
#[cfg(not(target_arch = "wasm32"))]
47-
{
48-
// Use the default target panic hook - Do nothing.
44+
static SET_HOOK: std::sync::Once = std::sync::Once::new();
45+
SET_HOOK.call_once(|| {
46+
#[cfg(target_arch = "wasm32")]
47+
{
48+
// This provides better panic handling in JS engines (displays the panic message and improves the backtrace).
49+
std::panic::set_hook(alloc::boxed::Box::new(console_error_panic_hook::hook));
50+
}
51+
#[cfg(not(target_arch = "wasm32"))]
52+
{
53+
#[cfg(feature = "error_panic_hook")]
54+
{
55+
let current_hook = std::panic::take_hook();
56+
std::panic::set_hook(alloc::boxed::Box::new(
57+
bevy_ecs::error::bevy_error_panic_hook(current_hook),
58+
));
59+
}
60+
61+
// Otherwise use the default target panic hook - Do nothing.
62+
}
63+
});
4964
}
5065
}
5166
}

crates/bevy_app/src/sub_app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use crate::{App, AppLabel, InternedAppLabel, Plugin, Plugins, PluginsState};
22
use alloc::{boxed::Box, string::String, vec::Vec};
33
use bevy_ecs::{
4+
error::{DefaultSystemErrorHandler, SystemErrorContext},
45
event::EventRegistry,
56
prelude::*,
6-
result::{DefaultSystemErrorHandler, SystemErrorContext},
77
schedule::{InternedScheduleLabel, ScheduleBuildSettings, ScheduleLabel},
88
system::{SystemId, SystemInput},
99
};
@@ -342,7 +342,7 @@ impl SubApp {
342342
/// for more information.
343343
pub fn set_system_error_handler(
344344
&mut self,
345-
error_handler: fn(Error, SystemErrorContext),
345+
error_handler: fn(BevyError, SystemErrorContext),
346346
) -> &mut Self {
347347
let mut default_handler = self
348348
.world_mut()

crates/bevy_ecs/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ categories = ["game-engines", "data-structures"]
1111
rust-version = "1.85.0"
1212

1313
[features]
14-
default = ["std", "bevy_reflect", "async_executor"]
14+
default = ["std", "bevy_reflect", "async_executor", "backtrace"]
1515

1616
# Functionality
1717

@@ -36,6 +36,9 @@ reflect_functions = ["bevy_reflect", "bevy_reflect/functions"]
3636
## Use the configurable global error handler as the default error handler
3737
configurable_error_handler = []
3838

39+
## Enables automatic backtrace capturing in BevyError
40+
backtrace = []
41+
3942
# Debugging Features
4043

4144
## Enables `tracing` integration, allowing spans and other metrics to be reported
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
use alloc::boxed::Box;
2+
use core::{
3+
error::Error,
4+
fmt::{Debug, Display},
5+
};
6+
7+
/// The built in "universal" Bevy error type. This has a blanket [`From`] impl for any type that implements Rust's [`Error`],
8+
/// meaning it can be used as a "catch all" error.
9+
///
10+
/// # Backtraces
11+
///
12+
/// When used with the `backtrace` Cargo feature, it will capture a backtrace when the error is constructed (generally in the [`From`] impl]).
13+
/// When printed, the backtrace will be displayed. By default, the backtrace will be trimmed down to filter out noise. To see the full backtrace,
14+
/// set the `BEVY_BACKTRACE=full` environment variable.
15+
///
16+
/// # Usage
17+
///
18+
/// ```
19+
/// # use bevy_ecs::prelude::*;
20+
///
21+
/// fn fallible_system() -> Result<(), BevyError> {
22+
/// // This will result in Rust's built-in ParseIntError, which will automatically
23+
/// // be converted into a BevyError.
24+
/// let parsed: usize = "I am not a number".parse()?;
25+
/// Ok(())
26+
/// }
27+
/// ```
28+
pub struct BevyError {
29+
inner: Box<InnerBevyError>,
30+
}
31+
32+
impl BevyError {
33+
/// Attempts to downcast the internal error to the given type.
34+
pub fn downcast_ref<E: Error + 'static>(&self) -> Option<&E> {
35+
self.inner.error.downcast_ref::<E>()
36+
}
37+
}
38+
39+
/// This type exists (rather than having a `BevyError(Box<dyn InnerBevyError)`) to make [`BevyError`] use a "thin pointer" instead of
40+
/// a "fat pointer", which reduces the size of our Result by a usize. This does introduce an extra indirection, but error handling is a "cold path".
41+
/// We don't need to optimize it to that degree.
42+
/// PERF: We could probably have the best of both worlds with a "custom vtable" impl, but thats not a huge priority right now and the code simplicity
43+
/// of the current impl is nice.
44+
struct InnerBevyError {
45+
error: Box<dyn Error + Send + Sync + 'static>,
46+
#[cfg(feature = "backtrace")]
47+
backtrace: std::backtrace::Backtrace,
48+
}
49+
50+
// NOTE: writing the impl this way gives us From<&str> ... nice!
51+
impl<E> From<E> for BevyError
52+
where
53+
Box<dyn Error + Send + Sync + 'static>: From<E>,
54+
{
55+
#[cold]
56+
fn from(error: E) -> Self {
57+
BevyError {
58+
inner: Box::new(InnerBevyError {
59+
error: error.into(),
60+
#[cfg(feature = "backtrace")]
61+
backtrace: std::backtrace::Backtrace::capture(),
62+
}),
63+
}
64+
}
65+
}
66+
67+
impl Display for BevyError {
68+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
69+
writeln!(f, "{}", self.inner.error)?;
70+
Ok(())
71+
}
72+
}
73+
74+
impl Debug for BevyError {
75+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
76+
writeln!(f, "{:?}", self.inner.error)?;
77+
#[cfg(feature = "backtrace")]
78+
{
79+
let backtrace = &self.inner.backtrace;
80+
if let std::backtrace::BacktraceStatus::Captured = backtrace.status() {
81+
let full_backtrace = std::env::var("BEVY_BACKTRACE").is_ok_and(|val| val == "full");
82+
83+
let backtrace_str = alloc::string::ToString::to_string(backtrace);
84+
let mut skip_next_location_line = false;
85+
for line in backtrace_str.split('\n') {
86+
if !full_backtrace {
87+
if skip_next_location_line {
88+
if line.starts_with(" at") {
89+
continue;
90+
}
91+
skip_next_location_line = false;
92+
}
93+
if line.contains("std::backtrace_rs::backtrace::") {
94+
skip_next_location_line = true;
95+
continue;
96+
}
97+
if line.contains("std::backtrace::Backtrace::") {
98+
skip_next_location_line = true;
99+
continue;
100+
}
101+
if line.contains("<bevy_ecs::error::bevy_error::BevyError as core::convert::From<E>>::from") {
102+
skip_next_location_line = true;
103+
continue;
104+
}
105+
if line.contains("<core::result::Result<T,F> as core::ops::try_trait::FromResidual<core::result::Result<core::convert::Infallible,E>>>::from_residual") {
106+
skip_next_location_line = true;
107+
continue;
108+
}
109+
if line.contains("__rust_begin_short_backtrace") {
110+
break;
111+
}
112+
if line.contains("bevy_ecs::observer::Observers::invoke::{{closure}}") {
113+
break;
114+
}
115+
}
116+
writeln!(f, "{}", line)?;
117+
}
118+
if !full_backtrace {
119+
if std::thread::panicking() {
120+
SKIP_NORMAL_BACKTRACE.store(1, core::sync::atomic::Ordering::Relaxed);
121+
}
122+
writeln!(f, "{FILTER_MESSAGE}")?;
123+
}
124+
}
125+
}
126+
127+
Ok(())
128+
}
129+
}
130+
131+
#[cfg(feature = "backtrace")]
132+
const FILTER_MESSAGE: &str = "note: Some \"noisy\" backtrace lines have been filtered out. Run with `BEVY_BACKTRACE=full` for a verbose backtrace.";
133+
134+
#[cfg(feature = "backtrace")]
135+
static SKIP_NORMAL_BACKTRACE: core::sync::atomic::AtomicUsize =
136+
core::sync::atomic::AtomicUsize::new(0);
137+
138+
/// When called, this will skip the currently configured panic hook when a [`BevyError`] backtrace has already been printed.
139+
#[cfg(feature = "std")]
140+
pub fn bevy_error_panic_hook(
141+
current_hook: impl Fn(&std::panic::PanicHookInfo),
142+
) -> impl Fn(&std::panic::PanicHookInfo) {
143+
move |info| {
144+
if SKIP_NORMAL_BACKTRACE.load(core::sync::atomic::Ordering::Relaxed) > 0 {
145+
if let Some(payload) = info.payload().downcast_ref::<&str>() {
146+
std::println!("{payload}");
147+
} else if let Some(payload) = info.payload().downcast_ref::<alloc::string::String>() {
148+
std::println!("{payload}");
149+
}
150+
SKIP_NORMAL_BACKTRACE.store(0, core::sync::atomic::Ordering::Relaxed);
151+
return;
152+
}
153+
154+
current_hook(info);
155+
}
156+
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
161+
#[test]
162+
#[cfg(not(miri))] // miri backtraces are weird
163+
#[cfg(not(windows))] // the windows backtrace in this context is ... unhelpful and not worth testing
164+
fn filtered_backtrace_test() {
165+
fn i_fail() -> crate::error::Result {
166+
let _: usize = "I am not a number".parse()?;
167+
Ok(())
168+
}
169+
170+
// SAFETY: this is not safe ... this test could run in parallel with another test
171+
// that writes the environment variable. We either accept that so we can write this test,
172+
// or we don't.
173+
174+
unsafe { std::env::set_var("RUST_BACKTRACE", "1") };
175+
176+
let error = i_fail().err().unwrap();
177+
let debug_message = alloc::format!("{error:?}");
178+
let mut lines = debug_message.lines().peekable();
179+
assert_eq!(
180+
"ParseIntError { kind: InvalidDigit }",
181+
lines.next().unwrap()
182+
);
183+
184+
// On mac backtraces can start with Backtrace::create
185+
let mut skip = false;
186+
if let Some(line) = lines.peek() {
187+
if &line[6..] == "std::backtrace::Backtrace::create" {
188+
skip = true;
189+
}
190+
}
191+
192+
if skip {
193+
lines.next().unwrap();
194+
}
195+
196+
let expected_lines = alloc::vec![
197+
"bevy_ecs::error::bevy_error::tests::filtered_backtrace_test::i_fail",
198+
"bevy_ecs::error::bevy_error::tests::filtered_backtrace_test",
199+
"bevy_ecs::error::bevy_error::tests::filtered_backtrace_test::{{closure}}",
200+
"core::ops::function::FnOnce::call_once",
201+
];
202+
203+
for expected in expected_lines {
204+
let line = lines.next().unwrap();
205+
assert_eq!(&line[6..], expected);
206+
let mut skip = false;
207+
if let Some(line) = lines.peek() {
208+
if line.starts_with(" at") {
209+
skip = true;
210+
}
211+
}
212+
213+
if skip {
214+
lines.next().unwrap();
215+
}
216+
}
217+
218+
// on linux there is a second call_once
219+
let mut skip = false;
220+
if let Some(line) = lines.peek() {
221+
if &line[6..] == "core::ops::function::FnOnce::call_once" {
222+
skip = true;
223+
}
224+
}
225+
if skip {
226+
lines.next().unwrap();
227+
}
228+
let mut skip = false;
229+
if let Some(line) = lines.peek() {
230+
if line.starts_with(" at") {
231+
skip = true;
232+
}
233+
}
234+
235+
if skip {
236+
lines.next().unwrap();
237+
}
238+
assert_eq!(super::FILTER_MESSAGE, lines.next().unwrap());
239+
assert!(lines.next().is_none());
240+
}
241+
}

0 commit comments

Comments
 (0)