Skip to main content

tty_web/
session.rs

1//! Persistent terminal sessions with scrollback and lifecycle management.
2//!
3//! A [`Session`] wraps a [`Terminal`] and adds:
4//! - a configurable ring-buffer of recent output (scrollback, default 256 KiB),
5//! - client attach/detach tracking,
6//! - orphan detection (no clients for 60 s → auto-remove).
7//!
8//! [`SessionStore`] is the global session registry. Each session gets a reaper
9//! task that periodically checks for removal conditions.
10
11use std::collections::HashMap;
12use std::collections::VecDeque;
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::sync::{Arc, Mutex, RwLock, Weak};
15use std::time::Instant;
16
17use tokio::sync::{broadcast, watch};
18
19use crate::terminal::Terminal;
20
21/// Default time without any attached clients before a session is reaped.
22pub const DEFAULT_ORPHAN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
23
24/// Return type of [`Session::attach`]: scrollback events, output stream,
25/// and window-size watch.
26pub type AttachResult = (
27    Vec<ScrollbackEvent>,
28    broadcast::Receiver<Vec<u8>>,
29    watch::Receiver<(u16, u16)>,
30);
31
32/// A scrollback event — either terminal output or a window-size change.
33///
34/// Storing events instead of raw bytes ensures that eviction never splits
35/// an escape sequence and that resize history is preserved for replay.
36#[derive(Clone, Debug, PartialEq)]
37pub enum ScrollbackEvent {
38    /// Raw terminal output bytes.
39    Output(Vec<u8>),
40    /// PTY window size changed (rows, cols).
41    WindowSize(u16, u16),
42}
43
44impl ScrollbackEvent {
45    /// Logical byte cost used for eviction accounting.
46    fn byte_cost(&self) -> usize {
47        match self {
48            Self::Output(data) => data.len(),
49            Self::WindowSize(_, _) => 4,
50        }
51    }
52}
53
54/// A persistent terminal session.
55///
56/// Tracks connected clients, buffers recent output for replay on reconnect,
57/// and detects when the session becomes orphaned.
58pub struct Session {
59    pub terminal: Terminal,
60    scrollback: Mutex<VecDeque<ScrollbackEvent>>,
61    scrollback_bytes: Mutex<usize>,
62    scrollback_limit: usize,
63    clients: AtomicUsize,
64    detached_at: Mutex<Option<Instant>>,
65    window_size: watch::Sender<(u16, u16)>,
66    orphan_timeout: std::time::Duration,
67}
68
69impl Session {
70    /// Create a new session.
71    ///
72    /// `orphan_timeout` controls how long a session with no attached clients
73    /// survives before the reaper removes it (default: [`DEFAULT_ORPHAN_TIMEOUT`]).
74    pub fn new(
75        terminal: Terminal,
76        output_rx: broadcast::Receiver<Vec<u8>>,
77        scrollback_limit: usize,
78        orphan_timeout: std::time::Duration,
79    ) -> Arc<Self> {
80        let (ws_tx, _) = watch::channel((24, 80));
81        let session = Arc::new(Self {
82            terminal,
83            scrollback: Mutex::new(VecDeque::new()),
84            scrollback_bytes: Mutex::new(0),
85            scrollback_limit,
86            clients: AtomicUsize::new(0),
87            detached_at: Mutex::new(None),
88            window_size: ws_tx,
89            orphan_timeout,
90        });
91
92        // Scrollback collector
93        let weak: Weak<Session> = Arc::downgrade(&session);
94        let mut rx = output_rx;
95        tokio::spawn(async move {
96            loop {
97                match rx.recv().await {
98                    Ok(data) => {
99                        let Some(s) = weak.upgrade() else {
100                            break;
101                        };
102                        s.push_scrollback(ScrollbackEvent::Output(data));
103                    }
104                    Err(broadcast::error::RecvError::Lagged(_)) => {
105                        continue;
106                    }
107                    Err(broadcast::error::RecvError::Closed) => break,
108                }
109            }
110        });
111
112        session
113    }
114
115    /// Push an event into the scrollback ring buffer, evicting old events
116    /// when the byte budget is exceeded.
117    fn push_scrollback(&self, event: ScrollbackEvent) {
118        let cost = event.byte_cost();
119        let mut sb = self.scrollback.lock().unwrap();
120        let mut bytes = self.scrollback_bytes.lock().unwrap();
121        *bytes += cost;
122        sb.push_back(event);
123        while *bytes > self.scrollback_limit {
124            if let Some(old) = sb.pop_front() {
125                *bytes -= old.byte_cost();
126            } else {
127                break;
128            }
129        }
130    }
131
132    /// Attach a client: increment the counter, subscribe to live output, and
133    /// return the scrollback event log. The subscription and snapshot are taken
134    /// under the same lock so no output is lost.
135    pub fn attach(&self) -> AttachResult {
136        self.clients.fetch_add(1, Ordering::Relaxed);
137        *self.detached_at.lock().unwrap() = None;
138        let sb = self.scrollback.lock().unwrap();
139        let rx = self.terminal.subscribe();
140        let ws_rx = self.window_size.subscribe();
141        let events: Vec<ScrollbackEvent> = sb.iter().cloned().collect();
142        (events, rx, ws_rx)
143    }
144
145    /// Update the current PTY window size (broadcast to viewers) and record
146    /// the resize in the scrollback log so replay clients see it too.
147    pub fn set_window_size(&self, rows: u16, cols: u16) {
148        let _ = self.window_size.send((rows, cols));
149        self.push_scrollback(ScrollbackEvent::WindowSize(rows, cols));
150    }
151
152    /// Detach a client. When the last client detaches, the orphan timer starts.
153    pub fn detach(&self) {
154        if self.clients.fetch_sub(1, Ordering::Relaxed) == 1 {
155            *self.detached_at.lock().unwrap() = Some(Instant::now());
156        }
157    }
158
159    /// Number of currently attached clients.
160    pub fn client_count(&self) -> usize {
161        self.clients.load(Ordering::Relaxed)
162    }
163
164    fn is_orphaned(&self) -> bool {
165        self.clients.load(Ordering::Relaxed) == 0
166            && self
167                .detached_at
168                .lock()
169                .unwrap()
170                .is_some_and(|t| t.elapsed() >= self.orphan_timeout)
171    }
172}
173
174/// Thread-safe session registry keyed by UUID.
175pub struct SessionStore {
176    sessions: RwLock<HashMap<String, Arc<Session>>>,
177}
178
179impl SessionStore {
180    /// Create an empty session store.
181    pub fn new() -> Arc<Self> {
182        Arc::new(Self {
183            sessions: RwLock::new(HashMap::new()),
184        })
185    }
186
187    /// Register a session under a new UUID and spawn a reaper task that
188    /// removes it when the shell exits with no clients or the orphan timeout
189    /// elapses.
190    pub fn insert(self: &Arc<Self>, session: Arc<Session>) -> String {
191        let id = uuid::Uuid::new_v4().to_string();
192        self.sessions
193            .write()
194            .unwrap()
195            .insert(id.clone(), session.clone());
196
197        // Reaper task: periodically checks for removal conditions
198        let store = Arc::downgrade(self);
199        let sid = id.clone();
200        let closed_rx = session.terminal.closed();
201        tokio::spawn(async move {
202            loop {
203                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
204                let Some(store) = store.upgrade() else { return };
205                let should_remove = {
206                    let sessions = store.sessions.read().unwrap();
207                    match sessions.get(&sid) {
208                        Some(s) => {
209                            s.is_orphaned()
210                                || (*closed_rx.borrow() && s.clients.load(Ordering::Relaxed) == 0)
211                        }
212                        None => return,
213                    }
214                };
215                if should_remove {
216                    store.sessions.write().unwrap().remove(&sid);
217                    tracing::info!("removed session {sid}");
218                    return;
219                }
220            }
221        });
222
223        id
224    }
225
226    /// Look up a session by ID.
227    pub fn get(&self, id: &str) -> Option<Arc<Session>> {
228        self.sessions.read().unwrap().get(id).cloned()
229    }
230
231    /// Returns `true` if there are no active sessions.
232    pub fn is_empty(&self) -> bool {
233        self.sessions.read().unwrap().is_empty()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    const TEST_SCROLLBACK_LIMIT: usize = 256 * 1024;
242
243    fn spawn_session() -> Arc<Session> {
244        let (terminal, output_rx) = Terminal::spawn("/bin/sh", None).expect("spawn /bin/sh");
245        Session::new(
246            terminal,
247            output_rx,
248            TEST_SCROLLBACK_LIMIT,
249            DEFAULT_ORPHAN_TIMEOUT,
250        )
251    }
252
253    #[tokio::test]
254    async fn test_attach_detach_clients() {
255        let session = spawn_session();
256
257        let (_sb1, _rx1, _ws1) = session.attach();
258        assert_eq!(session.clients.load(Ordering::Relaxed), 1);
259
260        let (_sb2, _rx2, _ws2) = session.attach();
261        assert_eq!(session.clients.load(Ordering::Relaxed), 2);
262
263        session.detach();
264        assert_eq!(session.clients.load(Ordering::Relaxed), 1);
265    }
266
267    #[tokio::test]
268    async fn test_not_orphaned_with_clients() {
269        let session = spawn_session();
270        let (_sb, _rx, _ws) = session.attach();
271        assert!(!session.is_orphaned());
272    }
273
274    #[tokio::test]
275    async fn test_not_orphaned_immediately_after_detach() {
276        let session = spawn_session();
277        let (_sb, _rx, _ws) = session.attach();
278        session.detach();
279        assert!(!session.is_orphaned());
280    }
281
282    #[tokio::test]
283    async fn test_orphaned_after_timeout() {
284        let session = spawn_session();
285        let (_sb, _rx, _ws) = session.attach();
286        session.detach();
287        *session.detached_at.lock().unwrap() =
288            Some(Instant::now() - session.orphan_timeout - std::time::Duration::from_secs(1));
289        assert!(session.is_orphaned());
290    }
291
292    #[tokio::test]
293    async fn test_scrollback_captures_output() {
294        let session = spawn_session();
295
296        session
297            .terminal
298            .write(b"echo scrollback_test_marker\n".to_vec())
299            .await
300            .unwrap();
301
302        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
303
304        let (events, _rx, _ws) = session.attach();
305        let has_marker = events.iter().any(|e| match e {
306            ScrollbackEvent::Output(data) => {
307                String::from_utf8_lossy(data).contains("scrollback_test_marker")
308            }
309            _ => false,
310        });
311        assert!(has_marker, "scrollback should contain Output with marker");
312    }
313
314    #[tokio::test]
315    async fn test_session_store_insert_and_get() {
316        let store = SessionStore::new();
317        let session = spawn_session();
318        let id = store.insert(session);
319
320        assert!(store.get(&id).is_some());
321        assert!(store.get("nonexistent").is_none());
322    }
323
324    #[tokio::test]
325    async fn test_scrollback_eviction_removes_whole_events() {
326        let (terminal, output_rx) = Terminal::spawn("/bin/sh", None).expect("spawn");
327        let session = Session::new(terminal, output_rx, 10, DEFAULT_ORPHAN_TIMEOUT);
328
329        session.push_scrollback(ScrollbackEvent::Output(b"aaaaa".to_vec())); // 5
330        session.push_scrollback(ScrollbackEvent::Output(b"bbbbb".to_vec())); // 5, total 10
331        session.push_scrollback(ScrollbackEvent::Output(b"ccc".to_vec())); // 3, total 13 → evict
332
333        let sb = session.scrollback.lock().unwrap();
334        let bytes = *session.scrollback_bytes.lock().unwrap();
335        assert!(bytes <= 10, "bytes {bytes} should be within limit");
336        assert!(
337            sb.iter().all(|e| matches!(e, ScrollbackEvent::Output(_))),
338            "all events should be Output"
339        );
340        assert_ne!(
341            sb.front(),
342            Some(&ScrollbackEvent::Output(b"aaaaa".to_vec())),
343            "oldest event should have been evicted"
344        );
345    }
346
347    #[tokio::test]
348    async fn test_set_window_size_records_event() {
349        let (terminal, output_rx) = Terminal::spawn("/bin/sh", None).expect("spawn");
350        let session = Session::new(
351            terminal,
352            output_rx,
353            TEST_SCROLLBACK_LIMIT,
354            DEFAULT_ORPHAN_TIMEOUT,
355        );
356
357        session.set_window_size(40, 120);
358
359        let sb = session.scrollback.lock().unwrap();
360        let has_ws = sb
361            .iter()
362            .any(|e| matches!(e, ScrollbackEvent::WindowSize(40, 120)));
363        assert!(has_ws, "scrollback should contain WindowSize(40, 120)");
364    }
365}