1use std::cell::Cell;
31use std::collections::VecDeque;
32use std::panic::PanicHookInfo;
33use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
34use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
35use std::thread::ThreadId;
36
37use shuyi_platform::crash::{CrashReport, LOG_LINES, crash_key, keep_newest};
38use shuyi_ui::storage::Storage;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct CrashReports {
46 pub game_version: &'static str,
48 pub keep: u32,
50}
51
52impl CrashReports {
53 #[must_use]
56 pub const fn new(game_version: &'static str) -> Self {
57 Self {
58 game_version,
59 keep: if cfg!(target_arch = "wasm32") { 1 } else { 5 },
60 }
61 }
62
63 #[must_use]
65 pub const fn off() -> Self {
66 Self {
67 game_version: "",
68 keep: 0,
69 }
70 }
71}
72
73impl Default for CrashReports {
74 fn default() -> Self {
75 Self::new("")
76 }
77}
78
79pub const PANIC_ON_PURPOSE: &str = "a panic on purpose (SHUYI_PANIC_AT_FRAME)";
81
82type StorageMaker = Arc<dyn Fn() -> Box<dyn Storage> + Send + Sync>;
84
85struct Installed {
87 reports: CrashReports,
88 storage: StorageMaker,
89 game_thread: ThreadId,
91}
92
93struct Written {
95 key: String,
96 report: CrashReport,
97}
98
99static HOOKED: AtomicBool = AtomicBool::new(false);
101static INSTALLED: Mutex<Option<Installed>> = Mutex::new(None);
103static FRAME: AtomicU32 = AtomicU32::new(0);
105static TOP_SCREEN: Mutex<Option<&'static str>> = Mutex::new(None);
107static LOG: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
109static WRITTEN: Mutex<Option<Written>> = Mutex::new(None);
111
112thread_local! {
113 static QUIET: Cell<bool> = const { Cell::new(false) };
115}
116
117fn try_lock<T>(mutex: &Mutex<T>) -> Option<MutexGuard<'_, T>> {
119 match mutex.try_lock() {
120 Ok(guard) => Some(guard),
121 Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()),
122 Err(TryLockError::WouldBlock) => None,
123 }
124}
125
126fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
128 mutex
129 .lock()
130 .unwrap_or_else(std::sync::PoisonError::into_inner)
131}
132
133pub fn install(
140 reports: CrashReports,
141 storage: impl Fn() -> Box<dyn Storage> + Send + Sync + 'static,
142) {
143 if reports.keep == 0 {
144 *lock(&INSTALLED) = None;
145 return;
146 }
147 *lock(&INSTALLED) = Some(Installed {
148 reports,
149 storage: Arc::new(storage),
150 game_thread: std::thread::current().id(),
151 });
152 if HOOKED.swap(true, Ordering::SeqCst) {
153 return;
154 }
155 let before = std::panic::take_hook();
156 std::panic::set_hook(Box::new(move |info| {
157 if !QUIET.with(Cell::get) {
158 report(info);
159 }
160 before(info);
161 }));
162}
163
164pub fn set_frame(frame: u32, top_screen: Option<&'static str>) {
167 FRAME.store(frame, Ordering::Relaxed);
168 if let Some(mut top) = try_lock(&TOP_SCREEN) {
169 *top = top_screen;
170 }
171}
172
173pub fn note_log(line: String) {
175 let mut log = lock(&LOG);
176 if log.len() == LOG_LINES {
177 log.pop_front();
178 }
179 log.push_back(line);
180}
181
182pub fn quiet<R>(f: impl FnOnce() -> R) -> R {
185 struct Restore(bool);
187 impl Drop for Restore {
188 fn drop(&mut self) {
189 QUIET.with(|quiet| quiet.set(self.0));
190 }
191 }
192 let _restore = Restore(QUIET.with(|quiet| quiet.replace(true)));
193 f()
194}
195
196pub fn add_record(record: Option<String>) {
201 let Some(Written { key, mut report }) = lock(&WRITTEN).take() else {
202 return;
203 };
204 let Some(storage) = lock(&INSTALLED).as_ref().map(|i| Arc::clone(&i.storage)) else {
205 return;
206 };
207 report.set_record(record);
208 let _ = storage().write(&key, &report.to_text());
209}
210
211pub(crate) fn panic_at_frame() -> Option<u32> {
213 #[cfg(not(target_arch = "wasm32"))]
214 {
215 std::env::var("SHUYI_PANIC_AT_FRAME")
216 .ok()?
217 .trim()
218 .parse()
219 .ok()
220 }
221 #[cfg(target_arch = "wasm32")]
222 {
223 None
224 }
225}
226
227fn now_ms() -> u64 {
229 #[cfg(not(target_arch = "wasm32"))]
230 {
231 let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
232 since.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
233 }
234 #[cfg(target_arch = "wasm32")]
235 {
236 shuyi_platform::Millis::from_secs_f64(macroquad::miniquad::date::now()).0
237 }
238}
239
240fn message(info: &PanicHookInfo<'_>) -> String {
242 let payload = info.payload();
243 if let Some(text) = payload.downcast_ref::<&str>() {
244 (*text).to_owned()
245 } else if let Some(text) = payload.downcast_ref::<String>() {
246 text.clone()
247 } else {
248 "a panic with no text".to_owned()
249 }
250}
251
252fn build(info: &PanicHookInfo<'_>, reports: CrashReports, on_game_thread: bool) -> CrashReport {
255 let mut report = CrashReport::new(now_ms(), message(info));
256 report.fatal = on_game_thread;
257 report.location = info
258 .location()
259 .map(|at| format!("{}:{}:{}", at.file(), at.line(), at.column()));
260 report.thread = std::thread::current().name().map(ToOwned::to_owned);
261 report.backtrace = std::backtrace::Backtrace::force_capture().to_string();
262 reports.game_version.clone_into(&mut report.game);
263 let build = if cfg!(debug_assertions) {
264 "debug"
265 } else {
266 "release"
267 };
268 let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
269 report.target = format!("{os} {arch} {build}");
270 report.frame = FRAME.load(Ordering::Relaxed);
271 report.top_screen = try_lock(&TOP_SCREEN)
272 .and_then(|top| *top)
273 .map(ToOwned::to_owned);
274 report.log = try_lock(&LOG)
275 .map(|log| log.iter().cloned().collect())
276 .unwrap_or_default();
277 report.limit();
278 report
279}
280
281fn report(info: &PanicHookInfo<'_>) {
285 let Some((reports, storage, game_thread)) = try_lock(&INSTALLED).and_then(|i| {
286 i.as_ref()
287 .map(|i| (i.reports, Arc::clone(&i.storage), i.game_thread))
288 }) else {
289 return;
290 };
291 let on_game_thread = std::thread::current().id() == game_thread;
292 let mut report = build(info, reports, on_game_thread);
293 let mut storage = storage();
294 while matches!(storage.read(&crash_key(report.when)), Ok(Some(_))) {
295 report.when = report.when.saturating_add(1);
296 }
297 let key = crash_key(report.when);
298 if storage.write(&key, &report.to_text()).is_err() {
299 return;
300 }
301 let keep = usize::try_from(reports.keep).unwrap_or(usize::MAX);
302 let _ = keep_newest(storage.as_mut(), keep);
303 if let Some(mut written) = try_lock(&WRITTEN) {
304 *written = Some(Written { key, report });
305 }
306}