1use std::collections::HashMap;
2use std::collections::VecDeque;
3use std::fs::{self, File, OpenOptions};
4use std::io::BufReader;
5use std::io::BufWriter;
6use std::os::unix::io::AsRawFd;
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::sync::Mutex;
10use std::sync::RwLock;
11use std::time::Duration;
12use std::time::Instant;
13
14use tracing::warn;
15
16use chrono::DateTime;
17use chrono::Utc;
18use serde::Deserialize;
19use serde::Serialize;
20use uuid::Uuid;
21
22use crate::common::mutex_lock_or_recover;
23use crate::common::rwlock_read_or_recover;
24use crate::common::rwlock_write_or_recover;
25use crate::core::Element;
26use crate::terminal::CursorPosition;
27use crate::terminal::PtyHandle;
28use crate::terminal::key_to_escape_sequence;
29
30use super::pty_session::PtySession;
31use crate::daemon::terminal_state::TerminalState;
32
33pub use crate::daemon::domain::session_types::ErrorEntry;
34pub use crate::daemon::domain::session_types::RecordingFrame;
35pub use crate::daemon::domain::session_types::RecordingStatus;
36pub use crate::daemon::domain::session_types::SessionId;
37pub use crate::daemon::domain::session_types::SessionInfo;
38pub use crate::daemon::domain::session_types::TraceEntry;
39pub use crate::daemon::error::SessionError;
40
41const MAX_RECORDING_FRAMES: usize = 1000;
42const MAX_TRACE_ENTRIES: usize = 500;
43const MAX_ERROR_ENTRIES: usize = 500;
44
45pub fn generate_session_id() -> SessionId {
50 SessionId::new(Uuid::new_v4().to_string()[..8].to_string())
51}
52
53fn get_last_n<T: Clone>(queue: &VecDeque<T>, count: usize) -> Vec<T> {
54 let start = queue.len().saturating_sub(count);
55 queue.iter().skip(start).cloned().collect()
56}
57
58fn push_bounded<T>(queue: &mut VecDeque<T>, item: T, max_size: usize) {
59 if queue.len() >= max_size {
60 queue.pop_front();
61 }
62 queue.push_back(item);
63}
64
65struct RecordingState {
66 is_recording: bool,
67 start_time: Instant,
68 frames: VecDeque<RecordingFrame>,
69}
70
71impl RecordingState {
72 fn new() -> Self {
73 Self {
74 is_recording: false,
75 start_time: Instant::now(),
76 frames: VecDeque::new(),
77 }
78 }
79}
80
81struct TraceState {
82 is_tracing: bool,
83 start_time: Instant,
84 entries: VecDeque<TraceEntry>,
85}
86
87impl TraceState {
88 fn new() -> Self {
89 Self {
90 is_tracing: false,
91 start_time: Instant::now(),
92 entries: VecDeque::new(),
93 }
94 }
95}
96
97struct ErrorState {
98 entries: VecDeque<ErrorEntry>,
99}
100
101impl ErrorState {
102 fn new() -> Self {
103 Self {
104 entries: VecDeque::new(),
105 }
106 }
107}
108
109#[derive(Clone, Copy)]
110enum ModifierKey {
111 Ctrl,
112 Alt,
113 Shift,
114 Meta,
115}
116
117impl ModifierKey {
118 fn from_str(key: &str) -> Option<Self> {
119 match key.to_lowercase().as_str() {
120 "ctrl" | "control" => Some(Self::Ctrl),
121 "alt" => Some(Self::Alt),
122 "shift" => Some(Self::Shift),
123 "meta" | "cmd" | "command" | "win" | "super" => Some(Self::Meta),
124 _ => None,
125 }
126 }
127}
128
129#[derive(Default)]
130struct ModifierState {
131 ctrl: bool,
132 alt: bool,
133 shift: bool,
134 meta: bool,
135}
136
137impl ModifierState {
138 fn set(&mut self, key: ModifierKey, value: bool) {
139 match key {
140 ModifierKey::Ctrl => self.ctrl = value,
141 ModifierKey::Alt => self.alt = value,
142 ModifierKey::Shift => self.shift = value,
143 ModifierKey::Meta => self.meta = value,
144 }
145 }
146}
147
148pub struct Session {
149 pub id: SessionId,
150 pub command: String,
151 pub created_at: DateTime<Utc>,
152 pty: PtySession,
153 terminal: TerminalState,
154 recording: RecordingState,
155 trace: TraceState,
156 held_modifiers: ModifierState,
157 errors: ErrorState,
158}
159
160impl Session {
161 fn new(id: SessionId, command: String, pty: PtyHandle, cols: u16, rows: u16) -> Self {
162 Self {
163 id,
164 command,
165 created_at: Utc::now(),
166 pty: PtySession::new(pty),
167 terminal: TerminalState::new(cols, rows),
168 recording: RecordingState::new(),
169 trace: TraceState::new(),
170 held_modifiers: ModifierState::default(),
171 errors: ErrorState::new(),
172 }
173 }
174
175 pub fn pid(&self) -> Option<u32> {
176 self.pty.pid()
177 }
178
179 pub fn is_running(&mut self) -> bool {
180 self.pty.is_running()
181 }
182
183 pub fn size(&self) -> (u16, u16) {
184 self.terminal.size()
185 }
186
187 pub fn update(&mut self) -> Result<(), SessionError> {
188 let mut buf = [0u8; 4096];
189
190 loop {
191 match self.pty.try_read(&mut buf, 10) {
192 Ok(0) => break,
193 Ok(n) => {
194 self.terminal.process(&buf[..n]);
195 }
196 Err(e) => {
197 let err_str = e.to_string();
198 if err_str.contains("Resource temporarily unavailable")
199 || err_str.contains("EAGAIN")
200 || err_str.contains("EWOULDBLOCK")
201 {
202 break;
203 }
204
205 return Err(e);
206 }
207 }
208 }
209
210 Ok(())
211 }
212
213 pub fn screen_text(&self) -> String {
214 self.terminal.screen_text()
215 }
216
217 pub fn cursor(&self) -> CursorPosition {
218 self.terminal.cursor()
219 }
220
221 pub fn detect_elements(&mut self) -> &[Element] {
222 let cursor = self.terminal.cursor();
223 self.terminal.detect_elements(&cursor)
224 }
225
226 pub fn cached_elements(&self) -> &[Element] {
227 self.terminal.cached_elements()
228 }
229
230 pub fn find_element(&self, element_ref: &str) -> Option<&Element> {
231 self.terminal.find_element(element_ref)
232 }
233
234 pub fn keystroke(&self, key: &str) -> Result<(), SessionError> {
235 let seq =
236 key_to_escape_sequence(key).ok_or_else(|| SessionError::InvalidKey(key.to_string()))?;
237 self.pty.write(&seq)?;
238 Ok(())
239 }
240
241 pub fn keydown(&mut self, key: &str) -> Result<(), SessionError> {
242 let modifier = ModifierKey::from_str(key).ok_or_else(|| {
243 SessionError::InvalidKey(format!(
244 "{}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be held",
245 key
246 ))
247 })?;
248 self.held_modifiers.set(modifier, true);
249 Ok(())
250 }
251
252 pub fn keyup(&mut self, key: &str) -> Result<(), SessionError> {
253 let modifier = ModifierKey::from_str(key).ok_or_else(|| {
254 SessionError::InvalidKey(format!(
255 "{}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be released",
256 key
257 ))
258 })?;
259 self.held_modifiers.set(modifier, false);
260 Ok(())
261 }
262
263 pub fn type_text(&self, text: &str) -> Result<(), SessionError> {
264 self.pty.write_str(text)?;
265 Ok(())
266 }
267
268 pub fn click(&mut self, element_ref: &str) -> Result<(), SessionError> {
269 self.update()?;
270 self.detect_elements();
271
272 let element = self
273 .find_element(element_ref)
274 .ok_or_else(|| SessionError::ElementNotFound(element_ref.to_string()))?;
275
276 match element.element_type.as_str() {
277 "checkbox" | "radio" => {
278 self.pty.write(b" ")?;
279 }
280 _ => {
281 self.pty.write(b"\r")?;
282 }
283 }
284
285 Ok(())
286 }
287
288 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<(), SessionError> {
289 self.pty.resize(cols, rows)?;
290 self.terminal.resize(cols, rows);
291 Ok(())
292 }
293
294 pub fn kill(&mut self) -> Result<(), SessionError> {
295 self.pty.kill()?;
296 Ok(())
297 }
298
299 pub fn pty_write(&self, data: &[u8]) -> Result<(), SessionError> {
300 self.pty.write(data)?;
301 Ok(())
302 }
303
304 pub fn pty_try_read(&self, buf: &mut [u8], timeout_ms: i32) -> Result<usize, SessionError> {
305 self.pty.try_read(buf, timeout_ms)
306 }
307
308 pub fn start_recording(&mut self) {
309 self.recording.is_recording = true;
310 self.recording.start_time = Instant::now();
311 self.recording.frames.clear();
312
313 let screen = self.terminal.screen_text();
314 push_bounded(
315 &mut self.recording.frames,
316 RecordingFrame {
317 timestamp_ms: 0,
318 screen,
319 },
320 MAX_RECORDING_FRAMES,
321 );
322 }
323
324 pub fn stop_recording(&mut self) -> Vec<RecordingFrame> {
325 self.recording.is_recording = false;
326 std::mem::take(&mut self.recording.frames)
327 .into_iter()
328 .collect()
329 }
330
331 pub fn add_recording_frame(&mut self, screen: String) {
332 if !self.recording.is_recording {
333 return;
334 }
335 let timestamp_ms = self.recording.start_time.elapsed().as_millis() as u64;
336 push_bounded(
337 &mut self.recording.frames,
338 RecordingFrame {
339 timestamp_ms,
340 screen,
341 },
342 MAX_RECORDING_FRAMES,
343 );
344 }
345
346 pub fn recording_status(&self) -> RecordingStatus {
347 RecordingStatus {
348 is_recording: self.recording.is_recording,
349 frame_count: self.recording.frames.len(),
350 duration_ms: if self.recording.is_recording {
351 self.recording.start_time.elapsed().as_millis() as u64
352 } else {
353 0
354 },
355 }
356 }
357
358 pub fn start_trace(&mut self) {
359 self.trace.is_tracing = true;
360 self.trace.start_time = Instant::now();
361 self.trace.entries.clear();
362 }
363
364 pub fn stop_trace(&mut self) {
365 self.trace.is_tracing = false;
366 }
367
368 pub fn is_tracing(&self) -> bool {
369 self.trace.is_tracing
370 }
371
372 pub fn get_trace_entries(&self, count: usize) -> Vec<TraceEntry> {
373 get_last_n(&self.trace.entries, count)
374 }
375
376 pub fn add_trace_entry(&mut self, action: String, details: Option<String>) {
377 if !self.trace.is_tracing {
378 return;
379 }
380 let timestamp_ms = self.trace.start_time.elapsed().as_millis() as u64;
381 push_bounded(
382 &mut self.trace.entries,
383 TraceEntry {
384 timestamp_ms,
385 action,
386 details,
387 },
388 MAX_TRACE_ENTRIES,
389 );
390 }
391
392 pub fn get_errors(&self, count: usize) -> Vec<ErrorEntry> {
393 get_last_n(&self.errors.entries, count)
394 }
395
396 pub fn add_error(&mut self, message: String, source: String) {
397 let timestamp = Utc::now().to_rfc3339();
398 push_bounded(
399 &mut self.errors.entries,
400 ErrorEntry {
401 timestamp,
402 message,
403 source,
404 },
405 MAX_ERROR_ENTRIES,
406 );
407 }
408
409 pub fn error_count(&self) -> usize {
410 self.errors.entries.len()
411 }
412
413 pub fn clear_errors(&mut self) {
414 self.errors.entries.clear();
415 }
416
417 pub fn clear_console(&mut self) {
418 self.terminal.clear();
419 }
420
421 pub fn analyze_screen(&self) -> Vec<crate::core::Component> {
422 let cursor = self.terminal.cursor();
423 self.terminal.analyze_screen(&cursor)
424 }
425}
426
427impl crate::daemon::repository::SessionOps for Session {
428 fn update(&mut self) -> Result<(), SessionError> {
429 Session::update(self)
430 }
431
432 fn screen_text(&self) -> String {
433 Session::screen_text(self)
434 }
435
436 fn detect_elements(&mut self) -> &[Element] {
437 Session::detect_elements(self)
438 }
439
440 fn find_element(&self, element_ref: &str) -> Option<&Element> {
441 Session::find_element(self, element_ref)
442 }
443
444 fn pty_write(&mut self, data: &[u8]) -> Result<(), SessionError> {
445 Session::pty_write(self, data)
446 }
447
448 fn analyze_screen(&self) -> Vec<crate::core::Component> {
449 Session::analyze_screen(self)
450 }
451}
452
453pub struct SessionManager {
457 sessions: RwLock<HashMap<SessionId, Arc<Mutex<Session>>>>,
458 active_session: RwLock<Option<SessionId>>,
459 persistence: SessionPersistence,
460 max_sessions: usize,
461}
462
463pub const DEFAULT_MAX_SESSIONS: usize = 16;
464
465impl Default for SessionManager {
466 fn default() -> Self {
467 Self::new()
468 }
469}
470
471impl SessionManager {
472 pub fn new() -> Self {
473 Self::with_max_sessions(DEFAULT_MAX_SESSIONS)
474 }
475
476 pub fn with_max_sessions(max_sessions: usize) -> Self {
477 let persistence = SessionPersistence::new();
478 if let Err(e) = persistence.cleanup_stale_sessions() {
479 warn!(error = %e, "Failed to cleanup stale sessions");
480 }
481
482 Self {
483 sessions: RwLock::new(HashMap::new()),
484 active_session: RwLock::new(None),
485 persistence,
486 max_sessions,
487 }
488 }
489
490 #[allow(clippy::too_many_arguments)]
491 pub fn spawn(
492 &self,
493 command: &str,
494 args: &[String],
495 cwd: Option<&str>,
496 env: Option<&HashMap<String, String>>,
497 session_id: Option<String>,
498 cols: u16,
499 rows: u16,
500 ) -> Result<(SessionId, u32), SessionError> {
501 {
502 let sessions = rwlock_read_or_recover(&self.sessions);
503 if sessions.len() >= self.max_sessions {
504 return Err(SessionError::LimitReached(self.max_sessions));
505 }
506 }
507
508 let id = session_id
509 .map(SessionId::new)
510 .unwrap_or_else(generate_session_id);
511
512 let pty = PtyHandle::spawn(command, args, cwd, env, cols, rows)?;
513 let pid = pty.pid().unwrap_or(0);
514
515 let session = Session::new(id.clone(), command.to_string(), pty, cols, rows);
516 let session = Arc::new(Mutex::new(session));
517
518 let created_at = Utc::now().to_rfc3339();
519 let persisted = PersistedSession {
520 id: id.to_string(),
521 command: command.to_string(),
522 pid,
523 created_at,
524 cols,
525 rows,
526 };
527
528 {
529 let mut sessions = rwlock_write_or_recover(&self.sessions);
530 sessions.insert(id.clone(), session);
531 }
532
533 {
534 let mut active = rwlock_write_or_recover(&self.active_session);
535 *active = Some(id.clone());
536 }
537
538 if let Err(e) = self.persistence.add_session(persisted) {
539 warn!(error = %e, "Failed to persist session metadata");
540 }
541
542 Ok((id, pid))
543 }
544
545 pub fn get(&self, session_id: &str) -> Result<Arc<Mutex<Session>>, SessionError> {
546 let sessions = rwlock_read_or_recover(&self.sessions);
547 let id = SessionId::new(session_id);
548 sessions
549 .get(&id)
550 .cloned()
551 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))
552 }
553
554 pub fn active(&self) -> Result<Arc<Mutex<Session>>, SessionError> {
555 let active_id = {
556 let active = rwlock_read_or_recover(&self.active_session);
557 active.clone()
558 };
559
560 match active_id {
561 Some(id) => self.get(id.as_str()),
562 None => Err(SessionError::NoActiveSession),
563 }
564 }
565
566 pub fn resolve(&self, session_id: Option<&str>) -> Result<Arc<Mutex<Session>>, SessionError> {
567 match session_id {
568 Some(id) => self.get(id),
569 None => self.active(),
570 }
571 }
572
573 pub fn set_active(&self, session_id: &str) -> Result<(), SessionError> {
574 let id = SessionId::new(session_id);
575 let sessions = rwlock_read_or_recover(&self.sessions);
576 if !sessions.contains_key(&id) {
577 return Err(SessionError::NotFound(session_id.to_string()));
578 }
579 let mut active = rwlock_write_or_recover(&self.active_session);
580 *active = Some(id);
581 Ok(())
582 }
583
584 pub fn list(&self) -> Vec<SessionInfo> {
585 use super::lock_helpers::acquire_session_lock;
586
587 let session_refs: Vec<(SessionId, Arc<Mutex<Session>>)> = {
588 let sessions = rwlock_read_or_recover(&self.sessions);
589 sessions
590 .iter()
591 .map(|(id, session)| (id.clone(), Arc::clone(session)))
592 .collect()
593 };
594
595 session_refs
596 .into_iter()
597 .map(
598 |(id, session)| match acquire_session_lock(&session, Duration::from_millis(100)) {
599 Some(mut sess) => SessionInfo {
600 id: id.clone(),
601 command: sess.command.clone(),
602 pid: sess.pid().unwrap_or(0),
603 running: sess.is_running(),
604 created_at: sess.created_at.to_rfc3339(),
605 size: sess.size(),
606 },
607 None => SessionInfo {
608 id: id.clone(),
609 command: "(locked)".to_string(),
610 pid: 0,
611 running: true,
612 created_at: "".to_string(),
613 size: (80, 24),
614 },
615 },
616 )
617 .collect()
618 }
619
620 pub fn kill(&self, session_id: &str) -> Result<(), SessionError> {
621 let id = SessionId::new(session_id);
622
623 let session = {
624 let mut sessions = rwlock_write_or_recover(&self.sessions);
625 let mut active = rwlock_write_or_recover(&self.active_session);
626
627 let session = sessions
628 .remove(&id)
629 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
630
631 if active.as_ref() == Some(&id) {
632 *active = None;
633 }
634
635 session
636 };
637
638 {
639 let mut sess = mutex_lock_or_recover(&session);
640 sess.kill()?;
641 }
642
643 if let Err(e) = self.persistence.remove_session(session_id) {
644 warn!(session_id = session_id, error = %e, "Failed to remove session from persistence");
645 }
646
647 Ok(())
648 }
649
650 pub fn session_count(&self) -> usize {
651 rwlock_read_or_recover(&self.sessions).len()
652 }
653
654 pub fn active_session_id(&self) -> Option<SessionId> {
655 rwlock_read_or_recover(&self.active_session).clone()
656 }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct PersistedSession {
665 pub id: String,
666 pub command: String,
667 pub pid: u32,
668 pub created_at: String,
669 pub cols: u16,
670 pub rows: u16,
671}
672
673pub struct SessionPersistence {
674 path: PathBuf,
675 lock_path: PathBuf,
676}
677
678impl SessionPersistence {
679 pub fn new() -> Self {
680 let path = Self::sessions_file_path();
681 let lock_path = path.with_extension("json.lock");
682 Self { path, lock_path }
683 }
684
685 fn sessions_file_path() -> PathBuf {
686 let home = std::env::var("HOME")
687 .map(PathBuf::from)
688 .unwrap_or_else(|_| PathBuf::from("/tmp"));
689 let dir = home.join(".agent-tui");
690 dir.join("sessions.json")
691 }
692
693 fn io_to_persistence(operation: &str, e: std::io::Error) -> SessionError {
694 SessionError::Persistence {
695 operation: operation.to_string(),
696 reason: e.to_string(),
697 }
698 }
699
700 fn ensure_dir(&self) -> Result<(), SessionError> {
701 if let Some(parent) = self.path.parent() {
702 fs::create_dir_all(parent).map_err(|e| {
703 Self::io_to_persistence(
704 "create_dir",
705 std::io::Error::new(
706 e.kind(),
707 format!("Failed to create directory '{}': {}", parent.display(), e),
708 ),
709 )
710 })?;
711 }
712 Ok(())
713 }
714
715 fn acquire_lock(&self) -> Result<File, SessionError> {
716 const PERSISTENCE_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
717
718 self.ensure_dir()?;
719 let lock_file = OpenOptions::new()
720 .write(true)
721 .create(true)
722 .truncate(false)
723 .open(&self.lock_path)
724 .map_err(|e| Self::io_to_persistence("open_lock", e))?;
725
726 let fd = lock_file.as_raw_fd();
727 let start = Instant::now();
728 let mut backoff = Duration::from_millis(1);
729
730 loop {
731 let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
732 if result == 0 {
733 return Ok(lock_file);
734 }
735
736 let err = std::io::Error::last_os_error();
737 if err.raw_os_error() != Some(libc::EWOULDBLOCK)
738 && err.raw_os_error() != Some(libc::EAGAIN)
739 {
740 return Err(Self::io_to_persistence("flock", err));
741 }
742
743 if start.elapsed() > PERSISTENCE_LOCK_TIMEOUT {
744 return Err(SessionError::Persistence {
745 operation: "acquire_lock".to_string(),
746 reason: "lock acquisition timed out after 5 seconds".to_string(),
747 });
748 }
749
750 std::thread::sleep(backoff);
751 backoff = (backoff * 2).min(Duration::from_millis(100));
752 }
753 }
754
755 fn load_unlocked(&self) -> Vec<PersistedSession> {
756 if !self.path.exists() {
757 return Vec::new();
758 }
759
760 match File::open(&self.path) {
761 Ok(file) => {
762 let reader = BufReader::new(file);
763 match serde_json::from_reader(reader) {
764 Ok(sessions) => sessions,
765 Err(e) => {
766 warn!(
767 path = %self.path.display(),
768 error = %e,
769 "Sessions file corrupted, starting with empty session list"
770 );
771 Vec::new()
772 }
773 }
774 }
775 Err(e) => {
776 warn!(
777 path = %self.path.display(),
778 error = %e,
779 "Failed to open sessions file"
780 );
781 Vec::new()
782 }
783 }
784 }
785
786 fn save_unlocked(&self, sessions: &[PersistedSession]) -> Result<(), SessionError> {
787 let temp_path = self.path.with_extension("json.tmp");
788
789 let file = File::create(&temp_path).map_err(|e| SessionError::Persistence {
790 operation: "create_temp".to_string(),
791 reason: format!(
792 "Failed to create temp file '{}': {}",
793 temp_path.display(),
794 e
795 ),
796 })?;
797 let writer = BufWriter::new(file);
798 serde_json::to_writer_pretty(writer, sessions).map_err(|e| SessionError::Persistence {
799 operation: "write_json".to_string(),
800 reason: format!(
801 "Failed to write sessions to '{}': {}",
802 temp_path.display(),
803 e
804 ),
805 })?;
806
807 fs::rename(&temp_path, &self.path).map_err(|e| SessionError::Persistence {
808 operation: "rename".to_string(),
809 reason: format!(
810 "Failed to rename '{}' to '{}': {}",
811 temp_path.display(),
812 self.path.display(),
813 e
814 ),
815 })?;
816
817 Ok(())
818 }
819
820 pub fn load(&self) -> Vec<PersistedSession> {
821 match self.acquire_lock() {
822 Ok(_lock) => self.load_unlocked(),
823 Err(e) => {
824 warn!(error = %e, "Failed to acquire lock for loading sessions");
825 self.load_unlocked()
826 }
827 }
828 }
829
830 pub fn save(&self, sessions: &[PersistedSession]) -> Result<(), SessionError> {
831 let _lock = self.acquire_lock()?;
832 self.save_unlocked(sessions)
833 }
834
835 pub fn add_session(&self, session: PersistedSession) -> Result<(), SessionError> {
836 let _lock = self.acquire_lock()?;
837 let mut sessions = self.load_unlocked();
838
839 sessions.retain(|s| s.id != session.id);
840 sessions.push(session);
841
842 self.save_unlocked(&sessions)
843 }
844
845 pub fn remove_session(&self, session_id: &str) -> Result<(), SessionError> {
846 let _lock = self.acquire_lock()?;
847 let mut sessions = self.load_unlocked();
848 sessions.retain(|s| s.id.as_str() != session_id);
849 self.save_unlocked(&sessions)
850 }
851
852 pub fn cleanup_stale_sessions(&self) -> Result<usize, SessionError> {
853 let _lock = self.acquire_lock()?;
854 let sessions = self.load_unlocked();
855 let mut cleaned = 0;
856
857 let active_sessions: Vec<PersistedSession> = sessions
858 .into_iter()
859 .filter(|s| {
860 let running = is_process_running(s.pid);
861 if !running {
862 cleaned += 1;
863 }
864 running
865 })
866 .collect();
867
868 self.save_unlocked(&active_sessions)?;
869 Ok(cleaned)
870 }
871}
872
873impl Default for SessionPersistence {
874 fn default() -> Self {
875 Self::new()
876 }
877}
878
879fn is_process_running(pid: u32) -> bool {
880 unsafe { libc::kill(pid as i32, 0) == 0 }
881}
882
883impl From<&SessionInfo> for PersistedSession {
884 fn from(info: &SessionInfo) -> Self {
885 PersistedSession {
886 id: info.id.to_string(),
887 command: info.command.clone(),
888 pid: info.pid,
889 created_at: info.created_at.clone(),
890 cols: info.size.0,
891 rows: info.size.1,
892 }
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 #[test]
901 fn test_persisted_session_serialization() {
902 let session = PersistedSession {
903 id: "test123".to_string(),
904 command: "bash".to_string(),
905 pid: 12345,
906 created_at: "2024-01-01T00:00:00Z".to_string(),
907 cols: 80,
908 rows: 24,
909 };
910
911 let json = serde_json::to_string(&session).unwrap();
912 let parsed: PersistedSession = serde_json::from_str(&json).unwrap();
913
914 assert_eq!(parsed.id, session.id);
915 assert_eq!(parsed.command, session.command);
916 assert_eq!(parsed.pid, session.pid);
917 }
918
919 #[test]
920 fn test_is_process_running() {
921 let current_pid = std::process::id();
922 assert!(is_process_running(current_pid));
923
924 assert!(!is_process_running(999999999));
925 }
926}