use futures_util::{SinkExt, StreamExt};
use sendspin::protocol::manager::{ConnectionManager, ManagerConfig};
use sendspin::protocol::messages::{ConnectionReason, GoodbyeReason, Message, ServerHello};
use sendspin::ProtocolClientBuilder;
use std::time::Duration;
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
struct Peer {
messages: tokio::sync::mpsc::UnboundedReceiver<String>,
_writer: tokio::sync::mpsc::UnboundedSender<WsMessage>,
_tasks: Vec<tokio::task::JoinHandle<()>>,
}
async fn connect_peer(url: &str, server_id: &str, reason: ConnectionReason) -> Peer {
let (ws, _) = connect_async(url).await.expect("WS connect failed");
let (mut write, mut read) = ws.split();
let hello_text = match read.next().await.expect("no hello").expect("ws error") {
WsMessage::Text(t) => t,
other => panic!("expected text client/hello, got {other:?}"),
};
let parsed: Message = serde_json::from_str(&hello_text).expect("client/hello must deserialize");
assert!(
matches!(parsed, Message::ClientHello(_)),
"first message should be client/hello"
);
let server_hello = serde_json::to_string(&Message::ServerHello(ServerHello {
server_id: server_id.to_string(),
name: format!("{server_id} name"),
version: 1,
active_roles: vec![],
connection_reason: reason,
}))
.unwrap();
write
.send(WsMessage::Text(server_hello.into()))
.await
.expect("send server/hello");
let (msg_tx, msg_rx) = tokio::sync::mpsc::unbounded_channel();
let reader = tokio::spawn(async move {
while let Some(Ok(msg)) = read.next().await {
let text = match msg {
WsMessage::Text(t) => t,
WsMessage::Close(_) => break,
_ => continue,
};
if msg_tx.send(text.to_string()).is_err() {
break;
}
}
});
let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<WsMessage>();
let writer = tokio::spawn(async move {
while let Some(frame) = out_rx.recv().await {
if write.send(frame).await.is_err() {
break;
}
}
});
Peer {
messages: msg_rx,
_writer: out_tx,
_tasks: vec![reader, writer],
}
}
async fn manager(config: Option<ManagerConfig>) -> (String, ConnectionManager) {
let builder = ProtocolClientBuilder::builder()
.client_id("test-managed-client".to_string())
.name("Managed Client".to_string())
.build();
let listener = builder.listen("127.0.0.1:0").await.expect("listen failed");
let mgr = match config {
Some(c) => ConnectionManager::with_config(listener, c),
None => ConnectionManager::new(listener),
};
let addr = mgr.local_addr().expect("local_addr");
(format!("ws://{addr}"), mgr)
}
async fn expect_goodbye(peer: &mut Peer) -> GoodbyeReason {
loop {
let text = timeout(Duration::from_secs(2), peer.messages.recv())
.await
.expect("timed out waiting for client/goodbye")
.expect("peer channel closed without a goodbye");
if let Ok(Message::ClientGoodbye(g)) = serde_json::from_str::<Message>(&text) {
return g.reason;
}
}
}
async fn assert_no_goodbye(peer: &mut Peer, window: Duration) {
let deadline = tokio::time::Instant::now() + window;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match timeout(remaining, peer.messages.recv()).await {
Err(_) => return, Ok(None) => panic!("peer connection closed unexpectedly"),
Ok(Some(text)) => {
if matches!(
serde_json::from_str::<Message>(&text),
Ok(Message::ClientGoodbye(_))
) {
panic!("unexpected client/goodbye");
}
}
}
}
}
async fn expect_channels_close(conn: &mut sendspin::ManagedConnection) {
loop {
match timeout(Duration::from_secs(2), conn.messages.recv()).await {
Ok(None) => return,
Ok(Some(_)) => continue,
Err(_) => panic!("displaced connection's channels never closed"),
}
}
}
#[tokio::test]
async fn test_first_server_is_promoted() {
let (url, mut mgr) = manager(None).await;
let _peer = connect_peer(&url, "server-a", ConnectionReason::Discovery).await;
let conn = timeout(Duration::from_secs(5), mgr.next_connection())
.await
.expect("next_connection timed out")
.expect("manager stopped");
assert_eq!(conn.server_hello.server_id, "server-a");
assert_eq!(
conn.server_hello.connection_reason,
ConnectionReason::Discovery
);
assert!(conn.peer.ip().is_loopback());
}
#[tokio::test]
async fn test_playback_displaces_discovery_incumbent() {
let (url, mut mgr) = manager(None).await;
let mut peer_a = connect_peer(&url, "server-a", ConnectionReason::Discovery).await;
let mut conn_a = mgr.next_connection().await.expect("manager stopped");
assert_eq!(conn_a.server_hello.server_id, "server-a");
let _peer_b = connect_peer(&url, "server-b", ConnectionReason::Playback).await;
assert_eq!(
expect_goodbye(&mut peer_a).await,
GoodbyeReason::AnotherServer
);
expect_channels_close(&mut conn_a).await;
let conn_b = timeout(Duration::from_secs(5), mgr.next_connection())
.await
.expect("next_connection timed out")
.expect("manager stopped");
assert_eq!(conn_b.server_hello.server_id, "server-b");
}
#[tokio::test]
async fn test_discovery_never_displaces_playback() {
let (url, mut mgr) = manager(None).await;
let mut peer_a = connect_peer(&url, "server-a", ConnectionReason::Playback).await;
let conn_a = mgr.next_connection().await.expect("manager stopped");
assert_eq!(conn_a.server_hello.server_id, "server-a");
mgr.set_last_played(Some("server-b".to_string()));
let mut peer_b = connect_peer(&url, "server-b", ConnectionReason::Discovery).await;
assert_eq!(
expect_goodbye(&mut peer_b).await,
GoodbyeReason::AnotherServer
);
assert_no_goodbye(&mut peer_a, Duration::from_millis(300)).await;
}
#[tokio::test]
async fn test_last_played_breaks_discovery_tie() {
let (url, mut mgr) = manager(None).await;
mgr.set_last_played(Some("server-b".to_string()));
let mut peer_a = connect_peer(&url, "server-a", ConnectionReason::Discovery).await;
let conn_a = mgr.next_connection().await.expect("manager stopped");
assert_eq!(conn_a.server_hello.server_id, "server-a");
let _peer_b = connect_peer(&url, "server-b", ConnectionReason::Discovery).await;
assert_eq!(
expect_goodbye(&mut peer_a).await,
GoodbyeReason::AnotherServer
);
let conn_b = timeout(Duration::from_secs(5), mgr.next_connection())
.await
.expect("next_connection timed out")
.expect("manager stopped");
assert_eq!(conn_b.server_hello.server_id, "server-b");
}
#[tokio::test]
async fn test_discovery_tie_defaults_to_keep() {
let (url, mut mgr) = manager(None).await;
let mut peer_a = connect_peer(&url, "server-a", ConnectionReason::Discovery).await;
let conn_a = mgr.next_connection().await.expect("manager stopped");
assert_eq!(conn_a.server_hello.server_id, "server-a");
let mut peer_b = connect_peer(&url, "server-b", ConnectionReason::Discovery).await;
assert_eq!(
expect_goodbye(&mut peer_b).await,
GoodbyeReason::AnotherServer
);
assert_no_goodbye(&mut peer_a, Duration::from_millis(300)).await;
}
#[tokio::test]
async fn test_incumbent_death_frees_slot() {
let (url, mut mgr) = manager(None).await;
let peer_a = connect_peer(&url, "server-a", ConnectionReason::Discovery).await;
let mut conn_a = mgr.next_connection().await.expect("manager stopped");
assert_eq!(conn_a.server_hello.server_id, "server-a");
drop(peer_a);
expect_channels_close(&mut conn_a).await;
let mut peer_b = connect_peer(&url, "server-b", ConnectionReason::Discovery).await;
let conn_b = timeout(Duration::from_secs(5), mgr.next_connection())
.await
.expect("next_connection timed out")
.expect("manager stopped");
assert_eq!(conn_b.server_hello.server_id, "server-b");
assert_no_goodbye(&mut peer_b, Duration::from_millis(300)).await;
}
#[tokio::test]
async fn test_disconnect_sends_goodbye_and_keeps_listening() {
let (url, mut mgr) = manager(None).await;
mgr.disconnect(GoodbyeReason::UserRequest)
.await
.expect("no-op disconnect");
let mut peer_a = connect_peer(&url, "server-a", ConnectionReason::Playback).await;
let mut conn_a = mgr.next_connection().await.expect("manager stopped");
mgr.disconnect(GoodbyeReason::UserRequest)
.await
.expect("disconnect");
assert_eq!(
expect_goodbye(&mut peer_a).await,
GoodbyeReason::UserRequest
);
expect_channels_close(&mut conn_a).await;
let _peer_b = connect_peer(&url, "server-b", ConnectionReason::Discovery).await;
let conn_b = timeout(Duration::from_secs(5), mgr.next_connection())
.await
.expect("next_connection timed out")
.expect("manager stopped");
assert_eq!(conn_b.server_hello.server_id, "server-b");
}
#[tokio::test]
async fn test_playback_displaces_playback() {
let (url, mut mgr) = manager(None).await;
let mut peer_a = connect_peer(&url, "server-a", ConnectionReason::Playback).await;
let conn_a = mgr.next_connection().await.expect("manager stopped");
assert_eq!(conn_a.server_hello.server_id, "server-a");
let _peer_b = connect_peer(&url, "server-b", ConnectionReason::Playback).await;
assert_eq!(
expect_goodbye(&mut peer_a).await,
GoodbyeReason::AnotherServer
);
let conn_b = timeout(Duration::from_secs(5), mgr.next_connection())
.await
.expect("next_connection timed out")
.expect("manager stopped");
assert_eq!(conn_b.server_hello.server_id, "server-b");
}
#[tokio::test]
async fn test_drop_aborts_inflight_handshake_promptly() {
let (url, mgr) = manager(Some(ManagerConfig {
establish_timeout: Duration::from_secs(30),
max_concurrent_handshakes: 1,
..ManagerConfig::default()
}))
.await;
let addr = url.strip_prefix("ws://").unwrap().to_string();
let stalled = tokio::net::TcpStream::connect(&addr)
.await
.expect("raw TCP connect");
tokio::time::sleep(Duration::from_millis(100)).await;
drop(mgr);
let mut buf = [0u8; 1];
let read = timeout(Duration::from_secs(2), async {
use tokio::io::AsyncReadExt;
let mut stream = stalled;
stream.read(&mut buf).await
})
.await;
match read {
Ok(Ok(0)) => {} Ok(Err(_)) => {} Ok(Ok(n)) => panic!("unexpected {n} bytes from dropped manager"),
Err(_) => panic!("handshake task kept the connection alive after manager drop"),
}
}
#[tokio::test]
async fn test_stalled_handshake_is_reaped_and_frees_slot() {
let (url, mut mgr) = manager(Some(ManagerConfig {
establish_timeout: Duration::from_millis(300),
max_concurrent_handshakes: 1,
..ManagerConfig::default()
}))
.await;
let addr = url.strip_prefix("ws://").unwrap().to_string();
let stalled = tokio::net::TcpStream::connect(&addr)
.await
.expect("raw TCP connect");
tokio::time::sleep(Duration::from_millis(100)).await;
let _peer = connect_peer(&url, "server-a", ConnectionReason::Playback).await;
let conn = timeout(Duration::from_secs(5), mgr.next_connection())
.await
.expect("next_connection timed out — stalled peer was never reaped")
.expect("manager stopped");
assert_eq!(conn.server_hello.server_id, "server-a");
drop(stalled);
}