Skip to main content

futu_opend/
crash_log.rs

1//! v1.4.110 P1-2: crash log + panic hook helpers
2//!
3//! 抽自 main.rs lines 20-123 (v1.4.97 P1-D-D 实装).
4//! Aligned with C++ NNCrashCenter pattern.
5
6// Local retention policy ledger:
7// codex/current/v1.4.113/2026-06-11-1049-v1.4.113-log-retention-followup-zh.md
8const DEFAULT_CRASH_LOG_MAX_FILES: usize = 20;
9
10/// v1.4.97 P1-D-D: directory holding dated crash logs.
11fn crash_log_dir() -> std::path::PathBuf {
12    // Reuse same `~/.futu-opend-rs/` root as credentials/keys.json (CLAUDE.md
13    // §登录 device_id 生命周期). Don't introduce new XDG / dirs crate dep.
14    let home = std::env::var_os("HOME")
15        .map(std::path::PathBuf::from)
16        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
17    home.join(".futu-opend-rs").join("crashes")
18}
19
20/// v1.4.97 P1-D-D: synchronously write a crash log file.
21///
22/// Best-effort: if mkdir/write fails, emit a small stderr diagnostic without
23/// the panic payload. Panic in panic-hook is never recoverable.
24pub fn write_crash_log_file(info: &std::panic::PanicHookInfo<'_>) {
25    let dir = crash_log_dir();
26    if let Err(err) = std::fs::create_dir_all(&dir) {
27        eprintln!(
28            "futu-opend: failed to create crash log directory {}: {}",
29            dir.display(),
30            err
31        );
32    }
33
34    // dated filename `{YYYYMMDDhhmmss}.log` aligns with C++ NNCrashCenter
35    // (NNCrashCenter.cpp:6-54). Avoid chrono dep here — sync formatting via
36    // std::time::SystemTime + manual local-broken-down-time would require
37    // libc::localtime_r unsafe; prefer simple unix epoch + version + thread.
38    let now_secs = crash_log_unix_secs_or_zero();
39    let path = dir.join(format!("crash-{now_secs}.log"));
40
41    let location = info
42        .location()
43        .map(|l| format!("{}:{}", l.file(), l.line()))
44        .unwrap_or_else(|| "<unknown>".to_string());
45    let payload = info
46        .payload()
47        .downcast_ref::<&str>()
48        .copied()
49        .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
50        .unwrap_or("<non-string panic payload>");
51    let thread = std::thread::current()
52        .name()
53        .unwrap_or("<unnamed>")
54        .to_string();
55    // v1.4.104 external reviewer OBS-P3-001 fix: force capture (不依赖 RUST_BACKTRACE env).
56    // 之前 `Backtrace::capture()` 只在 RUST_BACKTRACE=1 时返实 backtrace, 否则
57    // "disabled backtrace" 占位 → debug 价值大减. force_capture() 总返实 stack.
58    // crash 路径已是 "已经挂了" panic, 多花几 ms 抓 stack 是值得的.
59    let backtrace = std::backtrace::Backtrace::force_capture();
60
61    let body = format!(
62        "v1.4.97 P1-D-D crash report\n\
63         daemon_version: {}\n\
64         unix_timestamp: {}\n\
65         os: {}\n\
66         arch: {}\n\
67         thread: {}\n\
68         location: {}\n\
69         payload: {}\n\
70         backtrace:\n{}\n",
71        env!("CARGO_PKG_VERSION"),
72        now_secs,
73        std::env::consts::OS,
74        std::env::consts::ARCH,
75        thread,
76        location,
77        payload,
78        backtrace,
79    );
80    match std::fs::write(&path, body) {
81        Ok(()) => prune_old_crash_logs(&dir, DEFAULT_CRASH_LOG_MAX_FILES),
82        Err(err) => {
83            eprintln!(
84                "futu-opend: failed to write crash log {}: {}",
85                path.display(),
86                err
87            );
88        }
89    }
90}
91
92fn prune_old_crash_logs(dir: &std::path::Path, max_files: usize) {
93    if max_files == 0 {
94        return;
95    }
96    let Ok(entries) = std::fs::read_dir(dir) else {
97        return;
98    };
99    let mut crash_logs = Vec::new();
100    for entry in entries.flatten() {
101        let path = entry.path();
102        if !path
103            .file_name()
104            .and_then(|s| s.to_str())
105            .is_some_and(|s| s.starts_with("crash-") && s.ends_with(".log"))
106        {
107            continue;
108        }
109        let Ok(file_type) = entry.file_type() else {
110            continue;
111        };
112        if !file_type.is_file() {
113            continue;
114        }
115        let modified = entry
116            .metadata()
117            .and_then(|meta| meta.modified())
118            .unwrap_or(std::time::UNIX_EPOCH);
119        crash_logs.push((modified, path));
120    }
121
122    if crash_logs.len() <= max_files {
123        return;
124    }
125    crash_logs.sort_by(|(a_time, a_path), (b_time, b_path)| {
126        a_time.cmp(b_time).then_with(|| a_path.cmp(b_path))
127    });
128    let remove_count = crash_logs.len() - max_files;
129    for (_, path) in crash_logs.into_iter().take(remove_count) {
130        if let Err(err) = std::fs::remove_file(&path) {
131            eprintln!(
132                "futu-opend: failed to prune old crash log {}: {}",
133                path.display(),
134                err
135            );
136        }
137    }
138}
139
140fn crash_log_unix_secs_or_zero() -> u64 {
141    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
142        Ok(elapsed) => elapsed.as_secs(),
143        Err(err) => {
144            eprintln!(
145                "futu-opend: system wall clock is before UNIX_EPOCH while writing crash log; \
146                 using zero timestamp fallback: {err}"
147            );
148            0
149        }
150    }
151}
152
153/// v1.4.97 P1-D-D: at startup, scan crash dir for previous crashes, emit a
154/// WARN log with the latest filename so ops sees "daemon previously crashed
155/// at <ts>" without manual file inspection. Best-effort, silent on errors.
156pub fn warn_if_previous_crash() {
157    let dir = crash_log_dir();
158    let Ok(entries) = std::fs::read_dir(&dir) else {
159        return;
160    };
161    // Find latest crash-*.log by mtime
162    let mut latest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
163    for entry in entries.flatten() {
164        let path = entry.path();
165        if !path
166            .file_name()
167            .and_then(|s| s.to_str())
168            .is_some_and(|s| s.starts_with("crash-") && s.ends_with(".log"))
169        {
170            continue;
171        }
172        let Ok(meta) = entry.metadata() else { continue };
173        let Ok(mtime) = meta.modified() else { continue };
174        if latest.as_ref().is_none_or(|(t, _)| mtime > *t) {
175            latest = Some((mtime, path));
176        }
177    }
178    if let Some((_, path)) = latest {
179        // Use eprintln since this runs before tracing init.
180        eprintln!(
181            "⚠️  v1.4.97 P1-D-D: previous crash log detected at {}",
182            path.display()
183        );
184        eprintln!("    inspect for last-known panic payload + backtrace.");
185    }
186}
187
188#[cfg(test)]
189mod tests;