From c3b10c53fc78b2a6135cbee3364d2ccce6248c35 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Sun, 6 Sep 2026 20:57:55 +0800 Subject: [PATCH] fix(proxy): serialize startup flag clearing with takeover switch locks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup crash recovery cleared leftover proxy_config.enabled flags by re-reading every app row and overwriting them without holding the per-app switch lock used by set_takeover_for_app. A user manually enabling routing while restore_proxy_state_on_startup was still running could get its committed enabled=true clobbered, leaving the live config actually taken over (proxy address written, proxy running) while the database reported takeover disabled. The cleanup now lives on ProxyService as clear_stale_takeover_flags: each app's flag is only cleared under that app's switch lock, and only when the flag is genuinely stale — no live backup and no takeover placeholder in the live config. An enable that committed inside the startup window keeps its flag because the takeover is genuinely active. --- src-tauri/src/lib.rs | 68 +-------------- src-tauri/src/services/proxy.rs | 148 ++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 64 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 41475fd8d..fdc696559 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1991,25 +1991,6 @@ async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static apps } -/// 清除所有应用的接管标志(`proxy_config.enabled`)。 -/// -/// 「开机自动恢复路由接管」关闭时在启动阶段调用:崩溃恢复已经把 Live 配置 -/// 恢复为原始内容,这里再把残留的接管标志清掉,使本次及后续启动都不会 -/// 自动重新接管,客户端配置不再指向失效的路由地址。 -async fn clear_enabled_proxy_apps_for_startup(db: &database::Database) { - for app_type in PROXY_STARTUP_APP_TYPES { - if let Ok(mut config) = db.get_proxy_config_for_app(app_type).await { - if config.enabled { - config.enabled = false; - if let Err(e) = db.update_proxy_config_for_app(config).await { - log::warn!("清除 {app_type} 接管标志失败: {e}"); - } - } - } - } - let _ = db.set_live_takeover_active(false).await; -} - async fn restore_proxy_state_on_startup(state: &store::AppState) { // 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取) let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await; @@ -2023,7 +2004,7 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) { // 这里只清掉残留标志,不重新接管。 if !crate::settings::get_settings().proxy_restore_on_startup { log::info!("开机自动恢复路由接管已关闭,清除残留接管标志,保持 Live 为原始配置"); - clear_enabled_proxy_apps_for_startup(&state.db).await; + state.proxy_service.clear_stale_takeover_flags().await; return; } @@ -2339,9 +2320,9 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! { #[cfg(test)] mod tests { use super::{ - classify_exit_request, clear_enabled_proxy_apps_for_startup, enabled_proxy_apps_on_startup, - redact_url_for_log, redact_url_for_log_with_secrets, redact_url_origin_for_log, - runtime_log_level_allows, ExitRequestAction, + classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log, + redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows, + ExitRequestAction, }; use crate::database::Database; @@ -2467,45 +2448,4 @@ mod tests { assert_eq!(apps, vec!["grokbuild"]); } - - #[tokio::test] - async fn clear_enabled_proxy_apps_for_startup_clears_all_flags() { - let db = Database::memory().expect("initialize database"); - for app_type in ["claude", "grokbuild"] { - let mut config = db - .get_proxy_config_for_app(app_type) - .await - .expect("read proxy config"); - config.enabled = true; - db.update_proxy_config_for_app(config) - .await - .expect("enable proxy config"); - } - db.set_live_takeover_active(true) - .await - .expect("set takeover flag"); - - clear_enabled_proxy_apps_for_startup(&db).await; - - assert!( - enabled_proxy_apps_on_startup(&db).await.is_empty(), - "all enabled flags should be cleared" - ); - for app_type in ["claude", "grokbuild"] { - let config = db - .get_proxy_config_for_app(app_type) - .await - .expect("read proxy config"); - assert!( - !config.enabled, - "{app_type} enabled flag should be false after clearing" - ); - } - assert!( - !db.is_live_takeover_active() - .await - .expect("read takeover flag"), - "legacy takeover flag should be cleared" - ); - } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index b97fe9208..20d1cb60c 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1411,6 +1411,69 @@ impl ProxyService { Ok(()) } + /// Clear stale per-app takeover flags left behind by startup crash recovery. + /// + /// Used when "auto-resume routing on startup" is disabled: startup crash + /// recovery has already restored the live configs and deleted the backups, + /// so any remaining `proxy_config.enabled = true` is residue that must not + /// re-arm takeover on the next launch. + /// + /// Holds the per-app switch lock so this cleanup serializes against + /// `set_takeover_for_app`: it can never clobber an in-flight enable, and an + /// enable that already committed inside the startup window is preserved — + /// a genuinely active takeover (live placeholder or backup present) is left + /// untouched instead of being reported as residue. + pub async fn clear_stale_takeover_flags(&self) { + let mut active_takeover_left = false; + + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::GrokBuild, + ] { + let app_type_str = app.as_str(); + let _guard = self.switch_locks.lock_for_app(app_type_str).await; + + let mut config = match self.db.get_proxy_config_for_app(app_type_str).await { + Ok(config) => config, + Err(e) => { + log::warn!("读取 {app_type_str} 接管标志失败: {e}"); + continue; + } + }; + if !config.enabled { + continue; + } + + // After startup crash recovery a stale flag has neither a live backup + // nor a taken-over live config. If either is present, takeover was + // (re-)enabled inside the startup window and is genuinely active. + let has_backup = self + .db + .get_live_backup(app_type_str) + .await + .map(|backup| backup.is_some()) + .unwrap_or(false); + if has_backup || self.detect_takeover_in_live_config_for_app(&app) { + log::info!("{app_type_str} 接管处于激活状态(启动窗口内重新启用),保留接管标志"); + active_takeover_left = true; + continue; + } + + config.enabled = false; + if let Err(e) = self.db.update_proxy_config_for_app(config).await { + log::warn!("清除 {app_type_str} 接管标志失败: {e}"); + continue; + } + log::info!("已清除 {app_type_str} 的残留接管标志"); + } + + if !active_takeover_left { + let _ = self.db.set_live_takeover_active(false).await; + } + } + /// 同步 Live 配置中的 Token 到数据库 /// /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。 @@ -4732,6 +4795,91 @@ mod tests { ); } + #[tokio::test] + #[serial] + async fn clear_stale_takeover_flags_clears_residue_and_spares_active_takeover() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; + let service = ProxyService::new(db.clone()); + + // Claude mimics the Codex-review race: an enable that committed inside + // the startup window. It is genuinely taken over (live backup + + // placeholder in the live config), so its flag must be spared. + let provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "env": { + "ANTHROPIC_API_KEY": "provider-key", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + }), + None, + ); + db.save_provider("claude", &provider) + .expect("save provider"); + db.set_current_provider("claude", "p1") + .expect("set db current provider"); + crate::settings::set_current_provider(&AppType::Claude, Some("p1")) + .expect("set local current provider"); + service + .write_claude_live(&json!({ + "env": { + "ANTHROPIC_API_KEY": "live-key", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .expect("seed claude live config"); + service + .set_takeover_for_app("claude", true) + .await + .expect("enable takeover inside the startup window"); + + // Codex mimics the intended target: a stale flag whose live config was + // already restored by startup crash recovery (no placeholder, no backup). + let mut codex_config = db + .get_proxy_config_for_app("codex") + .await + .expect("read codex proxy config"); + codex_config.enabled = true; + db.update_proxy_config_for_app(codex_config) + .await + .expect("seed stale codex enabled flag"); + + service.clear_stale_takeover_flags().await; + + let claude_after = db + .get_proxy_config_for_app("claude") + .await + .expect("read claude proxy config after cleanup"); + assert!( + claude_after.enabled, + "active takeover inside the startup window must keep its enabled flag" + ); + let live = service.read_claude_live().expect("read claude live"); + let base_url = live + .get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|value| value.as_str()) + .expect("claude base url"); + assert!( + base_url.starts_with("http://127.0.0.1:"), + "active takeover must keep the proxy base url in live config" + ); + + let codex_after = db + .get_proxy_config_for_app("codex") + .await + .expect("read codex proxy config after cleanup"); + assert!( + !codex_after.enabled, + "stale residue flag must be cleared so it cannot re-arm takeover" + ); + } + #[tokio::test] #[serial] async fn update_config_reprojection_waits_for_codex_switch_lock_before_rebuilding_live_auth() {