Skip to content

Commit fa32a34

Browse files
committed
feat(logging): record abnormal exits and crash causes
Add a database-independent app_exit_monitor that writes a startup marker in the app log directory, records clean and forced exit events to app-exit-events.jsonl, and detects a leftover marker on the next launch as an abnormal exit. Wire the monitor into startup, window/user exit, restart, Windows updater install, config/database failure exits, and the panic hook. Panic still writes full crash.log backtraces, while JSONL now stores the panic message, source location, thread summary, version, pid, OS, and arch for quick diagnosis. Expose an Open Log Directory action in Settings so users can collect cc-switch.log, app-exit-events.jsonl, app-run-marker.json, and codex-router.log. Record the new diagnostics contract in memory.md.
1 parent f558467 commit fa32a34

9 files changed

Lines changed: 361 additions & 1 deletion

File tree

memory.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@
5353
- 现有“异常退出恢复”只针对代理/Live 接管残留:启动时检查 DB live backup 和 live config 占位符,必要时调用 `recover_from_crash()` 恢复配置。这不是通用的频繁退出检测,也不会统计崩溃次数。
5454
- 当前没有现成的“频繁退出/崩溃频率”检测:没有启动 marker、正常退出 marker 清理、退出原因/退出码统一记录、时间窗口计数、watchdog、最近 crash 自动提示,也没有“打开日志目录”的设置页按钮。排查别人频繁退出时,先让对方收集 `~/.cc-switch/crash.log``~/.cc-switch/logs/cc-switch.log`,若涉及 Codex MultiRouter 再收集 `~/.cc-switch/logs/codex-router.log`;如果 `crash.log` 没有新条目,就要考虑非 Rust panic 路径(前端/WebView、系统杀进程、安装器重启、进程 abort)。
5555

56+
## 2026-06-28 Abnormal Exit And Crash Cause Logging
57+
58+
- 新增 `src-tauri/src/app_exit_monitor.rs` 作为不依赖数据库的异常退出记录层:启动时写 `<app_config_dir>/logs/app-run-marker.json`,正常退出时删除 marker 并向 `<app_config_dir>/logs/app-exit-events.jsonl` 追加 `clean_exit`,下次启动如果发现 marker 残留则追加 `abnormal_exit_detected` 并在 `cc-switch.log` 打 warn。这样数据库初始化失败、配置迁移失败或 Tauri 事件循环异常退出也能留下证据。
59+
- `panic_hook` 现在除了继续写完整 `<app_config_dir>/crash.log`,还会向 `app-exit-events.jsonl` 写结构化 `panic` 事件,包含 panic message、源码位置和线程摘要;完整 backtrace 仍只在 `crash.log`,避免 JSONL 过大。
60+
- 已挂接的正常/显式退出路径包括窗口关闭退出、用户主动退出、Tauri restart、自定义 `restart_process`、Windows updater install 前退出、旧 config 加载失败用户退出、数据库初始化失败用户退出。系统强杀/abort 仍无法在退出前写 clean event,但会因 marker 残留在下次启动被识别。
61+
- 设置页高级日志配置新增“打开日志目录”入口,调用 `open_log_dir` 打开 `<app_config_dir>/logs`,方便用户收集 `cc-switch.log``app-exit-events.jsonl``app-run-marker.json``codex-router.log`。完整 Rust backtrace 的 `crash.log` 仍位于 `<app_config_dir>` 根目录。
62+
5663
## 2026-06-26 CCSwitchMulti v3.16.3-22 Prerelease
5764

5865
- `v3.16.3-22` 已作为 GitHub prerelease 发布:`https://github.com/BigStrongSun/ccswitchmulti/releases/tag/v3.16.3-22`。Release 为非 draft、`prerelease=true`,发布时间为 `2026-06-26T04:16:52Z`,tag 指向 `d4260d1aeb89ade1859f4a341612a8453fc57cbb chore(release): prepare v3.16.3-22 prerelease`

