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