From c0a5ed04770519391cbe198694327a4f856b10d8 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 16 Mar 2026 09:01:33 -0500 Subject: [PATCH 1/7] refactor(process): Draw attention to public APIs --- src/process.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/process.rs b/src/process.rs index bbe0683b..30137a1c 100644 --- a/src/process.rs +++ b/src/process.rs @@ -5,7 +5,6 @@ use nix; use nix::fcntl::{OFlag, open}; use nix::libc::STDERR_FILENO; use nix::pty::{PtyMaster, grantpt, posix_openpt, unlockpt}; -pub use nix::sys::{signal, wait}; use nix::sys::{stat, termios}; use nix::unistd::{ ForkResult, Pid, close, dup, dup2_stderr, dup2_stdin, dup2_stdout, fork, setsid, @@ -18,6 +17,8 @@ use std::os::unix::process::CommandExt; use std::process::Command; use std::{thread, time}; +pub use nix::sys::{signal, wait}; + /// Start a process in a forked tty to interact with it like you would /// within a terminal /// From ef54ee8accb4beed99b95bf5fa8e7e03ac742695 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 16 Mar 2026 09:04:54 -0500 Subject: [PATCH 2/7] fix(process): Directly re-export needed types --- examples/exit_code.rs | 8 ++++---- src/process.rs | 26 ++++++++++++++------------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/examples/exit_code.rs b/examples/exit_code.rs index 67486de6..dfb57065 100644 --- a/examples/exit_code.rs +++ b/examples/exit_code.rs @@ -1,5 +1,5 @@ use rexpect::error::Error; -use rexpect::process::wait; +use rexpect::process::WaitStatus; use rexpect::spawn; /// The following code emits: @@ -9,14 +9,14 @@ use rexpect::spawn; fn main() -> Result<(), Error> { let p = spawn("cat /etc/passwd", Some(2000))?; match p.process.wait() { - Ok(wait::WaitStatus::Exited(_, 0)) => println!("cat exited with code 0, all good!"), + Ok(WaitStatus::Exited(_, 0)) => println!("cat exited with code 0, all good!"), _ => println!("cat exited with code >0, or it was killed"), } let mut p = spawn("cat /this/does/not/exist", Some(2000))?; match p.process.wait() { - Ok(wait::WaitStatus::Exited(_, 0)) => println!("cat succeeded"), - Ok(wait::WaitStatus::Exited(_, c)) => { + Ok(WaitStatus::Exited(_, 0)) => println!("cat succeeded"), + Ok(WaitStatus::Exited(_, c)) => { println!("Cat failed with exit code {c}"); println!("Output (stdout and stderr): {}", p.exp_eof()?); } diff --git a/src/process.rs b/src/process.rs index 30137a1c..1a928c5f 100644 --- a/src/process.rs +++ b/src/process.rs @@ -18,6 +18,8 @@ use std::process::Command; use std::{thread, time}; pub use nix::sys::{signal, wait}; +pub use signal::Signal; +pub use wait::WaitStatus; /// Start a process in a forked tty to interact with it like you would /// within a terminal @@ -162,7 +164,7 @@ impl PtyProcess { /// # Example /// ```rust,no_run /// - /// use rexpect::process::{self, wait::WaitStatus}; + /// use rexpect::process::{self, WaitStatus}; /// use std::process::Command; /// /// # fn main() { @@ -174,26 +176,26 @@ impl PtyProcess { /// # } /// ``` /// - pub fn status(&self) -> Option { + pub fn status(&self) -> Option { wait::waitpid(self.child_pid, Some(wait::WaitPidFlag::WNOHANG)).ok() } /// Wait until process has exited (non-blocking). /// /// If the process doesn't terminate this will block forever. - pub fn wait(&self) -> Result { + pub fn wait(&self) -> Result { wait::waitpid(self.child_pid, None).map_err(Error::from) } /// Regularly exit the process (blocking). /// /// This method is blocking until the process is dead - pub fn exit(&mut self) -> Result { + pub fn exit(&mut self) -> Result { self.kill(signal::SIGTERM) } /// Kill the process with a specific signal (non-blocking). - pub fn signal(&mut self, sig: signal::Signal) -> Result<(), Error> { + pub fn signal(&mut self, sig: Signal) -> Result<(), Error> { signal::kill(self.child_pid, sig).map_err(Error::from) } @@ -207,26 +209,26 @@ impl PtyProcess { /// /// If `kill_timeout` is set and a repeated sending of signal does not result in the process /// being killed, then `kill -9` is sent after the `kill_timeout` duration has elapsed. - pub fn kill(&mut self, sig: signal::Signal) -> Result { + pub fn kill(&mut self, sig: Signal) -> Result { let start = time::Instant::now(); loop { match signal::kill(self.child_pid, sig) { Ok(_) => {} // process was already killed before -> ignore Err(nix::errno::Errno::ESRCH) => { - return Ok(wait::WaitStatus::Exited(Pid::from_raw(0), 0)); + return Ok(WaitStatus::Exited(Pid::from_raw(0), 0)); } Err(e) => return Err(Error::from(e)), } match self.status() { - Some(status) if status != wait::WaitStatus::StillAlive => return Ok(status), + Some(status) if status != WaitStatus::StillAlive => return Ok(status), Some(_) | None => thread::sleep(time::Duration::from_millis(100)), } // kill -9 if timeout is reached if let Some(timeout) = self.kill_timeout { if start.elapsed() > timeout { - signal::kill(self.child_pid, signal::Signal::SIGKILL).map_err(Error::from)?; + signal::kill(self.child_pid, Signal::SIGKILL).map_err(Error::from)?; } } } @@ -235,7 +237,7 @@ impl PtyProcess { impl Drop for PtyProcess { fn drop(&mut self) { - if let Some(wait::WaitStatus::StillAlive) = self.status() { + if let Some(WaitStatus::StillAlive) = self.status() { self.exit().expect("cannot exit"); } } @@ -244,7 +246,7 @@ impl Drop for PtyProcess { #[cfg(test)] mod tests { use super::*; - use nix::sys::{signal, wait}; + use nix::sys::wait; use std::io::{BufRead, BufReader, LineWriter, Write}; #[test] @@ -264,7 +266,7 @@ mod tests { thread::sleep(time::Duration::from_millis(100)); writer.write_all(&[3])?; // send ^C writer.flush()?; - let should = wait::WaitStatus::Signaled(process.child_pid, signal::Signal::SIGINT, false); + let should = WaitStatus::Signaled(process.child_pid, Signal::SIGINT, false); assert_eq!(should, wait::waitpid(process.child_pid, None).unwrap()); Ok(()) } From b7c7dcfd02e11a66d6e01e8ec3b9ddcc248668bd Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 16 Mar 2026 09:34:44 -0500 Subject: [PATCH 3/7] docs(process): Encourage get_file_handle --- src/process.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/process.rs b/src/process.rs index 1a928c5f..c1db0b7c 100644 --- a/src/process.rs +++ b/src/process.rs @@ -45,8 +45,7 @@ pub use wait::WaitStatus; /// # fn main() { /// /// let mut process = PtyProcess::new(Command::new("cat")).expect("could not execute cat"); -/// let fd = dup(&process.pty).unwrap(); -/// let f = File::from(fd); +/// let f = process.get_file_handle().unwrap(); /// let mut writer = LineWriter::new(&f); /// let mut reader = BufReader::new(&f); /// process.exit().expect("could not terminate process"); From 2aa73c8ab62a57308e1220d30f0fe28f2576c9d6 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 16 Mar 2026 09:37:03 -0500 Subject: [PATCH 4/7] feat(reader): Add builder methods to Options --- src/reader.rs | 32 ++++++++++++++++++-------------- src/session.rs | 17 ++--------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/src/reader.rs b/src/reader.rs index b49c298f..6b1efd09 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -19,6 +19,22 @@ pub struct Options { pub strip_ansi_escape_codes: bool, } +impl Options { + pub fn new() -> Self { + Default::default() + } + + pub fn timeout_ms(mut self, timeout_ms: Option) -> Self { + self.timeout_ms = timeout_ms; + self + } + + pub fn strip_ansi_escape_codes(mut self, yes: bool) -> Self { + self.strip_ansi_escape_codes = yes; + self + } +} + /// Non blocking reader /// /// Typically you'd need that to check for output of a process without blocking your thread. @@ -408,13 +424,7 @@ mod tests { #[test] fn test_skip_partial_ansi_code() { let f = io::Cursor::new("\x1b[31;1;4mHello\x1b[1"); - let mut r = NBReader::new( - f, - Options { - timeout_ms: None, - strip_ansi_escape_codes: true, - }, - ); + let mut r = NBReader::new(f, Options::new().strip_ansi_escape_codes(true)); let bytes = r .read_until(&ReadUntil::String("Hello".to_owned())) .unwrap(); @@ -425,13 +435,7 @@ mod tests { #[test] fn test_skip_ansi_codes() { let f = io::Cursor::new("\x1b[31;1;4mHello\x1b[0m"); - let mut r = NBReader::new( - f, - Options { - timeout_ms: None, - strip_ansi_escape_codes: true, - }, - ); + let mut r = NBReader::new(f, Options::new().strip_ansi_escape_codes(true)); let bytes = r .read_until(&ReadUntil::String("Hello".to_owned())) .unwrap(); diff --git a/src/session.rs b/src/session.rs index 273cbb8c..8698ecc0 100644 --- a/src/session.rs +++ b/src/session.rs @@ -245,13 +245,7 @@ fn tokenize_command(program: &str) -> Result, Error> { /// See [`spawn`] pub fn spawn_command(command: Command, timeout_ms: Option) -> Result { - spawn_with_options( - command, - Options { - timeout_ms, - strip_ansi_escape_codes: false, - }, - ) + spawn_with_options(command, Options::new().timeout_ms(timeout_ms)) } /// See [`spawn`] @@ -456,14 +450,7 @@ pub fn spawn_stream( writer: W, timeout_ms: Option, ) -> StreamSession { - StreamSession::new( - reader, - writer, - Options { - timeout_ms, - strip_ansi_escape_codes: false, - }, - ) + StreamSession::new(reader, writer, Options::new().timeout_ms(timeout_ms)) } #[cfg(test)] From 3c6456338d543e746e3be3ca99ea796c5f7f04a7 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 16 Mar 2026 09:38:13 -0500 Subject: [PATCH 5/7] feat(session): Add accessors to PtySession --- examples/exit_code.rs | 4 ++-- src/session.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/exit_code.rs b/examples/exit_code.rs index dfb57065..e341fd51 100644 --- a/examples/exit_code.rs +++ b/examples/exit_code.rs @@ -8,13 +8,13 @@ use rexpect::spawn; /// Output (stdout and stderr): cat: /this/does/not/exist: No such file or directory fn main() -> Result<(), Error> { let p = spawn("cat /etc/passwd", Some(2000))?; - match p.process.wait() { + match p.process().wait() { Ok(WaitStatus::Exited(_, 0)) => println!("cat exited with code 0, all good!"), _ => println!("cat exited with code >0, or it was killed"), } let mut p = spawn("cat /this/does/not/exist", Some(2000))?; - match p.process.wait() { + match p.process().wait() { Ok(WaitStatus::Exited(_, 0)) => println!("cat succeeded"), Ok(WaitStatus::Exited(_, c)) => { println!("Cat failed with exit code {c}"); diff --git a/src/session.rs b/src/session.rs index 8698ecc0..7cc734d6 100644 --- a/src/session.rs +++ b/src/session.rs @@ -210,6 +210,14 @@ impl PtySession { let stream = StreamSession::new(reader, f, options); Ok(Self { process, stream }) } + + pub fn process(&self) -> &PtyProcess { + &self.process + } + + pub fn process_mut(&mut self) -> &mut PtyProcess { + &mut self.process + } } /// Start command in background in a pty session (pty fork) and return a struct From 8a0b7612e14be2de0ecbffdd039ede0f67b86cec Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 16 Mar 2026 09:43:12 -0500 Subject: [PATCH 6/7] docs(session): Reorder PtyReplSession fields --- src/session.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/session.rs b/src/session.rs index 7cc734d6..5d13e06f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -273,14 +273,14 @@ pub fn spawn_with_options(command: Command, options: Options) -> Result>> " for python - pub prompt: String, - /// The `pty_session` you prepared before (initiating the shell, maybe set a custom prompt, etc.) /// /// See [`spawn_bash`] for an example pub pty_session: PtySession, + /// The prompt, used for `wait_for_prompt`, e.g. ">>> " for python + pub prompt: String, + /// If set, then the `quit_command` is called when this object is dropped /// you need to provide this if the shell you're testing is not killed by just sending /// SIGTERM @@ -425,8 +425,8 @@ pub fn spawn_bash(timeout: Option) -> Result { spawn_command(c, timeout).and_then(|p| { let new_prompt = "[REXPECT_PROMPT>"; let mut pb = PtyReplSession { - prompt: new_prompt.to_owned(), pty_session: p, + prompt: new_prompt.to_owned(), quit_command: Some("quit".to_owned()), echo_on: false, }; From cd038c5b1a6bb3dc6e21c1025fe3f587b833e5b2 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Mon, 16 Mar 2026 09:47:46 -0500 Subject: [PATCH 7/7] feat(session): Provide constructor for PtyReplSession --- src/session.rs | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/session.rs b/src/session.rs index 5d13e06f..f655d0be 100644 --- a/src/session.rs +++ b/src/session.rs @@ -273,25 +273,45 @@ pub fn spawn_with_options(command: Command, options: Options) -> Result>> " for python pub prompt: String, - - /// If set, then the `quit_command` is called when this object is dropped - /// you need to provide this if the shell you're testing is not killed by just sending - /// SIGTERM pub quit_command: Option, + pub echo_on: bool, +} + +impl PtyReplSession { + /// Start a REPL session + /// + /// `prompt`: used for [`Self::wait_for_prompt`], e.g. ">>> " for python + /// + /// See [`spawn_bash`] for an example + pub fn new(pty_session: PtySession, prompt: String) -> Self { + Self { + pty_session, + prompt, + quit_command: None, + echo_on: false, + } + } + + /// Called when this object is dropped. + /// + /// You need to provide this if the shell you're testing is not killed by just sending + /// SIGTERM. + pub fn quit_command(mut self, cmd: Option) -> Self { + self.quit_command = cmd; + self + } /// Set this to true if the repl has echo on (i.e. sends user input to stdout) /// /// Although echo is set off at pty fork (see `PtyProcess::new`) a few repls still /// seem to be able to send output. /// You may need to try with true first, and if tests fail set this to false. - pub echo_on: bool, + pub fn echo_on(mut self, yes: bool) -> Self { + self.echo_on = yes; + self + } } impl PtyReplSession {