shell_tunnel/relay/
registry.rs1use 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 pool_tx: mpsc::Sender<WebSocket>,
30 pool_rx: tokio::sync::Mutex<mpsc::Receiver<WebSocket>>,
31 refill_tx: mpsc::Sender<()>,
32}
33
34impl Device {
35 pub fn is_stale(&self, timeout: Duration) -> bool {
37 self.last_seen
38 .lock()
39 .map(|seen| seen.elapsed() > timeout)
40 .unwrap_or(false)
41 }
42
43 pub fn touch(&self) {
45 if let Ok(mut seen) = self.last_seen.lock() {
46 *seen = Instant::now();
47 }
48 }
49
50 pub async fn offer(&self, conn: WebSocket) -> Option<WebSocket> {
55 match self.pool_tx.try_send(conn) {
56 Ok(()) => None,
57 Err(mpsc::error::TrySendError::Full(conn)) => Some(conn),
58 Err(mpsc::error::TrySendError::Closed(conn)) => Some(conn),
59 }
60 }
61
62 pub async fn take(&self, timeout: Duration) -> Option<WebSocket> {
67 let _ = self.refill_tx.try_send(());
68 let mut rx = self.pool_rx.lock().await;
69 tokio::time::timeout(timeout, rx.recv())
70 .await
71 .ok()
72 .flatten()
73 }
74}
75
76#[derive(Debug, Clone, serde::Serialize)]
82pub struct DeviceSummary {
83 pub id: String,
85 pub label: Option<String>,
87 pub attached_secs: u64,
89 pub last_seen_secs: u64,
91}
92
93#[derive(Debug)]
95pub struct DeviceHandles {
96 pub device: Arc<Device>,
98 pub refill_rx: mpsc::Receiver<()>,
100}
101
102#[derive(Debug, Clone, Default)]
107pub struct DeviceRegistry {
108 devices: Arc<RwLock<HashMap<String, Arc<Device>>>>,
109}
110
111impl DeviceRegistry {
112 pub fn new() -> Self {
114 Self::default()
115 }
116
117 pub fn attach(&self, id: impl Into<String>, label: Option<String>) -> DeviceHandles {
119 let id = id.into();
120 let (pool_tx, pool_rx) = mpsc::channel(POOL_TARGET);
121 let (refill_tx, refill_rx) = mpsc::channel(POOL_TARGET);
122
123 let device = Arc::new(Device {
124 id: id.clone(),
125 label,
126 attached_at: Instant::now(),
127 last_seen: Mutex::new(Instant::now()),
128 pool_tx,
129 pool_rx: tokio::sync::Mutex::new(pool_rx),
130 refill_tx,
131 });
132
133 if let Ok(mut devices) = self.devices.write() {
134 devices.insert(id, Arc::clone(&device));
135 }
136 DeviceHandles { device, refill_rx }
137 }
138
139 pub fn detach(&self, id: &str) -> bool {
141 self.devices
142 .write()
143 .map(|mut devices| devices.remove(id).is_some())
144 .unwrap_or(false)
145 }
146
147 pub fn get(&self, id: &str) -> Option<Arc<Device>> {
149 Some(Arc::clone(self.devices.read().ok()?.get(id)?))
150 }
151
152 pub fn touch(&self, id: &str) -> bool {
154 match self.get(id) {
155 Some(device) => {
156 device.touch();
157 true
158 }
159 None => false,
160 }
161 }
162
163 pub fn list(&self) -> Vec<DeviceSummary> {
165 let mut devices: Vec<DeviceSummary> = match self.devices.read() {
166 Ok(devices) => devices
167 .values()
168 .map(|device| DeviceSummary {
169 id: device.id.clone(),
170 label: device.label.clone(),
171 attached_secs: device.attached_at.elapsed().as_secs(),
172 last_seen_secs: device
173 .last_seen
174 .lock()
175 .map(|seen| seen.elapsed().as_secs())
176 .unwrap_or_default(),
177 })
178 .collect(),
179 Err(_) => Vec::new(),
180 };
181 devices.sort_by_key(|device| device.attached_secs);
182 devices
183 }
184
185 pub fn count(&self) -> usize {
187 self.devices.read().map(|d| d.len()).unwrap_or(0)
188 }
189
190 pub fn evict_stale(&self, timeout: Duration) -> Vec<String> {
196 let mut evicted = Vec::new();
197 if let Ok(mut devices) = self.devices.write() {
198 devices.retain(|id, device| {
199 let keep = !device.is_stale(timeout);
200 if !keep {
201 evicted.push(id.clone());
202 }
203 keep
204 });
205 }
206 evicted
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[tokio::test]
215 async fn attach_and_get() {
216 let registry = DeviceRegistry::new();
217 let handles = registry.attach("dev-1", Some("build-box".into()));
218
219 assert_eq!(handles.device.id, "dev-1");
220 assert_eq!(registry.count(), 1);
221 assert_eq!(
222 registry.get("dev-1").unwrap().label.as_deref(),
223 Some("build-box")
224 );
225 assert!(registry.get("nope").is_none());
226 }
227
228 #[tokio::test]
229 async fn detach_removes_the_device() {
230 let registry = DeviceRegistry::new();
231 registry.attach("dev-1", None);
232
233 assert!(registry.detach("dev-1"));
234 assert!(!registry.detach("dev-1"));
235 assert_eq!(registry.count(), 0);
236 }
237
238 #[tokio::test]
239 async fn touch_only_succeeds_for_attached_devices() {
240 let registry = DeviceRegistry::new();
241 registry.attach("dev-1", None);
242
243 assert!(registry.touch("dev-1"));
244 assert!(!registry.touch("dev-2"));
245 }
246
247 #[tokio::test]
248 async fn stale_devices_are_evicted() {
249 let registry = DeviceRegistry::new();
250 registry.attach("fresh", None);
251
252 let evicted = registry.evict_stale(Duration::ZERO);
254 assert_eq!(evicted, vec!["fresh".to_string()]);
255 assert_eq!(registry.count(), 0);
256 }
257
258 #[tokio::test]
259 async fn a_heartbeat_keeps_a_device_attached() {
260 let registry = DeviceRegistry::new();
261 registry.attach("dev-1", None);
262 registry.touch("dev-1");
263
264 assert!(registry.evict_stale(Duration::from_secs(60)).is_empty());
265 assert_eq!(registry.count(), 1);
266 }
267
268 #[tokio::test]
269 async fn reattaching_replaces_the_previous_entry() {
270 let registry = DeviceRegistry::new();
271 registry.attach("dev-1", Some("old".into()));
272 registry.attach("dev-1", Some("new".into()));
273
274 assert_eq!(registry.count(), 1);
275 assert_eq!(registry.get("dev-1").unwrap().label.as_deref(), Some("new"));
276 }
277
278 #[tokio::test]
279 async fn taking_from_an_empty_pool_times_out_and_asks_for_a_refill() {
280 let registry = DeviceRegistry::new();
281 let mut handles = registry.attach("dev-1", None);
282
283 let taken = handles.device.take(Duration::from_millis(50)).await;
284 assert!(taken.is_none(), "an empty pool must not hand out a socket");
285 assert!(
286 handles.refill_rx.try_recv().is_ok(),
287 "the device should have been asked to open a connection"
288 );
289 }
290
291 #[tokio::test]
292 async fn listing_reports_attached_devices() {
293 let registry = DeviceRegistry::new();
294 registry.attach("dev-1", Some("build-box".into()));
295 registry.attach("dev-2", None);
296
297 let listed = registry.list();
298 assert_eq!(listed.len(), 2);
299 let first = listed.iter().find(|d| d.id == "dev-1").unwrap();
300 assert_eq!(first.label.as_deref(), Some("build-box"));
301 assert!(first.attached_secs < 5);
302 }
303
304 #[tokio::test]
305 async fn listing_an_empty_registry_is_empty() {
306 assert!(DeviceRegistry::new().list().is_empty());
307 }
308
309 #[tokio::test]
310 async fn registry_is_shared_across_clones() {
311 let registry = DeviceRegistry::new();
312 let clone = registry.clone();
313 clone.attach("dev-1", None);
314
315 assert_eq!(registry.count(), 1);
316 }
317}