From bc0da50cdb098821d9840bac48d2cb7453de1ffc Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Fri, 15 May 2026 00:17:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(protocol):=20A.1=20=E2=80=94=20fix=20hostn?= =?UTF-8?q?ame=20resolution=20on=20Android=20and=20iOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the Beta-blocking issue surfaced during Android v0.2.0-beta.1 verification: hostnames could not be used, only literal IPs. Root cause: tsclientlib's built-in resolver uses hickory-resolver, which reads /etc/resolv.conf. That file does not exist on Android or iOS, so any connect by hostname exited the connection task before signalling ready and surfaced the cryptic error BridgeError.connection(field0: protocol backend: connection task exited before signalling ready) Fix: crates/chanora_protocol/src/resolver.rs (new): Resolves hostnames via tokio::net::lookup_host, which uses the platform's getaddrinfo. Works on every platform Chanora targets. Tiny in-process positive-result cache (5 min TTL) keeps reconnects cheap. IPv4 sorted ahead of IPv6 in the returned list to favour the more reliable path on dual-stack networks. crates/chanora_protocol/src/adapter.rs: connection_task now resolves the hostname itself and passes the resulting SocketAddr (not the hostname String) to tsclientlib::Connection::build. tsclientlib's ServerAddress enum accepts SocketAddr via its From impl, so the upstream resolver is skipped entirely. crates/chanora_protocol/src/lib.rs: New typed error arm ProtocolError::DnsFailed { host, reason } so the UI can distinguish 'server not found' from 'server refused our packets'. crates/chanora_bridge/src/lib.rs: Matching BridgeError::DnsFailed { host, reason } DTO surfaced to Dart, with explicit From mapping so the UI gets the structured fields rather than a stringified mess. Tests added (crates/chanora_protocol/src/resolver.rs::tests): - rejects_empty - literal_ipv4_short_circuits - literal_ipv4_default_port_path - unresolvable_returns_dns_failed - resolves_known_hostname (#[ignore], --ignored to run; hits net) Empirical verification (2026-05-14): Workspace: cargo check + cargo test --workspace clean. Live resolver test: cn.teamspeak.app → 175.178.125.23:9987 (passes). cargo test -p chanora_core --test alpha_smoke -- --ignored: server='Vigorous Pro' channels=42 clients=20 (passes by hostname). flutter test: alpha_e2e_test + beta_e2e_test both green. Physical Moto G Stylus 5G (Android 14 arm64-v8a): APK rebuilt (48.9 MB). adb install + launch. Connect form left at default 'cn.teamspeak.app'. logcat shows: chanora_protocol: dns resolved input=cn.teamspeak.app resolved=175.178.125.23:9987 tsclientlib: starting connection to 175.178.125.23:9987 tsproto::resend: Connecting → Connected chanora_protocol: initial state snapshot received UI shows 'Connected to Vigorous Pro' / '42 channels • 20 online'. This is the first item in Category A (post-Beta polish bundle). Pause point: review before A.6 (full reconnect). --- Cargo.lock | 1 + crates/chanora_bridge/src/lib.rs | 13 ++ crates/chanora_protocol/Cargo.toml | 1 + crates/chanora_protocol/src/adapter.rs | 27 +++- crates/chanora_protocol/src/lib.rs | 24 +++ crates/chanora_protocol/src/resolver.rs | 201 ++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 crates/chanora_protocol/src/resolver.rs diff --git a/Cargo.lock b/Cargo.lock index 7a51ca3..200830b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,6 +373,7 @@ version = "0.0.1-pre" dependencies = [ "async-trait", "futures", + "once_cell", "serde", "thiserror 2.0.18", "tokio", diff --git a/crates/chanora_bridge/src/lib.rs b/crates/chanora_bridge/src/lib.rs index bd61757..f031290 100644 --- a/crates/chanora_bridge/src/lib.rs +++ b/crates/chanora_bridge/src/lib.rs @@ -47,6 +47,15 @@ pub enum BridgeError { /// The caller submitted a malformed command DTO. #[error("invalid command: {0}")] InvalidCommand(String), + /// Hostname resolution failed. Distinct from `Connection` so the + /// UI can show a meaningful "Server not found" message. + #[error("dns: could not resolve '{host}': {reason}")] + DnsFailed { + /// The hostname (or `host:port`) the caller submitted. + host: String, + /// Reason from the platform resolver. + reason: String, + }, /// Connection layer failure (typed-mapped from CoreError). #[error("connection: {0}")] Connection(String), @@ -72,6 +81,10 @@ impl From for BridgeError { chanora_core::CoreError::AudioNotStarted => { BridgeError::InvalidCommand("audio not started".to_string()) } + chanora_core::CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed { + host, + reason, + }) => BridgeError::DnsFailed { host, reason }, chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")), chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")), other => BridgeError::Unmapped(format!("{other}")), diff --git a/crates/chanora_protocol/Cargo.toml b/crates/chanora_protocol/Cargo.toml index 7fffef7..114c4a5 100644 --- a/crates/chanora_protocol/Cargo.toml +++ b/crates/chanora_protocol/Cargo.toml @@ -13,6 +13,7 @@ publish.workspace = true thiserror.workspace = true tracing.workspace = true serde.workspace = true +once_cell = "1" # tsclientlib is git-only and not on crates.io. The "audio" feature # pulls in `audiopus` only — `sdl2` is a dev-dep used by upstream diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index fc5b957..8a68de4 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -165,7 +165,32 @@ async fn connection_task( voice_in_tx: mpsc::Sender, ready_tx: oneshot::Sender>, ) { - let mut builder = Connection::build(cfg.address.clone()).name(cfg.nickname.clone()); + // Resolve the hostname OURSELVES using the platform resolver. + // tsclientlib's built-in hickory-resolver reads /etc/resolv.conf, + // which does not exist on Android or iOS — by side-stepping it + // here we get hostname connects working on every platform. + let addrs = match crate::resolver::resolve(&cfg.address).await { + Ok(a) => a, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + }; + // Pick the first address (IPv4 preferred by the resolver's + // ordering). Future retry logic could fall back to subsequent + // addresses; one is enough for the Beta connect flow. + let resolved = addrs[0]; + info!( + target: "chanora_protocol", + input = %cfg.address, + resolved = %resolved, + "dns resolved" + ); + + // Pass the resolved SocketAddr directly to tsclientlib so it + // skips its own resolver entirely (tsclientlib accepts + // SocketAddr via the From for ServerAddress impl). + let mut builder = Connection::build(resolved).name(cfg.nickname.clone()); let identity = match cfg.identity.as_deref() { Some(s) => match Identity::new_from_str(s) { diff --git a/crates/chanora_protocol/src/lib.rs b/crates/chanora_protocol/src/lib.rs index f978333..52c49bd 100644 --- a/crates/chanora_protocol/src/lib.rs +++ b/crates/chanora_protocol/src/lib.rs @@ -20,12 +20,25 @@ //! //! Promoted from `poc/tsclientlib-connect-spike` on 2026-05-14 //! as part of the Alpha build. +//! +//! ## Hostname resolution (A.1) +//! +//! Upstream `tsclientlib` uses `hickory-resolver` which reads +//! `/etc/resolv.conf`. That file does not exist on Android or iOS, +//! and the Beta UI surfaced the resulting cryptic "connection task +//! exited before signalling ready" errors. We side-step the issue by +//! resolving hostnames ourselves with `tokio::net::lookup_host`, +//! which uses platform `getaddrinfo` (works correctly on every +//! supported platform), and feeding the resulting `SocketAddr` +//! directly to `tsclientlib::Connection::build`. A small in-process +//! positive-result cache keeps reconnects fast. #![forbid(unsafe_code)] #![warn(missing_docs)] mod adapter; mod dto; +mod resolver; pub use adapter::{ConnectConfig, InboundVoice, ProtocolClient}; pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot}; @@ -51,6 +64,17 @@ pub enum ProtocolError { #[error("invalid protocol configuration: {0}")] Invalid(String), + /// Hostname resolution failed. Distinct from [`Self::Connect`] + /// so the UI can show a meaningful "Server not found" message + /// instead of a generic connection error. + #[error("dns lookup failed for '{host}': {reason}")] + DnsFailed { + /// The hostname (or `host:port`) the caller submitted. + host: String, + /// Reason from the platform resolver. + reason: String, + }, + /// Failed to dial / handshake with the server. #[error("connect failed: {0}")] Connect(String), diff --git a/crates/chanora_protocol/src/resolver.rs b/crates/chanora_protocol/src/resolver.rs new file mode 100644 index 0000000..352484a --- /dev/null +++ b/crates/chanora_protocol/src/resolver.rs @@ -0,0 +1,201 @@ +//! Platform DNS resolver for the protocol layer. +//! +//! `tsclientlib`'s internal resolver uses `hickory-resolver`, which +//! reads `/etc/resolv.conf`. That file does not exist on Android or +//! iOS, so hostname connects fail there with a cryptic "connection +//! task exited before signalling ready" error. +//! +//! This module bypasses that by resolving hostnames ourselves via +//! `tokio::net::lookup_host`, which uses the platform's +//! `getaddrinfo`. That works on every platform Chanora targets. +//! +//! A tiny positive-result cache (5 minute TTL) keeps reconnects +//! cheap. Negative results are not cached: DNS failures are usually +//! transient and the user typically retries within seconds. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use once_cell::sync::Lazy; +use tracing::{debug, warn}; + +use crate::ProtocolError; + +/// Default TeamSpeak server UDP port. +pub(crate) const DEFAULT_TS_PORT: u16 = 9987; + +/// Positive-result cache TTL. +const CACHE_TTL: Duration = Duration::from_secs(5 * 60); + +struct CacheEntry { + addrs: Vec, + at: Instant, +} + +static CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +/// Resolve `host_input` to a list of socket addresses, preferring +/// IPv4 over IPv6 so the upstream's first connect attempt is the +/// most likely to succeed on networks with brittle IPv6. +/// +/// `host_input` may be: +/// * `hostname` (port defaults to 9987) +/// * `hostname:port` +/// * `ip` (port 9987) +/// * `ip:port` +/// * `[v6]:port` +/// +/// Returns at least one [`SocketAddr`] on success. Returns +/// [`ProtocolError::DnsFailed`] on lookup failure or empty result. +pub(crate) async fn resolve(host_input: &str) -> Result, ProtocolError> { + let input = host_input.trim(); + if input.is_empty() { + return Err(ProtocolError::Invalid("address is empty".to_string())); + } + + // Try literal SocketAddr first — short-circuit the cache for + // numeric inputs since they cannot change. + if let Ok(addr) = input.parse::() { + return Ok(vec![addr]); + } + + // Normalise to host:port for lookup_host. Accept bare hostname + // (default port 9987) and bare IP literals. + let lookup_key = if input.contains(':') { + // Either host:port, [v6]:port, or a v6 literal without port. + // The latter is a misuse — require brackets. + input.to_string() + } else { + format!("{input}:{DEFAULT_TS_PORT}") + }; + + // Cache lookup. + if let Some(hit) = cache_get(&lookup_key) { + debug!(target: "chanora_protocol", host = %lookup_key, "dns cache hit"); + return Ok(hit); + } + + // Cold lookup via the platform resolver. Collect into an owned + // `Vec` inside an inner scope so the iterator's borrow on + // `lookup_input` is fully released before we move the key into + // either the cache or the error branch. + let resolved: Vec = { + let lookup_input: String = lookup_key.clone(); + let collected = match tokio::net::lookup_host(lookup_input.as_str()).await { + Ok(iter) => iter.collect::>(), + Err(e) => { + warn!(target: "chanora_protocol", host = %lookup_key, error = %e, "dns lookup failed"); + return Err(ProtocolError::DnsFailed { + host: lookup_key, + reason: format!("{e}"), + }); + } + }; + collected + }; + + if resolved.is_empty() { + return Err(ProtocolError::DnsFailed { + host: lookup_key, + reason: "no addresses returned by platform resolver".to_string(), + }); + } + + // Prefer IPv4 first — keeps connect latency low on dual-stack + // networks where IPv6 routing is sometimes broken. We don't + // discard IPv6; it just sorts after. + let mut ordered = resolved.clone(); + ordered.sort_by_key(|a| match a { + SocketAddr::V4(_) => 0u8, + SocketAddr::V6(_) => 1u8, + }); + + cache_put(lookup_key.clone(), ordered.clone()); + + debug!( + target: "chanora_protocol", + host = %lookup_key, + count = ordered.len(), + first = %ordered[0], + "dns resolved" + ); + Ok(ordered) +} + +fn cache_get(key: &str) -> Option> { + let mut guard = CACHE.lock().ok()?; + let entry = guard.get(key)?; + if entry.at.elapsed() < CACHE_TTL { + Some(entry.addrs.clone()) + } else { + guard.remove(key); + None + } +} + +fn cache_put(key: String, addrs: Vec) { + if let Ok(mut guard) = CACHE.lock() { + guard.insert( + key, + CacheEntry { + addrs, + at: Instant::now(), + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn rejects_empty() { + let r = resolve("").await; + assert!(matches!(r, Err(ProtocolError::Invalid(_)))); + } + + #[tokio::test] + async fn literal_ipv4_short_circuits() { + let r = resolve("127.0.0.1:9987").await.unwrap(); + assert_eq!(r.len(), 1); + assert_eq!(r[0].port(), 9987); + assert!(r[0].is_ipv4()); + } + + #[tokio::test] + async fn literal_ipv4_default_port_path() { + // Bare IPv4 with no port → looked up via lookup_host (which + // works for literal IPs too) and default port applied. + let r = resolve("127.0.0.1").await.unwrap(); + assert_eq!(r[0].port(), DEFAULT_TS_PORT); + assert!(r[0].is_ipv4()); + } + + #[tokio::test] + #[ignore = "hits the network; run with --ignored"] + async fn resolves_known_hostname() { + let r = resolve("cn.teamspeak.app").await.expect("dns must succeed"); + assert!(!r.is_empty()); + // Port should default to 9987. + assert!(r.iter().any(|a| a.port() == DEFAULT_TS_PORT)); + // Should have at least one IPv4 (cn.teamspeak.app currently + // resolves to 175.178.125.23). + assert!(r.iter().any(|a| a.is_ipv4())); + } + + #[tokio::test] + async fn unresolvable_returns_dns_failed() { + let r = + resolve("nonexistent-server-for-chanora-tests.invalid").await; + match r { + Err(ProtocolError::DnsFailed { host, .. }) => { + assert!(host.contains("nonexistent-server-for-chanora-tests.invalid")); + } + other => panic!("expected DnsFailed, got {other:?}"), + } + } +}