Skip to main content

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    /// macOS-only background drain of the master (see [`macos_drain`]).
34    ///
35    /// When present, `poll_read` serves bytes from this drain instead of the fd.
36    /// Started before the child is spawned so the child's output is captured the
37    /// instant it is written — before macOS/`tokio::process` can discard it on a
38    /// fast-exiting child's exit (issue #40). `None` until `start_read_drain`.
39    #[cfg(target_os = "macos")]
40    drain: Option<macos_drain::Drain>,
41}
42
43impl std::fmt::Debug for UnixPtyMaster {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("UnixPtyMaster")
46            .field("fd", &self.async_fd.as_raw_fd())
47            .field("open", &self.open.load(Ordering::SeqCst))
48            .finish()
49    }
50}
51
52impl UnixPtyMaster {
53    /// Open a new PTY master.
54    ///
55    /// This allocates a new pseudo-terminal pair and returns the master side.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if PTY allocation fails.
60    pub fn open() -> Result<(Self, String)> {
61        // Open master PTY.
62        //
63        // L1: request close-on-exec so the master fd can't leak into unrelated
64        // children spawned concurrently. On Linux rustix honors `CLOEXEC`
65        // atomically inside `posix_openpt` (no open->set race). Other platforms'
66        // `posix_openpt` has no atomic `O_CLOEXEC`, so there we set `FD_CLOEXEC`
67        // with a follow-up fcntl (best-effort; a small open->fcntl window
68        // remains, closed only once the platform gains an atomic path).
69        #[cfg(target_os = "linux")]
70        let master_fd = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)
71            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
72        #[cfg(not(target_os = "linux"))]
73        let master_fd = {
74            let fd = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY)
75                .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
76            let flags = rustix::io::fcntl_getfd(&fd)
77                .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
78            rustix::io::fcntl_setfd(&fd, flags | rustix::io::FdFlags::CLOEXEC)
79                .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
80            fd
81        };
82
83        // Grant access to slave
84        grantpt(&master_fd)
85            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
86
87        // Unlock slave
88        unlockpt(&master_fd)
89            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
90
91        // Get slave name
92        let slave_name = ptsname(&master_fd, Vec::new())
93            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
94        let slave_path = slave_name
95            .to_str()
96            .map_err(|_| {
97                PtyError::Create(io::Error::new(
98                    io::ErrorKind::InvalidData,
99                    "invalid slave path encoding",
100                ))
101            })?
102            .to_string();
103
104        // Set non-blocking mode
105        fcntl_setfl(&master_fd, OFlags::NONBLOCK)
106            .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
107
108        // Wrap for async I/O
109        let async_fd = AsyncFd::new(master_fd).map_err(PtyError::Create)?;
110
111        Ok((
112            Self {
113                async_fd,
114                open: Arc::new(AtomicBool::new(true)),
115                #[cfg(target_os = "macos")]
116                drain: None,
117            },
118            slave_path,
119        ))
120    }
121
122    /// Start the macOS background read drain (see the `macos_drain` module).
123    ///
124    /// Must be called **before** the child is spawned so no output is missed.
125    /// A dedicated OS thread reads a `dup` of the master into a userspace buffer
126    /// the instant bytes arrive, so a fast-exiting child's final output is
127    /// captured before macOS/`tokio::process` can discard it on exit (#40).
128    /// After this, `poll_read` serves from the drain rather than the fd.
129    ///
130    /// macOS-only: on other targets the master's output survives the child's
131    /// exit, so there is nothing to drain and this method does not exist (the
132    /// call in `UnixPtySystem::spawn` is likewise `cfg`-gated).
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the master fd cannot be duplicated or the drain
137    /// thread cannot be spawned.
138    #[cfg(target_os = "macos")]
139    pub(crate) fn start_read_drain(&mut self) -> Result<()> {
140        if self.drain.is_none() {
141            let drain =
142                macos_drain::Drain::start(self.async_fd.as_raw_fd()).map_err(PtyError::Io)?;
143            self.drain = Some(drain);
144        }
145        Ok(())
146    }
147
148    /// Get the slave PTY path.
149    ///
150    /// This can be used to open the slave side for a child process.
151    pub fn slave_name(&self) -> Result<String> {
152        let name = ptsname(self.async_fd.get_ref(), Vec::new())
153            .map_err(|e| PtyError::Io(io::Error::from_raw_os_error(e.raw_os_error())))?;
154        name.to_str()
155            .map(std::string::ToString::to_string)
156            .map_err(|_| {
157                PtyError::Io(io::Error::new(
158                    io::ErrorKind::InvalidData,
159                    "invalid slave path encoding",
160                ))
161            })
162    }
163
164    /// Check if the PTY is still open.
165    #[must_use]
166    pub fn is_open(&self) -> bool {
167        self.open.load(Ordering::SeqCst)
168    }
169
170    /// Set the window size.
171    pub fn set_window_size(&self, size: WindowSize) -> Result<()> {
172        if !self.is_open() {
173            return Err(PtyError::Closed);
174        }
175
176        // On macOS, use libc::ioctl directly with TIOCSWINSZ
177        #[cfg(target_os = "macos")]
178        {
179            #[allow(clippy::struct_field_names)]
180            #[repr(C)]
181            struct LibcWinsize {
182                ws_row: libc::c_ushort,
183                ws_col: libc::c_ushort,
184                ws_xpixel: libc::c_ushort,
185                ws_ypixel: libc::c_ushort,
186            }
187
188            let winsize = LibcWinsize {
189                ws_row: size.rows,
190                ws_col: size.cols,
191                ws_xpixel: size.xpixel,
192                ws_ypixel: size.ypixel,
193            };
194
195            // SAFETY: ioctl with TIOCSWINSZ is the standard way to set terminal window size.
196            // We're passing a valid winsize struct to a valid file descriptor.
197            #[allow(unsafe_code)]
198            let result = unsafe {
199                libc::ioctl(
200                    self.async_fd.as_raw_fd(),
201                    libc::TIOCSWINSZ,
202                    &raw const winsize,
203                )
204            };
205
206            if result == -1 {
207                return Err(PtyError::Resize(io::Error::last_os_error()));
208            }
209            Ok(())
210        }
211
212        // On other Unix systems, use rustix
213        #[cfg(not(target_os = "macos"))]
214        {
215            let winsize = Winsize {
216                ws_col: size.cols,
217                ws_row: size.rows,
218                ws_xpixel: size.xpixel,
219                ws_ypixel: size.ypixel,
220            };
221
222            tcsetwinsize(self.async_fd.get_ref(), winsize)
223                .map_err(|e| PtyError::Resize(io::Error::from_raw_os_error(e.raw_os_error())))
224        }
225    }
226
227    /// Get the current window size.
228    pub fn get_window_size(&self) -> Result<WindowSize> {
229        if !self.is_open() {
230            return Err(PtyError::Closed);
231        }
232
233        let winsize = rustix::termios::tcgetwinsize(self.async_fd.get_ref())
234            .map_err(|e| PtyError::GetAttributes(io::Error::from_raw_os_error(e.raw_os_error())))?;
235
236        Ok(WindowSize {
237            cols: winsize.ws_col,
238            rows: winsize.ws_row,
239            xpixel: winsize.ws_xpixel,
240            ypixel: winsize.ws_ypixel,
241        })
242    }
243
244    /// Close the PTY master.
245    pub fn close(&mut self) -> Result<()> {
246        self.open.store(false, Ordering::SeqCst);
247        Ok(())
248    }
249}
250
251impl AsRawFd for UnixPtyMaster {
252    fn as_raw_fd(&self) -> RawFd {
253        self.async_fd.as_raw_fd()
254    }
255}
256
257impl AsyncRead for UnixPtyMaster {
258    fn poll_read(
259        self: Pin<&mut Self>,
260        cx: &mut Context<'_>,
261        buf: &mut ReadBuf<'_>,
262    ) -> Poll<io::Result<()>> {
263        if !self.open.load(Ordering::SeqCst) {
264            return Poll::Ready(Ok(())); // EOF
265        }
266
267        // macOS: serve from the background drain, which captured the child's
268        // output before it could be discarded on exit (#40).
269        #[cfg(target_os = "macos")]
270        if let Some(drain) = self.drain.as_ref() {
271            return drain.poll_read(cx.waker(), buf);
272        }
273
274        loop {
275            let mut guard = match self.async_fd.poll_read_ready(cx) {
276                Poll::Ready(Ok(guard)) => guard,
277                Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
278                Poll::Pending => return Poll::Pending,
279            };
280
281            let unfilled = buf.initialize_unfilled();
282            match rustix::io::read(self.async_fd.get_ref(), unfilled) {
283                Ok(0) => {
284                    // EOF
285                    return Poll::Ready(Ok(()));
286                }
287                Ok(n) => {
288                    buf.advance(n);
289                    return Poll::Ready(Ok(()));
290                }
291                Err(rustix::io::Errno::AGAIN) => {
292                    guard.clear_ready();
293                }
294                Err(rustix::io::Errno::INTR) => {
295                    // Interrupted by a signal before any bytes moved; the fd is
296                    // still ready, so loop and retry the read rather than
297                    // surfacing a spurious error (E1).
298                }
299                Err(e) => {
300                    return Poll::Ready(Err(io::Error::from_raw_os_error(e.raw_os_error())));
301                }
302            }
303        }
304    }
305}
306
307impl AsyncWrite for UnixPtyMaster {
308    fn poll_write(
309        self: Pin<&mut Self>,
310        cx: &mut Context<'_>,
311        buf: &[u8],
312    ) -> Poll<io::Result<usize>> {
313        if !self.open.load(Ordering::SeqCst) {
314            return Poll::Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, "PTY closed")));
315        }
316
317        loop {
318            let mut guard = match self.async_fd.poll_write_ready(cx) {
319                Poll::Ready(Ok(guard)) => guard,
320                Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
321                Poll::Pending => return Poll::Pending,
322            };
323
324            match rustix::io::write(self.async_fd.get_ref(), buf) {
325                Ok(n) => return Poll::Ready(Ok(n)),
326                Err(rustix::io::Errno::AGAIN) => {
327                    guard.clear_ready();
328                }
329                Err(rustix::io::Errno::INTR) => {
330                    // Interrupted by a signal before any bytes moved; the fd is
331                    // still ready, so loop and retry the write rather than
332                    // surfacing a spurious error (E1).
333                }
334                Err(e) => {
335                    return Poll::Ready(Err(io::Error::from_raw_os_error(e.raw_os_error())));
336                }
337            }
338        }
339    }
340
341    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
342        Poll::Ready(Ok(()))
343    }
344
345    fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
346        self.open.store(false, Ordering::SeqCst);
347        Poll::Ready(Ok(()))
348    }
349}
350
351impl PtyMaster for UnixPtyMaster {
352    fn resize(&self, size: WindowSize) -> Result<()> {
353        self.set_window_size(size)
354    }
355
356    fn window_size(&self) -> Result<WindowSize> {
357        self.get_window_size()
358    }
359
360    fn close(&mut self) -> Result<()> {
361        Self::close(self)
362    }
363
364    fn is_open(&self) -> bool {
365        Self::is_open(self)
366    }
367
368    fn as_raw_fd(&self) -> RawFd {
369        AsRawFd::as_raw_fd(self)
370    }
371}
372
373/// Open the slave side of a PTY.
374///
375/// # Safety
376///
377/// The caller must ensure the path is a valid PTY slave path.
378pub fn open_slave(path: &str) -> Result<OwnedFd> {
379    use std::path::Path;
380
381    use rustix::fs::{Mode, OFlags, open};
382
383    // CLOEXEC: the child receives the slave via three explicit `dup`ed stdio
384    // fds; this original handle (used only for the pre_exec `TIOCSCTTY`) must
385    // not additionally leak across the exec. `dup` does not copy the flag, so
386    // the stdio copies remain inheritable as intended.
387    let fd = open(
388        Path::new(path),
389        OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC,
390        Mode::empty(),
391    )
392    .map_err(|e| PtyError::Create(io::Error::from_raw_os_error(e.raw_os_error())))?;
393
394    Ok(fd)
395}
396
397/// macOS-only background drain of a PTY master fd.
398///
399/// # Why this exists (issue #40)
400///
401/// On macOS, a child spawned via `tokio::process` can lose its final PTY output:
402/// when a fast-exiting child (e.g. `echo hello`) exits, `tokio::process`'s exit
403/// handling discards the master's still-buffered output before the session's
404/// first read observes it, so `expect` sees EOF with an empty buffer. This was
405/// root-caused on-device: the loss is **not** caused by reaping (the child is an
406/// un-reaped zombie at loss time) and does not happen with a raw `fork`/`exec`
407/// child — only `tokio::process` triggers it, and only on macOS. Linux
408/// (`EIO`-based EOF) and Windows (`ConPTY`) are unaffected.
409///
410/// # How it fixes it
411///
412/// A dedicated OS thread (independent of the Tokio runtime, so it behaves the
413/// same for multi-thread and current-thread runtimes) `poll`s a `dup` of the
414/// master and reads bytes into a userspace buffer the instant they are written.
415/// Because [`UnixPtyMaster::start_read_drain`] runs it **before the child is
416/// spawned**, the reader is already waiting when the child writes, so the output
417/// is captured before the exit-time discard can happen. [`UnixPtyMaster::poll_read`]
418/// then serves this buffer; EOF (`read` returns 0, which macOS surfaces on the
419/// child's *exit* without a reap) is recorded and reported once the buffer is
420/// empty. A Tokio task or an inline `AsyncFd` read is *not* sufficient — both are
421/// subject to runtime scheduling latency and still lose the race under load.
422#[cfg(target_os = "macos")]
423pub(crate) mod macos_drain {
424    use std::collections::VecDeque;
425    use std::io;
426    use std::os::unix::io::RawFd;
427    use std::sync::atomic::{AtomicBool, Ordering};
428    use std::sync::{Arc, Mutex};
429    use std::task::{Poll, Waker};
430
431    use tokio::io::ReadBuf;
432
433    /// Soft cap on the userspace buffer. When reached, the drain thread stops
434    /// reading so the kernel PTY buffer fills and the child sees backpressure —
435    /// preserving flow control for a producer whose output is never read.
436    const BUFFER_CAP: usize = 1 << 20; // 1 MiB
437
438    struct Shared {
439        buf: VecDeque<u8>,
440        eof: bool,
441        err: Option<io::ErrorKind>,
442        waker: Option<Waker>,
443    }
444
445    /// Handle to the background drain thread and its shared buffer.
446    pub(crate) struct Drain {
447        shared: Arc<Mutex<Shared>>,
448        stop: Arc<AtomicBool>,
449    }
450
451    impl Drain {
452        /// Start draining a `dup` of `master_fd` on a dedicated OS thread.
453        #[allow(unsafe_code)]
454        pub(crate) fn start(master_fd: RawFd) -> io::Result<Self> {
455            // A private read-only handle to the same master, so the reader thread
456            // never contends with `poll_write` on the original fd.
457            // SAFETY: `master_fd` is a live PTY master fd owned by the caller for
458            // the duration of this call.
459            let read_fd = unsafe { libc::dup(master_fd) };
460            if read_fd < 0 {
461                return Err(io::Error::last_os_error());
462            }
463            // Best-effort: don't let this fd leak into concurrently-spawned
464            // children. SAFETY: `read_fd` is a valid fd created just above.
465            unsafe {
466                libc::fcntl(read_fd, libc::F_SETFD, libc::FD_CLOEXEC);
467            }
468
469            let shared = Arc::new(Mutex::new(Shared {
470                buf: VecDeque::new(),
471                eof: false,
472                err: None,
473                waker: None,
474            }));
475            let stop = Arc::new(AtomicBool::new(false));
476
477            let thread_shared = Arc::clone(&shared);
478            let thread_stop = Arc::clone(&stop);
479            // Spawn detached: the thread exits on EOF/error, or within one poll
480            // interval of `stop` being set on drop — no join, so teardown never
481            // blocks.
482            std::thread::Builder::new()
483                .name("pty-macos-drain".into())
484                .spawn(move || drain_loop(read_fd, &thread_shared, &thread_stop))
485                .inspect_err(|_e| {
486                    // SAFETY: `read_fd` is the fd we created and still own here.
487                    unsafe { libc::close(read_fd) };
488                })?;
489
490            Ok(Self { shared, stop })
491        }
492
493        /// Serve buffered bytes / EOF / error to `poll_read`.
494        pub(crate) fn poll_read(
495            &self,
496            waker: &Waker,
497            buf: &mut ReadBuf<'_>,
498        ) -> Poll<io::Result<()>> {
499            let mut sh = self.shared.lock().unwrap();
500            if !sh.buf.is_empty() {
501                let n = sh.buf.len().min(buf.remaining());
502                // `VecDeque` storage may wrap; copy the contiguous run(s).
503                let (front, back) = sh.buf.as_slices();
504                let take_front = front.len().min(n);
505                buf.put_slice(&front[..take_front]);
506                let take_back = n - take_front;
507                if take_back > 0 {
508                    buf.put_slice(&back[..take_back]);
509                }
510                sh.buf.drain(..n);
511                return Poll::Ready(Ok(()));
512            }
513            if let Some(kind) = sh.err {
514                return Poll::Ready(Err(kind.into()));
515            }
516            if sh.eof {
517                // 0-byte read == EOF, now that every buffered byte is delivered.
518                return Poll::Ready(Ok(()));
519            }
520            sh.waker = Some(waker.clone());
521            Poll::Pending
522        }
523    }
524
525    impl Drop for Drain {
526        fn drop(&mut self) {
527            self.stop.store(true, Ordering::Relaxed);
528        }
529    }
530
531    /// Append bytes and wake any pending `poll_read`. The lock is dropped before
532    /// waking so we never wake under the lock.
533    fn push(shared: &Arc<Mutex<Shared>>, data: &[u8]) {
534        let mut sh = shared.lock().unwrap();
535        sh.buf.extend(data);
536        let waker = sh.waker.take();
537        drop(sh);
538        if let Some(w) = waker {
539            w.wake();
540        }
541    }
542
543    /// Record terminal state — EOF (`err = None`) or an error — and wake any
544    /// pending `poll_read`. The lock is dropped before waking.
545    fn finish(shared: &Arc<Mutex<Shared>>, err: Option<io::ErrorKind>) {
546        let mut sh = shared.lock().unwrap();
547        match err {
548            Some(kind) if sh.err.is_none() => sh.err = Some(kind),
549            Some(_) => {}
550            None => sh.eof = true,
551        }
552        let waker = sh.waker.take();
553        drop(sh);
554        if let Some(w) = waker {
555            w.wake();
556        }
557    }
558
559    #[allow(unsafe_code)]
560    fn drain_loop(fd: RawFd, shared: &Arc<Mutex<Shared>>, stop: &Arc<AtomicBool>) {
561        use std::cmp::Ordering as CmpOrdering;
562
563        let mut tmp = [0u8; 4096];
564        'outer: loop {
565            if stop.load(Ordering::Relaxed) {
566                break;
567            }
568            // Apply backpressure: if the userspace buffer is full, stop reading so
569            // the kernel PTY buffer fills and the child blocks.
570            let full = { shared.lock().unwrap().buf.len() >= BUFFER_CAP };
571            if full {
572                std::thread::sleep(std::time::Duration::from_millis(2));
573                continue;
574            }
575            let mut pfd = libc::pollfd {
576                fd,
577                events: libc::POLLIN,
578                revents: 0,
579            };
580            // 50ms timeout so `stop` is observed promptly after drop.
581            // SAFETY: `pfd` is a valid single-element pollfd for a live fd.
582            let pr = unsafe { libc::poll(std::ptr::from_mut(&mut pfd), 1, 50) };
583            if stop.load(Ordering::Relaxed) {
584                break;
585            }
586            if pr < 0 {
587                let e = io::Error::last_os_error();
588                if e.raw_os_error() == Some(libc::EINTR) {
589                    continue;
590                }
591                finish(shared, Some(e.kind()));
592                break;
593            }
594            if pr == 0 {
595                continue; // timeout, no data
596            }
597            // Readable: drain everything currently available.
598            loop {
599                // SAFETY: `fd` is our live dup of the master; `tmp` is a valid
600                // local buffer of `tmp.len()` bytes.
601                let n = unsafe { libc::read(fd, tmp.as_mut_ptr().cast(), tmp.len()) };
602                match n.cmp(&0) {
603                    CmpOrdering::Greater => {
604                        // `n > 0`, so the cast is lossless.
605                        push(shared, &tmp[..n as usize]);
606                    }
607                    CmpOrdering::Equal => {
608                        finish(shared, None); // EOF
609                        break 'outer;
610                    }
611                    CmpOrdering::Less => {
612                        let e = io::Error::last_os_error();
613                        match e.raw_os_error() {
614                            Some(libc::EAGAIN) => break, // drained; back to poll
615                            Some(libc::EINTR) => {}      // retry
616                            _ => {
617                                finish(shared, Some(e.kind()));
618                                break 'outer;
619                            }
620                        }
621                    }
622                }
623            }
624        }
625        // SAFETY: `fd` is our dup; nothing else uses it after this thread ends.
626        unsafe { libc::close(fd) };
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[tokio::test]
635    async fn open_pty() {
636        let result = UnixPtyMaster::open();
637        assert!(result.is_ok());
638
639        let (master, slave_path) = result.unwrap();
640        assert!(master.is_open());
641        // Linux uses /dev/pts/N, macOS uses /dev/ttys* (slave), BSD may use /dev/ttyp* or /dev/pty*
642        assert!(
643            slave_path.starts_with("/dev/pts/")
644                || slave_path.starts_with("/dev/ttys")
645                || slave_path.starts_with("/dev/ttyp")
646                || slave_path.starts_with("/dev/pty")
647        );
648    }
649
650    /// L1: the master fd must be close-on-exec so it can't leak into unrelated
651    /// children spawned concurrently (atomic on Linux via `OpenptFlags`, via a
652    /// follow-up fcntl elsewhere).
653    #[tokio::test]
654    async fn master_is_close_on_exec() {
655        let (master, _slave_path) = UnixPtyMaster::open().expect("open");
656        let flags = rustix::io::fcntl_getfd(master.async_fd.get_ref()).expect("F_GETFD");
657        assert!(
658            flags.contains(rustix::io::FdFlags::CLOEXEC),
659            "master fd is missing FD_CLOEXEC"
660        );
661    }
662
663    #[tokio::test]
664    async fn window_size_operations() {
665        let (master, _slave_path) = UnixPtyMaster::open().unwrap();
666
667        // On macOS, we need to open the slave before setting window size works reliably
668        #[cfg(target_os = "macos")]
669        let _slave_fd = open_slave(&_slave_path).unwrap();
670
671        // Set window size
672        let size = WindowSize::new(120, 40);
673        assert!(master.set_window_size(size).is_ok());
674
675        // Get window size
676        let retrieved = master.get_window_size().unwrap();
677        assert_eq!(retrieved.cols, 120);
678        assert_eq!(retrieved.rows, 40);
679    }
680
681    #[tokio::test]
682    async fn close_pty() {
683        let (mut master, _) = UnixPtyMaster::open().unwrap();
684        assert!(master.is_open());
685
686        master.close().unwrap();
687        assert!(!master.is_open());
688    }
689}