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}