use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use axum::extract::ws::WebSocket;
use tokio::sync::mpsc;
pub const POOL_TARGET: usize = 4;
#[derive(Debug)]
pub struct Device {
pub id: String,
pub label: Option<String>,
attached_at: Instant,
last_seen: Mutex<Instant>,
pool_tx: mpsc::Sender<WebSocket>,
pool_rx: tokio::sync::Mutex<mpsc::Receiver<WebSocket>>,
refill_tx: mpsc::Sender<()>,
}
impl Device {
pub fn is_stale(&self, timeout: Duration) -> bool {
self.last_seen
.lock()
.map(|seen| seen.elapsed() > timeout)
.unwrap_or(false)
}
pub fn touch(&self) {
if let Ok(mut seen) = self.last_seen.lock() {
*seen = Instant::now();
}
}
pub async fn offer(&self, conn: WebSocket) -> Option<WebSocket> {
match self.pool_tx.try_send(conn) {
Ok(()) => None,
Err(mpsc::error::TrySendError::Full(conn)) => Some(conn),
Err(mpsc::error::TrySendError::Closed(conn)) => Some(conn),
}
}
pub async fn take(&self, timeout: Duration) -> Option<WebSocket> {
let _ = self.refill_tx.try_send(());
let mut rx = self.pool_rx.lock().await;
tokio::time::timeout(timeout, rx.recv())
.await
.ok()
.flatten()
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DeviceSummary {
pub id: String,
pub label: Option<String>,
pub attached_secs: u64,
pub last_seen_secs: u64,
}
#[derive(Debug)]
pub struct DeviceHandles {
pub device: Arc<Device>,
pub refill_rx: mpsc::Receiver<()>,
}
#[derive(Debug, Clone, Default)]
pub struct DeviceRegistry {
devices: Arc<RwLock<HashMap<String, Arc<Device>>>>,
}
impl DeviceRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn attach(&self, id: impl Into<String>, label: Option<String>) -> DeviceHandles {
let id = id.into();
let (pool_tx, pool_rx) = mpsc::channel(POOL_TARGET);
let (refill_tx, refill_rx) = mpsc::channel(POOL_TARGET);
let device = Arc::new(Device {
id: id.clone(),
label,
attached_at: Instant::now(),
last_seen: Mutex::new(Instant::now()),
pool_tx,
pool_rx: tokio::sync::Mutex::new(pool_rx),
refill_tx,
});
if let Ok(mut devices) = self.devices.write() {
devices.insert(id, Arc::clone(&device));
}
DeviceHandles { device, refill_rx }
}
pub fn detach(&self, id: &str) -> bool {
self.devices
.write()
.map(|mut devices| devices.remove(id).is_some())
.unwrap_or(false)
}
pub fn get(&self, id: &str) -> Option<Arc<Device>> {
Some(Arc::clone(self.devices.read().ok()?.get(id)?))
}
pub fn touch(&self, id: &str) -> bool {
match self.get(id) {
Some(device) => {
device.touch();
true
}
None => false,
}
}
pub fn list(&self) -> Vec<DeviceSummary> {
let mut devices: Vec<DeviceSummary> = match self.devices.read() {
Ok(devices) => devices
.values()
.map(|device| DeviceSummary {
id: device.id.clone(),
label: device.label.clone(),
attached_secs: device.attached_at.elapsed().as_secs(),
last_seen_secs: device
.last_seen
.lock()
.map(|seen| seen.elapsed().as_secs())
.unwrap_or_default(),
})
.collect(),
Err(_) => Vec::new(),
};
devices.sort_by_key(|device| device.attached_secs);
devices
}
pub fn count(&self) -> usize {
self.devices.read().map(|d| d.len()).unwrap_or(0)
}
pub fn evict_stale(&self, timeout: Duration) -> Vec<String> {
let mut evicted = Vec::new();
if let Ok(mut devices) = self.devices.write() {
devices.retain(|id, device| {
let keep = !device.is_stale(timeout);
if !keep {
evicted.push(id.clone());
}
keep
});
}
evicted
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn attach_and_get() {
let registry = DeviceRegistry::new();
let handles = registry.attach("dev-1", Some("build-box".into()));
assert_eq!(handles.device.id, "dev-1");
assert_eq!(registry.count(), 1);
assert_eq!(
registry.get("dev-1").unwrap().label.as_deref(),
Some("build-box")
);
assert!(registry.get("nope").is_none());
}
#[tokio::test]
async fn detach_removes_the_device() {
let registry = DeviceRegistry::new();
registry.attach("dev-1", None);
assert!(registry.detach("dev-1"));
assert!(!registry.detach("dev-1"));
assert_eq!(registry.count(), 0);
}
#[tokio::test]
async fn touch_only_succeeds_for_attached_devices() {
let registry = DeviceRegistry::new();
registry.attach("dev-1", None);
assert!(registry.touch("dev-1"));
assert!(!registry.touch("dev-2"));
}
#[tokio::test]
async fn stale_devices_are_evicted() {
let registry = DeviceRegistry::new();
registry.attach("fresh", None);
let evicted = registry.evict_stale(Duration::ZERO);
assert_eq!(evicted, vec!["fresh".to_string()]);
assert_eq!(registry.count(), 0);
}
#[tokio::test]
async fn a_heartbeat_keeps_a_device_attached() {
let registry = DeviceRegistry::new();
registry.attach("dev-1", None);
registry.touch("dev-1");
assert!(registry.evict_stale(Duration::from_secs(60)).is_empty());
assert_eq!(registry.count(), 1);
}
#[tokio::test]
async fn reattaching_replaces_the_previous_entry() {
let registry = DeviceRegistry::new();
registry.attach("dev-1", Some("old".into()));
registry.attach("dev-1", Some("new".into()));
assert_eq!(registry.count(), 1);
assert_eq!(registry.get("dev-1").unwrap().label.as_deref(), Some("new"));
}
#[tokio::test]
async fn taking_from_an_empty_pool_times_out_and_asks_for_a_refill() {
let registry = DeviceRegistry::new();
let mut handles = registry.attach("dev-1", None);
let taken = handles.device.take(Duration::from_millis(50)).await;
assert!(taken.is_none(), "an empty pool must not hand out a socket");
assert!(
handles.refill_rx.try_recv().is_ok(),
"the device should have been asked to open a connection"
);
}
#[tokio::test]
async fn listing_reports_attached_devices() {
let registry = DeviceRegistry::new();
registry.attach("dev-1", Some("build-box".into()));
registry.attach("dev-2", None);
let listed = registry.list();
assert_eq!(listed.len(), 2);
let first = listed.iter().find(|d| d.id == "dev-1").unwrap();
assert_eq!(first.label.as_deref(), Some("build-box"));
assert!(first.attached_secs < 5);
}
#[tokio::test]
async fn listing_an_empty_registry_is_empty() {
assert!(DeviceRegistry::new().list().is_empty());
}
#[tokio::test]
async fn registry_is_shared_across_clones() {
let registry = DeviceRegistry::new();
let clone = registry.clone();
clone.attach("dev-1", None);
assert_eq!(registry.count(), 1);
}
}