Skip to main content

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}