1use std::collections::HashMap;
14use std::sync::atomic::{AtomicU32, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use crate::tools::strip_ansi;
19use crate::{Result, RuntimeError};
20
21use super::config::ShellConfig;
22use super::pty::PtyHandle;
23use super::readiness::{ReadinessDetector, ReadinessResult, ReadinessStrategy};
24
25pub struct SessionManager {
31 sessions: Mutex<HashMap<String, ShellSession>>,
32 config: ShellConfig,
33 next_id: AtomicU32,
34}
35
36struct ShellSession {
38 pty: PtyHandle,
39 detector: ReadinessDetector,
40 created_at: Instant,
41 last_active: Instant,
42 idle_timeout: Duration,
43 status: SessionStatus,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub enum SessionStatus {
49 Active,
50 Exited(Option<i32>),
51 Closed,
52}
53
54pub struct SessionOpts {
56 pub command: Option<String>,
57 pub working_directory: Option<String>,
58 pub env: HashMap<String, String>,
59 pub rows: Option<u16>,
60 pub cols: Option<u16>,
61 pub readiness_timeout_ms: Option<u64>,
62 pub idle_timeout: Option<u64>,
63}
64
65#[derive(Debug)]
67pub struct SendResult {
68 pub output: String,
69 pub status: String,
71}
72
73pub struct ShellSessionInfo {
75 pub id: String,
76 pub status: SessionStatus,
77 pub created_at: Instant,
78 pub last_active: Instant,
79}
80
81fn normalize_output(raw: &str) -> String {
87 strip_ansi(raw).replace("\r\n", "\n").replace('\r', "")
88}
89
90fn process_input_escapes(input: &str) -> String {
96 let mut result = String::with_capacity(input.len());
97 let mut chars = input.chars().peekable();
98
99 while let Some(ch) = chars.next() {
100 if ch == '\\' {
101 match chars.peek() {
102 Some('n') => {
103 chars.next();
104 result.push('\n');
105 }
106 Some('r') => {
107 chars.next();
108 result.push('\r');
109 }
110 Some('t') => {
111 chars.next();
112 result.push('\t');
113 }
114 Some('\\') => {
115 chars.next();
116 result.push('\\');
117 }
118 Some('a') => {
119 chars.next();
120 result.push('\x07');
121 } Some('b') => {
123 chars.next();
124 result.push('\x08');
125 } Some('0') => {
127 chars.next();
128 result.push('\0');
129 } Some('e') => {
131 chars.next();
132 tracing::warn!("blocked \\e escape sequence (raw ESC) in shell input");
133 }
134 Some('x') => {
135 chars.next(); let mut hex = String::new();
137 for _ in 0..2 {
138 if let Some(&c) = chars.peek() {
139 if c.is_ascii_hexdigit() {
140 hex.push(c);
141 chars.next();
142 } else {
143 break;
144 }
145 }
146 }
147 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
148 if byte == 0x1b {
149 tracing::warn!(
151 "blocked \\x1b escape sequence (raw ESC) in shell input"
152 );
153 } else if byte >= 0x80 {
154 tracing::warn!("blocked \\x{hex:} high byte (>= 0x80) in shell input");
156 } else {
157 result.push(byte as char);
159 }
160 } else {
161 result.push('\\');
163 result.push('x');
164 result.push_str(&hex);
165 }
166 }
167 _ => {
168 result.push(ch);
170 }
171 }
172 } else {
173 result.push(ch);
174 }
175 }
176 result
177}
178
179fn status_string(status: &SessionStatus) -> String {
181 match status {
182 SessionStatus::Active => "active".into(),
183 SessionStatus::Exited(Some(code)) => format!("exited({code})"),
184 SessionStatus::Exited(None) => "exited".into(),
185 SessionStatus::Closed => "closed".into(),
186 }
187}
188
189async fn wait_for_output(
194 pty: &mut PtyHandle,
195 detector: &ReadinessDetector,
196 timeout_override: Option<u64>,
197 tx_delta: Option<&crate::tools::output::DeltaSender>,
198 max_output: usize,
199) -> (String, String) {
200 let override_detector;
203 let effective_detector = if let Some(ms) = timeout_override {
204 override_detector = ReadinessDetector::new(
205 ReadinessStrategy::Hybrid,
206 &[], ms,
208 ms.saturating_mul(10).max(10_000), );
210 &override_detector
211 } else {
212 detector
213 };
214
215 let mut output = String::new();
216 let start = Instant::now();
217 let mut last_output_time = Instant::now();
218 let poll_interval = Duration::from_millis(50);
219
220 loop {
221 let bytes = pty.try_read_output(poll_interval).await;
223
224 if !bytes.is_empty() {
225 let text = String::from_utf8_lossy(&bytes);
226 output.push_str(&text);
227 last_output_time = Instant::now();
228
229 if let Some(tx) = tx_delta {
231 tx.send(normalize_output(&text));
232 }
233 }
234
235 if output.len() > max_output {
237 let mut trunc = max_output;
238 while trunc > 0 && !output.is_char_boundary(trunc) {
239 trunc -= 1;
240 }
241 output.truncate(trunc);
242 return (normalize_output(&output), "active".into());
243 }
244
245 if !pty.is_alive() {
247 tokio::time::sleep(Duration::from_millis(50)).await;
249 let remaining = pty.try_read_output(Duration::from_millis(100)).await;
250 if !remaining.is_empty() {
251 let remaining_text = String::from_utf8_lossy(&remaining);
252 output.push_str(&remaining_text);
253
254 if let Some(tx) = tx_delta {
256 tx.send(normalize_output(&remaining_text));
257 }
258 }
259 return (
261 normalize_output(&output),
262 status_string(&SessionStatus::Exited(None)),
263 );
264 }
265
266 let silence_elapsed = last_output_time.elapsed();
268 let total_elapsed = start.elapsed();
269
270 match effective_detector.check(&output, silence_elapsed, total_elapsed) {
271 ReadinessResult::Ready => return (normalize_output(&output), "active".into()),
272 ReadinessResult::SilenceTimeout => return (normalize_output(&output), "active".into()),
273 ReadinessResult::MaxTimeout => return (normalize_output(&output), "timeout".into()),
274 ReadinessResult::Waiting => continue,
275 }
276 }
277}
278
279impl SessionManager {
284 pub fn new(config: ShellConfig) -> Arc<Self> {
286 Arc::new(Self {
287 sessions: Mutex::new(HashMap::new()),
288 config,
289 next_id: AtomicU32::new(0),
290 })
291 }
292
293 pub async fn create_session(
297 &self,
298 opts: SessionOpts,
299 tx_delta: Option<&crate::tools::output::DeltaSender>,
300 max_output: usize,
301 ) -> Result<(String, String, String)> {
302 {
304 let sessions = self
305 .sessions
306 .lock()
307 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
308 if sessions.len() >= self.config.max_sessions {
309 return Err(RuntimeError::Tool(format!(
310 "maximum session limit reached ({})",
311 self.config.max_sessions
312 )));
313 }
314 }
315
316 let seq = self.next_id.fetch_add(1, Ordering::SeqCst) + 1;
318 let id = format!("shell_{:02}", seq);
319
320 let command = opts.command.unwrap_or_else(|| {
322 std::env::var("SHELL").unwrap_or_else(|_| {
323 if cfg!(windows) {
324 "powershell".into()
327 } else {
328 "bash".into()
329 }
330 })
331 });
332 let rows = opts.rows.unwrap_or(self.config.default_rows);
333 let cols = opts.cols.unwrap_or(self.config.default_cols);
334
335 let idle_timeout = opts
336 .idle_timeout
337 .map(Duration::from_secs)
338 .unwrap_or(self.config.idle_timeout);
339
340 let mut pty = PtyHandle::spawn(
342 &command,
343 opts.working_directory.as_deref(),
344 opts.env,
345 rows,
346 cols,
347 )?;
348
349 let silence_ms = opts
351 .readiness_timeout_ms
352 .unwrap_or(self.config.readiness_timeout_ms);
353 let detector = ReadinessDetector::new(
354 super::readiness::ReadinessStrategy::Hybrid,
355 &self.config.prompt_patterns,
356 silence_ms,
357 self.config.max_readiness_timeout_ms,
358 );
359
360 tokio::time::sleep(Duration::from_millis(200)).await;
365 let (initial_output, status_str) = wait_for_output(
366 &mut pty,
367 &detector,
368 opts.readiness_timeout_ms,
369 tx_delta,
370 max_output,
371 )
372 .await;
373
374 let now = Instant::now();
375 let status = if status_str.starts_with("exited") {
376 SessionStatus::Exited(None)
377 } else {
378 SessionStatus::Active
379 };
380
381 let session = ShellSession {
382 pty,
383 detector,
384 created_at: now,
385 last_active: now,
386 idle_timeout,
387 status,
388 };
389
390 {
392 let mut sessions = self
393 .sessions
394 .lock()
395 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
396 sessions.insert(id.clone(), session);
397 }
398
399 Ok((id, initial_output, status_str))
400 }
401
402 pub async fn send_input(
404 &self,
405 id: &str,
406 input: &str,
407 timeout_ms: Option<u64>,
408 tx_delta: Option<&crate::tools::output::DeltaSender>,
409 max_output: usize,
410 ) -> Result<SendResult> {
411 let mut session = {
413 let mut sessions = self
414 .sessions
415 .lock()
416 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
417 sessions.remove(id).ok_or_else(|| {
418 RuntimeError::Tool(format!(
419 "session {id} not found — it may have been closed, reaped, or is currently in use by another call"
420 ))
421 })?
422 };
423
424 if session.status != SessionStatus::Active {
426 let s_str = status_string(&session.status);
428 let mut sessions = self
429 .sessions
430 .lock()
431 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
432 sessions.insert(id.to_string(), session);
433 return Err(RuntimeError::Tool(format!(
434 "session {id} is not active (status: {s_str})"
435 )));
436 }
437
438 let processed = process_input_escapes(input);
440 session.pty.write(processed.as_bytes())?;
441
442 let (output, status_str) = wait_for_output(
444 &mut session.pty,
445 &session.detector,
446 timeout_ms,
447 tx_delta,
448 max_output,
449 )
450 .await;
451
452 session.last_active = Instant::now();
454 if !session.pty.is_alive() {
455 session.status = SessionStatus::Exited(None);
456 }
457
458 let result = SendResult {
459 output,
460 status: status_str,
461 };
462
463 {
465 let mut sessions = self
466 .sessions
467 .lock()
468 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
469 sessions.insert(id.to_string(), session);
470 }
471
472 Ok(result)
473 }
474
475 pub async fn close_session(&self, id: &str) -> Result<String> {
479 let mut session = {
480 let mut sessions = self
481 .sessions
482 .lock()
483 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
484 match sessions.remove(id) {
485 Some(s) => s,
486 None => return Ok(String::new()),
487 }
488 };
489
490 let remaining = session
492 .pty
493 .try_read_output(Duration::from_millis(100))
494 .await;
495 let final_output = if remaining.is_empty() {
496 String::new()
497 } else {
498 strip_ansi(&String::from_utf8_lossy(&remaining))
499 };
500
501 drop(session);
503
504 Ok(final_output)
505 }
506
507 pub fn reap_idle(&self) -> Vec<String> {
511 let mut sessions = match self.sessions.lock() {
512 Ok(s) => s,
513 Err(e) => {
514 tracing::error!("session lock poisoned: {e}");
515 return Vec::new();
516 }
517 };
518
519 let grace_period = Duration::from_secs(5);
520
521 let ids_to_reap: Vec<String> = sessions
522 .iter()
523 .filter(|(_, s)| {
524 let elapsed = s.last_active.elapsed();
525 elapsed > s.idle_timeout && elapsed > grace_period
526 })
527 .map(|(id, _)| id.clone())
528 .collect();
529
530 for id in &ids_to_reap {
531 sessions.remove(id);
532 }
534
535 ids_to_reap
536 }
537
538 pub fn shutdown_all(&self) {
540 match self.sessions.lock() {
541 Ok(mut sessions) => {
542 sessions.drain();
543 }
545 Err(e) => {
546 tracing::error!("session lock poisoned: {e}");
547 }
548 }
549 }
550
551 pub fn active_count(&self) -> usize {
553 match self.sessions.lock() {
554 Ok(s) => s.len(),
555 Err(e) => {
556 tracing::error!("session lock poisoned: {e}");
557 0
558 }
559 }
560 }
561
562 pub fn list_sessions(&self) -> Vec<ShellSessionInfo> {
564 match self.sessions.lock() {
565 Ok(sessions) => sessions
566 .iter()
567 .map(|(id, s)| ShellSessionInfo {
568 id: id.clone(),
569 status: s.status.clone(),
570 created_at: s.created_at,
571 last_active: s.last_active,
572 })
573 .collect(),
574 Err(e) => {
575 tracing::error!("session lock poisoned: {e}");
576 Vec::new()
577 }
578 }
579 }
580}
581
582impl Drop for SessionManager {
587 fn drop(&mut self) {
588 self.shutdown_all();
589 }
590}
591
592pub fn start_reaper(
602 manager: Arc<SessionManager>,
603 cancel: tokio_util::sync::CancellationToken,
604) -> tokio::task::JoinHandle<()> {
605 tokio::spawn(async move {
606 let interval = Duration::from_secs(30);
607 loop {
608 tokio::select! {
609 _ = cancel.cancelled() => break,
610 _ = tokio::time::sleep(interval) => {
611 let reaped = manager.reap_idle();
612 for id in &reaped {
613 tracing::info!(session_id = %id, "reaped idle shell session");
614 }
615 }
616 }
617 }
618 })
619}
620
621#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn default_manager() -> Arc<SessionManager> {
630 SessionManager::new(ShellConfig::default())
631 }
632
633 fn opts_for(command: &str) -> SessionOpts {
634 SessionOpts {
635 command: Some(command.to_string()),
636 working_directory: None,
637 env: HashMap::new(),
638 rows: None,
639 cols: None,
640 readiness_timeout_ms: None,
641 idle_timeout: None,
642 }
643 }
644
645 #[tokio::test]
647 async fn test_create_session_echo_hello() {
648 let mgr = default_manager();
649 let (id, output, _status) = mgr
650 .create_session(opts_for("echo hello"), None, 256 * 1024)
651 .await
652 .expect("failed to create session");
653
654 assert!(id.starts_with("shell_"));
655 assert!(
656 output.contains("hello"),
657 "expected 'hello' in output, got: {output:?}"
658 );
659 }
660
661 #[tokio::test]
663 async fn test_send_input_echo() {
664 let mgr = default_manager();
665 let (id, _initial, _status) = mgr
666 .create_session(opts_for("bash"), None, 256 * 1024)
667 .await
668 .expect("failed to create session");
669
670 let result = mgr
671 .send_input(&id, "echo test\n", None, None, 256 * 1024)
672 .await
673 .expect("failed to send input");
674
675 assert!(
676 result.output.contains("test"),
677 "expected 'test' in output, got: {:?}",
678 result.output
679 );
680
681 let _ = mgr.close_session(&id).await;
683 }
684
685 #[tokio::test]
687 async fn test_close_session_idempotent() {
688 let mgr = default_manager();
689 let (id, _, _status) = mgr
690 .create_session(opts_for("bash"), None, 256 * 1024)
691 .await
692 .expect("failed to create session");
693
694 let result1 = mgr.close_session(&id).await;
695 assert!(result1.is_ok(), "first close should succeed");
696
697 let result2 = mgr.close_session(&id).await;
698 assert!(
699 result2.is_ok(),
700 "second close should also succeed (idempotent)"
701 );
702 assert_eq!(result2.unwrap(), "", "second close returns empty string");
703 }
704
705 #[tokio::test]
707 async fn test_max_sessions_limit() {
708 let config = ShellConfig {
709 max_sessions: 2,
710 ..Default::default()
711 };
712 let mgr = SessionManager::new(config);
713
714 let (id1, _, _s) = mgr
715 .create_session(opts_for("bash"), None, 256 * 1024)
716 .await
717 .expect("session 1");
718 let (id2, _, _s) = mgr
719 .create_session(opts_for("bash"), None, 256 * 1024)
720 .await
721 .expect("session 2");
722
723 let result = mgr.create_session(opts_for("bash"), None, 256 * 1024).await;
724 assert!(result.is_err(), "third session should fail");
725 let err_msg = format!("{}", result.unwrap_err());
726 assert!(
727 err_msg.contains("maximum session limit"),
728 "error should mention limit, got: {err_msg}"
729 );
730
731 let _ = mgr.close_session(&id1).await;
733 let _ = mgr.close_session(&id2).await;
734 }
735
736 #[tokio::test]
738 async fn test_session_not_found() {
739 let mgr = default_manager();
740 let result = mgr
741 .send_input("shell_99", "hello\n", None, None, 256 * 1024)
742 .await;
743 assert!(result.is_err(), "send to non-existent session should fail");
744 let err_msg = format!("{}", result.unwrap_err());
745 assert!(
746 err_msg.contains("not found"),
747 "error should mention 'not found', got: {err_msg}"
748 );
749 }
750
751 #[test]
754 fn test_normalize_output_crlf() {
755 assert_eq!(normalize_output("hello\r\nworld\r\n"), "hello\nworld\n");
756 }
757
758 #[test]
759 fn test_normalize_output_lone_cr() {
760 assert_eq!(normalize_output("abc\rdef"), "abcdef");
761 }
762
763 #[test]
766 fn test_escape_newline() {
767 assert_eq!(process_input_escapes(r"hello\n"), "hello\n");
768 }
769
770 #[test]
771 fn test_escape_tab() {
772 assert_eq!(process_input_escapes(r"a\tb"), "a\tb");
773 }
774
775 #[test]
776 fn test_escape_ctrl_c() {
777 assert_eq!(process_input_escapes(r"\x03"), "\x03");
778 }
779
780 #[test]
781 fn test_escape_ctrl_d() {
782 assert_eq!(process_input_escapes(r"\x04"), "\x04");
783 }
784
785 #[test]
786 fn test_escape_literal_backslash() {
787 assert_eq!(process_input_escapes(r"a\\b"), "a\\b");
788 }
789
790 #[test]
791 fn test_escape_real_newline_passthrough() {
792 assert_eq!(process_input_escapes("hello\n"), "hello\n");
794 }
795
796 #[test]
797 fn test_escape_mixed() {
798 assert_eq!(process_input_escapes(r"ls -la\n"), "ls -la\n");
799 assert_eq!(process_input_escapes(r"124\n"), "124\n");
800 }
801
802 #[test]
803 fn test_escape_unknown_sequence() {
804 assert_eq!(process_input_escapes(r"\q"), "\\q");
806 }
807
808 #[test]
809 fn test_escape_hex_partial() {
810 assert_eq!(process_input_escapes(r"\xZZ"), "\\xZZ");
812 }
813
814 #[test]
815 fn test_escape_bell() {
816 assert_eq!(process_input_escapes(r"\a"), "\x07");
817 }
818
819 #[test]
820 fn test_escape_backspace() {
821 assert_eq!(process_input_escapes(r"\b"), "\x08");
822 }
823
824 #[test]
825 fn test_escape_null() {
826 assert_eq!(process_input_escapes(r"\0"), "\0");
827 }
828
829 #[test]
830 fn test_escape_esc_blocked() {
831 assert_eq!(process_input_escapes(r"\e"), "");
833 }
834
835 #[test]
836 fn test_escape_hex_1b_blocked() {
837 assert_eq!(process_input_escapes(r"\x1b"), "");
839 }
840
841 #[test]
842 fn test_escape_hex_high_byte_blocked() {
843 assert_eq!(process_input_escapes(r"\x80"), "");
845 assert_eq!(process_input_escapes(r"\xff"), "");
846 }
847
848 #[test]
849 fn test_escape_hex_del_allowed() {
850 assert_eq!(process_input_escapes(r"\x7f"), "\x7f");
852 }
853}