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    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/// How long this device has been taking to answer proxied requests.
36///
37/// **What one measurement covers, exactly**: from the moment the relay hands a
38/// request to the device's socket to the moment it has finished reading the
39/// answer. That is transfer time and the device's own processing *added
40/// together*, and it does not come apart here — `send` returns as soon as the
41/// socket buffer accepts the frame, so the relay never observes the two
42/// separately. A number from this says how long the device took to answer, not
43/// how fast the link to it is.
44///
45/// Waiting for a free connection from the pool is deliberately outside it: that
46/// is the relay's own queueing, not the device's.
47#[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    /// Record how long one proxied exchange with this device took.
57    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    /// Whether the device has missed heartbeats for longer than `timeout`.
68    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    /// Record that the device is alive.
76    pub fn touch(&self) {
77        if let Ok(mut seen) = self.last_seen.lock() {
78            *seen = Instant::now();
79        }
80    }
81
82    /// Offer a freshly opened data connection to the pool.
83    ///
84    /// Returns the connection back if the pool is full, so the caller can close
85    /// it rather than leak it.
86    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    /// Take an idle data connection, waiting up to `timeout` for one.
95    ///
96    /// Asks the device to open a replacement first: the request that consumes a
97    /// connection is exactly the event that makes the pool one short.
98    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/// A point-in-time view of an attached device.
109///
110/// Separate from [`Device`] because a device owns its data connections and
111/// therefore cannot be cloned or serialized; this is what an operator asking
112/// "what is attached right now?" actually needs.
113#[derive(Debug, Clone, serde::Serialize)]
114pub struct DeviceSummary {
115    /// Routing key used in `/d/<id>/…`.
116    pub id: String,
117    /// Label the device supplied, if any.
118    pub label: Option<String>,
119    /// Seconds since the device attached.
120    pub attached_secs: u64,
121    /// Seconds since its last heartbeat.
122    pub last_seen_secs: u64,
123    /// Proxied exchanges measured so far, absent until there has been one.
124    ///
125    /// Absent rather than zero: a device nothing has called yet has no timing,
126    /// and a `0` there reads as "answers instantly".
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub exchanges: Option<u64>,
129    /// How long the most recent exchange took, in milliseconds.
130    ///
131    /// Transfer *and* the device's own processing together — see [`Exchanges`].
132    /// Splitting them is not possible from the relay's side.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub last_exchange_ms: Option<u64>,
135    /// Mean over every exchange since this device attached, in milliseconds.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub mean_exchange_ms: Option<u64>,
138    /// The slowest single exchange since this device attached, in milliseconds.
139    ///
140    /// Kept alongside the mean because the mean hides exactly the case an
141    /// operator is looking for: an occasional very slow answer.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub slowest_exchange_ms: Option<u64>,
144}
145
146/// Handles handed to the control session that owns a device.
147#[derive(Debug)]
148pub struct DeviceHandles {
149    /// The registered device.
150    pub device: Arc<Device>,
151    /// Fires whenever the pool wants another data connection.
152    pub refill_rx: mpsc::Receiver<()>,
153}
154
155/// Thread-safe set of attached devices.
156///
157/// Mirrors [`crate::session::SessionStore`]: an `RwLock<HashMap<_, _>>` rather
158/// than a concurrent-map dependency, since reads dominate and the map is small.
159#[derive(Debug, Clone, Default)]
160pub struct DeviceRegistry {
161    devices: Arc<RwLock<HashMap<String, Arc<Device>>>>,
162}
163
164impl DeviceRegistry {
165    /// Create an empty registry.
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Attach a device under `id`, replacing any previous entry for it.
171    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    /// Detach a device, e.g. when its control connection closes.
194    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    /// Look up an attached device.
202    pub fn get(&self, id: &str) -> Option<Arc<Device>> {
203        Some(Arc::clone(self.devices.read().ok()?.get(id)?))
204    }
205
206    /// Record a heartbeat. Returns `false` if the device is not attached.
207    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    /// Snapshot of every attached device, newest attachment first.
218    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    /// Number of attached devices.
252    pub fn count(&self) -> usize {
253        self.devices.read().map(|d| d.len()).unwrap_or(0)
254    }
255
256    /// Drop devices that have not been heard from within `timeout`.
257    ///
258    /// A half-open connection (the peer vanished without a close frame) is
259    /// indistinguishable from an idle one at the socket level, so staleness is
260    /// judged from heartbeats.
261    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        // Zero timeout: everything already attached counts as stale.
319        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}