rust_pty/unix/
child.rs

1//! Unix child process management for PTY.
2//!
3//! This module provides child process spawning and management for Unix PTY
4//! sessions, handling fork, exec, and process lifecycle.
5
6use std::ffi::OsStr;
7use std::future::Future;
8use std::io;
9use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd};
10use std::pin::Pin;
11use std::process::ExitStatus as StdExitStatus;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use rustix::process::{Pid, Signal, WaitStatus, kill_process};
16use tokio::process::Child as TokioChild;
17use tokio::sync::Mutex;
18
19use crate::config::{PtyConfig, PtySignal};
20use crate::error::{PtyError, Result};
21use crate::traits::{ExitStatus, PtyChild};
22
23/// Unix child process handle.
24///
25/// This struct manages a child process spawned in a PTY, providing methods
26/// for monitoring its state and sending signals.
27pub struct UnixPtyChild {
28    /// The underlying tokio child process (if using Command-based spawn).
29    child: Arc<Mutex<Option<TokioChild>>>,
30    /// The process ID.
31    pid: u32,
32    /// Whether the process is still running.
33    running: Arc<AtomicBool>,
34    /// Cached exit status.
35    exit_status: Arc<Mutex<Option<ExitStatus>>>,
36}
37
38impl std::fmt::Debug for UnixPtyChild {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("UnixPtyChild")
41            .field("pid", &self.pid)
42            .field("running", &self.running.load(Ordering::SeqCst))
43            .finish()
44    }
45}
46
47impl UnixPtyChild {
48    /// Create a new child process handle.
49    #[must_use]
50    pub fn new(child: TokioChild) -> Self {
51        let pid = child.id().expect("child should have pid");
52        Self {
53            child: Arc::new(Mutex::new(Some(child))),
54            pid,
55            running: Arc::new(AtomicBool::new(true)),
56            exit_status: Arc::new(Mutex::new(None)),
57        }
58    }
59
60    /// Create a child handle from just a PID (for fork-based spawning).
61    #[must_use]
62    pub fn from_pid(pid: u32) -> Self {
63        Self {
64            child: Arc::new(Mutex::new(None)),
65            pid,
66            running: Arc::new(AtomicBool::new(true)),
67            exit_status: Arc::new(Mutex::new(None)),
68        }
69    }
70
71    /// Get the process ID.
72    #[must_use]
73    pub const fn pid(&self) -> u32 {
74        self.pid
75    }
76
77    /// Check if the process is still running.
78    #[must_use]
79    pub fn is_running(&self) -> bool {
80        self.running.load(Ordering::SeqCst)
81    }
82
83    /// Wait for the child process to exit.
84    pub async fn wait(&mut self) -> Result<ExitStatus> {
85        // Check cached status
86        {
87            let status = self.exit_status.lock().await;
88            if let Some(s) = *status {
89                return Ok(s);
90            }
91        }
92
93        // Try to wait using tokio child if available
94        let mut child_guard = self.child.lock().await;
95        if let Some(ref mut child) = *child_guard {
96            let status = child.wait().await.map_err(PtyError::Wait)?;
97            let exit_status = convert_exit_status(status);
98
99            self.running.store(false, Ordering::SeqCst);
100            *self.exit_status.lock().await = Some(exit_status);
101
102            return Ok(exit_status);
103        }
104
105        // Fall back to waitpid for fork-based spawn
106        drop(child_guard);
107        self.wait_pid().await
108    }
109
110    /// Wait using waitpid system call.
111    async fn wait_pid(&self) -> Result<ExitStatus> {
112        use rustix::process::{WaitOptions, waitpid};
113
114        let pid = Pid::from_raw(self.pid as i32).ok_or_else(|| {
115            PtyError::Wait(io::Error::new(io::ErrorKind::InvalidInput, "invalid pid"))
116        })?;
117
118        // Use blocking waitpid in a spawn_blocking context
119        let result = tokio::task::spawn_blocking(move || waitpid(Some(pid), WaitOptions::empty()))
120            .await
121            .map_err(|e| PtyError::Wait(io::Error::other(e)))?;
122
123        match result {
124            Ok(Some((_pid, wait_status))) => {
125                let exit_status = convert_wait_status(wait_status);
126
127                self.running.store(false, Ordering::SeqCst);
128                *self.exit_status.lock().await = Some(exit_status);
129
130                Ok(exit_status)
131            }
132            Ok(None) => {
133                // Process still running, shouldn't happen with default options
134                Err(PtyError::Wait(io::Error::new(
135                    io::ErrorKind::WouldBlock,
136                    "process still running",
137                )))
138            }
139            Err(e) => Err(PtyError::Wait(io::Error::from_raw_os_error(
140                e.raw_os_error(),
141            ))),
142        }
143    }
144
145    /// Try to get the exit status without blocking.
146    pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
147        use rustix::process::{WaitOptions, waitpid};
148
149        // Check cached status first
150        if let Ok(guard) = self.exit_status.try_lock()
151            && let Some(s) = *guard
152        {
153            return Ok(Some(s));
154        }
155
156        let pid = Pid::from_raw(self.pid as i32).ok_or_else(|| {
157            PtyError::Wait(io::Error::new(io::ErrorKind::InvalidInput, "invalid pid"))
158        })?;
159
160        match waitpid(Some(pid), WaitOptions::NOHANG) {
161            Ok(Some((_pid, wait_status))) => {
162                let exit_status = convert_wait_status(wait_status);
163
164                self.running.store(false, Ordering::SeqCst);
165                if let Ok(mut guard) = self.exit_status.try_lock() {
166                    *guard = Some(exit_status);
167                }
168
169                Ok(Some(exit_status))
170            }
171            Ok(None) => Ok(None), // Still running
172            Err(e) => Err(PtyError::Wait(io::Error::from_raw_os_error(
173                e.raw_os_error(),
174            ))),
175        }
176    }
177
178    /// Send a signal to the child process.
179    pub fn signal(&self, signal: PtySignal) -> Result<()> {
180        if !self.is_running() {
181            return Err(PtyError::ProcessExited(0));
182        }
183
184        let sig_num = signal.as_unix_signal().ok_or_else(|| {
185            PtyError::Signal(io::Error::new(
186                io::ErrorKind::Unsupported,
187                "unsupported signal",
188            ))
189        })?;
190
191        let pid = Pid::from_raw(self.pid as i32).ok_or_else(|| {
192            PtyError::Signal(io::Error::new(io::ErrorKind::InvalidInput, "invalid pid"))
193        })?;
194
195        let signal = Signal::from_named_raw(sig_num).ok_or_else(|| {
196            PtyError::Signal(io::Error::new(
197                io::ErrorKind::InvalidInput,
198                "invalid signal",
199            ))
200        })?;
201
202        kill_process(pid, signal)
203            .map_err(|e| PtyError::Signal(io::Error::from_raw_os_error(e.raw_os_error())))
204    }
205
206    /// Kill the child process (SIGKILL).
207    pub fn kill(&mut self) -> Result<()> {
208        self.signal(PtySignal::Kill)
209    }
210}
211
212impl PtyChild for UnixPtyChild {
213    fn pid(&self) -> u32 {
214        Self::pid(self)
215    }
216
217    fn is_running(&self) -> bool {
218        Self::is_running(self)
219    }
220
221    fn wait(&mut self) -> Pin<Box<dyn Future<Output = Result<ExitStatus>> + Send + '_>> {
222        Box::pin(Self::wait(self))
223    }
224
225    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
226        Self::try_wait(self)
227    }
228
229    fn signal(&self, signal: PtySignal) -> Result<()> {
230        Self::signal(self, signal)
231    }
232
233    fn kill(&mut self) -> Result<()> {
234        Self::kill(self)
235    }
236}
237
238/// Convert rustix `WaitStatus` to our `ExitStatus`.
239fn convert_wait_status(status: WaitStatus) -> ExitStatus {
240    if status.exited() {
241        // Get exit code
242        let code = status.exit_status().unwrap_or(0);
243        ExitStatus::Exited(code)
244    } else if status.signaled() {
245        // Get terminating signal - it's already an i32
246        let signal = status.terminating_signal().unwrap_or(0);
247        ExitStatus::Signaled(signal)
248    } else {
249        // Stopped or continued - process not actually exited
250        ExitStatus::Exited(-1)
251    }
252}
253
254/// Convert `std::process::ExitStatus` to our `ExitStatus`.
255fn convert_exit_status(status: StdExitStatus) -> ExitStatus {
256    #[cfg(unix)]
257    {
258        use std::os::unix::process::ExitStatusExt;
259        if let Some(code) = status.code() {
260            ExitStatus::Exited(code)
261        } else if let Some(signal) = status.signal() {
262            ExitStatus::Signaled(signal)
263        } else {
264            ExitStatus::Exited(-1)
265        }
266    }
267
268    #[cfg(not(unix))]
269    {
270        ExitStatus::Exited(status.code().unwrap_or(-1))
271    }
272}
273
274/// Spawn a child process in a PTY.
275///
276/// This sets up the child's stdin/stdout/stderr to use the slave PTY
277/// and executes the specified program.
278#[allow(unsafe_code)]
279pub async fn spawn_child<S, I>(
280    slave_fd: OwnedFd,
281    program: S,
282    args: I,
283    config: &PtyConfig,
284) -> Result<UnixPtyChild>
285where
286    S: AsRef<OsStr>,
287    I: IntoIterator,
288    I::Item: AsRef<OsStr>,
289{
290    use std::process::Stdio;
291
292    use tokio::process::Command;
293
294    // Convert to raw fd for dup2
295    let slave_raw = slave_fd.as_raw_fd();
296
297    // Build environment
298    let env = config.effective_env();
299
300    // Build command
301    let mut cmd = Command::new(program.as_ref());
302    cmd.args(args);
303    cmd.env_clear();
304    cmd.envs(env);
305
306    if let Some(ref dir) = config.working_directory {
307        cmd.current_dir(dir);
308    }
309
310    // Set up stdio to use the slave PTY
311    // SAFETY: We're duplicating a valid fd
312    unsafe {
313        cmd.stdin(Stdio::from_raw_fd(libc::dup(slave_raw)));
314        cmd.stdout(Stdio::from_raw_fd(libc::dup(slave_raw)));
315        cmd.stderr(Stdio::from_raw_fd(libc::dup(slave_raw)));
316    }
317
318    // Configure process
319    if config.new_session {
320        cmd.process_group(0);
321    }
322
323    // Pre-exec hook to set up controlling terminal
324    #[cfg(unix)]
325    if config.controlling_terminal {
326        // SAFETY: These are async-signal-safe operations
327        unsafe {
328            cmd.pre_exec(move || {
329                // Create new session
330                if libc::setsid() == -1 {
331                    return Err(io::Error::last_os_error());
332                }
333
334                // Set controlling terminal
335                // Cast TIOCSCTTY to c_ulong for macOS compatibility (u32 -> u64)
336                if libc::ioctl(slave_raw, libc::c_ulong::from(libc::TIOCSCTTY), 0) == -1 {
337                    return Err(io::Error::last_os_error());
338                }
339
340                Ok(())
341            });
342        }
343    }
344
345    let child = cmd.spawn().map_err(PtyError::Spawn)?;
346
347    Ok(UnixPtyChild::new(child))
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[tokio::test]
355    async fn child_from_pid() {
356        let child = UnixPtyChild::from_pid(1234);
357        assert_eq!(child.pid(), 1234);
358        assert!(child.is_running());
359    }
360}