fix(proxies): measure latency for the selected successful address family (#585)

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ke Wang
2026-11-03 11:34:40 +08:00
committed by GitHub
co-authored by Codex
parent e7c24781fb
commit a92b95c48c
3 changed files with 57 additions and 3 deletions
+22 -3
View File
@@ -125,6 +125,17 @@ impl HttpProxyProbe {
}
impl HttpProxyProbe {
async fn timed_exit_ip_at(
&self,
proxy: Option<&OutboundProxy>,
endpoint: &str,
) -> (Result<IpAddr, &'static str>, 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<Ipv4Addr> = 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 {
@@ -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}})
}
+2
View File
@@ -589,6 +589,8 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提
代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。
出口测试结果属于诊断信息,不作为选择、授权或导入绑定的准入条件。
双栈出口并发检测,延迟对应首选可用出口的 HTTP 探测耗时(IPv4 优先),不包含另一地址族失败的等待;
检测仍等待两个地址族完成或超时,以保留双栈可用性结果。两者均失败时,延迟为整体检测耗时。
代理启停由代理记录独立持有;关闭时保留绑定、连接配置和位置规则,账号执行投影改为直连,
不使用该代理的位置覆盖,重新启用后恢复原出口。
连接配置改变时在同一事务内同步关联账号并清除旧测试结果。