fix(proxy): serialize startup flag clearing with takeover switch locks

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.
This commit is contained in:
YUZHEthefool committed 2026-09-06 20:57:55 +08:00
1 parent 157ff7e8d1
commit c3b10c53fc
2 files changed
+152 -64

No files matched your search

+4 -64
View File
@@ -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"
);
}
}
+148
View File
@@ -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() {