1use std::collections::HashMap;
22use std::fmt;
23use std::io::{self, Read};
24use std::path::PathBuf;
25use std::sync::mpsc;
26use std::thread;
27use std::time::{Duration, Instant};
28
29use crate::{
30 DEFAULT_INPUT_WRITE_TIMEOUT, PtyInputWriter, deadline_after, detach_join, normalize_line_input,
31};
32use portable_pty::{CommandBuilder, ExitStatus, MasterPty, PtySize};
33
34#[derive(Debug, Clone)]
36pub struct ShellConfig {
37 pub shell: Option<PathBuf>,
40
41 pub args: Vec<String>,
43
44 pub env: HashMap<String, String>,
46
47 pub cwd: Option<PathBuf>,
49
50 pub cols: u16,
52
53 pub rows: u16,
55
56 pub term: String,
58
59 pub log_events: bool,
61
62 pub input_write_timeout: Duration,
64}
65
66impl Default for ShellConfig {
67 fn default() -> Self {
68 Self {
69 shell: None,
70 args: Vec::new(),
71 env: HashMap::new(),
72 cwd: None,
73 cols: 80,
74 rows: 24,
75 term: "xterm-256color".to_string(),
76 log_events: false,
77 input_write_timeout: DEFAULT_INPUT_WRITE_TIMEOUT,
78 }
79 }
80}
81
82impl ShellConfig {
83 #[must_use]
85 pub fn with_shell(shell: impl Into<PathBuf>) -> Self {
86 Self {
87 shell: Some(shell.into()),
88 ..Default::default()
89 }
90 }
91
92 #[must_use]
94 pub fn size(mut self, cols: u16, rows: u16) -> Self {
95 self.cols = cols;
96 self.rows = rows;
97 self
98 }
99
100 #[must_use]
102 pub fn arg(mut self, arg: impl Into<String>) -> Self {
103 self.args.push(arg.into());
104 self
105 }
106
107 #[must_use]
109 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
110 self.env.insert(key.into(), value.into());
111 self
112 }
113
114 #[must_use]
116 pub fn inherit_env(mut self) -> Self {
117 for (key, value) in std::env::vars() {
118 self.env.entry(key).or_insert(value);
119 }
120 self
121 }
122
123 #[must_use]
125 pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
126 self.cwd = Some(path.into());
127 self
128 }
129
130 #[must_use]
132 pub fn term(mut self, term: impl Into<String>) -> Self {
133 self.term = term.into();
134 self
135 }
136
137 #[must_use]
139 pub fn logging(mut self, enabled: bool) -> Self {
140 self.log_events = enabled;
141 self
142 }
143
144 #[must_use]
146 pub fn input_write_timeout(mut self, timeout: Duration) -> Self {
147 self.input_write_timeout = timeout;
148 self
149 }
150
151 fn resolve_shell(&self) -> PathBuf {
153 if let Some(ref shell) = self.shell {
154 return shell.clone();
155 }
156
157 if let Some(shell) = preferred_default_shell() {
158 return shell;
159 }
160
161 if let Ok(shell) = std::env::var("SHELL") {
163 return PathBuf::from(shell);
164 }
165
166 PathBuf::from("/bin/sh")
168 }
169}
170
171#[derive(Debug)]
173enum ReaderMsg {
174 Data(Vec<u8>),
175 Eof,
176 Err(io::Error),
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum ProcessState {
182 Running,
184 Exited(u32),
186 Signaled(String),
188 Unknown,
190}
191
192impl ProcessState {
193 #[must_use]
195 pub fn is_alive(&self) -> bool {
196 matches!(self, ProcessState::Running)
197 }
198
199 #[must_use]
201 pub fn exit_code(&self) -> Option<u32> {
202 match self {
203 ProcessState::Exited(code) => Some(*code),
204 _ => None,
205 }
206 }
207
208 #[must_use]
210 pub fn signal_name(&self) -> Option<&str> {
211 match self {
212 ProcessState::Signaled(signal) => Some(signal.as_str()),
213 _ => None,
214 }
215 }
216}
217
218pub struct PtyProcess {
245 child: Box<dyn portable_pty::Child + Send + Sync>,
246 master: Box<dyn MasterPty + Send>,
247 input_writer: PtyInputWriter,
248 rx: mpsc::Receiver<ReaderMsg>,
249 reader_thread: Option<thread::JoinHandle<()>>,
250 captured: Vec<u8>,
251 eof: bool,
252 state: ProcessState,
253 config: ShellConfig,
254}
255
256impl fmt::Debug for PtyProcess {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 f.debug_struct("PtyProcess")
259 .field("pid", &self.child.process_id())
260 .field("state", &self.state)
261 .field("captured_len", &self.captured.len())
262 .field("eof", &self.eof)
263 .finish()
264 }
265}
266
267impl PtyProcess {
268 pub fn spawn(config: ShellConfig) -> io::Result<Self> {
277 let shell_path = config.resolve_shell();
278
279 if config.log_events {
280 log_event(
281 "PTY_PROCESS_SPAWN",
282 format!("shell={}", shell_path.display()),
283 );
284 }
285
286 let mut cmd = CommandBuilder::new(&shell_path);
288
289 for arg in &config.args {
291 cmd.arg(arg);
292 }
293
294 cmd.env("TERM", &config.term);
296 for (key, value) in &config.env {
297 cmd.env(key, value);
298 }
299
300 if let Some(ref cwd) = config.cwd {
302 cmd.cwd(cwd);
303 }
304
305 let pty_system = portable_pty::native_pty_system();
307 let pair = pty_system
308 .openpty(PtySize {
309 rows: config.rows,
310 cols: config.cols,
311 pixel_width: 0,
312 pixel_height: 0,
313 })
314 .map_err(|e| io::Error::other(e.to_string()))?;
315
316 let child = pair
318 .slave
319 .spawn_command(cmd)
320 .map_err(|e| io::Error::other(e.to_string()))?;
321
322 let mut reader = pair
324 .master
325 .try_clone_reader()
326 .map_err(|e| io::Error::other(e.to_string()))?;
327 let writer = pair
328 .master
329 .take_writer()
330 .map_err(|e| io::Error::other(e.to_string()))?;
331 let input_writer = PtyInputWriter::spawn(writer, "ftui-pty-process-writer")?;
332
333 let (tx, rx) = mpsc::channel::<ReaderMsg>();
335 let reader_thread = thread::spawn(move || {
336 let mut buf = [0u8; 8192];
337 loop {
338 match reader.read(&mut buf) {
339 Ok(0) => {
340 let _ = tx.send(ReaderMsg::Eof);
341 break;
342 }
343 Ok(n) => {
344 let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
345 }
346 Err(err) => {
347 let _ = tx.send(ReaderMsg::Err(err));
348 break;
349 }
350 }
351 }
352 });
353
354 if config.log_events {
355 log_event(
356 "PTY_PROCESS_STARTED",
357 format!("pid={:?}", child.process_id()),
358 );
359 }
360
361 Ok(Self {
362 child,
363 master: pair.master,
364 input_writer,
365 rx,
366 reader_thread: Some(reader_thread),
367 captured: Vec::new(),
368 eof: false,
369 state: ProcessState::Running,
370 config,
371 })
372 }
373
374 #[must_use]
378 pub fn is_alive(&mut self) -> bool {
379 self.poll_state();
380 self.state.is_alive()
381 }
382
383 #[must_use]
385 pub fn state(&mut self) -> ProcessState {
386 self.poll_state();
387 self.state.clone()
388 }
389
390 #[must_use]
392 pub fn pid(&self) -> Option<u32> {
393 self.child.process_id()
394 }
395
396 pub fn kill(&mut self) -> io::Result<()> {
404 if !self.state.is_alive() {
405 return Ok(());
406 }
407
408 if self.config.log_events {
409 log_event(
410 "PTY_PROCESS_KILL",
411 format!("pid={:?}", self.child.process_id()),
412 );
413 }
414
415 self.child.kill()?;
417 self.state = ProcessState::Unknown;
418
419 match self.wait_timeout(Duration::from_millis(100)) {
421 Ok(status) => {
422 self.update_state_from_exit(&status);
423 }
424 Err(_) => {
425 self.state = ProcessState::Unknown;
427 }
428 }
429
430 Ok(())
431 }
432
433 pub fn wait(&mut self) -> io::Result<ExitStatus> {
441 let status = self.child.wait()?;
442 self.update_state_from_exit(&status);
443 Ok(status)
444 }
445
446 pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result<ExitStatus> {
452 let deadline = deadline_after(timeout, "PtyProcess wait_timeout")?;
453
454 loop {
455 match self.child.try_wait()? {
457 Some(status) => {
458 self.update_state_from_exit(&status);
459 return Ok(status);
460 }
461 None => {
462 if Instant::now() >= deadline {
463 return Err(io::Error::new(
464 io::ErrorKind::TimedOut,
465 "wait_timeout: process did not exit in time",
466 ));
467 }
468 thread::sleep(Duration::from_millis(10));
469 }
470 }
471 }
472 }
473
474 pub fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
483 let result = self.input_writer.write_with_timeout(
484 data,
485 self.config.input_write_timeout,
486 "ftui-pty-process-write",
487 "ftui-pty-process-detached-write",
488 );
489 if matches!(
490 result.as_ref().err().map(io::Error::kind),
491 Some(io::ErrorKind::TimedOut)
492 ) {
493 let _ = self.child.kill();
494 self.state = ProcessState::Unknown;
495 }
496 result?;
497
498 if self.config.log_events {
499 log_event("PTY_PROCESS_INPUT", format!("bytes={}", data.len()));
500 }
501
502 Ok(())
503 }
504
505 pub fn write_line(&mut self, line: impl AsRef<[u8]>) -> io::Result<()> {
507 let normalized = normalize_line_input(line.as_ref());
508 self.write_all(&normalized)
509 }
510
511 pub fn read_available(&mut self) -> io::Result<Vec<u8>> {
513 self.drain_channel(Duration::ZERO)?;
514 Ok(self.captured.clone())
515 }
516
517 pub fn read_until(&mut self, pattern: &[u8], timeout: Duration) -> io::Result<Vec<u8>> {
523 if pattern.is_empty() {
524 return Ok(self.captured.clone());
525 }
526
527 let deadline = deadline_after(timeout, "PtyProcess read_until")?;
528
529 loop {
530 if find_subsequence(&self.captured, pattern).is_some() {
532 return Ok(self.captured.clone());
533 }
534
535 if self.eof || Instant::now() >= deadline {
536 break;
537 }
538
539 let remaining = deadline.saturating_duration_since(Instant::now());
540 self.drain_channel(remaining)?;
541 }
542
543 Err(io::Error::new(
544 io::ErrorKind::TimedOut,
545 format!(
546 "read_until: pattern not found (captured {} bytes)",
547 self.captured.len()
548 ),
549 ))
550 }
551
552 pub fn drain(&mut self, timeout: Duration) -> io::Result<usize> {
554 if self.eof {
555 return Ok(0);
556 }
557
558 let start_len = self.captured.len();
559 let deadline = deadline_after(timeout, "PtyProcess drain")?;
560
561 while !self.eof && Instant::now() < deadline {
562 let remaining = deadline.saturating_duration_since(Instant::now());
563 match self.drain_channel(remaining) {
564 Ok(0) if self.eof => break,
565 Ok(_) => continue,
566 Err(e) if e.kind() == io::ErrorKind::TimedOut => break,
567 Err(e) => return Err(e),
568 }
569 }
570
571 Ok(self.captured.len() - start_len)
572 }
573
574 #[must_use]
576 pub fn output(&self) -> &[u8] {
577 &self.captured
578 }
579
580 pub fn clear_output(&mut self) {
582 self.captured.clear();
583 }
584
585 pub fn resize(&mut self, cols: u16, rows: u16) -> io::Result<()> {
591 if self.config.log_events {
592 log_event("PTY_PROCESS_RESIZE", format!("cols={} rows={}", cols, rows));
593 }
594 self.master
595 .resize(PtySize {
596 rows,
597 cols,
598 pixel_width: 0,
599 pixel_height: 0,
600 })
601 .map_err(|e| io::Error::other(e.to_string()))
602 }
603
604 fn poll_state(&mut self) {
607 if !self.state.is_alive() {
608 return;
609 }
610
611 match self.child.try_wait() {
612 Ok(Some(status)) => {
613 self.update_state_from_exit(&status);
614 }
615 Ok(None) => {
616 }
618 Err(_) => {
619 self.state = ProcessState::Unknown;
620 }
621 }
622 }
623
624 fn update_state_from_exit(&mut self, status: &ExitStatus) {
625 if let Some(signal) = status.signal() {
626 self.state = ProcessState::Signaled(signal.to_string());
627 return;
628 }
629
630 self.state = ProcessState::Exited(status.exit_code());
631 }
632
633 fn drain_channel(&mut self, timeout: Duration) -> io::Result<usize> {
634 if self.eof {
635 return Ok(0);
636 }
637
638 let mut total = 0usize;
639
640 let first = if timeout.is_zero() {
642 match self.rx.try_recv() {
643 Ok(msg) => Some(msg),
644 Err(mpsc::TryRecvError::Empty) => return Ok(0),
645 Err(mpsc::TryRecvError::Disconnected) => {
646 self.eof = true;
647 return Ok(0);
648 }
649 }
650 } else {
651 match self.rx.recv_timeout(timeout) {
652 Ok(msg) => Some(msg),
653 Err(mpsc::RecvTimeoutError::Timeout) => return Ok(0),
654 Err(mpsc::RecvTimeoutError::Disconnected) => {
655 self.eof = true;
656 return Ok(0);
657 }
658 }
659 };
660
661 let mut msg = match first {
662 Some(m) => m,
663 None => return Ok(0),
664 };
665
666 loop {
667 match msg {
668 ReaderMsg::Data(bytes) => {
669 total = total.saturating_add(bytes.len());
670 self.captured.extend_from_slice(&bytes);
671 }
672 ReaderMsg::Eof => {
673 self.eof = true;
674 break;
675 }
676 ReaderMsg::Err(err) => return Err(err),
677 }
678
679 match self.rx.try_recv() {
680 Ok(next) => msg = next,
681 Err(mpsc::TryRecvError::Empty) => break,
682 Err(mpsc::TryRecvError::Disconnected) => {
683 self.eof = true;
684 break;
685 }
686 }
687 }
688
689 if total > 0 && self.config.log_events {
690 log_event("PTY_PROCESS_OUTPUT", format!("bytes={}", total));
691 }
692
693 Ok(total)
694 }
695}
696
697impl Drop for PtyProcess {
698 fn drop(&mut self) {
699 let _ = self.child.kill();
701 self.input_writer.flush_best_effort();
702 self.input_writer
703 .detach_thread("ftui-pty-process-detached-writer");
704
705 if let Some(handle) = self.reader_thread.take() {
706 detach_reader_join(handle);
707 }
708
709 if self.config.log_events {
710 log_event(
711 "PTY_PROCESS_DROP",
712 format!("pid={:?}", self.child.process_id()),
713 );
714 }
715 }
716}
717
718fn detach_reader_join(handle: thread::JoinHandle<()>) {
719 detach_join(handle, "ftui-pty-process-detached-reader-join");
720}
721
722fn preferred_default_shell() -> Option<PathBuf> {
725 ["/bin/bash", "/usr/bin/bash"]
726 .into_iter()
727 .map(PathBuf::from)
728 .find(|candidate| candidate.is_file())
729}
730
731fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
732 if needle.is_empty() {
733 return Some(0);
734 }
735 haystack
736 .windows(needle.len())
737 .position(|window| window == needle)
738}
739
740fn log_event(event: &str, detail: impl fmt::Display) {
741 let timestamp = time::OffsetDateTime::now_utc()
742 .format(&time::format_description::well_known::Rfc3339)
743 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
744 eprintln!("[{}] {}: {}", timestamp, event, detail);
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
754 fn shell_config_defaults() {
755 let config = ShellConfig::default();
756 assert!(config.shell.is_none());
757 assert!(config.args.is_empty());
758 assert!(config.env.is_empty());
759 assert!(config.cwd.is_none());
760 assert_eq!(config.cols, 80);
761 assert_eq!(config.rows, 24);
762 assert_eq!(config.term, "xterm-256color");
763 assert!(!config.log_events);
764 }
765
766 #[test]
767 fn shell_config_with_shell() {
768 let config = ShellConfig::with_shell("/bin/bash");
769 assert_eq!(config.shell, Some(PathBuf::from("/bin/bash")));
770 }
771
772 #[test]
773 fn shell_config_builder_chain() {
774 let config = ShellConfig::default()
775 .size(120, 40)
776 .arg("-l")
777 .env("FOO", "bar")
778 .cwd("/tmp")
779 .term("dumb")
780 .logging(true);
781
782 assert_eq!(config.cols, 120);
783 assert_eq!(config.rows, 40);
784 assert_eq!(config.args, vec!["-l"]);
785 assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
786 assert_eq!(config.cwd, Some(PathBuf::from("/tmp")));
787 assert_eq!(config.term, "dumb");
788 assert!(config.log_events);
789 }
790
791 #[test]
792 fn shell_config_resolve_shell_explicit() {
793 let config = ShellConfig::with_shell("/bin/zsh");
794 assert_eq!(config.resolve_shell(), PathBuf::from("/bin/zsh"));
795 }
796
797 #[test]
798 fn shell_config_resolve_shell_prefers_bash_when_available() {
799 let config = ShellConfig::default();
800 let shell = config.resolve_shell();
801
802 if let Some(preferred) = preferred_default_shell() {
803 assert_eq!(shell, preferred);
804 } else if let Ok(env_shell) = std::env::var("SHELL") {
805 assert_eq!(shell, PathBuf::from(env_shell));
806 } else {
807 assert_eq!(shell, PathBuf::from("/bin/sh"));
808 }
809 }
810
811 #[test]
814 fn process_state_is_alive() {
815 assert!(ProcessState::Running.is_alive());
816 assert!(!ProcessState::Exited(0).is_alive());
817 assert!(!ProcessState::Signaled("SIGTERM".to_string()).is_alive());
818 assert!(!ProcessState::Unknown.is_alive());
819 }
820
821 #[test]
822 fn process_state_exit_code() {
823 assert_eq!(ProcessState::Running.exit_code(), None);
824 assert_eq!(ProcessState::Exited(0).exit_code(), Some(0));
825 assert_eq!(ProcessState::Exited(7).exit_code(), Some(7));
826 assert_eq!(
827 ProcessState::Signaled("SIGTERM".to_string()).exit_code(),
828 None
829 );
830 assert_eq!(ProcessState::Unknown.exit_code(), None);
831 }
832
833 #[test]
834 fn process_state_signal_name() {
835 assert_eq!(ProcessState::Running.signal_name(), None);
836 assert_eq!(
837 ProcessState::Signaled("Terminated".to_string()).signal_name(),
838 Some("Terminated")
839 );
840 assert_eq!(ProcessState::Exited(7).signal_name(), None);
841 }
842
843 #[test]
846 fn find_subsequence_empty_needle() {
847 assert_eq!(find_subsequence(b"anything", b""), Some(0));
848 }
849
850 #[test]
851 fn find_subsequence_found() {
852 assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
853 }
854
855 #[test]
856 fn find_subsequence_not_found() {
857 assert_eq!(find_subsequence(b"hello world", b"xyz"), None);
858 }
859
860 #[cfg(unix)]
863 #[test]
864 fn spawn_and_basic_io() {
865 let config = ShellConfig::default()
866 .logging(false)
867 .env("FTUI_BASIC", "hello-pty-process");
868 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
869
870 assert!(proc.is_alive());
872 assert!(proc.pid().is_some());
873
874 proc.write_line("echo $FTUI_BASIC")
876 .expect("write should succeed");
877
878 let output = proc
879 .read_until(b"hello-pty-process", Duration::from_secs(5))
880 .expect("should find output");
881 assert!(
882 output
883 .windows(b"hello-pty-process".len())
884 .any(|w| w == b"hello-pty-process"),
885 "expected to find 'hello-pty-process' in output"
886 );
887
888 proc.kill().expect("kill should succeed");
890 assert!(!proc.is_alive());
891 }
892
893 #[cfg(unix)]
894 #[test]
895 fn spawn_with_env() {
896 let config = ShellConfig::default()
897 .logging(false)
898 .env("TEST_VAR", "test_value_123");
899
900 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
901
902 proc.write_line("echo $TEST_VAR")
903 .expect("write should succeed");
904
905 let output = proc
906 .read_until(b"test_value_123", Duration::from_secs(5))
907 .expect("should find env var in output");
908
909 assert!(
910 output
911 .windows(b"test_value_123".len())
912 .any(|w| w == b"test_value_123"),
913 "expected to find env var value in output"
914 );
915
916 proc.kill().expect("kill should succeed");
917 }
918
919 #[cfg(unix)]
920 #[test]
921 fn exit_command_terminates() {
922 let config = ShellConfig::default().logging(false);
923 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
924
925 proc.write_line("exit 0").expect("write should succeed");
926
927 let status = proc
929 .wait_timeout(Duration::from_secs(5))
930 .expect("wait should succeed");
931 assert!(status.success());
932 assert!(!proc.is_alive());
933 }
934
935 #[cfg(unix)]
936 #[test]
937 fn non_zero_exit_preserves_exit_code() {
938 let config = ShellConfig::with_shell("/bin/sh")
939 .logging(false)
940 .arg("-c")
941 .arg("exit 7");
942 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
943
944 let status = proc
945 .wait_timeout(Duration::from_secs(5))
946 .expect("wait should succeed");
947 assert!(!status.success());
948 assert_eq!(status.exit_code(), 7);
949 assert_eq!(proc.state().exit_code(), Some(7));
950 assert_eq!(proc.state(), ProcessState::Exited(7));
951 }
952
953 #[cfg(unix)]
954 #[test]
955 fn signal_exit_preserves_signaled_state() {
956 let config = ShellConfig::with_shell("/bin/sh")
957 .logging(false)
958 .arg("-c")
959 .arg("kill -KILL $$");
960 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
961
962 let status = proc
963 .wait_timeout(Duration::from_secs(5))
964 .expect("wait should succeed");
965 assert!(!status.success());
966 assert!(status.signal().is_some(), "expected signal exit status");
967
968 let state = proc.state();
969 assert!(matches!(state, ProcessState::Signaled(_)));
970 assert!(state.signal_name().is_some());
971 }
972
973 #[cfg(unix)]
974 #[test]
975 fn kill_is_idempotent() {
976 let config = ShellConfig::default().logging(false);
977 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
978
979 proc.kill().expect("first kill should succeed");
980 proc.kill().expect("second kill should succeed");
981 proc.kill().expect("third kill should succeed");
982
983 assert!(!proc.is_alive());
984 }
985
986 #[cfg(unix)]
987 #[test]
988 fn drain_captures_all_output() {
989 let config = ShellConfig::default().logging(false);
990 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
991
992 proc.write_line("for i in 1 2 3 4 5; do echo line$i; done; exit 0")
994 .expect("write should succeed");
995
996 let _ = proc.wait_timeout(Duration::from_secs(5));
998
999 let _ = proc.drain(Duration::from_secs(2));
1001
1002 let output = String::from_utf8_lossy(proc.output());
1003 for i in 1..=5 {
1004 assert!(
1005 output.contains(&format!("line{i}")),
1006 "missing line{i} in output: {output:?}"
1007 );
1008 }
1009 }
1010
1011 #[cfg(unix)]
1012 #[test]
1013 fn clear_output_works() {
1014 let config = ShellConfig::default().logging(false);
1015 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
1016
1017 proc.write_line("echo test").expect("write should succeed");
1018 let _ = proc
1019 .read_until(b"test", Duration::from_secs(5))
1020 .expect("should capture output after sending a line");
1021
1022 assert!(!proc.output().is_empty());
1023
1024 proc.clear_output();
1025 assert!(proc.output().is_empty());
1026
1027 proc.kill().expect("kill should succeed");
1028 }
1029
1030 #[cfg(unix)]
1031 #[test]
1032 fn specific_shell_path() {
1033 let config = ShellConfig::with_shell("/bin/sh").logging(false);
1034 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
1035
1036 assert!(proc.is_alive());
1037 proc.kill().expect("kill should succeed");
1038 }
1039
1040 #[cfg(unix)]
1041 #[test]
1042 fn invalid_shell_fails() {
1043 let config = ShellConfig::with_shell("/nonexistent/shell").logging(false);
1044 let result = PtyProcess::spawn(config);
1045
1046 assert!(result.is_err());
1047 }
1048
1049 #[cfg(unix)]
1050 #[test]
1051 fn drop_does_not_block_when_background_process_keeps_pty_open() {
1052 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1053 let (done_tx, done_rx) = mpsc::channel();
1054 let drop_thread = thread::spawn(move || {
1055 let proc = PtyProcess::spawn(
1056 ShellConfig::with_shell(shell)
1057 .logging(false)
1058 .arg("-c")
1059 .arg("sleep 1 >/dev/null 2>&1 &"),
1060 )
1061 .expect("spawn should succeed");
1062 drop(proc);
1063 done_tx.send(()).expect("signal drop completion");
1064 });
1065
1066 assert!(
1067 done_rx.recv_timeout(Duration::from_millis(400)).is_ok(),
1068 "PtyProcess drop should not wait for background descendants to close the PTY"
1069 );
1070 drop_thread.join().expect("drop thread join");
1071 }
1072
1073 #[cfg(unix)]
1074 #[test]
1075 fn write_all_times_out_when_child_does_not_drain_stdin() {
1076 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1077 let config = ShellConfig::with_shell(shell)
1078 .logging(false)
1079 .input_write_timeout(Duration::from_millis(100))
1080 .arg("-c")
1081 .arg("sleep 5");
1082 let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
1083
1084 let payload = vec![b'x'; 8 * 1024 * 1024];
1085 let start = Instant::now();
1086 let err = proc
1087 .write_all(&payload)
1088 .expect_err("write_all should time out when the child never reads stdin");
1089 assert_eq!(err.kind(), io::ErrorKind::TimedOut);
1090 assert!(
1091 start.elapsed() < Duration::from_secs(2),
1092 "write_all should fail promptly instead of hanging"
1093 );
1094 }
1095}