src-tauri/src/app_exit_monitor.rs

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
//! 应用退出与崩溃监控。
2+
//!
3+
//! 这个模块不依赖数据库,确保数据库初始化失败、panic hook 或更新安装器直接退出时
4+
//! 仍能留下可排查的本地证据。
5+
6+
use serde::{Deserialize, Serialize};
7+
use serde_json::{json, Value};
8+
use std::fs::{self, OpenOptions};
9+
use std::io::Write;
10+
use std::path::PathBuf;
11+
use std::sync::OnceLock;
12+
13+
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
14+
const RUN_MARKER_FILE: &str = "app-run-marker.json";
15+
const EXIT_EVENTS_FILE: &str = "app-exit-events.jsonl";
16+
17+
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
18+
19+
/// 初始化退出监控使用的配置目录。
20+
///
21+
/// 必须在 Store 覆盖目录刷新后调用;若 panic 发生得更早,模块会回退到默认
22+
/// `~/.cc-switch`,避免因为目录尚未初始化而丢失崩溃证据。
23+
pub fn init_app_config_dir(dir: PathBuf) {
24+
let _ = APP_CONFIG_DIR.set(dir);
25+
}
26+
27+
/// 记录应用启动,并检查上次是否存在未清理的运行 marker。
28+
///
29+
/// 返回 `Some` 表示上次进程没有走正常退出记录。调用方可以据此打 warn 日志或在 UI
30+
/// 层后续提示用户查看日志目录。
31+
pub fn record_startup() -> Option<PreviousRunReport> {
32+
let previous = read_run_marker();
33+
if let Some(marker) = previous.as_ref() {
34+
let report = PreviousRunReport {
35+
marker: marker.clone(),
36+
crash_log_modified_at: file_modified_at(crash_log_path()),
37+
};
38+
append_event(
39+
"abnormal_exit_detected",
40+
"previous run marker remained at startup",
41+
None,
42+
Some(json!({
43+
"previousRun": report.marker,
44+
"crashLogModifiedAt": report.crash_log_modified_at,
45+
})),
46+
);
47+
}
48+
49+
let marker = RunMarker {
50+
started_at: now_string(),
51+
pid: std::process::id(),
52+
version: APP_VERSION.to_string(),
53+
os: std::env::consts::OS.to_string(),
54+
arch: std::env::consts::ARCH.to_string(),
55+
cwd: std::env::current_dir()
56+
.map(|path| path.display().to_string())
57+
.unwrap_or_else(|_| "unknown".to_string()),
58+
};
59+
60+
if let Err(err) = write_run_marker(&marker) {
61+
log::warn!("写入应用运行 marker 失败: {err}");
62+
}
63+
64+
previous.map(|marker| PreviousRunReport {
65+
marker,
66+
crash_log_modified_at: file_modified_at(crash_log_path()),
67+
})
68+
}
69+
70+
/// 记录一次正常退出,并清理运行 marker。
71+
///
72+
/// 退出原因由调用方传入,便于区分托盘退出、窗口关闭、设置重启和更新安装等不同路径。
73+
pub fn record_clean_exit(reason: &str, exit_code: i32) {
74+
append_event("clean_exit", reason, Some(exit_code), None);
75+
if let Err(err) = fs::remove_file(run_marker_path()) {
76+
if err.kind() != std::io::ErrorKind::NotFound {
77+
log::warn!("清理应用运行 marker 失败: {err}");
78+
}
79+
}
80+
}
81+
82+
/// 记录即将直接退出的错误路径。
83+
///
84+
/// 这类路径通常发生在数据库或配置加载阶段,不能假设 Tauri 事件循环和数据库都可用。
85+
pub fn record_forced_exit(reason: &str, exit_code: i32, detail: impl Into<Option<String>>) {
86+
append_event(
87+
"forced_exit",
88+
reason,
89+
Some(exit_code),
90+
detail.into().map(|detail| json!({ "detail": detail })),
91+
);
92+
let _ = fs::remove_file(run_marker_path());
93+
}
94+
95+
/// 记录 panic hook 捕获到的崩溃摘要。
96+
///
97+
/// 详细 backtrace 仍由 `panic_hook` 写入 `crash.log`;这里写一条结构化 JSONL,
98+
/// 方便下次启动或用户汇总“崩溃原因”。
99+
pub fn record_panic(message: &str, location: Option<String>, thread: Option<String>) {
100+
append_event(
101+
"panic",
102+
message,
103+
None,
104+
Some(json!({
105+
"location": location,
106+
"thread": thread,
107+
})),
108+
);
109+
}
110+
111+
/// 打开日志目录。
112+
///
113+
/// 返回路径字符串供前端 toast 或调试使用;实际打开由命令层完成。
114+
pub fn log_dir_path() -> PathBuf {
115+
get_app_config_dir().join("logs")
116+
}
117+
118+
/// 异常退出历史文件路径。
119+
pub fn exit_events_path() -> PathBuf {
120+
log_dir_path().join(EXIT_EVENTS_FILE)
121+
}
122+
123+
/// 上次未正常退出的报告。
124+
#[derive(Debug, Clone, Serialize, Deserialize)]
125+
#[serde(rename_all = "camelCase")]
126+
pub struct PreviousRunReport {
127+
pub marker: RunMarker,
128+
pub crash_log_modified_at: Option<String>,
129+
}
130+
131+
#[derive(Debug, Clone, Serialize, Deserialize)]
132+
#[serde(rename_all = "camelCase")]
133+
pub struct RunMarker {
134+
pub started_at: String,
135+
pub pid: u32,
136+
pub version: String,
137+
pub os: String,
138+
pub arch: String,
139+
pub cwd: String,
140+
}
141+
142+
#[derive(Debug, Serialize)]
143+
#[serde(rename_all = "camelCase")]
144+
struct ExitEvent {
145+
timestamp: String,
146+
kind: String,
147+
reason: String,
148+
exit_code: Option<i32>,
149+
version: String,
150+
os: String,
151+
arch: String,
152+
pid: u32,
153+
details: Option<Value>,
154+
}
155+
156+
fn append_event(kind: &str, reason: &str, exit_code: Option<i32>, details: Option<Value>) {
157+
let event = ExitEvent {
158+
timestamp: now_string(),
159+
kind: kind.to_string(),
160+
reason: reason.to_string(),
161+
exit_code,
162+
version: APP_VERSION.to_string(),
163+
os: std::env::consts::OS.to_string(),
164+
arch: std::env::consts::ARCH.to_string(),
165+
pid: std::process::id(),
166+
details,
167+
};
168+
169+
let path = exit_events_path();
170+
if let Some(parent) = path.parent() {
171+
if fs::create_dir_all(parent).is_err() {
172+
return;
173+
}
174+
}
175+
176+
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else {
177+
return;
178+
};
179+
if let Ok(line) = serde_json::to_string(&event) {
180+
let _ = writeln!(file, "{line}");
181+
}
182+
}
183+
184+
fn read_run_marker() -> Option<RunMarker> {
185+
let text = fs::read_to_string(run_marker_path()).ok()?;
186+
serde_json::from_str(&text).ok()
187+
}
188+
189+
fn write_run_marker(marker: &RunMarker) -> std::io::Result<()> {
190+
let path = run_marker_path();
191+
if let Some(parent) = path.parent() {
192+
fs::create_dir_all(parent)?;
193+
}
194+
let text = serde_json::to_string_pretty(marker).map_err(std::io::Error::other)?;
195+
fs::write(path, text)
196+
}
197+
198+
fn run_marker_path() -> PathBuf {
199+
log_dir_path().join(RUN_MARKER_FILE)
200+
}
201+
202+
fn crash_log_path() -> PathBuf {
203+
get_app_config_dir().join("crash.log")
204+
}
205+
206+
fn get_app_config_dir() -> PathBuf {
207+
APP_CONFIG_DIR
208+
.get()
209+
.cloned()
210+
.unwrap_or_else(default_app_config_dir)
211+
}
212+
213+
fn default_app_config_dir() -> PathBuf {
214+
dirs::home_dir()
215+
.unwrap_or_else(|| PathBuf::from("."))
216+
.join(".cc-switch")
217+
}
218+
219+
fn now_string() -> String {
220+
chrono::Local::now()
221+
.format("%Y-%m-%d %H:%M:%S%.3f")
222+
.to_string()
223+
}
224+
225+
fn file_modified_at(path: PathBuf) -> Option<String> {
226+
let modified = fs::metadata(path).ok()?.modified().ok()?;
227+
let datetime: chrono::DateTime<chrono::Local> = modified.into();
228+
Some(datetime.format("%Y-%m-%d %H:%M:%S%.3f").to_string())
229+
}
230+
231+
#[cfg(test)]
232+
mod tests {
233+
use super::*;
234+
235+
#[test]
236+
fn exit_events_path_uses_logs_directory() {
237+
let path = exit_events_path();
238+
assert!(path.ends_with(EXIT_EVENTS_FILE));
239+
assert!(path.to_string_lossy().contains("logs"));
240+
}
241+
242+
#[test]
243+
fn clean_exit_event_serializes_exit_code() {
244+
let event = ExitEvent {
245+
timestamp: "2026-06-28 12:00:00.000".to_string(),
246+
kind: "clean_exit".to_string(),
247+
reason: "unit_test".to_string(),
248+
exit_code: Some(0),
249+
version: "test".to_string(),
250+
os: "windows".to_string(),
251+
arch: "x86_64".to_string(),
252+
pid: 42,
253+
details: None,
254+
};
255+
256+
let text = serde_json::to_string(&event).expect("serialize event");
257+
assert!(text.contains("\"kind\":\"clean_exit\""));
258+
assert!(text.contains("\"exitCode\":0"));
259+
}
260+
}

