shuyi_platform/crash.rs
1//! Crash reports (ADR-0079): what a backend's panic hook writes through the
2//! game's [`Storage`], and what the game hears of it at its next start
3//! ([`Setup::last_crash`](crate::Setup::last_crash)).
4//!
5//! A report ([`CrashReport`]) is stored at `crash-<when>` ([`crash_key`]),
6//! `when` being milliseconds since 1970 on the backend's clock. It holds the
7//! panic's message, place, thread and backtrace, the engine's, the game's
8//! and the target's versions, the frame and the screen that was running,
9//! the last lines of the log and the game's own record of its play. It never
10//! holds the player's settings, saves or keymap, nor what the player typed
11//! unless the game's record holds it. Reports stay on the machine.
12//!
13//! A report is a record in storage, like the settings and the profiles, not
14//! an asset kind a game ships, so no format is registered for it. Its reader
15//! ([`CrashReport::parse`]) still keeps ADR-0007 ยง5: a size limit, errors and
16//! never a panic.
17
18use crate::alloc_prelude::*;
19use alloc::format;
20
21use serde::{Deserialize, Serialize};
22use shuyi_ui::storage::{Storage, StorageError};
23
24/// The version of the report's text this build writes.
25pub const REPORT_VERSION: u32 = 1;
26/// The engine's version a report names: every `shuyi-*` crate has the
27/// workspace's.
28pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
29/// The longest message kept, in bytes.
30pub const MESSAGE_BYTES: usize = 4 * 1024;
31/// The longest backtrace kept, in bytes.
32pub const BACKTRACE_BYTES: usize = 32 * 1024;
33/// The most lines of the log kept: the last ones.
34pub const LOG_LINES: usize = 200;
35/// The longest line of the log kept, in bytes.
36pub const LOG_LINE_BYTES: usize = 1024;
37/// The longest record of the game's play kept, in bytes: a longer one is
38/// left out ([`CrashReport::set_record`]).
39pub const RECORD_BYTES: usize = 1024 * 1024;
40/// The longest text [`CrashReport::parse`] reads, in bytes.
41pub const TEXT_BYTES: usize = 2 * 1024 * 1024;
42/// The key that names the last report offered to the game
43/// ([`offer_last_crash`]).
44pub const OFFERED_KEY: &str = "crash-offered";
45
46/// What every report's key starts with.
47const PREFIX: &str = "crash-";
48
49/// What a backend's panic hook found: plain data, written with
50/// [`to_text`](Self::to_text) and read with [`parse`](Self::parse). Every
51/// field but [`version`](Self::version) and [`message`](Self::message) may
52/// be missing from a report's text, so a newer field never stops an older
53/// report from reading.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub struct CrashReport {
57 /// The version of its text: [`REPORT_VERSION`].
58 pub version: u32,
59 /// When it happened: milliseconds since 1970 on the backend's clock.
60 #[serde(default)]
61 pub when: u64,
62 /// The panic's message, cut at [`MESSAGE_BYTES`].
63 pub message: String,
64 /// Whether the panic ended the game: one on the thread that runs it.
65 /// A panic on another thread (a sound library's, with no sound device)
66 /// is kept, and never offered ([`offer_last_crash`]). A report without
67 /// the field, older than it, reads as `true`.
68 #[serde(default = "ended_the_game")]
69 pub fatal: bool,
70 /// Where it panicked: `file:line:column`.
71 #[serde(default)]
72 pub location: Option<String>,
73 /// The thread's name, if it has one.
74 #[serde(default)]
75 pub thread: Option<String>,
76 /// The backtrace as the standard library writes it, cut at
77 /// [`BACKTRACE_BYTES`]. A stripped build's frames are `<unknown>`.
78 #[serde(default)]
79 pub backtrace: String,
80 /// The engine's version ([`ENGINE_VERSION`]).
81 #[serde(default)]
82 pub engine: String,
83 /// The game's version, empty if the game gave none.
84 #[serde(default)]
85 pub game: String,
86 /// The OS, the processor and the build: `linux x86_64 release`.
87 #[serde(default)]
88 pub target: String,
89 /// The frame that was running, counted as
90 /// [`App::frame_number`](crate::App::frame_number) counts them.
91 #[serde(default)]
92 pub frame: u32,
93 /// The name of the screen on top, if the game was running.
94 #[serde(default)]
95 pub top_screen: Option<String>,
96 /// The last [`LOG_LINES`] lines of the log, oldest first, each cut at
97 /// [`LOG_LINE_BYTES`].
98 #[serde(default)]
99 pub log: Vec<String>,
100 /// The game's record of its play, in the format its tools' `replay`
101 /// reads ([`Start::crash_record`](crate::Start::crash_record)).
102 #[serde(default)]
103 pub record: Option<String>,
104}
105
106impl CrashReport {
107 /// A report of `message` at `when`, cut at [`MESSAGE_BYTES`], and
108 /// nothing else known yet.
109 pub fn new(when: u64, message: impl Into<String>) -> Self {
110 let mut message = message.into();
111 cut(&mut message, MESSAGE_BYTES);
112 Self {
113 version: REPORT_VERSION,
114 when,
115 message,
116 fatal: true,
117 location: None,
118 thread: None,
119 backtrace: String::new(),
120 engine: ENGINE_VERSION.to_owned(),
121 game: String::new(),
122 target: String::new(),
123 frame: 0,
124 top_screen: None,
125 log: Vec::new(),
126 record: None,
127 }
128 }
129
130 /// Cuts the message, the backtrace and the log to their limits: the
131 /// last [`LOG_LINES`] lines, each at [`LOG_LINE_BYTES`].
132 pub fn limit(&mut self) {
133 cut(&mut self.message, MESSAGE_BYTES);
134 cut(&mut self.backtrace, BACKTRACE_BYTES);
135 let extra = self.log.len().saturating_sub(LOG_LINES);
136 self.log.drain(..extra);
137 for line in &mut self.log {
138 cut(line, LOG_LINE_BYTES);
139 }
140 }
141
142 /// Sets the game's record of its play, unless it is over
143 /// [`RECORD_BYTES`]: then the record stays `None`, and the log's last
144 /// line says why.
145 pub fn set_record(&mut self, record: Option<String>) {
146 match record {
147 Some(text) if text.len() > RECORD_BYTES => {
148 self.record = None;
149 self.log.push(format!(
150 "the game's record was left out: {} bytes, over the {RECORD_BYTES} a report keeps",
151 text.len()
152 ));
153 self.limit();
154 }
155 record => self.record = record,
156 }
157 }
158
159 /// The report's text: RON, with `\n` line endings on every OS.
160 pub fn to_text(&self) -> String {
161 let config = ron::ser::PrettyConfig::new().indentor(" ").new_line("\n");
162 // RON refuses no value a report holds; a hook writes nothing rather
163 // than panic.
164 ron::ser::to_string_pretty(self, config).unwrap_or_default()
165 }
166
167 /// The report `text` holds, or why it holds none: text over
168 /// [`TEXT_BYTES`] isn't read. Never panics.
169 pub fn parse(text: &str) -> Result<Self, String> {
170 if text.len() > TEXT_BYTES {
171 return Err(format!(
172 "a crash report of {} bytes, over the {TEXT_BYTES} one may be",
173 text.len()
174 ));
175 }
176 ron::from_str(text).map_err(|e| e.to_string())
177 }
178}
179
180/// What a report without [`CrashReport::fatal`] reads as: every report
181/// written before it was offered as a crash.
182const fn ended_the_game() -> bool {
183 true
184}
185
186/// `text` cut at `bytes` bytes, at the last whole character that fits.
187fn cut(text: &mut String, bytes: usize) {
188 // At or past its end, the boundary is its end: nothing is cut.
189 let end = text.floor_char_boundary(bytes);
190 text.truncate(end);
191}
192
193/// The key a report made at `when` is stored at: `crash-<when>`.
194pub fn crash_key(when: u64) -> String {
195 format!("{PREFIX}{when}")
196}
197
198/// When the report at `key` was made, if `key` is a report's: `crash-` and
199/// digits only.
200pub fn report_when(key: &str) -> Option<u64> {
201 let digits = key.strip_prefix(PREFIX)?;
202 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
203 return None;
204 }
205 digits.parse().ok()
206}
207
208/// The keys of the reports in `storage`, newest first. Every other key is
209/// ignored, and a storage that can't list gives none.
210pub fn report_keys(storage: &dyn Storage) -> Vec<String> {
211 let mut keys: Vec<(u64, String)> = storage
212 .list()
213 .unwrap_or_default()
214 .into_iter()
215 .filter_map(|key| Some((report_when(&key)?, key)))
216 .collect();
217 keys.sort_by_key(|(when, _)| core::cmp::Reverse(*when));
218 keys.into_iter().map(|(_, key)| key).collect()
219}
220
221/// Deletes all but the newest `n` reports in `storage`.
222pub fn keep_newest(storage: &mut dyn Storage, n: usize) -> Result<(), StorageError> {
223 for key in report_keys(storage).iter().skip(n) {
224 storage.delete(key)?;
225 }
226 Ok(())
227}
228
229/// What a game hears at its start of the last crash: when, the message and
230/// the place, and the report's key in its storage. What the player sees of
231/// it, if anything, is the game's to word.
232#[derive(Debug, Clone, PartialEq, Eq)]
233#[non_exhaustive]
234pub struct LastCrash {
235 /// When it happened: milliseconds since 1970.
236 pub when: u64,
237 /// The panic's message.
238 pub message: String,
239 /// Where it panicked: `file:line:column`.
240 pub location: Option<String>,
241 /// The report's key in the game's storage.
242 pub key: String,
243}
244
245/// The newest [`fatal`](CrashReport::fatal) report in `storage` newer than
246/// the one [`OFFERED_KEY`] names; from then on it names the newest report,
247/// so each is offered or skipped once. A report that isn't fatal, or
248/// doesn't read, is skipped.
249pub fn offer_last_crash(storage: &mut dyn Storage) -> Option<LastCrash> {
250 let offered = storage
251 .read(OFFERED_KEY)
252 .ok()
253 .flatten()
254 .and_then(|text| ron::from_str::<String>(&text).ok())
255 .and_then(|key| report_when(&key));
256 let newer: Vec<String> = report_keys(storage)
257 .into_iter()
258 .filter(|key| report_when(key) > offered)
259 .collect();
260 let newest = newer.first()?;
261 let marked = ron::to_string(newest).unwrap_or_default();
262 // A storage that can't write offers it again next time: no harm.
263 let _ = storage.write(OFFERED_KEY, &marked);
264 newer.into_iter().find_map(|key| {
265 let report = CrashReport::parse(&storage.read(&key).ok()??).ok()?;
266 report.fatal.then_some(LastCrash {
267 when: report.when,
268 message: report.message,
269 location: report.location,
270 key,
271 })
272 })
273}
274
275#[cfg(test)]
276mod tests;