tty_web/
pty.rs

1//! UNIX pseudo-terminal (PTY) allocation and window-size control.
2//!
3//! Wraps `openpty(3)` and `ioctl(TIOCSWINSZ)` from the [`nix`] crate into a
4//! safe, async-friendly interface.
5
6use std::os::fd::{AsFd, AsRawFd, OwnedFd};
7use std::os::unix::process::CommandExt;
8use std::path::Path;
9use std::process::{Child, Command, Stdio};
10
11use nix::fcntl;
12use nix::libc;
13use nix::pty::openpty;
14
15/// Owns the master side of a PTY and the child shell process.
16pub struct PtyMaster {
17    /// Master file descriptor (non-blocking).
18    pub master: OwnedFd,
19    /// Child process running the shell.
20    pub child: Child,
21}
22
23impl PtyMaster {
24    /// Allocate a new PTY pair, spawn `shell` on the slave side, and return the
25    /// master fd set to non-blocking mode.
26    ///
27    /// If `pwd` is provided, the shell process starts in that directory.
28    pub fn spawn(shell: &str, pwd: Option<&Path>) -> std::io::Result<Self> {
29        let pty = openpty(None, None).map_err(std::io::Error::other)?;
30
31        let slave_out = pty.slave.try_clone()?;
32        let slave_err = pty.slave.try_clone()?;
33
34        let mut cmd = Command::new(shell);
35        cmd.stdin(Stdio::from(pty.slave))
36            .stdout(Stdio::from(slave_out))
37            .stderr(Stdio::from(slave_err))
38            .env("TERM", "xterm-256color")
39            .env("COLORTERM", "truecolor");
40
41        if let Some(dir) = pwd {
42            cmd.current_dir(dir);
43        }
44
45        // Safety: pre_exec runs in forked child before exec.
46        // Only async-signal-safe libc calls are used.
47        let child = unsafe {
48            cmd.pre_exec(|| {
49                if libc::setsid() == -1 {
50                    return Err(std::io::Error::last_os_error());
51                }
52                if libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 {
53                    return Err(std::io::Error::last_os_error());
54                }
55                Ok(())
56            })
57            .spawn()?
58        };
59
60        // Set master fd to non-blocking for async I/O
61        let flags =
62            fcntl::fcntl(&pty.master, fcntl::FcntlArg::F_GETFL).map_err(std::io::Error::other)?;
63        let mut flags = fcntl::OFlag::from_bits_truncate(flags);
64        flags.insert(fcntl::OFlag::O_NONBLOCK);
65        fcntl::fcntl(&pty.master, fcntl::FcntlArg::F_SETFL(flags))
66            .map_err(std::io::Error::other)?;
67
68        Ok(PtyMaster {
69            master: pty.master,
70            child,
71        })
72    }
73}
74
75/// Set the terminal window size on a PTY file descriptor.
76///
77/// Safe wrapper around `ioctl(TIOCSWINSZ)`. The caller must
78/// pass an fd that refers to a valid PTY master or slave.
79pub fn set_window_size(fd: impl AsFd, rows: u16, cols: u16) -> std::io::Result<()> {
80    let ws = libc::winsize {
81        ws_row: rows,
82        ws_col: cols,
83        ws_xpixel: 0,
84        ws_ypixel: 0,
85    };
86    // Safety: fd is a valid file descriptor (enforced by AsFd),
87    // ws is a valid winsize struct on the stack.
88    let ret = unsafe { libc::ioctl(fd.as_fd().as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
89    if ret == -1 {
90        Err(std::io::Error::last_os_error())
91    } else {
92        Ok(())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn test_spawn_and_child_alive() {
102        let mut pty = PtyMaster::spawn("/bin/sh", None).expect("spawn /bin/sh");
103        // Child should still be running
104        assert!(
105            pty.child.try_wait().unwrap().is_none(),
106            "child should be alive"
107        );
108        // Cleanup
109        let _ = pty.child.kill();
110        let _ = pty.child.wait();
111    }
112
113    #[test]
114    fn test_set_window_size() {
115        let mut pty = PtyMaster::spawn("/bin/sh", None).expect("spawn /bin/sh");
116        set_window_size(&pty.master, 40, 120).expect("set_window_size should succeed");
117        let _ = pty.child.kill();
118        let _ = pty.child.wait();
119    }
120
121    #[test]
122    fn test_spawn_with_pwd() {
123        let dir = std::env::temp_dir();
124        let mut pty = PtyMaster::spawn("/bin/sh", Some(dir.as_path())).expect("spawn with pwd");
125        assert!(
126            pty.child.try_wait().unwrap().is_none(),
127            "child should be alive"
128        );
129        let _ = pty.child.kill();
130        let _ = pty.child.wait();
131    }
132}