Skip to main content

shell_tunnel/session/
store.rs

1//! Session storage and management.
2
3use std::collections::HashMap;
4use std::sync::RwLock;
5use std::time::Instant;
6
7use super::{SessionContext, SessionId, SessionState};
8use crate::error::ShellTunnelError;
9use crate::Result;
10
11/// A shell session.
12///
13/// A session carries no execution configuration. It is an identifier the audit
14/// trail records against, a place for streaming to attach, and the bookkeeping
15/// in [`SessionContext`]; where a command runs and what it runs with are
16/// decided per execute. It once held a `shell`, a `working_dir` and an `env`
17/// that no execute ever read.
18#[derive(Debug, Clone)]
19pub struct Session {
20    /// Unique identifier.
21    pub id: SessionId,
22    /// Current state.
23    pub state: SessionState,
24    /// Execution bookkeeping (last command, exit code, count).
25    pub context: SessionContext,
26    /// Time when session was created.
27    pub created_at: Instant,
28    /// Time of last activity.
29    pub last_activity: Instant,
30}
31
32impl Session {
33    /// Create a new session with the given ID.
34    pub fn new(id: SessionId) -> Self {
35        let now = Instant::now();
36
37        Self {
38            id,
39            state: SessionState::Created,
40            context: SessionContext::new(),
41            created_at: now,
42            last_activity: now,
43        }
44    }
45
46    /// Update the last activity timestamp.
47    pub fn touch(&mut self) {
48        self.last_activity = Instant::now();
49    }
50
51    /// Get the idle duration since last activity.
52    pub fn idle_duration(&self) -> std::time::Duration {
53        self.last_activity.elapsed()
54    }
55}
56
57/// Thread-safe storage for sessions.
58pub struct SessionStore {
59    sessions: RwLock<HashMap<SessionId, Session>>,
60}
61
62impl SessionStore {
63    /// Create a new empty session store.
64    pub fn new() -> Self {
65        Self {
66            sessions: RwLock::new(HashMap::new()),
67        }
68    }
69
70    /// Create a new session.
71    ///
72    /// Returns the newly assigned session ID.
73    pub fn create(&self) -> Result<SessionId> {
74        let id = SessionId::new();
75        let session = Session::new(id);
76
77        let mut sessions = self
78            .sessions
79            .write()
80            .map_err(|_| ShellTunnelError::LockPoisoned)?;
81
82        sessions.insert(id, session);
83        Ok(id)
84    }
85
86    /// Get a clone of the session with the given ID.
87    pub fn get(&self, id: &SessionId) -> Result<Option<Session>> {
88        let sessions = self
89            .sessions
90            .read()
91            .map_err(|_| ShellTunnelError::LockPoisoned)?;
92        Ok(sessions.get(id).cloned())
93    }
94
95    /// Check if a session exists.
96    pub fn contains(&self, id: &SessionId) -> Result<bool> {
97        let sessions = self
98            .sessions
99            .read()
100            .map_err(|_| ShellTunnelError::LockPoisoned)?;
101        Ok(sessions.contains_key(id))
102    }
103
104    /// Update a session using a closure.
105    ///
106    /// The closure receives a mutable reference to the session and can modify it.
107    /// Returns an error if the session doesn't exist.
108    pub fn update<F>(&self, id: &SessionId, f: F) -> Result<()>
109    where
110        F: FnOnce(&mut Session),
111    {
112        let mut sessions = self
113            .sessions
114            .write()
115            .map_err(|_| ShellTunnelError::LockPoisoned)?;
116
117        let session = sessions
118            .get_mut(id)
119            .ok_or_else(|| ShellTunnelError::SessionNotFound(id.to_string()))?;
120
121        f(session);
122        Ok(())
123    }
124
125    /// Remove a session from the store.
126    ///
127    /// Returns the removed session, or None if it didn't exist.
128    pub fn remove(&self, id: &SessionId) -> Result<Option<Session>> {
129        let mut sessions = self
130            .sessions
131            .write()
132            .map_err(|_| ShellTunnelError::LockPoisoned)?;
133        Ok(sessions.remove(id))
134    }
135
136    /// Get the number of sessions in the store.
137    pub fn count(&self) -> usize {
138        self.sessions.read().map(|s| s.len()).unwrap_or(0)
139    }
140
141    /// List all session IDs.
142    pub fn list_ids(&self) -> Result<Vec<SessionId>> {
143        let sessions = self
144            .sessions
145            .read()
146            .map_err(|_| ShellTunnelError::LockPoisoned)?;
147        Ok(sessions.keys().copied().collect())
148    }
149
150    /// Remove all sessions matching a predicate.
151    ///
152    /// Returns the number of sessions removed.
153    pub fn remove_matching<F>(&self, predicate: F) -> Result<usize>
154    where
155        F: Fn(&Session) -> bool,
156    {
157        let mut sessions = self
158            .sessions
159            .write()
160            .map_err(|_| ShellTunnelError::LockPoisoned)?;
161
162        let before = sessions.len();
163        sessions.retain(|_, session| !predicate(session));
164        Ok(before - sessions.len())
165    }
166
167    /// Drop sessions that have sat idle past `ttl`, returning their ids.
168    ///
169    /// Nothing reclaimed a shell session before this: there was no TTL, no cap
170    /// and no sweeper, and [`remove_matching`](Self::remove_matching) had no
171    /// caller outside tests. A client that creates sessions and never `DELETE`s
172    /// them therefore accumulated them for as long as the process ran. Upload
173    /// sessions have had exactly this — a periodic sweep against
174    /// `fs::SESSION_TTL` — since they were introduced; shell sessions simply had
175    /// no equivalent.
176    ///
177    /// **A session running a command is never swept, whatever its idle clock
178    /// says.** That clock is only advanced when a command starts and when it
179    /// ends (`BusySession`), so during a long command it does not move at all —
180    /// idle time alone cannot tell "abandoned" from "busy", and a sweep keyed on
181    /// it would reap a session with a command actively running in it. The state
182    /// is what distinguishes them: the same guard that stops the clock also
183    /// holds the session [`Active`](SessionState::Active) for the length of the
184    /// command, and that guard closes every exit including a cancelled future.
185    /// `Active` cannot hide an unbounded session either, since a command's
186    /// deadline is bounded (`execution::MAX_TIMEOUT`).
187    ///
188    /// That last sentence is only true while the guard's life is the *command's*
189    /// life, which is a stronger condition than it sounds. Until 0.21.1 the
190    /// session WebSocket handler held it across delivery as well, and delivery
191    /// is bounded by nothing: a consumer that stopped reading its socket parked
192    /// the handler mid-send, so the session stayed `Active` — and unsweepable —
193    /// for as long as that consumer liked. Measured at 75 s on a command that
194    /// died at its 5 s deadline, ending when the consumer resumed rather than at
195    /// any deadline at all. A guard that outlives what it claims to track puts
196    /// this sweep back where it was before there was one.
197    ///
198    /// Ids are returned rather than counted so the caller can record what went;
199    /// a session vanishing with no trace is what makes an abandoned one
200    /// indistinguishable from one the client deleted.
201    pub fn sweep_idle(&self, ttl: std::time::Duration) -> Result<Vec<SessionId>> {
202        let mut sessions = self
203            .sessions
204            .write()
205            .map_err(|_| ShellTunnelError::LockPoisoned)?;
206
207        let expired: Vec<SessionId> = sessions
208            .values()
209            .filter(|s| s.state != SessionState::Active && s.idle_duration() > ttl)
210            .map(|s| s.id)
211            .collect();
212
213        for id in &expired {
214            sessions.remove(id);
215        }
216        Ok(expired)
217    }
218}
219
220impl Default for SessionStore {
221    fn default() -> Self {
222        Self::new()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_create_session() {
232        let store = SessionStore::new();
233        let id = store.create().unwrap();
234
235        assert!(store.contains(&id).unwrap());
236        assert_eq!(store.count(), 1);
237    }
238
239    #[test]
240    fn test_get_session() {
241        let store = SessionStore::new();
242        let id = store.create().unwrap();
243
244        let session = store.get(&id).unwrap().unwrap();
245        assert_eq!(session.id, id);
246        assert_eq!(session.state, SessionState::Created);
247    }
248
249    #[test]
250    fn test_get_nonexistent() {
251        let store = SessionStore::new();
252        let fake_id = SessionId::from_raw(999999);
253
254        let result = store.get(&fake_id).unwrap();
255        assert!(result.is_none());
256    }
257
258    #[test]
259    fn test_update_session() {
260        let store = SessionStore::new();
261        let id = store.create().unwrap();
262
263        store
264            .update(&id, |s| {
265                s.state = SessionState::Active;
266            })
267            .unwrap();
268
269        let session = store.get(&id).unwrap().unwrap();
270        assert_eq!(session.state, SessionState::Active);
271    }
272
273    #[test]
274    fn test_update_nonexistent() {
275        let store = SessionStore::new();
276        let fake_id = SessionId::from_raw(999999);
277
278        let result = store.update(&fake_id, |_| {});
279        assert!(result.is_err());
280    }
281
282    #[test]
283    fn test_remove_session() {
284        let store = SessionStore::new();
285        let id = store.create().unwrap();
286
287        let removed = store.remove(&id).unwrap();
288        assert!(removed.is_some());
289        assert_eq!(removed.unwrap().id, id);
290
291        assert!(!store.contains(&id).unwrap());
292        assert_eq!(store.count(), 0);
293    }
294
295    #[test]
296    fn test_list_ids() {
297        let store = SessionStore::new();
298        let id1 = store.create().unwrap();
299        let id2 = store.create().unwrap();
300        let id3 = store.create().unwrap();
301
302        let ids = store.list_ids().unwrap();
303        assert_eq!(ids.len(), 3);
304        assert!(ids.contains(&id1));
305        assert!(ids.contains(&id2));
306        assert!(ids.contains(&id3));
307    }
308
309    #[test]
310    fn test_remove_matching() {
311        let store = SessionStore::new();
312        store.create().unwrap();
313        store.create().unwrap();
314
315        // Mark one as terminated
316        let ids = store.list_ids().unwrap();
317        store
318            .update(&ids[0], |s| s.state = SessionState::Terminated)
319            .unwrap();
320
321        // Remove terminated sessions
322        let removed = store
323            .remove_matching(|s| s.state == SessionState::Terminated)
324            .unwrap();
325
326        assert_eq!(removed, 1);
327        assert_eq!(store.count(), 1);
328    }
329
330    /// A session nobody has touched past the TTL goes.
331    ///
332    /// Nothing reclaimed one before: no TTL, no cap, no sweeper, and
333    /// `remove_matching` had no caller outside this file.
334    #[test]
335    fn an_idle_session_is_swept() {
336        let store = SessionStore::new();
337        let id = store.create().unwrap();
338        store
339            .update(&id, |s| {
340                let _ = s.state.transition_to(SessionState::Idle);
341            })
342            .unwrap();
343
344        // Every session here is older than a zero TTL.
345        let swept = store.sweep_idle(std::time::Duration::ZERO).unwrap();
346
347        assert_eq!(swept, vec![id], "the idle session must be the one reported");
348        assert_eq!(store.count(), 0);
349    }
350
351    /// A session running a command is never swept, however still its clock is.
352    ///
353    /// This is the contract the sweep exists under, and the one that is easy to
354    /// get wrong: `last_activity` is advanced when a command starts and when it
355    /// ends, and *not in between*, so a session mid-command looks exactly as
356    /// idle as an abandoned one. Keyed on the clock alone this sweep would reap
357    /// a session with a command actively running in it; `Active` is what parts
358    /// them.
359    #[test]
360    fn a_session_running_a_command_is_never_swept() {
361        let store = SessionStore::new();
362        let running = store.create().unwrap();
363        let abandoned = store.create().unwrap();
364        store
365            .update(&running, |s| {
366                let _ = s.state.transition_to(SessionState::Active);
367            })
368            .unwrap();
369        store
370            .update(&abandoned, |s| {
371                let _ = s.state.transition_to(SessionState::Idle);
372            })
373            .unwrap();
374
375        let swept = store.sweep_idle(std::time::Duration::ZERO).unwrap();
376
377        assert_eq!(
378            swept,
379            vec![abandoned],
380            "only the abandoned session may be swept"
381        );
382        assert!(
383            store.contains(&running).unwrap(),
384            "a session with a command running in it must survive a sweep"
385        );
386    }
387
388    /// A session younger than the TTL stays, so the sweep is not just "remove
389    /// everything that is not busy".
390    #[test]
391    fn a_session_within_the_ttl_stays() {
392        let store = SessionStore::new();
393        let id = store.create().unwrap();
394
395        let swept = store
396            .sweep_idle(std::time::Duration::from_secs(3600))
397            .unwrap();
398
399        assert!(swept.is_empty(), "nothing has been idle for an hour yet");
400        assert!(store.contains(&id).unwrap());
401    }
402
403    #[test]
404    fn test_concurrent_access() {
405        use std::sync::Arc;
406        use std::thread;
407
408        let store = Arc::new(SessionStore::new());
409        let mut handles = vec![];
410
411        // Spawn 100 threads that each create a session
412        for _ in 0..100 {
413            let store = Arc::clone(&store);
414            handles.push(thread::spawn(move || store.create().unwrap()));
415        }
416
417        let ids: Vec<SessionId> = handles.into_iter().map(|h| h.join().unwrap()).collect();
418
419        // All IDs should be unique
420        let unique: std::collections::HashSet<_> = ids.iter().collect();
421        assert_eq!(unique.len(), 100);
422
423        // Store should have 100 sessions
424        assert_eq!(store.count(), 100);
425    }
426}