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 crate::tools::utf8_stream::Utf8StreamDecoder;
24use super::readiness::{ReadinessDetector, ReadinessResult, ReadinessStrategy};
25
26pub struct SessionManager {
32 sessions: Mutex<HashMap<String, ShellSession>>,
33 config: ShellConfig,
34 next_id: AtomicU32,
35}
36
37struct ShellSession {
39 pty: PtyHandle,
40 decoder: Utf8StreamDecoder,
43 detector: ReadinessDetector,
44 created_at: Instant,
45 last_active: Instant,
46 idle_timeout: Duration,
47 status: SessionStatus,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum SessionStatus {
53 Active,
54 Exited(Option<i32>),
55 Closed,
56}
57
58pub struct SessionOpts {
60 pub command: Option<String>,
61 pub working_directory: Option<String>,
62 pub base_env: Option<Vec<(String, String)>>,
66 pub env: HashMap<String, String>,
67 pub rows: Option<u16>,
68 pub cols: Option<u16>,
69 pub readiness_timeout_ms: Option<u64>,
70 pub idle_timeout: Option<u64>,
71}
72
73#[derive(Debug)]
75pub struct SendResult {
76 pub output: String,
77 pub status: String,
79}
80
81pub struct ShellSessionInfo {
83 pub id: String,
84 pub status: SessionStatus,
85 pub created_at: Instant,
86 pub last_active: Instant,
87}
88
89fn normalize_output(raw: &str) -> String {
95 strip_ansi(raw).replace("\r\n", "\n").replace('\r', "")
96}
97
98fn process_input_escapes(input: &str) -> String {
104 let mut result = String::with_capacity(input.len());
105 let mut chars = input.chars().peekable();
106
107 while let Some(ch) = chars.next() {
108 if ch == '\\' {
109 match chars.peek() {
110 Some('n') => {
111 chars.next();
112 result.push('\n');
113 }
114 Some('r') => {
115 chars.next();
116 result.push('\r');
117 }
118 Some('t') => {
119 chars.next();
120 result.push('\t');
121 }
122 Some('\\') => {
123 chars.next();
124 result.push('\\');
125 }
126 Some('a') => {
127 chars.next();
128 result.push('\x07');
129 } Some('b') => {
131 chars.next();
132 result.push('\x08');
133 } Some('0') => {
135 chars.next();
136 result.push('\0');
137 } Some('e') => {
139 chars.next();
140 tracing::warn!("blocked \\e escape sequence (raw ESC) in shell input");
141 }
142 Some('x') => {
143 chars.next(); let mut hex = String::new();
145 for _ in 0..2 {
146 if let Some(&c) = chars.peek() {
147 if c.is_ascii_hexdigit() {
148 hex.push(c);
149 chars.next();
150 } else {
151 break;
152 }
153 }
154 }
155 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
156 if byte == 0x1b {
157 tracing::warn!(
159 "blocked \\x1b escape sequence (raw ESC) in shell input"
160 );
161 } else if byte >= 0x80 {
162 tracing::warn!("blocked \\x{hex:} high byte (>= 0x80) in shell input");
164 } else {
165 result.push(byte as char);
167 }
168 } else {
169 result.push('\\');
171 result.push('x');
172 result.push_str(&hex);
173 }
174 }
175 _ => {
176 result.push(ch);
178 }
179 }
180 } else {
181 result.push(ch);
182 }
183 }
184 result
185}
186
187fn status_string(status: &SessionStatus) -> String {
189 match status {
190 SessionStatus::Active => "active".into(),
191 SessionStatus::Exited(Some(code)) => format!("exited({code})"),
192 SessionStatus::Exited(None) => "exited".into(),
193 SessionStatus::Closed => "closed".into(),
194 }
195}
196
197async fn wait_for_output(
202 pty: &mut PtyHandle,
203 decoder: &mut Utf8StreamDecoder,
204 detector: &ReadinessDetector,
205 timeout_override: Option<u64>,
206 tx_delta: Option<&crate::tools::output::DeltaSender>,
207 max_output: usize,
208) -> (String, String) {
209 let override_detector;
212 let effective_detector = if let Some(ms) = timeout_override {
213 override_detector = ReadinessDetector::new(
214 ReadinessStrategy::Hybrid,
215 &[], ms,
217 ms.saturating_mul(10).max(10_000), );
219 &override_detector
220 } else {
221 detector
222 };
223
224 let mut output = String::new();
225 let start = Instant::now();
226 let mut last_output_time = Instant::now();
227 let poll_interval = Duration::from_millis(50);
228
229 loop {
230 let bytes = pty.try_read_output(poll_interval).await;
232
233 if !bytes.is_empty() {
234 let text = decoder.decode(&bytes);
235 output.push_str(&text);
236 last_output_time = Instant::now();
237
238 if let Some(tx) = tx_delta {
240 tx.send(normalize_output(&text));
241 }
242 }
243
244 if output.len() > max_output {
246 let mut trunc = max_output;
247 while trunc > 0 && !output.is_char_boundary(trunc) {
248 trunc -= 1;
249 }
250 output.truncate(trunc);
251 return (normalize_output(&output), "active".into());
252 }
253
254 if !pty.is_alive() {
256 tokio::time::sleep(Duration::from_millis(50)).await;
258 let remaining = pty.try_read_output(Duration::from_millis(100)).await;
259 let mut remaining_text = decoder.decode(&remaining);
260 remaining_text.push_str(&decoder.finish());
261 if !remaining_text.is_empty() {
262 output.push_str(&remaining_text);
263
264 if let Some(tx) = tx_delta {
266 tx.send(normalize_output(&remaining_text));
267 }
268 }
269 return (
271 normalize_output(&output),
272 status_string(&SessionStatus::Exited(None)),
273 );
274 }
275
276 let silence_elapsed = last_output_time.elapsed();
278 let total_elapsed = start.elapsed();
279
280 match effective_detector.check(&output, silence_elapsed, total_elapsed) {
281 ReadinessResult::Ready => return (normalize_output(&output), "active".into()),
282 ReadinessResult::SilenceTimeout => return (normalize_output(&output), "active".into()),
283 ReadinessResult::MaxTimeout => return (normalize_output(&output), "timeout".into()),
284 ReadinessResult::Waiting => continue,
285 }
286 }
287}
288
289impl SessionManager {
294 pub fn new(config: ShellConfig) -> Arc<Self> {
296 Arc::new(Self {
297 sessions: Mutex::new(HashMap::new()),
298 config,
299 next_id: AtomicU32::new(0),
300 })
301 }
302
303 pub async fn create_session(
307 &self,
308 opts: SessionOpts,
309 tx_delta: Option<&crate::tools::output::DeltaSender>,
310 max_output: usize,
311 ) -> Result<(String, String, String)> {
312 self.create_session_cancellable(opts, tx_delta, max_output, None).await
313 }
314
315 pub(crate) async fn create_session_cancellable(
316 &self, opts: SessionOpts,
317 tx_delta: Option<&crate::tools::output::DeltaSender>, max_output: usize,
318 cancel: Option<&crate::CancellationToken>,
319 ) -> Result<(String, String, String)> {
320 {
322 let sessions = self
323 .sessions
324 .lock()
325 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
326 if sessions.len() >= self.config.max_sessions {
327 return Err(RuntimeError::Tool(format!(
328 "maximum session limit reached ({})",
329 self.config.max_sessions
330 )));
331 }
332 }
333
334 let seq = self.next_id.fetch_add(1, Ordering::SeqCst) + 1;
336 let id = format!("shell_{:02}", seq);
337
338 let command = opts.command.unwrap_or_else(|| {
340 std::env::var("SHELL").unwrap_or_else(|_| {
341 if cfg!(windows) {
342 "powershell".into()
345 } else {
346 "bash".into()
347 }
348 })
349 });
350 let rows = opts.rows.unwrap_or(self.config.default_rows);
351 let cols = opts.cols.unwrap_or(self.config.default_cols);
352
353 let idle_timeout = opts
354 .idle_timeout
355 .map(Duration::from_secs)
356 .unwrap_or(self.config.idle_timeout);
357
358 if cancel.is_some_and(|c| c.is_cancelled()) {
360 return Err(RuntimeError::Tool("Shell canceled before launch".into()));
361 }
362 let probe_cwd = opts.working_directory.clone();
363 let probe_env = opts.env;
364 let base_env = opts.base_env;
365 let probe = tokio::task::spawn_blocking(move ||
367 PtyHandle::prepare_scope(probe_cwd.as_deref(), base_env.as_deref(), &probe_env));
368 let prepared = if let Some(cancel) = cancel {
369 tokio::select! {
370 biased;
371 _ = cancel.cancelled() => return Err(RuntimeError::Tool("Shell canceled before launch".into())),
372 result = probe => result,
373 }
374 } else { probe.await }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
375 if cancel.is_some_and(|c| c.is_cancelled()) {
376 return Err(RuntimeError::Tool("Shell canceled before launch".into()));
377 }
378 let mut pty = PtyHandle::spawn_prepared(
379 &command, opts.working_directory.as_deref(), rows, cols, prepared,
380 )?;
381
382 let silence_ms = opts
384 .readiness_timeout_ms
385 .unwrap_or(self.config.readiness_timeout_ms);
386 let detector = ReadinessDetector::new(
387 super::readiness::ReadinessStrategy::Hybrid,
388 &self.config.prompt_patterns,
389 silence_ms,
390 self.config.max_readiness_timeout_ms,
391 );
392
393 tokio::time::sleep(Duration::from_millis(200)).await;
398 let mut decoder = Utf8StreamDecoder::new();
399 let (initial_output, status_str) = wait_for_output(
400 &mut pty,
401 &mut decoder,
402 &detector,
403 opts.readiness_timeout_ms,
404 tx_delta,
405 max_output,
406 )
407 .await;
408
409 let now = Instant::now();
410 let status = if status_str.starts_with("exited") {
411 SessionStatus::Exited(None)
412 } else {
413 SessionStatus::Active
414 };
415
416 let session = ShellSession {
417 pty,
418 decoder,
419 detector,
420 created_at: now,
421 last_active: now,
422 idle_timeout,
423 status,
424 };
425
426 {
428 let mut sessions = self
429 .sessions
430 .lock()
431 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
432 sessions.insert(id.clone(), session);
433 }
434
435 Ok((id, initial_output, status_str))
436 }
437
438 pub async fn send_input(
440 &self,
441 id: &str,
442 input: &str,
443 timeout_ms: Option<u64>,
444 tx_delta: Option<&crate::tools::output::DeltaSender>,
445 max_output: usize,
446 ) -> Result<SendResult> {
447 let mut session = {
449 let mut sessions = self
450 .sessions
451 .lock()
452 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
453 sessions.remove(id).ok_or_else(|| {
454 RuntimeError::Tool(format!(
455 "session {id} not found — it may have been closed, reaped, or is currently in use by another call"
456 ))
457 })?
458 };
459
460 if session.status != SessionStatus::Active {
462 let s_str = status_string(&session.status);
464 let mut sessions = self
465 .sessions
466 .lock()
467 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
468 sessions.insert(id.to_string(), session);
469 return Err(RuntimeError::Tool(format!(
470 "session {id} is not active (status: {s_str})"
471 )));
472 }
473
474 let processed = process_input_escapes(input);
476 session.pty.write(processed.as_bytes())?;
477
478 let (output, status_str) = wait_for_output(
480 &mut session.pty,
481 &mut session.decoder,
482 &session.detector,
483 timeout_ms,
484 tx_delta,
485 max_output,
486 )
487 .await;
488
489 session.last_active = Instant::now();
491 if !session.pty.is_alive() {
492 session.status = SessionStatus::Exited(None);
493 }
494
495 let result = SendResult {
496 output,
497 status: status_str,
498 };
499
500 {
502 let mut sessions = self
503 .sessions
504 .lock()
505 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
506 sessions.insert(id.to_string(), session);
507 }
508
509 Ok(result)
510 }
511
512 pub async fn close_session(&self, id: &str) -> Result<String> {
516 let mut session = {
517 let mut sessions = self
518 .sessions
519 .lock()
520 .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
521 match sessions.remove(id) {
522 Some(s) => s,
523 None => return Ok(String::new()),
524 }
525 };
526
527 let remaining = session
529 .pty
530 .try_read_output(Duration::from_millis(100))
531 .await;
532 let mut final_text = session.decoder.decode(&remaining);
533 final_text.push_str(&session.decoder.finish());
534 let final_output = if final_text.is_empty() {
535 String::new()
536 } else {
537 strip_ansi(&final_text)
538 };
539
540 drop(session);
542
543 Ok(final_output)
544 }
545
546 pub fn reap_idle(&self) -> Vec<String> {
550 let mut sessions = match self.sessions.lock() {
551 Ok(s) => s,
552 Err(e) => {
553 tracing::error!("session lock poisoned: {e}");
554 return Vec::new();
555 }
556 };
557
558 let grace_period = Duration::from_secs(5);
559
560 let ids_to_reap: Vec<String> = sessions
561 .iter()
562 .filter(|(_, s)| {
563 let elapsed = s.last_active.elapsed();
564 elapsed > s.idle_timeout && elapsed > grace_period
565 })
566 .map(|(id, _)| id.clone())
567 .collect();
568
569 for id in &ids_to_reap {
570 sessions.remove(id);
571 }
573
574 ids_to_reap
575 }
576
577 pub fn shutdown_all(&self) {
579 match self.sessions.lock() {
580 Ok(mut sessions) => {
581 sessions.drain();
582 }
584 Err(e) => {
585 tracing::error!("session lock poisoned: {e}");
586 }
587 }
588 }
589
590 pub fn active_count(&self) -> usize {
592 match self.sessions.lock() {
593 Ok(s) => s.len(),
594 Err(e) => {
595 tracing::error!("session lock poisoned: {e}");
596 0
597 }
598 }
599 }
600
601 pub fn list_sessions(&self) -> Vec<ShellSessionInfo> {
603 match self.sessions.lock() {
604 Ok(sessions) => sessions
605 .iter()
606 .map(|(id, s)| ShellSessionInfo {
607 id: id.clone(),
608 status: s.status.clone(),
609 created_at: s.created_at,
610 last_active: s.last_active,
611 })
612 .collect(),
613 Err(e) => {
614 tracing::error!("session lock poisoned: {e}");
615 Vec::new()
616 }
617 }
618 }
619}
620
621impl Drop for SessionManager {
626 fn drop(&mut self) {
627 self.shutdown_all();
628 }
629}
630
631pub fn start_reaper(
641 manager: Arc<SessionManager>,
642 cancel: tokio_util::sync::CancellationToken,
643) -> tokio::task::JoinHandle<()> {
644 tokio::spawn(async move {
645 let interval = Duration::from_secs(30);
646 loop {
647 tokio::select! {
648 _ = cancel.cancelled() => break,
649 _ = tokio::time::sleep(interval) => {
650 let reaped = manager.reap_idle();
651 for id in &reaped {
652 tracing::info!(session_id = %id, "reaped idle shell session");
653 }
654 }
655 }
656 }
657 })
658}
659
660#[cfg(test)]
665mod tests {
666 use super::*;
667
668 fn default_manager() -> Arc<SessionManager> {
669 SessionManager::new(ShellConfig::default())
670 }
671
672 fn opts_for(command: &str) -> SessionOpts {
673 SessionOpts {
674 command: Some(command.to_string()),
675 working_directory: None,
676 base_env: None,
677 env: HashMap::from([("HISTFILE".to_string(), "/dev/null".to_string())]),
680 rows: None,
681 cols: None,
682 readiness_timeout_ms: None,
683 idle_timeout: None,
684 }
685 }
686
687 #[tokio::test]
688 async fn canceled_creation_never_spawns_or_registers_a_shell() {
689 let mgr = default_manager();
690 let token = crate::CancellationToken::new();
691 token.cancel();
692 let error = mgr.create_session_cancellable(opts_for("echo should-not-run"), None,
693 256 * 1024, Some(&token)).await.unwrap_err().to_string();
694 assert!(error.contains("canceled before launch"), "{error}");
695 assert!(mgr.sessions.lock().unwrap().is_empty());
696 }
697
698 #[tokio::test]
700 async fn test_create_session_echo_hello() {
701 let mgr = default_manager();
702 let (id, output, _status) = mgr
703 .create_session(opts_for("echo hello"), None, 256 * 1024)
704 .await
705 .expect("failed to create session");
706
707 assert!(id.starts_with("shell_"));
708 assert!(
709 output.contains("hello"),
710 "expected 'hello' in output, got: {output:?}"
711 );
712 }
713
714 #[tokio::test]
716 async fn test_send_input_echo() {
717 let mgr = default_manager();
718 let (id, _initial, _status) = mgr
719 .create_session(opts_for("bash"), None, 256 * 1024)
720 .await
721 .expect("failed to create session");
722
723 let result = mgr
724 .send_input(&id, "echo test\n", None, None, 256 * 1024)
725 .await
726 .expect("failed to send input");
727
728 assert!(
729 result.output.contains("test"),
730 "expected 'test' in output, got: {:?}",
731 result.output
732 );
733
734 let _ = mgr.close_session(&id).await;
736 }
737
738 #[tokio::test]
742 async fn test_character_split_across_polls_is_not_replaced() {
743 let mgr = default_manager();
744 let (id, _initial, _status) = mgr
745 .create_session(opts_for("bash"), None, 256 * 1024)
746 .await
747 .expect("failed to create session");
748
749 let result = mgr
750 .send_input(
751 &id,
752 "printf 'A\\342\\226'; sleep 0.15; printf '\\200B'; echo\n",
753 Some(2000),
754 None,
755 256 * 1024,
756 )
757 .await
758 .expect("failed to send input");
759 let _ = mgr.close_session(&id).await;
760
761 assert!(
762 !result.output.contains('\u{FFFD}'),
763 "split character became U+FFFD: {:?}",
764 result.output
765 );
766 assert!(result.output.contains("A\u{2580}B"), "{:?}", result.output);
767 }
768
769 #[tokio::test]
771 async fn test_close_session_idempotent() {
772 let mgr = default_manager();
773 let (id, _, _status) = mgr
774 .create_session(opts_for("bash"), None, 256 * 1024)
775 .await
776 .expect("failed to create session");
777
778 let result1 = mgr.close_session(&id).await;
779 assert!(result1.is_ok(), "first close should succeed");
780
781 let result2 = mgr.close_session(&id).await;
782 assert!(
783 result2.is_ok(),
784 "second close should also succeed (idempotent)"
785 );
786 assert_eq!(result2.unwrap(), "", "second close returns empty string");
787 }
788
789 #[tokio::test]
791 async fn test_max_sessions_limit() {
792 let config = ShellConfig {
793 max_sessions: 2,
794 ..Default::default()
795 };
796 let mgr = SessionManager::new(config);
797
798 let (id1, _, _s) = mgr
799 .create_session(opts_for("bash"), None, 256 * 1024)
800 .await
801 .expect("session 1");
802 let (id2, _, _s) = mgr
803 .create_session(opts_for("bash"), None, 256 * 1024)
804 .await
805 .expect("session 2");
806
807 let result = mgr.create_session(opts_for("bash"), None, 256 * 1024).await;
808 assert!(result.is_err(), "third session should fail");
809 let err_msg = format!("{}", result.unwrap_err());
810 assert!(
811 err_msg.contains("maximum session limit"),
812 "error should mention limit, got: {err_msg}"
813 );
814
815 let _ = mgr.close_session(&id1).await;
817 let _ = mgr.close_session(&id2).await;
818 }
819
820 #[tokio::test]
822 async fn test_session_not_found() {
823 let mgr = default_manager();
824 let result = mgr
825 .send_input("shell_99", "hello\n", None, None, 256 * 1024)
826 .await;
827 assert!(result.is_err(), "send to non-existent session should fail");
828 let err_msg = format!("{}", result.unwrap_err());
829 assert!(
830 err_msg.contains("not found"),
831 "error should mention 'not found', got: {err_msg}"
832 );
833 }
834
835 #[test]
838 fn test_normalize_output_crlf() {
839 assert_eq!(normalize_output("hello\r\nworld\r\n"), "hello\nworld\n");
840 }
841
842 #[test]
843 fn test_normalize_output_lone_cr() {
844 assert_eq!(normalize_output("abc\rdef"), "abcdef");
845 }
846
847 #[test]
850 fn test_escape_newline() {
851 assert_eq!(process_input_escapes(r"hello\n"), "hello\n");
852 }
853
854 #[test]
855 fn test_escape_tab() {
856 assert_eq!(process_input_escapes(r"a\tb"), "a\tb");
857 }
858
859 #[test]
860 fn test_escape_ctrl_c() {
861 assert_eq!(process_input_escapes(r"\x03"), "\x03");
862 }
863
864 #[test]
865 fn test_escape_ctrl_d() {
866 assert_eq!(process_input_escapes(r"\x04"), "\x04");
867 }
868
869 #[test]
870 fn test_escape_literal_backslash() {
871 assert_eq!(process_input_escapes(r"a\\b"), "a\\b");
872 }
873
874 #[test]
875 fn test_escape_real_newline_passthrough() {
876 assert_eq!(process_input_escapes("hello\n"), "hello\n");
878 }
879
880 #[test]
881 fn test_escape_mixed() {
882 assert_eq!(process_input_escapes(r"ls -la\n"), "ls -la\n");
883 assert_eq!(process_input_escapes(r"124\n"), "124\n");
884 }
885
886 #[test]
887 fn test_escape_unknown_sequence() {
888 assert_eq!(process_input_escapes(r"\q"), "\\q");
890 }
891
892 #[test]
893 fn test_escape_hex_partial() {
894 assert_eq!(process_input_escapes(r"\xZZ"), "\\xZZ");
896 }
897
898 #[test]
899 fn test_escape_bell() {
900 assert_eq!(process_input_escapes(r"\a"), "\x07");
901 }
902
903 #[test]
904 fn test_escape_backspace() {
905 assert_eq!(process_input_escapes(r"\b"), "\x08");
906 }
907
908 #[test]
909 fn test_escape_null() {
910 assert_eq!(process_input_escapes(r"\0"), "\0");
911 }
912
913 #[test]
914 fn test_escape_esc_blocked() {
915 assert_eq!(process_input_escapes(r"\e"), "");
917 }
918
919 #[test]
920 fn test_escape_hex_1b_blocked() {
921 assert_eq!(process_input_escapes(r"\x1b"), "");
923 }
924
925 #[test]
926 fn test_escape_hex_high_byte_blocked() {
927 assert_eq!(process_input_escapes(r"\x80"), "");
929 assert_eq!(process_input_escapes(r"\xff"), "");
930 }
931
932 #[test]
933 fn test_escape_hex_del_allowed() {
934 assert_eq!(process_input_escapes(r"\x7f"), "\x7f");
936 }
937}