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