chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -15,6 +15,7 @@ chanora_state = { path = "../../crates/chanora_state" }
|
||||
chanora_audio = { path = "../../crates/chanora_audio" }
|
||||
chanora_storage = { path = "../../crates/chanora_storage" }
|
||||
chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
||||
chanora_server_prefetch = { path = "../../crates/chanora_server_prefetch" }
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
@@ -22,5 +23,6 @@ tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
[dev-dependencies]
|
||||
# Used by integration tests to inspect the bookmark DB row layout
|
||||
# without going through the public repository API.
|
||||
chanora_server_prefetch = { path = "../../crates/chanora_server_prefetch", features = ["test-support"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
|
||||
|
||||
@@ -46,6 +46,7 @@ use tokio::sync::{broadcast, oneshot, watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use chanora_server_prefetch::ServerPrefetcher;
|
||||
use chanora_state::channel_join::{
|
||||
self, AuthoritativeSource, ChannelId as JoinChannelId, ChannelJoinEvent, ChannelJoinState,
|
||||
ConnectionEpoch, JoinFailureKind,
|
||||
@@ -64,8 +65,8 @@ pub use chanora_diagnostics::{
|
||||
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
|
||||
};
|
||||
pub use chanora_protocol::{
|
||||
ChannelInfo, ChatMessage, ClientInfo, ConnectConfig, DisconnectReason, MessageTarget,
|
||||
ProtocolError, ServerActivity, ServerSnapshot,
|
||||
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
|
||||
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||
|
||||
@@ -431,6 +432,9 @@ pub struct ChanoraSession {
|
||||
/// extension). Lives alongside the identity file. Wired by
|
||||
/// [`Self::init_storage`].
|
||||
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
|
||||
/// Invisible server-address prefetch cache. Warmed by Flutter typing
|
||||
/// but validated by Rust before Connect can reuse it.
|
||||
server_prefetch: ServerPrefetcher,
|
||||
/// Single transmit-mode selector for the whole session
|
||||
/// lifetime (SAD-083). Re-wired to a fresh
|
||||
/// [`AudioTransmitGate`] every time the audio engine starts;
|
||||
@@ -483,6 +487,7 @@ impl ChanoraSession {
|
||||
network_tx,
|
||||
identity_store: Arc::new(Mutex::new(None)),
|
||||
bookmark_store: Arc::new(Mutex::new(None)),
|
||||
server_prefetch: ServerPrefetcher::new(),
|
||||
voice_selector: selector,
|
||||
release_tail,
|
||||
pending_binding: Arc::new(Mutex::new(None)),
|
||||
@@ -628,6 +633,38 @@ impl ChanoraSession {
|
||||
self.events_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Warm server-address resolution for a later user-initiated connect.
|
||||
pub async fn prefetch_server(&self, host: String) -> Result<(), CoreError> {
|
||||
let host = host.trim().to_lowercase();
|
||||
if let Err(err) = self.server_prefetch.prefetch(host.clone()).await {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
host = %host,
|
||||
error = %err,
|
||||
"server prefetch setup failed"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_prefetched_resolution(&self, cfg: &mut ConnectConfig) {
|
||||
cfg.resolved_address = None;
|
||||
let host = cfg.address.trim();
|
||||
if host.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(addr) = self.server_prefetch.fresh_match(host).await {
|
||||
cfg.resolved_address = Some(addr);
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_connect_configs(&self, cfg: ConnectConfig) -> (ConnectConfig, ConnectConfig) {
|
||||
let stored_cfg = connect_config_without_prefetched_resolution(&cfg);
|
||||
let mut dial_cfg = stored_cfg.clone();
|
||||
self.apply_prefetched_resolution(&mut dial_cfg).await;
|
||||
(stored_cfg, dial_cfg)
|
||||
}
|
||||
|
||||
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
|
||||
/// if a connection is already active (DEC-006). Audio is not
|
||||
/// started automatically; call [`Self::start_audio`] after.
|
||||
@@ -679,7 +716,9 @@ impl ChanoraSession {
|
||||
}
|
||||
}
|
||||
|
||||
let client = chanora_protocol::ProtocolClient::connect(cfg.clone()).await?;
|
||||
let (cfg, dial_cfg) = self.prepare_connect_configs(cfg).await;
|
||||
|
||||
let client = chanora_protocol::ProtocolClient::connect(dial_cfg).await?;
|
||||
let snap = client.snapshot().await?;
|
||||
let epoch = self.allocate_connection_epoch().await;
|
||||
let mut join_state = ChannelJoinState::new(epoch);
|
||||
@@ -896,6 +935,13 @@ impl ChanoraSession {
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// Fetch richer profile and live connection details for one online client.
|
||||
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
Ok(state.protocol.client_profile(client_id).await?)
|
||||
}
|
||||
|
||||
/// True if a connection is currently active.
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.inner.lock().await.is_some()
|
||||
@@ -928,6 +974,12 @@ impl ChanoraSession {
|
||||
rec.drain()
|
||||
}
|
||||
|
||||
/// Snapshot recorded protocol events without clearing them.
|
||||
pub async fn protocol_events_snapshot(&self) -> Vec<String> {
|
||||
let rec = self.event_recorder.lock().await;
|
||||
rec.snapshot()
|
||||
}
|
||||
|
||||
/// Record a platform lifecycle event (SRS-138).
|
||||
pub async fn record_lifecycle_event(&self, state: &str) {
|
||||
let mut rec = self.event_recorder.lock().await;
|
||||
@@ -1234,11 +1286,11 @@ impl ChanoraSession {
|
||||
audio.set_output_muted(muted);
|
||||
}
|
||||
}
|
||||
// Muting the speaker is treated as a local deafen: it must
|
||||
// also clamp microphone TX so users cannot keep talking while
|
||||
// unable to hear replies. This does not send server-side
|
||||
// input-mute; it only drives the local transmit gate.
|
||||
let mic_disabled = state.local_input_muted || state.local_output_muted;
|
||||
// Input mute must also stop local outbound voice production
|
||||
// so the transmit selector stays in sync with the server-side
|
||||
// mic mute. Output mute is playback-only and must not affect
|
||||
// the mic gate.
|
||||
let mic_disabled = state.local_input_muted;
|
||||
self.voice_selector.set_hard_mute(mic_disabled);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1323,6 +1375,19 @@ impl ChanoraSession {
|
||||
Ok(audio.audio_processing_stats())
|
||||
}
|
||||
|
||||
/// Best-effort audio-processing diagnostics for user exports.
|
||||
///
|
||||
/// This intentionally never waits for the connection mutex. During
|
||||
/// server join the connect path owns that mutex while awaiting network
|
||||
/// I/O; diagnostics must stay responsive and omit live audio stats
|
||||
/// rather than blocking the UI thread.
|
||||
pub fn audio_processing_stats_if_ready(&self) -> Option<AudioProcessingStats> {
|
||||
let guard = self.inner.try_lock().ok()?;
|
||||
let state = guard.as_ref()?;
|
||||
let audio = state.audio.as_ref()?;
|
||||
Some(audio.audio_processing_stats())
|
||||
}
|
||||
|
||||
/// Configure the preferred Silero ONNX VAD model path.
|
||||
///
|
||||
/// This does not require an active connection. Running iOS audio
|
||||
@@ -2367,6 +2432,12 @@ fn map_join_error_code(code: channel_join::JoinErrorCode) -> VoiceJoinErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_config_without_prefetched_resolution(cfg: &ConnectConfig) -> ConnectConfig {
|
||||
let mut cfg = cfg.clone();
|
||||
cfg.resolved_address = None;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2376,6 +2447,109 @@ mod tests {
|
||||
let _ = ChanoraSession::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_stats_nonblocking_under_lock() {
|
||||
let s = ChanoraSession::new();
|
||||
let _guard = s.inner.try_lock().unwrap();
|
||||
|
||||
assert!(s.audio_processing_stats_if_ready().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_stats_absent_when_disconnected() {
|
||||
let s = ChanoraSession::new();
|
||||
|
||||
assert!(s.audio_processing_stats_if_ready().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_uses_fresh_prefetch() {
|
||||
let session = ChanoraSession::new();
|
||||
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
||||
|
||||
let generation = session.server_prefetch.begin_for_test("example.com").await;
|
||||
session
|
||||
.server_prefetch
|
||||
.store_success_for_test(generation, "example.com", addr, std::time::Instant::now())
|
||||
.await;
|
||||
|
||||
let mut cfg = ConnectConfig::default();
|
||||
cfg.address = " example.com ".to_string();
|
||||
session.apply_prefetched_resolution(&mut cfg).await;
|
||||
|
||||
assert_eq!(cfg.resolved_address, Some(addr));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_clears_untrusted_resolved_address() {
|
||||
let session = ChanoraSession::new();
|
||||
let mut cfg = ConnectConfig::default();
|
||||
cfg.address = "example.com".to_string();
|
||||
cfg.resolved_address = Some("127.0.0.1:9987".parse().unwrap());
|
||||
|
||||
session.apply_prefetched_resolution(&mut cfg).await;
|
||||
|
||||
assert_eq!(cfg.resolved_address, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefetch_swallows_setup_errors() {
|
||||
let session = ChanoraSession::new();
|
||||
session
|
||||
.server_prefetch
|
||||
.fail_next_prefetch_setup_for_test("resolver unavailable")
|
||||
.await;
|
||||
|
||||
let result = session
|
||||
.prefetch_server(" Example.COM ".to_string())
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_config_clears_resolved_address() {
|
||||
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
||||
let mut cfg = ConnectConfig::default();
|
||||
cfg.address = "example.com".to_string();
|
||||
cfg.nickname = "tester".to_string();
|
||||
cfg.password = Some("secret".to_string());
|
||||
cfg.identity = Some("identity".to_string());
|
||||
cfg.ready_timeout = std::time::Duration::from_secs(42);
|
||||
cfg.resolved_address = Some(addr);
|
||||
|
||||
let cleaned = connect_config_without_prefetched_resolution(&cfg);
|
||||
|
||||
assert_eq!(cleaned.address, cfg.address);
|
||||
assert_eq!(cleaned.nickname, cfg.nickname);
|
||||
assert_eq!(cleaned.password, cfg.password);
|
||||
assert_eq!(cleaned.identity, cfg.identity);
|
||||
assert_eq!(cleaned.ready_timeout, cfg.ready_timeout);
|
||||
assert_eq!(cleaned.resolved_address, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_configs_sanitizes_stored_keeps_dial() {
|
||||
let session = ChanoraSession::new();
|
||||
let cached: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
||||
let untrusted: std::net::SocketAddr = "127.0.0.2:9987".parse().unwrap();
|
||||
|
||||
let generation = session.server_prefetch.begin_for_test("example.com").await;
|
||||
session
|
||||
.server_prefetch
|
||||
.store_success_for_test(generation, "example.com", cached, std::time::Instant::now())
|
||||
.await;
|
||||
|
||||
let mut cfg = ConnectConfig::default();
|
||||
cfg.address = "example.com".to_string();
|
||||
cfg.resolved_address = Some(untrusted);
|
||||
|
||||
let (stored_cfg, dial_cfg) = session.prepare_connect_configs(cfg).await;
|
||||
|
||||
assert_eq!(stored_cfg.resolved_address, None);
|
||||
assert_eq!(dial_cfg.resolved_address, Some(cached));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_when_not_connected_is_noop() {
|
||||
let s = ChanoraSession::new();
|
||||
@@ -2411,7 +2585,6 @@ mod tests {
|
||||
],
|
||||
clients: vec![ClientInfo {
|
||||
id: chanora_protocol::ClientId(10),
|
||||
uid: "uid".into(),
|
||||
channel: chanora_protocol::ChannelId(1),
|
||||
name: "u".into(),
|
||||
input_muted: false,
|
||||
|
||||
@@ -18,6 +18,7 @@ async fn alpha_smoke() {
|
||||
password: None,
|
||||
identity: None,
|
||||
ready_timeout: Duration::from_secs(15),
|
||||
resolved_address: None,
|
||||
};
|
||||
let snap = s.connect(cfg).await.expect("connect");
|
||||
println!(
|
||||
|
||||
Reference in New Issue
Block a user