From 2b6b2b97a31bafad07f65d80f4103090f3abfef7 Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Tue, 9 Jun 2026 10:53:09 +0200 Subject: [PATCH 1/3] Initial implementation of async DNS resolution And DNS resolution timeout --- client/transport/Cargo.toml | 6 ++++ client/transport/src/ws/mod.rs | 65 ++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/client/transport/Cargo.toml b/client/transport/Cargo.toml index c641442d39..6ea667363a 100644 --- a/client/transport/Cargo.toml +++ b/client/transport/Cargo.toml @@ -67,6 +67,12 @@ web = [ "thiserror", ] +[dev-dependencies] +# `rt` provides the `#[tokio::test]` runtime and the `spawn_blocking` pool that +# `tokio::net::lookup_host` relies on. `macros`/`time` are already enabled via the `ws` +# feature but are listed here so the test requirements are explicit. +tokio = { workspace = true, features = ["rt", "macros", "time"] } + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/client/transport/src/ws/mod.rs b/client/transport/src/ws/mod.rs index 8e00741c48..84dcb69698 100644 --- a/client/transport/src/ws/mod.rs +++ b/client/transport/src/ws/mod.rs @@ -363,7 +363,7 @@ impl WsTransportClientBuilder { let mut connector = self.tls_connector(&target)?; // The sockaddrs might get reused if the server replies with a relative URI. - let mut target_sockaddrs = uri.socket_addrs(|| None).map_err(WsHandshakeError::ResolutionFailed)?; + let mut target_sockaddrs = resolve_sockaddrs(&uri, self.connection_timeout).await?; for _ in 0..self.max_redirections { tracing::debug!(target: LOG_TARGET, "Connecting to target: {:?}", target); @@ -408,10 +408,7 @@ impl WsTransportClientBuilder { // redirection with absolute path => need to lookup. Ok(uri) => { // Absolute URI. - target_sockaddrs = uri.socket_addrs(|| None).map_err(|e| { - tracing::debug!(target: LOG_TARGET, "Redirection failed: {:?}", e); - e - })?; + target_sockaddrs = resolve_sockaddrs(&uri, self.connection_timeout).await?; target = uri.try_into().map_err(|e| { tracing::debug!(target: LOG_TARGET, "Redirection failed: {:?}", e); @@ -530,6 +527,49 @@ impl WsTransportClientBuilder { } } +/// Resolve the socket addresses for `uri` using asynchronous DNS resolution. +/// +/// This replaces `url::Url::socket_addrs`, which performs *blocking* `getaddrinfo` +/// resolution (via `std::net::ToSocketAddrs`) on the calling thread. Inside the async client +/// that means a Tokio worker thread can be blocked for as long as the system resolver takes +/// `tokio::net::lookup_host` runs the lookup on Tokio's blocking thread pool instead, +/// and IP literals are turned into a `SocketAddr` directly without any DNS lookup. +/// +/// The whole resolution is bounded by `timeout_dur`; on expiry it returns +/// `WsHandshakeError::Timeout`. This budget is per-resolution and is additive with the +/// per-address TCP connect timeout applied in `connect`. +async fn resolve_sockaddrs(uri: &Url, timeout_dur: Duration) -> Result, WsHandshakeError> { + let resolve = async { + let port = + uri.port_or_known_default().ok_or_else(|| WsHandshakeError::Url("No port number in the URL".into()))?; + + // NOTE: match on `uri.host()` (the `url::Host` enum) rather than `host_str()`. The latter + // returns IPv6 hosts *with* brackets (e.g. `"[::1]"`), which `getaddrinfo` rejects, whereas + // the enum exposes a parsed `Ipv6Addr`. This mirrors what `url::Url::socket_addrs` does + // internally, so IP literals and default ports behave exactly as before. + match uri.host() { + Some(url::Host::Domain(domain)) => { + let addrs = tokio::net::lookup_host((domain, port)) + .await + .map_err(|e| { + tracing::debug!(target: LOG_TARGET, "DNS resolution failed for {domain}: {e:?}"); + WsHandshakeError::ResolutionFailed(e) + })? + .collect(); + Ok(addrs) + } + Some(url::Host::Ipv4(ip)) => Ok(vec![SocketAddr::from((ip, port))]), + Some(url::Host::Ipv6(ip)) => Ok(vec![SocketAddr::from((ip, port))]), + None => Err(WsHandshakeError::Url("No host name in the URL".into())), + } + }; + + match tokio::time::timeout(timeout_dur, resolve).await { + Ok(res) => res, + Err(_elapsed) => Err(WsHandshakeError::Timeout(timeout_dur)), + } +} + #[cfg(feature = "tls")] async fn connect( sockaddr: SocketAddr, @@ -673,9 +713,11 @@ fn build_tls_config(cert_store: &CertificateStore) -> Result Date: Tue, 9 Jun 2026 11:02:35 +0200 Subject: [PATCH 2/3] Clean up --- client/transport/src/ws/mod.rs | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/client/transport/src/ws/mod.rs b/client/transport/src/ws/mod.rs index 84dcb69698..21984164fe 100644 --- a/client/transport/src/ws/mod.rs +++ b/client/transport/src/ws/mod.rs @@ -531,8 +531,8 @@ impl WsTransportClientBuilder { /// /// This replaces `url::Url::socket_addrs`, which performs *blocking* `getaddrinfo` /// resolution (via `std::net::ToSocketAddrs`) on the calling thread. Inside the async client -/// that means a Tokio worker thread can be blocked for as long as the system resolver takes -/// `tokio::net::lookup_host` runs the lookup on Tokio's blocking thread pool instead, +/// that means a Tokio worker thread can be blocked for as long as the system resolver takes. +/// `tokio::net::lookup_host` runs the lookup on Tokio's blocking thread pool instead, /// and IP literals are turned into a `SocketAddr` directly without any DNS lookup. /// /// The whole resolution is bounded by `timeout_dur`; on expiry it returns @@ -548,26 +548,20 @@ async fn resolve_sockaddrs(uri: &Url, timeout_dur: Duration) -> Result { - let addrs = tokio::net::lookup_host((domain, port)) - .await - .map_err(|e| { - tracing::debug!(target: LOG_TARGET, "DNS resolution failed for {domain}: {e:?}"); - WsHandshakeError::ResolutionFailed(e) - })? - .collect(); - Ok(addrs) - } + Some(url::Host::Domain(domain)) => tokio::net::lookup_host((domain, port)) + .await + .map(|addrs| addrs.collect()) + .map_err(|e| { + tracing::debug!(target: LOG_TARGET, "DNS resolution failed for {domain}: {e:?}"); + WsHandshakeError::ResolutionFailed(e) + }), Some(url::Host::Ipv4(ip)) => Ok(vec![SocketAddr::from((ip, port))]), Some(url::Host::Ipv6(ip)) => Ok(vec![SocketAddr::from((ip, port))]), None => Err(WsHandshakeError::Url("No host name in the URL".into())), } }; - match tokio::time::timeout(timeout_dur, resolve).await { - Ok(res) => res, - Err(_elapsed) => Err(WsHandshakeError::Timeout(timeout_dur)), - } + tokio::time::timeout(timeout_dur, resolve).await.map_err(|_| WsHandshakeError::Timeout(timeout_dur))? } #[cfg(feature = "tls")] From ccacde42d458aad62bb0d0e59e329198e21fe80b Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Wed, 10 Jun 2026 16:23:34 +0200 Subject: [PATCH 3/3] cargo fmt --- client/transport/src/ws/mod.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/transport/src/ws/mod.rs b/client/transport/src/ws/mod.rs index 21984164fe..e8cf78196b 100644 --- a/client/transport/src/ws/mod.rs +++ b/client/transport/src/ws/mod.rs @@ -548,13 +548,12 @@ async fn resolve_sockaddrs(uri: &Url, timeout_dur: Duration) -> Result tokio::net::lookup_host((domain, port)) - .await - .map(|addrs| addrs.collect()) - .map_err(|e| { + Some(url::Host::Domain(domain)) => { + tokio::net::lookup_host((domain, port)).await.map(|addrs| addrs.collect()).map_err(|e| { tracing::debug!(target: LOG_TARGET, "DNS resolution failed for {domain}: {e:?}"); WsHandshakeError::ResolutionFailed(e) - }), + }) + } Some(url::Host::Ipv4(ip)) => Ok(vec![SocketAddr::from((ip, port))]), Some(url::Host::Ipv6(ip)) => Ok(vec![SocketAddr::from((ip, port))]), None => Err(WsHandshakeError::Url("No host name in the URL".into())),