Skip to main content

nexcore_pty/
lib.rs

1//! POSIX pseudo-terminal allocation — safe wrappers around `openpty(3)` and friends.
2//!
3//! Foundation-layer crate with a single external dependency (`libc`).
4//! Wraps `openpty()`, `fork()`, `setsid()`, `dup2()`, `execvp()`, and
5//! `TIOCSWINSZ` ioctl behind safe Rust functions.
6//!
7//! # Supply Chain Sovereignty
8//!
9//! This crate replaces external PTY crates (`nix`, `portable-pty`) with a
10//! minimal, auditable wrapper around POSIX system calls. ~250 lines, zero
11//! transitive dependencies beyond `libc`.
12//!
13//! ## Primitive Grounding
14//!
15//! `∂(Boundary) + ς(State) + →(Causality)`
16
17#![warn(missing_docs)]
18#[cfg(not(unix))]
19compile_error!("nexcore-pty requires a Unix platform (Linux or macOS)");
20
21use std::ffi::CString;
22use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
23
24// ─── Types ───────────────────────────────────────────────────────────
25
26/// Terminal dimensions for PTY allocation and resize.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct WinSize {
29    /// Row count.
30    pub rows: u16,
31    /// Column count.
32    pub cols: u16,
33}
34
35/// A matched pair of PTY file descriptors (master + slave).
36///
37/// The master fd is used by the controlling process (terminal emulator).
38/// The slave fd becomes the child's stdin/stdout/stderr.
39pub struct PtyPair {
40    /// Master side — read/write from terminal emulator.
41    pub master: OwnedFd,
42    /// Slave side — becomes child's controlling terminal.
43    pub slave: OwnedFd,
44}
45
46/// Configuration for spawning a child process in a PTY.
47pub struct SpawnConfig<'a> {
48    /// Shell binary path (e.g., "/bin/bash").
49    pub program: &'a str,
50    /// Command-line arguments (argv\[0\] should be program name).
51    pub args: &'a [&'a str],
52    /// Working directory for the child.
53    pub working_dir: &'a str,
54    /// Environment variables to set (merged with inherited env).
55    pub env: &'a [(String, String)],
56}
57
58/// Errors from PTY operations.
59#[non_exhaustive]
60#[derive(Debug)]
61pub enum PtyError {
62    /// `openpty()` failed.
63    OpenPtyFailed(std::io::Error),
64    /// `fork()` failed.
65    ForkFailed(std::io::Error),
66    /// `execvp()` failed in child (communicated via pipe).
67    ExecFailed(std::io::Error),
68    /// `ioctl(TIOCSWINSZ)` failed.
69    ResizeFailed(std::io::Error),
70    /// `fcntl(F_SETFL)` failed.
71    SetNonblockFailed(std::io::Error),
72    /// `kill()` or `waitpid()` failed.
73    ProcessError(std::io::Error),
74    /// A string argument contained an interior NUL byte.
75    InvalidString(std::ffi::NulError),
76}
77
78impl std::fmt::Display for PtyError {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::OpenPtyFailed(e) => write!(f, "openpty failed: {e}"),
82            Self::ForkFailed(e) => write!(f, "fork failed: {e}"),
83            Self::ExecFailed(e) => write!(f, "exec failed in child: {e}"),
84            Self::ResizeFailed(e) => write!(f, "TIOCSWINSZ ioctl failed: {e}"),
85            Self::SetNonblockFailed(e) => write!(f, "fcntl F_SETFL failed: {e}"),
86            Self::ProcessError(e) => write!(f, "process operation failed: {e}"),
87            Self::InvalidString(e) => write!(f, "invalid C string: {e}"),
88        }
89    }
90}
91
92impl std::error::Error for PtyError {
93    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
94        match self {
95            Self::OpenPtyFailed(e)
96            | Self::ForkFailed(e)
97            | Self::ExecFailed(e)
98            | Self::ResizeFailed(e)
99            | Self::SetNonblockFailed(e)
100            | Self::ProcessError(e) => Some(e),
101            Self::InvalidString(e) => Some(e),
102        }
103    }
104}
105
106impl PtyError {
107    /// Extract the underlying I/O error for conversion to other error types.
108    ///
109    /// # Panics
110    ///
111    /// Panics if called on `InvalidString` variant. Callers should match
112    /// or use `source()` instead for that case.
113    #[must_use]
114    pub fn into_io(self) -> std::io::Error {
115        match self {
116            Self::OpenPtyFailed(e)
117            | Self::ForkFailed(e)
118            | Self::ExecFailed(e)
119            | Self::ResizeFailed(e)
120            | Self::SetNonblockFailed(e)
121            | Self::ProcessError(e) => e,
122            Self::InvalidString(e) => std::io::Error::new(std::io::ErrorKind::InvalidInput, e),
123        }
124    }
125}
126
127// ─── Functions ───────────────────────────────────────────────────────
128
129/// Allocate a new pseudo-terminal pair.
130///
131/// Returns master and slave file descriptors. The master fd is for the
132/// terminal emulator; the slave fd is for the child process.
133///
134/// The PTY is initialized with the given terminal dimensions.
135#[allow(unsafe_code, reason = "FFI boundary for POSIX openpty(3)")]
136pub fn open_pty(size: WinSize) -> Result<PtyPair, PtyError> {
137    let mut master_fd: libc::c_int = -1;
138    let mut slave_fd: libc::c_int = -1;
139
140    let ws = libc::winsize {
141        ws_row: size.rows,
142        ws_col: size.cols,
143        ws_xpixel: 0,
144        ws_ypixel: 0,
145    };
146
147    // SAFETY: `master_fd` and `slave_fd` are valid mutable pointers to c_int on
148    // the stack. `openpty` writes valid fds on success (returns 0). On failure
149    // (returns -1), no fds are created. We pass NULL for name and termios
150    // (use defaults), and a valid winsize pointer for initial dimensions.
151    let ret = unsafe {
152        libc::openpty(
153            &mut master_fd,
154            &mut slave_fd,
155            std::ptr::null_mut(), // name — not needed
156            std::ptr::null(),     // termios — use defaults (includes ICRNL)
157            &ws,
158        )
159    };
160
161    if ret != 0 {
162        return Err(PtyError::OpenPtyFailed(std::io::Error::last_os_error()));
163    }
164
165    // SAFETY: `openpty` succeeded (ret == 0), so both fds are valid, open file
166    // descriptors. `OwnedFd::from_raw_fd` takes ownership and will close them
167    // on drop.
168    let master = unsafe { OwnedFd::from_raw_fd(master_fd) };
169    let slave = unsafe { OwnedFd::from_raw_fd(slave_fd) };
170
171    Ok(PtyPair { master, slave })
172}
173
174/// Fork and exec a program in the slave PTY.
175///
176/// - Creates a new session (`setsid`)
177/// - Sets the slave fd as controlling terminal
178/// - Dups slave to stdin/stdout/stderr
179/// - Closes master fd in child
180/// - Execs the program
181///
182/// Returns the child PID. The slave fd is consumed (closed in parent
183/// when `slave` is dropped).
184///
185/// **Critical safety note:** Between `fork()` and `exec()`, only
186/// async-signal-safe functions are called. No Rust allocation occurs
187/// in the child before exec.
188#[allow(unsafe_code, reason = "FFI boundary for POSIX fork(2)/execvp(3)")]
189pub fn fork_exec(
190    slave: OwnedFd,
191    master_fd: RawFd,
192    config: &SpawnConfig<'_>,
193) -> Result<u32, PtyError> {
194    // Pre-fork: prepare all C strings while we can still allocate safely.
195    let c_program = CString::new(config.program).map_err(PtyError::InvalidString)?;
196
197    let c_args: Vec<CString> = config
198        .args
199        .iter()
200        .map(|a| CString::new(*a))
201        .collect::<Result<Vec<_>, _>>()
202        .map_err(PtyError::InvalidString)?;
203
204    let c_arg_ptrs: Vec<*const libc::c_char> = c_args
205        .iter()
206        .map(|a| a.as_ptr())
207        .chain(std::iter::once(std::ptr::null()))
208        .collect();
209
210    let c_working_dir = CString::new(config.working_dir).map_err(PtyError::InvalidString)?;
211
212    let c_env: Vec<CString> = config
213        .env
214        .iter()
215        .map(|(k, v)| CString::new(format!("{k}={v}")))
216        .collect::<Result<Vec<_>, _>>()
217        .map_err(PtyError::InvalidString)?;
218
219    // Create a pipe for exec error reporting.
220    // If execvp succeeds, the write end is auto-closed (CLOEXEC).
221    // If execvp fails, the child writes errno to the pipe.
222    let mut pipe_fds = [0i32; 2];
223
224    // SAFETY: pipe_fds is a valid [c_int; 2] on the stack.
225    // pipe() writes two valid fds on success. We set CLOEXEC manually.
226    #[cfg(target_os = "linux")]
227    {
228        let ret = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
229        if ret != 0 {
230            return Err(PtyError::ForkFailed(std::io::Error::last_os_error()));
231        }
232    }
233    #[cfg(not(target_os = "linux"))]
234    {
235        // SAFETY: pipe_fds is a valid [c_int; 2]. pipe() creates two valid fds.
236        let ret = unsafe { libc::pipe(pipe_fds.as_mut_ptr()) };
237        if ret != 0 {
238            return Err(PtyError::ForkFailed(std::io::Error::last_os_error()));
239        }
240        // SAFETY: pipe_fds[0] and pipe_fds[1] are valid fds from pipe().
241        // F_SETFD with FD_CLOEXEC is a standard fcntl operation.
242        unsafe {
243            libc::fcntl(pipe_fds[0], libc::F_SETFD, libc::FD_CLOEXEC);
244            libc::fcntl(pipe_fds[1], libc::F_SETFD, libc::FD_CLOEXEC);
245        }
246    }
247
248    let pipe_read = pipe_fds[0];
249    let pipe_write = pipe_fds[1];
250    let slave_fd = slave.as_raw_fd();
251
252    // SAFETY: fork() is async-signal-safe. After fork(), in the child process,
253    // we only call async-signal-safe functions (setsid, dup2, close, chdir,
254    // execvp, write, _exit). No Rust heap allocation occurs between fork and
255    // exec. The child never returns to Rust code — it either execs or calls
256    // _exit(1).
257    let pid = unsafe { libc::fork() };
258
259    if pid < 0 {
260        // Fork failed — clean up pipe fds.
261        // SAFETY: pipe_read and pipe_write are valid fds from pipe().
262        unsafe {
263            libc::close(pipe_read);
264            libc::close(pipe_write);
265        }
266        drop(slave);
267        return Err(PtyError::ForkFailed(std::io::Error::last_os_error()));
268    }
269
270    if pid == 0 {
271        // ── Child process ──
272        // Only async-signal-safe calls from here until exec.
273
274        // SAFETY: close(pipe_read) — we only need the write end in the child.
275        // master_fd — the child doesn't need the master side.
276        unsafe {
277            libc::close(pipe_read);
278            libc::close(master_fd);
279        }
280
281        // SAFETY: setsid() creates a new session. The child becomes session
282        // leader and process group leader. This is required for the slave PTY
283        // to become the controlling terminal.
284        unsafe {
285            libc::setsid();
286        }
287
288        // SAFETY: TIOCSCTTY sets the slave as the controlling terminal for
289        // this session. The slave_fd is a valid PTY slave from openpty().
290        // Argument 0 means "don't steal from another session."
291        #[cfg(not(target_os = "macos"))]
292        unsafe {
293            libc::ioctl(slave_fd, libc::TIOCSCTTY, 0);
294        }
295        // On macOS, opening the slave after setsid() automatically makes it
296        // the controlling terminal — TIOCSCTTY is still available but the
297        // constant may differ. Opening the slave path is the portable approach,
298        // but since we already have the fd from openpty(), TIOCSCTTY works.
299        #[cfg(target_os = "macos")]
300        unsafe {
301            libc::ioctl(slave_fd, libc::TIOCSCTTY as libc::c_ulong, 0);
302        }
303
304        // SAFETY: dup2() copies slave_fd to stdin/stdout/stderr. slave_fd is
305        // a valid fd from openpty(). After dup2, fds 0/1/2 point to the slave.
306        unsafe {
307            libc::dup2(slave_fd, libc::STDIN_FILENO);
308            libc::dup2(slave_fd, libc::STDOUT_FILENO);
309            libc::dup2(slave_fd, libc::STDERR_FILENO);
310        }
311
312        // Close original slave fd if it wasn't 0, 1, or 2.
313        if slave_fd > 2 {
314            // SAFETY: slave_fd is valid and distinct from 0/1/2.
315            unsafe {
316                libc::close(slave_fd);
317            }
318        }
319
320        // Change working directory.
321        // SAFETY: c_working_dir is a valid CString prepared before fork.
322        unsafe {
323            libc::chdir(c_working_dir.as_ptr());
324        }
325
326        // Set environment variables.
327        for c_var in &c_env {
328            // SAFETY: c_var is a valid CString in "KEY=VALUE" format.
329            unsafe {
330                libc::putenv(c_var.as_ptr() as *mut libc::c_char);
331            }
332        }
333
334        // Exec the program.
335        // SAFETY: c_program is a valid CString. c_arg_ptrs is a null-terminated
336        // array of valid CString pointers, prepared before fork. execvp either
337        // replaces the process image (never returns) or returns -1 on failure.
338        unsafe {
339            libc::execvp(c_program.as_ptr(), c_arg_ptrs.as_ptr());
340        }
341
342        // If we get here, execvp failed. Write errno to pipe and exit.
343        let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(1);
344        let err_bytes = err.to_ne_bytes();
345        // SAFETY: pipe_write is valid (CLOEXEC didn't fire because exec failed).
346        // err_bytes is a valid [u8; 4] on the stack.
347        unsafe {
348            libc::write(pipe_write, err_bytes.as_ptr().cast(), err_bytes.len());
349            libc::_exit(1);
350        }
351    }
352
353    // ── Parent process ──
354
355    // Close pipe write end — we only read from it.
356    // SAFETY: pipe_write is a valid fd from pipe().
357    unsafe {
358        libc::close(pipe_write);
359    }
360
361    // Drop the slave fd — parent doesn't need it. The child has dup2'd it.
362    drop(slave);
363
364    // Read from pipe to check if exec succeeded.
365    // If exec succeeded, pipe_read returns EOF (0 bytes) because CLOEXEC
366    // closed the write end. If exec failed, we get 4 bytes of errno.
367    let mut err_buf = [0u8; 4];
368    // SAFETY: pipe_read is a valid fd. err_buf is a valid [u8; 4] on stack.
369    let n = unsafe { libc::read(pipe_read, err_buf.as_mut_ptr().cast(), err_buf.len()) };
370
371    // SAFETY: pipe_read is a valid fd.
372    unsafe {
373        libc::close(pipe_read);
374    }
375
376    if n > 0 {
377        // Exec failed — decode errno.
378        let errno = i32::from_ne_bytes(err_buf);
379        // Reap the child to avoid zombie.
380        // SAFETY: pid is a valid child PID from fork().
381        unsafe {
382            libc::waitpid(pid, std::ptr::null_mut(), 0);
383        }
384        return Err(PtyError::ExecFailed(std::io::Error::from_raw_os_error(
385            errno,
386        )));
387    }
388
389    // pid is a valid child PID (positive i32 from successful fork).
390    #[allow(
391        clippy::cast_sign_loss,
392        reason = "pid is positive after successful fork (checked pid < 0 and pid == 0 above)"
393    )]
394    let child_pid = pid as u32;
395
396    Ok(child_pid)
397}
398
399/// Resize the terminal via `TIOCSWINSZ` ioctl.
400///
401/// Sends the new dimensions to the terminal driver, which delivers
402/// `SIGWINCH` to the foreground process group.
403#[allow(unsafe_code, reason = "FFI boundary for POSIX ioctl(2) TIOCSWINSZ")]
404pub fn resize(master: &OwnedFd, size: WinSize) -> Result<(), PtyError> {
405    let ws = libc::winsize {
406        ws_row: size.rows,
407        ws_col: size.cols,
408        ws_xpixel: 0,
409        ws_ypixel: 0,
410    };
411
412    // SAFETY: master is a valid PTY master fd (from OwnedFd). ws is a valid
413    // libc::winsize on the stack. TIOCSWINSZ is the correct ioctl for setting
414    // terminal dimensions.
415    let ret = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
416
417    if ret != 0 {
418        return Err(PtyError::ResizeFailed(std::io::Error::last_os_error()));
419    }
420
421    Ok(())
422}
423
424/// Set a file descriptor to non-blocking mode (`O_NONBLOCK`).
425///
426/// Required before wrapping in tokio's `AsyncFd`.
427#[allow(unsafe_code, reason = "FFI boundary for POSIX fcntl(2)")]
428pub fn set_nonblocking(fd: &OwnedFd) -> Result<(), PtyError> {
429    let raw = fd.as_raw_fd();
430
431    // SAFETY: raw is a valid fd from OwnedFd. F_GETFL retrieves current flags.
432    let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
433    if flags < 0 {
434        return Err(PtyError::SetNonblockFailed(std::io::Error::last_os_error()));
435    }
436
437    // SAFETY: raw is valid. F_SETFL with O_NONBLOCK added to existing flags.
438    let ret = unsafe { libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK) };
439    if ret != 0 {
440        return Err(PtyError::SetNonblockFailed(std::io::Error::last_os_error()));
441    }
442
443    Ok(())
444}
445
446/// Read bytes from a PTY master fd (synchronous).
447///
448/// For use inside `tokio::io::unix::AsyncFd::try_io()`. Returns the number
449/// of bytes read, or 0 if the slave side has been closed (child exited).
450#[allow(unsafe_code, reason = "FFI boundary for POSIX read(2)")]
451pub fn read_master(master: &OwnedFd, buf: &mut [u8]) -> std::io::Result<usize> {
452    // SAFETY: master is a valid fd from OwnedFd. buf is a valid mutable slice.
453    // read(2) writes at most buf.len() bytes and returns the count, or -1 on error.
454    let n = unsafe { libc::read(master.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) };
455    if n < 0 {
456        Err(std::io::Error::last_os_error())
457    } else {
458        // n >= 0, safe to cast to usize.
459        #[allow(
460            clippy::cast_sign_loss,
461            reason = "n is non-negative (checked n < 0 above)"
462        )]
463        Ok(n as usize)
464    }
465}
466
467/// Write bytes to a PTY master fd (synchronous).
468///
469/// For use inside `tokio::io::unix::AsyncFd::try_io()`. Returns the number
470/// of bytes written.
471#[allow(unsafe_code, reason = "FFI boundary for POSIX write(2)")]
472pub fn write_master(master: &OwnedFd, data: &[u8]) -> std::io::Result<usize> {
473    // SAFETY: master is a valid fd from OwnedFd. data is a valid slice.
474    // write(2) writes at most data.len() bytes and returns the count, or -1 on error.
475    let n = unsafe { libc::write(master.as_raw_fd(), data.as_ptr().cast(), data.len()) };
476    if n < 0 {
477        Err(std::io::Error::last_os_error())
478    } else {
479        #[allow(
480            clippy::cast_sign_loss,
481            reason = "n is non-negative (checked n < 0 above)"
482        )]
483        Ok(n as usize)
484    }
485}
486
487/// Send a signal to a process.
488///
489/// Wrapper around `kill(2)`. Use `libc::SIGKILL` (9) for unconditional
490/// termination, `libc::SIGTERM` (15) for graceful shutdown.
491#[allow(unsafe_code, reason = "FFI boundary for POSIX kill(2)")]
492pub fn signal_process(pid: u32, sig: i32) -> Result<(), PtyError> {
493    // SAFETY: pid is a valid PID from our fork(). sig is a signal number.
494    // kill(2) is safe with any pid/sig — errors are returned, never UB.
495    #[allow(
496        clippy::cast_possible_wrap,
497        reason = "PID fits in i32 (kernel guarantees PIDs < 2^22)"
498    )]
499    let ret = unsafe { libc::kill(pid as i32, sig) };
500    if ret != 0 {
501        return Err(PtyError::ProcessError(std::io::Error::last_os_error()));
502    }
503    Ok(())
504}
505
506/// Non-blocking wait for child exit status.
507///
508/// Returns `Some(exit_code)` if the child has exited, `None` if still running.
509/// Uses `WNOHANG` flag.
510#[allow(unsafe_code, reason = "FFI boundary for POSIX waitpid(2)")]
511pub fn try_wait_pid(pid: u32) -> Result<Option<i32>, PtyError> {
512    let mut status: libc::c_int = 0;
513
514    // SAFETY: pid is a valid child PID from fork(). status is a valid c_int
515    // on the stack. WNOHANG makes it non-blocking — returns 0 if child is
516    // still running, pid if exited, -1 on error.
517    #[allow(
518        clippy::cast_possible_wrap,
519        reason = "PID fits in i32 (kernel guarantees PIDs < 2^22)"
520    )]
521    let ret = unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) };
522
523    if ret < 0 {
524        return Err(PtyError::ProcessError(std::io::Error::last_os_error()));
525    }
526
527    if ret == 0 {
528        // Child still running.
529        return Ok(None);
530    }
531
532    // Child exited — extract exit code.
533    // WIFEXITED/WEXITSTATUS/WIFSIGNALED/WTERMSIG are safe functions in libc.
534    if libc::WIFEXITED(status) {
535        Ok(Some(libc::WEXITSTATUS(status)))
536    } else if libc::WIFSIGNALED(status) {
537        // Killed by signal — return -signal_number as the exit code.
538        Ok(Some(-libc::WTERMSIG(status)))
539    } else {
540        Ok(Some(-1))
541    }
542}
543
544// ─── Tests ───────────────────────────────────────────────────────────
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn open_pty_creates_valid_pair() {
552        let pair = open_pty(WinSize { rows: 24, cols: 80 });
553        assert!(pair.is_ok(), "openpty should succeed");
554        let pair = pair.expect("already checked");
555        // Both fds should be valid (positive).
556        assert!(pair.master.as_raw_fd() >= 0);
557        assert!(pair.slave.as_raw_fd() >= 0);
558        assert_ne!(pair.master.as_raw_fd(), pair.slave.as_raw_fd());
559    }
560
561    #[test]
562    fn resize_on_valid_master() {
563        let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
564        let result = resize(
565            &pair.master,
566            WinSize {
567                rows: 40,
568                cols: 120,
569            },
570        );
571        assert!(result.is_ok(), "resize should succeed on valid PTY master");
572    }
573
574    #[test]
575    fn set_nonblocking_on_valid_fd() {
576        let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
577        let result = set_nonblocking(&pair.master);
578        assert!(result.is_ok(), "set_nonblocking should succeed");
579    }
580
581    #[test]
582    fn fork_exec_echo() {
583        let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
584        let master_raw = pair.master.as_raw_fd();
585        let config = SpawnConfig {
586            program: "/bin/echo",
587            args: &["echo", "hello"],
588            working_dir: "/tmp",
589            env: &[],
590        };
591        let result = fork_exec(pair.slave, master_raw, &config);
592        assert!(result.is_ok(), "fork_exec /bin/echo should succeed");
593        let pid = result.expect("already checked");
594        assert!(pid > 0);
595
596        // Read output from master.
597        let mut buf = [0u8; 256];
598        let n = read_master(&pair.master, &mut buf);
599        assert!(n.is_ok(), "should read from master");
600
601        // Wait for child to finish.
602        let wait = try_wait_pid(pid);
603        // Child may have already exited — either Some or None is valid here.
604        assert!(wait.is_ok());
605    }
606
607    #[test]
608    fn fork_exec_nonexistent_fails() {
609        let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
610        let master_raw = pair.master.as_raw_fd();
611        let config = SpawnConfig {
612            program: "/nonexistent/binary/path",
613            args: &["nonexistent"],
614            working_dir: "/tmp",
615            env: &[],
616        };
617        let result = fork_exec(pair.slave, master_raw, &config);
618        assert!(
619            result.is_err(),
620            "fork_exec of nonexistent binary should fail"
621        );
622        if let Err(PtyError::ExecFailed(_)) = result {
623            // Expected — exec failed, reported via pipe.
624        } else {
625            panic!("Expected ExecFailed, got {:?}", result.err());
626        }
627    }
628
629    #[test]
630    fn signal_and_wait() {
631        let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
632        let master_raw = pair.master.as_raw_fd();
633        let config = SpawnConfig {
634            program: "/bin/sleep",
635            args: &["sleep", "60"],
636            working_dir: "/tmp",
637            env: &[],
638        };
639        let pid = fork_exec(pair.slave, master_raw, &config).expect("fork_exec sleep");
640
641        // Should be running.
642        let status = try_wait_pid(pid).expect("try_wait");
643        assert_eq!(status, None, "sleep should still be running");
644
645        // Kill it.
646        signal_process(pid, libc::SIGKILL).expect("signal_process");
647
648        // Wait for it to actually die (brief spin since SIGKILL is immediate).
649        let mut exited = false;
650        for _ in 0..100 {
651            if let Ok(Some(_)) = try_wait_pid(pid) {
652                exited = true;
653                break;
654            }
655            std::thread::sleep(std::time::Duration::from_millis(10));
656        }
657        assert!(exited, "process should have exited after SIGKILL");
658    }
659
660    #[test]
661    fn write_and_read_master() {
662        let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
663        let master_raw = pair.master.as_raw_fd();
664        let config = SpawnConfig {
665            program: "/bin/cat",
666            args: &["cat"],
667            working_dir: "/tmp",
668            env: &[],
669        };
670        let pid = fork_exec(pair.slave, master_raw, &config).expect("fork_exec cat");
671
672        // Write to master (goes to cat's stdin).
673        let written = write_master(&pair.master, b"test\n");
674        assert!(written.is_ok(), "write to master should succeed");
675
676        // Give cat a moment to echo back.
677        std::thread::sleep(std::time::Duration::from_millis(100));
678
679        // Read from master (cat's stdout).
680        set_nonblocking(&pair.master).expect("nonblock");
681        let mut buf = [0u8; 256];
682        let n = read_master(&pair.master, &mut buf);
683        // May succeed with data or get EAGAIN — both are valid.
684        if let Ok(count) = n {
685            assert!(count > 0, "should have read some output from cat");
686        }
687
688        // Clean up.
689        signal_process(pid, libc::SIGKILL).ok();
690        try_wait_pid(pid).ok();
691    }
692
693    #[test]
694    fn pty_error_display() {
695        let err = PtyError::OpenPtyFailed(std::io::Error::from_raw_os_error(2));
696        let msg = format!("{err}");
697        assert!(msg.contains("openpty failed"));
698    }
699
700    #[test]
701    fn pty_error_into_io() {
702        let err = PtyError::ResizeFailed(std::io::Error::from_raw_os_error(22));
703        let io_err = err.into_io();
704        assert_eq!(io_err.raw_os_error(), Some(22));
705    }
706}