feat(protocol): A.1 — fix hostname resolution on Android and iOS
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<CoreError::Protocol(DnsFailed)>
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).
This commit is contained in:
Generated
+1
@@ -373,6 +373,7 @@ version = "0.0.1-pre"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
||||
@@ -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<chanora_core::CoreError> 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}")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,7 +165,32 @@ async fn connection_task(
|
||||
voice_in_tx: mpsc::Sender<InboundVoice>,
|
||||
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
||||
) {
|
||||
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<SocketAddr> 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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<SocketAddr>,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
static CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> =
|
||||
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<Vec<SocketAddr>, 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::<SocketAddr>() {
|
||||
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<SocketAddr> = {
|
||||
let lookup_input: String = lookup_key.clone();
|
||||
let collected = match tokio::net::lookup_host(lookup_input.as_str()).await {
|
||||
Ok(iter) => iter.collect::<Vec<SocketAddr>>(),
|
||||
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<Vec<SocketAddr>> {
|
||||
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<SocketAddr>) {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user