rust_expect/session/handle.rs
1//! Session handle for interacting with spawned processes.
2//!
3//! This module provides the main `Session` type that users interact with
4//! to control spawned processes, send input, and expect output.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use tokio::io::{AsyncReadExt, AsyncWriteExt};
10use tokio::sync::Mutex;
11
12#[cfg(unix)]
13use crate::backend::{AsyncPty, PtyConfig, PtySpawner};
14#[cfg(windows)]
15use crate::backend::{PtyConfig, PtySpawner, WindowsAsyncPty};
16use crate::config::SessionConfig;
17use crate::dialog::{Dialog, DialogExecutor, DialogResult};
18use crate::error::{ExpectError, Result};
19use crate::expect::{ExpectState, MatchResult, Matcher, Pattern, PatternManager, PatternSet};
20use crate::interact::InteractBuilder;
21use crate::types::{ControlChar, Dimensions, Match, ProcessExitStatus, SessionId, SessionState};
22
23/// A session handle for interacting with a spawned process.
24///
25/// The session provides methods to send input, expect patterns in output,
26/// and manage the lifecycle of the process.
27pub struct Session<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> {
28 /// The underlying transport (PTY, SSH channel, etc.).
29 transport: Arc<Mutex<T>>,
30 /// Session configuration.
31 config: SessionConfig,
32 /// Pattern matcher.
33 matcher: Matcher,
34 /// Pattern manager for before/after patterns.
35 pattern_manager: PatternManager,
36 /// Current session state.
37 state: SessionState,
38 /// Unique session identifier.
39 id: SessionId,
40 /// EOF flag.
41 eof: bool,
42}
43
44impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> Session<T> {
45 /// Create a new session with the given transport.
46 pub fn new(transport: T, config: SessionConfig) -> Self {
47 let buffer_size = config.buffer.max_size;
48 let mut matcher = Matcher::new(buffer_size);
49 matcher.set_default_timeout(config.timeout.default);
50 Self {
51 transport: Arc::new(Mutex::new(transport)),
52 config,
53 matcher,
54 pattern_manager: PatternManager::new(),
55 state: SessionState::Starting,
56 id: SessionId::new(),
57 eof: false,
58 }
59 }
60
61 /// Get the session ID.
62 #[must_use]
63 pub const fn id(&self) -> &SessionId {
64 &self.id
65 }
66
67 /// Get the current session state.
68 #[must_use]
69 pub const fn state(&self) -> SessionState {
70 self.state
71 }
72
73 /// Get the session configuration.
74 #[must_use]
75 pub const fn config(&self) -> &SessionConfig {
76 &self.config
77 }
78
79 /// Check if EOF has been detected.
80 #[must_use]
81 pub const fn is_eof(&self) -> bool {
82 self.eof
83 }
84
85 /// Get the current buffer contents.
86 #[must_use]
87 pub fn buffer(&mut self) -> String {
88 self.matcher.buffer_str()
89 }
90
91 /// Clear the buffer.
92 pub fn clear_buffer(&mut self) {
93 self.matcher.clear();
94 }
95
96 /// Get the pattern manager for before/after patterns.
97 #[must_use]
98 pub const fn pattern_manager(&self) -> &PatternManager {
99 &self.pattern_manager
100 }
101
102 /// Get mutable access to the pattern manager.
103 pub const fn pattern_manager_mut(&mut self) -> &mut PatternManager {
104 &mut self.pattern_manager
105 }
106
107 /// Set the session state.
108 pub const fn set_state(&mut self, state: SessionState) {
109 self.state = state;
110 }
111
112 /// Send bytes to the process.
113 ///
114 /// # Errors
115 ///
116 /// Returns an error if the write fails.
117 #[allow(clippy::significant_drop_tightening)]
118 pub async fn send(&mut self, data: &[u8]) -> Result<()> {
119 if matches!(self.state, SessionState::Closed | SessionState::Exited(_)) {
120 return Err(ExpectError::SessionClosed);
121 }
122
123 let mut transport = self.transport.lock().await;
124 transport
125 .write_all(data)
126 .await
127 .map_err(|e| ExpectError::io_context("writing to process", e))?;
128 transport
129 .flush()
130 .await
131 .map_err(|e| ExpectError::io_context("flushing process output", e))?;
132 Ok(())
133 }
134
135 /// Send a string to the process.
136 ///
137 /// # Errors
138 ///
139 /// Returns an error if the write fails.
140 pub async fn send_str(&mut self, s: &str) -> Result<()> {
141 self.send(s.as_bytes()).await
142 }
143
144 /// Send a line to the process (appends newline based on config).
145 ///
146 /// # Errors
147 ///
148 /// Returns an error if the write fails.
149 pub async fn send_line(&mut self, line: &str) -> Result<()> {
150 let line_ending = self.config.line_ending.as_str();
151 let data = format!("{line}{line_ending}");
152 self.send(data.as_bytes()).await
153 }
154
155 /// Send a control character to the process.
156 ///
157 /// # Errors
158 ///
159 /// Returns an error if the write fails.
160 pub async fn send_control(&mut self, ctrl: ControlChar) -> Result<()> {
161 self.send(&[ctrl.as_byte()]).await
162 }
163
164 /// Expect a pattern in the output.
165 ///
166 /// Blocks until the pattern is matched, EOF is detected, or timeout occurs.
167 ///
168 /// # Errors
169 ///
170 /// Returns an error on timeout, EOF (if not expected), or I/O error.
171 pub async fn expect(&mut self, pattern: impl Into<Pattern>) -> Result<Match> {
172 let patterns = PatternSet::from_patterns(vec![pattern.into()]);
173 self.expect_any(&patterns).await
174 }
175
176 /// Expect any of the given patterns.
177 ///
178 /// # Errors
179 ///
180 /// Returns an error on timeout, EOF (if not expected), or I/O error.
181 pub async fn expect_any(&mut self, patterns: &PatternSet) -> Result<Match> {
182 let timeout = self.matcher.get_timeout(patterns);
183 let state = ExpectState::new(patterns.clone(), timeout);
184
185 loop {
186 // Check before patterns first
187 if let Some((_, action)) = self
188 .pattern_manager
189 .check_before(&self.matcher.buffer_str())
190 {
191 match action {
192 crate::expect::HandlerAction::Continue => {}
193 crate::expect::HandlerAction::Return(s) => {
194 return Ok(Match::new(0, s, String::new(), self.matcher.buffer_str()));
195 }
196 crate::expect::HandlerAction::Abort(msg) => {
197 return Err(ExpectError::PatternNotFound {
198 pattern: msg,
199 buffer: self.matcher.buffer_str(),
200 });
201 }
202 crate::expect::HandlerAction::Respond(s) => {
203 self.send_str(&s).await?;
204 }
205 }
206 }
207
208 // Check for pattern match
209 if let Some(result) = self.matcher.try_match_any(patterns) {
210 return Ok(self.matcher.consume_match(&result));
211 }
212
213 // Check for timeout
214 if state.is_timed_out() {
215 return Err(ExpectError::Timeout {
216 duration: timeout,
217 pattern: patterns
218 .iter()
219 .next()
220 .map(|p| p.pattern.as_str().to_string())
221 .unwrap_or_default(),
222 buffer: self.matcher.buffer_str(),
223 });
224 }
225
226 // Check for EOF
227 if self.eof {
228 if state.expects_eof() {
229 return Ok(Match::new(
230 0,
231 String::new(),
232 self.matcher.buffer_str(),
233 String::new(),
234 ));
235 }
236 return Err(ExpectError::Eof {
237 buffer: self.matcher.buffer_str(),
238 });
239 }
240
241 // Read more data
242 self.read_with_timeout(state.remaining_time()).await?;
243 }
244 }
245
246 /// Expect with a specific timeout.
247 ///
248 /// # Errors
249 ///
250 /// Returns an error on timeout, EOF, or I/O error.
251 pub async fn expect_timeout(
252 &mut self,
253 pattern: impl Into<Pattern>,
254 timeout: Duration,
255 ) -> Result<Match> {
256 let pattern = pattern.into();
257 let mut patterns = PatternSet::new();
258 patterns.add(pattern).add(Pattern::timeout(timeout));
259 self.expect_any(&patterns).await
260 }
261
262 /// Read data from the transport with timeout.
263 async fn read_with_timeout(&mut self, timeout: Duration) -> Result<usize> {
264 let mut buf = [0u8; 4096];
265 let mut transport = self.transport.lock().await;
266
267 match tokio::time::timeout(timeout, transport.read(&mut buf)).await {
268 Ok(Ok(0)) => {
269 self.eof = true;
270 Ok(0)
271 }
272 Ok(Ok(n)) => {
273 self.matcher.append(&buf[..n]);
274 Ok(n)
275 }
276 Ok(Err(e)) => {
277 // On Linux, reading from PTY master returns EIO when the slave is closed
278 // (i.e., the child process has terminated). Treat this as EOF.
279 // See: https://bugs.python.org/issue5380
280 if is_pty_eof_error(&e) {
281 self.eof = true;
282 Ok(0)
283 } else {
284 Err(ExpectError::io_context("reading from process", e))
285 }
286 }
287 Err(_) => {
288 // Timeout, but not an error - caller will handle
289 Ok(0)
290 }
291 }
292 }
293
294 /// Wait for the process to exit.
295 ///
296 /// This method blocks until EOF is detected on the session, which typically
297 /// happens when the child process terminates.
298 ///
299 /// # Warning
300 ///
301 /// This method has no timeout and may block indefinitely if the process
302 /// does not exit. Consider using [`wait_timeout`](Self::wait_timeout) or
303 /// [`expect_eof_timeout`](Self::expect_eof_timeout) for bounded waits.
304 ///
305 /// # Errors
306 ///
307 /// Returns an error if waiting fails due to I/O error.
308 pub async fn wait(&mut self) -> Result<ProcessExitStatus> {
309 // Read until EOF
310 while !self.eof {
311 if self.read_with_timeout(Duration::from_millis(100)).await? == 0 && !self.eof {
312 tokio::time::sleep(Duration::from_millis(10)).await;
313 }
314 }
315
316 // Return unknown status - actual status depends on backend
317 self.state = SessionState::Exited(ProcessExitStatus::Unknown);
318 Ok(ProcessExitStatus::Unknown)
319 }
320
321 /// Wait for the process to exit with a timeout.
322 ///
323 /// Like [`wait`](Self::wait), but with a maximum duration to wait.
324 ///
325 /// # Errors
326 ///
327 /// Returns an error if:
328 /// - The timeout expires before the process exits
329 /// - An I/O error occurs while waiting
330 pub async fn wait_timeout(&mut self, timeout: Duration) -> Result<ProcessExitStatus> {
331 let deadline = tokio::time::Instant::now() + timeout;
332
333 while !self.eof {
334 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
335 if remaining.is_zero() {
336 return Err(ExpectError::timeout(
337 timeout,
338 "<EOF>",
339 self.matcher.buffer_str(),
340 ));
341 }
342
343 // Use smaller of remaining time or 100ms for polling
344 let poll_timeout = remaining.min(Duration::from_millis(100));
345 if self.read_with_timeout(poll_timeout).await? == 0 && !self.eof {
346 tokio::time::sleep(Duration::from_millis(10)).await;
347 }
348 }
349
350 self.state = SessionState::Exited(ProcessExitStatus::Unknown);
351 Ok(ProcessExitStatus::Unknown)
352 }
353
354 /// Check if a pattern matches immediately without blocking.
355 #[must_use]
356 pub fn check(&mut self, pattern: &Pattern) -> Option<MatchResult> {
357 self.matcher.try_match(pattern)
358 }
359
360 /// Get the underlying transport.
361 ///
362 /// Use with caution as direct access bypasses session management.
363 #[must_use]
364 pub const fn transport(&self) -> &Arc<Mutex<T>> {
365 &self.transport
366 }
367
368 /// Start an interactive session with pattern hooks.
369 ///
370 /// This returns a builder that allows you to configure pattern-based
371 /// callbacks that fire when patterns match in the output or input.
372 ///
373 /// # Example
374 ///
375 /// ```ignore
376 /// use rust_expect::{Session, InteractAction};
377 ///
378 /// #[tokio::main]
379 /// async fn main() -> Result<(), rust_expect::ExpectError> {
380 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
381 ///
382 /// session.interact()
383 /// .on_output("password:", |ctx| {
384 /// ctx.send("my_password\n")
385 /// })
386 /// .on_output("logout", |_| {
387 /// InteractAction::Stop
388 /// })
389 /// .start()
390 /// .await?;
391 ///
392 /// Ok(())
393 /// }
394 /// ```
395 #[must_use]
396 pub fn interact(&self) -> InteractBuilder<'_, T>
397 where
398 T: 'static,
399 {
400 InteractBuilder::new(&self.transport)
401 }
402
403 /// Run a dialog on this session.
404 ///
405 /// A dialog is a predefined sequence of expect/send operations.
406 /// This method executes the dialog and returns the result.
407 ///
408 /// # Example
409 ///
410 /// ```ignore
411 /// use rust_expect::{Session, Dialog, DialogStep};
412 ///
413 /// #[tokio::main]
414 /// async fn main() -> Result<(), rust_expect::ExpectError> {
415 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
416 ///
417 /// let dialog = Dialog::named("shell_test")
418 /// .step(DialogStep::new("prompt")
419 /// .with_expect("$")
420 /// .with_send("echo hello\n"))
421 /// .step(DialogStep::new("verify")
422 /// .with_expect("hello"));
423 ///
424 /// let result = session.run_dialog(&dialog).await?;
425 /// assert!(result.success);
426 /// Ok(())
427 /// }
428 /// ```
429 ///
430 /// # Errors
431 ///
432 /// Returns an error if I/O fails. Step-level timeouts are reported
433 /// in the `DialogResult` rather than as errors.
434 pub async fn run_dialog(&mut self, dialog: &Dialog) -> Result<DialogResult> {
435 let executor = DialogExecutor::default();
436 executor.execute(self, dialog).await
437 }
438
439 /// Run a dialog with a custom executor.
440 ///
441 /// This allows customizing the executor settings (max steps, default timeout).
442 ///
443 /// # Errors
444 ///
445 /// Returns an error if I/O fails.
446 pub async fn run_dialog_with(
447 &mut self,
448 dialog: &Dialog,
449 executor: &DialogExecutor,
450 ) -> Result<DialogResult> {
451 executor.execute(self, dialog).await
452 }
453
454 /// Expect end-of-file (process termination).
455 ///
456 /// This is a convenience method for waiting until the process terminates
457 /// and closes its output stream.
458 ///
459 /// # Example
460 ///
461 /// ```ignore
462 /// use rust_expect::Session;
463 ///
464 /// #[tokio::main]
465 /// async fn main() -> Result<(), rust_expect::ExpectError> {
466 /// let mut session = Session::spawn("echo", &["hello"]).await?;
467 /// session.expect("hello").await?;
468 /// session.expect_eof().await?;
469 /// Ok(())
470 /// }
471 /// ```
472 ///
473 /// # Errors
474 ///
475 /// Returns an error if the session times out before EOF or an I/O error occurs.
476 pub async fn expect_eof(&mut self) -> Result<Match> {
477 self.expect(Pattern::eof()).await
478 }
479
480 /// Expect end-of-file with a specific timeout.
481 ///
482 /// # Errors
483 ///
484 /// Returns an error if the session times out before EOF or an I/O error occurs.
485 pub async fn expect_eof_timeout(&mut self, timeout: Duration) -> Result<Match> {
486 let mut patterns = PatternSet::new();
487 patterns.add(Pattern::eof()).add(Pattern::timeout(timeout));
488 self.expect_any(&patterns).await
489 }
490
491 /// Run a batch of commands, waiting for the prompt after each.
492 ///
493 /// This is a convenience method for executing multiple shell commands
494 /// in sequence. For each command, it sends the command line and waits
495 /// for the prompt pattern to appear.
496 ///
497 /// # Example
498 ///
499 /// ```ignore
500 /// use rust_expect::{Session, Pattern};
501 ///
502 /// #[tokio::main]
503 /// async fn main() -> Result<(), rust_expect::ExpectError> {
504 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
505 /// session.expect(Pattern::shell_prompt()).await?;
506 ///
507 /// // Run a batch of commands
508 /// let results = session.run_script(
509 /// &["pwd", "whoami", "date"],
510 /// Pattern::shell_prompt(),
511 /// ).await?;
512 ///
513 /// for result in &results {
514 /// println!("Output: {}", result.before.trim());
515 /// }
516 ///
517 /// Ok(())
518 /// }
519 /// ```
520 ///
521 /// # Errors
522 ///
523 /// Returns an error if any command times out or I/O fails.
524 /// On error, partial results are lost; consider using [`Self::run_script_with_results`]
525 /// if you need to capture partial results on failure.
526 pub async fn run_script<I, S>(&mut self, commands: I, prompt: Pattern) -> Result<Vec<Match>>
527 where
528 I: IntoIterator<Item = S>,
529 S: AsRef<str>,
530 {
531 let mut results = Vec::new();
532
533 for cmd in commands {
534 self.send_line(cmd.as_ref()).await?;
535 let result = self.expect(prompt.clone()).await?;
536 results.push(result);
537 }
538
539 Ok(results)
540 }
541
542 /// Run a batch of commands with a specific timeout per command.
543 ///
544 /// Like [`run_script`](Self::run_script), but applies the given timeout
545 /// to each command individually.
546 ///
547 /// # Errors
548 ///
549 /// Returns an error if any command times out or I/O fails.
550 pub async fn run_script_timeout<I, S>(
551 &mut self,
552 commands: I,
553 prompt: Pattern,
554 timeout: Duration,
555 ) -> Result<Vec<Match>>
556 where
557 I: IntoIterator<Item = S>,
558 S: AsRef<str>,
559 {
560 let mut results = Vec::new();
561
562 for cmd in commands {
563 self.send_line(cmd.as_ref()).await?;
564 let result = self.expect_timeout(prompt.clone(), timeout).await?;
565 results.push(result);
566 }
567
568 Ok(results)
569 }
570
571 /// Run a batch of commands, collecting results even on failure.
572 ///
573 /// Unlike [`run_script`](Self::run_script), this method continues
574 /// collecting results and returns them along with any error that occurred.
575 ///
576 /// # Returns
577 ///
578 /// A tuple of `(results, error)` where:
579 /// - `results` contains the matches for successfully completed commands
580 /// - `error` is `Some(err)` if an error occurred, `None` if all commands succeeded
581 ///
582 /// # Example
583 ///
584 /// ```ignore
585 /// use rust_expect::{Session, Pattern};
586 ///
587 /// #[tokio::main]
588 /// async fn main() -> Result<(), rust_expect::ExpectError> {
589 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
590 /// session.expect(Pattern::shell_prompt()).await?;
591 ///
592 /// let (results, error) = session.run_script_with_results(
593 /// &["pwd", "bad_command", "date"],
594 /// Pattern::shell_prompt(),
595 /// ).await;
596 ///
597 /// println!("Completed {} commands", results.len());
598 /// if let Some(e) = error {
599 /// eprintln!("Script failed at command {}: {}", results.len(), e);
600 /// }
601 ///
602 /// Ok(())
603 /// }
604 /// ```
605 pub async fn run_script_with_results<I, S>(
606 &mut self,
607 commands: I,
608 prompt: Pattern,
609 ) -> (Vec<Match>, Option<ExpectError>)
610 where
611 I: IntoIterator<Item = S>,
612 S: AsRef<str>,
613 {
614 let mut results = Vec::new();
615
616 for cmd in commands {
617 match self.send_line(cmd.as_ref()).await {
618 Ok(()) => {}
619 Err(e) => return (results, Some(e)),
620 }
621
622 match self.expect(prompt.clone()).await {
623 Ok(result) => results.push(result),
624 Err(e) => return (results, Some(e)),
625 }
626 }
627
628 (results, None)
629 }
630}
631
632impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> std::fmt::Debug for Session<T> {
633 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634 f.debug_struct("Session")
635 .field("id", &self.id)
636 .field("state", &self.state)
637 .field("eof", &self.eof)
638 .finish_non_exhaustive()
639 }
640}
641
642// Unix-specific spawn implementation
643#[cfg(unix)]
644impl Session<AsyncPty> {
645 /// Spawn a new process with the given command.
646 ///
647 /// This creates a new PTY, forks a child process, and returns a Session
648 /// connected to the child's terminal.
649 ///
650 /// # Example
651 ///
652 /// ```ignore
653 /// use rust_expect::Session;
654 ///
655 /// #[tokio::main]
656 /// async fn main() -> Result<(), rust_expect::ExpectError> {
657 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
658 /// session.expect("$").await?;
659 /// session.send_line("echo hello").await?;
660 /// session.expect("hello").await?;
661 /// Ok(())
662 /// }
663 /// ```
664 ///
665 /// # Errors
666 ///
667 /// Returns an error if:
668 /// - The command contains null bytes
669 /// - PTY allocation fails
670 /// - Fork fails
671 /// - The command cannot be executed
672 pub async fn spawn(command: &str, args: &[&str]) -> Result<Self> {
673 Self::spawn_with_config(command, args, SessionConfig::default()).await
674 }
675
676 /// Spawn a new process with custom configuration.
677 ///
678 /// # Errors
679 ///
680 /// Returns an error if spawning fails.
681 pub async fn spawn_with_config(
682 command: &str,
683 args: &[&str],
684 config: SessionConfig,
685 ) -> Result<Self> {
686 let pty_config = PtyConfig::from(&config);
687 let spawner = PtySpawner::with_config(pty_config);
688
689 // Convert &[&str] to Vec<String> for the spawner
690 let args_owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
691
692 // Spawn the process
693 let handle = spawner.spawn(command, &args_owned).await?;
694
695 // Wrap in AsyncPty for async I/O
696 let async_pty = AsyncPty::from_handle(handle)
697 .map_err(|e| ExpectError::io_context("creating async PTY wrapper", e))?;
698
699 // Create the session
700 let mut session = Self::new(async_pty, config);
701 session.state = SessionState::Running;
702
703 Ok(session)
704 }
705
706 /// Get the child process ID.
707 #[must_use]
708 pub fn pid(&self) -> u32 {
709 // We need to access the inner transport's pid
710 // For now, use the blocking lock since we know it's not contended
711 // during a sync call like this
712 if let Ok(transport) = self.transport.try_lock() {
713 transport.pid()
714 } else {
715 0
716 }
717 }
718
719 /// Resize the terminal.
720 ///
721 /// # Errors
722 ///
723 /// Returns an error if the resize ioctl fails.
724 pub async fn resize_pty(&mut self, cols: u16, rows: u16) -> Result<()> {
725 let mut transport = self.transport.lock().await;
726 transport.resize(cols, rows)
727 }
728
729 /// Send a signal to the child process.
730 ///
731 /// # Errors
732 ///
733 /// Returns an error if sending the signal fails.
734 pub fn signal(&self, signal: i32) -> Result<()> {
735 if let Ok(transport) = self.transport.try_lock() {
736 transport.signal(signal)
737 } else {
738 Err(ExpectError::io_context(
739 "sending signal to process",
740 std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
741 ))
742 }
743 }
744
745 /// Kill the child process.
746 ///
747 /// # Errors
748 ///
749 /// Returns an error if killing the process fails.
750 pub fn kill(&self) -> Result<()> {
751 if let Ok(transport) = self.transport.try_lock() {
752 transport.kill()
753 } else {
754 Err(ExpectError::io_context(
755 "killing process",
756 std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
757 ))
758 }
759 }
760}
761
762// Windows-specific spawn implementation
763#[cfg(windows)]
764impl Session<WindowsAsyncPty> {
765 /// Spawn a new process with the given command.
766 ///
767 /// This creates a new PTY using Windows ConPTY, spawns a child process,
768 /// and returns a Session connected to the child's terminal.
769 ///
770 /// # Example
771 ///
772 /// ```ignore
773 /// use rust_expect::Session;
774 ///
775 /// #[tokio::main]
776 /// async fn main() -> Result<(), rust_expect::ExpectError> {
777 /// let mut session = Session::spawn("cmd.exe", &[]).await?;
778 /// session.expect(">").await?;
779 /// session.send_line("echo hello").await?;
780 /// session.expect("hello").await?;
781 /// Ok(())
782 /// }
783 /// ```
784 ///
785 /// # Errors
786 ///
787 /// Returns an error if:
788 /// - ConPTY is not available (Windows version too old)
789 /// - PTY allocation fails
790 /// - The command cannot be executed
791 pub async fn spawn(command: &str, args: &[&str]) -> Result<Self> {
792 Self::spawn_with_config(command, args, SessionConfig::default()).await
793 }
794
795 /// Spawn a new process with custom configuration.
796 ///
797 /// # Errors
798 ///
799 /// Returns an error if spawning fails.
800 pub async fn spawn_with_config(
801 command: &str,
802 args: &[&str],
803 config: SessionConfig,
804 ) -> Result<Self> {
805 let pty_config = PtyConfig::from(&config);
806 let spawner = PtySpawner::with_config(pty_config);
807
808 // Convert &[&str] to Vec<String> for the spawner
809 let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
810
811 // Spawn the process
812 let handle = spawner.spawn(command, &args_owned).await?;
813
814 // Wrap in WindowsAsyncPty for async I/O
815 let async_pty = WindowsAsyncPty::from_handle(handle);
816
817 // Create the session
818 let mut session = Session::new(async_pty, config);
819 session.state = SessionState::Running;
820
821 Ok(session)
822 }
823
824 /// Get the child process ID.
825 #[must_use]
826 pub fn pid(&self) -> u32 {
827 if let Ok(transport) = self.transport.try_lock() {
828 transport.pid()
829 } else {
830 0
831 }
832 }
833
834 /// Resize the terminal.
835 ///
836 /// # Errors
837 ///
838 /// Returns an error if the resize operation fails.
839 pub async fn resize_pty(&mut self, cols: u16, rows: u16) -> Result<()> {
840 let mut transport = self.transport.lock().await;
841 transport.resize(cols, rows)
842 }
843
844 /// Check if the child process is still running.
845 #[must_use]
846 pub fn is_running(&self) -> bool {
847 if let Ok(transport) = self.transport.try_lock() {
848 transport.is_running()
849 } else {
850 true // Assume running if we can't check
851 }
852 }
853
854 /// Kill the child process.
855 ///
856 /// # Errors
857 ///
858 /// Returns an error if killing the process fails.
859 pub fn kill(&self) -> Result<()> {
860 if let Ok(mut transport) = self.transport.try_lock() {
861 transport.kill()
862 } else {
863 Err(ExpectError::io_context(
864 "killing process",
865 std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
866 ))
867 }
868 }
869}
870
871/// Extension trait for session operations.
872pub trait SessionExt {
873 /// Send and expect in one call.
874 fn send_expect(
875 &mut self,
876 send: &str,
877 expect: impl Into<Pattern>,
878 ) -> impl std::future::Future<Output = Result<Match>> + Send;
879
880 /// Resize the terminal.
881 fn resize(
882 &mut self,
883 dimensions: Dimensions,
884 ) -> impl std::future::Future<Output = Result<()>> + Send;
885}
886
887/// Check if an I/O error indicates PTY EOF.
888///
889/// On Linux, reading from the PTY master returns EIO when the slave side
890/// has been closed (i.e., the child process has terminated). This is different
891/// from the standard EOF behavior where `read()` returns 0 bytes.
892///
893/// This function returns true for errors that should be treated as EOF:
894/// - EIO (errno 5) on Unix systems
895/// - `BrokenPipe` on any platform
896fn is_pty_eof_error(e: &std::io::Error) -> bool {
897 use std::io::ErrorKind;
898
899 // BrokenPipe indicates the other end has closed
900 if e.kind() == ErrorKind::BrokenPipe {
901 return true;
902 }
903
904 // On Unix, check for EIO which indicates slave PTY closed
905 #[cfg(unix)]
906 {
907 if let Some(errno) = e.raw_os_error() {
908 // EIO is 5 on Linux/macOS/BSD
909 if errno == libc::EIO {
910 return true;
911 }
912 }
913 }
914
915 false
916}