src-tauri/src/commands/settings.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#![allow(non_snake_case)]
22

33
use tauri::{AppHandle, Emitter};
4+
use tauri_plugin_opener::OpenerExt;
45
use tauri_plugin_updater::UpdaterExt;
56

67
/// 应用更新下载进度(通过 `update-download-progress` 事件发给前端)。
@@ -239,6 +240,7 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
239240
// 因此清理只能放在 install 前执行,且必须显式移除托盘图标。
240241
crate::save_window_state_before_exit(&app);
241242
crate::cleanup_before_exit(&app).await;
243+
crate::app_exit_monitor::record_clean_exit("windows_update_install", 0);
242244
crate::remove_tray_icon_before_exit(&app);
243245
crate::destroy_single_instance_lock(&app);
244246
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
@@ -746,3 +748,21 @@ pub async fn set_log_config(
746748
);
747749
Ok(true)
748750
}
751+
752+
/// 打开应用日志目录。
753+
///
754+
/// 目录中包含 `cc-switch.log`、`app-exit-events.jsonl`、`app-run-marker.json`,
755+
/// 以及 Codex MultiRouter 的 `codex-router.log`。`crash.log` 位于同级配置目录。
756+
#[tauri::command]
757+
pub async fn open_log_dir(app: AppHandle) -> Result<bool, String> {
758+
let log_dir = crate::app_exit_monitor::log_dir_path();
759+
if !log_dir.exists() {
760+
std::fs::create_dir_all(&log_dir).map_err(|e| format!("创建日志目录失败: {e}"))?;
761+
}
762+
763+
app.opener()
764+
.open_path(log_dir.to_string_lossy().to_string(), None::<String>)
765+
.map_err(|e| format!("打开日志目录失败: {e}"))?;
766+
767+
Ok(true)
768+
}

0 commit comments

Comments
 (0)