1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//! What a shipped build does when one of its systems panics: says so in
//! the log, and stops the round it was in, rather than closing the game.
//!
//! At a party one bad system used to take a player out of the match and
//! off the beach. Bevy 0.20 hands a panic in a system, a command or an
//! observer to the fallback error handler instead of unwinding through the
//! app, and this is that handler, installed in release builds only: a
//! debug build still panics, so a bug is found where it happens.
//!
//! A round is not played on after one. The handler cannot tell which
//! system failed (Bevy keeps system names only in its `debug` builds), and
//! a panic in the middle of a tick leaves the board half moved: online the
//! hash check would end the round at the next comparison anyway, and
//! offline nothing would. So the round is stopped cleanly and the player
//! is told, and outside a round the game simply carries on.
use super::*;
use bevy::ecs::error::{BevyError, ErrorContext, Severity, match_severity};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
/// A panic was caught since the last frame looked.
static TRIPPED: AtomicBool = AtomicBool::new(false);
/// Each panic's text, once: a system that panics every frame would
/// otherwise write its line sixty times a second.
static SEEN: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The fallback error handler for a shipped build: a panic is logged and
/// noted, and every other error goes where Bevy would send it.
pub(super) fn keep_going(error: BevyError, ctx: ErrorContext) {
if error.severity() != Severity::Panic {
match_severity(error, ctx);
return;
}
TRIPPED.store(true, Ordering::Relaxed);
let line = format!("{ctx}: {error}");
let mut seen = SEEN
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !seen.contains(&line) {
error!("caught a panic and carried on: {line}");
seen.push(line);
}
}
/// Install [`keep_going`] in a release build. A debug build keeps Bevy's
/// own handler, which panics.
pub(super) fn install(app: &mut App) {
if cfg!(not(debug_assertions)) {
app.insert_resource(bevy::ecs::error::FallbackErrorHandler(keep_going));
}
}
/// After a caught panic, leave the round being played for the menu, and
/// say why there. Outside a round there is nothing to leave.
pub(super) fn leave_a_broken_round(
screen: Res<State<Screen>>,
settings: Res<settings::GameSettings>,
mut notice: ResMut<RoundNotice>,
mut next_screen: ResMut<NextState<Screen>>,
) {
if !TRIPPED.swap(false, Ordering::Relaxed) {
return;
}
if matches!(
screen.get(),
Screen::Puzzle | Screen::Versus | Screen::Interlude | Screen::Editor
) {
notice.0 = settings.tr().round_failed.to_string();
next_screen.set(Screen::Menu);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A system that panics under this handler leaves the app running, and
/// the round it was in is left for the menu with a word about it.
fn a_system_with_a_bug() {
panic!("a system with a bug (expected by this test)");
}
#[test]
fn a_panic_ends_the_round_and_not_the_game() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.insert_state(Screen::Versus);
app.insert_resource(settings::GameSettings::default());
app.init_resource::<RoundNotice>();
app.insert_resource(bevy::ecs::error::FallbackErrorHandler(keep_going));
app.add_systems(Update, (a_system_with_a_bug, leave_a_broken_round).chain());
// The panic's message is printed by the default hook: it is this
// test's, and expected. The hook is not swapped out to hide it,
// because it is process-wide and would hide other tests' failures.
app.update();
app.update();
assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
assert_eq!(
app.world().resource::<RoundNotice>().0,
i18n::EN.round_failed
);
}
}