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 }