Skip to main content

shell_tunnel/relay/
registry.rs

1//! Which devices are attached, and the idle connections that reach them.
2
3use 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
10/// How many idle data connections a device is asked to keep ready.
11///
12/// Pre-opened so a request does not pay a WebSocket handshake (2–3 RTT) before
13/// it can be forwarded; small because each one is a real socket on both ends.
14pub const POOL_TARGET: usize = 4;
15
16/// A device attached to the relay.
17///
18/// Held behind an `Arc`: the control session, the pool, and every in-flight
19/// request all refer to the same entry, and the data connections cannot be
20/// cloned.
21#[derive(Debug)]
22pub struct Device {
23    /// Relay-assigned identifier; also the routing key in `/d/<id>/…`.
24    pub id: String,
25    /// Optional label the device supplied, for operator-facing logs.
26    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    /// Whether the device has missed heartbeats for longer than `timeout`.
36    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    /// Record that the device is alive.
44    pub fn touch(&self) {
45        if let Ok(mut seen) = self.last_seen.lock() {
46            *seen = Instant::now();
47        }
48    }
49
50    /// Offer a freshly opened data connection to the pool.
51    ///
52    /// Returns the connection back if the pool is full, so the caller can close
53    /// it rather than leak it.
54    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    /// Take an idle data connection, waiting up to `timeout` for one.
63    ///
64    /// Asks the device to open a replacement first: the request that consumes a
65    /// connection is exactly the event that makes the pool one short.
66    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/// A point-in-time view of an attached device.
77///
78/// Separate from [`Device`] because a device owns its data connections and
79/// therefore cannot be cloned or serialized; this is what an operator asking
80/// "what is attached right now?" actually needs.
81#[derive(Debug, Clone, serde::Serialize)]
82pub struct DeviceSummary {
83    /// Routing key used in `/d/<id>/…`.
84    pub id: String,
85    /// Label the device supplied, if any.
86    pub label: Option<String>,
87    /// Seconds since the device attached.
88    pub attached_secs: u64,
89    /// Seconds since its last heartbeat.
90    pub last_seen_secs: u64,
91}
92
93/// Handles handed to the control session that owns a device.
94#[derive(Debug)]
95pub struct DeviceHandles {
96    /// The registered device.
97    pub device: Arc<Device>,
98    /// Fires whenever the pool wants another data connection.
99    pub refill_rx: mpsc::Receiver<()>,
100}
101
102/// Thread-safe set of attached devices.
103///
104/// Mirrors [`crate::session::SessionStore`]: an `RwLock<HashMap<_, _>>` rather
105/// than a concurrent-map dependency, since reads dominate and the map is small.
106#[derive(Debug, Clone, Default)]
107pub struct DeviceRegistry {
108    devices: Arc<RwLock<HashMap<String, Arc<Device>>>>,
109}
110
111impl DeviceRegistry {
112    /// Create an empty registry.
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Attach a device under `id`, replacing any previous entry for it.
118    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    /// Detach a device, e.g. when its control connection closes.
140    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    /// Look up an attached device.
148    pub fn get(&self, id: &str) -> Option<Arc<Device>> {
149        Some(Arc::clone(self.devices.read().ok()?.get(id)?))
150    }
151
152    /// Record a heartbeat. Returns `false` if the device is not attached.
153    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    /// Snapshot of every attached device, newest attachment first.
164    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    /// Number of attached devices.
186    pub fn count(&self) -> usize {
187        self.devices.read().map(|d| d.len()).unwrap_or(0)
188    }
189
190    /// Drop devices that have not been heard from within `timeout`.
191    ///
192    /// A half-open connection (the peer vanished without a close frame) is
193    /// indistinguishable from an idle one at the socket level, so staleness is
194    /// judged from heartbeats.
195    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        // Zero timeout: everything already attached counts as stale.
253        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}