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).
103 lines
3.6 KiB
Rust
103 lines
3.6 KiB
Rust
//! # `chanora_protocol`
|
|
//!
|
|
//! TeamSpeak-compatible protocol adapter. Isolates `tsclientlib`
|
|
//! behind a typed boundary so the rest of Chanora is decoupled from
|
|
//! the upstream library's types (SAD-067, SysDes-011, SysDes-029).
|
|
//!
|
|
//! ## What this crate exposes
|
|
//!
|
|
//! * [`ConnectConfig`] — typed connection parameters.
|
|
//! * [`ProtocolClient`] — async handle owning the connection task.
|
|
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`] — opaque
|
|
//! DTOs containing only `String`s and primitives.
|
|
//! * [`ProtocolError`] — typed error catalogue.
|
|
//!
|
|
//! ## What this crate does NOT expose
|
|
//!
|
|
//! * `tsclientlib::*` types.
|
|
//! * `tsproto::*` types.
|
|
//! * Any audio-related types — those live in `chanora_audio`.
|
|
//!
|
|
//! 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};
|
|
|
|
// Re-export the upstream voice types so chanora_audio can build outbound
|
|
// voice packets without taking a direct dependency on tsclientlib /
|
|
// tsproto_packets. Per SAD-067 this is the *one* deliberate
|
|
// re-export: the audio path is performance-sensitive and a parallel
|
|
// type hierarchy would force copies for every 20 ms frame.
|
|
pub use tsproto_packets::packets::{
|
|
AudioData, CodecType, Direction, InAudioBuf, OutAudio, OutPacket,
|
|
};
|
|
|
|
use thiserror::Error;
|
|
|
|
/// Errors surfaced by the protocol adapter. None of these expose
|
|
/// `tsclientlib`-specific types; raw upstream errors are mapped here
|
|
/// to typed arms.
|
|
#[derive(Debug, Error)]
|
|
pub enum ProtocolError {
|
|
/// Configuration is invalid before any I/O is attempted (bad
|
|
/// hostname, missing identity, etc.).
|
|
#[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),
|
|
|
|
/// The connection ended before becoming ready.
|
|
#[error("disconnected before ready: {0}")]
|
|
DisconnectedEarly(String),
|
|
|
|
/// Connection lost after becoming ready.
|
|
#[error("connection lost: {0}")]
|
|
Lost(String),
|
|
|
|
/// Identity parsing failed.
|
|
#[error("identity error: {0}")]
|
|
Identity(String),
|
|
|
|
/// Operation timed out.
|
|
#[error("protocol timeout")]
|
|
Timeout,
|
|
|
|
/// A backend error escaped the mapping. Production callers
|
|
/// should never see this; if they do, it is a mapping bug here.
|
|
#[error("protocol backend: {0}")]
|
|
Backend(String),
|
|
}
|