feat(core): FileTransferService with coalescing, throttling, negative cache
- New file_transfer module with FileTransferService struct - Semaphore(2) throttles concurrent downloads - In-flight HashMap coalesces duplicate avatar requests - 5-min negative cache short-circuits ServerRejected misses - ChanoraSession delegates get_avatar through the service - connect/disconnect update shared protocol handle - clear_cache/cache_size delegate to service - 2 new unit tests (cached hit, negative cache)
This commit is contained in:
@@ -0,0 +1,256 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use chanora_cache::{BlobCache, BlobCacheError, PREFIX_AVATAR};
|
||||||
|
use chanora_protocol::{ProtocolClient, ProtocolError};
|
||||||
|
use tokio::sync::{Mutex, Semaphore, oneshot};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_DOWNLOADS: usize = 2;
|
||||||
|
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
type InFlightWaiters = Vec<oneshot::Sender<Result<Option<Vec<u8>>, FileTransferError>>>;
|
||||||
|
|
||||||
|
/// Errors raised while resolving protocol-owned file assets.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FileTransferError {
|
||||||
|
/// No live protocol client is available for a download.
|
||||||
|
#[error("not connected")]
|
||||||
|
NotConnected,
|
||||||
|
/// The protocol layer failed while downloading the asset.
|
||||||
|
#[error("protocol error: {0}")]
|
||||||
|
Protocol(#[from] ProtocolError),
|
||||||
|
/// The blob cache failed while reading or writing the asset.
|
||||||
|
#[error("cache error: {0}")]
|
||||||
|
Cache(#[from] BlobCacheError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for FileTransferError {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::NotConnected => Self::NotConnected,
|
||||||
|
Self::Protocol(error) => Self::Protocol(clone_protocol_error(error)),
|
||||||
|
Self::Cache(error) => Self::Cache(clone_blob_cache_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileTransferService {
|
||||||
|
cache: BlobCache,
|
||||||
|
protocol: Arc<Mutex<Option<ProtocolClient>>>,
|
||||||
|
semaphore: Arc<Semaphore>,
|
||||||
|
in_flight: Arc<Mutex<HashMap<String, InFlightWaiters>>>,
|
||||||
|
negative_cache: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTransferService {
|
||||||
|
pub fn new(cache: BlobCache, protocol: Arc<Mutex<Option<ProtocolClient>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
cache,
|
||||||
|
protocol,
|
||||||
|
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)),
|
||||||
|
in_flight: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
negative_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_protocol(&self, client: Option<ProtocolClient>) {
|
||||||
|
*self.protocol.lock().await = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
client_uid: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(avatar_hash).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rx = {
|
||||||
|
let mut in_flight = self.in_flight.lock().await;
|
||||||
|
if let Some(waiters) = in_flight.get_mut(avatar_hash) {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
waiters.push(tx);
|
||||||
|
Some(rx)
|
||||||
|
} else {
|
||||||
|
in_flight.insert(avatar_hash.to_string(), Vec::new());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(rx) = rx {
|
||||||
|
return rx.await.unwrap_or_else(|_| {
|
||||||
|
Err(FileTransferError::Protocol(ProtocolError::Lost(
|
||||||
|
"coalesced avatar download waiter dropped".to_string(),
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = self
|
||||||
|
.semaphore
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.expect("file transfer semaphore should stay open");
|
||||||
|
|
||||||
|
let result = self.do_download_avatar(avatar_hash, client_uid).await;
|
||||||
|
self.finish_in_flight(avatar_hash, &result).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_cache(&self) -> Result<(), FileTransferError> {
|
||||||
|
self.cache.clear().await?;
|
||||||
|
self.negative_cache.lock().await.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cache_size(&self) -> Result<u64, FileTransferError> {
|
||||||
|
Ok(self.cache.total_size().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_download_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
client_uid: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(avatar_hash).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
|
||||||
|
match client.download_avatar(client_uid).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
self.cache.put(PREFIX_AVATAR, avatar_hash, &bytes).await?;
|
||||||
|
self.negative_cache.lock().await.remove(avatar_hash);
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
Err(ProtocolError::ServerRejected { .. }) => {
|
||||||
|
self.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(avatar_hash.to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(error) => Err(FileTransferError::Protocol(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_in_flight(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
result: &Result<Option<Vec<u8>>, FileTransferError>,
|
||||||
|
) {
|
||||||
|
let waiters = self.in_flight.lock().await.remove(avatar_hash).unwrap_or_default();
|
||||||
|
for waiter in waiters {
|
||||||
|
if waiter.send(result.clone()).is_err() {
|
||||||
|
warn!(target: "chanora_core", avatar_hash, "avatar download waiter dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_negative_cache_hit(&self, avatar_hash: &str) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut negative_cache = self.negative_cache.lock().await;
|
||||||
|
match negative_cache.get(avatar_hash).copied() {
|
||||||
|
Some(expires_at) if expires_at > now => true,
|
||||||
|
Some(_) => {
|
||||||
|
negative_cache.remove(avatar_hash);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_protocol_error(error: &ProtocolError) -> ProtocolError {
|
||||||
|
match error {
|
||||||
|
ProtocolError::Invalid(message) => ProtocolError::Invalid(message.clone()),
|
||||||
|
ProtocolError::DnsFailed { host, reason } => ProtocolError::DnsFailed {
|
||||||
|
host: host.clone(),
|
||||||
|
reason: reason.clone(),
|
||||||
|
},
|
||||||
|
ProtocolError::Connect(message) => ProtocolError::Connect(message.clone()),
|
||||||
|
ProtocolError::DisconnectedEarly(message) => {
|
||||||
|
ProtocolError::DisconnectedEarly(message.clone())
|
||||||
|
}
|
||||||
|
ProtocolError::Lost(message) => ProtocolError::Lost(message.clone()),
|
||||||
|
ProtocolError::Identity(message) => ProtocolError::Identity(message.clone()),
|
||||||
|
ProtocolError::Timeout => ProtocolError::Timeout,
|
||||||
|
ProtocolError::ServerRejected { code, message } => ProtocolError::ServerRejected {
|
||||||
|
code: *code,
|
||||||
|
message: message.clone(),
|
||||||
|
},
|
||||||
|
ProtocolError::Backend(message) => ProtocolError::Backend(message.clone()),
|
||||||
|
ProtocolError::FileTransfer(message) => ProtocolError::FileTransfer(message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_blob_cache_error(error: &BlobCacheError) -> BlobCacheError {
|
||||||
|
match error {
|
||||||
|
BlobCacheError::Io(message) => BlobCacheError::Io(message.clone()),
|
||||||
|
BlobCacheError::InvalidKey(message) => BlobCacheError::InvalidKey(message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_cache_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
path.push(format!("chanora-core-file-transfer-{name}-{nanos}"));
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_cached_avatar_without_connection() {
|
||||||
|
let cache_dir = test_cache_dir("cache-hit");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
cache.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
|
let avatar = service
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(avatar, Some(b"avatar".to_vec()));
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn negative_cache_short_circuits_not_connected() {
|
||||||
|
let cache_dir = test_cache_dir("negative-cache");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
service
|
||||||
|
.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6".to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
|
||||||
|
let avatar = service
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(avatar, None);
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
-70
@@ -53,6 +53,7 @@ use chanora_state::channel_join::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod events;
|
mod events;
|
||||||
|
mod file_transfer;
|
||||||
mod network_diagnostics;
|
mod network_diagnostics;
|
||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ pub use events::{
|
|||||||
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
||||||
VoiceJoinSyncState,
|
VoiceJoinSyncState,
|
||||||
};
|
};
|
||||||
|
pub use file_transfer::FileTransferError;
|
||||||
use network_diagnostics::NetworkDiagnostics;
|
use network_diagnostics::NetworkDiagnostics;
|
||||||
|
|
||||||
/// Errors that can arise during top-level orchestration.
|
/// Errors that can arise during top-level orchestration.
|
||||||
@@ -95,6 +97,9 @@ pub enum CoreError {
|
|||||||
/// Blob-cache failure.
|
/// Blob-cache failure.
|
||||||
#[error("cache: {0}")]
|
#[error("cache: {0}")]
|
||||||
Cache(#[from] chanora_cache::BlobCacheError),
|
Cache(#[from] chanora_cache::BlobCacheError),
|
||||||
|
/// File-transfer failure.
|
||||||
|
#[error("file transfer: {0}")]
|
||||||
|
FileTransfer(#[from] FileTransferError),
|
||||||
/// Diagnostics error.
|
/// Diagnostics error.
|
||||||
#[error("diagnostics: {0}")]
|
#[error("diagnostics: {0}")]
|
||||||
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
|
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
|
||||||
@@ -133,7 +138,6 @@ struct SupervisorInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ConnectedState {
|
struct ConnectedState {
|
||||||
protocol: chanora_protocol::ProtocolClient,
|
|
||||||
audio: Option<chanora_audio::AudioEngine>,
|
audio: Option<chanora_audio::AudioEngine>,
|
||||||
/// Active PTT controller (SDD-088). Owns the platform input
|
/// Active PTT controller (SDD-088). Owns the platform input
|
||||||
/// backend, the active binding, and the capability watch
|
/// backend, the active binding, and the capability watch
|
||||||
@@ -200,7 +204,8 @@ pub struct ChanoraSession {
|
|||||||
/// extension). Lives alongside the identity file. Wired by
|
/// extension). Lives alongside the identity file. Wired by
|
||||||
/// [`Self::init_storage`].
|
/// [`Self::init_storage`].
|
||||||
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
|
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
|
||||||
blob_cache: Arc<Mutex<Option<chanora_cache::BlobCache>>>,
|
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
||||||
|
file_transfer: Arc<Mutex<Option<Arc<file_transfer::FileTransferService>>>>,
|
||||||
/// Invisible server-address prefetch cache. Warmed by Flutter typing
|
/// Invisible server-address prefetch cache. Warmed by Flutter typing
|
||||||
/// but validated by Rust before Connect can reuse it.
|
/// but validated by Rust before Connect can reuse it.
|
||||||
server_prefetch: ServerPrefetcher,
|
server_prefetch: ServerPrefetcher,
|
||||||
@@ -256,7 +261,8 @@ impl ChanoraSession {
|
|||||||
network_tx,
|
network_tx,
|
||||||
identity_store: Arc::new(Mutex::new(None)),
|
identity_store: Arc::new(Mutex::new(None)),
|
||||||
bookmark_store: Arc::new(Mutex::new(None)),
|
bookmark_store: Arc::new(Mutex::new(None)),
|
||||||
blob_cache: Arc::new(Mutex::new(None)),
|
protocol: Arc::new(Mutex::new(None)),
|
||||||
|
file_transfer: Arc::new(Mutex::new(None)),
|
||||||
server_prefetch: ServerPrefetcher::new(),
|
server_prefetch: ServerPrefetcher::new(),
|
||||||
voice_selector: selector,
|
voice_selector: selector,
|
||||||
release_tail,
|
release_tail,
|
||||||
@@ -278,6 +284,19 @@ impl ChanoraSession {
|
|||||||
ConnectionEpoch(epoch)
|
ConnectionEpoch(epoch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn store_protocol(&self, client: Option<chanora_protocol::ProtocolClient>) {
|
||||||
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
|
if let Some(service) = service {
|
||||||
|
service.set_protocol(client).await;
|
||||||
|
} else {
|
||||||
|
*self.protocol.lock().await = client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn take_protocol(&self) -> Option<chanora_protocol::ProtocolClient> {
|
||||||
|
self.protocol.lock().await.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Wire a directory-backed identity store. Called by the bridge
|
/// Wire a directory-backed identity store. Called by the bridge
|
||||||
/// during `bridge_init` once Flutter has resolved the platform
|
/// during `bridge_init` once Flutter has resolved the platform
|
||||||
/// app-private storage directory. Subsequent [`Self::connect`]
|
/// app-private storage directory. Subsequent [`Self::connect`]
|
||||||
@@ -346,8 +365,12 @@ impl ChanoraSession {
|
|||||||
pub async fn init_cache(&self, dir: &str) -> Result<(), CoreError> {
|
pub async fn init_cache(&self, dir: &str) -> Result<(), CoreError> {
|
||||||
let cache = chanora_cache::BlobCache::new(dir, 100 * 1024 * 1024)?;
|
let cache = chanora_cache::BlobCache::new(dir, 100 * 1024 * 1024)?;
|
||||||
cache.evict().await?;
|
cache.evict().await?;
|
||||||
let mut guard = self.blob_cache.lock().await;
|
let service = Arc::new(file_transfer::FileTransferService::new(
|
||||||
*guard = Some(cache);
|
cache,
|
||||||
|
self.protocol.clone(),
|
||||||
|
));
|
||||||
|
let mut guard = self.file_transfer.lock().await;
|
||||||
|
*guard = Some(service);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,47 +380,31 @@ impl ChanoraSession {
|
|||||||
avatar_hash: &str,
|
avatar_hash: &str,
|
||||||
client_uid: &str,
|
client_uid: &str,
|
||||||
) -> Result<Option<Vec<u8>>, CoreError> {
|
) -> Result<Option<Vec<u8>>, CoreError> {
|
||||||
{
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
let guard = self.blob_cache.lock().await;
|
if let Some(service) = service {
|
||||||
if let Some(ref cache) = *guard {
|
return Ok(service.get_avatar(avatar_hash, client_uid).await?);
|
||||||
if let Some(bytes) = cache.get(chanora_cache::PREFIX_AVATAR, avatar_hash).await? {
|
|
||||||
return Ok(Some(bytes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let guard = self.inner.lock().await;
|
let protocol = self.protocol.lock().await;
|
||||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
let bytes = state.protocol.download_avatar(client_uid).await?;
|
Ok(Some(client.download_avatar(client_uid).await?))
|
||||||
|
|
||||||
{
|
|
||||||
let cache_guard = self.blob_cache.lock().await;
|
|
||||||
if let Some(ref cache) = *cache_guard {
|
|
||||||
let _ = cache
|
|
||||||
.put(chanora_cache::PREFIX_AVATAR, avatar_hash, &bytes)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(bytes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Purge cached protocol-owned assets.
|
/// Purge cached protocol-owned assets.
|
||||||
pub async fn clear_cache(&self) -> Result<(), CoreError> {
|
pub async fn clear_cache(&self) -> Result<(), CoreError> {
|
||||||
let guard = self.blob_cache.lock().await;
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
if let Some(ref cache) = *guard {
|
if let Some(service) = service {
|
||||||
cache.clear().await?;
|
service.clear_cache().await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report the configured blob-cache size.
|
/// Report the configured blob-cache size.
|
||||||
pub async fn cache_size(&self) -> Result<u64, CoreError> {
|
pub async fn cache_size(&self) -> Result<u64, CoreError> {
|
||||||
let guard = self.blob_cache.lock().await;
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
if let Some(ref cache) = *guard {
|
match service {
|
||||||
Ok(cache.total_size().await?)
|
Some(service) => Ok(service.cache_size().await?),
|
||||||
} else {
|
None => Ok(0),
|
||||||
Ok(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +585,7 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
|
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
|
||||||
state_arc: self.inner.clone(),
|
state_arc: self.inner.clone(),
|
||||||
|
protocol: self.protocol.clone(),
|
||||||
events_tx: self.events_tx.clone(),
|
events_tx: self.events_tx.clone(),
|
||||||
initial_cfg: cfg.clone(),
|
initial_cfg: cfg.clone(),
|
||||||
initial_lost_rx: lost_rx,
|
initial_lost_rx: lost_rx,
|
||||||
@@ -632,9 +640,9 @@ impl ChanoraSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawn_event_forwarders(&client, &self.events_tx);
|
spawn_event_forwarders(&client, &self.events_tx);
|
||||||
|
self.store_protocol(Some(client)).await;
|
||||||
|
|
||||||
*guard = Some(ConnectedState {
|
*guard = Some(ConnectedState {
|
||||||
protocol: client,
|
|
||||||
audio: None,
|
audio: None,
|
||||||
ptt_controller: None,
|
ptt_controller: None,
|
||||||
cancel_tx: Some(cancel_tx),
|
cancel_tx: Some(cancel_tx),
|
||||||
@@ -695,9 +703,13 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
/// Return a fresh snapshot of the current server state.
|
/// Return a fresh snapshot of the current server state.
|
||||||
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
|
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
|
||||||
|
let snap = {
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
client.snapshot().await?
|
||||||
|
};
|
||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||||
let snap = state.protocol.snapshot().await?;
|
|
||||||
let current_channel = self
|
let current_channel = self
|
||||||
.find_own_in(&snap)
|
.find_own_in(&snap)
|
||||||
.await
|
.await
|
||||||
@@ -725,9 +737,9 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
/// Fetch richer profile and live connection details for one online client.
|
/// Fetch richer profile and live connection details for one online client.
|
||||||
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
|
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
|
||||||
let guard = self.inner.lock().await;
|
let protocol = self.protocol.lock().await;
|
||||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
Ok(state.protocol.client_profile(client_id).await?)
|
Ok(client.client_profile(client_id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if a connection is currently active.
|
/// True if a connection is currently active.
|
||||||
@@ -744,9 +756,9 @@ impl ChanoraSession {
|
|||||||
if !should_dispatch_text_message(&message, &target) {
|
if !should_dispatch_text_message(&message, &target) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let guard = self.inner.lock().await;
|
let protocol = self.protocol.lock().await;
|
||||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
state.protocol.send_text_message(message, target).await?;
|
client.send_text_message(message, target).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,11 +817,16 @@ impl ChanoraSession {
|
|||||||
// session permanently unable to restart audio without a
|
// session permanently unable to restart audio without a
|
||||||
// reconnect (the user saw "voice_in already taken" on the
|
// reconnect (the user saw "voice_in already taken" on the
|
||||||
// second channel switch).
|
// second channel switch).
|
||||||
let voice_out = state.protocol.voice_out();
|
let (voice_out, voice_in) = {
|
||||||
let voice_in = state
|
let protocol = self.protocol.lock().await;
|
||||||
.protocol
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
.take_voice_in()
|
(
|
||||||
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
client.voice_out(),
|
||||||
|
client
|
||||||
|
.take_voice_in()
|
||||||
|
.ok_or(CoreError::Invariant("voice_in already taken"))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
let gate = AudioTransmitGate::new(cfg.ptt_initial);
|
let gate = AudioTransmitGate::new(cfg.ptt_initial);
|
||||||
cfg.voice_activity_selector = Some(self.voice_selector.clone());
|
cfg.voice_activity_selector = Some(self.voice_selector.clone());
|
||||||
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
|
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
|
||||||
@@ -1040,10 +1057,11 @@ impl ChanoraSession {
|
|||||||
let password_to_send = requested_password
|
let password_to_send = requested_password
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||||
state
|
{
|
||||||
.protocol
|
let protocol = self.protocol.lock().await;
|
||||||
.move_to_channel(channel_id, password_to_send)
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
.await?;
|
client.move_to_channel(channel_id, password_to_send).await?;
|
||||||
|
}
|
||||||
if let Some(pw) = requested_password {
|
if let Some(pw) = requested_password {
|
||||||
state.channel_passwords.insert(channel_id, pw);
|
state.channel_passwords.insert(channel_id, pw);
|
||||||
}
|
}
|
||||||
@@ -1068,7 +1086,11 @@ impl ChanoraSession {
|
|||||||
if let Some(muted) = output {
|
if let Some(muted) = output {
|
||||||
state.local_output_muted = muted;
|
state.local_output_muted = muted;
|
||||||
}
|
}
|
||||||
state.protocol.set_muted(input, output).await?;
|
{
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
client.set_muted(input, output).await?;
|
||||||
|
}
|
||||||
if let Some(muted) = output {
|
if let Some(muted) = output {
|
||||||
if let Some(audio) = state.audio.as_ref() {
|
if let Some(audio) = state.audio.as_ref() {
|
||||||
audio.set_output_muted(muted);
|
audio.set_output_muted(muted);
|
||||||
@@ -1391,11 +1413,12 @@ impl ChanoraSession {
|
|||||||
let password_to_send = requested_password
|
let password_to_send = requested_password
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||||
if let Err(e) = state
|
let move_result = {
|
||||||
.protocol
|
let protocol = self.protocol.lock().await;
|
||||||
.queue_move_to_channel(channel_id, password_to_send)
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
.await
|
client.queue_move_to_channel(channel_id, password_to_send).await
|
||||||
{
|
};
|
||||||
|
if let Err(e) = move_result {
|
||||||
// TS3 error 0x0302 = `channel_already_in`: we're already
|
// TS3 error 0x0302 = `channel_already_in`: we're already
|
||||||
// in the target channel, so this is a no-op success.
|
// in the target channel, so this is a no-op success.
|
||||||
// Rolling `in_channel` back to false would break PTT
|
// Rolling `in_channel` back to false would break PTT
|
||||||
@@ -1657,7 +1680,9 @@ impl ChanoraSession {
|
|||||||
audio.stop();
|
audio.stop();
|
||||||
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
||||||
}
|
}
|
||||||
state.protocol.disconnect().await;
|
if let Some(protocol) = self.take_protocol().await {
|
||||||
|
protocol.disconnect().await;
|
||||||
|
}
|
||||||
// Wait for the supervisor to wind down so we don't race
|
// Wait for the supervisor to wind down so we don't race
|
||||||
// a redial against the explicit disconnect.
|
// a redial against the explicit disconnect.
|
||||||
if let Some(handle) = state.supervisor.take() {
|
if let Some(handle) = state.supervisor.take() {
|
||||||
@@ -1719,6 +1744,7 @@ async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration:
|
|||||||
|
|
||||||
struct SupervisorContext {
|
struct SupervisorContext {
|
||||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||||
|
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
||||||
events_tx: broadcast::Sender<SessionEvent>,
|
events_tx: broadcast::Sender<SessionEvent>,
|
||||||
initial_cfg: ConnectConfig,
|
initial_cfg: ConnectConfig,
|
||||||
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
|
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
|
||||||
@@ -1853,6 +1879,7 @@ fn spawn_event_forwarders(
|
|||||||
async fn supervisor_loop(ctx: SupervisorContext) {
|
async fn supervisor_loop(ctx: SupervisorContext) {
|
||||||
let SupervisorContext {
|
let SupervisorContext {
|
||||||
state_arc,
|
state_arc,
|
||||||
|
protocol,
|
||||||
events_tx,
|
events_tx,
|
||||||
initial_cfg,
|
initial_cfg,
|
||||||
initial_lost_rx,
|
initial_lost_rx,
|
||||||
@@ -2122,20 +2149,21 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
|
|
||||||
// Reattach into the session state.
|
// Reattach into the session state.
|
||||||
let restart_audio = {
|
let restart_audio = {
|
||||||
|
let guard = state_arc.lock().await;
|
||||||
|
if guard.is_none() {
|
||||||
|
// Session was disposed mid-reconnect.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
let old = protocol.lock().await.replace(new_client);
|
||||||
|
drop(old);
|
||||||
let mut guard = state_arc.lock().await;
|
let mut guard = state_arc.lock().await;
|
||||||
let state = match guard.as_mut() {
|
let state = match guard.as_mut() {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => {
|
None => {
|
||||||
// Session was disposed mid-reconnect.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Replace the dead protocol client with the new one.
|
|
||||||
// The old client's background task either already
|
|
||||||
// exited (loss notifier fired) or will exit when
|
|
||||||
// its request channel drops (watchdog path).
|
|
||||||
let old = std::mem::replace(&mut state.protocol, new_client);
|
|
||||||
drop(old);
|
|
||||||
|
|
||||||
let _ = channel_join::reduce(
|
let _ = channel_join::reduce(
|
||||||
&mut state.join_state,
|
&mut state.join_state,
|
||||||
@@ -2165,9 +2193,9 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
let guard = state_arc.lock().await;
|
let protocol = protocol.lock().await;
|
||||||
if let Some(state) = guard.as_ref() {
|
if let Some(client) = protocol.as_ref() {
|
||||||
spawn_event_forwarders(&state.protocol, &events_tx);
|
spawn_event_forwarders(client, &events_tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2179,8 +2207,15 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
};
|
};
|
||||||
let mut guard = state_arc.lock().await;
|
let mut guard = state_arc.lock().await;
|
||||||
if let Some(state) = guard.as_mut() {
|
if let Some(state) = guard.as_mut() {
|
||||||
let voice_out = state.protocol.voice_out();
|
let (voice_out, voice_in) = {
|
||||||
if let Some(voice_in) = state.protocol.take_voice_in() {
|
let protocol = protocol.lock().await;
|
||||||
|
let client = match protocol.as_ref() {
|
||||||
|
Some(client) => client,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
(client.voice_out(), client.take_voice_in())
|
||||||
|
};
|
||||||
|
if let Some(voice_in) = voice_in {
|
||||||
let gate = chanora_audio::AudioTransmitGate::new(
|
let gate = chanora_audio::AudioTransmitGate::new(
|
||||||
audio_cfg.ptt_initial,
|
audio_cfg.ptt_initial,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user