git.lucas.co / cce-terminal
terminal emulator
git clone https://git.lucas.co/cce-terminal.git

src/pty.rs (7.2K)

  1 //! PTY plumbing: openpty, shell spawn with the slave as controlling terminal,
  2 //! and dup'd master handles for the reader thread / key-input writes.
  3 
  4 use std::fs::File;
  5 use std::io;
  6 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
  7 use std::os::unix::process::CommandExt;
  8 use std::process::{Child, Command, Stdio};
  9 
 10 pub struct Pty {
 11     pub master: OwnedFd,
 12     pub child: Child,
 13 }
 14 
 15 /// Open a pty pair and spawn `command` (or `$SHELL`) on the slave side, in
 16 /// its own session with the slave as controlling terminal. The slave fd is
 17 /// fully handed to the child (stdin/stdout/stderr) and closed in the parent,
 18 /// so EOF on the master is the child-exit signal. `cwd` starts the child
 19 /// there (a new tab inherits the active one's directory); `None` inherits
 20 /// the terminal's own.
 21 pub fn spawn_shell(
 22     cols: u16,
 23     rows: u16,
 24     command: Option<&[String]>,
 25     cwd: Option<&std::path::Path>,
 26 ) -> io::Result<Pty> {
 27     let mut master: libc::c_int = -1;
 28     let mut slave: libc::c_int = -1;
 29     let ws = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 };
 30     let ret = unsafe {
 31         libc::openpty(&mut master, &mut slave, std::ptr::null_mut(), std::ptr::null(), &ws)
 32     };
 33     if ret != 0 {
 34         return Err(io::Error::last_os_error());
 35     }
 36     let master = unsafe { OwnedFd::from_raw_fd(master) };
 37     let slave = unsafe { OwnedFd::from_raw_fd(slave) };
 38     unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) };
 39 
 40     let mut cmd = match command {
 41         Some(argv) => {
 42             let mut c = Command::new(&argv[0]);
 43             c.args(&argv[1..]);
 44             c
 45         }
 46         None => Command::new(std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())),
 47     };
 48     // The VT layer is alacritty_terminal, so alacritty's terminfo entry
 49     // describes us accurately (verified present on the host).
 50     cmd.env("TERM", "alacritty")
 51         .env("COLORTERM", "truecolor")
 52         .stdin(Stdio::from(slave.try_clone()?))
 53         .stdout(Stdio::from(slave.try_clone()?))
 54         .stderr(Stdio::from(slave));
 55     // A directory that vanished since it was read is not worth failing the
 56     // spawn over — the child just starts where the terminal did.
 57     if let Some(dir) = cwd.filter(|d| d.is_dir()) {
 58         cmd.current_dir(dir);
 59     }
 60     unsafe {
 61         cmd.pre_exec(|| {
 62             if libc::setsid() < 0 {
 63                 return Err(io::Error::last_os_error());
 64             }
 65             // stdin IS the slave after the Stdio wiring above.
 66             if libc::ioctl(0, libc::TIOCSCTTY as libc::c_ulong, 0) < 0 {
 67                 return Err(io::Error::last_os_error());
 68             }
 69             Ok(())
 70         });
 71     }
 72     let child = cmd.spawn()?;
 73     Ok(Pty { master, child })
 74 }
 75 
 76 impl Pty {
 77     pub fn resize(&self, cols: u16, rows: u16) {
 78         let ws = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 };
 79         unsafe { libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
 80     }
 81 
 82     /// A dup of the master as a `File` (own fd, CLOEXEC) — one for the reader
 83     /// thread, one for key-input writes.
 84     pub fn dup_handle(&self) -> io::Result<File> {
 85         let fd = unsafe { libc::fcntl(self.master.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) };
 86         if fd < 0 {
 87             return Err(io::Error::last_os_error());
 88         }
 89         Ok(unsafe { File::from_raw_fd(fd) })
 90     }
 91 }
 92 
 93 #[cfg(test)]
 94 mod tests {
 95     use super::*;
 96     use std::io::{Read, Write};
 97     use std::time::{Duration, Instant};
 98 
 99     /// Full round trip through a real shell: keystroke bytes in on the master,
100     /// command output back out — the headless equivalent of typing into the
101     /// window (the GUI path is `handle_key_input` → the same master fd).
102     ///
103     /// The child's whole environment is pinned through `env -i` rather than
104     /// inherited, and this is load-bearing twice over.
105     ///
106     /// It used to spawn `$SHELL` with the developer's environment, type a
107     /// command and let the shell `exit` — which is exactly when an
108     /// interactive shell flushes its history file. So `cargo test` appended
109     /// `printf 'RT-%s\n' OK; exit` to the developer's own shell history,
110     /// intermittently: it is a race with the `kill` below, and reproduced in
111     /// 2 runs out of 8. Appended rather than truncated, so nothing was lost,
112     /// but a test has no business writing there at all.
113     ///
114     /// A private `HOME` alone does NOT fix it. `HISTFILE` is commonly
115     /// exported from an rc file as an ABSOLUTE path — `export
116     /// HISTFILE="$HOME/.cache/.zsh_history"` is the shape — so the test
117     /// binary inherits it and the child writes through to the real one no
118     /// matter where its home points. Clearing the environment is what closes
119     /// that, and `HISTFILE=/dev/null` then covers the shells that would
120     /// otherwise default back into the private home.
121     ///
122     /// Second, `$SHELL` made the test mean something different on every
123     /// machine: it sourced the developer's rc files, so a slow, noisy or
124     /// interactive one could hang this or break its assertion, and a
125     /// non-POSIX login shell would not read the command at all. What is
126     /// under test is the pty plumbing — openpty, the controlling terminal,
127     /// the master round trip — not which binary the developer logs in with.
128     /// The cost is that `spawn_shell`'s `$SHELL` fallback is no longer
129     /// covered here; it is one `env::var` with a `/bin/sh` default.
130     #[test]
131     fn shell_round_trip() {
132         let home = std::env::temp_dir()
133             .join(format!("cce-terminal-test-home-{}", std::process::id()));
134         std::fs::create_dir_all(&home).expect("test home");
135         let argv: Vec<String> = [
136             "/usr/bin/env".to_string(),
137             "-i".to_string(),
138             "PATH=/usr/bin:/bin".to_string(),
139             format!("HOME={}", home.display()),
140             "HISTFILE=/dev/null".to_string(),
141             // spawn_shell sets these on the Command, and `env -i` would
142             // otherwise drop them before the shell ever sees them.
143             "TERM=alacritty".to_string(),
144             "COLORTERM=truecolor".to_string(),
145             "/bin/sh".to_string(),
146         ]
147         .to_vec();
148         let mut pty = spawn_shell(80, 24, Some(&argv), None).expect("openpty/spawn");
149         let mut writer = pty.dup_handle().unwrap();
150         let mut reader = pty.dup_handle().unwrap();
151         writer.write_all(b"printf 'RT-%s\\n' OK; exit\r").unwrap();
152 
153         let deadline = Instant::now() + Duration::from_secs(10);
154         let mut out = Vec::new();
155         let mut buf = [0u8; 4096];
156         while Instant::now() < deadline {
157             match reader.read(&mut buf) {
158                 Ok(0) | Err(_) => break, // EOF/EIO: shell exited
159                 Ok(n) => {
160                     out.extend_from_slice(&buf[..n]);
161                     if String::from_utf8_lossy(&out).contains("RT-OK") {
162                         break;
163                     }
164                 }
165             }
166         }
167         assert!(
168             String::from_utf8_lossy(&out).contains("RT-OK"),
169             "no round-trip output; got: {:?}",
170             String::from_utf8_lossy(&out)
171         );
172         let _ = pty.child.kill();
173         let _ = pty.child.wait();
174         let _ = std::fs::remove_dir_all(&home);
175     }
176 }