diff --git a/docs/BACKEND_REFACTOR_PLAN.md b/docs/BACKEND_REFACTOR_PLAN.md index 2227789..1092a66 100644 --- a/docs/BACKEND_REFACTOR_PLAN.md +++ b/docs/BACKEND_REFACTOR_PLAN.md @@ -89,6 +89,11 @@ - **阶段 4:服务层抽象 🚧** - 新增 `services/provider.rs` 并实现 `ProviderService::switch`,负责供应商切换时的业务流程(live 回填、持久化、MCP 同步),命令层通过薄封装调用并负责状态持久化。 - 扩展 `ProviderService` 提供 `delete` 能力,统一 Codex/Claude 清理逻辑;`tests/provider_service.rs` 校验切换与删除在成功/失败场景(包括缺失供应商、缺少 auth、删除当前供应商)下的行为,确保命令/托盘复用时拥有回归护栏。 +- **阶段 5:锁与阻塞优化 🚧** + - `AppState` 由 `Mutex` 切换为 `RwLock`,命令层根据读/写语义分别使用 `read()` 与 `write()`,避免查询场景被多余互斥阻塞。 + - 配套更新托盘初始化、服务层、MCP/Provider/Import Export 命令及所有集成测试,确保新锁语义下的并发安全;`cargo test` 全量通过(含命令、服务层集成用例)。 + - 针对可能耗时的配置导入/导出命令,抽取 `load_config_for_import` 负责文件 IO 与备份逻辑,并在命令层通过 `tauri::async_runtime::spawn_blocking` 下沉至阻塞线程执行,主线程仅负责状态写入与响应组装。 + - 其余命令(如设置查询、单文件读写)评估后维持同步执行,以免引入不必要的线程切换;后续若新增批量 IO 场景,再按同一模式挂载到阻塞线程。 ## 渐进式重构路线 diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 7cc2ed7..5fdaec9 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -58,7 +58,7 @@ pub async fn get_mcp_config( .to_string(); let mut cfg = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let app_ty = AppType::from(app.as_deref().unwrap_or("claude")); let (servers, normalized) = mcp::get_servers_snapshot_for(&mut cfg, &app_ty); @@ -84,7 +84,7 @@ pub async fn upsert_mcp_server_in_config( ) -> Result { let mut cfg = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let app_ty = AppType::from(app.as_deref().unwrap_or("claude")); let mut sync_targets: Vec = Vec::new(); @@ -115,7 +115,7 @@ pub async fn upsert_mcp_server_in_config( let cfg2 = state .config - .lock() + .read() .map_err(|e| format!("获取锁失败: {}", e))?; for app_ty_to_sync in sync_targets { match app_ty_to_sync { @@ -135,7 +135,7 @@ pub async fn delete_mcp_server_in_config( ) -> Result { let mut cfg = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let app_ty = AppType::from(app.as_deref().unwrap_or("claude")); let existed = mcp::delete_in_config_for(&mut cfg, &app_ty, &id)?; @@ -143,7 +143,7 @@ pub async fn delete_mcp_server_in_config( state.save()?; let cfg2 = state .config - .lock() + .read() .map_err(|e| format!("获取锁失败: {}", e))?; match app_ty { AppType::Claude => mcp::sync_enabled_to_claude(&cfg2)?, @@ -169,7 +169,7 @@ pub async fn set_mcp_enabled( pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result { let mut cfg = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let normalized = mcp::normalize_servers_for(&mut cfg, &AppType::Claude); mcp::sync_enabled_to_claude(&cfg)?; @@ -186,7 +186,7 @@ pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result) -> Result { let mut cfg = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let normalized = mcp::normalize_servers_for(&mut cfg, &AppType::Codex); mcp::sync_enabled_to_codex(&cfg)?; @@ -216,7 +216,7 @@ fn set_mcp_enabled_internal( id: &str, enabled: bool, ) -> Result { - let mut cfg = state.config.lock()?; + let mut cfg = state.config.write()?; let changed = mcp::set_enabled_and_sync_for(&mut cfg, &app_ty, id, enabled)?; drop(cfg); state.save()?; @@ -234,7 +234,7 @@ pub fn set_mcp_enabled_test_hook( } fn import_mcp_from_claude_internal(state: &AppState) -> Result { - let mut cfg = state.config.lock()?; + let mut cfg = state.config.write()?; let changed = mcp::import_from_claude(&mut cfg)?; drop(cfg); if changed > 0 { @@ -249,7 +249,7 @@ pub fn import_mcp_from_claude_test_hook(state: &AppState) -> Result Result { - let mut cfg = state.config.lock()?; + let mut cfg = state.config.write()?; let changed = mcp::import_from_codex(&mut cfg)?; drop(cfg); if changed > 0 { diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 3051ba7..096e674 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -60,7 +60,7 @@ pub async fn get_providers( let config = state .config - .lock() + .read() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config @@ -85,7 +85,7 @@ pub async fn get_current_provider( let config = state .config - .lock() + .read() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config @@ -114,7 +114,7 @@ pub async fn add_provider( let is_current = { let config = state .config - .lock() + .read() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config .get_manager(&app_type) @@ -145,7 +145,7 @@ pub async fn add_provider( { let mut config = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config .get_manager_mut(&app_type) @@ -178,7 +178,7 @@ pub async fn update_provider( let (exists, is_current) = { let config = state .config - .lock() + .read() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config .get_manager(&app_type) @@ -215,7 +215,7 @@ pub async fn update_provider( { let mut config = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config .get_manager_mut(&app_type) @@ -274,7 +274,7 @@ pub async fn delete_provider( { let mut config = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; ProviderService::delete(&mut config, app_type, &id).map_err(|e| e.to_string())?; } @@ -286,7 +286,7 @@ pub async fn delete_provider( /// 切换供应商 fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { - let mut config = state.config.lock().map_err(AppError::from)?; + let mut config = state.config.write().map_err(AppError::from)?; ProviderService::switch(&mut config, app_type, id)?; @@ -323,7 +323,7 @@ pub async fn switch_provider( fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> { { - let config = state.config.lock()?; + let config = state.config.read()?; if let Some(manager) = config.get_manager(&app_type) { if !manager.get_all_providers().is_empty() { // 已存在供应商则视为已导入,保持与原逻辑一致 @@ -358,7 +358,7 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result None, ); - let mut config = state.config.lock()?; + let mut config = state.config.write()?; let manager = config .get_manager_mut(&app_type) .ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?; @@ -420,7 +420,7 @@ pub async fn query_provider_usage( let (api_key, base_url, usage_script_code, timeout) = { let config = state .config - .lock() + .read() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config.get_manager(&app_type).ok_or("应用类型不存在")?; @@ -601,7 +601,7 @@ pub async fn get_custom_endpoints( .ok_or_else(|| "缺少 providerId".to_string())?; let mut cfg_guard = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = cfg_guard @@ -647,7 +647,7 @@ pub async fn add_custom_endpoint( let mut cfg_guard = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = cfg_guard .get_manager_mut(&app_type) @@ -696,7 +696,7 @@ pub async fn remove_custom_endpoint( let mut cfg_guard = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = cfg_guard .get_manager_mut(&app_type) @@ -734,7 +734,7 @@ pub async fn update_endpoint_last_used( let mut cfg_guard = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = cfg_guard .get_manager_mut(&app_type) @@ -779,7 +779,7 @@ pub async fn update_providers_sort_order( let mut config = state .config - .lock() + .write() .map_err(|e| format!("获取锁失败: {}", e))?; let manager = config diff --git a/src-tauri/src/import_export.rs b/src-tauri/src/import_export.rs index 80e0272..6489032 100644 --- a/src-tauri/src/import_export.rs +++ b/src-tauri/src/import_export.rs @@ -193,19 +193,23 @@ fn sync_claude_live( /// 导出配置文件 #[tauri::command] pub async fn export_config_to_file(file_path: String) -> Result { - // 读取当前配置文件 - let config_path = crate::config::get_app_config_path(); - let config_content = - fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e).to_string())?; + tauri::async_runtime::spawn_blocking(move || { + let config_path = crate::config::get_app_config_path(); + let config_content = + fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - // 写入到指定文件 - fs::write(&file_path, &config_content).map_err(|e| AppError::io(&file_path, e).to_string())?; + let target_path = PathBuf::from(&file_path); + fs::write(&target_path, &config_content).map_err(|e| AppError::io(&target_path, e))?; - Ok(json!({ - "success": true, - "message": "Configuration exported successfully", - "filePath": file_path - })) + Ok::<_, AppError>(json!({ + "success": true, + "message": "Configuration exported successfully", + "filePath": file_path + })) + }) + .await + .map_err(|e| format!("导出配置失败: {}", e))? + .map_err(|e: AppError| e.to_string()) } /// 从文件导入配置 @@ -214,15 +218,26 @@ pub async fn import_config_from_file( file_path: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result { - import_config_from_path(Path::new(&file_path), &state) - .map_err(|e| e.to_string()) - .map(|backup_id| { - json!({ - "success": true, - "message": "Configuration imported successfully", - "backupId": backup_id - }) - }) + let path_buf = PathBuf::from(&file_path); + let (new_config, backup_id) = + tauri::async_runtime::spawn_blocking(move || load_config_for_import(&path_buf)) + .await + .map_err(|e| format!("导入配置失败: {}", e))? + .map_err(|e| e.to_string())?; + + { + let mut guard = state + .config + .write() + .map_err(|e| AppError::from(e).to_string())?; + *guard = new_config; + } + + Ok(json!({ + "success": true, + "message": "Configuration imported successfully", + "backupId": backup_id + })) } /// 从文件导入配置的核心逻辑,供命令及测试复用。 @@ -230,6 +245,17 @@ pub fn import_config_from_path( file_path: &Path, state: &crate::store::AppState, ) -> Result { + let (new_config, backup_id) = load_config_for_import(file_path)?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + *guard = new_config; + } + + Ok(backup_id) +} + +fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> { let import_content = fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?; let new_config: crate::app_config::MultiAppConfig = @@ -240,12 +266,7 @@ pub fn import_config_from_path( fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?; - { - let mut guard = state.config.lock().map_err(AppError::from)?; - *guard = new_config; - } - - Ok(backup_id) + Ok((new_config, backup_id)) } /// 同步当前供应商配置到对应的 live 文件 @@ -256,7 +277,7 @@ pub async fn sync_current_providers_live( { let mut config_state = state .config - .lock() + .write() .map_err(|e| AppError::from(e).to_string())?; sync_current_providers_to_live(&mut config_state).map_err(|e| e.to_string())?; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index de5c2a3..04bf1f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,7 +45,7 @@ fn create_tray_menu( app: &tauri::AppHandle, app_state: &AppState, ) -> Result, AppError> { - let config = app_state.config.lock().map_err(AppError::from)?; + let config = app_state.config.read().map_err(AppError::from)?; let mut menu_builder = MenuBuilder::new(app); @@ -433,7 +433,7 @@ pub fn run() { // 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档 { - let mut config_guard = app_state.config.lock().unwrap(); + let mut config_guard = app_state.config.write().unwrap(); let migrated = migration::migrate_copies_into_config(&mut config_guard)?; if migrated { log::info!("已将副本文件导入到 config.json,并完成归档"); diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index d1d4d66..d06f258 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,10 +1,10 @@ use crate::app_config::MultiAppConfig; use crate::error::AppError; -use std::sync::Mutex; +use std::sync::RwLock; /// 全局应用状态 pub struct AppState { - pub config: Mutex, + pub config: RwLock, } impl AppState { @@ -16,13 +16,13 @@ impl AppState { }); Self { - config: Mutex::new(config), + config: RwLock::new(config), } } /// 保存配置到文件 pub fn save(&self) -> Result<(), AppError> { - let config = self.config.lock().map_err(AppError::from)?; + let config = self.config.read().map_err(AppError::from)?; config.save() } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index c6acbbe..4bc4f11 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -1,5 +1,5 @@ use serde_json::json; -use std::{fs, path::Path, sync::Mutex}; +use std::{fs, path::Path, sync::RwLock}; use tauri::async_runtime; use cc_switch_lib::{ @@ -728,7 +728,7 @@ fn import_config_from_path_overwrites_state_and_creates_backup() { .expect("write import file"); let app_state = AppState { - config: Mutex::new(MultiAppConfig::default()), + config: RwLock::new(MultiAppConfig::default()), }; let backup_id = @@ -757,7 +757,7 @@ fn import_config_from_path_overwrites_state_and_creates_backup() { "saved config should record new current provider" ); - let guard = app_state.config.lock().expect("lock state after import"); + let guard = app_state.config.read().expect("lock state after import"); let claude_manager = guard .get_manager(&AppType::Claude) .expect("claude manager in state"); @@ -784,7 +784,7 @@ fn import_config_from_path_invalid_json_returns_error() { fs::write(&invalid_path, "{ not-json ").expect("write invalid json"); let app_state = AppState { - config: Mutex::new(MultiAppConfig::default()), + config: RwLock::new(MultiAppConfig::default()), }; let err = import_config_from_path(&invalid_path, &app_state).expect_err("import should fail"); @@ -802,7 +802,7 @@ fn import_config_from_path_missing_file_produces_io_error() { let missing_path = Path::new("/nonexistent/import.json"); let app_state = AppState { - config: Mutex::new(MultiAppConfig::default()), + config: RwLock::new(MultiAppConfig::default()), }; let err = import_config_from_path(missing_path, &app_state) diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 945bc69..6307e09 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -1,4 +1,4 @@ -use std::fs; +use std::{fs, sync::RwLock}; use serde_json::json; @@ -37,14 +37,14 @@ fn import_default_config_claude_persists_provider() { let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Claude); let state = AppState { - config: std::sync::Mutex::new(config), + config: RwLock::new(config), }; import_default_config_test_hook(&state, AppType::Claude) .expect("import default config succeeds"); // 验证内存状态 - let guard = state.config.lock().expect("lock config"); + let guard = state.config.read().expect("lock config"); let manager = guard .get_manager(&AppType::Claude) .expect("claude manager present"); @@ -71,7 +71,7 @@ fn import_default_config_without_live_file_returns_error() { let home = ensure_test_home(); let state = AppState { - config: std::sync::Mutex::new(MultiAppConfig::default()), + config: RwLock::new(MultiAppConfig::default()), }; let err = import_default_config_test_hook(&state, AppType::Claude) @@ -113,7 +113,7 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() { .expect("seed ~/.claude.json"); let state = AppState { - config: std::sync::Mutex::new(MultiAppConfig::default()), + config: RwLock::new(MultiAppConfig::default()), }; let changed = @@ -123,7 +123,7 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() { "import should report inserted or normalized entries" ); - let guard = state.config.lock().expect("lock config"); + let guard = state.config.read().expect("lock config"); let claude_servers = &guard.mcp.claude.servers; let entry = claude_servers .get("echo") @@ -155,7 +155,7 @@ fn import_mcp_from_claude_invalid_json_preserves_state() { .expect("seed invalid ~/.claude.json"); let state = AppState { - config: std::sync::Mutex::new(MultiAppConfig::default()), + config: RwLock::new(MultiAppConfig::default()), }; let err = @@ -197,13 +197,13 @@ fn set_mcp_enabled_for_codex_writes_live_config() { ); let state = AppState { - config: std::sync::Mutex::new(config), + config: RwLock::new(config), }; set_mcp_enabled_test_hook(&state, AppType::Codex, "codex-server", true) .expect("set enabled should succeed"); - let guard = state.config.lock().expect("lock config"); + let guard = state.config.read().expect("lock config"); let entry = guard .mcp .codex diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index d0e5fdb..4b3b605 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -1,4 +1,5 @@ use serde_json::json; +use std::sync::RwLock; use cc_switch_lib::{ get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook, @@ -71,7 +72,7 @@ command = "say" ); let app_state = AppState { - config: std::sync::Mutex::new(config), + config: RwLock::new(config), }; switch_provider_test_hook(&app_state, AppType::Codex, "new-provider") @@ -94,7 +95,7 @@ command = "say" "config.toml should contain synced MCP servers" ); - let locked = app_state.config.lock().expect("lock config after switch"); + let locked = app_state.config.read().expect("lock config after switch"); let manager = locked .get_manager(&AppType::Codex) .expect("codex manager after switch"); @@ -142,7 +143,7 @@ fn switch_provider_missing_provider_returns_error() { .current = "does-not-exist".to_string(); let app_state = AppState { - config: std::sync::Mutex::new(config), + config: RwLock::new(config), }; let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider") @@ -210,7 +211,7 @@ fn switch_provider_updates_claude_live_and_state() { } let app_state = AppState { - config: std::sync::Mutex::new(config), + config: RwLock::new(config), }; switch_provider_test_hook(&app_state, AppType::Claude, "new-provider") @@ -227,7 +228,7 @@ fn switch_provider_updates_claude_live_and_state() { "live settings.json should reflect new provider auth" ); - let locked = app_state.config.lock().expect("lock config after switch"); + let locked = app_state.config.read().expect("lock config after switch"); let manager = locked .get_manager(&AppType::Claude) .expect("claude manager after switch"); @@ -304,7 +305,7 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() { } let app_state = AppState { - config: std::sync::Mutex::new(config), + config: RwLock::new(config), }; let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid") @@ -317,7 +318,7 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() { other => panic!("expected config error, got {other:?}"), } - let locked = app_state.config.lock().expect("lock config after failure"); + let locked = app_state.config.read().expect("lock config after failure"); let manager = locked.get_manager(&AppType::Codex).expect("codex manager"); assert!( manager.current.is_empty(),