1#![cfg_attr(
9 not(test),
10 deny(
11 clippy::unwrap_used,
12 clippy::unreachable,
13 clippy::expect_used,
14 clippy::panic,
15 clippy::unimplemented,
16 clippy::todo
17 )
18)]
19
20use std::any::Any;
21use std::fs::OpenOptions;
22use std::io::Write;
23use std::panic;
24use std::path::{Path, PathBuf};
25use std::sync::{Mutex, OnceLock};
26
27pub const DEFAULT_CRASH_LOG_PATH: &str = "data/crash.log";
29pub const DEFAULT_ISSUE_TEMPLATE: &str = "issues/new?template=bug_report.yml";
31
32static CRASH_LOG: OnceLock<Result<Mutex<std::fs::File>, String>> = OnceLock::new();
33static PANIC_HOOK_CONFIG: OnceLock<PanicHookConfig> = OnceLock::new();
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PanicHookConfig {
38 pub app_name: String,
40 pub version: String,
42 pub repository: String,
44 pub crash_log_path: PathBuf,
46 pub issue_template: String,
48}
49
50impl PanicHookConfig {
51 pub fn new(
53 app_name: impl Into<String>,
54 version: impl Into<String>,
55 repository: impl Into<String>,
56 ) -> Self {
57 Self {
58 app_name: app_name.into(),
59 version: version.into(),
60 repository: repository.into(),
61 crash_log_path: PathBuf::from(DEFAULT_CRASH_LOG_PATH),
62 issue_template: DEFAULT_ISSUE_TEMPLATE.to_string(),
63 }
64 }
65
66 #[must_use]
68 pub fn with_crash_log_path(mut self, crash_log_path: impl Into<PathBuf>) -> Self {
69 self.crash_log_path = crash_log_path.into();
70 self
71 }
72
73 #[must_use]
75 pub fn with_issue_template(mut self, issue_template: impl Into<String>) -> Self {
76 self.issue_template = issue_template.into();
77 self
78 }
79}
80
81#[derive(Debug, Clone)]
82struct PanicContext {
83 app_name: String,
84 version: String,
85 platform: &'static str,
86 repository: String,
87 issue_template: String,
88 timestamp: String,
89 thread_name: String,
90 location: String,
91 message: String,
92}
93
94#[derive(Debug, Clone)]
95struct CrashReportWriteFailure {
96 reason: String,
97 report: String,
98}
99
100impl CrashReportWriteFailure {
101 fn new(reason: String, context: &PanicContext) -> Self {
102 let backtrace = std::backtrace::Backtrace::force_capture().to_string();
103 Self {
104 reason,
105 report: render_crash_report(context, &backtrace),
106 }
107 }
108}
109
110pub fn install_panic_hook(config: PanicHookConfig) {
116 let _config_already_installed = PANIC_HOOK_CONFIG.set(config.clone()).is_err();
117 panic::set_hook(Box::new(move |info| {
118 let config = PANIC_HOOK_CONFIG.get().unwrap_or(&config);
119 let thread = std::thread::current();
120 let context = PanicContext {
121 app_name: config.app_name.clone(),
122 version: config.version.clone(),
123 platform: std::env::consts::OS,
124 repository: config.repository.clone(),
125 issue_template: config.issue_template.clone(),
126 timestamp: chrono::Local::now()
127 .format("%Y-%m-%d %H:%M:%S%.3f")
128 .to_string(),
129 thread_name: thread.name().unwrap_or("<unnamed>").to_string(),
130 location: info.location().map_or_else(
131 || "<unknown>".to_string(),
132 |loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()),
133 ),
134 message: panic_payload_message(info.payload()),
135 };
136
137 let crash_log_path = crash_log_display_path(&config.crash_log_path);
138 let crash_log_result = write_crash_report(&config.crash_log_path, &context);
139 let failure_report = crash_log_result
140 .as_ref()
141 .err()
142 .map(|failure| failure.report.trim_end());
143 let notice = render_user_panic_notice(&context, &crash_log_path, crash_log_result.as_ref());
144 write_stderr_diagnostics(failure_report, ¬ice);
145 }));
146}
147
148fn write_stderr_diagnostics(failure_report: Option<&str>, notice: &str) {
150 write_diagnostics(&mut std::io::stderr().lock(), failure_report, notice);
151}
152
153fn write_diagnostics(mut writer: impl Write, failure_report: Option<&str>, notice: &str) {
160 if let Some(report) = failure_report {
161 let _ = writeln!(writer, "{report}");
162 }
163 let _ = writeln!(writer, "{notice}");
164}
165
166fn write_crash_report(
167 crash_log_path: &Path,
168 context: &PanicContext,
169) -> Result<(), CrashReportWriteFailure> {
170 let file_mutex = crash_log_file(crash_log_path)
171 .map_err(|reason| CrashReportWriteFailure::new(reason, context))?;
172 write_crash_report_to_file(file_mutex, crash_log_path, context)
173}
174
175fn write_crash_report_to_file(
176 file_mutex: &Mutex<std::fs::File>,
177 crash_log_path: &Path,
178 context: &PanicContext,
179) -> Result<(), CrashReportWriteFailure> {
180 let mut guard = file_mutex.try_lock().map_err(|_| {
181 CrashReportWriteFailure::new(
182 "crash log is locked by another panic writer".to_string(),
183 context,
184 )
185 })?;
186
187 let backtrace = std::backtrace::Backtrace::force_capture().to_string();
188 let crash_report = render_crash_report(context, &backtrace);
189 guard
190 .write_all(crash_report.as_bytes())
191 .map_err(|error| CrashReportWriteFailure {
192 reason: format!("failed to write {}: {error}", crash_log_path.display()),
193 report: crash_report,
194 })
195}
196
197fn crash_log_file(crash_log_path: &Path) -> Result<&'static Mutex<std::fs::File>, String> {
198 CRASH_LOG
199 .get_or_init(|| open_crash_log_file(crash_log_path))
200 .as_ref()
201 .map_err(Clone::clone)
202}
203
204fn open_crash_log_file(crash_log_path: &Path) -> Result<Mutex<std::fs::File>, String> {
205 if let Some(parent) = crash_log_path.parent() {
206 std::fs::create_dir_all(parent).map_err(|error| {
207 format!(
208 "failed to create crash log dir '{}': {error}",
209 parent.display()
210 )
211 })?;
212 }
213 OpenOptions::new()
214 .create(true)
215 .append(true)
216 .open(crash_log_path)
217 .map(Mutex::new)
218 .map_err(|error| format!("failed to open {}: {error}", crash_log_path.display()))
219}
220
221fn crash_log_display_path(crash_log_path: &Path) -> PathBuf {
222 std::env::current_dir().map_or_else(
223 |_| crash_log_path.to_path_buf(),
224 |dir| dir.join(crash_log_path),
225 )
226}
227
228fn panic_payload_message(payload: &(dyn Any + Send)) -> String {
229 if let Some(message) = payload.downcast_ref::<&str>() {
230 (*message).to_string()
231 } else if let Some(message) = payload.downcast_ref::<String>() {
232 message.clone()
233 } else {
234 "<non-string panic payload>".to_string()
235 }
236}
237
238fn issue_report_target(repository: &str, issue_template: &str) -> String {
239 let repository = repository.trim_end_matches('/');
240 let issue_template = issue_template.trim_start_matches('/');
241 if repository.is_empty() {
242 "the project issue tracker".to_string()
243 } else if issue_template.is_empty() {
244 repository.to_string()
245 } else {
246 format!("{repository}/{issue_template}")
247 }
248}
249
250fn render_crash_report(context: &PanicContext, backtrace: &str) -> String {
251 let report_target = issue_report_target(&context.repository, &context.issue_template);
252 format!(
253 "=== {} Panic Report ===\n\
254 Version: {}\n\
255 Platform: {}\n\
256 Timestamp: {}\n\
257 Thread: {}\n\
258 Location: {}\n\
259 Message: {}\n\
260 Report: {}\n\
261 Backtrace:\n{}\n\
262 ===============================\n\n",
263 context.app_name,
264 context.version,
265 context.platform,
266 context.timestamp,
267 context.thread_name,
268 context.location,
269 context.message,
270 report_target,
271 backtrace.trim_end()
272 )
273}
274
275fn render_user_panic_notice(
276 context: &PanicContext,
277 crash_log_path: &Path,
278 crash_log_result: Result<&(), &CrashReportWriteFailure>,
279) -> String {
280 let report_target = issue_report_target(&context.repository, &context.issue_template);
281 let diagnostic_line = match crash_log_result {
282 Ok(()) => format!(
283 "A diagnostic report was written to {}.",
284 crash_log_path.display()
285 ),
286 Err(failure) => format!(
287 "A diagnostic report could not be written to {}: {}.",
288 crash_log_path.display(),
289 failure.reason
290 ),
291 };
292
293 let fallback_line = match crash_log_result {
294 Ok(()) => String::new(),
295 Err(_) => " The diagnostic report was printed to stderr instead.".to_string(),
296 };
297
298 format!(
299 "{} encountered an unexpected internal error.\n\
300 {diagnostic_line}{fallback_line}\n\
301 Timestamp: {}\n\
302 If the process exits, restart {} and report the diagnostic report at:\n\
303 {report_target}",
304 context.app_name, context.timestamp, context.app_name
305 )
306}
307
308#[cfg(test)]
309mod tests {
310 use super::{
311 CrashReportWriteFailure, PanicContext, PanicHookConfig, issue_report_target,
312 open_crash_log_file, panic_payload_message, render_crash_report, render_user_panic_notice,
313 write_crash_report_to_file,
314 };
315 use aster_forge_test::temp::TestTempDir;
316 use std::sync::{Mutex, OnceLock};
317
318 const PANIC_HOOK_CHILD_ENV: &str = "ASTER_FORGE_PANIC_HOOK_CHILD";
319 const PANIC_HOOK_PATH_ENV: &str = "ASTER_FORGE_PANIC_HOOK_PATH";
320
321 fn write_crash_report_with_log(
322 crash_log: &OnceLock<Result<Mutex<std::fs::File>, String>>,
323 crash_log_path: &std::path::Path,
324 context: &PanicContext,
325 ) -> Result<(), CrashReportWriteFailure> {
326 let file_mutex = crash_log_file_from(crash_log, crash_log_path)
327 .map_err(|reason| CrashReportWriteFailure::new(reason, context))?;
328 write_crash_report_to_file(file_mutex, crash_log_path, context)
329 }
330
331 fn crash_log_file_from<'a>(
332 crash_log: &'a OnceLock<Result<Mutex<std::fs::File>, String>>,
333 crash_log_path: &std::path::Path,
334 ) -> Result<&'a Mutex<std::fs::File>, String> {
335 crash_log
336 .get_or_init(|| open_crash_log_file(crash_log_path))
337 .as_ref()
338 .map_err(Clone::clone)
339 }
340
341 fn test_context() -> PanicContext {
342 PanicContext {
343 app_name: "AsterDrive".to_string(),
344 version: "0.1.0-test".to_string(),
345 platform: "test-os",
346 repository: "https://example.test/asterdrive/".to_string(),
347 issue_template: super::DEFAULT_ISSUE_TEMPLATE.to_string(),
348 timestamp: "2026-05-05 12:34:56.789".to_string(),
349 thread_name: "test-thread".to_string(),
350 location: "src/main.rs:42:9".to_string(),
351 message: "secret panic payload".to_string(),
352 }
353 }
354
355 fn crash_log_fixture(scope: &str) -> (TestTempDir, std::path::PathBuf) {
356 let directory = TestTempDir::new(scope);
357 let path = directory.join("crash.log");
358 (directory, path)
359 }
360
361 fn write_parent_file_fixture(path: &std::path::Path) {
362 let parent = path
363 .parent()
364 .expect("parent-file fixture should have parent");
365 std::fs::create_dir_all(parent).expect("parent-file fixture dir should be writable");
366 std::fs::write(path, "not a directory").expect("parent-file fixture should be writable");
367 }
368
369 #[test]
370 fn user_notice_is_short_and_omits_developer_diagnostics() {
371 let context = test_context();
372 let notice = render_user_panic_notice(
373 &context,
374 std::path::Path::new("/tmp/asterdrive/data/crash.log"),
375 Ok(&()),
376 );
377
378 assert!(notice.contains("AsterDrive encountered an unexpected internal error."));
379 assert!(notice.contains("/tmp/asterdrive/data/crash.log"));
380 assert!(notice.contains("2026-05-05 12:34:56.789"));
381 assert!(
382 notice.contains("https://example.test/asterdrive/issues/new?template=bug_report.yml")
383 );
384 assert!(!notice.contains("src/main.rs:42:9"));
385 assert!(!notice.contains("secret panic payload"));
386 assert!(!notice.contains("Backtrace"));
387 }
388
389 #[test]
390 fn user_notice_reports_when_crash_log_could_not_be_written() {
391 let context = test_context();
392 let failure = CrashReportWriteFailure {
393 reason: "permission denied".to_string(),
394 report: render_crash_report(&context, "frame 1"),
395 };
396 let notice = render_user_panic_notice(
397 &context,
398 std::path::Path::new("data/crash.log"),
399 Err(&failure),
400 );
401
402 assert!(notice.contains("could not be written"));
403 assert!(notice.contains("data/crash.log"));
404 assert!(notice.contains("permission denied"));
405 assert!(notice.contains("printed to stderr"));
406 }
407
408 #[test]
409 fn crash_report_keeps_developer_diagnostics() {
410 let context = test_context();
411 let report = render_crash_report(&context, "frame 1\nframe 2\n");
412
413 assert!(report.contains("=== AsterDrive Panic Report ==="));
414 assert!(report.contains("Version: 0.1.0-test"));
415 assert!(report.contains("Platform: test-os"));
416 assert!(report.contains("Thread: test-thread"));
417 assert!(report.contains("Location: src/main.rs:42:9"));
418 assert!(report.contains("Message: secret panic payload"));
419 assert!(report.contains(
420 "Report: https://example.test/asterdrive/issues/new?template=bug_report.yml"
421 ));
422 assert!(report.contains("Backtrace:\nframe 1\nframe 2"));
423 }
424
425 #[test]
426 fn crash_report_write_failure_new_renders_report_with_reason() {
427 let context = test_context();
428 let failure = CrashReportWriteFailure::new("permission denied".to_string(), &context);
429
430 assert_eq!(failure.reason, "permission denied");
431 assert!(failure.report.contains("=== AsterDrive Panic Report ==="));
432 assert!(failure.report.contains("Message: secret panic payload"));
433 assert!(failure.report.contains("Backtrace:"));
434 }
435
436 #[test]
437 fn crash_log_file_creates_parent_directory_and_reuses_file() {
438 let (_directory, path) = crash_log_fixture("panic-reused-file");
439 let crash_log = OnceLock::new();
440
441 let first = crash_log_file_from(&crash_log, &path).expect("crash log should open");
442 let second = crash_log_file_from(&crash_log, &path).expect("crash log should be reused");
443
444 assert!(path.parent().expect("test path has parent").exists());
445 assert!(path.exists());
446 assert!(std::ptr::eq(first, second));
447 }
448
449 #[test]
450 fn crash_log_file_returns_cached_initialization_error() {
451 let (_directory, path) = crash_log_fixture("panic-cached-open-error");
452 let crash_log = OnceLock::new();
453 write_parent_file_fixture(&path);
454 let nested_log = path.join("crash.log");
455
456 let first_error = crash_log_file_from(&crash_log, &nested_log)
457 .expect_err("file parent should not be usable as directory");
458 let second_error = crash_log_file_from(&crash_log, &nested_log)
459 .expect_err("cached initialization error should be returned");
460
461 assert!(first_error.contains("failed to create crash log dir"));
462 assert_eq!(first_error, second_error);
463 }
464
465 #[test]
466 fn write_crash_report_appends_developer_report() {
467 let (_directory, path) = crash_log_fixture("panic-appended-report");
468 let crash_log = OnceLock::new();
469 let context = test_context();
470
471 write_crash_report_with_log(&crash_log, &path, &context)
472 .expect("crash report should be written");
473 write_crash_report_with_log(&crash_log, &path, &context)
474 .expect("second crash report should append");
475
476 let contents =
477 std::fs::read_to_string(&path).expect("crash report should be readable from fixture");
478 assert_eq!(
479 contents.matches("=== AsterDrive Panic Report ===").count(),
480 2
481 );
482 assert!(contents.contains("Location: src/main.rs:42:9"));
483 assert!(contents.contains("Message: secret panic payload"));
484 }
485
486 #[test]
487 fn write_crash_report_returns_rendered_failure_when_log_is_locked() {
488 let (_directory, path) = crash_log_fixture("panic-locked-log");
489 let crash_log = OnceLock::new();
490 let context = test_context();
491 let file_mutex = crash_log_file_from(&crash_log, &path).expect("crash log should open");
492 let _locked = file_mutex.lock().expect("fixture lock should be available");
493
494 let failure = write_crash_report_with_log(&crash_log, &path, &context)
495 .expect_err("locked crash log should report failure");
496
497 assert_eq!(
498 failure.reason,
499 "crash log is locked by another panic writer"
500 );
501 assert!(failure.report.contains("=== AsterDrive Panic Report ==="));
502 assert!(failure.report.contains("Message: secret panic payload"));
503 }
504
505 #[test]
506 fn write_crash_report_returns_rendered_failure_when_log_cannot_open() {
507 let (_directory, path) = crash_log_fixture("panic-open-failure");
508 let crash_log = OnceLock::new();
509 write_parent_file_fixture(&path);
510 let nested_log = path.join("crash.log");
511 let context = test_context();
512
513 let failure = write_crash_report_with_log(&crash_log, &nested_log, &context)
514 .expect_err("invalid crash log path should report failure");
515
516 assert!(failure.reason.contains("failed to create crash log dir"));
517 assert!(failure.report.contains("=== AsterDrive Panic Report ==="));
518 assert!(failure.report.contains("Backtrace:"));
519 }
520
521 #[test]
522 fn install_panic_hook_writes_report_for_caught_thread_panic() {
523 if std::env::var_os(PANIC_HOOK_CHILD_ENV).is_some() {
524 run_panic_hook_child();
525 return;
526 }
527
528 let (_directory, path) = crash_log_fixture("panic-installed-hook");
529 let current_exe = std::env::current_exe().expect("current test executable should resolve");
530 let output = std::process::Command::new(current_exe)
531 .arg("--exact")
532 .arg("tests::install_panic_hook_writes_report_for_caught_thread_panic")
533 .arg("--nocapture")
534 .env(PANIC_HOOK_CHILD_ENV, "1")
535 .env(PANIC_HOOK_PATH_ENV, &path)
536 .output()
537 .expect("panic hook child process should run");
538
539 assert!(
540 output.status.success(),
541 "panic hook child failed\nstdout:\n{}\nstderr:\n{}",
542 String::from_utf8_lossy(&output.stdout),
543 String::from_utf8_lossy(&output.stderr)
544 );
545
546 let contents =
547 std::fs::read_to_string(&path).expect("panic hook should write crash report");
548 assert!(contents.contains("=== HookTest Panic Report ==="));
549 assert!(contents.contains("Version: 9.9.9-test"));
550 assert!(contents.contains("Thread: panic-hook-fixture"));
551 assert!(contents.contains("Message: hook panic payload"));
552 assert!(
553 contents.contains("Report: https://example.test/hook/issues/new?template=panic.yml")
554 );
555 }
556
557 fn run_panic_hook_child() {
558 let path = std::env::var_os(PANIC_HOOK_PATH_ENV)
559 .map(std::path::PathBuf::from)
560 .expect("panic hook child path should be provided");
561 let config = PanicHookConfig::new("HookTest", "9.9.9-test", "https://example.test/hook")
562 .with_crash_log_path(path)
563 .with_issue_template("issues/new?template=panic.yml");
564
565 super::install_panic_hook(config);
566
567 let result = std::thread::Builder::new()
568 .name("panic-hook-fixture".to_string())
569 .spawn(|| panic!("hook panic payload"))
570 .expect("panic fixture thread should spawn")
571 .join();
572
573 assert!(result.is_err());
574 }
575
576 struct FailingWriter;
577
578 impl std::io::Write for FailingWriter {
579 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
580 Err(std::io::Error::new(
581 std::io::ErrorKind::BrokenPipe,
582 "stderr closed",
583 ))
584 }
585
586 fn flush(&mut self) -> std::io::Result<()> {
587 Err(std::io::Error::new(
588 std::io::ErrorKind::BrokenPipe,
589 "stderr closed",
590 ))
591 }
592 }
593
594 #[test]
595 fn write_diagnostics_ignores_broken_stderr_and_captures_content() {
596 super::write_diagnostics(FailingWriter, Some("report-body"), "notice-body");
599
600 let mut captured = Vec::new();
601 super::write_diagnostics(&mut captured, Some("report-body"), "notice-body");
602 let output = String::from_utf8(captured).expect("diagnostics should be utf-8");
603 assert_eq!(output, "report-body\nnotice-body\n");
604
605 let mut captured = Vec::new();
606 super::write_diagnostics(&mut captured, None, "notice-body");
607 let output = String::from_utf8(captured).expect("diagnostics should be utf-8");
608 assert_eq!(output, "notice-body\n");
609 }
610
611 #[test]
612 fn panic_payload_message_handles_common_payload_types() {
613 let owned = "owned panic".to_string();
614
615 assert_eq!(panic_payload_message(&"static panic"), "static panic");
616 assert_eq!(panic_payload_message(&owned), "owned panic");
617 assert_eq!(
618 panic_payload_message(&123_i32),
619 "<non-string panic payload>"
620 );
621 }
622
623 #[test]
624 fn issue_report_target_tolerates_empty_repository() {
625 assert_eq!(
626 issue_report_target(
627 "https://example.test/project/",
628 super::DEFAULT_ISSUE_TEMPLATE
629 ),
630 "https://example.test/project/issues/new?template=bug_report.yml"
631 );
632 assert_eq!(
633 issue_report_target("", super::DEFAULT_ISSUE_TEMPLATE),
634 "the project issue tracker"
635 );
636 assert_eq!(
637 issue_report_target("https://example.test/project", ""),
638 "https://example.test/project"
639 );
640 }
641}