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}