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
322 .command
323 .unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "bash".into()));
324 let rows = opts.rows.unwrap_or(self.config.default_rows);
325 let cols = opts.cols.unwrap_or(self.config.default_cols);
326
327 let idle_timeout = opts
328 .idle_timeout
329 .map(Duration::from_secs)
330 .unwrap_or(self.config.idle_timeout);
331
332 let mut pty = PtyHandle::spawn(
334 &command,
335 opts.working_directory.as_deref(),
336 opts.env,
337 rows,
338 cols,
339 )?;
340
341 let silence_ms = opts
343 .readiness_timeout_ms
344 .unwrap_or(self.config.readiness_timeout_ms);
345 let detector = ReadinessDetector::new(
346 super::readiness::ReadinessStrategy::Hybrid,
347 &self.config.prompt_patterns,
348 silence_ms,
349 self.config.max_readiness_timeout_ms,
350 );
351
352 tokio::time::sleep(Duration::from_millis(200)).await;
357 let (initial_output, status_str) = wait_for_output(
358 &mut pty,
359 &detector,
360 opts.readiness_timeout_ms,
361 tx_delta,
362 max_output,
363 )
364 .await;
365
366 let now = Instant::now();
367 let status = if status_str.starts_with("exited") {
368 SessionStatus::Exited(None)
369 } else {
370 SessionStatus::Active
371 };
372
373 let session = ShellSession {
374 pty,
375 detector,
376 created_at: now,
377 last_active: now,
378 idle_timeout,
379 status,
380 };
381
382 {
384 let mut sessions = self
385 .sessions
386 .lock()
387 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
388 sessions.insert(id.clone(), session);
389 }
390
391 Ok((id, initial_output, status_str))
392 }
393
394 pub async fn send_input(
396 &self,
397 id: &str,
398 input: &str,
399 timeout_ms: Option<u64>,
400 tx_delta: Option<&crate::tools::output::DeltaSender>,
401 max_output: usize,
402 ) -> Result<SendResult> {
403 let mut session = {
405 let mut sessions = self
406 .sessions
407 .lock()
408 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
409 sessions.remove(id).ok_or_else(|| {
410 RuntimeError::Tool(format!(
411 "session {id} not found — it may have been closed, reaped, or is currently in use by another call"
412 ))
413 })?
414 };
415
416 if session.status != SessionStatus::Active {
418 let s_str = status_string(&session.status);
420 let mut sessions = self
421 .sessions
422 .lock()
423 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
424 sessions.insert(id.to_string(), session);
425 return Err(RuntimeError::Tool(format!(
426 "session {id} is not active (status: {s_str})"
427 )));
428 }
429
430 let processed = process_input_escapes(input);
432 session.pty.write(processed.as_bytes())?;
433
434 let (output, status_str) = wait_for_output(
436 &mut session.pty,
437 &session.detector,
438 timeout_ms,
439 tx_delta,
440 max_output,
441 )
442 .await;
443
444 session.last_active = Instant::now();
446 if !session.pty.is_alive() {
447 session.status = SessionStatus::Exited(None);
448 }
449
450 let result = SendResult {
451 output,
452 status: status_str,
453 };
454
455 {
457 let mut sessions = self
458 .sessions
459 .lock()
460 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
461 sessions.insert(id.to_string(), session);
462 }
463
464 Ok(result)
465 }
466
467 pub async fn close_session(&self, id: &str) -> Result<String> {
471 let mut session = {
472 let mut sessions = self
473 .sessions
474 .lock()
475 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
476 match sessions.remove(id) {
477 Some(s) => s,
478 None => return Ok(String::new()),
479 }
480 };
481
482 let remaining = session
484 .pty
485 .try_read_output(Duration::from_millis(100))
486 .await;
487 let final_output = if remaining.is_empty() {
488 String::new()
489 } else {
490 strip_ansi(&String::from_utf8_lossy(&remaining))
491 };
492
493 drop(session);
495
496 Ok(final_output)
497 }
498
499 pub fn reap_idle(&self) -> Vec<String> {
503 let mut sessions = match self.sessions.lock() {
504 Ok(s) => s,
505 Err(e) => {
506 tracing::error!("session lock poisoned: {e}");
507 return Vec::new();
508 }
509 };
510
511 let grace_period = Duration::from_secs(5);
512
513 let ids_to_reap: Vec<String> = sessions
514 .iter()
515 .filter(|(_, s)| {
516 let elapsed = s.last_active.elapsed();
517 elapsed > s.idle_timeout && elapsed > grace_period
518 })
519 .map(|(id, _)| id.clone())
520 .collect();
521
522 for id in &ids_to_reap {
523 sessions.remove(id);
524 }
526
527 ids_to_reap
528 }
529
530 pub fn shutdown_all(&self) {
532 match self.sessions.lock() {
533 Ok(mut sessions) => {
534 sessions.drain();
535 }
537 Err(e) => {
538 tracing::error!("session lock poisoned: {e}");
539 }
540 }
541 }
542
543 pub fn active_count(&self) -> usize {
545 match self.sessions.lock() {
546 Ok(s) => s.len(),
547 Err(e) => {
548 tracing::error!("session lock poisoned: {e}");
549 0
550 }
551 }
552 }
553
554 pub fn list_sessions(&self) -> Vec<ShellSessionInfo> {
556 match self.sessions.lock() {
557 Ok(sessions) => sessions
558 .iter()
559 .map(|(id, s)| ShellSessionInfo {
560 id: id.clone(),
561 status: s.status.clone(),
562 created_at: s.created_at,
563 last_active: s.last_active,
564 })
565 .collect(),
566 Err(e) => {
567 tracing::error!("session lock poisoned: {e}");
568 Vec::new()
569 }
570 }
571 }
572}
573
574impl Drop for SessionManager {
579 fn drop(&mut self) {
580 self.shutdown_all();
581 }
582}
583
584pub fn start_reaper(
594 manager: Arc<SessionManager>,
595 cancel: tokio_util::sync::CancellationToken,
596) -> tokio::task::JoinHandle<()> {
597 tokio::spawn(async move {
598 let interval = Duration::from_secs(30);
599 loop {
600 tokio::select! {
601 _ = cancel.cancelled() => break,
602 _ = tokio::time::sleep(interval) => {
603 let reaped = manager.reap_idle();
604 for id in &reaped {
605 tracing::info!(session_id = %id, "reaped idle shell session");
606 }
607 }
608 }
609 }
610 })
611}
612
613#[cfg(test)]
618mod tests {
619 use super::*;
620
621 fn default_manager() -> Arc<SessionManager> {
622 SessionManager::new(ShellConfig::default())
623 }
624
625 fn opts_for(command: &str) -> SessionOpts {
626 SessionOpts {
627 command: Some(command.to_string()),
628 working_directory: None,
629 env: HashMap::new(),
630 rows: None,
631 cols: None,
632 readiness_timeout_ms: None,
633 idle_timeout: None,
634 }
635 }
636
637 #[tokio::test]
639 async fn test_create_session_echo_hello() {
640 let mgr = default_manager();
641 let (id, output, _status) = mgr
642 .create_session(opts_for("echo hello"), None, 256 * 1024)
643 .await
644 .expect("failed to create session");
645
646 assert!(id.starts_with("shell_"));
647 assert!(
648 output.contains("hello"),
649 "expected 'hello' in output, got: {output:?}"
650 );
651 }
652
653 #[tokio::test]
655 async fn test_send_input_echo() {
656 let mgr = default_manager();
657 let (id, _initial, _status) = mgr
658 .create_session(opts_for("bash"), None, 256 * 1024)
659 .await
660 .expect("failed to create session");
661
662 let result = mgr
663 .send_input(&id, "echo test\n", None, None, 256 * 1024)
664 .await
665 .expect("failed to send input");
666
667 assert!(
668 result.output.contains("test"),
669 "expected 'test' in output, got: {:?}",
670 result.output
671 );
672
673 let _ = mgr.close_session(&id).await;
675 }
676
677 #[tokio::test]
679 async fn test_close_session_idempotent() {
680 let mgr = default_manager();
681 let (id, _, _status) = mgr
682 .create_session(opts_for("bash"), None, 256 * 1024)
683 .await
684 .expect("failed to create session");
685
686 let result1 = mgr.close_session(&id).await;
687 assert!(result1.is_ok(), "first close should succeed");
688
689 let result2 = mgr.close_session(&id).await;
690 assert!(
691 result2.is_ok(),
692 "second close should also succeed (idempotent)"
693 );
694 assert_eq!(result2.unwrap(), "", "second close returns empty string");
695 }
696
697 #[tokio::test]
699 async fn test_max_sessions_limit() {
700 let config = ShellConfig {
701 max_sessions: 2,
702 ..Default::default()
703 };
704 let mgr = SessionManager::new(config);
705
706 let (id1, _, _s) = mgr
707 .create_session(opts_for("bash"), None, 256 * 1024)
708 .await
709 .expect("session 1");
710 let (id2, _, _s) = mgr
711 .create_session(opts_for("bash"), None, 256 * 1024)
712 .await
713 .expect("session 2");
714
715 let result = mgr.create_session(opts_for("bash"), None, 256 * 1024).await;
716 assert!(result.is_err(), "third session should fail");
717 let err_msg = format!("{}", result.unwrap_err());
718 assert!(
719 err_msg.contains("maximum session limit"),
720 "error should mention limit, got: {err_msg}"
721 );
722
723 let _ = mgr.close_session(&id1).await;
725 let _ = mgr.close_session(&id2).await;
726 }
727
728 #[tokio::test]
730 async fn test_session_not_found() {
731 let mgr = default_manager();
732 let result = mgr
733 .send_input("shell_99", "hello\n", None, None, 256 * 1024)
734 .await;
735 assert!(result.is_err(), "send to non-existent session should fail");
736 let err_msg = format!("{}", result.unwrap_err());
737 assert!(
738 err_msg.contains("not found"),
739 "error should mention 'not found', got: {err_msg}"
740 );
741 }
742
743 #[test]
746 fn test_normalize_output_crlf() {
747 assert_eq!(normalize_output("hello\r\nworld\r\n"), "hello\nworld\n");
748 }
749
750 #[test]
751 fn test_normalize_output_lone_cr() {
752 assert_eq!(normalize_output("abc\rdef"), "abcdef");
753 }
754
755 #[test]
758 fn test_escape_newline() {
759 assert_eq!(process_input_escapes(r"hello\n"), "hello\n");
760 }
761
762 #[test]
763 fn test_escape_tab() {
764 assert_eq!(process_input_escapes(r"a\tb"), "a\tb");
765 }
766
767 #[test]
768 fn test_escape_ctrl_c() {
769 assert_eq!(process_input_escapes(r"\x03"), "\x03");
770 }
771
772 #[test]
773 fn test_escape_ctrl_d() {
774 assert_eq!(process_input_escapes(r"\x04"), "\x04");
775 }
776
777 #[test]
778 fn test_escape_literal_backslash() {
779 assert_eq!(process_input_escapes(r"a\\b"), "a\\b");
780 }
781
782 #[test]
783 fn test_escape_real_newline_passthrough() {
784 assert_eq!(process_input_escapes("hello\n"), "hello\n");
786 }
787
788 #[test]
789 fn test_escape_mixed() {
790 assert_eq!(process_input_escapes(r"ls -la\n"), "ls -la\n");
791 assert_eq!(process_input_escapes(r"124\n"), "124\n");
792 }
793
794 #[test]
795 fn test_escape_unknown_sequence() {
796 assert_eq!(process_input_escapes(r"\q"), "\\q");
798 }
799
800 #[test]
801 fn test_escape_hex_partial() {
802 assert_eq!(process_input_escapes(r"\xZZ"), "\\xZZ");
804 }
805
806 #[test]
807 fn test_escape_bell() {
808 assert_eq!(process_input_escapes(r"\a"), "\x07");
809 }
810
811 #[test]
812 fn test_escape_backspace() {
813 assert_eq!(process_input_escapes(r"\b"), "\x08");
814 }
815
816 #[test]
817 fn test_escape_null() {
818 assert_eq!(process_input_escapes(r"\0"), "\0");
819 }
820
821 #[test]
822 fn test_escape_esc_blocked() {
823 assert_eq!(process_input_escapes(r"\e"), "");
825 }
826
827 #[test]
828 fn test_escape_hex_1b_blocked() {
829 assert_eq!(process_input_escapes(r"\x1b"), "");
831 }
832
833 #[test]
834 fn test_escape_hex_high_byte_blocked() {
835 assert_eq!(process_input_escapes(r"\x80"), "");
837 assert_eq!(process_input_escapes(r"\xff"), "");
838 }
839
840 #[test]
841 fn test_escape_hex_del_allowed() {
842 assert_eq!(process_input_escapes(r"\x7f"), "\x7f");
844 }
845}