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..e8cf78196b 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,42 @@ 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)) => { + 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())), + } + }; + + tokio::time::timeout(timeout_dur, resolve).await.map_err(|_| WsHandshakeError::Timeout(timeout_dur))? +} + #[cfg(feature = "tls")] async fn connect( sockaddr: SocketAddr, @@ -673,9 +706,11 @@ fn build_tls_config(cert_store: &CertificateStore) -> Result