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