use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{RwLock, mpsc};
use tracing::{debug, warn};
use super::messages::WsEvent;
pub type ClientSender = mpsc::UnboundedSender<WsEvent>;
pub type ClientId = u64;
#[derive(Debug)]
pub struct WsManager {
subs: RwLock<HashMap<String, HashMap<ClientId, ClientSender>>>,
next_id: std::sync::atomic::AtomicU64,
}
impl WsManager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
subs: RwLock::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(1),
})
}
pub async fn subscribe(&self, game_id: &str) -> (ClientId, mpsc::UnboundedReceiver<WsEvent>) {
let id = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (tx, rx) = mpsc::unbounded_channel();
let mut subs = self.subs.write().await;
subs.entry(game_id.to_string()).or_default().insert(id, tx);
debug!(game_id, client_id = id, "WS client subscribed");
(id, rx)
}
pub async fn unsubscribe(&self, game_id: &str, client_id: ClientId) {
let mut subs = self.subs.write().await;
if let Some(clients) = subs.get_mut(game_id) {
clients.remove(&client_id);
if clients.is_empty() {
subs.remove(game_id);
}
}
debug!(game_id, client_id, "WS client unsubscribed");
}
pub async fn broadcast(&self, game_id: &str, event: WsEvent) {
let subs = self.subs.read().await;
if let Some(clients) = subs.get(game_id) {
let mut stale: Vec<ClientId> = Vec::new();
for (&cid, tx) in clients {
if tx.send(event.clone()).is_err() {
stale.push(cid);
}
}
drop(subs);
if !stale.is_empty() {
let mut subs = self.subs.write().await;
if let Some(clients) = subs.get_mut(game_id) {
for cid in &stale {
clients.remove(cid);
warn!(game_id, client_id = cid, "removed stale WS client");
}
if clients.is_empty() {
subs.remove(game_id);
}
}
}
}
}
pub async fn subscriber_count(&self, game_id: &str) -> usize {
let subs = self.subs.read().await;
subs.get(game_id).map_or(0, |c| c.len())
}
pub async fn total_connections(&self) -> usize {
let subs = self.subs.read().await;
subs.values().map(|c| c.len()).sum()
}
pub async fn active_games(&self) -> Vec<String> {
let subs = self.subs.read().await;
subs.keys().cloned().collect()
}
}
impl Default for WsManager {
fn default() -> Self {
Self {
subs: RwLock::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(1),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn subscribe_returns_unique_ids() {
let mgr = WsManager::new();
let (id1, _rx1) = mgr.subscribe("g1").await;
let (id2, _rx2) = mgr.subscribe("g1").await;
assert_ne!(id1, id2);
}
#[tokio::test]
async fn subscriber_count_tracks_correctly() {
let mgr = WsManager::new();
assert_eq!(mgr.subscriber_count("g1").await, 0);
let (id1, _rx1) = mgr.subscribe("g1").await;
assert_eq!(mgr.subscriber_count("g1").await, 1);
let (_id2, _rx2) = mgr.subscribe("g1").await;
assert_eq!(mgr.subscriber_count("g1").await, 2);
mgr.unsubscribe("g1", id1).await;
assert_eq!(mgr.subscriber_count("g1").await, 1);
}
#[tokio::test]
async fn unsubscribe_removes_empty_game() {
let mgr = WsManager::new();
let (id1, _rx1) = mgr.subscribe("g1").await;
mgr.unsubscribe("g1", id1).await;
assert_eq!(mgr.subscriber_count("g1").await, 0);
assert!(mgr.active_games().await.is_empty());
}
#[tokio::test]
async fn broadcast_delivers_to_all_subscribers() {
let mgr = WsManager::new();
let (_id1, mut rx1) = mgr.subscribe("g1").await;
let (_id2, mut rx2) = mgr.subscribe("g1").await;
let evt = WsEvent::game_state("g1", "startfen", "active", "white", 0, false);
mgr.broadcast("g1", evt).await;
let msg1 = rx1.recv().await.unwrap();
let msg2 = rx2.recv().await.unwrap();
assert_eq!(msg1.to_json(), msg2.to_json());
}
#[tokio::test]
async fn broadcast_does_not_cross_games() {
let mgr = WsManager::new();
let (_id1, mut rx1) = mgr.subscribe("g1").await;
let (_id2, mut rx2) = mgr.subscribe("g2").await;
let evt = WsEvent::game_state("g1", "fen", "active", "white", 0, false);
mgr.broadcast("g1", evt).await;
assert!(rx1.recv().await.is_some());
assert!(rx2.try_recv().is_err());
}
#[tokio::test]
async fn broadcast_removes_stale_clients() {
let mgr = WsManager::new();
let (_id1, rx1) = mgr.subscribe("g1").await;
let (_id2, _rx2) = mgr.subscribe("g1").await;
drop(rx1);
let evt = WsEvent::pong();
mgr.broadcast("g1", evt).await;
assert_eq!(mgr.subscriber_count("g1").await, 1);
}
#[tokio::test]
async fn total_connections_across_games() {
let mgr = WsManager::new();
let (_id1, _rx1) = mgr.subscribe("g1").await;
let (_id2, _rx2) = mgr.subscribe("g1").await;
let (_id3, _rx3) = mgr.subscribe("g2").await;
assert_eq!(mgr.total_connections().await, 3);
}
#[tokio::test]
async fn active_games_lists_games() {
let mgr = WsManager::new();
let (_id1, _rx1) = mgr.subscribe("g1").await;
let (_id2, _rx2) = mgr.subscribe("g2").await;
let games = mgr.active_games().await;
assert_eq!(games.len(), 2);
assert!(games.contains(&"g1".to_string()));
assert!(games.contains(&"g2".to_string()));
}
#[tokio::test]
async fn broadcast_to_nonexistent_game_is_noop() {
let mgr = WsManager::new();
mgr.broadcast("nonexistent", WsEvent::pong()).await;
}
#[tokio::test]
async fn unsubscribe_nonexistent_is_noop() {
let mgr = WsManager::new();
mgr.unsubscribe("g1", 999).await;
}
}