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/// At startup, scan the crash directory and report the latest crash without
154/// destroying the forensic file. A crash from the running daemon version stays
155/// prominent; an older-version crash is retained as a quiet archive notice.
156/// Best-effort and silent on directory errors.
157pub fn warn_if_previous_crash() {
158    let dir = crash_log_dir();
159    let now_secs = std::time::SystemTime::now()
160        .duration_since(std::time::UNIX_EPOCH)
161        .ok()
162        .map(|elapsed| elapsed.as_secs());
163    if let Some(notice) = previous_crash_notice(&dir, env!("CARGO_PKG_VERSION"), now_secs) {
164        // This still runs before tracing initialization; keep stderr direct.
165        eprintln!("{notice}");
166    }
167}
168
169fn previous_crash_notice(
170    dir: &std::path::Path,
171    current_version: &str,
172    now_secs: Option<u64>,
173) -> Option<String> {
174    let Ok(entries) = std::fs::read_dir(dir) else {
175        return None;
176    };
177    // Existing on-disk contract: the newest crash file is selected by mtime.
178    let mut latest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
179    for entry in entries.flatten() {
180        let path = entry.path();
181        if !path
182            .file_name()
183            .and_then(|s| s.to_str())
184            .is_some_and(|s| s.starts_with("crash-") && s.ends_with(".log"))
185        {
186            continue;
187        }
188        let Ok(meta) = entry.metadata() else { continue };
189        let Ok(mtime) = meta.modified() else { continue };
190        if latest.as_ref().is_none_or(|(t, _)| mtime > *t) {
191            latest = Some((mtime, path));
192        }
193    }
194    let (_, path) = latest?;
195
196    // Read failures and malformed legacy files deliberately classify as
197    // unknown-version and stay prominent rather than hiding a real crash.
198    let contents = std::fs::read_to_string(&path).ok();
199    let daemon_version = contents
200        .as_deref()
201        .and_then(|body| crash_header_value(body, "daemon_version:"))
202        .filter(|version| semver::Version::parse(version).is_ok());
203    let crash_timestamp = contents
204        .as_deref()
205        .and_then(|body| crash_header_value(body, "unix_timestamp:"))
206        .and_then(|value| value.parse::<u64>().ok());
207    let occurred_at = crash_timestamp
208        .map(|timestamp| format_crash_time(timestamp, now_secs))
209        .unwrap_or_else(|| "时间未知".to_string());
210
211    Some(match daemon_version {
212        Some(version) if version == current_version => format!(
213            "⚠️  上次运行发生崩溃({occurred_at},v{current_version})\n    崩溃详情:{}",
214            path.display()
215        ),
216        Some(version) => format!(
217            "历史崩溃存档:v{version},{occurred_at};当前版本 v{current_version}。详情:{}",
218            path.display()
219        ),
220        None => format!(
221            "⚠️  上次运行发生崩溃({occurred_at},版本未知)\n    崩溃详情:{}",
222            path.display()
223        ),
224    })
225}
226
227fn crash_header_value<'a>(body: &'a str, key: &str) -> Option<&'a str> {
228    body.lines()
229        .find_map(|line| line.strip_prefix(key).map(str::trim))
230        .filter(|value| !value.is_empty())
231}
232
233fn format_crash_time(timestamp: u64, now_secs: Option<u64>) -> String {
234    let absolute = i64::try_from(timestamp)
235        .ok()
236        .and_then(|seconds| chrono::DateTime::<chrono::Utc>::from_timestamp(seconds, 0))
237        .map(|date_time| date_time.format("%Y-%m-%d %H:%M:%S UTC").to_string())
238        .unwrap_or_else(|| format!("unix timestamp {timestamp}"));
239
240    let Some(now_secs) = now_secs else {
241        return absolute;
242    };
243    if timestamp > now_secs {
244        return format!("{absolute},晚于当前系统时间");
245    }
246
247    let age_secs = now_secs - timestamp;
248    let relative = if age_secs < 60 {
249        "刚刚".to_string()
250    } else if age_secs < 3_600 {
251        format!("{} 分钟前", age_secs / 60)
252    } else if age_secs < 86_400 {
253        format!("{} 小时前", age_secs / 3_600)
254    } else {
255        format!("{} 天前", age_secs / 86_400)
256    };
257    format!("{absolute},{relative}")
258}
259
260#[cfg(test)]
261mod tests;