Skip to main content

rust_expect/backend/
pty.rs

1//! PTY backend for local process spawning.
2//!
3//! This module provides the PTY backend that uses the rust-pty crate
4//! to spawn local processes with pseudo-terminal support.
5
6use std::io;
7use std::pin::Pin;
8use std::task::{Context, Poll};
9
10use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
11
12use crate::backend::ChildExit;
13use crate::config::SessionConfig;
14use crate::error::{ExpectError, Result, SpawnError};
15use crate::types::ProcessExitStatus;
16
17/// A PTY-based transport for local process communication.
18pub struct PtyTransport {
19    /// The PTY reader half.
20    reader: Box<dyn AsyncRead + Unpin + Send>,
21    /// The PTY writer half.
22    writer: Box<dyn AsyncWrite + Unpin + Send>,
23    /// Process ID.
24    pid: Option<u32>,
25}
26
27impl PtyTransport {
28    /// Create a new PTY transport from reader and writer.
29    pub fn new<R, W>(reader: R, writer: W) -> Self
30    where
31        R: AsyncRead + Unpin + Send + 'static,
32        W: AsyncWrite + Unpin + Send + 'static,
33    {
34        Self {
35            reader: Box::new(reader),
36            writer: Box::new(writer),
37            pid: None,
38        }
39    }
40
41    /// Set the process ID.
42    pub const fn set_pid(&mut self, pid: u32) {
43        self.pid = Some(pid);
44    }
45
46    /// Get the process ID.
47    #[must_use]
48    pub const fn pid(&self) -> Option<u32> {
49        self.pid
50    }
51}
52
53impl AsyncRead for PtyTransport {
54    fn poll_read(
55        mut self: Pin<&mut Self>,
56        cx: &mut Context<'_>,
57        buf: &mut ReadBuf<'_>,
58    ) -> Poll<io::Result<()>> {
59        Pin::new(&mut self.reader).poll_read(cx, buf)
60    }
61}
62
63impl AsyncWrite for PtyTransport {
64    fn poll_write(
65        mut self: Pin<&mut Self>,
66        cx: &mut Context<'_>,
67        buf: &[u8],
68    ) -> Poll<io::Result<usize>> {
69        Pin::new(&mut self.writer).poll_write(cx, buf)
70    }
71
72    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
73        Pin::new(&mut self.writer).poll_flush(cx)
74    }
75
76    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
77        Pin::new(&mut self.writer).poll_shutdown(cx)
78    }
79}
80
81/// Configuration for PTY spawning.
82#[derive(Debug, Clone)]
83#[non_exhaustive]
84pub struct PtyConfig {
85    /// Terminal dimensions (cols, rows).
86    pub dimensions: (u16, u16),
87    /// Whether to use a login shell.
88    pub login_shell: bool,
89    /// Environment variable handling.
90    pub env_mode: EnvMode,
91    /// Environment variables to apply per `env_mode` (overlay for `Extend`,
92    /// the full set for `Clear`, ignored for `Inherit`).
93    pub env: std::collections::HashMap<String, String>,
94    /// Working directory for the spawned child. `None` inherits the parent's
95    /// current directory.
96    pub working_directory: Option<std::path::PathBuf>,
97}
98
99impl Default for PtyConfig {
100    fn default() -> Self {
101        Self {
102            dimensions: (80, 24),
103            login_shell: false,
104            env_mode: EnvMode::Inherit,
105            env: std::collections::HashMap::new(),
106            working_directory: None,
107        }
108    }
109}
110
111impl From<&SessionConfig> for PtyConfig {
112    fn from(config: &SessionConfig) -> Self {
113        Self {
114            dimensions: config.dimensions,
115            login_shell: false,
116            env_mode: match (config.inherit_env, config.env.is_empty()) {
117                (false, _) => EnvMode::Clear,
118                (true, true) => EnvMode::Inherit,
119                (true, false) => EnvMode::Extend,
120            },
121            env: config.env.clone(),
122            working_directory: config.working_dir.clone(),
123        }
124    }
125}
126
127/// Environment variable handling mode.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum EnvMode {
130    /// Inherit all environment variables from parent.
131    Inherit,
132    /// Clear environment and only use specified variables.
133    Clear,
134    /// Inherit and extend with specified variables.
135    Extend,
136}
137
138/// Apply `env_mode` plus the user-supplied overrides to the calling
139/// process's environment.
140///
141/// **Must only be called in a child process after `fork`** — it mutates
142/// global `environ` state via `setenv`/`clearenv`/`unsetenv`, which is
143/// safe only because the child is single-threaded at this point (between
144/// fork and exec).
145///
146/// - `Inherit`: leave the inherited parent env in place; just apply overrides.
147/// - `Clear`:   wipe environ (Linux: `clearenv`; elsewhere: walk + `unsetenv`)
148///   then apply overrides.
149/// - `Extend`:  same as Inherit semantically; overrides overwrite existing.
150#[cfg(unix)]
151#[allow(unsafe_code)]
152unsafe fn apply_env_in_child(
153    env_mode: EnvMode,
154    env_pairs: &[(std::ffi::CString, std::ffi::CString)],
155) {
156    // SAFETY: caller (this function's doc-comment contract) guarantees we are
157    // executing post-fork, pre-exec in a child process, which is single-threaded.
158    // Mutating `environ` via clearenv/setenv/unsetenv is therefore race-free.
159    unsafe {
160        match env_mode {
161            EnvMode::Inherit | EnvMode::Extend => {}
162            EnvMode::Clear => {
163                #[cfg(target_os = "linux")]
164                {
165                    libc::clearenv();
166                }
167                #[cfg(not(target_os = "linux"))]
168                {
169                    // Collect every existing key into owned CStrings BEFORE we
170                    // start calling unsetenv. unsetenv mutates the global
171                    // `environ` array — entries shift, the array can be
172                    // reallocated — so iterating it concurrently with
173                    // mutation is fragile and libc-dependent. Snapshotting
174                    // first sidesteps the issue entirely, and the keys can
175                    // be of arbitrary length without truncation.
176                    // Edition 2024 requires extern blocks declaring foreign
177                    // statics to be wrapped in `unsafe extern`.
178                    unsafe extern "C" {
179                        static mut environ: *mut *mut libc::c_char;
180                    }
181                    let mut names: Vec<std::ffi::CString> = Vec::new();
182                    if !environ.is_null() {
183                        let mut p = environ;
184                        while !(*p).is_null() {
185                            let entry = *p;
186                            // Find the '=' separator (or NUL if malformed).
187                            let mut len = 0usize;
188                            while *entry.add(len) != 0 && *entry.add(len) != b'=' as libc::c_char {
189                                len += 1;
190                            }
191                            if len > 0 {
192                                let bytes = std::slice::from_raw_parts(entry.cast::<u8>(), len);
193                                if let Ok(c) = std::ffi::CString::new(bytes) {
194                                    names.push(c);
195                                }
196                            }
197                            p = p.add(1);
198                        }
199                    }
200                    for name in &names {
201                        libc::unsetenv(name.as_ptr());
202                    }
203                }
204            }
205        }
206        for (k, v) in env_pairs {
207            libc::setenv(k.as_ptr(), v.as_ptr(), 1);
208        }
209    }
210}
211
212/// Validate environment-variable overrides and convert them to pairs of
213/// `CString` that can be safely applied between fork and exec on Unix.
214///
215/// `setenv` allocates, so the canonical safety model after `fork` is to
216/// only use async-signal-safe functions. We do still call `setenv` in the
217/// child — this codebase forks before any tokio worker threads exist, so
218/// allocator state is single-threaded and the call is sound in practice.
219/// Pre-building these `CString`s here means we don't have to allocate in
220/// the child on the keys or values themselves.
221#[cfg(unix)]
222fn build_env_cstrings(
223    env: &std::collections::HashMap<String, String>,
224) -> Result<Vec<(std::ffi::CString, std::ffi::CString)>> {
225    use std::ffi::CString;
226
227    let mut pairs: Vec<(CString, CString)> = Vec::with_capacity(env.len());
228    for (k, v) in env {
229        if k.contains('=') {
230            return Err(ExpectError::Spawn(SpawnError::InvalidArgument {
231                kind: "env key".to_string(),
232                value: k.clone(),
233                reason: "env key contains '='".to_string(),
234            }));
235        }
236        let key = CString::new(k.as_str()).map_err(|_| {
237            ExpectError::Spawn(SpawnError::InvalidArgument {
238                kind: "env key".to_string(),
239                value: k.clone(),
240                reason: "env key contains null byte".to_string(),
241            })
242        })?;
243        let val = CString::new(v.as_str()).map_err(|_| {
244            ExpectError::Spawn(SpawnError::InvalidArgument {
245                kind: "env value".to_string(),
246                value: v.clone(),
247                reason: "env value contains null byte".to_string(),
248            })
249        })?;
250        pairs.push((key, val));
251    }
252    Ok(pairs)
253}
254
255/// Validate the configured working directory and convert it to a `CString`
256/// that can be safely passed to `chdir` between fork and exec on Unix.
257///
258/// The directory's existence is checked here so a bad path yields a clean
259/// `InvalidWorkingDir` error instead of an opaque child exit, and the
260/// allocation happens pre-fork because allocating in the child is unsound.
261#[cfg(unix)]
262fn build_cwd_cstring(
263    working_directory: Option<&std::path::PathBuf>,
264) -> Result<Option<std::ffi::CString>> {
265    use std::ffi::CString;
266    use std::os::unix::ffi::OsStrExt;
267
268    let Some(path) = working_directory else {
269        return Ok(None);
270    };
271    if !path.is_dir() {
272        return Err(ExpectError::Spawn(SpawnError::InvalidWorkingDir {
273            path: path.display().to_string(),
274        }));
275    }
276    let cstring = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
277        ExpectError::Spawn(SpawnError::InvalidWorkingDir {
278            path: path.display().to_string(),
279        })
280    })?;
281    Ok(Some(cstring))
282}
283
284/// Spawner for PTY sessions.
285pub struct PtySpawner {
286    config: PtyConfig,
287}
288
289impl PtySpawner {
290    /// Create a new PTY spawner with default configuration.
291    #[must_use]
292    pub fn new() -> Self {
293        Self {
294            config: PtyConfig::default(),
295        }
296    }
297
298    /// Create a new PTY spawner with custom configuration.
299    #[must_use]
300    pub const fn with_config(config: PtyConfig) -> Self {
301        Self { config }
302    }
303
304    /// Set the terminal dimensions.
305    pub const fn set_dimensions(&mut self, cols: u16, rows: u16) {
306        self.config.dimensions = (cols, rows);
307    }
308
309    /// Spawn a command.
310    ///
311    /// # Runtime requirement (Unix)
312    ///
313    /// The Unix implementation forks and then calls `setenv` / `unsetenv` /
314    /// `clearenv` between fork and exec to apply the configured env mode.
315    /// Those libc functions are **not** async-signal-safe — they allocate
316    /// — so the post-fork window in the child must run on a single thread
317    /// for the call to be sound. In this crate that is true because
318    /// callers reach `spawn` directly from a fresh `tokio::main` or
319    /// equivalent before any background thread has captured the
320    /// allocator lock at the fork point.
321    ///
322    /// **If you embed this crate in a host that pre-spawns worker
323    /// threads (for example, a multi-threaded scheduler that's already
324    /// running by the time you call `Session::spawn`)**, the assumption
325    /// breaks: another thread may hold the allocator lock at the moment
326    /// of `fork`, and the child can deadlock or corrupt heap state on
327    /// the first `setenv` call. In that environment, prefer a
328    /// `posix_spawn`-based spawner or a pre-fork sentinel-pipe helper.
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if:
333    /// - The command or arguments contain null bytes
334    /// - PTY allocation fails
335    /// - Fork fails
336    /// - Exec fails (child exits with code 1)
337    #[cfg(unix)]
338    #[allow(unsafe_code)]
339    #[allow(clippy::unused_async)]
340    pub async fn spawn(&self, command: &str, args: &[String]) -> Result<PtyHandle> {
341        use std::ffi::CString;
342
343        // Validate and create CStrings BEFORE forking so we can return proper errors
344        let cmd_cstring = CString::new(command).map_err(|_| {
345            ExpectError::Spawn(SpawnError::InvalidArgument {
346                kind: "command".to_string(),
347                value: command.to_string(),
348                reason: "command contains null byte".to_string(),
349            })
350        })?;
351
352        let mut argv_cstrings: Vec<CString> = Vec::with_capacity(args.len() + 1);
353        argv_cstrings.push(cmd_cstring.clone());
354
355        for (idx, arg) in args.iter().enumerate() {
356            let arg_cstring = CString::new(arg.as_str()).map_err(|_| {
357                ExpectError::Spawn(SpawnError::InvalidArgument {
358                    kind: format!("argument[{idx}]"),
359                    value: arg.clone(),
360                    reason: "argument contains null byte".to_string(),
361                })
362            })?;
363            argv_cstrings.push(arg_cstring);
364        }
365
366        // Validate env entries before fork so we can return a clean error.
367        let env_pairs = build_env_cstrings(&self.config.env)?;
368        let env_mode = self.config.env_mode;
369
370        // Validate the working directory and build its CString before forking;
371        // chdir(2) is async-signal-safe but the CString allocation is not.
372        let workdir_cstring = build_cwd_cstring(self.config.working_directory.as_ref())?;
373
374        // Create PTY pair
375        // SAFETY: openpty() is called with valid pointers to stack-allocated integers.
376        // The null pointers for name, termp, and winp are explicitly allowed per POSIX.
377        // We check the return value and handle errors appropriately.
378        let pty_result = unsafe {
379            let mut master: libc::c_int = 0;
380            let mut slave: libc::c_int = 0;
381
382            // Open PTY
383            if libc::openpty(
384                &raw mut master,
385                &raw mut slave,
386                std::ptr::null_mut(),
387                std::ptr::null_mut(),
388                std::ptr::null_mut(),
389            ) != 0
390            {
391                return Err(ExpectError::Spawn(SpawnError::PtyAllocation {
392                    reason: "Failed to open PTY".to_string(),
393                }));
394            }
395
396            (master, slave)
397        };
398
399        let (master_fd, slave_fd) = pty_result;
400
401        // Fork the process
402        // SAFETY: fork() is safe to call at this point as we have no threads running
403        // that could hold locks. The child process will immediately set up its
404        // environment and exec into the target program.
405        let pid = unsafe { libc::fork() };
406
407        match pid {
408            -1 => Err(ExpectError::Spawn(SpawnError::Io(
409                io::Error::last_os_error(),
410            ))),
411            0 => {
412                // Child process
413                // SAFETY: This runs in the forked child process only. We:
414                // - Close the master fd (not needed in child)
415                // - Create a new session with setsid()
416                // - Set the slave as the controlling terminal via TIOCSCTTY
417                // - Redirect stdin/stdout/stderr to the slave pty
418                // - Close the original slave fd if it's not 0, 1, or 2
419                // - Execute the target command (never returns on success)
420                // - Exit with code 1 if exec fails
421                // All file descriptors are valid and owned by this process.
422                unsafe {
423                    libc::close(master_fd);
424                    libc::setsid();
425                    // Widen TIOCSCTTY to c_ulong for macOS compatibility (u32 -> u64).
426                    libc::ioctl(slave_fd, libc::c_ulong::from(libc::TIOCSCTTY), 0);
427
428                    libc::dup2(slave_fd, 0);
429                    libc::dup2(slave_fd, 1);
430                    libc::dup2(slave_fd, 2);
431
432                    if slave_fd > 2 {
433                        libc::close(slave_fd);
434                    }
435
436                    // Change to the configured working directory before exec.
437                    if let Some(ref cwd) = workdir_cstring
438                        && libc::chdir(cwd.as_ptr()) != 0
439                    {
440                        libc::_exit(1);
441                    }
442
443                    // Apply env_mode + overrides before exec.
444                    apply_env_in_child(env_mode, &env_pairs);
445
446                    // Use pre-validated CStrings (validated before fork)
447                    let argv_ptrs: Vec<*const libc::c_char> = argv_cstrings
448                        .iter()
449                        .map(|s| s.as_ptr())
450                        .chain(std::iter::once(std::ptr::null()))
451                        .collect();
452
453                    libc::execvp(cmd_cstring.as_ptr(), argv_ptrs.as_ptr());
454                    libc::_exit(1);
455                }
456            }
457            child_pid => {
458                // Parent process
459                // SAFETY: slave_fd is a valid file descriptor obtained from openpty().
460                // The parent doesn't need the slave end; only the child uses it.
461                unsafe {
462                    libc::close(slave_fd);
463                }
464
465                // Set non-blocking
466                // SAFETY: master_fd is a valid file descriptor from openpty().
467                // F_GETFL and F_SETFL with O_NONBLOCK are standard operations
468                // that don't violate any safety invariants.
469                unsafe {
470                    let flags = libc::fcntl(master_fd, libc::F_GETFL);
471                    libc::fcntl(master_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
472                }
473
474                Ok(PtyHandle {
475                    master_fd,
476                    pid: child_pid as u32,
477                    dimensions: self.config.dimensions,
478                })
479            }
480        }
481    }
482
483    /// Spawn a command on Windows using ConPTY.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if:
488    /// - ConPTY is not available (Windows version too old)
489    /// - PTY allocation fails
490    /// - Process spawning fails
491    #[cfg(windows)]
492    pub async fn spawn(&self, command: &str, args: &[String]) -> Result<WindowsPtyHandle> {
493        use rust_pty::{PtySystem, WindowsPtySystem};
494
495        // Build env per env_mode:
496        // - Inherit: env: None (rust-pty inherits parent env), but if we also
497        //   have overrides, we need to inherit + overlay → build a full map.
498        // - Clear:   env: Some(our overrides) — parent env discarded.
499        // - Extend:  env: Some(parent + our overrides), parent first so ours win.
500        let built_env: Option<std::collections::HashMap<std::ffi::OsString, std::ffi::OsString>> =
501            match self.config.env_mode {
502                EnvMode::Inherit if self.config.env.is_empty() => None,
503                EnvMode::Inherit | EnvMode::Extend => {
504                    let mut m: std::collections::HashMap<_, _> = std::env::vars_os().collect();
505                    for (k, v) in &self.config.env {
506                        m.insert(std::ffi::OsString::from(k), std::ffi::OsString::from(v));
507                    }
508                    Some(m)
509                }
510                EnvMode::Clear => Some(
511                    self.config
512                        .env
513                        .iter()
514                        .map(|(k, v)| (std::ffi::OsString::from(k), std::ffi::OsString::from(v)))
515                        .collect(),
516                ),
517            };
518
519        // Create configuration for rust-pty
520        let pty_config = rust_pty::PtyConfig {
521            window_size: self.config.dimensions,
522            env: match self.config.env_mode {
523                EnvMode::Clear if self.config.env.is_empty() => {
524                    Some(std::collections::HashMap::new())
525                }
526                _ => built_env,
527            },
528            working_directory: self.config.working_directory.clone(),
529            ..Default::default()
530        };
531
532        // Spawn using rust-pty's Windows implementation
533        let (master, child) =
534            WindowsPtySystem::spawn(command, args.iter().map(|s| s.as_str()), &pty_config)
535                .await
536                .map_err(|e| {
537                    ExpectError::Spawn(SpawnError::PtyAllocation {
538                        reason: format!("Windows ConPTY spawn failed: {e}"),
539                    })
540                })?;
541
542        Ok(WindowsPtyHandle {
543            master,
544            child,
545            dimensions: self.config.dimensions,
546        })
547    }
548}
549
550impl Default for PtySpawner {
551    fn default() -> Self {
552        Self::new()
553    }
554}
555
556/// Handle to a spawned PTY process (Unix).
557#[cfg(unix)]
558#[derive(Debug)]
559pub struct PtyHandle {
560    /// Master PTY file descriptor.
561    master_fd: i32,
562    /// Process ID.
563    pid: u32,
564    /// Terminal dimensions (cols, rows).
565    dimensions: (u16, u16),
566}
567
568/// Handle to a spawned PTY process (Windows).
569#[cfg(windows)]
570pub struct WindowsPtyHandle {
571    /// The PTY master from rust-pty.
572    pub(crate) master: rust_pty::WindowsPtyMaster,
573    /// The child process handle.
574    pub(crate) child: rust_pty::WindowsPtyChild,
575    /// Terminal dimensions (cols, rows).
576    dimensions: (u16, u16),
577}
578
579#[cfg(windows)]
580impl std::fmt::Debug for WindowsPtyHandle {
581    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582        f.debug_struct("WindowsPtyHandle")
583            .field("dimensions", &self.dimensions)
584            .finish_non_exhaustive()
585    }
586}
587
588#[cfg(unix)]
589impl PtyHandle {
590    /// Get the process ID.
591    #[must_use]
592    pub const fn pid(&self) -> u32 {
593        self.pid
594    }
595
596    /// Get the terminal dimensions.
597    #[must_use]
598    pub const fn dimensions(&self) -> (u16, u16) {
599        self.dimensions
600    }
601
602    /// Resize the terminal.
603    #[allow(unsafe_code)]
604    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
605        let winsize = libc::winsize {
606            ws_row: rows,
607            ws_col: cols,
608            ws_xpixel: 0,
609            ws_ypixel: 0,
610        };
611
612        // SAFETY: master_fd is a valid PTY file descriptor stored in self.
613        // TIOCSWINSZ is a valid ioctl command for PTYs that sets the window size.
614        // winsize is a valid pointer to a properly initialized struct on the stack.
615        // Cast to c_ulong for macOS compatibility (u32 -> u64).
616        let result =
617            unsafe { libc::ioctl(self.master_fd, libc::TIOCSWINSZ as libc::c_ulong, &winsize) };
618
619        if result != 0 {
620            Err(ExpectError::Io(io::Error::last_os_error()))
621        } else {
622            self.dimensions = (cols, rows);
623            Ok(())
624        }
625    }
626
627    /// Wait for the process to exit.
628    #[allow(unsafe_code)]
629    pub fn wait(&self) -> Result<i32> {
630        let mut status: libc::c_int = 0;
631        // SAFETY: self.pid is a valid process ID from fork().
632        // status is a valid pointer to a stack-allocated integer.
633        // The options argument (0) means blocking wait, which is valid.
634        let result = unsafe { libc::waitpid(self.pid as i32, &raw mut status, 0) };
635
636        if result == -1 {
637            Err(ExpectError::Io(io::Error::last_os_error()))
638        } else if libc::WIFEXITED(status) {
639            Ok(libc::WEXITSTATUS(status))
640        } else if libc::WIFSIGNALED(status) {
641            Ok(128 + libc::WTERMSIG(status))
642        } else {
643            Ok(-1)
644        }
645    }
646
647    /// Send a signal to the process.
648    #[allow(unsafe_code)]
649    pub fn signal(&self, signal: i32) -> Result<()> {
650        // SAFETY: self.pid is a valid process ID from fork().
651        // The signal is passed from the caller and must be a valid signal number.
652        // kill() is safe to call with any PID; it returns an error for invalid PIDs.
653        let result = unsafe { libc::kill(self.pid as i32, signal) };
654        if result != 0 {
655            Err(ExpectError::Io(io::Error::last_os_error()))
656        } else {
657            Ok(())
658        }
659    }
660
661    /// Kill the process.
662    pub fn kill(&self) -> Result<()> {
663        self.signal(libc::SIGKILL)
664    }
665}
666
667#[cfg(windows)]
668impl WindowsPtyHandle {
669    /// Get the process ID.
670    #[must_use]
671    pub fn pid(&self) -> u32 {
672        self.child.pid()
673    }
674
675    /// Get the terminal dimensions.
676    #[must_use]
677    pub const fn dimensions(&self) -> (u16, u16) {
678        self.dimensions
679    }
680
681    /// Resize the terminal.
682    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
683        use rust_pty::{PtyMaster, WindowSize};
684        let size = WindowSize::new(cols, rows);
685        self.master
686            .resize(size)
687            .map_err(|e| ExpectError::Io(io::Error::other(format!("resize failed: {e}"))))?;
688        self.dimensions = (cols, rows);
689        Ok(())
690    }
691
692    /// Check if the child process is still running.
693    #[must_use]
694    pub fn is_running(&self) -> bool {
695        self.child.is_running()
696    }
697
698    /// Kill the process.
699    pub fn kill(&mut self) -> Result<()> {
700        self.child
701            .kill()
702            .map_err(|e| ExpectError::Io(io::Error::other(format!("kill failed: {e}"))))
703    }
704}
705
706#[cfg(unix)]
707impl Drop for PtyHandle {
708    #[allow(unsafe_code)]
709    fn drop(&mut self) {
710        // Close the master fd
711        // SAFETY: master_fd is a valid file descriptor obtained from openpty()
712        // and stored in this struct. It has not been closed elsewhere as we own it.
713        // Closing in Drop ensures the fd is released when the handle is dropped.
714        unsafe {
715            libc::close(self.master_fd);
716        }
717    }
718}
719
720/// Async wrapper around a PTY file descriptor for use with Tokio.
721///
722/// This provides `AsyncRead` and `AsyncWrite` implementations that
723/// integrate with the Tokio runtime.
724#[cfg(unix)]
725pub struct AsyncPty {
726    /// The async file descriptor wrapper.
727    inner: tokio::io::unix::AsyncFd<std::os::unix::io::RawFd>,
728    /// Process ID.
729    pid: u32,
730    /// Terminal dimensions.
731    dimensions: (u16, u16),
732    /// Cached exit status, set once the child has been reaped.
733    ///
734    /// `waitpid` is not idempotent — a second reap of an already-collected
735    /// child fails with `ECHILD` — so the first observed status is cached here
736    /// and returned by all subsequent `try_wait`/`is_running` calls.
737    exit_status: Option<ProcessExitStatus>,
738}
739
740#[cfg(unix)]
741impl AsyncPty {
742    /// Create a new async PTY wrapper from a `PtyHandle`.
743    ///
744    /// Takes ownership of the `PtyHandle`'s file descriptor.
745    ///
746    /// # Errors
747    ///
748    /// Returns an error if the `AsyncFd` cannot be created.
749    pub fn from_handle(handle: PtyHandle) -> io::Result<Self> {
750        let fd = handle.master_fd;
751        let pid = handle.pid;
752        let dimensions = handle.dimensions;
753
754        // Prevent the original handle from closing the fd
755        std::mem::forget(handle);
756
757        let inner = tokio::io::unix::AsyncFd::new(fd)?;
758        Ok(Self {
759            inner,
760            pid,
761            dimensions,
762            exit_status: None,
763        })
764    }
765
766    /// Non-blocking reap of the child process.
767    ///
768    /// Returns `Some(status)` if the child has exited (caching it for future
769    /// calls), or `None` while it is still running. A child reaped elsewhere
770    /// (`ECHILD`) is reported as exited with [`ProcessExitStatus::Unknown`],
771    /// since its real code is no longer recoverable.
772    #[allow(unsafe_code)]
773    pub fn try_wait(&mut self) -> Option<ProcessExitStatus> {
774        if let Some(status) = self.exit_status {
775            return Some(status);
776        }
777
778        let mut raw: libc::c_int = 0;
779        loop {
780            // SAFETY: self.pid is a valid PID from fork(); &raw mut raw is a
781            // valid out-pointer; WNOHANG makes this a non-blocking query.
782            let result = unsafe { libc::waitpid(self.pid as i32, &raw mut raw, libc::WNOHANG) };
783
784            if result == 0 {
785                // Child still running.
786                return None;
787            }
788
789            if result == -1 {
790                let err = io::Error::last_os_error();
791                if err.kind() == io::ErrorKind::Interrupted {
792                    continue; // EINTR — retry the syscall.
793                }
794                // ECHILD (already reaped) or another error: the child is gone
795                // but its real status is unrecoverable.
796                let status = ProcessExitStatus::Unknown;
797                self.exit_status = Some(status);
798                return Some(status);
799            }
800
801            let status = decode_wait_status(raw);
802            self.exit_status = Some(status);
803            return Some(status);
804        }
805    }
806
807    /// Check whether the child process is still running.
808    ///
809    /// Non-blocking: performs a `waitpid(WNOHANG)` peek, so it reports the truth
810    /// immediately after the child exits. Mirrors `WindowsAsyncPty::is_running`.
811    pub fn is_running(&mut self) -> bool {
812        self.try_wait().is_none()
813    }
814
815    /// Get the process ID.
816    #[must_use]
817    pub const fn pid(&self) -> u32 {
818        self.pid
819    }
820
821    /// Get the terminal dimensions.
822    #[must_use]
823    pub const fn dimensions(&self) -> (u16, u16) {
824        self.dimensions
825    }
826
827    /// Resize the terminal.
828    #[allow(unsafe_code)]
829    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
830        let winsize = libc::winsize {
831            ws_row: rows,
832            ws_col: cols,
833            ws_xpixel: 0,
834            ws_ypixel: 0,
835        };
836
837        // SAFETY: The fd is valid and TIOCSWINSZ is a valid ioctl for PTYs.
838        // Cast to c_ulong for macOS compatibility (u32 -> u64).
839        let result = unsafe {
840            libc::ioctl(
841                *self.inner.get_ref(),
842                libc::TIOCSWINSZ as libc::c_ulong,
843                &winsize,
844            )
845        };
846
847        if result != 0 {
848            Err(ExpectError::Io(io::Error::last_os_error()))
849        } else {
850            self.dimensions = (cols, rows);
851            Ok(())
852        }
853    }
854
855    /// Send a signal to the child process.
856    #[allow(unsafe_code)]
857    pub fn signal(&self, signal: i32) -> Result<()> {
858        // SAFETY: pid is a valid process ID from fork().
859        let result = unsafe { libc::kill(self.pid as i32, signal) };
860        if result != 0 {
861            Err(ExpectError::Io(io::Error::last_os_error()))
862        } else {
863            Ok(())
864        }
865    }
866
867    /// Kill the child process.
868    pub fn kill(&self) -> Result<()> {
869        self.signal(libc::SIGKILL)
870    }
871}
872
873#[cfg(unix)]
874impl AsyncRead for AsyncPty {
875    #[allow(unsafe_code)]
876    fn poll_read(
877        self: Pin<&mut Self>,
878        cx: &mut Context<'_>,
879        buf: &mut ReadBuf<'_>,
880    ) -> Poll<io::Result<()>> {
881        loop {
882            let mut guard = match self.inner.poll_read_ready(cx) {
883                Poll::Ready(Ok(guard)) => guard,
884                Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
885                Poll::Pending => return Poll::Pending,
886            };
887
888            let fd = *self.inner.get_ref();
889            let unfilled = buf.initialize_unfilled();
890
891            // SAFETY: fd is a valid file descriptor, unfilled is a valid buffer.
892            let result = unsafe {
893                libc::read(
894                    fd,
895                    unfilled.as_mut_ptr().cast::<libc::c_void>(),
896                    unfilled.len(),
897                )
898            };
899
900            if result >= 0 {
901                buf.advance(result as usize);
902                return Poll::Ready(Ok(()));
903            }
904
905            let err = io::Error::last_os_error();
906            if err.kind() == io::ErrorKind::WouldBlock {
907                guard.clear_ready();
908                continue;
909            }
910            return Poll::Ready(Err(err));
911        }
912    }
913}
914
915#[cfg(unix)]
916impl AsyncWrite for AsyncPty {
917    #[allow(unsafe_code)]
918    fn poll_write(
919        mut self: Pin<&mut Self>,
920        cx: &mut Context<'_>,
921        buf: &[u8],
922    ) -> Poll<io::Result<usize>> {
923        // A dead child's PTY master buffers writes on Linux; surface exit as BrokenPipe.
924        if self.as_mut().get_mut().try_wait().is_some() {
925            return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
926        }
927        loop {
928            let mut guard = match self.inner.poll_write_ready(cx) {
929                Poll::Ready(Ok(guard)) => guard,
930                Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
931                Poll::Pending => return Poll::Pending,
932            };
933
934            let fd = *self.inner.get_ref();
935
936            // SAFETY: fd is a valid file descriptor, buf is a valid buffer.
937            let result = unsafe { libc::write(fd, buf.as_ptr().cast::<libc::c_void>(), buf.len()) };
938
939            if result >= 0 {
940                return Poll::Ready(Ok(result as usize));
941            }
942
943            let err = io::Error::last_os_error();
944            if err.kind() == io::ErrorKind::WouldBlock {
945                guard.clear_ready();
946                continue;
947            }
948            return Poll::Ready(Err(err));
949        }
950    }
951
952    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
953        // PTY doesn't need explicit flushing
954        Poll::Ready(Ok(()))
955    }
956
957    fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
958        // Shutdown is handled by Drop
959        Poll::Ready(Ok(()))
960    }
961}
962
963#[cfg(unix)]
964impl Drop for AsyncPty {
965    #[allow(unsafe_code)]
966    fn drop(&mut self) {
967        // SAFETY: The fd is valid and owned by us.
968        unsafe {
969            libc::close(*self.inner.get_ref());
970        }
971    }
972}
973
974#[cfg(unix)]
975impl std::fmt::Debug for AsyncPty {
976    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
977        f.debug_struct("AsyncPty")
978            .field("fd", self.inner.get_ref())
979            .field("pid", &self.pid)
980            .field("dimensions", &self.dimensions)
981            .field("exit_status", &self.exit_status)
982            .finish()
983    }
984}
985
986#[cfg(unix)]
987impl ChildExit for AsyncPty {
988    fn try_exit_status(&mut self) -> Option<ProcessExitStatus> {
989        self.try_wait()
990    }
991}
992
993/// Decode a raw `waitpid` status word into a [`ProcessExitStatus`].
994///
995/// Distinguishes a normal exit (`WIFEXITED` → `Exited(code)`) from termination
996/// by a signal (`WIFSIGNALED` → `Signaled(sig)`), preserving the raw signal
997/// number rather than collapsing it into `128 + sig`.
998#[cfg(unix)]
999const fn decode_wait_status(raw: libc::c_int) -> ProcessExitStatus {
1000    if libc::WIFEXITED(raw) {
1001        ProcessExitStatus::Exited(libc::WEXITSTATUS(raw))
1002    } else if libc::WIFSIGNALED(raw) {
1003        ProcessExitStatus::Signaled(libc::WTERMSIG(raw))
1004    } else {
1005        // Stopped/continued: the child has not actually terminated.
1006        ProcessExitStatus::Unknown
1007    }
1008}
1009
1010/// Async wrapper around Windows ConPTY for use with Tokio.
1011///
1012/// This wraps the rust-pty WindowsPtyMaster and provides the same interface
1013/// as the Unix AsyncPty for consistent cross-platform Session usage.
1014#[cfg(windows)]
1015pub struct WindowsAsyncPty {
1016    /// The underlying Windows PTY master.
1017    master: rust_pty::WindowsPtyMaster,
1018    /// The child process handle.
1019    child: rust_pty::WindowsPtyChild,
1020    /// Process ID.
1021    pid: u32,
1022    /// Terminal dimensions.
1023    dimensions: (u16, u16),
1024}
1025
1026#[cfg(windows)]
1027impl WindowsAsyncPty {
1028    /// Create a new Windows async PTY wrapper from a WindowsPtyHandle.
1029    ///
1030    /// Takes ownership of the handle.
1031    pub fn from_handle(handle: WindowsPtyHandle) -> Self {
1032        let pid = handle.child.pid();
1033        let dimensions = handle.dimensions;
1034        Self {
1035            master: handle.master,
1036            child: handle.child,
1037            pid,
1038            dimensions,
1039        }
1040    }
1041
1042    /// Get the process ID.
1043    #[must_use]
1044    pub const fn pid(&self) -> u32 {
1045        self.pid
1046    }
1047
1048    /// Get the terminal dimensions.
1049    #[must_use]
1050    pub const fn dimensions(&self) -> (u16, u16) {
1051        self.dimensions
1052    }
1053
1054    /// Resize the terminal.
1055    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
1056        use rust_pty::{PtyMaster, WindowSize};
1057        let size = WindowSize::new(cols, rows);
1058        self.master
1059            .resize(size)
1060            .map_err(|e| ExpectError::Io(io::Error::other(format!("resize failed: {e}"))))?;
1061        self.dimensions = (cols, rows);
1062        Ok(())
1063    }
1064
1065    /// Check if the child process is still running.
1066    #[must_use]
1067    pub fn is_running(&self) -> bool {
1068        self.child.is_running()
1069    }
1070
1071    /// Kill the child process.
1072    pub fn kill(&mut self) -> Result<()> {
1073        self.child
1074            .kill()
1075            .map_err(|e| ExpectError::Io(io::Error::other(format!("kill failed: {e}"))))
1076    }
1077}
1078
1079#[cfg(windows)]
1080impl ChildExit for WindowsAsyncPty {
1081    fn try_exit_status(&mut self) -> Option<ProcessExitStatus> {
1082        // WindowsPtyChild::try_wait peeks GetExitCodeProcess without blocking and
1083        // returns the real status once the child has exited. The exit watcher
1084        // installed by rust-pty guarantees EOF is delivered to readers, so by the
1085        // time Session::wait reaches here the child has typically already exited.
1086        match self.child.try_wait() {
1087            Ok(Some(rust_pty::ExitStatus::Exited(code))) => Some(ProcessExitStatus::Exited(code)),
1088            // Windows reports every exit as `Terminated(exit_code)`; the code is the real exit code.
1089            Ok(Some(rust_pty::ExitStatus::Terminated(code))) => {
1090                Some(ProcessExitStatus::Exited(code as i32))
1091            }
1092            // Still running, or status unrecoverable.
1093            Ok(None) | Err(_) => None,
1094        }
1095    }
1096}
1097
1098#[cfg(windows)]
1099impl AsyncRead for WindowsAsyncPty {
1100    fn poll_read(
1101        mut self: Pin<&mut Self>,
1102        cx: &mut Context<'_>,
1103        buf: &mut ReadBuf<'_>,
1104    ) -> Poll<io::Result<()>> {
1105        // Delegate to the underlying WindowsPtyMaster which implements AsyncRead
1106        Pin::new(&mut self.master).poll_read(cx, buf)
1107    }
1108}
1109
1110#[cfg(windows)]
1111impl AsyncWrite for WindowsAsyncPty {
1112    fn poll_write(
1113        mut self: Pin<&mut Self>,
1114        cx: &mut Context<'_>,
1115        buf: &[u8],
1116    ) -> Poll<io::Result<usize>> {
1117        // Mirror the Unix guard: a write after the ConPTY child exits must surface closure.
1118        if matches!(self.child.try_wait(), Ok(Some(_))) {
1119            return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
1120        }
1121        Pin::new(&mut self.master).poll_write(cx, buf)
1122    }
1123
1124    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
1125        Pin::new(&mut self.master).poll_flush(cx)
1126    }
1127
1128    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
1129        Pin::new(&mut self.master).poll_shutdown(cx)
1130    }
1131}
1132
1133#[cfg(windows)]
1134impl std::fmt::Debug for WindowsAsyncPty {
1135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1136        f.debug_struct("WindowsAsyncPty")
1137            .field("pid", &self.pid)
1138            .field("dimensions", &self.dimensions)
1139            .finish_non_exhaustive()
1140    }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    #[test]
1148    fn pty_config_default() {
1149        let config = PtyConfig::default();
1150        assert_eq!(config.dimensions.0, 80);
1151        assert_eq!(config.dimensions.1, 24);
1152        assert_eq!(config.env_mode, EnvMode::Inherit);
1153    }
1154
1155    #[test]
1156    fn pty_config_from_session() {
1157        let session_config = SessionConfig {
1158            dimensions: (120, 40),
1159            ..Default::default()
1160        };
1161
1162        let pty_config = PtyConfig::from(&session_config);
1163        assert_eq!(pty_config.dimensions.0, 120);
1164        assert_eq!(pty_config.dimensions.1, 40);
1165    }
1166
1167    #[cfg(unix)]
1168    #[tokio::test]
1169    async fn spawn_rejects_null_byte_in_command() {
1170        let spawner = PtySpawner::new();
1171        let result = spawner.spawn("test\0command", &[]).await;
1172
1173        assert!(result.is_err());
1174        let err = result.unwrap_err();
1175        let err_str = err.to_string();
1176        assert!(
1177            err_str.contains("null byte"),
1178            "Expected error about null byte, got: {err_str}"
1179        );
1180    }
1181
1182    #[cfg(unix)]
1183    #[tokio::test]
1184    async fn spawn_rejects_null_byte_in_args() {
1185        let spawner = PtySpawner::new();
1186        let result = spawner
1187            .spawn("/bin/echo", &["hello\0world".to_string()])
1188            .await;
1189
1190        assert!(result.is_err());
1191        let err = result.unwrap_err();
1192        let err_str = err.to_string();
1193        assert!(
1194            err_str.contains("null byte"),
1195            "Expected error about null byte, got: {err_str}"
1196        );
1197    }
1198}