Skip to main content

arcbox_virtio_console/
pty.rs

1//! PTY (pseudo-terminal) console backend.
2//!
3//! Provides a real terminal interface that can be connected to by terminal
4//! emulators or the host shell.
5
6use std::os::unix::io::RawFd;
7use std::sync::{Mutex, PoisonError};
8
9use crate::ConsoleIo;
10
11/// Serializes PTY allocation.
12///
13/// macOS's `openpty` resolves the slave name through `ptsname`'s
14/// process-global static buffer before opening it, so two concurrent calls
15/// can open the wrong (or an already-recycled) slave and fail with `ENOENT`.
16/// Holding this lock across the allocate-and-name sequence makes it atomic.
17static PTY_ALLOC_LOCK: Mutex<()> = Mutex::new(());
18
19/// PTY (pseudo-terminal) console backend.
20///
21/// Provides a real terminal interface that can be connected to
22/// by terminal emulators or the host shell.
23pub struct PtyConsole {
24    /// Master file descriptor.
25    master_fd: RawFd,
26    /// Slave file descriptor (opened for the guest).
27    slave_fd: RawFd,
28    /// Path to the slave PTY device.
29    slave_path: String,
30    /// Non-blocking mode.
31    nonblocking: bool,
32}
33
34impl PtyConsole {
35    /// Creates a new PTY console.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if PTY allocation fails.
40    pub fn new() -> std::io::Result<Self> {
41        let mut master_fd: libc::c_int = -1;
42        let mut slave_fd: libc::c_int = -1;
43
44        // The guarded section carries no invariants a panicking thread could
45        // break, so a poisoned lock is safe to reuse.
46        let alloc = PTY_ALLOC_LOCK
47            .lock()
48            .unwrap_or_else(PoisonError::into_inner);
49
50        // SAFETY: openpty writes the new master/slave fds via the out-pointers
51        // we provided. Any failure is reported via the return code, in which
52        // case we propagate `last_os_error()` without using the (uninitialised) fds.
53        let ret = unsafe {
54            libc::openpty(
55                &mut master_fd,
56                &mut slave_fd,
57                std::ptr::null_mut(),
58                std::ptr::null_mut(),
59                std::ptr::null_mut(),
60            )
61        };
62
63        if ret < 0 {
64            return Err(std::io::Error::last_os_error());
65        }
66
67        let mut name_buf = [0 as libc::c_char; 256];
68        // SAFETY: ttyname_r writes a NUL-terminated path into name_buf, bounded
69        // by the length we pass; on failure it returns the error number and the
70        // buffer contents are not used.
71        let err = unsafe { libc::ttyname_r(slave_fd, name_buf.as_mut_ptr(), name_buf.len()) };
72        drop(alloc);
73        if err != 0 {
74            // SAFETY: closing the fds openpty just handed us; no aliases exist.
75            unsafe {
76                libc::close(master_fd);
77                libc::close(slave_fd);
78            }
79            return Err(std::io::Error::from_raw_os_error(err));
80        }
81        // SAFETY: ttyname_r succeeded, so name_buf holds a NUL-terminated C string.
82        let slave_path = unsafe { std::ffi::CStr::from_ptr(name_buf.as_ptr()) }
83            .to_string_lossy()
84            .into_owned();
85
86        tracing::info!(
87            "Created PTY console: master_fd={}, slave={}",
88            master_fd,
89            slave_path
90        );
91
92        Ok(Self {
93            master_fd,
94            slave_fd,
95            slave_path,
96            nonblocking: false,
97        })
98    }
99
100    /// Returns the path to the slave PTY device.
101    ///
102    /// Users can connect to this path with a terminal emulator:
103    /// - `screen /dev/pts/X`
104    /// - `minicom -D /dev/pts/X`
105    #[must_use]
106    pub fn slave_path(&self) -> &str {
107        &self.slave_path
108    }
109
110    /// Returns the master file descriptor.
111    #[must_use]
112    pub const fn master_fd(&self) -> RawFd {
113        self.master_fd
114    }
115
116    /// Sets non-blocking mode on the master.
117    pub fn set_nonblocking(&mut self, nonblocking: bool) -> std::io::Result<()> {
118        // SAFETY: F_GETFL/F_SETFL on a fd we own; fcntl is signal-safe and
119        // the only side effect on success is updating the fd's status flags.
120        let flags = unsafe { libc::fcntl(self.master_fd, libc::F_GETFL) };
121        if flags < 0 {
122            return Err(std::io::Error::last_os_error());
123        }
124
125        let new_flags = if nonblocking {
126            flags | libc::O_NONBLOCK
127        } else {
128            flags & !libc::O_NONBLOCK
129        };
130
131        // SAFETY: see above — fcntl on our owned fd.
132        let ret = unsafe { libc::fcntl(self.master_fd, libc::F_SETFL, new_flags) };
133        if ret < 0 {
134            return Err(std::io::Error::last_os_error());
135        }
136
137        self.nonblocking = nonblocking;
138        Ok(())
139    }
140
141    /// Sets terminal size.
142    pub fn set_window_size(&self, rows: u16, cols: u16) -> std::io::Result<()> {
143        let ws = libc::winsize {
144            ws_row: rows,
145            ws_col: cols,
146            ws_xpixel: 0,
147            ws_ypixel: 0,
148        };
149
150        // SAFETY: TIOCSWINSZ takes a `&winsize` we borrow on the stack; the
151        // ioctl reads it and does not retain the pointer.
152        let ret = unsafe { libc::ioctl(self.master_fd, libc::TIOCSWINSZ, &ws) };
153        if ret < 0 {
154            Err(std::io::Error::last_os_error())
155        } else {
156            Ok(())
157        }
158    }
159
160    /// Checks if data is available to read.
161    #[must_use]
162    pub fn has_data(&self) -> bool {
163        let mut pollfd = libc::pollfd {
164            fd: self.master_fd,
165            events: libc::POLLIN,
166            revents: 0,
167        };
168
169        // SAFETY: poll borrows our pollfd for the duration of the call (timeout 0).
170        let ret = unsafe { libc::poll(&mut pollfd, 1, 0) };
171        ret > 0 && (pollfd.revents & libc::POLLIN) != 0
172    }
173}
174
175impl Drop for PtyConsole {
176    fn drop(&mut self) {
177        // SAFETY: closing fds we exclusively own (no aliases handed out).
178        if self.master_fd >= 0 {
179            unsafe { libc::close(self.master_fd) };
180        }
181        if self.slave_fd >= 0 {
182            unsafe { libc::close(self.slave_fd) };
183        }
184    }
185}
186
187impl ConsoleIo for PtyConsole {
188    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
189        // SAFETY: read into a buffer borrowed mutably from `buf`; libc only
190        // writes within `buf.len()` bytes of `buf.as_mut_ptr()`.
191        let ret = unsafe {
192            libc::read(
193                self.master_fd,
194                buf.as_mut_ptr() as *mut libc::c_void,
195                buf.len(),
196            )
197        };
198
199        if ret < 0 {
200            let err = std::io::Error::last_os_error();
201            if err.kind() == std::io::ErrorKind::WouldBlock {
202                Ok(0)
203            } else {
204                Err(err)
205            }
206        } else {
207            Ok(ret as usize)
208        }
209    }
210
211    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
212        // SAFETY: write reads `buf.len()` bytes starting at `buf.as_ptr()`,
213        // both borrowed from the caller for the duration of the call.
214        let ret = unsafe {
215            libc::write(
216                self.master_fd,
217                buf.as_ptr() as *const libc::c_void,
218                buf.len(),
219            )
220        };
221
222        if ret < 0 {
223            Err(std::io::Error::last_os_error())
224        } else {
225            Ok(ret as usize)
226        }
227    }
228
229    fn flush(&mut self) -> std::io::Result<()> {
230        // PTY doesn't need explicit flush
231        Ok(())
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_pty_console_creation() {
241        let pty = PtyConsole::new().unwrap();
242        assert!(!pty.slave_path().is_empty());
243        assert!(pty.master_fd() >= 0);
244    }
245
246    #[test]
247    fn test_pty_console_write_read() {
248        let mut pty = PtyConsole::new().unwrap();
249        pty.set_nonblocking(true).unwrap();
250
251        let written = pty.write(b"test\n").unwrap();
252        assert!(written > 0);
253
254        // Note: Reading from PTY may not return data immediately — the slave
255        // side has to echo back first.
256    }
257
258    #[test]
259    fn test_pty_console_nonblocking() {
260        let mut pty = PtyConsole::new().unwrap();
261
262        pty.set_nonblocking(true).unwrap();
263        assert!(pty.nonblocking);
264
265        let mut buf = [0u8; 10];
266        let n = pty.read(&mut buf).unwrap();
267        assert_eq!(n, 0);
268    }
269
270    #[test]
271    fn test_pty_console_window_size() {
272        let pty = PtyConsole::new().unwrap();
273
274        assert!(pty.set_window_size(24, 80).is_ok());
275        assert!(pty.set_window_size(50, 132).is_ok());
276    }
277
278    #[test]
279    fn test_pty_console_has_data() {
280        let pty = PtyConsole::new().unwrap();
281        assert!(!pty.has_data());
282    }
283
284    #[test]
285    fn test_pty_console_flush() {
286        let mut pty = PtyConsole::new().unwrap();
287        assert!(pty.flush().is_ok());
288    }
289
290    /// Allocation used to race: macOS's `openpty` resolves the slave name via
291    /// a process-global static buffer, so parallel creations could open a
292    /// wrong or stale slave and fail with ENOENT.
293    #[test]
294    fn test_pty_console_concurrent_creation() {
295        let threads: Vec<_> = (0..8)
296            .map(|_| {
297                std::thread::spawn(|| {
298                    for _ in 0..8 {
299                        let pty = PtyConsole::new().unwrap();
300                        assert!(pty.slave_path().starts_with("/dev/"));
301                    }
302                })
303            })
304            .collect();
305        for t in threads {
306            t.join().unwrap();
307        }
308    }
309}