aster_forge_panic/
lib.rs

1//! Shared panic hook and crash report writer for Aster services.
2//!
3//! This crate implements the full crash-reporting behavior used by Aster
4//! services: a process-wide panic hook, a lazily opened crash log, backtrace
5//! capture for developer diagnostics, user-facing stderr notices, and a
6//! repository issue target. Product crates provide names, versions, repository
7//! URLs, and crash log paths through [`PanicHookConfig`].
8#![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
27/// Default crash log path used by Aster services.
28pub const DEFAULT_CRASH_LOG_PATH: &str = "data/crash.log";
29/// Default repository issue template path used in panic notices.
30pub 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/// Configuration used by the shared panic hook.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PanicHookConfig {
38    /// Human-facing service name shown in crash reports.
39    pub app_name: String,
40    /// Service version shown in crash reports.
41    pub version: String,
42    /// Repository URL used to build issue-report targets.
43    pub repository: String,
44    /// Path to the crash log file.
45    pub crash_log_path: PathBuf,
46    /// Repository-relative issue template path.
47    pub issue_template: String,
48}
49
50impl PanicHookConfig {
51    /// Creates a panic hook config with Aster defaults for path and issue template.
52    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    /// Overrides the crash log path.
67    #[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    /// Overrides the repository-relative issue template path.
74    #[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
110/// Installs the shared panic hook for the current process.
111///
112/// The first configuration installed in a process is retained. This matches the
113/// process-wide nature of Rust panic hooks and avoids swapping crash-log targets
114/// after a hook has already been installed.
115pub 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, &notice);
145    }));
146}
147
148/// Prints panic diagnostics to stderr on a best-effort basis.
149fn write_stderr_diagnostics(failure_report: Option<&str>, notice: &str) {
150    write_diagnostics(&mut std::io::stderr().lock(), failure_report, notice);
151}
152
153/// Writes the crash report (when the log file failed) followed by the user
154/// notice, ignoring IO errors.
155///
156/// stderr may be closed or a broken pipe; a panicking write (`eprintln!`)
157/// inside the panic hook would abort the process via double panic and destroy
158/// the very diagnostics this hook exists to capture.
159fn 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        // A closed or broken-pipe stderr must not panic the panic hook (the
597        // old `eprintln!` would, aborting the process via double panic).
598        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}