use crate::error::Error;
use crate::protocol::client::{
ArtworkChunk, AudioChunk, Connection, ConnectionGuard, Controller, VisualizerChunk, WsSender,
};
use crate::protocol::listener::ProtocolListener;
use crate::protocol::messages::{
ConnectionReason, GoodbyeReason, Message, PlayerState, ServerHello,
};
use crate::sync::ClockSync;
use parking_lot::Mutex;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{oneshot, Semaphore};
use tokio::task::JoinHandle;
pub fn should_switch(
current: &ServerHello,
candidate: &ServerHello,
last_played: Option<&str>,
) -> bool {
match (&candidate.connection_reason, ¤t.connection_reason) {
(ConnectionReason::Playback, _) => true,
(ConnectionReason::Discovery, ConnectionReason::Playback) => false,
(ConnectionReason::Discovery, ConnectionReason::Discovery) => {
matches!(last_played, Some(lp) if candidate.server_id == lp)
}
}
}
pub const DEFAULT_ESTABLISH_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_CONCURRENT_HANDSHAKES: usize = 2;
pub const DEFAULT_GOODBYE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct ManagerConfig {
pub establish_timeout: Duration,
pub max_concurrent_handshakes: usize,
pub goodbye_timeout: Duration,
}
impl Default for ManagerConfig {
fn default() -> Self {
Self {
establish_timeout: DEFAULT_ESTABLISH_TIMEOUT,
max_concurrent_handshakes: DEFAULT_MAX_CONCURRENT_HANDSHAKES,
goodbye_timeout: DEFAULT_GOODBYE_TIMEOUT,
}
}
}
pub struct ManagedConnection {
pub messages: UnboundedReceiver<Message>,
pub audio: UnboundedReceiver<AudioChunk>,
pub artwork: UnboundedReceiver<ArtworkChunk>,
pub visualizer: UnboundedReceiver<VisualizerChunk>,
pub clock_sync: Arc<Mutex<ClockSync>>,
pub sender: WsSender,
pub controller: Option<Controller>,
pub server_hello: ServerHello,
pub peer: SocketAddr,
}
impl ManagedConnection {
pub async fn enter_external_source(&self) -> Result<(), Error> {
self.sender.enter_external_source().await
}
pub async fn exit_external_source(&self, player: Option<PlayerState>) -> Result<(), Error> {
self.sender.exit_external_source(player).await
}
}
enum Command {
SetLastPlayed(Option<String>),
Disconnect(GoodbyeReason, oneshot::Sender<Result<(), Error>>),
}
struct Incumbent {
guard: ConnectionGuard,
server_hello: ServerHello,
peer: SocketAddr,
}
pub struct ConnectionManager {
conn_rx: mpsc::UnboundedReceiver<ManagedConnection>,
cmd_tx: mpsc::UnboundedSender<Command>,
accept_task: JoinHandle<()>,
driver_task: JoinHandle<()>,
local_addr: Option<SocketAddr>,
}
impl std::fmt::Debug for ConnectionManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConnectionManager")
.field("local_addr", &self.local_addr)
.finish()
}
}
impl ConnectionManager {
pub fn new(listener: ProtocolListener) -> Self {
Self::with_config(listener, ManagerConfig::default())
}
pub fn with_config(listener: ProtocolListener, mut config: ManagerConfig) -> Self {
config.max_concurrent_handshakes = config.max_concurrent_handshakes.max(1);
let local_addr = listener.local_addr().ok();
let (established_tx, established_rx) =
mpsc::channel::<(Connection, SocketAddr)>(config.max_concurrent_handshakes);
let (conn_tx, conn_rx) = mpsc::unbounded_channel();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let goodbye_timeout = config.goodbye_timeout;
let accept_task = tokio::spawn(accept_loop(Arc::new(listener), established_tx, config));
let driver_task = tokio::spawn(driver(established_rx, conn_tx, cmd_rx, goodbye_timeout));
Self {
conn_rx,
cmd_tx,
accept_task,
driver_task,
local_addr,
}
}
pub async fn next_connection(&mut self) -> Option<ManagedConnection> {
self.conn_rx.recv().await
}
pub fn set_last_played(&self, server_id: Option<String>) {
let _ = self.cmd_tx.send(Command::SetLastPlayed(server_id));
}
pub async fn disconnect(&self, reason: GoodbyeReason) -> Result<(), Error> {
let (ack_tx, ack_rx) = oneshot::channel();
self.cmd_tx
.send(Command::Disconnect(reason, ack_tx))
.map_err(|_| Error::Connection("connection manager stopped".to_string()))?;
ack_rx
.await
.map_err(|_| Error::Connection("connection manager stopped".to_string()))?
}
pub fn local_addr(&self) -> Option<SocketAddr> {
self.local_addr
}
}
impl Drop for ConnectionManager {
fn drop(&mut self) {
self.accept_task.abort();
self.driver_task.abort();
}
}
async fn accept_loop(
listener: Arc<ProtocolListener>,
established_tx: mpsc::Sender<(Connection, SocketAddr)>,
config: ManagerConfig,
) {
let slots = Arc::new(Semaphore::new(config.max_concurrent_handshakes));
let mut handshakes = tokio::task::JoinSet::new();
loop {
while handshakes.try_join_next().is_some() {}
let permit = Arc::clone(&slots)
.acquire_owned()
.await
.expect("handshake semaphore is never closed");
let (tcp, peer) = match listener.accept_tcp().await {
Ok(accepted) => accepted,
Err(e) => {
log::warn!("ConnectionManager accept failed: {e}");
drop(permit);
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let listener = Arc::clone(&listener);
let established_tx = established_tx.clone();
let deadline = config.establish_timeout;
handshakes.spawn(async move {
let _permit = permit;
match tokio::time::timeout(deadline, listener.handshake_and_drive(tcp)).await {
Ok(Ok(client)) => {
let _ = established_tx.send((client.split(), peer)).await;
}
Ok(Err(e)) => log::warn!("Inbound handshake from {peer} failed: {e}"),
Err(_) => log::warn!(
"Inbound connection from {peer} did not establish within {deadline:?}; dropping"
),
}
});
}
}
async fn driver(
mut established_rx: mpsc::Receiver<(Connection, SocketAddr)>,
conn_tx: mpsc::UnboundedSender<ManagedConnection>,
mut cmd_rx: mpsc::UnboundedReceiver<Command>,
goodbye_timeout: Duration,
) {
let mut last_played: Option<String> = None;
let mut current: Option<Incumbent> = None;
let mut goodbyes = tokio::task::JoinSet::new();
loop {
while goodbyes.try_join_next().is_some() {}
let incumbent_closed = async {
match current.as_mut() {
Some(inc) => inc.guard.closed().await,
None => std::future::pending().await,
}
};
tokio::select! {
established = established_rx.recv() => {
let Some((conn, peer)) = established else {
break;
};
arbitrate(
&mut current,
conn,
peer,
last_played.as_deref(),
&conn_tx,
&mut goodbyes,
goodbye_timeout,
);
}
_ = incumbent_closed => {
let inc = current.take().expect("closed() only fires with an incumbent");
log::info!(
"Server {} ({}) disconnected; awaiting next server",
inc.server_hello.server_id,
inc.peer
);
}
cmd = cmd_rx.recv() => {
match cmd {
None => break, Some(Command::SetLastPlayed(id)) => last_played = id,
Some(Command::Disconnect(reason, ack)) => {
match current.take() {
Some(inc) if inc.guard.is_closed() => {
let _ = ack.send(Ok(()));
}
Some(inc) => {
goodbyes.spawn(async move {
let _ = ack.send(
flush_goodbye(inc.guard, reason, goodbye_timeout).await,
);
});
}
None => {
let _ = ack.send(Ok(()));
}
}
}
}
}
}
}
}
async fn flush_goodbye(
guard: ConnectionGuard,
reason: GoodbyeReason,
deadline: Duration,
) -> Result<(), Error> {
match tokio::time::timeout(deadline, guard.disconnect(reason.clone())).await {
Ok(result) => result,
Err(_) => {
log::warn!("client/goodbye ({reason:?}) flush timed out; connection aborted");
Err(Error::Connection(
"client/goodbye flush timed out; connection aborted".to_string(),
))
}
}
}
fn arbitrate(
current: &mut Option<Incumbent>,
conn: Connection,
peer: SocketAddr,
last_played: Option<&str>,
conn_tx: &mpsc::UnboundedSender<ManagedConnection>,
goodbyes: &mut tokio::task::JoinSet<()>,
goodbye_timeout: Duration,
) {
if current.as_ref().is_some_and(|inc| inc.guard.is_closed()) {
let dead = current.take().expect("checked Some above");
log::info!(
"Server {} ({}) already disconnected; arbitration proceeds without it",
dead.server_hello.server_id,
dead.peer
);
}
if let Some(inc) = current.as_ref() {
if !should_switch(&inc.server_hello, &conn.server_hello, last_played) {
log::info!(
"Keeping server {} — rejecting {} ({peer}) with goodbye(another_server)",
inc.server_hello.server_id,
conn.server_hello.server_id,
);
goodbyes.spawn(async move {
let _ =
flush_goodbye(conn.guard, GoodbyeReason::AnotherServer, goodbye_timeout).await;
});
return;
}
let displaced = current.take().expect("checked Some above");
log::info!(
"Switching {} -> {} — goodbye(another_server) to displaced server",
displaced.server_hello.server_id,
conn.server_hello.server_id,
);
goodbyes.spawn(async move {
let _ = flush_goodbye(
displaced.guard,
GoodbyeReason::AnotherServer,
goodbye_timeout,
)
.await;
});
} else {
log::info!(
"Server {} ({peer}) connected (reason: {:?})",
conn.server_hello.server_id,
conn.server_hello.connection_reason,
);
}
let Connection {
messages,
audio,
artwork,
visualizer,
clock_sync,
sender,
controller,
server_hello,
guard,
} = conn;
*current = Some(Incumbent {
guard,
server_hello: server_hello.clone(),
peer,
});
let _ = conn_tx.send(ManagedConnection {
messages,
audio,
artwork,
visualizer,
clock_sync,
sender,
controller,
server_hello,
peer,
});
}
#[cfg(test)]
mod tests {
use super::*;
fn hello(server_id: &str, reason: ConnectionReason) -> ServerHello {
ServerHello {
server_id: server_id.to_string(),
name: format!("{server_id} name"),
version: 1,
active_roles: vec![],
connection_reason: reason,
}
}
#[test]
fn playback_candidate_always_wins() {
let current = hello("a", ConnectionReason::Playback);
let candidate = hello("b", ConnectionReason::Playback);
assert!(should_switch(¤t, &candidate, None));
let current = hello("a", ConnectionReason::Discovery);
assert!(should_switch(¤t, &candidate, None));
assert!(should_switch(¤t, &candidate, Some("a")));
}
#[test]
fn discovery_never_displaces_playback() {
let current = hello("a", ConnectionReason::Playback);
let candidate = hello("b", ConnectionReason::Discovery);
assert!(!should_switch(¤t, &candidate, None));
assert!(!should_switch(¤t, &candidate, Some("b")));
}
#[test]
fn both_discovery_prefers_last_played() {
let current = hello("a", ConnectionReason::Discovery);
let candidate = hello("b", ConnectionReason::Discovery);
assert!(should_switch(¤t, &candidate, Some("b")));
assert!(!should_switch(¤t, &candidate, Some("a")));
}
#[test]
fn both_discovery_defaults_to_keep() {
let current = hello("a", ConnectionReason::Discovery);
let candidate = hello("b", ConnectionReason::Discovery);
assert!(!should_switch(¤t, &candidate, None));
assert!(!should_switch(¤t, &candidate, Some("c")));
}
}