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;
7#[cfg(feature = "screen")]
8use std::sync::{Mutex as StdMutex, MutexGuard};
9use std::time::Duration;
10
11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tokio::sync::Mutex;
13
14use crate::backend::ChildExit;
15#[cfg(unix)]
16use crate::backend::{AsyncPty, PtyConfig, PtySpawner};
17#[cfg(windows)]
18use crate::backend::{PtyConfig, PtySpawner, WindowsAsyncPty};
19use crate::config::SessionConfig;
20use crate::dialog::{Dialog, DialogExecutor, DialogResult};
21use crate::error::{ExpectError, Result};
22use crate::expect::{ExpectState, MatchResult, Matcher, Pattern, PatternManager, PatternSet};
23use crate::interact::InteractBuilder;
24#[cfg(feature = "screen")]
25use crate::screen::Screen;
26use crate::types::{ControlChar, Dimensions, Match, ProcessExitStatus, SessionId, SessionState};
27
28/// Callback invoked for every chunk of bytes read from the transport.
29///
30/// Taps observe the raw byte stream as it arrives, after it is appended to the
31/// matcher buffer but before any pattern matching is performed. They are the
32/// foundation for screen emulation, transcript recording, and other features
33/// that need to see output as it happens.
34pub type OutputTap = Arc<dyn Fn(&[u8]) + Send + Sync>;
35
36/// Opaque handle identifying a registered output tap. Returned by
37/// [`Session::add_output_tap`] and accepted by
38/// [`Session::remove_output_tap`].
39///
40/// Backed by `u64`. The id space is large enough that wraparound is not
41/// reachable in practice; the implementation uses a non-wrapping `+= 1`
42/// so a hypothetical exhaustion would surface as a loud panic instead of
43/// silently colliding with a still-registered tap.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct TapId(u64);
46
47impl std::fmt::Display for TapId {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 write!(f, "tap#{}", self.0)
50 }
51}
52
53/// Lock the screen mutex, recovering from poisoning.
54///
55/// A user-supplied tap (or `Screen::process` panicking on a malformed parse
56/// path) can poison the screen mutex. Silently returning a default on
57/// poisoning makes screen-aware expects look like they always-miss, which
58/// is a confusing failure mode. Recovering via `into_inner` lets the call
59/// continue against the actual screen state — the screen contents are
60/// still valid; only the lock was tainted.
61#[cfg(feature = "screen")]
62fn lock_screen(screen: &Arc<StdMutex<Screen>>) -> MutexGuard<'_, Screen> {
63 match screen.lock() {
64 Ok(g) => g,
65 Err(poison) => {
66 tracing::warn!("screen mutex was poisoned; recovering inner state");
67 poison.into_inner()
68 }
69 }
70}
71
72/// A session handle for interacting with a spawned process.
73///
74/// The session provides methods to send input, expect patterns in output,
75/// and manage the lifecycle of the process.
76pub struct Session<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> {
77 /// The underlying transport (PTY, SSH channel, etc.).
78 transport: Arc<Mutex<T>>,
79 /// Session configuration.
80 config: SessionConfig,
81 /// Pattern matcher.
82 matcher: Matcher,
83 /// Pattern manager for before/after patterns.
84 pattern_manager: PatternManager,
85 /// Current session state.
86 state: SessionState,
87 /// Unique session identifier.
88 id: SessionId,
89 /// EOF flag.
90 eof: bool,
91 /// Output taps invoked on every chunk of bytes read from the transport,
92 /// stored as (id, callback) so they can be removed individually.
93 output_taps: Vec<(TapId, OutputTap)>,
94 /// Monotonic counter for assigning new `TapId`s.
95 next_tap_id: u64,
96 /// Attached virtual terminal screen, fed from an output tap.
97 #[cfg(feature = "screen")]
98 screen: Option<Arc<StdMutex<Screen>>>,
99 /// Tap id used to feed the attached screen, so `detach_screen` can
100 /// remove only that tap and leave user-registered taps in place.
101 #[cfg(feature = "screen")]
102 screen_tap_id: Option<TapId>,
103 /// Poll interval used by the screen-aware expect helpers
104 /// (`expect_screen_contains`, `wait_screen_not_contains`,
105 /// `wait_screen_stable`). 50 ms by default.
106 #[cfg(feature = "screen")]
107 screen_poll_interval: Duration,
108}
109
110/// Whether a write error means the child/peer is gone (the PTY slave end
111/// closed, or the pipe broke) as opposed to a transient or unexpected failure.
112///
113/// After the slave closes, a write to the PTY master fails with `EIO` on Unix —
114/// which `std` reports as an uncategorized kind, so we match the raw code — and
115/// with `BrokenPipe` on Windows.
116fn write_error_means_closed(err: &std::io::Error) -> bool {
117 use std::io::ErrorKind;
118 if matches!(
119 err.kind(),
120 ErrorKind::BrokenPipe | ErrorKind::ConnectionReset
121 ) {
122 return true;
123 }
124 #[cfg(unix)]
125 if err.raw_os_error() == Some(libc::EIO) {
126 return true;
127 }
128 false
129}
130
131impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> Session<T> {
132 /// Create a new session with the given transport.
133 pub fn new(transport: T, config: SessionConfig) -> Self {
134 let buffer_size = config.buffer.max_size;
135 let mut matcher = Matcher::new(buffer_size);
136 matcher.set_default_timeout(config.timeout.default);
137 Self {
138 transport: Arc::new(Mutex::new(transport)),
139 config,
140 matcher,
141 pattern_manager: PatternManager::new(),
142 state: SessionState::Starting,
143 id: SessionId::new(),
144 eof: false,
145 output_taps: Vec::new(),
146 next_tap_id: 0,
147 #[cfg(feature = "screen")]
148 screen: None,
149 #[cfg(feature = "screen")]
150 screen_tap_id: None,
151 #[cfg(feature = "screen")]
152 screen_poll_interval: Duration::from_millis(50),
153 }
154 }
155
156 /// Set the polling interval used by the screen-aware expect helpers.
157 ///
158 /// Affects `expect_screen_contains`, `wait_screen_not_contains`, and
159 /// `wait_screen_stable`. Smaller values reduce match latency at the
160 /// cost of CPU; larger values do the opposite. Default is 50 ms.
161 ///
162 /// Available with the `screen` feature.
163 #[cfg(feature = "screen")]
164 pub const fn set_screen_poll_interval(&mut self, interval: Duration) {
165 self.screen_poll_interval = interval;
166 }
167
168 /// Get the current screen-poll interval. Default 50 ms.
169 ///
170 /// Available with the `screen` feature.
171 #[cfg(feature = "screen")]
172 #[must_use]
173 pub const fn screen_poll_interval(&self) -> Duration {
174 self.screen_poll_interval
175 }
176
177 /// Register a callback that will be invoked with every chunk of bytes
178 /// read from the transport.
179 ///
180 /// Taps observe the raw byte stream as it arrives — they receive bytes
181 /// in the same form the underlying process produced them, including any
182 /// ANSI escape sequences. Taps are invoked synchronously inside the read
183 /// loop after the bytes are appended to the matcher buffer; they should
184 /// be cheap and non-blocking. Use a channel if expensive work is required.
185 ///
186 /// Multiple taps may be registered; they are invoked in registration
187 /// order. Taps are dropped when the session is dropped.
188 ///
189 /// # Example
190 ///
191 /// ```ignore
192 /// use std::sync::Arc;
193 /// use std::sync::Mutex;
194 /// let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
195 /// let buf = captured.clone();
196 /// session.add_output_tap(move |chunk| {
197 /// buf.lock().unwrap().extend_from_slice(chunk);
198 /// });
199 /// ```
200 pub fn add_output_tap<F>(&mut self, f: F) -> TapId
201 where
202 F: Fn(&[u8]) + Send + Sync + 'static,
203 {
204 let id = TapId(self.next_tap_id);
205 // Plain addition (not wrapping_add): on the astronomically unlikely
206 // event of u64 exhaustion on a single session, we'd rather panic
207 // loudly than silently issue a colliding id.
208 self.next_tap_id += 1;
209 self.output_taps.push((id, Arc::new(f)));
210 id
211 }
212
213 /// Remove a previously registered output tap by its [`TapId`]. Returns
214 /// `true` if a tap was removed, `false` if the id was not registered
215 /// (already removed, or never existed).
216 pub fn remove_output_tap(&mut self, id: TapId) -> bool {
217 let len_before = self.output_taps.len();
218 self.output_taps.retain(|(existing, _)| *existing != id);
219 self.output_taps.len() != len_before
220 }
221
222 /// Iterate the callbacks for all currently registered output taps.
223 ///
224 /// Exposed for instrumentation and inspection only — the read loops in
225 /// [`expect`](Self::expect) and [`interact`](Self::interact) invoke
226 /// these themselves. Returns the callback `Arc`s in registration
227 /// order; ids are intentionally omitted (use
228 /// [`add_output_tap`](Self::add_output_tap)'s return value if you
229 /// need the id).
230 pub fn output_tap_callbacks(&self) -> impl Iterator<Item = &OutputTap> {
231 self.output_taps.iter().map(|(_, cb)| cb)
232 }
233
234 /// Attach a virtual terminal screen to this session.
235 ///
236 /// Creates a [`Screen`](crate::screen::Screen) with the session's
237 /// configured dimensions and registers an output tap that feeds every
238 /// chunk of output into the screen's ANSI parser. The screen is then
239 /// accessible via [`screen()`](Self::screen) and is automatically updated
240 /// whenever output is read from the transport (i.e. inside `expect_*`,
241 /// `wait`, or `wait_screen_stable`).
242 ///
243 /// Repeated calls replace the previous screen.
244 ///
245 /// Available with the `screen` feature.
246 #[cfg(feature = "screen")]
247 pub fn attach_screen(&mut self) {
248 let (cols, rows) = self.config.dimensions;
249 self.attach_screen_with_dims(rows, cols);
250 }
251
252 /// Attach a screen with custom dimensions.
253 ///
254 /// `rows` and `cols` are the screen size in cells. Note that this does
255 /// not resize the PTY itself — use [`resize_pty`](Self::resize_pty) for
256 /// that. The two should normally match, but it can be useful to set a
257 /// larger virtual screen for transcript capture.
258 ///
259 /// Available with the `screen` feature.
260 #[cfg(feature = "screen")]
261 pub fn attach_screen_with_dims(&mut self, rows: u16, cols: u16) {
262 // Replace any previous screen + its tap so we don't leak callbacks.
263 self.detach_screen();
264 let screen = Arc::new(StdMutex::new(Screen::new(rows as usize, cols as usize)));
265 let screen_for_tap = screen.clone();
266 let id = self.add_output_tap(move |chunk| {
267 // Reuse the shared poison-recovery helper so the tap-side and
268 // read-side recovery logic stays in lockstep.
269 lock_screen(&screen_for_tap).process(chunk);
270 });
271 self.screen = Some(screen);
272 self.screen_tap_id = Some(id);
273 }
274
275 /// Attach a screen with a bounded scrollback history, sized to the
276 /// session's configured dimensions.
277 ///
278 /// Rows that scroll off the top are retained (up to `scrollback_lines`)
279 /// and readable via the attached [`Screen`](crate::screen::Screen)'s
280 /// `scrollback()` / `full_text()`. For lossless capture independent of the
281 /// bound, register [`on_screen_line_scrolled_out`](Self::on_screen_line_scrolled_out).
282 ///
283 /// Available with the `screen` feature.
284 #[cfg(feature = "screen")]
285 pub fn attach_screen_with_scrollback(&mut self, scrollback_lines: usize) {
286 let (cols, rows) = self.config.dimensions;
287 // Replace any previous screen + its tap so we don't leak callbacks.
288 self.detach_screen();
289 let screen = Arc::new(StdMutex::new(Screen::with_scrollback(
290 rows as usize,
291 cols as usize,
292 scrollback_lines,
293 )));
294 let screen_for_tap = screen.clone();
295 let id = self.add_output_tap(move |chunk| {
296 lock_screen(&screen_for_tap).process(chunk);
297 });
298 self.screen = Some(screen);
299 self.screen_tap_id = Some(id);
300 }
301
302 /// Register a callback fired for each row that scrolls off the attached
303 /// screen, delivered as the row finalizes. Returns `false` if no screen is
304 /// attached.
305 ///
306 /// See [`Screen::on_line_scrolled_out`](crate::screen::Screen::on_line_scrolled_out)
307 /// for the reentrancy contract: the callback runs while the screen lock is
308 /// held and must not re-enter the `Session`/`Screen`.
309 ///
310 /// Available with the `screen` feature.
311 #[cfg(feature = "screen")]
312 pub fn on_screen_line_scrolled_out<F>(&mut self, callback: F) -> bool
313 where
314 F: FnMut(&crate::screen::Row) + Send + 'static,
315 {
316 if let Some(screen) = self.screen.as_ref() {
317 lock_screen(screen).on_line_scrolled_out(callback);
318 true
319 } else {
320 false
321 }
322 }
323
324 /// Detach the currently attached screen, also removing its output tap.
325 /// No-op if no screen is attached. Returns `true` if a screen was
326 /// detached.
327 ///
328 /// Available with the `screen` feature.
329 #[cfg(feature = "screen")]
330 pub fn detach_screen(&mut self) -> bool {
331 if let Some(id) = self.screen_tap_id.take() {
332 self.remove_output_tap(id);
333 }
334 self.screen.take().is_some()
335 }
336
337 /// Get the attached virtual terminal screen, if any.
338 ///
339 /// Returns a shared handle protected by a [`std::sync::Mutex`]. Lock it
340 /// briefly to read screen state — the lock is also taken by the output
341 /// tap on every read, so holding it for long stretches blocks the read
342 /// loop.
343 ///
344 /// Available with the `screen` feature.
345 #[cfg(feature = "screen")]
346 #[must_use]
347 pub const fn screen(&self) -> Option<&Arc<StdMutex<Screen>>> {
348 self.screen.as_ref()
349 }
350
351 /// Get the session ID.
352 #[must_use]
353 pub const fn id(&self) -> &SessionId {
354 &self.id
355 }
356
357 /// Get the current session state.
358 #[must_use]
359 pub const fn state(&self) -> SessionState {
360 self.state
361 }
362
363 /// Get the session configuration.
364 #[must_use]
365 pub const fn config(&self) -> &SessionConfig {
366 &self.config
367 }
368
369 /// Check if EOF has been detected.
370 #[must_use]
371 pub const fn is_eof(&self) -> bool {
372 self.eof
373 }
374
375 /// Get the current buffer contents.
376 #[must_use]
377 pub fn buffer(&mut self) -> String {
378 self.matcher.buffer_str()
379 }
380
381 /// Clear the buffer.
382 pub fn clear_buffer(&mut self) {
383 self.matcher.clear();
384 }
385
386 /// Get the pattern manager for before/after patterns.
387 #[must_use]
388 pub const fn pattern_manager(&self) -> &PatternManager {
389 &self.pattern_manager
390 }
391
392 /// Get mutable access to the pattern manager.
393 pub const fn pattern_manager_mut(&mut self) -> &mut PatternManager {
394 &mut self.pattern_manager
395 }
396
397 /// Set the session state.
398 pub const fn set_state(&mut self, state: SessionState) {
399 self.state = state;
400 }
401
402 /// Send bytes to the process.
403 ///
404 /// # Errors
405 ///
406 /// Returns [`ExpectError::SessionClosed`] if the child has already exited
407 /// (so the write would go to a dead PTY), or an I/O error if the write
408 /// otherwise fails.
409 #[allow(clippy::significant_drop_tightening)]
410 pub async fn send(&mut self, data: &[u8]) -> Result<()> {
411 if matches!(self.state, SessionState::Closed | SessionState::Exited(_)) {
412 return Err(ExpectError::SessionClosed);
413 }
414
415 // Perform the write under the lock, then release it before touching
416 // session state so the error-handling path can take `&mut self`.
417 let result = {
418 let mut transport = self.transport.lock().await;
419 match transport.write_all(data).await {
420 Ok(()) => transport.flush().await,
421 Err(e) => Err(e),
422 }
423 };
424
425 match result {
426 Ok(()) => Ok(()),
427 // A write to an already-exited child's PTY fails once the slave end
428 // closes (EIO on Unix, BrokenPipe on Windows). Surface that as a
429 // clean SessionClosed rather than a raw OS error, and mark the
430 // session closed so subsequent sends short-circuit immediately.
431 Err(e) if write_error_means_closed(&e) => {
432 self.state = SessionState::Closed;
433 Err(ExpectError::SessionClosed)
434 }
435 Err(e) => Err(ExpectError::io_context("writing to process", e)),
436 }
437 }
438
439 /// Send a string to the process.
440 ///
441 /// # Errors
442 ///
443 /// Returns an error if the write fails.
444 pub async fn send_str(&mut self, s: &str) -> Result<()> {
445 self.send(s.as_bytes()).await
446 }
447
448 /// Send a line to the process (appends newline based on config).
449 ///
450 /// # Errors
451 ///
452 /// Returns an error if the write fails.
453 pub async fn send_line(&mut self, line: &str) -> Result<()> {
454 let line_ending = self.config.line_ending.as_str();
455 let data = format!("{line}{line_ending}");
456 self.send(data.as_bytes()).await
457 }
458
459 /// Send a control character to the process.
460 ///
461 /// # Errors
462 ///
463 /// Returns an error if the write fails.
464 pub async fn send_control(&mut self, ctrl: ControlChar) -> Result<()> {
465 self.send(&[ctrl.as_byte()]).await
466 }
467
468 /// Send a Shift+Tab keystroke.
469 ///
470 /// Sends the xterm "back tab" sequence `\x1b[Z` (CSI Z). Most TUIs use
471 /// this to cycle a focused-element ring backwards or, in Claude Code's
472 /// case, to cycle permission modes. Compatible with both plain xterm
473 /// and the kitty keyboard protocol's CSI-u fallback mode.
474 ///
475 /// # Errors
476 ///
477 /// Returns an error if the write fails.
478 pub async fn send_shift_tab(&mut self) -> Result<()> {
479 self.send(b"\x1b[Z").await
480 }
481
482 /// Send text using bracketed paste mode (DECSET 2004).
483 ///
484 /// Wraps the content in `\x1b[200~` and `\x1b[201~` markers. Applications
485 /// that have enabled bracketed paste treat the enclosed content as
486 /// pasted input rather than typed input — this suppresses autocomplete,
487 /// command-history scanning, and per-character interpretation such as a
488 /// leading `/` triggering a slash-command popup. Safe to call even when
489 /// the receiver hasn't enabled bracketed paste: most terminals ignore
490 /// the markers and deliver the inner text as-is.
491 ///
492 /// # Errors
493 ///
494 /// Returns an error if the write fails or if `text` contains the
495 /// closing paste marker `\x1b[201~`, which would let the receiver drop
496 /// out of paste mode mid-payload. Callers that want to send such bytes
497 /// should write them through the regular [`send`](Self::send) path.
498 pub async fn send_paste(&mut self, text: &str) -> Result<()> {
499 if memchr::memmem::find(text.as_bytes(), b"\x1b[201~").is_some() {
500 return Err(ExpectError::InvalidInput {
501 api: "send_paste".to_string(),
502 reason:
503 "input contains the bracketed-paste end marker (\\x1b[201~); use send() for raw bytes that include this sequence"
504 .to_string(),
505 });
506 }
507 self.send(b"\x1b[200~").await?;
508 self.send(text.as_bytes()).await?;
509 self.send(b"\x1b[201~").await
510 }
511
512 /// Expect a pattern in the output.
513 ///
514 /// Blocks until the pattern is matched, EOF is detected, or timeout occurs.
515 ///
516 /// # Errors
517 ///
518 /// Returns an error on timeout, EOF (if not expected), or I/O error.
519 pub async fn expect(&mut self, pattern: impl Into<Pattern>) -> Result<Match> {
520 let patterns = PatternSet::from_patterns(vec![pattern.into()]);
521 self.expect_any(&patterns).await
522 }
523
524 /// Expect any of the given patterns.
525 ///
526 /// # Errors
527 ///
528 /// Returns an error on timeout, EOF (if not expected), or I/O error.
529 pub async fn expect_any(&mut self, patterns: &PatternSet) -> Result<Match> {
530 let timeout = self.matcher.get_timeout(patterns);
531 let state = ExpectState::new(patterns.clone(), timeout);
532
533 loop {
534 // Check before patterns first
535 if let Some((_, action)) = self
536 .pattern_manager
537 .check_before(&self.matcher.buffer_str())
538 {
539 match action {
540 crate::expect::HandlerAction::Continue => {}
541 crate::expect::HandlerAction::Return(s) => {
542 return Ok(Match::new(0, s, String::new(), self.matcher.buffer_str()));
543 }
544 crate::expect::HandlerAction::Abort(msg) => {
545 return Err(ExpectError::PatternNotFound {
546 pattern: msg,
547 buffer: self.matcher.buffer_str(),
548 });
549 }
550 crate::expect::HandlerAction::Respond(s) => {
551 self.send_str(&s).await?;
552 }
553 }
554 }
555
556 // Check for pattern match
557 if let Some(result) = self.matcher.try_match_any(patterns) {
558 return Ok(self.matcher.consume_match(&result));
559 }
560
561 // Check for timeout
562 if state.is_timed_out() {
563 return Err(ExpectError::Timeout {
564 duration: timeout,
565 pattern: patterns
566 .iter()
567 .next()
568 .map(|p| p.pattern.as_str().to_string())
569 .unwrap_or_default(),
570 buffer: self.matcher.buffer_str(),
571 });
572 }
573
574 // Check for EOF
575 if self.eof {
576 if state.expects_eof() {
577 return Ok(Match::new(
578 0,
579 String::new(),
580 self.matcher.buffer_str(),
581 String::new(),
582 ));
583 }
584 return Err(ExpectError::Eof {
585 buffer: self.matcher.buffer_str(),
586 });
587 }
588
589 // Read more data
590 self.read_with_timeout(state.remaining_time()).await?;
591 }
592 }
593
594 /// Expect with a specific timeout.
595 ///
596 /// # Errors
597 ///
598 /// Returns an error on timeout, EOF, or I/O error.
599 pub async fn expect_timeout(
600 &mut self,
601 pattern: impl Into<Pattern>,
602 timeout: Duration,
603 ) -> Result<Match> {
604 let pattern = pattern.into();
605 let mut patterns = PatternSet::new();
606 patterns.add(pattern).add(Pattern::timeout(timeout));
607 self.expect_any(&patterns).await
608 }
609
610 /// Wait until the attached screen contains the given substring.
611 ///
612 /// Drives reads from the transport in short increments, checking the
613 /// rendered screen text after each. Returns successfully as soon as
614 /// `needle` appears in the screen text, or with [`ExpectError::Timeout`]
615 /// when `timeout` elapses without a match. Returns [`ExpectError::Eof`]
616 /// if the process exits before the substring appears.
617 ///
618 /// This is the screen-aware counterpart to [`expect`](Self::expect): use
619 /// it when the byte stream is full of ANSI escape sequences (e.g. when
620 /// driving a TUI), where literal substring matching on the byte stream
621 /// would fail because of interleaved cursor positioning and SGR codes.
622 ///
623 /// Requires an attached screen — call [`attach_screen`](Self::attach_screen)
624 /// first.
625 ///
626 /// # Errors
627 ///
628 /// Returns an error if no screen is attached, the timeout expires, EOF
629 /// is reached, or an I/O error occurs.
630 ///
631 /// Available with the `screen` feature.
632 #[cfg(feature = "screen")]
633 pub async fn expect_screen_contains(&mut self, needle: &str, timeout: Duration) -> Result<()> {
634 let Some(screen) = self.screen.clone() else {
635 return Err(ExpectError::ScreenNotAttached);
636 };
637
638 let start = tokio::time::Instant::now();
639 let poll = self.screen_poll_interval;
640
641 loop {
642 if lock_screen(&screen).query().contains(needle) {
643 return Ok(());
644 }
645 if self.eof {
646 return Err(ExpectError::Eof {
647 buffer: lock_screen(&screen).text(),
648 });
649 }
650 let elapsed = start.elapsed();
651 if elapsed >= timeout {
652 return Err(ExpectError::Timeout {
653 duration: timeout,
654 pattern: needle.to_string(),
655 buffer: lock_screen(&screen).text(),
656 });
657 }
658 let remaining = timeout.saturating_sub(elapsed);
659 self.read_with_timeout(poll.min(remaining)).await?;
660 }
661 }
662
663 /// Wait until the attached screen no longer contains the given substring.
664 ///
665 /// The inverse of [`expect_screen_contains`](Self::expect_screen_contains).
666 /// Returns successfully as soon as `needle` is absent from the rendered
667 /// screen, or with [`ExpectError::Timeout`] when `timeout` elapses with
668 /// the substring still present. EOF is treated as "absent" (the screen
669 /// state is frozen at the final paint).
670 ///
671 /// Useful for anchoring on the *disappearance* of an indicator —
672 /// e.g. waiting for a "request in flight" status to clear, a spinner
673 /// glyph to stop, or a modal to close.
674 ///
675 /// Requires an attached screen.
676 ///
677 /// # Errors
678 ///
679 /// Returns an error if no screen is attached, the timeout expires while
680 /// the substring is still visible, or an I/O error occurs.
681 ///
682 /// Available with the `screen` feature.
683 #[cfg(feature = "screen")]
684 pub async fn wait_screen_not_contains(
685 &mut self,
686 needle: &str,
687 timeout: Duration,
688 ) -> Result<()> {
689 let Some(screen) = self.screen.clone() else {
690 return Err(ExpectError::ScreenNotAttached);
691 };
692
693 let start = tokio::time::Instant::now();
694 let poll = self.screen_poll_interval;
695
696 loop {
697 if !lock_screen(&screen).query().contains(needle) {
698 return Ok(());
699 }
700 if self.eof {
701 return Ok(());
702 }
703 let elapsed = start.elapsed();
704 if elapsed >= timeout {
705 return Err(ExpectError::Timeout {
706 duration: timeout,
707 pattern: format!("!{needle}"),
708 buffer: lock_screen(&screen).text(),
709 });
710 }
711 let remaining = timeout.saturating_sub(elapsed);
712 self.read_with_timeout(poll.min(remaining)).await?;
713 }
714 }
715
716 /// Wait until the attached screen has been unchanged for `quiet_period`.
717 ///
718 /// Drives reads in short increments and tracks whether the rendered
719 /// screen text changes between reads. Returns successfully when the
720 /// screen has been quiescent for `quiet_period`, or with
721 /// [`ExpectError::Timeout`] if `max_wait` elapses first.
722 ///
723 /// Useful as a generic "wait for the TUI to finish drawing" primitive
724 /// when no specific anchor is available — for example, after submitting
725 /// a prompt and before reading the response.
726 ///
727 /// A small `quiet_period` (e.g. 100-300 ms) catches paint completion;
728 /// a larger one (1-2 s) waits out streaming responses with mid-stream
729 /// pauses. Tune to the specific application.
730 ///
731 /// Requires an attached screen.
732 ///
733 /// # Errors
734 ///
735 /// Returns an error if no screen is attached, `max_wait` elapses, or an
736 /// I/O error occurs. EOF is **not** an error — if the process exits, the
737 /// final screen state is considered stable and the method returns Ok.
738 ///
739 /// Available with the `screen` feature.
740 #[cfg(feature = "screen")]
741 pub async fn wait_screen_stable(
742 &mut self,
743 quiet_period: Duration,
744 max_wait: Duration,
745 ) -> Result<()> {
746 let Some(screen) = self.screen.clone() else {
747 return Err(ExpectError::ScreenNotAttached);
748 };
749
750 let start = tokio::time::Instant::now();
751 let poll = self.screen_poll_interval;
752 let mut last_revision = lock_screen(&screen).revision();
753 let mut last_change = tokio::time::Instant::now();
754
755 loop {
756 if last_change.elapsed() >= quiet_period {
757 return Ok(());
758 }
759 if self.eof {
760 return Ok(());
761 }
762 if start.elapsed() >= max_wait {
763 return Err(ExpectError::Timeout {
764 duration: max_wait,
765 pattern: "<screen stability>".to_string(),
766 buffer: lock_screen(&screen).text(),
767 });
768 }
769 self.read_with_timeout(poll).await?;
770 let current_revision = lock_screen(&screen).revision();
771 if current_revision != last_revision {
772 last_revision = current_revision;
773 last_change = tokio::time::Instant::now();
774 }
775 }
776 }
777
778 /// Read data from the transport with timeout.
779 async fn read_with_timeout(&mut self, timeout: Duration) -> Result<usize> {
780 let mut buf = [0u8; 4096];
781 let mut transport = self.transport.lock().await;
782
783 match tokio::time::timeout(timeout, transport.read(&mut buf)).await {
784 Ok(Ok(0)) => {
785 self.eof = true;
786 Ok(0)
787 }
788 Ok(Ok(n)) => {
789 self.matcher.append(&buf[..n]);
790 // Run taps in catch_unwind so a panicking user callback can't
791 // unwind across our await boundary or poison subsequent taps.
792 // We log and continue rather than propagate — taps are
793 // observers, not error sources.
794 for (id, tap) in &self.output_taps {
795 let tap_clone = tap.clone();
796 let chunk = &buf[..n];
797 let result =
798 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tap_clone(chunk)));
799 if result.is_err() {
800 tracing::warn!(
801 %id,
802 "output tap panicked; the panic was caught and other taps continue"
803 );
804 }
805 }
806 Ok(n)
807 }
808 Ok(Err(e)) => {
809 // On Linux, reading from PTY master returns EIO when the slave is closed
810 // (i.e., the child process has terminated). Treat this as EOF.
811 // See: https://bugs.python.org/issue5380
812 if is_pty_eof_error(&e) {
813 self.eof = true;
814 Ok(0)
815 } else {
816 Err(ExpectError::io_context("reading from process", e))
817 }
818 }
819 Err(_) => {
820 // Timeout, but not an error - caller will handle
821 Ok(0)
822 }
823 }
824 }
825
826 /// Check if a pattern matches immediately without blocking.
827 #[must_use]
828 pub fn check(&mut self, pattern: &Pattern) -> Option<MatchResult> {
829 self.matcher.try_match(pattern)
830 }
831
832 /// Get the underlying transport.
833 ///
834 /// Use with caution as direct access bypasses session management.
835 #[must_use]
836 pub const fn transport(&self) -> &Arc<Mutex<T>> {
837 &self.transport
838 }
839
840 /// Start an interactive session with pattern hooks.
841 ///
842 /// This returns a builder that allows you to configure pattern-based
843 /// callbacks that fire when patterns match in the output or input.
844 ///
845 /// # Example
846 ///
847 /// ```no_run
848 /// use rust_expect::{Session, InteractAction};
849 ///
850 /// #[tokio::main]
851 /// async fn main() -> Result<(), rust_expect::ExpectError> {
852 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
853 ///
854 /// session.interact()
855 /// .on_output("password:", |ctx| {
856 /// ctx.send("my_password\n")
857 /// })
858 /// .on_output("logout", |_| {
859 /// InteractAction::Stop
860 /// })
861 /// .start()
862 /// .await?;
863 ///
864 /// Ok(())
865 /// }
866 /// ```
867 #[must_use]
868 pub fn interact(&self) -> InteractBuilder<'_, T>
869 where
870 T: 'static,
871 {
872 // Snapshot the currently registered output taps so the interact
873 // read loop can fire them on every chunk. Without this, attached
874 // screens and transcript recorders would silently freeze for the
875 // duration of interact().
876 let taps: Vec<OutputTap> = self
877 .output_taps
878 .iter()
879 .map(|(_, tap)| tap.clone())
880 .collect();
881 InteractBuilder::new(&self.transport, taps)
882 }
883
884 /// Run a dialog on this session.
885 ///
886 /// A dialog is a predefined sequence of expect/send operations.
887 /// This method executes the dialog and returns the result.
888 ///
889 /// # Example
890 ///
891 /// ```no_run
892 /// use rust_expect::{Session, Dialog, DialogStep};
893 ///
894 /// #[tokio::main]
895 /// async fn main() -> Result<(), rust_expect::ExpectError> {
896 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
897 ///
898 /// let dialog = Dialog::named("shell_test")
899 /// .step(DialogStep::new("prompt")
900 /// .with_expect("$")
901 /// .with_send("echo hello\n"))
902 /// .step(DialogStep::new("verify")
903 /// .with_expect("hello"));
904 ///
905 /// let result = session.run_dialog(&dialog).await?;
906 /// assert!(result.success);
907 /// Ok(())
908 /// }
909 /// ```
910 ///
911 /// # Errors
912 ///
913 /// Returns an error if I/O fails. Step-level timeouts are reported
914 /// in the `DialogResult` rather than as errors.
915 pub async fn run_dialog(&mut self, dialog: &Dialog) -> Result<DialogResult> {
916 let executor = DialogExecutor::default();
917 executor.execute(self, dialog).await
918 }
919
920 /// Run a dialog with a custom executor.
921 ///
922 /// This allows customizing the executor settings (max steps, default timeout).
923 ///
924 /// # Errors
925 ///
926 /// Returns an error if I/O fails.
927 pub async fn run_dialog_with(
928 &mut self,
929 dialog: &Dialog,
930 executor: &DialogExecutor,
931 ) -> Result<DialogResult> {
932 executor.execute(self, dialog).await
933 }
934
935 /// Expect end-of-file (process termination).
936 ///
937 /// This is a convenience method for waiting until the process terminates
938 /// and closes its output stream.
939 ///
940 /// # Example
941 ///
942 /// ```no_run
943 /// use rust_expect::Session;
944 ///
945 /// #[tokio::main]
946 /// async fn main() -> Result<(), rust_expect::ExpectError> {
947 /// let mut session = Session::spawn("echo", &["hello"]).await?;
948 /// session.expect("hello").await?;
949 /// session.expect_eof().await?;
950 /// Ok(())
951 /// }
952 /// ```
953 ///
954 /// # Errors
955 ///
956 /// Returns an error if the session times out before EOF or an I/O error occurs.
957 pub async fn expect_eof(&mut self) -> Result<Match> {
958 self.expect(Pattern::eof()).await
959 }
960
961 /// Expect end-of-file with a specific timeout.
962 ///
963 /// # Errors
964 ///
965 /// Returns an error if the session times out before EOF or an I/O error occurs.
966 pub async fn expect_eof_timeout(&mut self, timeout: Duration) -> Result<Match> {
967 let mut patterns = PatternSet::new();
968 patterns.add(Pattern::eof()).add(Pattern::timeout(timeout));
969 self.expect_any(&patterns).await
970 }
971
972 /// Run a batch of commands, waiting for the prompt after each.
973 ///
974 /// This is a convenience method for executing multiple shell commands
975 /// in sequence. For each command, it sends the command line and waits
976 /// for the prompt pattern to appear.
977 ///
978 /// # Example
979 ///
980 /// ```no_run
981 /// use rust_expect::{Session, Pattern};
982 ///
983 /// #[tokio::main]
984 /// async fn main() -> Result<(), rust_expect::ExpectError> {
985 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
986 /// session.expect(Pattern::shell_prompt()).await?;
987 ///
988 /// // Run a batch of commands
989 /// let results = session.run_script(
990 /// &["pwd", "whoami", "date"],
991 /// Pattern::shell_prompt(),
992 /// ).await?;
993 ///
994 /// for result in &results {
995 /// println!("Output: {}", result.before.trim());
996 /// }
997 ///
998 /// Ok(())
999 /// }
1000 /// ```
1001 ///
1002 /// # Errors
1003 ///
1004 /// Returns an error if any command times out or I/O fails.
1005 /// On error, partial results are lost; consider using [`Self::run_script_with_results`]
1006 /// if you need to capture partial results on failure.
1007 pub async fn run_script<I, S>(&mut self, commands: I, prompt: Pattern) -> Result<Vec<Match>>
1008 where
1009 I: IntoIterator<Item = S>,
1010 S: AsRef<str>,
1011 {
1012 let mut results = Vec::new();
1013
1014 for cmd in commands {
1015 self.send_line(cmd.as_ref()).await?;
1016 let result = self.expect(prompt.clone()).await?;
1017 results.push(result);
1018 }
1019
1020 Ok(results)
1021 }
1022
1023 /// Run a batch of commands with a specific timeout per command.
1024 ///
1025 /// Like [`run_script`](Self::run_script), but applies the given timeout
1026 /// to each command individually.
1027 ///
1028 /// # Errors
1029 ///
1030 /// Returns an error if any command times out or I/O fails.
1031 pub async fn run_script_timeout<I, S>(
1032 &mut self,
1033 commands: I,
1034 prompt: Pattern,
1035 timeout: Duration,
1036 ) -> Result<Vec<Match>>
1037 where
1038 I: IntoIterator<Item = S>,
1039 S: AsRef<str>,
1040 {
1041 let mut results = Vec::new();
1042
1043 for cmd in commands {
1044 self.send_line(cmd.as_ref()).await?;
1045 let result = self.expect_timeout(prompt.clone(), timeout).await?;
1046 results.push(result);
1047 }
1048
1049 Ok(results)
1050 }
1051
1052 /// Run a batch of commands, collecting results even on failure.
1053 ///
1054 /// Unlike [`run_script`](Self::run_script), this method continues
1055 /// collecting results and returns them along with any error that occurred.
1056 ///
1057 /// # Returns
1058 ///
1059 /// A tuple of `(results, error)` where:
1060 /// - `results` contains the matches for successfully completed commands
1061 /// - `error` is `Some(err)` if an error occurred, `None` if all commands succeeded
1062 ///
1063 /// # Example
1064 ///
1065 /// ```no_run
1066 /// use rust_expect::{Session, Pattern};
1067 ///
1068 /// #[tokio::main]
1069 /// async fn main() -> Result<(), rust_expect::ExpectError> {
1070 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
1071 /// session.expect(Pattern::shell_prompt()).await?;
1072 ///
1073 /// let (results, error) = session.run_script_with_results(
1074 /// &["pwd", "bad_command", "date"],
1075 /// Pattern::shell_prompt(),
1076 /// ).await;
1077 ///
1078 /// println!("Completed {} commands", results.len());
1079 /// if let Some(e) = error {
1080 /// eprintln!("Script failed at command {}: {}", results.len(), e);
1081 /// }
1082 ///
1083 /// Ok(())
1084 /// }
1085 /// ```
1086 pub async fn run_script_with_results<I, S>(
1087 &mut self,
1088 commands: I,
1089 prompt: Pattern,
1090 ) -> (Vec<Match>, Option<ExpectError>)
1091 where
1092 I: IntoIterator<Item = S>,
1093 S: AsRef<str>,
1094 {
1095 let mut results = Vec::new();
1096
1097 for cmd in commands {
1098 match self.send_line(cmd.as_ref()).await {
1099 Ok(()) => {}
1100 Err(e) => return (results, Some(e)),
1101 }
1102
1103 match self.expect(prompt.clone()).await {
1104 Ok(result) => results.push(result),
1105 Err(e) => return (results, Some(e)),
1106 }
1107 }
1108
1109 (results, None)
1110 }
1111}
1112
1113/// Process-lifecycle methods available when the transport can report a child's
1114/// exit status (PTY-backed sessions). Transports without a child process use the
1115/// default [`ChildExit`] impl and report [`ProcessExitStatus::Unknown`].
1116impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send + ChildExit> Session<T> {
1117 /// Wait for the process to exit.
1118 ///
1119 /// Blocks until EOF is detected on the session — which happens when the
1120 /// child closes the slave end of the PTY, i.e. when it terminates — and
1121 /// then reaps the child to report its real exit status.
1122 ///
1123 /// # Warning
1124 ///
1125 /// This method has no timeout and may block indefinitely if the process
1126 /// does not exit. Consider using [`wait_timeout`](Self::wait_timeout) or
1127 /// [`expect_eof_timeout`](Self::expect_eof_timeout) for bounded waits.
1128 ///
1129 /// # Errors
1130 ///
1131 /// Returns an error if waiting fails due to I/O error.
1132 pub async fn wait(&mut self) -> Result<ProcessExitStatus> {
1133 // Read until EOF (child closed the PTY slave / terminated).
1134 while !self.eof {
1135 if self.read_with_timeout(Duration::from_millis(100)).await? == 0 && !self.eof {
1136 tokio::time::sleep(Duration::from_millis(10)).await;
1137 }
1138 }
1139
1140 let status = self.reap_exit_status().await;
1141 self.state = SessionState::Exited(status);
1142 Ok(status)
1143 }
1144
1145 /// Wait for the process to exit with a timeout.
1146 ///
1147 /// Like [`wait`](Self::wait), but with a maximum duration to wait.
1148 ///
1149 /// # Errors
1150 ///
1151 /// Returns an error if:
1152 /// - The timeout expires before the process exits
1153 /// - An I/O error occurs while waiting
1154 pub async fn wait_timeout(&mut self, timeout: Duration) -> Result<ProcessExitStatus> {
1155 let deadline = tokio::time::Instant::now() + timeout;
1156
1157 while !self.eof {
1158 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1159 if remaining.is_zero() {
1160 return Err(ExpectError::timeout(
1161 timeout,
1162 "<EOF>",
1163 self.matcher.buffer_str(),
1164 ));
1165 }
1166
1167 // Use smaller of remaining time or 100ms for polling
1168 let poll_timeout = remaining.min(Duration::from_millis(100));
1169 if self.read_with_timeout(poll_timeout).await? == 0 && !self.eof {
1170 tokio::time::sleep(Duration::from_millis(10)).await;
1171 }
1172 }
1173
1174 let status = self.reap_exit_status().await;
1175 self.state = SessionState::Exited(status);
1176 Ok(status)
1177 }
1178
1179 /// Reap the child's real exit status after EOF has been observed.
1180 ///
1181 /// EOF means the child closed the PTY slave, so it has exited or is about
1182 /// to. Poll the transport's non-blocking reap briefly to collect the real
1183 /// `Exited`/`Signaled` status, falling back to [`ProcessExitStatus::Unknown`]
1184 /// (the historical return) rather than blocking — e.g. for a non-process
1185 /// transport, or a child that closed its output but lingers before exiting.
1186 async fn reap_exit_status(&self) -> ProcessExitStatus {
1187 // ~100ms ceiling (20 × 5ms); the common case resolves on the first poll.
1188 const ATTEMPTS: u32 = 20;
1189 for _ in 0..ATTEMPTS {
1190 // Lock released at the end of this statement, before the sleep.
1191 let status = self.transport.lock().await.try_exit_status();
1192 if let Some(status) = status {
1193 return status;
1194 }
1195 tokio::time::sleep(Duration::from_millis(5)).await;
1196 }
1197 ProcessExitStatus::Unknown
1198 }
1199}
1200
1201impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> std::fmt::Debug for Session<T> {
1202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1203 f.debug_struct("Session")
1204 .field("id", &self.id)
1205 .field("state", &self.state)
1206 .field("eof", &self.eof)
1207 .finish_non_exhaustive()
1208 }
1209}
1210
1211// Unix-specific spawn implementation
1212#[cfg(unix)]
1213impl Session<AsyncPty> {
1214 /// Spawn a new process with the given command.
1215 ///
1216 /// This creates a new PTY, forks a child process, and returns a Session
1217 /// connected to the child's terminal.
1218 ///
1219 /// # Example
1220 ///
1221 /// ```no_run
1222 /// use rust_expect::Session;
1223 ///
1224 /// #[tokio::main]
1225 /// async fn main() -> Result<(), rust_expect::ExpectError> {
1226 /// let mut session = Session::spawn("/bin/bash", &[]).await?;
1227 /// session.expect("$").await?;
1228 /// session.send_line("echo hello").await?;
1229 /// session.expect("hello").await?;
1230 /// Ok(())
1231 /// }
1232 /// ```
1233 ///
1234 /// # Errors
1235 ///
1236 /// Returns an error if:
1237 /// - The command contains null bytes
1238 /// - PTY allocation fails
1239 /// - Fork fails
1240 /// - The command cannot be executed
1241 pub async fn spawn(command: &str, args: &[&str]) -> Result<Self> {
1242 Self::spawn_with_config(command, args, SessionConfig::default()).await
1243 }
1244
1245 /// Spawn a new process with custom configuration.
1246 ///
1247 /// # Errors
1248 ///
1249 /// Returns an error if spawning fails.
1250 pub async fn spawn_with_config(
1251 command: &str,
1252 args: &[&str],
1253 config: SessionConfig,
1254 ) -> Result<Self> {
1255 let pty_config = PtyConfig::from(&config);
1256 let spawner = PtySpawner::with_config(pty_config);
1257
1258 // Convert &[&str] to Vec<String> for the spawner
1259 let args_owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
1260
1261 // Spawn the process
1262 let handle = spawner.spawn(command, &args_owned).await?;
1263
1264 // Wrap in AsyncPty for async I/O
1265 let async_pty = AsyncPty::from_handle(handle)
1266 .map_err(|e| ExpectError::io_context("creating async PTY wrapper", e))?;
1267
1268 // Create the session
1269 let mut session = Self::new(async_pty, config);
1270 session.state = SessionState::Running;
1271
1272 Ok(session)
1273 }
1274
1275 /// Get the child process ID.
1276 #[must_use]
1277 pub fn pid(&self) -> u32 {
1278 // We need to access the inner transport's pid
1279 // For now, use the blocking lock since we know it's not contended
1280 // during a sync call like this
1281 if let Ok(transport) = self.transport.try_lock() {
1282 transport.pid()
1283 } else {
1284 0
1285 }
1286 }
1287
1288 /// Resize the terminal.
1289 ///
1290 /// Also resizes the attached screen (if any) so it stays consistent
1291 /// with the PTY. Without this, screen-aware assertions would drift
1292 /// after a resize.
1293 ///
1294 /// # Errors
1295 ///
1296 /// Returns an error if the resize ioctl fails.
1297 pub async fn resize_pty(&mut self, cols: u16, rows: u16) -> Result<()> {
1298 {
1299 let mut transport = self.transport.lock().await;
1300 transport.resize(cols, rows)?;
1301 }
1302 self.config.dimensions = (cols, rows);
1303 #[cfg(feature = "screen")]
1304 if let Some(screen) = self.screen.as_ref()
1305 && let Ok(mut s) = screen.lock()
1306 {
1307 s.resize(rows as usize, cols as usize);
1308 }
1309 Ok(())
1310 }
1311
1312 /// Send a signal to the child process.
1313 ///
1314 /// # Errors
1315 ///
1316 /// Returns an error if sending the signal fails.
1317 pub fn signal(&self, signal: i32) -> Result<()> {
1318 if let Ok(transport) = self.transport.try_lock() {
1319 transport.signal(signal)
1320 } else {
1321 Err(ExpectError::io_context(
1322 "sending signal to process",
1323 std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
1324 ))
1325 }
1326 }
1327
1328 /// Kill the child process.
1329 ///
1330 /// # Errors
1331 ///
1332 /// Returns an error if killing the process fails.
1333 pub fn kill(&self) -> Result<()> {
1334 if let Ok(transport) = self.transport.try_lock() {
1335 transport.kill()
1336 } else {
1337 Err(ExpectError::io_context(
1338 "killing process",
1339 std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
1340 ))
1341 }
1342 }
1343
1344 /// Check whether the child process is still running.
1345 ///
1346 /// Performs a non-blocking `waitpid(WNOHANG)` peek, so it reports the truth
1347 /// immediately after the child exits. The portable counterpart of
1348 /// [`Session::<WindowsAsyncPty>::is_running`].
1349 #[must_use]
1350 pub fn is_running(&self) -> bool {
1351 if let Ok(mut transport) = self.transport.try_lock() {
1352 transport.is_running()
1353 } else {
1354 true // Assume running if we can't check
1355 }
1356 }
1357}
1358
1359// Windows-specific spawn implementation
1360#[cfg(windows)]
1361impl Session<WindowsAsyncPty> {
1362 /// Spawn a new process with the given command.
1363 ///
1364 /// This creates a new PTY using Windows ConPTY, spawns a child process,
1365 /// and returns a Session connected to the child's terminal.
1366 ///
1367 /// # Example
1368 ///
1369 /// ```no_run
1370 /// use rust_expect::Session;
1371 ///
1372 /// #[tokio::main]
1373 /// async fn main() -> Result<(), rust_expect::ExpectError> {
1374 /// let mut session = Session::spawn("cmd.exe", &[]).await?;
1375 /// session.expect(">").await?;
1376 /// session.send_line("echo hello").await?;
1377 /// session.expect("hello").await?;
1378 /// Ok(())
1379 /// }
1380 /// ```
1381 ///
1382 /// # Errors
1383 ///
1384 /// Returns an error if:
1385 /// - ConPTY is not available (Windows version too old)
1386 /// - PTY allocation fails
1387 /// - The command cannot be executed
1388 pub async fn spawn(command: &str, args: &[&str]) -> Result<Self> {
1389 Self::spawn_with_config(command, args, SessionConfig::default()).await
1390 }
1391
1392 /// Spawn a new process with custom configuration.
1393 ///
1394 /// # Errors
1395 ///
1396 /// Returns an error if spawning fails.
1397 pub async fn spawn_with_config(
1398 command: &str,
1399 args: &[&str],
1400 config: SessionConfig,
1401 ) -> Result<Self> {
1402 let pty_config = PtyConfig::from(&config);
1403 let spawner = PtySpawner::with_config(pty_config);
1404
1405 // Convert &[&str] to Vec<String> for the spawner
1406 let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
1407
1408 // Spawn the process
1409 let handle = spawner.spawn(command, &args_owned).await?;
1410
1411 // Wrap in WindowsAsyncPty for async I/O
1412 let async_pty = WindowsAsyncPty::from_handle(handle);
1413
1414 // Create the session
1415 let mut session = Session::new(async_pty, config);
1416 session.state = SessionState::Running;
1417
1418 Ok(session)
1419 }
1420
1421 /// Get the child process ID.
1422 #[must_use]
1423 pub fn pid(&self) -> u32 {
1424 if let Ok(transport) = self.transport.try_lock() {
1425 transport.pid()
1426 } else {
1427 0
1428 }
1429 }
1430
1431 /// Resize the terminal.
1432 ///
1433 /// # Errors
1434 ///
1435 /// Returns an error if the resize operation fails.
1436 pub async fn resize_pty(&mut self, cols: u16, rows: u16) -> Result<()> {
1437 let mut transport = self.transport.lock().await;
1438 transport.resize(cols, rows)
1439 }
1440
1441 /// Check if the child process is still running.
1442 #[must_use]
1443 pub fn is_running(&self) -> bool {
1444 if let Ok(transport) = self.transport.try_lock() {
1445 transport.is_running()
1446 } else {
1447 true // Assume running if we can't check
1448 }
1449 }
1450
1451 /// Kill the child process.
1452 ///
1453 /// # Errors
1454 ///
1455 /// Returns an error if killing the process fails.
1456 pub fn kill(&self) -> Result<()> {
1457 if let Ok(mut transport) = self.transport.try_lock() {
1458 transport.kill()
1459 } else {
1460 Err(ExpectError::io_context(
1461 "killing process",
1462 std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
1463 ))
1464 }
1465 }
1466}
1467
1468/// Extension trait for session operations.
1469pub trait SessionExt {
1470 /// Send and expect in one call.
1471 fn send_expect(
1472 &mut self,
1473 send: &str,
1474 expect: impl Into<Pattern>,
1475 ) -> impl std::future::Future<Output = Result<Match>> + Send;
1476
1477 /// Resize the terminal.
1478 fn resize(
1479 &mut self,
1480 dimensions: Dimensions,
1481 ) -> impl std::future::Future<Output = Result<()>> + Send;
1482}
1483
1484/// Check if an I/O error indicates PTY EOF.
1485///
1486/// On Linux, reading from the PTY master returns EIO when the slave side
1487/// has been closed (i.e., the child process has terminated). This is different
1488/// from the standard EOF behavior where `read()` returns 0 bytes.
1489///
1490/// This function returns true for errors that should be treated as EOF:
1491/// - EIO (errno 5) on Unix systems
1492/// - `BrokenPipe` on any platform
1493fn is_pty_eof_error(e: &std::io::Error) -> bool {
1494 use std::io::ErrorKind;
1495
1496 // BrokenPipe indicates the other end has closed
1497 if e.kind() == ErrorKind::BrokenPipe {
1498 return true;
1499 }
1500
1501 // On Unix, check for EIO which indicates slave PTY closed
1502 #[cfg(unix)]
1503 {
1504 if let Some(errno) = e.raw_os_error() {
1505 // EIO is 5 on Linux/macOS/BSD
1506 if errno == libc::EIO {
1507 return true;
1508 }
1509 }
1510 }
1511
1512 false
1513}