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