diff --git a/backend/crates/gateway-host/src/proxy_probe.rs b/backend/crates/gateway-host/src/proxy_probe.rs index 71c355bd9..57c6bc941 100644 --- a/backend/crates/gateway-host/src/proxy_probe.rs +++ b/backend/crates/gateway-host/src/proxy_probe.rs @@ -125,6 +125,17 @@ impl HttpProxyProbe { } impl HttpProxyProbe { + async fn timed_exit_ip_at( + &self, + proxy: Option<&OutboundProxy>, + endpoint: &str, + ) -> (Result, u64) { + let started = Instant::now(); + let result = self.exit_ip_at(proxy, endpoint).await; + let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + (result, latency_ms) + } + async fn test_connection(&self, proxy: Option<&OutboundProxy>) -> ProxyTestResult { let started = Instant::now(); let timeout_limit = Duration::from_secs(15); @@ -169,15 +180,15 @@ impl HttpProxyProbe { } => { let probe_dual = async { tokio::join!( - self.exit_ip_at(proxy, ipv4_endpoint), - self.exit_ip_at(proxy, ipv6_endpoint), + self.timed_exit_ip_at(proxy, ipv4_endpoint), + self.timed_exit_ip_at(proxy, ipv6_endpoint), ) }; let result = tokio::time::timeout(timeout_limit, probe_dual).await; let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); match result { - Ok((res_v4, res_v6)) => { + Ok(((res_v4, latency_v4), (res_v6, latency_v6))) => { let exit_ipv4: Option = match res_v4 { Ok(IpAddr::V4(v4)) => Some(v4), _ => None, @@ -186,6 +197,14 @@ impl HttpProxyProbe { Ok(IpAddr::V6(v6)) => Some(v6), _ => None, }; + // 延迟对应首选的可用出口,另一地址族失败等待不代表该出口延迟 + let latency_ms = if exit_ipv4.is_some() { + latency_v4 + } else if exit_ipv6.is_some() { + latency_v6 + } else { + latency_ms + }; if exit_ipv4.is_some() && exit_ipv6.is_some() { ProxyTestResult { diff --git a/backend/crates/gateway-host/tests/proxy_probe.rs b/backend/crates/gateway-host/tests/proxy_probe.rs index 495da13d4..4bf209782 100644 --- a/backend/crates/gateway-host/tests/proxy_probe.rs +++ b/backend/crates/gateway-host/tests/proxy_probe.rs @@ -155,6 +155,39 @@ async fn dual_stack_proxy_probe_reports_single_stack_when_only_one_succeeds() { assert!(result.message.contains("仅 IPv4")); } +#[tokio::test] +async fn dual_stack_latency_measures_the_selected_exit_without_the_other_family_wait() { + use std::time::{Duration, Instant}; + + for working_family in ["v4", "v6"] { + let server = MockServer::start().await; + for (family, ip) in [("v4", "203.0.113.8"), ("v6", "2001:db8::8")] { + let response = if family == working_family { + ResponseTemplate::new(200).set_body_json(json!({"ip": ip})) + } else { + ResponseTemplate::new(503).set_delay(Duration::from_millis(500)) + }; + Mock::given(path(format!("/{family}"))) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + } + let started = Instant::now(); + let result = HttpProxyProbe::new_dual( + format!("{}/v4", server.uri()), + format!("{}/v6", server.uri()), + ) + .test(None, false) + .await; + assert!(result.success); + assert_eq!(result.exit_ipv4.is_some(), working_family == "v4"); + assert_eq!(result.exit_ipv6.is_some(), working_family == "v6"); + assert!(started.elapsed() >= Duration::from_millis(500)); + assert!(u128::from(result.latency_ms) + 400 < started.elapsed().as_millis()); + } +} + fn location_response(ip: &str, timezone: &str) -> serde_json::Value { json!({"success": true, "ip": ip, "country_code": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": {"id": timezone}}) } diff --git a/docs/architecture.md b/docs/architecture.md index 6ff4b6078..5abda7dae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -589,6 +589,8 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提 代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。 出口测试结果属于诊断信息,不作为选择、授权或导入绑定的准入条件。 +双栈出口并发检测,延迟对应首选可用出口的 HTTP 探测耗时(IPv4 优先),不包含另一地址族失败的等待; +检测仍等待两个地址族完成或超时,以保留双栈可用性结果。两者均失败时,延迟为整体检测耗时。 代理启停由代理记录独立持有;关闭时保留绑定、连接配置和位置规则,账号执行投影改为直连, 不使用该代理的位置覆盖,重新启用后恢复原出口。 连接配置改变时在同一事务内同步关联账号并清除旧测试结果。