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 last_seen: Mutex<Instant>,
28 pool_tx: mpsc::Sender<WebSocket>,
29 pool_rx: tokio::sync::Mutex<mpsc::Receiver<WebSocket>>,
30 refill_tx: mpsc::Sender<()>,
31}
32
33impl Device {
34 pub fn is_stale(&self, timeout: Duration) -> bool {
36 self.last_seen
37 .lock()
38 .map(|seen| seen.elapsed() > timeout)
39 .unwrap_or(false)
40 }
41
42 pub fn touch(&self) {
44 if let Ok(mut seen) = self.last_seen.lock() {
45 *seen = Instant::now();
46 }
47 }
48
49 pub async fn offer(&self, conn: WebSocket) -> Option<WebSocket> {
54 match self.pool_tx.try_send(conn) {
55 Ok(()) => None,
56 Err(mpsc::error::TrySendError::Full(conn)) => Some(conn),
57 Err(mpsc::error::TrySendError::Closed(conn)) => Some(conn),
58 }
59 }
60
61 pub async fn take(&self, timeout: Duration) -> Option<WebSocket> {
66 let _ = self.refill_tx.try_send(());
67 let mut rx = self.pool_rx.lock().await;
68 tokio::time::timeout(timeout, rx.recv())
69 .await
70 .ok()
71 .flatten()
72 }
73}
74
75#[derive(Debug)]
77pub struct DeviceHandles {
78 pub device: Arc<Device>,
80 pub refill_rx: mpsc::Receiver<()>,
82}
83
84#[derive(Debug, Clone, Default)]
89pub struct DeviceRegistry {
90 devices: Arc<RwLock<HashMap<String, Arc<Device>>>>,
91}
92
93impl DeviceRegistry {
94 pub fn new() -> Self {
96 Self::default()
97 }
98
99 pub fn attach(&self, id: impl Into<String>, label: Option<String>) -> DeviceHandles {
101 let id = id.into();
102 let (pool_tx, pool_rx) = mpsc::channel(POOL_TARGET);
103 let (refill_tx, refill_rx) = mpsc::channel(POOL_TARGET);
104
105 let device = Arc::new(Device {
106 id: id.clone(),
107 label,
108 last_seen: Mutex::new(Instant::now()),
109 pool_tx,
110 pool_rx: tokio::sync::Mutex::new(pool_rx),
111 refill_tx,
112 });
113
114 if let Ok(mut devices) = self.devices.write() {
115 devices.insert(id, Arc::clone(&device));
116 }
117 DeviceHandles { device, refill_rx }
118 }
119
120 pub fn detach(&self, id: &str) -> bool {
122 self.devices
123 .write()
124 .map(|mut devices| devices.remove(id).is_some())
125 .unwrap_or(false)
126 }
127
128 pub fn get(&self, id: &str) -> Option<Arc<Device>> {
130 Some(Arc::clone(self.devices.read().ok()?.get(id)?))
131 }
132
133 pub fn touch(&self, id: &str) -> bool {
135 match self.get(id) {
136 Some(device) => {
137 device.touch();
138 true
139 }
140 None => false,
141 }
142 }
143
144 pub fn count(&self) -> usize {
146 self.devices.read().map(|d| d.len()).unwrap_or(0)
147 }
148
149 pub fn evict_stale(&self, timeout: Duration) -> Vec<String> {
155 let mut evicted = Vec::new();
156 if let Ok(mut devices) = self.devices.write() {
157 devices.retain(|id, device| {
158 let keep = !device.is_stale(timeout);
159 if !keep {
160 evicted.push(id.clone());
161 }
162 keep
163 });
164 }
165 evicted
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[tokio::test]
174 async fn attach_and_get() {
175 let registry = DeviceRegistry::new();
176 let handles = registry.attach("dev-1", Some("build-box".into()));
177
178 assert_eq!(handles.device.id, "dev-1");
179 assert_eq!(registry.count(), 1);
180 assert_eq!(
181 registry.get("dev-1").unwrap().label.as_deref(),
182 Some("build-box")
183 );
184 assert!(registry.get("nope").is_none());
185 }
186
187 #[tokio::test]
188 async fn detach_removes_the_device() {
189 let registry = DeviceRegistry::new();
190 registry.attach("dev-1", None);
191
192 assert!(registry.detach("dev-1"));
193 assert!(!registry.detach("dev-1"));
194 assert_eq!(registry.count(), 0);
195 }
196
197 #[tokio::test]
198 async fn touch_only_succeeds_for_attached_devices() {
199 let registry = DeviceRegistry::new();
200 registry.attach("dev-1", None);
201
202 assert!(registry.touch("dev-1"));
203 assert!(!registry.touch("dev-2"));
204 }
205
206 #[tokio::test]
207 async fn stale_devices_are_evicted() {
208 let registry = DeviceRegistry::new();
209 registry.attach("fresh", None);
210
211 let evicted = registry.evict_stale(Duration::ZERO);
213 assert_eq!(evicted, vec!["fresh".to_string()]);
214 assert_eq!(registry.count(), 0);
215 }
216
217 #[tokio::test]
218 async fn a_heartbeat_keeps_a_device_attached() {
219 let registry = DeviceRegistry::new();
220 registry.attach("dev-1", None);
221 registry.touch("dev-1");
222
223 assert!(registry.evict_stale(Duration::from_secs(60)).is_empty());
224 assert_eq!(registry.count(), 1);
225 }
226
227 #[tokio::test]
228 async fn reattaching_replaces_the_previous_entry() {
229 let registry = DeviceRegistry::new();
230 registry.attach("dev-1", Some("old".into()));
231 registry.attach("dev-1", Some("new".into()));
232
233 assert_eq!(registry.count(), 1);
234 assert_eq!(registry.get("dev-1").unwrap().label.as_deref(), Some("new"));
235 }
236
237 #[tokio::test]
238 async fn taking_from_an_empty_pool_times_out_and_asks_for_a_refill() {
239 let registry = DeviceRegistry::new();
240 let mut handles = registry.attach("dev-1", None);
241
242 let taken = handles.device.take(Duration::from_millis(50)).await;
243 assert!(taken.is_none(), "an empty pool must not hand out a socket");
244 assert!(
245 handles.refill_rx.try_recv().is_ok(),
246 "the device should have been asked to open a connection"
247 );
248 }
249
250 #[tokio::test]
251 async fn registry_is_shared_across_clones() {
252 let registry = DeviceRegistry::new();
253 let clone = registry.clone();
254 clone.attach("dev-1", None);
255
256 assert_eq!(registry.count(), 1);
257 }
258}