rust_pty/unix/
pty.rs

1//! Unix PTY allocation and management.
2//!
3//! This module provides the core PTY master implementation for Unix systems,
4//! using rustix for low-level PTY operations.
5
6use std::io;
7use std::os::unix::io::{AsRawFd, OwnedFd, RawFd};
8use std::pin::Pin;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::task::{Context, Poll};
12
13use rustix::fs::{OFlags, fcntl_setfl};
14use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt};
15#[cfg(not(target_os = "macos"))]
16use rustix::termios::{Winsize, tcsetwinsize};
17use tokio::io::unix::AsyncFd;
18use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
19
20use crate::config::WindowSize;
21use crate::error::{PtyError, Result};
22use crate::traits::PtyMaster;
23
24/// Unix PTY master implementation.
25///
26/// This struct wraps the master side of a Unix pseudo-terminal, providing
27/// async read/write operations and terminal control.
28pub struct UnixPtyMaster {
29    /// The master file descriptor wrapped for async I/O.
30    async_fd: AsyncFd<OwnedFd>,
31    /// Whether the PTY is still open.
32    open: Arc<AtomicBool>,
33}
34
35impl std::fmt::Debug for UnixPtyMaster {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("UnixPtyMaster")
38            .field("fd", &self.async_fd.as_raw_fd())
39            .field("open", &self.open.load(Ordering::SeqCst))
40            .finish()
41    }
42}
43
44impl UnixPtyMaster {
45    /// Open a new PTY master.
46    ///
47    /// This allocates a new pseudo-terminal pair and returns the master side.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error if PTY allocation fails.
52    pub fn open() -> Result<(Self, String)> {
53        // Open master PTY
54        let master_fd = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY)
55            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
56
57        // Grant access to slave
58        grantpt(&master_fd)
59            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
60
61        // Unlock slave
62        unlockpt(&master_fd)
63            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
64
65        // Get slave name
66        let slave_name = ptsname(&master_fd, Vec::new())
67            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
68        let slave_path = slave_name
69            .to_str()
70            .map_err(|_| {
71                PtyError::Create(io::Error::new(
72                    io::ErrorKind::InvalidData,
73                    "invalid slave path encoding",
74                ))
75            })?
76            .to_string();
77
78        // Set non-blocking mode
79        fcntl_setfl(&master_fd, OFlags::NONBLOCK)
80            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
81
82        // Wrap for async I/O
83        let async_fd = AsyncFd::new(master_fd).map_err(PtyError::Create)?;
84
85        Ok((
86            Self {
87                async_fd,
88                open: Arc::new(AtomicBool::new(true)),
89            },
90            slave_path,
91        ))
92    }
93
94    /// Get the slave PTY path.
95    ///
96    /// This can be used to open the slave side for a child process.
97    pub fn slave_name(&self) -> Result<String> {
98        let name = ptsname(self.async_fd.get_ref(), Vec::new())
99            .map_err(|e| PtyError::Io(io::Error::from_raw_os_error(e.raw_os_error())))?;
100        name.to_str()
101            .map(std::string::ToString::to_string)
102            .map_err(|_| {
103                PtyError::Io(io::Error::new(
104                    io::ErrorKind::InvalidData,
105                    "invalid slave path encoding",
106                ))
107            })
108    }
109
110    /// Check if the PTY is still open.
111    #[must_use]
112    pub fn is_open(&self) -> bool {
113        self.open.load(Ordering::SeqCst)
114    }
115
116    /// Set the window size.
117    pub fn set_window_size(&self, size: WindowSize) -> Result<()> {
118        if !self.is_open() {
119            return Err(PtyError::Closed);
120        }
121
122        // On macOS, use libc::ioctl directly with TIOCSWINSZ
123        #[cfg(target_os = "macos")]
124        {
125            #[allow(clippy::struct_field_names)]
126            #[repr(C)]
127            struct LibcWinsize {
128                ws_row: libc::c_ushort,
129                ws_col: libc::c_ushort,
130                ws_xpixel: libc::c_ushort,
131                ws_ypixel: libc::c_ushort,
132            }
133
134            let winsize = LibcWinsize {
135                ws_row: size.rows,
136                ws_col: size.cols,
137                ws_xpixel: size.xpixel,
138                ws_ypixel: size.ypixel,
139            };
140
141            // SAFETY: ioctl with TIOCSWINSZ is the standard way to set terminal window size.
142            // We're passing a valid winsize struct to a valid file descriptor.
143            #[allow(unsafe_code)]
144            let result = unsafe {
145                libc::ioctl(
146                    self.async_fd.as_raw_fd(),
147                    libc::TIOCSWINSZ,
148                    &raw const winsize,
149                )
150            };
151
152            if result == -1 {
153                return Err(PtyError::Resize(io::Error::last_os_error()));
154            }
155            Ok(())
156        }
157
158        // On other Unix systems, use rustix
159        #[cfg(not(target_os = "macos"))]
160        {
161            let winsize = Winsize {
162                ws_col: size.cols,
163                ws_row: size.rows,
164                ws_xpixel: size.xpixel,
165                ws_ypixel: size.ypixel,
166            };
167
168            tcsetwinsize(self.async_fd.get_ref(), winsize)
169                .map_err(|e| PtyError::Resize(io::Error::from_raw_os_error(e.raw_os_error())))
170        }
171    }
172
173    /// Get the current window size.
174    pub fn get_window_size(&self) -> Result<WindowSize> {
175        if !self.is_open() {
176            return Err(PtyError::Closed);
177        }
178
179        let winsize = rustix::termios::tcgetwinsize(self.async_fd.get_ref())
180            .map_err(|e| PtyError::GetAttributes(io::Error::from_raw_os_error(e.raw_os_error())))?;
181
182        Ok(WindowSize {
183            cols: winsize.ws_col,
184            rows: winsize.ws_row,
185            xpixel: winsize.ws_xpixel,
186            ypixel: winsize.ws_ypixel,
187        })
188    }
189
190    /// Close the PTY master.
191    pub fn close(&mut self) -> Result<()> {
192        self.open.store(false, Ordering::SeqCst);
193        Ok(())
194    }
195}
196
197impl AsRawFd for UnixPtyMaster {
198    fn as_raw_fd(&self) -> RawFd {
199        self.async_fd.as_raw_fd()
200    }
201}
202
203impl AsyncRead for UnixPtyMaster {
204    fn poll_read(
205        self: Pin<&mut Self>,
206        cx: &mut Context<'_>,
207        buf: &mut ReadBuf<'_>,
208    ) -> Poll<io::Result<()>> {
209        if !self.open.load(Ordering::SeqCst) {
210            return Poll::Ready(Ok(())); // EOF
211        }
212
213        loop {
214            let mut guard = match self.async_fd.poll_read_ready(cx) {
215                Poll::Ready(Ok(guard)) => guard,
216                Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
217                Poll::Pending => return Poll::Pending,
218            };
219
220            let unfilled = buf.initialize_unfilled();
221            match rustix::io::read(self.async_fd.get_ref(), unfilled) {
222                Ok(0) => {
223                    // EOF
224                    return Poll::Ready(Ok(()));
225                }
226                Ok(n) => {
227                    buf.advance(n);
228                    return Poll::Ready(Ok(()));
229                }
230                Err(rustix::io::Errno::AGAIN) => {
231                    guard.clear_ready();
232                }
233                Err(e) => {
234                    return Poll::Ready(Err(io::Error::from_raw_os_error(e.raw_os_error())));
235                }
236            }
237        }
238    }
239}
240
241impl AsyncWrite for UnixPtyMaster {
242    fn poll_write(
243        self: Pin<&mut Self>,
244        cx: &mut Context<'_>,
245        buf: &[u8],
246    ) -> Poll<io::Result<usize>> {
247        if !self.open.load(Ordering::SeqCst) {
248            return Poll::Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, "PTY closed")));
249        }
250
251        loop {
252            let mut guard = match self.async_fd.poll_write_ready(cx) {
253                Poll::Ready(Ok(guard)) => guard,
254                Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
255                Poll::Pending => return Poll::Pending,
256            };
257
258            match rustix::io::write(self.async_fd.get_ref(), buf) {
259                Ok(n) => return Poll::Ready(Ok(n)),
260                Err(rustix::io::Errno::AGAIN) => {
261                    guard.clear_ready();
262                }
263                Err(e) => {
264                    return Poll::Ready(Err(io::Error::from_raw_os_error(e.raw_os_error())));
265                }
266            }
267        }
268    }
269
270    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
271        Poll::Ready(Ok(()))
272    }
273
274    fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
275        self.open.store(false, Ordering::SeqCst);
276        Poll::Ready(Ok(()))
277    }
278}
279
280impl PtyMaster for UnixPtyMaster {
281    fn resize(&self, size: WindowSize) -> Result<()> {
282        self.set_window_size(size)
283    }
284
285    fn window_size(&self) -> Result<WindowSize> {
286        self.get_window_size()
287    }
288
289    fn close(&mut self) -> Result<()> {
290        Self::close(self)
291    }
292
293    fn is_open(&self) -> bool {
294        Self::is_open(self)
295    }
296
297    fn as_raw_fd(&self) -> RawFd {
298        AsRawFd::as_raw_fd(self)
299    }
300}
301
302/// Open the slave side of a PTY.
303///
304/// # Safety
305///
306/// The caller must ensure the path is a valid PTY slave path.
307pub fn open_slave(path: &str) -> Result<OwnedFd> {
308    use std::path::Path;
309
310    use rustix::fs::{Mode, OFlags, open};
311
312    let fd = open(
313        Path::new(path),
314        OFlags::RDWR | OFlags::NOCTTY,
315        Mode::empty(),
316    )
317    .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
318
319    Ok(fd)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[tokio::test]
327    async fn open_pty() {
328        let result = UnixPtyMaster::open();
329        assert!(result.is_ok());
330
331        let (master, slave_path) = result.unwrap();
332        assert!(master.is_open());
333        // Linux uses /dev/pts/N, macOS uses /dev/ttys* (slave), BSD may use /dev/ttyp* or /dev/pty*
334        assert!(
335            slave_path.starts_with("/dev/pts/")
336                || slave_path.starts_with("/dev/ttys")
337                || slave_path.starts_with("/dev/ttyp")
338                || slave_path.starts_with("/dev/pty")
339        );
340    }
341
342    #[tokio::test]
343    async fn window_size_operations() {
344        let (master, _slave_path) = UnixPtyMaster::open().unwrap();
345
346        // On macOS, we need to open the slave before setting window size works reliably
347        #[cfg(target_os = "macos")]
348        let _slave_fd = open_slave(&_slave_path).unwrap();
349
350        // Set window size
351        let size = WindowSize::new(120, 40);
352        assert!(master.set_window_size(size).is_ok());
353
354        // Get window size
355        let retrieved = master.get_window_size().unwrap();
356        assert_eq!(retrieved.cols, 120);
357        assert_eq!(retrieved.rows, 40);
358    }
359
360    #[tokio::test]
361    async fn close_pty() {
362        let (mut master, _) = UnixPtyMaster::open().unwrap();
363        assert!(master.is_open());
364
365        master.close().unwrap();
366        assert!(!master.is_open());
367    }
368}