Skip to main content

rmux_pty/
lib.rs

1#![deny(missing_docs)]
2#![deny(unsafe_op_in_unsafe_fn)]
3
4//! PTY allocation, sizing, and child-process management for RMUX.
5//!
6//! This crate confines PTY and terminal-control boundaries behind a small,
7//! documented API that exposes:
8//! - PTY allocation,
9//! - terminal size query and resize on PTY file descriptors,
10//! - child spawning into a controlling terminal-backed PTY, and
11//! - child signaling and reaping.
12
13mod backend;
14mod child;
15mod pty;
16mod size;
17
18#[cfg(any(test, all(not(unix), not(windows))))]
19pub(crate) mod unsupported_op {
20    //! Canonical operation tokens carried by `PtyError::Unsupported` arms.
21    //!
22    //! The Tier-3 (`cfg(all(not(unix), not(windows)))`) call sites in `pty.rs`
23    //! and `child.rs` reference these constants by name, and the
24    //! platform-agnostic inventory test in this crate's `tests` module reads
25    //! the same `ALL` slice.
26    pub(crate) const OPEN_PTY_PAIR: &str = "open pty pair";
27    pub(crate) const SPAWN_PTY_CHILD: &str = "spawn pty child";
28    pub(crate) const WAIT_FOR_PTY_CHILD: &str = "wait for pty child";
29    pub(crate) const TRY_WAIT_FOR_PTY_CHILD: &str = "try wait for pty child";
30    pub(crate) const SIGNAL_PTY_FOREGROUND: &str = "signal pty foreground process group";
31    pub(crate) const SIGNAL_PTY_SESSION_LEADER: &str = "signal pty session leader";
32    pub(crate) const QUERY_PTY_SIZE: &str = "query pty size";
33    pub(crate) const RESIZE_PTY: &str = "resize pty";
34    pub(crate) const CLONE_PTY_IO: &str = "clone pty io";
35
36    pub(crate) const ALL: &[&str] = &[
37        OPEN_PTY_PAIR,
38        SPAWN_PTY_CHILD,
39        WAIT_FOR_PTY_CHILD,
40        TRY_WAIT_FOR_PTY_CHILD,
41        SIGNAL_PTY_FOREGROUND,
42        SIGNAL_PTY_SESSION_LEADER,
43        QUERY_PTY_SIZE,
44        RESIZE_PTY,
45        CLONE_PTY_IO,
46    ];
47}
48
49use std::error::Error as StdError;
50use std::ffi::NulError;
51use std::fmt;
52
53pub use child::{ChildCommand, PtyChild, SpawnedPty};
54#[cfg(unix)]
55pub use pty::PtySlave;
56pub use pty::{PtyIo, PtyMaster, PtyPair};
57pub use size::{TerminalGeometry, TerminalPixels, TerminalSize};
58
59/// A crate-local result type for PTY operations.
60pub type Result<T> = std::result::Result<T, PtyError>;
61
62/// A platform-neutral process identifier for PTY children.
63#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
64pub struct ProcessId(u32);
65
66impl ProcessId {
67    /// Creates a process identifier from the operating-system value.
68    pub fn new(raw: u32) -> Result<Self> {
69        if raw == 0 {
70            return Err(PtyError::InvalidPid(raw));
71        }
72        Ok(Self(raw))
73    }
74
75    /// Returns the raw operating-system process id.
76    #[must_use]
77    pub const fn as_u32(self) -> u32 {
78        self.0
79    }
80
81    #[cfg(unix)]
82    pub(crate) fn as_rustix_pid(self) -> Result<rustix::process::Pid> {
83        let raw = i32::try_from(self.0).map_err(|_| PtyError::InvalidPid(self.0))?;
84        rustix::process::Pid::from_raw(raw).ok_or(PtyError::InvalidPid(self.0))
85    }
86}
87
88/// A high-level child process termination request.
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub enum PtySignal {
91    /// Ask the foreground program to interrupt its current operation.
92    Interrupt,
93    /// Ask the process group to terminate gracefully.
94    Terminate,
95    /// Forcefully stop the process group.
96    Kill,
97    /// Hang up the terminal session.
98    Hangup,
99    /// Continue a stopped foreground process group.
100    Continue,
101}
102
103/// Compatibility signal names used by existing RMUX call sites.
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub struct Signal(PtySignal);
106
107impl Signal {
108    /// Interrupt request.
109    pub const INT: Self = Self(PtySignal::Interrupt);
110    /// Termination request.
111    pub const TERM: Self = Self(PtySignal::Terminate);
112    /// Force-kill request.
113    pub const KILL: Self = Self(PtySignal::Kill);
114    /// Hangup request.
115    pub const HUP: Self = Self(PtySignal::Hangup);
116    /// Continue request.
117    pub const CONT: Self = Self(PtySignal::Continue);
118
119    #[cfg(unix)]
120    pub(crate) const fn as_rustix_signal(self) -> rustix::process::Signal {
121        match self.0 {
122            PtySignal::Interrupt => rustix::process::Signal::INT,
123            PtySignal::Terminate => rustix::process::Signal::TERM,
124            PtySignal::Kill => rustix::process::Signal::KILL,
125            PtySignal::Hangup => rustix::process::Signal::HUP,
126            PtySignal::Continue => rustix::process::Signal::CONT,
127        }
128    }
129}
130
131/// Errors produced by PTY allocation, resize, and child-process operations.
132#[derive(Debug)]
133pub enum PtyError {
134    /// A syscall-backed PTY or terminal-control error.
135    #[cfg(unix)]
136    Os(rustix::io::Errno),
137    /// A child-process spawn or wait error from the standard library.
138    Spawn(std::io::Error),
139    /// A command path, argument, or environment value contained an interior NUL.
140    Nul(NulError),
141    /// `std::process` returned a PID that could not be represented by RMUX.
142    InvalidPid(u32),
143    /// The requested PTY operation is not implemented by this platform backend yet.
144    Unsupported(&'static str),
145}
146
147impl fmt::Display for PtyError {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            #[cfg(unix)]
151            Self::Os(errno) => write!(formatter, "pty syscall failed: {errno}"),
152            Self::Spawn(error) => write!(formatter, "child process operation failed: {error}"),
153            Self::Nul(error) => write!(
154                formatter,
155                "interior NUL byte in process configuration: {error}"
156            ),
157            Self::InvalidPid(pid) => {
158                write!(formatter, "child process returned an invalid pid: {pid}")
159            }
160            Self::Unsupported(operation) => {
161                write!(
162                    formatter,
163                    "pty operation is unsupported on this platform: {operation}"
164                )
165            }
166        }
167    }
168}
169
170impl StdError for PtyError {
171    fn source(&self) -> Option<&(dyn StdError + 'static)> {
172        match self {
173            #[cfg(unix)]
174            Self::Os(errno) => Some(errno),
175            Self::Spawn(error) => Some(error),
176            Self::Nul(error) => Some(error),
177            Self::InvalidPid(_) => None,
178            Self::Unsupported(_) => None,
179        }
180    }
181}
182
183#[cfg(unix)]
184impl From<rustix::io::Errno> for PtyError {
185    fn from(value: rustix::io::Errno) -> Self {
186        Self::Os(value)
187    }
188}
189
190impl From<std::io::Error> for PtyError {
191    fn from(value: std::io::Error) -> Self {
192        Self::Spawn(value)
193    }
194}
195
196impl From<NulError> for PtyError {
197    fn from(value: NulError) -> Self {
198        Self::Nul(value)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::{unsupported_op, PtyError};
205
206    #[test]
207    fn pty_error_unsupported_display_is_stable_for_documented_operations() {
208        for operation in unsupported_op::ALL {
209            let formatted = format!("{}", PtyError::Unsupported(operation));
210            assert_eq!(
211                formatted,
212                format!("pty operation is unsupported on this platform: {operation}")
213            );
214        }
215    }
216
217    #[test]
218    fn pty_error_unsupported_inventory_matches_documented_count() {
219        assert_eq!(unsupported_op::ALL.len(), 9);
220    }
221
222    #[test]
223    fn pty_error_unsupported_inventory_entries_are_unique_and_non_empty() {
224        use std::collections::BTreeSet;
225
226        let unique: BTreeSet<&&str> = unsupported_op::ALL.iter().collect();
227        assert_eq!(unique.len(), unsupported_op::ALL.len());
228        for operation in unsupported_op::ALL {
229            assert!(!operation.is_empty());
230            assert!(!operation.contains(':'));
231        }
232    }
233
234    #[test]
235    fn pty_error_unsupported_carries_no_source() {
236        use std::error::Error as _;
237
238        let err = PtyError::Unsupported(unsupported_op::QUERY_PTY_SIZE);
239        assert!(err.source().is_none());
240    }
241
242    #[cfg(all(not(unix), not(windows)))]
243    #[test]
244    fn unsupported_backend_returns_explicit_errors() {
245        use std::io;
246
247        use super::{ChildCommand, PtyPair};
248
249        let open_pair =
250            PtyPair::open().expect_err("non-Unix non-Windows targets have no PTY backend");
251        assert!(matches!(
252            open_pair,
253            PtyError::Unsupported(op) if op == unsupported_op::OPEN_PTY_PAIR
254        ));
255
256        let spawn = ChildCommand::new("cmd.exe")
257            .spawn()
258            .expect_err("non-Unix non-Windows targets have no PTY backend");
259        assert!(matches!(
260            spawn,
261            PtyError::Unsupported(op) if op == unsupported_op::SPAWN_PTY_CHILD
262        ));
263
264        // The Tier-3 read/write/set_nonblocking arms in `pty.rs` return a typed
265        // `io::Error` with `ErrorKind::Unsupported`. They cannot be exercised
266        // here without a `PtyIo` instance (no constructor on Tier-3), so the
267        // Tier-3 contract for those call sites is enforced at compile time by
268        // the `cfg(not(windows))` arm in `pty.rs` together with the shared
269        // `unsupported_op` constant module referenced both here and at every
270        // Tier-3 producer.
271        let _ = io::ErrorKind::Unsupported;
272    }
273}