1use 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
21pub const DEFAULT_ORPHAN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
23
24pub type AttachResult = (
27 Vec<ScrollbackEvent>,
28 broadcast::Receiver<Vec<u8>>,
29 watch::Receiver<(u16, u16)>,
30);
31
32#[derive(Clone, Debug, PartialEq)]
37pub enum ScrollbackEvent {
38 Output(Vec<u8>),
40 WindowSize(u16, u16),
42}
43
44impl ScrollbackEvent {
45 fn byte_cost(&self) -> usize {
47 match self {
48 Self::Output(data) => data.len(),
49 Self::WindowSize(_, _) => 4,
50 }
51 }
52}
53
54pub 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 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 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 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 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 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 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 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
174pub struct SessionStore {
176 sessions: RwLock<HashMap<String, Arc<Session>>>,
177}
178
179impl SessionStore {
180 pub fn new() -> Arc<Self> {
182 Arc::new(Self {
183 sessions: RwLock::new(HashMap::new()),
184 })
185 }
186
187 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 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 pub fn get(&self, id: &str) -> Option<Arc<Session>> {
228 self.sessions.read().unwrap().get(id).cloned()
229 }
230
231 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())); session.push_scrollback(ScrollbackEvent::Output(b"bbbbb".to_vec())); session.push_scrollback(ScrollbackEvent::Output(b"ccc".to_vec())); 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}