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