Skip to main content

rust_pty/
unix.rs

1//! Unix platform implementation for PTY operations.
2//!
3//! This module provides the Unix-specific PTY implementation, including:
4//!
5//! - PTY master/slave pair allocation via openpt/grantpt/unlockpt
6//! - Async I/O through tokio's `AsyncFd`
7//! - Child process management with proper session/controlling terminal setup
8//! - Signal handling for SIGWINCH and SIGCHLD
9//!
10//! # Platform Support
11//!
12//! This implementation works on:
13//! - Linux (using /dev/ptmx)
14//! - macOS (using /dev/ptmx)
15//! - FreeBSD and other Unix-like systems
16//!
17//! # Example
18//!
19//! ```no_run
20//! use rust_pty::unix::UnixPtySystem;
21//! use rust_pty::{PtySystem, PtyConfig};
22//!
23//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
24//! let config = PtyConfig::default();
25//! let args: [&str; 0] = [];
26//! let (master, child) = UnixPtySystem::spawn("/bin/bash", args, &config).await?;
27//! # let _ = (master, child);
28//! # Ok(()) }
29//! ```
30
31mod buffer;
32mod child;
33mod pty;
34mod signals;
35
36use std::ffi::OsStr;
37use std::io;
38
39pub use buffer::PtyBuffer;
40pub use child::{UnixPtyChild, spawn_child};
41pub use pty::{UnixPtyMaster, open_slave};
42pub use signals::{
43    PtySignalEvent, SignalHandle, is_sigchld, is_sigwinch, on_window_change, sigchld, sigwinch,
44    start_signal_handler,
45};
46
47use crate::config::PtyConfig;
48use crate::error::{PtyError, Result};
49use crate::traits::PtySystem;
50
51/// Allocate a PTY master, retrying briefly on transient allocation failure.
52///
53/// macOS caps the system-wide PTY count at `kern.tty.ptmx_max` (511 by
54/// default), far below Linux's dynamic `/dev/pts`. Under heavy concurrent
55/// spawning `openpt` can momentarily fail (the BSD exhaustion code is `ENXIO`,
56/// "Device not configured") even though a slot frees moments later as other
57/// sessions are torn down. A short bounded backoff (~10 attempts, ~90ms worst
58/// case) turns those intermittent failures into reliable spawns; a genuinely
59/// permanent failure still surfaces promptly, carrying the underlying error.
60async fn open_master_with_retry() -> Result<(UnixPtyMaster, String)> {
61    const ATTEMPTS: u32 = 10;
62
63    let mut last_err = PtyError::Create(io::Error::other("openpt was never attempted"));
64    for attempt in 0..ATTEMPTS {
65        match UnixPtyMaster::open() {
66            Ok(pair) => return Ok(pair),
67            Err(e) => {
68                last_err = e;
69                if attempt + 1 < ATTEMPTS {
70                    // Non-blocking backoff so other sessions can release PTYs.
71                    tokio::time::sleep(std::time::Duration::from_millis(
72                        2 * u64::from(attempt + 1),
73                    ))
74                    .await;
75                }
76            }
77        }
78    }
79    Err(last_err)
80}
81
82/// Unix PTY system implementation.
83///
84/// This struct provides the factory methods for creating PTY sessions on Unix.
85#[derive(Debug, Clone, Copy, Default)]
86pub struct UnixPtySystem;
87
88impl PtySystem for UnixPtySystem {
89    type Master = UnixPtyMaster;
90    type Child = UnixPtyChild;
91
92    async fn spawn<S, I>(
93        program: S,
94        args: I,
95        config: &PtyConfig,
96    ) -> Result<(Self::Master, Self::Child)>
97    where
98        S: AsRef<OsStr> + Send,
99        I: IntoIterator + Send,
100        I::Item: AsRef<OsStr>,
101    {
102        // Open master PTY (retrying briefly on transient macOS ptmx exhaustion)
103        let (master, slave_path) = open_master_with_retry().await?;
104
105        // macOS (#40): start draining the master into userspace *before* the
106        // child is spawned, so its output is captured the instant it is written
107        // and cannot be discarded when a fast-exiting child exits. Only macOS
108        // needs this (see `UnixPtyMaster::start_read_drain`).
109        #[cfg(target_os = "macos")]
110        let master = {
111            let mut master = master;
112            master.start_read_drain()?;
113            master
114        };
115
116        // Open slave for child.
117        //
118        // This must precede `set_window_size`: on macOS, `TIOCSWINSZ` on the
119        // master fails with `ENOTTY` ("Inappropriate ioctl for device") until
120        // the slave side has been opened. On Linux the order is immaterial.
121        let slave_fd = open_slave(&slave_path)?;
122
123        // Set initial window size (W1) now that the slave is open.
124        let window_size = config.window_size.into();
125        master.set_window_size(window_size)?;
126
127        // Spawn child process
128        let child = spawn_child(slave_fd, program, args, config).await?;
129
130        Ok((master, child))
131    }
132}
133
134/// Convenience type alias for the default PTY system on Unix.
135pub type NativePtySystem = UnixPtySystem;
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[tokio::test]
142    async fn spawn_shell() {
143        let config = PtyConfig::default();
144        let result = UnixPtySystem::spawn_shell(&config).await;
145
146        // This may fail in some test environments, but the logic should be correct
147        if let Ok((mut master, mut child)) = result {
148            assert!(master.is_open());
149            assert!(child.is_running());
150
151            // Clean up
152            child.kill().ok();
153            master.close().ok();
154        }
155    }
156
157    #[tokio::test]
158    async fn spawn_echo() {
159        let config = PtyConfig::default();
160        let result = UnixPtySystem::spawn("echo", ["hello"], &config).await;
161
162        if let Ok((mut master, mut child)) = result {
163            // Wait for child to exit
164            let status = child.wait().await;
165            assert!(status.is_ok());
166
167            master.close().ok();
168        }
169    }
170
171    /// Regression: the default config sets both `new_session` and
172    /// `controlling_terminal`. Previously `spawn_child` called
173    /// `process_group(0)` (making the child a group leader) *and* `setsid()` in
174    /// the `pre_exec` hook, so `setsid()` failed with EPERM and the default spawn
175    /// errored. Unlike `spawn_echo`/`spawn_shell` above (which swallow spawn
176    /// failure via `if let Ok`), this asserts the spawn actually succeeds.
177    #[tokio::test]
178    async fn spawn_succeeds_with_default_config() {
179        let config = PtyConfig::default();
180        let (mut master, mut child) = UnixPtySystem::spawn("/bin/sh", ["-c", "exit 0"], &config)
181            .await
182            .expect("default-config spawn must succeed (EPERM regression)");
183
184        let status = child.wait().await.expect("wait");
185        assert_eq!(status, crate::traits::ExitStatus::Exited(0));
186        master.close().ok();
187    }
188
189    /// The allocation-retry wrapper succeeds on a healthy system (happy path;
190    /// the exhaustion-retry path itself is environmental and not unit-testable
191    /// without destabilizing the whole test run).
192    #[tokio::test]
193    async fn allocation_retry_happy_path() {
194        let (master, _slave_path) = open_master_with_retry().await.expect("allocate");
195        assert!(master.is_open());
196    }
197
198    /// `try_wait` must report the child's real exit status without erroring,
199    /// under a multi-threaded runtime (the scenario where a raw `waitpid` in
200    /// `try_wait` could race tokio's own child reaper).
201    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
202    async fn try_wait_reports_real_exit_status_under_multi_thread() {
203        let config = PtyConfig::default();
204        let (mut master, mut child) = UnixPtySystem::spawn("/bin/sh", ["-c", "exit 7"], &config)
205            .await
206            .expect("spawn");
207
208        let mut status = None;
209        for _ in 0..500 {
210            match child.try_wait() {
211                Ok(Some(s)) => {
212                    status = Some(s);
213                    break;
214                }
215                Ok(None) => tokio::time::sleep(std::time::Duration::from_millis(10)).await,
216                Err(e) => panic!("try_wait errored (reaper race?): {e:?}"),
217            }
218        }
219        assert_eq!(
220            status,
221            Some(crate::traits::ExitStatus::Exited(7)),
222            "try_wait did not report the real exit status"
223        );
224        master.close().ok();
225    }
226}