1//! Local crash capture shared by the desktop and mobile hosts. Uploads belong to the host.
2
3#[cfg(not(target_arch = "wasm32"))]
4mod native;
5#[cfg(unix)]
6pub use native::record_signal;
7
8#[cfg(not(target_arch = "wasm32"))]
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Mutex, OnceLock};
12use web_time::Instant;
13
14pub 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"))]
18static REPORT: OnceLock<PathBuf> = OnceLock::new();
19/// The backend drawing and its adapter, as "Metal (Apple M2)".
20pub static RENDERER: Mutex<String> = Mutex::new(String::new());
21/// Names from the notebook catalog, hidden in panic reports.
22static NAMES: Mutex<Vec<String>> = Mutex::new(Vec::new());
23static STARTED: OnceLock<Instant> = OnceLock::new();
24/// The first panic's report is kept; the ones it sets off would only hide it.
25static KEPT: AtomicBool = AtomicBool::new(false);
26
27/// Bounds on what a panic adds, so a report stays well under what the site takes.
28const MESSAGE: usize = 2 << 10;
29const FRAMES: usize = 48;
30const BACKTRACE: usize = 32 << 10;
31const NAMED: usize = 256;
32
33/// Reports each panic on `system`, then hands the report to `then` to log.
34pub 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.
82pub 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>`.
101fn 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
132fn 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`.
177fn 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"))]
211fn 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")]
217fn 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"))]
230fn 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")]
237fn home() -> String {
238 String::new()
239}
240
241#[cfg(not(target_arch = "wasm32"))]
242fn 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"))]
263pub fn kept() -> Option<String> {
264 std::fs::read_to_string(REPORT.get()?).ok()
265}
266
267#[cfg(not(target_arch = "wasm32"))]
268pub 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")]
276const STORED: &str = "snowbound-crash";
277
278#[cfg(target_arch = "wasm32")]
279fn storage() -> Option<web_sys::Storage> {
280 web_sys::window()?.local_storage().ok()?
281}
282
283#[cfg(target_arch = "wasm32")]
284fn keep(report: &str) {
285 if let Some(storage) = storage() {
286 let _ = storage.set_item(STORED, report);
287 }
288}
289
290#[cfg(target_arch = "wasm32")]
291pub fn kept() -> Option<String> {
292 storage()?.get_item(STORED).ok()?
293}
294
295#[cfg(target_arch = "wasm32")]
296pub 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"))]
304pub 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)]
317mod 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}