From 1b3ee19f62151541a2730c4d0f3dcd0822263eaa Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 13 Jan 2026 18:01:27 +0800 Subject: [PATCH] fix(proxy): resolve HalfOpen permit leak and RectifierConfig default values - Fix RectifierConfig::default() to return enabled=true (was false due to derive) - Add release_permit_neutral() for releasing permits without affecting health stats - Fix 3 permit leak points in rectifier retry branches - Add unit tests for default values and permit release --- src-tauri/src/commands/misc.rs | 5 +- src-tauri/src/proxy/circuit_breaker.rs | 6 ++- src-tauri/src/proxy/forwarder.rs | 52 ++++++++++++++++++- src-tauri/src/proxy/provider_router.rs | 69 ++++++++++++++++++++++++++ src-tauri/src/proxy/types.rs | 46 ++++++++++++++++- 5 files changed, 171 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 7a6707a2c..27d9f7544 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -532,7 +532,7 @@ fn launch_terminal_with_env( #[cfg(target_os = "macos")] { launch_macos_terminal(&config_file, &config_path_escaped)?; - return Ok(()); + Ok(()) } #[cfg(target_os = "linux")] @@ -583,8 +583,7 @@ fn escape_shell_path(path: &std::path::Path) -> String { /// 生成 bash 包装脚本,用于清理临时文件 fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String { format!( - "bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'", - config_path, escaped_path, escaped_path + "bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'" ) } diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs index 310ce9bcb..890a5138f 100644 --- a/src-tauri/src/proxy/circuit_breaker.rs +++ b/src-tauri/src/proxy/circuit_breaker.rs @@ -319,7 +319,11 @@ impl CircuitBreaker { } } - fn release_half_open_permit(&self) { + /// 仅释放 HalfOpen permit,不影响健康统计 + /// + /// 用于整流器等场景:请求结果不应计入 Provider 健康度, + /// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死 + pub fn release_half_open_permit(&self) { let mut current = self.half_open_requests.load(Ordering::SeqCst); loop { if current == 0 { diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 2a83b3b8d..7758d4b07 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -267,6 +267,14 @@ impl RequestForwarder { // 已经重试过:直接返回错误(不可重试客户端错误) if rectifier_retried { log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试"); + // 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题) + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; let mut status = self.status.write().await; status.failed_requests += 1; status.last_error = Some(e.to_string()); @@ -289,6 +297,14 @@ impl RequestForwarder { log::warn!( "[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试" ); + // 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题) + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; let mut status = self.status.write().await; status.failed_requests += 1; status.last_error = Some(e.to_string()); @@ -380,11 +396,43 @@ impl RequestForwarder { }); } Err(retry_err) => { - // 整流重试仍失败:直接返回错误(不可重试客户端错误) - // 不记录熔断器、不继续 failover + // 整流重试仍失败:区分错误类型决定是否记录熔断器 log::warn!( "[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}" ); + + // 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit + let is_provider_error = match &retry_err { + ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => { + true + } + ProxyError::UpstreamError { status, .. } => *status >= 500, + _ => false, + }; + + if is_provider_error { + // Provider 问题:记录失败到熔断器 + let _ = self + .router + .record_result( + &provider.id, + app_type_str, + used_half_open_permit, + false, + Some(retry_err.to_string()), + ) + .await; + } else { + // 客户端问题:仅释放 permit,不记录熔断器 + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + } + let mut status = self.status.write().await; status.failed_requests += 1; status.last_error = Some(retry_err.to_string()); diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index a793100bf..5f3a315fd 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -151,6 +151,24 @@ impl ProviderRouter { self.reset_circuit_breaker(&circuit_key).await; } + /// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口) + /// + /// 用于整流器等场景:请求结果不应计入 Provider 健康度, + /// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死 + pub async fn release_permit_neutral( + &self, + provider_id: &str, + app_type: &str, + used_half_open_permit: bool, + ) { + if !used_half_open_permit { + return; + } + let circuit_key = format!("{app_type}:{provider_id}"); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + breaker.release_half_open_permit(); + } + /// 更新所有熔断器的配置(热更新) pub async fn update_all_configs(&self, config: CircuitBreakerConfig) { let breakers = self.circuit_breakers.read().await; @@ -325,4 +343,55 @@ mod tests { assert!(router.allow_provider_request("b", "claude").await.allowed); } + + #[tokio::test] + async fn test_release_permit_neutral_frees_half_open_slot() { + let db = Arc::new(Database::memory().unwrap()); + + // 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen + db.update_circuit_breaker_config(&CircuitBreakerConfig { + failure_threshold: 1, + timeout_seconds: 0, + ..Default::default() + }) + .await + .unwrap(); + + let provider_a = + Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None); + db.save_provider("claude", &provider_a).unwrap(); + db.add_to_failover_queue("claude", "a").unwrap(); + + // 启用自动故障转移 + let mut config = db.get_proxy_config_for_app("claude").await.unwrap(); + config.auto_failover_enabled = true; + db.update_proxy_config_for_app(config).await.unwrap(); + + let router = ProviderRouter::new(db.clone()); + + // 触发熔断:1 次失败 + router + .record_result("a", "claude", false, false, Some("fail".to_string())) + .await + .unwrap(); + + // 第一次请求:获取 HalfOpen 探测名额 + let first = router.allow_provider_request("a", "claude").await; + assert!(first.allowed); + assert!(first.used_half_open_permit); + + // 第二次请求应被拒绝(名额已被占用) + let second = router.allow_provider_request("a", "claude").await; + assert!(!second.allowed); + + // 使用 release_permit_neutral 释放名额(不影响健康统计) + router + .release_permit_neutral("a", "claude", first.used_half_open_permit) + .await; + + // 第三次请求应被允许(名额已释放) + let third = router.allow_provider_request("a", "claude").await; + assert!(third.allowed); + assert!(third.used_half_open_permit); + } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 012488cb3..f49963927 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -195,7 +195,7 @@ pub struct AppProxyConfig { /// 整流器配置 /// /// 存储在 settings 表中 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RectifierConfig { /// 总开关:是否启用整流器 @@ -208,6 +208,50 @@ pub struct RectifierConfig { pub request_thinking_signature: bool, } +impl Default for RectifierConfig { + fn default() -> Self { + Self { + enabled: true, + request_thinking_signature: true, + } + } +} + fn default_true() -> bool { true } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rectifier_config_default_enabled() { + // 验证 RectifierConfig::default() 返回全启用状态 + // 防止回归:#[derive(Default)] 会使 bool 默认为 false + let config = RectifierConfig::default(); + assert!(config.enabled, "整流器总开关默认应为 true"); + assert!( + config.request_thinking_signature, + "thinking 签名整流器默认应为 true" + ); + } + + #[test] + fn test_rectifier_config_serde_default() { + // 验证反序列化缺字段时使用 default_true + let json = "{}"; + let config: RectifierConfig = serde_json::from_str(json).unwrap(); + assert!(config.enabled); + assert!(config.request_thinking_signature); + } + + #[test] + fn test_rectifier_config_serde_explicit_false() { + // 验证显式设置 false 时正确反序列化 + let json = r#"{"enabled": false, "requestThinkingSignature": false}"#; + let config: RectifierConfig = serde_json::from_str(json).unwrap(); + assert!(!config.enabled); + assert!(!config.request_thinking_signature); + } +}