1use std::collections::HashMap;
4use std::sync::{Arc, Mutex, RwLock};
5use std::time::{Duration, Instant};
6
7use axum::extract::ws::WebSocket;
8use tokio::sync::mpsc;
9
10pub const POOL_TARGET: usize = 4;
15
16#[derive(Debug)]
22pub struct Device {
23 pub id: String,
25 pub label: Option<String>,
27 attached_at: Instant,
28 last_seen: Mutex<Instant>,
29 exchanges: Mutex<Exchanges>,
30 pool_tx: mpsc::Sender<WebSocket>,
31 pool_rx: tokio::sync::Mutex<mpsc::Receiver<WebSocket>>,
32 refill_tx: mpsc::Sender<()>,
33}
34
35#[derive(Debug, Default, Clone, Copy)]
48struct Exchanges {
49 count: u64,
50 total_ms: u64,
51 last_ms: u64,
52 slowest_ms: u64,
53}
54
55impl Device {
56 pub fn record_exchange(&self, elapsed: Duration) {
58 let ms = elapsed.as_millis().min(u64::MAX as u128) as u64;
59 if let Ok(mut exchanges) = self.exchanges.lock() {
60 exchanges.count = exchanges.count.saturating_add(1);
61 exchanges.total_ms = exchanges.total_ms.saturating_add(ms);
62 exchanges.last_ms = ms;
63 exchanges.slowest_ms = exchanges.slowest_ms.max(ms);
64 }
65 }
66
67 pub fn is_stale(&self, timeout: Duration) -> bool {
69 self.last_seen
70 .lock()
71 .map(|seen| seen.elapsed() > timeout)
72 .unwrap_or(false)
73 }
74
75 pub fn touch(&self) {
77 if let Ok(mut seen) = self.last_seen.lock() {
78 *seen = Instant::now();
79 }
80 }
81
82 pub async fn offer(&self, conn: WebSocket) -> Option<WebSocket> {
87 match self.pool_tx.try_send(conn) {
88 Ok(()) => None,
89 Err(mpsc::error::TrySendError::Full(conn)) => Some(conn),
90 Err(mpsc::error::TrySendError::Closed(conn)) => Some(conn),
91 }
92 }
93
94 pub async fn take(&self, timeout: Duration) -> Option<WebSocket> {
99 let _ = self.refill_tx.try_send(());
100 let mut rx = self.pool_rx.lock().await;
101 tokio::time::timeout(timeout, rx.recv())
102 .await
103 .ok()
104 .flatten()
105 }
106}
107
108#[derive(Debug, Clone, serde::Serialize)]
114pub struct DeviceSummary {
115 pub id: String,
117 pub label: Option<String>,
119 pub attached_secs: u64,
121 pub last_seen_secs: u64,
123 #[serde(skip_serializing_if = "Option::is_none")]
128 pub exchanges: Option<u64>,
129 #[serde(skip_serializing_if = "Option::is_none")]
134 pub last_exchange_ms: Option<u64>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub mean_exchange_ms: Option<u64>,
138 #[serde(skip_serializing_if = "Option::is_none")]
143 pub slowest_exchange_ms: Option<u64>,
144}
145
146#[derive(Debug)]
148pub struct DeviceHandles {
149 pub device: Arc<Device>,
151 pub refill_rx: mpsc::Receiver<()>,
153}
154
155#[derive(Debug, Clone, Default)]
160pub struct DeviceRegistry {
161 devices: Arc<RwLock<HashMap<String, Arc<Device>>>>,
162}
163
164impl DeviceRegistry {
165 pub fn new() -> Self {
167 Self::default()
168 }
169
170 pub fn attach(&self, id: impl Into<String>, label: Option<String>) -> DeviceHandles {
172 let id = id.into();
173 let (pool_tx, pool_rx) = mpsc::channel(POOL_TARGET);
174 let (refill_tx, refill_rx) = mpsc::channel(POOL_TARGET);
175
176 let device = Arc::new(Device {
177 id: id.clone(),
178 label,
179 attached_at: Instant::now(),
180 last_seen: Mutex::new(Instant::now()),
181 exchanges: Mutex::new(Exchanges::default()),
182 pool_tx,
183 pool_rx: tokio::sync::Mutex::new(pool_rx),
184 refill_tx,
185 });
186
187 if let Ok(mut devices) = self.devices.write() {
188 devices.insert(id, Arc::clone(&device));
189 }
190 DeviceHandles { device, refill_rx }
191 }
192
193 pub fn detach(&self, id: &str) -> bool {
195 self.devices
196 .write()
197 .map(|mut devices| devices.remove(id).is_some())
198 .unwrap_or(false)
199 }
200
201 pub fn get(&self, id: &str) -> Option<Arc<Device>> {
203 Some(Arc::clone(self.devices.read().ok()?.get(id)?))
204 }
205
206 pub fn touch(&self, id: &str) -> bool {
208 match self.get(id) {
209 Some(device) => {
210 device.touch();
211 true
212 }
213 None => false,
214 }
215 }
216
217 pub fn list(&self) -> Vec<DeviceSummary> {
219 let mut devices: Vec<DeviceSummary> = match self.devices.read() {
220 Ok(devices) => devices
221 .values()
222 .map(|device| {
223 let measured = device
224 .exchanges
225 .lock()
226 .ok()
227 .map(|exchanges| *exchanges)
228 .filter(|exchanges| exchanges.count > 0);
229 DeviceSummary {
230 id: device.id.clone(),
231 label: device.label.clone(),
232 attached_secs: device.attached_at.elapsed().as_secs(),
233 last_seen_secs: device
234 .last_seen
235 .lock()
236 .map(|seen| seen.elapsed().as_secs())
237 .unwrap_or_default(),
238 exchanges: measured.map(|e| e.count),
239 last_exchange_ms: measured.map(|e| e.last_ms),
240 mean_exchange_ms: measured.map(|e| e.total_ms / e.count),
241 slowest_exchange_ms: measured.map(|e| e.slowest_ms),
242 }
243 })
244 .collect(),
245 Err(_) => Vec::new(),
246 };
247 devices.sort_by_key(|device| device.attached_secs);
248 devices
249 }
250
251 pub fn count(&self) -> usize {
253 self.devices.read().map(|d| d.len()).unwrap_or(0)
254 }
255
256 pub fn evict_stale(&self, timeout: Duration) -> Vec<String> {
262 let mut evicted = Vec::new();
263 if let Ok(mut devices) = self.devices.write() {
264 devices.retain(|id, device| {
265 let keep = !device.is_stale(timeout);
266 if !keep {
267 evicted.push(id.clone());
268 }
269 keep
270 });
271 }
272 evicted
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[tokio::test]
281 async fn attach_and_get() {
282 let registry = DeviceRegistry::new();
283 let handles = registry.attach("dev-1", Some("build-box".into()));
284
285 assert_eq!(handles.device.id, "dev-1");
286 assert_eq!(registry.count(), 1);
287 assert_eq!(
288 registry.get("dev-1").unwrap().label.as_deref(),
289 Some("build-box")
290 );
291 assert!(registry.get("nope").is_none());
292 }
293
294 #[tokio::test]
295 async fn detach_removes_the_device() {
296 let registry = DeviceRegistry::new();
297 registry.attach("dev-1", None);
298
299 assert!(registry.detach("dev-1"));
300 assert!(!registry.detach("dev-1"));
301 assert_eq!(registry.count(), 0);
302 }
303
304 #[tokio::test]
305 async fn touch_only_succeeds_for_attached_devices() {
306 let registry = DeviceRegistry::new();
307 registry.attach("dev-1", None);
308
309 assert!(registry.touch("dev-1"));
310 assert!(!registry.touch("dev-2"));
311 }
312
313 #[tokio::test]
314 async fn stale_devices_are_evicted() {
315 let registry = DeviceRegistry::new();
316 registry.attach("fresh", None);
317
318 let evicted = registry.evict_stale(Duration::ZERO);
320 assert_eq!(evicted, vec!["fresh".to_string()]);
321 assert_eq!(registry.count(), 0);
322 }
323
324 #[tokio::test]
325 async fn a_heartbeat_keeps_a_device_attached() {
326 let registry = DeviceRegistry::new();
327 registry.attach("dev-1", None);
328 registry.touch("dev-1");
329
330 assert!(registry.evict_stale(Duration::from_secs(60)).is_empty());
331 assert_eq!(registry.count(), 1);
332 }
333
334 #[tokio::test]
335 async fn reattaching_replaces_the_previous_entry() {
336 let registry = DeviceRegistry::new();
337 registry.attach("dev-1", Some("old".into()));
338 registry.attach("dev-1", Some("new".into()));
339
340 assert_eq!(registry.count(), 1);
341 assert_eq!(registry.get("dev-1").unwrap().label.as_deref(), Some("new"));
342 }
343
344 #[tokio::test]
345 async fn taking_from_an_empty_pool_times_out_and_asks_for_a_refill() {
346 let registry = DeviceRegistry::new();
347 let mut handles = registry.attach("dev-1", None);
348
349 let taken = handles.device.take(Duration::from_millis(50)).await;
350 assert!(taken.is_none(), "an empty pool must not hand out a socket");
351 assert!(
352 handles.refill_rx.try_recv().is_ok(),
353 "the device should have been asked to open a connection"
354 );
355 }
356
357 #[tokio::test]
358 async fn listing_reports_attached_devices() {
359 let registry = DeviceRegistry::new();
360 registry.attach("dev-1", Some("build-box".into()));
361 registry.attach("dev-2", None);
362
363 let listed = registry.list();
364 assert_eq!(listed.len(), 2);
365 let first = listed.iter().find(|d| d.id == "dev-1").unwrap();
366 assert_eq!(first.label.as_deref(), Some("build-box"));
367 assert!(first.attached_secs < 5);
368 }
369
370 #[tokio::test]
371 async fn listing_an_empty_registry_is_empty() {
372 assert!(DeviceRegistry::new().list().is_empty());
373 }
374
375 #[tokio::test]
376 async fn registry_is_shared_across_clones() {
377 let registry = DeviceRegistry::new();
378 let clone = registry.clone();
379 clone.attach("dev-1", None);
380
381 assert_eq!(registry.count(), 1);
382 }
383}