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 now bounded (`execution::MAX_TIMEOUT`).
187    ///
188    /// Ids are returned rather than counted so the caller can record what went;
189    /// a session vanishing with no trace is what makes an abandoned one
190    /// indistinguishable from one the client deleted.
191    pub fn sweep_idle(&self, ttl: std::time::Duration) -> Result<Vec<SessionId>> {
192        let mut sessions = self
193            .sessions
194            .write()
195            .map_err(|_| ShellTunnelError::LockPoisoned)?;
196
197        let expired: Vec<SessionId> = sessions
198            .values()
199            .filter(|s| s.state != SessionState::Active && s.idle_duration() > ttl)
200            .map(|s| s.id)
201            .collect();
202
203        for id in &expired {
204            sessions.remove(id);
205        }
206        Ok(expired)
207    }
208}
209
210impl Default for SessionStore {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_create_session() {
222        let store = SessionStore::new();
223        let id = store.create().unwrap();
224
225        assert!(store.contains(&id).unwrap());
226        assert_eq!(store.count(), 1);
227    }
228
229    #[test]
230    fn test_get_session() {
231        let store = SessionStore::new();
232        let id = store.create().unwrap();
233
234        let session = store.get(&id).unwrap().unwrap();
235        assert_eq!(session.id, id);
236        assert_eq!(session.state, SessionState::Created);
237    }
238
239    #[test]
240    fn test_get_nonexistent() {
241        let store = SessionStore::new();
242        let fake_id = SessionId::from_raw(999999);
243
244        let result = store.get(&fake_id).unwrap();
245        assert!(result.is_none());
246    }
247
248    #[test]
249    fn test_update_session() {
250        let store = SessionStore::new();
251        let id = store.create().unwrap();
252
253        store
254            .update(&id, |s| {
255                s.state = SessionState::Active;
256            })
257            .unwrap();
258
259        let session = store.get(&id).unwrap().unwrap();
260        assert_eq!(session.state, SessionState::Active);
261    }
262
263    #[test]
264    fn test_update_nonexistent() {
265        let store = SessionStore::new();
266        let fake_id = SessionId::from_raw(999999);
267
268        let result = store.update(&fake_id, |_| {});
269        assert!(result.is_err());
270    }
271
272    #[test]
273    fn test_remove_session() {
274        let store = SessionStore::new();
275        let id = store.create().unwrap();
276
277        let removed = store.remove(&id).unwrap();
278        assert!(removed.is_some());
279        assert_eq!(removed.unwrap().id, id);
280
281        assert!(!store.contains(&id).unwrap());
282        assert_eq!(store.count(), 0);
283    }
284
285    #[test]
286    fn test_list_ids() {
287        let store = SessionStore::new();
288        let id1 = store.create().unwrap();
289        let id2 = store.create().unwrap();
290        let id3 = store.create().unwrap();
291
292        let ids = store.list_ids().unwrap();
293        assert_eq!(ids.len(), 3);
294        assert!(ids.contains(&id1));
295        assert!(ids.contains(&id2));
296        assert!(ids.contains(&id3));
297    }
298
299    #[test]
300    fn test_remove_matching() {
301        let store = SessionStore::new();
302        store.create().unwrap();
303        store.create().unwrap();
304
305        // Mark one as terminated
306        let ids = store.list_ids().unwrap();
307        store
308            .update(&ids[0], |s| s.state = SessionState::Terminated)
309            .unwrap();
310
311        // Remove terminated sessions
312        let removed = store
313            .remove_matching(|s| s.state == SessionState::Terminated)
314            .unwrap();
315
316        assert_eq!(removed, 1);
317        assert_eq!(store.count(), 1);
318    }
319
320    /// A session nobody has touched past the TTL goes.
321    ///
322    /// Nothing reclaimed one before: no TTL, no cap, no sweeper, and
323    /// `remove_matching` had no caller outside this file.
324    #[test]
325    fn an_idle_session_is_swept() {
326        let store = SessionStore::new();
327        let id = store.create().unwrap();
328        store
329            .update(&id, |s| {
330                let _ = s.state.transition_to(SessionState::Idle);
331            })
332            .unwrap();
333
334        // Every session here is older than a zero TTL.
335        let swept = store.sweep_idle(std::time::Duration::ZERO).unwrap();
336
337        assert_eq!(swept, vec![id], "the idle session must be the one reported");
338        assert_eq!(store.count(), 0);
339    }
340
341    /// A session running a command is never swept, however still its clock is.
342    ///
343    /// This is the contract the sweep exists under, and the one that is easy to
344    /// get wrong: `last_activity` is advanced when a command starts and when it
345    /// ends, and *not in between*, so a session mid-command looks exactly as
346    /// idle as an abandoned one. Keyed on the clock alone this sweep would reap
347    /// a session with a command actively running in it; `Active` is what parts
348    /// them.
349    #[test]
350    fn a_session_running_a_command_is_never_swept() {
351        let store = SessionStore::new();
352        let running = store.create().unwrap();
353        let abandoned = store.create().unwrap();
354        store
355            .update(&running, |s| {
356                let _ = s.state.transition_to(SessionState::Active);
357            })
358            .unwrap();
359        store
360            .update(&abandoned, |s| {
361                let _ = s.state.transition_to(SessionState::Idle);
362            })
363            .unwrap();
364
365        let swept = store.sweep_idle(std::time::Duration::ZERO).unwrap();
366
367        assert_eq!(
368            swept,
369            vec![abandoned],
370            "only the abandoned session may be swept"
371        );
372        assert!(
373            store.contains(&running).unwrap(),
374            "a session with a command running in it must survive a sweep"
375        );
376    }
377
378    /// A session younger than the TTL stays, so the sweep is not just "remove
379    /// everything that is not busy".
380    #[test]
381    fn a_session_within_the_ttl_stays() {
382        let store = SessionStore::new();
383        let id = store.create().unwrap();
384
385        let swept = store
386            .sweep_idle(std::time::Duration::from_secs(3600))
387            .unwrap();
388
389        assert!(swept.is_empty(), "nothing has been idle for an hour yet");
390        assert!(store.contains(&id).unwrap());
391    }
392
393    #[test]
394    fn test_concurrent_access() {
395        use std::sync::Arc;
396        use std::thread;
397
398        let store = Arc::new(SessionStore::new());
399        let mut handles = vec![];
400
401        // Spawn 100 threads that each create a session
402        for _ in 0..100 {
403            let store = Arc::clone(&store);
404            handles.push(thread::spawn(move || store.create().unwrap()));
405        }
406
407        let ids: Vec<SessionId> = handles.into_iter().map(|h| h.join().unwrap()).collect();
408
409        // All IDs should be unique
410        let unique: std::collections::HashSet<_> = ids.iter().collect();
411        assert_eq!(unique.len(), 100);
412
413        // Store should have 100 sessions
414        assert_eq!(store.count(), 100);
415    }
416}