From fe49a0c18937ab274d40583d16373766f5a8e7bf Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 12 Jan 2026 17:34:08 +0800 Subject: [PATCH] fix(proxy): improve global proxy stability and error handling - Fix RwLock silent failures with explicit error propagation - Handle init() duplicate calls gracefully with warning log - Align fallback client config with build_client settings - Make scan_local_proxies async to avoid UI blocking - Add mixed mode support for Clash 7890 port (http+socks5) - Use multiple test targets for better proxy connectivity test - Clear invalid proxy config on init failure - Restore timeout constraints in usage_script - Fix mask_url output for URLs without port - Add structured error codes [GP-001 to GP-009] --- src-tauri/src/commands/global_proxy.rs | 159 ++++++++++++++++--------- src-tauri/src/database/dao/settings.rs | 4 +- src-tauri/src/lib.rs | 32 +++-- src-tauri/src/proxy/forwarder.rs | 9 +- src-tauri/src/proxy/http_client.rs | 135 +++++++++++++++++---- src-tauri/src/usage_script.rs | 3 +- 6 files changed, 251 insertions(+), 91 deletions(-) diff --git a/src-tauri/src/commands/global_proxy.rs b/src-tauri/src/commands/global_proxy.rs index 6d0814413..e8a994df6 100644 --- a/src-tauri/src/commands/global_proxy.rs +++ b/src-tauri/src/commands/global_proxy.rs @@ -20,6 +20,9 @@ pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result, url: String) -> Result<(), String> { let url_opt = if url.trim().is_empty() { @@ -28,17 +31,20 @@ pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> R Some(url.as_str()) }; - // 1. 先验证并构建新的 HTTP 客户端(如果失败则不落库) - http_client::update_proxy(url_opt)?; + // 1. 先验证代理配置是否有效(不应用) + http_client::validate_proxy(url_opt)?; - // 2. 验证成功后再保存到数据库 + // 2. 验证成功后保存到数据库 state .db .set_global_proxy_url(url_opt) .map_err(|e| e.to_string())?; + // 3. DB 写入成功后再应用到运行态 + http_client::apply_proxy(url_opt)?; + log::info!( - "[GlobalProxy] Configuration updated: {}", + "[GlobalProxy] [GP-009] Configuration updated: {}", url_opt .map(http_client::mask_url) .unwrap_or_else(|| "direct connection".to_string()) @@ -62,6 +68,7 @@ pub struct ProxyTestResult { /// 测试代理连接 /// /// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。 +/// 使用多个测试目标,任一成功即认为代理可用。 #[tauri::command] pub async fn test_proxy_url(url: String) -> Result { if url.trim().is_empty() { @@ -80,40 +87,58 @@ pub async fn test_proxy_url(url: String) -> Result { .build() .map_err(|e| format!("Failed to build client: {e}"))?; - // 测试连接到 api.anthropic.com - // 使用 HEAD 请求,即使返回 401 也说明网络通了 - let test_url = "https://api.anthropic.com"; + // 使用多个测试目标,提高兼容性 + // 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点 + let test_urls = [ + "https://httpbin.org/get", + "https://www.google.com", + "https://api.anthropic.com", + ]; - match client.head(test_url).send().await { - Ok(resp) => { - let latency = start.elapsed().as_millis() as u64; - log::debug!( - "[GlobalProxy] Test successful: {} -> {} ({}ms)", - http_client::mask_url(&url), - resp.status(), - latency - ); - Ok(ProxyTestResult { - success: true, - latency_ms: latency, - error: None, - }) - } - Err(e) => { - let latency = start.elapsed().as_millis() as u64; - log::debug!( - "[GlobalProxy] Test failed: {} -> {} ({}ms)", - http_client::mask_url(&url), - e, - latency - ); - Ok(ProxyTestResult { - success: false, - latency_ms: latency, - error: Some(e.to_string()), - }) + let mut last_error = None; + + for test_url in test_urls { + match client.head(test_url).send().await { + Ok(resp) => { + let latency = start.elapsed().as_millis() as u64; + log::debug!( + "[GlobalProxy] Test successful: {} -> {} via {} ({}ms)", + http_client::mask_url(&url), + test_url, + resp.status(), + latency + ); + return Ok(ProxyTestResult { + success: true, + latency_ms: latency, + error: None, + }); + } + Err(e) => { + log::debug!("[GlobalProxy] Test to {test_url} failed: {e}"); + last_error = Some(e); + } } } + + // 所有测试目标都失败 + let latency = start.elapsed().as_millis() as u64; + let error_msg = last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "All test targets failed".to_string()); + + log::debug!( + "[GlobalProxy] Test failed: {} -> {} ({}ms)", + http_client::mask_url(&url), + error_msg, + latency + ); + + Ok(ProxyTestResult { + success: false, + latency_ms: latency, + error: Some(error_msg), + }) } /// 获取当前出站代理状态 @@ -151,34 +176,56 @@ pub struct DetectedProxy { } /// 常见代理端口配置 -const PROXY_PORTS: &[(u16, &str)] = &[ - (7890, "http"), // Clash - (7891, "socks5"), // Clash SOCKS - (1080, "socks5"), // 通用 SOCKS5 - (8080, "http"), // 通用 HTTP - (8888, "http"), // Charles/Fiddler - (3128, "http"), // Squid - (10808, "socks5"), // V2Ray - (10809, "http"), // V2Ray HTTP +/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5) +/// 对于 mixed 端口,会同时返回两种协议供用户选择 +const PROXY_PORTS: &[(u16, &str, bool)] = &[ + (7890, "http", true), // Clash (mixed mode) + (7891, "socks5", false), // Clash SOCKS only + (1080, "socks5", false), // 通用 SOCKS5 + (8080, "http", false), // 通用 HTTP + (8888, "http", false), // Charles/Fiddler + (3128, "http", false), // Squid + (10808, "socks5", false), // V2Ray SOCKS + (10809, "http", false), // V2Ray HTTP ]; /// 扫描本地代理 /// /// 检测常见端口是否有代理服务在运行。 +/// 使用异步任务避免阻塞 UI 线程。 #[tauri::command] -pub fn scan_local_proxies() -> Vec { - let mut found = Vec::new(); +pub async fn scan_local_proxies() -> Vec { + // 使用 spawn_blocking 避免阻塞主线程 + tokio::task::spawn_blocking(|| { + let mut found = Vec::new(); - for &(port, proxy_type) in PROXY_PORTS { - let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port); - if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() { - found.push(DetectedProxy { - url: format!("{proxy_type}://127.0.0.1:{port}"), - proxy_type: proxy_type.to_string(), - port, - }); + for &(port, primary_type, is_mixed) in PROXY_PORTS { + let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port); + if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() { + // 添加主要类型 + found.push(DetectedProxy { + url: format!("{primary_type}://127.0.0.1:{port}"), + proxy_type: primary_type.to_string(), + port, + }); + // 对于 mixed 端口,同时添加另一种协议 + if is_mixed { + let alt_type = if primary_type == "http" { + "socks5" + } else { + "http" + }; + found.push(DetectedProxy { + url: format!("{alt_type}://127.0.0.1:{port}"), + proxy_type: alt_type.to_string(), + port, + }); + } + } } - } - found + found + }) + .await + .unwrap_or_default() } diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 01cb6e640..708391651 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -70,8 +70,8 @@ impl Database { /// 获取全局出站代理 URL /// - /// 返回 None 表示未配置代理(直连) - /// 返回 Some("") 或 Some(url) 需要调用方判断是否为空 + /// 返回 None 表示未配置或已清除代理(直连) + /// 返回 Some(url) 表示已配置代理 pub fn get_global_proxy_url(&self) -> Result, AppError> { self.get_setting(Self::GLOBAL_PROXY_URL_KEY) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b25644e8..37e75b0e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -646,14 +646,32 @@ pub fn run() { // 初始化全局出站代理 HTTP 客户端 { - let proxy_url = app - .state::() - .db - .get_global_proxy_url() - .ok() - .flatten(); + let db = &app.state::().db; + let proxy_url = db.get_global_proxy_url().ok().flatten(); + if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) { - log::error!("[GlobalProxy] Failed to initialize: {e}"); + log::error!( + "[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}" + ); + + // 清除无效的代理配置 + if proxy_url.is_some() { + log::warn!( + "[GlobalProxy] [GP-006] Clearing invalid proxy config from database" + ); + if let Err(clear_err) = db.set_global_proxy_url(None) { + log::error!( + "[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}" + ); + } + } + + // 使用直连模式重新初始化 + if let Err(fallback_err) = crate::proxy::http_client::init(None) { + log::error!( + "[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}" + ); + } } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index c0604d6a3..2ad82d9b7 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -380,7 +380,14 @@ impl RequestForwarder { // 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置) let client = super::http_client::get(); - let mut request = client.post(&url).timeout(self.non_streaming_timeout); + let mut request = client.post(&url); + + // 只有当 timeout > 0 时才设置请求超时 + // Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时" + // 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒) + if !self.non_streaming_timeout.is_zero() { + request = request.timeout(self.non_streaming_timeout); + } // 过滤黑名单 Headers,保护隐私并避免冲突 for (key, value) in headers { diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs index 6ae72e9cf..b4be31277 100644 --- a/src-tauri/src/proxy/http_client.rs +++ b/src-tauri/src/proxy/http_client.rs @@ -25,7 +25,19 @@ pub fn init(proxy_url: Option<&str>) -> Result<(), String> { let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); let client = build_client(effective_url)?; - let _ = GLOBAL_CLIENT.set(RwLock::new(client)); + // 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新 + if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() { + log::warn!( + "[GlobalProxy] [GP-003] Already initialized, updating instead: {}", + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) + ); + // 已初始化,改用 apply_proxy 更新 + return apply_proxy(proxy_url); + } + + // 初始化代理 URL 记录 let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string()))); log::info!( @@ -38,21 +50,41 @@ pub fn init(proxy_url: Option<&str>) -> Result<(), String> { Ok(()) } -/// 更新代理配置(热更新) +/// 验证代理配置(不应用) /// -/// 可在运行时调用以更改代理设置,无需重启应用。 +/// 只验证代理 URL 是否有效,不实际更新全局客户端。 +/// 用于在持久化之前验证配置的有效性。 /// /// # Arguments -/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连 -pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { +/// * `proxy_url` - 代理 URL,None 或空字符串表示直连 +/// +/// # Returns +/// 验证成功返回 Ok(()),失败返回错误信息 +pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + // 只调用 build_client 来验证,但不应用 + build_client(effective_url)?; + Ok(()) +} + +/// 应用代理配置(假设已验证) +/// +/// 直接应用代理配置到全局客户端,不做额外验证。 +/// 应在 validate_proxy 成功后调用。 +/// +/// # Arguments +/// * `proxy_url` - 代理 URL,None 或空字符串表示直连 +pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> { let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); let new_client = build_client(effective_url)?; // 更新客户端 if let Some(lock) = GLOBAL_CLIENT.get() { - if let Ok(mut client) = lock.write() { - *client = new_client; - } + let mut client = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}"); + "Failed to update proxy: lock poisoned".to_string() + })?; + *client = new_client; } else { // 如果还没初始化,则初始化 return init(proxy_url); @@ -60,9 +92,55 @@ pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { // 更新代理 URL 记录 if let Some(lock) = CURRENT_PROXY_URL.get() { - if let Ok(mut url) = lock.write() { - *url = effective_url.map(|s| s.to_string()); - } + let mut url = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}"); + "Failed to update proxy URL record: lock poisoned".to_string() + })?; + *url = effective_url.map(|s| s.to_string()); + } + + log::info!( + "[GlobalProxy] Applied: {}", + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) + ); + + Ok(()) +} + +/// 更新代理配置(热更新) +/// +/// 可在运行时调用以更改代理设置,无需重启应用。 +/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用, +/// 请使用 validate_proxy + apply_proxy 组合。 +/// +/// # Arguments +/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连 +#[allow(dead_code)] +pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + let new_client = build_client(effective_url)?; + + // 更新客户端 + if let Some(lock) = GLOBAL_CLIENT.get() { + let mut client = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}"); + "Failed to update proxy: lock poisoned".to_string() + })?; + *client = new_client; + } else { + // 如果还没初始化,则初始化 + return init(proxy_url); + } + + // 更新代理 URL 记录 + if let Some(lock) = CURRENT_PROXY_URL.get() { + let mut url = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}"); + "Failed to update proxy URL record: lock poisoned".to_string() + })?; + *url = effective_url.map(|s| s.to_string()); } log::info!( @@ -84,10 +162,14 @@ pub fn get() -> Client { .and_then(|lock| lock.read().ok()) .map(|c| c.clone()) .unwrap_or_else(|| { - // 如果还没初始化,创建一个默认客户端 - log::warn!("[GlobalProxy] Client not initialized, using default"); + // 如果还没初始化,创建一个默认客户端(配置与 build_client 一致) + log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback"); Client::builder() - .timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(600)) + .connect_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(60)) + .no_proxy() .build() .unwrap_or_default() }) @@ -149,16 +231,12 @@ fn build_client(proxy_url: Option<&str>) -> Result { /// 隐藏 URL 中的敏感信息(用于日志) pub fn mask_url(url: &str) -> String { if let Ok(parsed) = url::Url::parse(url) { - // 隐藏用户名和密码 - format!( - "{}://{}:{}", - parsed.scheme(), - parsed.host_str().unwrap_or("?"), - parsed - .port() - .map(|p| p.to_string()) - .unwrap_or_else(|| "?".to_string()) - ) + // 隐藏用户名和密码,保留 scheme、host 和端口 + let host = parsed.host_str().unwrap_or("?"); + match parsed.port() { + Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port), + None => format!("{}://{}", parsed.scheme(), host), + } } else { // URL 解析失败,返回部分内容 if url.len() > 20 { @@ -184,6 +262,15 @@ mod tests { mask_url("socks5://admin:secret@proxy.example.com:1080"), "socks5://proxy.example.com:1080" ); + // 无端口的 URL 不应显示 ":?" + assert_eq!( + mask_url("http://proxy.example.com"), + "http://proxy.example.com" + ); + assert_eq!( + mask_url("https://user:pass@proxy.example.com"), + "https://proxy.example.com" + ); } #[test] diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 8ded73074..3da7ac8ec 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -215,7 +215,8 @@ struct RequestConfig { async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result { // 使用全局 HTTP 客户端(已包含代理配置) let client = crate::proxy::http_client::get(); - let request_timeout = std::time::Duration::from_secs(timeout_secs); + // 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒) + let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30)); // 严格校验 HTTP 方法,非法值不回退为 GET let method: reqwest::Method = config.method.parse().map_err(|_| {