Skip to main content

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    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    /// Overrides the repository-relative issue template path.
73    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
108/// Installs the shared panic hook for the current process.
109///
110/// The first configuration installed in a process is retained. This matches the
111/// process-wide nature of Rust panic hooks and avoids swapping crash-log targets
112/// after a hook has already been installed.
113pub 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, &notice);
143    }));
144}
145
146/// Prints panic diagnostics to stderr on a best-effort basis.
147fn write_stderr_diagnostics(failure_report: Option<&str>, notice: &str) {
148    write_diagnostics(&mut std::io::stderr().lock(), failure_report, notice);
149}
150
151/// Writes the crash report (when the log file failed) followed by the user
152/// notice, ignoring IO errors.
153///
154/// stderr may be closed or a broken pipe; a panicking write (`eprintln!`)
155/// inside the panic hook would abort the process via double panic and destroy
156/// the very diagnostics this hook exists to capture.
157fn 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        // A closed or broken-pipe stderr must not panic the panic hook (the
594        // old `eprintln!` would, aborting the process via double panic).
595        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}