| 1 | //! Local crash capture shared by the desktop and mobile hosts. Uploads belong to the host. |
| 2 | |
| 3 | #[cfg(not(target_arch = "wasm32"))] |
| 4 | mod native; |
| 5 | #[cfg(unix)] |
| 6 | pub use native::record_signal; |
| 7 | |
| 8 | #[cfg(not(target_arch = "wasm32"))] |
| 9 | use std::path::PathBuf; |
| 10 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 11 | use std::sync::{Mutex, OnceLock}; |
| 12 | use web_time::Instant; |
| 13 | |
| 14 | pub const ADDRESS: &std::ffi::CStr = c"https://snowbound.paperclover.net/crash"; |
| 15 | |
| 16 | /// The host's private report file. |
| 17 | #[cfg(not(target_arch = "wasm32"))] |
| 18 | static REPORT: OnceLock<PathBuf> = OnceLock::new(); |
| 19 | /// The backend drawing and its adapter, as "Metal (Apple M2)". |
| 20 | pub static RENDERER: Mutex<String> = Mutex::new(String::new()); |
| 21 | /// Names from the notebook catalog, hidden in panic reports. |
| 22 | static NAMES: Mutex<Vec<String>> = Mutex::new(Vec::new()); |
| 23 | static STARTED: OnceLock<Instant> = OnceLock::new(); |
| 24 | /// The first panic's report is kept; the ones it sets off would only hide it. |
| 25 | static KEPT: AtomicBool = AtomicBool::new(false); |
| 26 | |
| 27 | /// Bounds on what a panic adds, so a report stays well under what the site takes. |
| 28 | const MESSAGE: usize = 2 << 10; |
| 29 | const FRAMES: usize = 48; |
| 30 | const BACKTRACE: usize = 32 << 10; |
| 31 | const NAMED: usize = 256; |
| 32 | |
| 33 | /// Reports each panic on `system`, then hands the report to `then` to log. |
| 34 | pub fn hook( |
| 35 | build: &str, |
| 36 | platform: String, |
| 37 | system: String, |
| 38 | then: impl Fn(&str) + Send + Sync + 'static, |
| 39 | ) { |
| 40 | let heading = format!("Snowbound {build}, {platform}\nSystem: {system}\n"); |
| 41 | #[cfg(not(target_arch = "wasm32"))] |
| 42 | let _ = native::HEADING.set(heading.clone()); |
| 43 | STARTED.get_or_init(Instant::now); |
| 44 | let home = home(); |
| 45 | std::panic::set_hook(Box::new(move |info| { |
| 46 | let message = info |
| 47 | .payload_as_str() |
| 48 | .unwrap_or("Box<dyn Any>") |
| 49 | .chars() |
| 50 | .take(MESSAGE) |
| 51 | .collect::<String>(); |
| 52 | let at = info.location().map(ToString::to_string).unwrap_or_default(); |
| 53 | // Another thread may hold the lock, or this one may have panicked holding it. |
| 54 | let names = NAMES |
| 55 | .try_lock() |
| 56 | .map(|names| names.clone()) |
| 57 | .unwrap_or_default(); |
| 58 | let renderer = RENDERER |
| 59 | .try_lock() |
| 60 | .map(|name| name.clone()) |
| 61 | .unwrap_or_default(); |
| 62 | let thread = std::thread::current(); |
| 63 | let report = format!( |
| 64 | "{heading}Renderer: {renderer}\nThread: {}\nUptime: {} s\n\ |
| 65 | Panic: {}\nAt: {}\n\nBacktrace:\n{}", |
| 66 | scrub(thread.name().unwrap_or("unnamed"), &home, &names), |
| 67 | STARTED |
| 68 | .get() |
| 69 | .map_or(0, |started| started.elapsed().as_secs()), |
| 70 | scrub(&message, &home, &names), |
| 71 | scrub(&at, &home, &[]), |
| 72 | scrub(&frames(&backtrace()), &home, &[]), |
| 73 | ); |
| 74 | if !KEPT.swap(true, Ordering::Relaxed) { |
| 75 | keep(&report); |
| 76 | } |
| 77 | then(&report); |
| 78 | })); |
| 79 | } |
| 80 | |
| 81 | /// Hides `path`'s names, a notebook's or a section's within it, in reports from now on. |
| 82 | pub fn conceal(path: &str) { |
| 83 | let Ok(mut names) = NAMES.lock() else { |
| 84 | return; |
| 85 | }; |
| 86 | for name in path.split(['/', '\\']) { |
| 87 | let name = [".onetoc2", ".onepkg", ".one"] |
| 88 | .iter() |
| 89 | .find_map(|extension| name.strip_suffix(extension)) |
| 90 | .unwrap_or(name); |
| 91 | if !name.is_empty() && names.len() < NAMED && !names.iter().any(|n| n == name) { |
| 92 | names.push(name.to_owned()); |
| 93 | } |
| 94 | } |
| 95 | // Longer first, so a name holding another is hidden whole. |
| 96 | names.sort_by_key(|name| std::cmp::Reverse(name.len())); |
| 97 | } |
| 98 | |
| 99 | /// `text` with `home` as `~`, `names` as `<name>`, and every path but a source file's as |
| 100 | /// `<path>`. |
| 101 | fn scrub(text: &str, home: &str, names: &[String]) -> String { |
| 102 | let mut text = if home.len() > 1 { |
| 103 | text.replace(home, "~") |
| 104 | } else { |
| 105 | text.to_owned() |
| 106 | }; |
| 107 | for name in names { |
| 108 | if name.chars().count() >= 3 { |
| 109 | text = text.replace(name.as_str(), "<name>"); |
| 110 | continue; |
| 111 | } |
| 112 | let mut hidden = String::with_capacity(text.len()); |
| 113 | let mut kept = 0; |
| 114 | for (at, found) in text.match_indices(name) { |
| 115 | let end = at + found.len(); |
| 116 | let word = |char: char| char.is_alphanumeric() || char == '_'; |
| 117 | if text[..at].chars().next_back().is_some_and(word) |
| 118 | || text[end..].chars().next().is_some_and(word) |
| 119 | { |
| 120 | continue; |
| 121 | } |
| 122 | hidden.push_str(&text[kept..at]); |
| 123 | hidden.push_str("<name>"); |
| 124 | kept = end; |
| 125 | } |
| 126 | hidden.push_str(&text[kept..]); |
| 127 | text = hidden; |
| 128 | } |
| 129 | hide_paths(&text) |
| 130 | } |
| 131 | |
| 132 | fn hide_paths(text: &str) -> String { |
| 133 | let mut hidden = String::with_capacity(text.len()); |
| 134 | let mut rest = text; |
| 135 | let mut previous = None; |
| 136 | while let Some(next) = rest.chars().next() { |
| 137 | let begins = matches!( |
| 138 | previous, |
| 139 | None | Some(' ' | '\t' | '\n' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ',' | ':') |
| 140 | ) && (rest.starts_with('/') |
| 141 | || rest.starts_with("~/") |
| 142 | || rest.starts_with("~\\") |
| 143 | || rest.starts_with("\\\\") |
| 144 | || rest.get(1..3) == Some(":\\") && next.is_ascii_alphabetic()); |
| 145 | if !begins { |
| 146 | hidden.push(next); |
| 147 | previous = Some(next); |
| 148 | rest = &rest[next.len_utf8()..]; |
| 149 | continue; |
| 150 | } |
| 151 | let word = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())]; |
| 152 | if word |
| 153 | .trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, ':' | ',' | ')')) |
| 154 | .ends_with(".rs") |
| 155 | { |
| 156 | hidden.push_str(word); |
| 157 | previous = word.chars().last(); |
| 158 | rest = &rest[word.len()..]; |
| 159 | continue; |
| 160 | } |
| 161 | // A quoted path runs to its quote, spaces and all; another to a break in the sentence. |
| 162 | let end = match previous { |
| 163 | Some(quote @ ('"' | '\'' | '`')) => rest.find([quote, '\n']), |
| 164 | _ => [": ", ", ", ")", "\n"] |
| 165 | .iter() |
| 166 | .filter_map(|stop| rest.find(stop)) |
| 167 | .min(), |
| 168 | }; |
| 169 | hidden.push_str("<path>"); |
| 170 | previous = Some('>'); |
| 171 | rest = &rest[end.unwrap_or(rest.len())..]; |
| 172 | } |
| 173 | hidden |
| 174 | } |
| 175 | |
| 176 | /// `backtrace`'s frames after the panic machinery's, at most `FRAMES`. |
| 177 | fn frames(backtrace: &str) -> String { |
| 178 | let starts = |line: &str| { |
| 179 | let line = line.trim_start(); |
| 180 | line.split_once(": ").is_some_and(|(number, _)| { |
| 181 | !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()) |
| 182 | }) |
| 183 | }; |
| 184 | let lines: Vec<&str> = backtrace.lines().collect(); |
| 185 | let panicking = lines |
| 186 | .iter() |
| 187 | .rposition(|line| starts(line) && line.contains("panicking::")); |
| 188 | let mut kept = String::new(); |
| 189 | let mut count = 0; |
| 190 | for line in &lines[panicking.map_or(0, |at| at + 1)..] { |
| 191 | if starts(line) { |
| 192 | // A system library's frame, which says nothing without its symbols. |
| 193 | if line.ends_with(": <unknown>") { |
| 194 | continue; |
| 195 | } |
| 196 | count += 1; |
| 197 | if count > FRAMES || kept.len() > BACKTRACE { |
| 198 | kept.push_str(" …\n"); |
| 199 | break; |
| 200 | } |
| 201 | } else if count == 0 { |
| 202 | continue; |
| 203 | } |
| 204 | kept.push_str(line); |
| 205 | kept.push('\n'); |
| 206 | } |
| 207 | kept |
| 208 | } |
| 209 | |
| 210 | #[cfg(not(target_arch = "wasm32"))] |
| 211 | fn backtrace() -> String { |
| 212 | std::backtrace::Backtrace::force_capture().to_string() |
| 213 | } |
| 214 | |
| 215 | /// The browser's stack, as wasm32-unknown-unknown's std has no backtrace. |
| 216 | #[cfg(target_arch = "wasm32")] |
| 217 | fn backtrace() -> String { |
| 218 | let error = js_sys::Error::new(""); |
| 219 | js_sys::Reflect::get(&error, &"stack".into()) |
| 220 | .ok() |
| 221 | .and_then(|stack| stack.as_string()) |
| 222 | .unwrap_or_default() |
| 223 | .lines() |
| 224 | .enumerate() |
| 225 | .map(|(number, line)| format!("{number:4}: {}\n", line.trim())) |
| 226 | .collect() |
| 227 | } |
| 228 | |
| 229 | #[cfg(not(target_arch = "wasm32"))] |
| 230 | fn home() -> String { |
| 231 | let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }); |
| 232 | home.map(|home| home.to_string_lossy().into_owned()) |
| 233 | .unwrap_or_default() |
| 234 | } |
| 235 | |
| 236 | #[cfg(target_arch = "wasm32")] |
| 237 | fn home() -> String { |
| 238 | String::new() |
| 239 | } |
| 240 | |
| 241 | #[cfg(not(target_arch = "wasm32"))] |
| 242 | fn keep(report: &str) { |
| 243 | use std::io::Write; |
| 244 | if let Some(path) = REPORT.get() { |
| 245 | if let Some(folder) = path.parent() { |
| 246 | let _ = std::fs::create_dir_all(folder); |
| 247 | } |
| 248 | let mut options = std::fs::OpenOptions::new(); |
| 249 | options.write(true).create(true).truncate(true); |
| 250 | #[cfg(unix)] |
| 251 | { |
| 252 | use std::os::unix::fs::OpenOptionsExt; |
| 253 | options.mode(0o600).custom_flags(libc::O_NOFOLLOW); |
| 254 | } |
| 255 | if let Ok(mut file) = options.open(path) { |
| 256 | let _ = file.write_all(report.as_bytes()); |
| 257 | let _ = file.sync_all(); |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | #[cfg(not(target_arch = "wasm32"))] |
| 263 | pub fn kept() -> Option<String> { |
| 264 | std::fs::read_to_string(REPORT.get()?).ok() |
| 265 | } |
| 266 | |
| 267 | #[cfg(not(target_arch = "wasm32"))] |
| 268 | pub fn forget() { |
| 269 | if let Some(path) = REPORT.get() { |
| 270 | let _ = std::fs::remove_file(path); |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | /// The browser keeps the report in local storage, which a panic can still reach. |
| 275 | #[cfg(target_arch = "wasm32")] |
| 276 | const STORED: &str = "snowbound-crash"; |
| 277 | |
| 278 | #[cfg(target_arch = "wasm32")] |
| 279 | fn storage() -> Option<web_sys::Storage> { |
| 280 | web_sys::window()?.local_storage().ok()? |
| 281 | } |
| 282 | |
| 283 | #[cfg(target_arch = "wasm32")] |
| 284 | fn keep(report: &str) { |
| 285 | if let Some(storage) = storage() { |
| 286 | let _ = storage.set_item(STORED, report); |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | #[cfg(target_arch = "wasm32")] |
| 291 | pub fn kept() -> Option<String> { |
| 292 | storage()?.get_item(STORED).ok()? |
| 293 | } |
| 294 | |
| 295 | #[cfg(target_arch = "wasm32")] |
| 296 | pub fn forget() { |
| 297 | if let Some(storage) = storage() { |
| 298 | let _ = storage.remove_item(STORED); |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | /// Keeps reports at `path`; installs the host's native fault capture without replacing a pending report. |
| 303 | #[cfg(not(target_arch = "wasm32"))] |
| 304 | pub fn set_path(path: PathBuf) -> std::io::Result<()> { |
| 305 | if REPORT.get().is_some() { |
| 306 | return Ok(()); |
| 307 | } |
| 308 | if let Some(folder) = path.parent() { |
| 309 | std::fs::create_dir_all(folder)?; |
| 310 | } |
| 311 | native::prepare(&path)?; |
| 312 | let _ = REPORT.set(path); |
| 313 | Ok(()) |
| 314 | } |
| 315 | |
| 316 | #[cfg(test)] |
| 317 | mod tests { |
| 318 | use super::*; |
| 319 | |
| 320 | #[test] |
| 321 | fn reports_hide_the_home_folder_paths_and_names() { |
| 322 | let names = vec!["Work Notes".to_owned(), "Meetings".to_owned()]; |
| 323 | let scrub = |text| scrub(text, "/Users/ada", &names); |
| 324 | assert_eq!( |
| 325 | scrub( |
| 326 | r#"called `Result::unwrap()` on an `Err` value: Io { path: "/Users/ada/OneNote Notebooks/Work Notes/To Do.one", kind: NotFound }"# |
| 327 | ), |
| 328 | r#"called `Result::unwrap()` on an `Err` value: Io { path: "<path>", kind: NotFound }"# |
| 329 | ); |
| 330 | assert_eq!( |
| 331 | scrub("Cannot read /Volumes/Share/Shared Notes/a.one: denied"), |
| 332 | "Cannot read <path>: denied" |
| 333 | ); |
| 334 | assert_eq!( |
| 335 | scrub(r"Cannot read C:\Users\ada\Notes\b.one, retrying"), |
| 336 | "Cannot read <path>, retrying" |
| 337 | ); |
| 338 | assert_eq!(scrub("opening smb://nas/notes/x.one"), "opening smb:<path>"); |
| 339 | assert_eq!( |
| 340 | scrub("section Meetings of Work Notes is locked"), |
| 341 | "section <name> of <name> is locked" |
| 342 | ); |
| 343 | // Sources stay, the home folder as ~. |
| 344 | assert_eq!( |
| 345 | scrub("at /Users/ada/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"), |
| 346 | "at ~/.cargo/registry/src/winit-0.30/src/lib.rs:12:5" |
| 347 | ); |
| 348 | assert_eq!( |
| 349 | scrub("index out of bounds: the len is 3 but the index is 5"), |
| 350 | "index out of bounds: the len is 3 but the index is 5" |
| 351 | ); |
| 352 | } |
| 353 | |
| 354 | #[test] |
| 355 | fn names_are_kept_from_a_sections_path() { |
| 356 | conceal("Projects/Snow Plan.one"); |
| 357 | let names = NAMES.lock().unwrap().clone(); |
| 358 | assert!(names.contains(&"Projects".to_owned()), "{names:?}"); |
| 359 | assert!(names.contains(&"Snow Plan".to_owned()), "{names:?}"); |
| 360 | assert!(!names.iter().any(|name| name.ends_with(".one"))); |
| 361 | } |
| 362 | |
| 363 | #[test] |
| 364 | fn short_names_are_hidden_without_breaking_diagnostics() { |
| 365 | let names = vec!["AI".into(), "x".into(), "日".into()]; |
| 366 | assert_eq!( |
| 367 | scrub("section 'AI': FAIL index x 日", "", &names), |
| 368 | "section '<name>': FAIL index <name> <name>" |
| 369 | ); |
| 370 | } |
| 371 | |
| 372 | #[test] |
| 373 | fn backtraces_start_past_the_panic() { |
| 374 | let backtrace = " 0: std::backtrace::Backtrace::force_capture |
| 375 | 1: snowbound::crash::hook::{{closure}} |
| 376 | at ./src/crash.rs:30:9 |
| 377 | 2: std::panicking::rust_panic_with_hook |
| 378 | 3: core::panicking::panic_fmt |
| 379 | 4: snowbound::State::frame |
| 380 | at ./src/main.rs:1500:13 |
| 381 | 5: <unknown> |
| 382 | 6: main |
| 383 | "; |
| 384 | assert_eq!( |
| 385 | frames(backtrace), |
| 386 | " 4: snowbound::State::frame\n at ./src/main.rs:1500:13\n 6: main\n" |
| 387 | ); |
| 388 | let deep: String = (0..100) |
| 389 | .map(|frame| format!("{frame:4}: f{frame}\n")) |
| 390 | .collect(); |
| 391 | let kept = frames(&deep); |
| 392 | assert_eq!(kept.lines().count(), FRAMES + 1); |
| 393 | assert!(kept.ends_with("…\n")); |
| 394 | } |
| 395 | |
| 396 | /// A report written by a panic is what the next launch finds, until it is forgotten. |
| 397 | #[test] |
| 398 | fn a_report_outlives_the_run_until_answered() { |
| 399 | let folder = std::env::temp_dir().join(format!("snowbound-crash-{}", std::process::id())); |
| 400 | let _ = REPORT.set(folder.join("crash.txt")); |
| 401 | let report = REPORT.get().unwrap(); |
| 402 | let _ = std::fs::remove_file(report); |
| 403 | assert_eq!(kept(), None); |
| 404 | keep("Snowbound development\nPanic: x"); |
| 405 | assert_eq!(kept().as_deref(), Some("Snowbound development\nPanic: x")); |
| 406 | forget(); |
| 407 | assert_eq!(kept(), None); |
| 408 | std::fs::remove_dir_all(folder).unwrap(); |
| 409 | } |
| 410 | } |