//! PTY spawning + I/O for shop. //! //! Opens a Linux pseudo-terminal, forks, execs a shell in the child with the //! slave as controlling terminal, and hands the parent a nonblocking master //! fd for read/write. The master fd implements [`AsFd`] so callers can plug //! it into any Unix event loop (calloop, mio, epoll). //! //! Reference: rio's teletypewriter/src/unix/mod.rs (MIT). Not a line-for-line //! port — we use nix's safe wrappers rather than raw libc, and skip the //! corcovado (mio 0.6) coupling entirely. use std::ffi::CString; use std::fs::File; use std::io::{self, Write}; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; use std::path::Path; use nix::fcntl::{FcntlArg, OFlag, fcntl}; use nix::libc; use nix::pty::{Winsize, openpty}; use nix::sys::signal::{Signal, kill}; use nix::unistd::{ForkResult, Pid, execvp, fork, setsid}; /// Dimensions of the terminal surface reported to the kernel via TIOCSWINSZ. #[derive(Debug, Clone, Copy)] pub struct PtySize { pub cols: u16, pub rows: u16, pub cell_width: u16, pub cell_height: u16, } impl PtySize { fn to_winsize(self) -> Winsize { Winsize { ws_row: self.rows, ws_col: self.cols, ws_xpixel: self.cols.saturating_mul(self.cell_width), ws_ypixel: self.rows.saturating_mul(self.cell_height), } } } /// A running shell attached to a PTY. Dropping this sends SIGHUP to the /// child. pub struct Pty { master: OwnedFd, child: Pid, recorder: Option, } impl Pty { /// Fork a child running `command`, hand the child an already-set-up /// controlling terminal, and return the parent-side master fd. /// /// `term` controls the value of the `TERM` env var for the child; the /// shell/programs use it to select escape sequences. Start with /// `"xterm-256color"` until we have a terminfo entry for shop. pub fn spawn(command: &str, args: &[&str], size: PtySize, term: &str) -> anyhow::Result { let ws = size.to_winsize(); let pair = openpty(Some(&ws), None)?; // SAFETY: post-fork, the child only calls async-signal-safe libc // calls (setsid, dup2, ioctl, execvp) plus setenv (not strictly // signal-safe, tolerated by GLIBC for terminal setup). #[allow(unsafe_code)] match unsafe { fork() }? { ForkResult::Parent { child } => { drop(pair.slave); let flags = fcntl(pair.master.as_fd(), FcntlArg::F_GETFL)?; let nb = OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK; fcntl(pair.master.as_fd(), FcntlArg::F_SETFL(nb))?; Ok(Self { master: pair.master, child, recorder: None, }) } ForkResult::Child => { run_child(pair.slave, command, args, term); } } } /// Send SIGWINCH to the child after updating the master's window size. pub fn resize(&self, size: PtySize) -> anyhow::Result<()> { let ws = size.to_winsize(); // SAFETY: master.as_raw_fd() is a valid fd for our process; ioctl // TIOCSWINSZ reads Winsize by pointer. #[allow(unsafe_code)] let rc = unsafe { libc::ioctl( self.master.as_raw_fd(), libc::TIOCSWINSZ, std::ptr::from_ref(&ws), ) }; if rc != 0 { anyhow::bail!(io::Error::last_os_error()); } Ok(()) } /// Nonblocking read. Returns `Ok(0)` on EOF, `WouldBlock` when there's /// nothing to read yet. /// /// If a recorder is attached (see [`set_recorder`](Self::set_recorder)), /// bytes read here are teed to it byte-for-byte before returning. /// Recorder write errors are swallowed — the terminal must not die /// because a debug capture failed. pub fn read(&self, buf: &mut [u8]) -> io::Result { let n = nix::unistd::read(&self.master, buf).map_err(io::Error::from)?; if n > 0 { if let Some(f) = self.recorder.as_ref() { let _ = (&*f).write_all(&buf[..n]); } } Ok(n) } /// Tee every subsequent successful [`read`](Self::read) to `path`. The /// file is created (or truncated if it exists) up front, so a bad path /// surfaces here rather than mid-session. /// /// Meant for capturing the PTY byte stream for feeding into the /// `kitty-graphics-testkit` corpus. See `capture-apc`. pub fn set_recorder(&mut self, path: impl AsRef) -> io::Result<()> { self.recorder = Some(File::create(path)?); Ok(()) } /// Write bytes to the shell (e.g. keystrokes). pub fn write(&self, buf: &[u8]) -> io::Result { nix::unistd::write(&self.master, buf).map_err(io::Error::from) } pub fn child(&self) -> Pid { self.child } } impl AsFd for Pty { fn as_fd(&self) -> BorrowedFd<'_> { self.master.as_fd() } } impl Drop for Pty { fn drop(&mut self) { let _ = kill(self.child, Signal::SIGHUP); } } fn run_child(slave: OwnedFd, command: &str, args: &[&str], term: &str) -> ! { // New session detaches us from the parent's controlling terminal. let _ = setsid(); // Make the slave our controlling terminal. #[allow(unsafe_code)] unsafe { libc::ioctl(slave.as_raw_fd(), libc::TIOCSCTTY as _, 0); } // Duplicate slave onto stdin/stdout/stderr. libc::dup2 rather than nix's // typed version, which needs an owned destination (unusable for stdio). // SAFETY: slave.as_raw_fd() is valid; STDIN/OUT/ERR_FILENO are constants. let slave_raw = slave.as_raw_fd(); #[allow(unsafe_code)] unsafe { libc::dup2(slave_raw, libc::STDIN_FILENO); libc::dup2(slave_raw, libc::STDOUT_FILENO); libc::dup2(slave_raw, libc::STDERR_FILENO); } drop(slave); // TERM is the classic escape-set selector; SHELL is informational. // SAFETY: single-threaded child process, no other threads to race. #[allow(unsafe_code)] unsafe { std::env::set_var("TERM", term); } let cmd_c = CString::new(command).expect("command has NUL"); let mut arg_cstrs: Vec = Vec::with_capacity(args.len() + 1); arg_cstrs.push(cmd_c.clone()); for a in args { arg_cstrs.push(CString::new(*a).expect("arg has NUL")); } let _ = execvp(&cmd_c, &arg_cstrs); // execvp only returns on failure. std::process::exit(127); }