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    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    /// Whether the device has missed heartbeats for longer than `timeout`.
35    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    /// Record that the device is alive.
43    pub fn touch(&self) {
44        if let Ok(mut seen) = self.last_seen.lock() {
45            *seen = Instant::now();
46        }
47    }
48
49    /// Offer a freshly opened data connection to the pool.
50    ///
51    /// Returns the connection back if the pool is full, so the caller can close
52    /// it rather than leak it.
53    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    /// Take an idle data connection, waiting up to `timeout` for one.
62    ///
63    /// Asks the device to open a replacement first: the request that consumes a
64    /// connection is exactly the event that makes the pool one short.
65    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/// Handles handed to the control session that owns a device.
76#[derive(Debug)]
77pub struct DeviceHandles {
78    /// The registered device.
79    pub device: Arc<Device>,
80    /// Fires whenever the pool wants another data connection.
81    pub refill_rx: mpsc::Receiver<()>,
82}
83
84/// Thread-safe set of attached devices.
85///
86/// Mirrors [`crate::session::SessionStore`]: an `RwLock<HashMap<_, _>>` rather
87/// than a concurrent-map dependency, since reads dominate and the map is small.
88#[derive(Debug, Clone, Default)]
89pub struct DeviceRegistry {
90    devices: Arc<RwLock<HashMap<String, Arc<Device>>>>,
91}
92
93impl DeviceRegistry {
94    /// Create an empty registry.
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Attach a device under `id`, replacing any previous entry for it.
100    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    /// Detach a device, e.g. when its control connection closes.
121    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    /// Look up an attached device.
129    pub fn get(&self, id: &str) -> Option<Arc<Device>> {
130        Some(Arc::clone(self.devices.read().ok()?.get(id)?))
131    }
132
133    /// Record a heartbeat. Returns `false` if the device is not attached.
134    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    /// Number of attached devices.
145    pub fn count(&self) -> usize {
146        self.devices.read().map(|d| d.len()).unwrap_or(0)
147    }
148
149    /// Drop devices that have not been heard from within `timeout`.
150    ///
151    /// A half-open connection (the peer vanished without a close frame) is
152    /// indistinguishable from an idle one at the socket level, so staleness is
153    /// judged from heartbeats.
154    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        // Zero timeout: everything already attached counts as stale.
212        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}