Skip to main content

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, RawFd};
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        // For a `tokio::process`-spawned child, reap through tokio's own
157        // non-blocking `try_wait`. tokio owns SIGCHLD reaping for children it
158        // spawned; issuing a raw `waitpid` on the same PID here is incorrect
159        // API usage that only happens to work while tokio's reaper is idle, and
160        // it can race that reaper (stealing the status, or losing it to tokio).
161        match self.child.try_lock() {
162            Ok(mut child_guard) => {
163                if let Some(ref mut child) = *child_guard {
164                    return match child.try_wait().map_err(PtyError::Wait)? {
165                        Some(status) => {
166                            let exit_status = convert_exit_status(status);
167                            self.running.store(false, Ordering::SeqCst);
168                            if let Ok(mut guard) = self.exit_status.try_lock() {
169                                *guard = Some(exit_status);
170                            }
171                            Ok(Some(exit_status))
172                        }
173                        None => Ok(None),
174                    };
175                }
176                // No `TokioChild` (a `from_pid` adoptee): fall through to
177                // `waitpid` below.
178            }
179            Err(_) => {
180                // The child handle is momentarily locked (e.g. an in-flight
181                // `wait`). Don't race it with a raw `waitpid`; report
182                // not-yet-determinable rather than risk a double reap.
183                return Ok(None);
184            }
185        }
186
187        let pid = Pid::from_raw(self.pid as i32).ok_or_else(|| {
188            PtyError::Wait(io::Error::new(io::ErrorKind::InvalidInput, "invalid pid"))
189        })?;
190
191        match waitpid(Some(pid), WaitOptions::NOHANG) {
192            Ok(Some((_pid, wait_status))) => {
193                let exit_status = convert_wait_status(wait_status);
194
195                self.running.store(false, Ordering::SeqCst);
196                if let Ok(mut guard) = self.exit_status.try_lock() {
197                    *guard = Some(exit_status);
198                }
199
200                Ok(Some(exit_status))
201            }
202            Ok(None) => Ok(None), // Still running
203            Err(e) => Err(PtyError::Wait(io::Error::from_raw_os_error(
204                e.raw_os_error(),
205            ))),
206        }
207    }
208
209    /// Send a signal to the child process.
210    pub fn signal(&self, signal: PtySignal) -> Result<()> {
211        if !self.is_running() {
212            return Err(PtyError::ProcessExited(0));
213        }
214
215        let sig_num = signal.as_unix_signal().ok_or_else(|| {
216            PtyError::Signal(io::Error::new(
217                io::ErrorKind::Unsupported,
218                "unsupported signal",
219            ))
220        })?;
221
222        let pid = Pid::from_raw(self.pid as i32).ok_or_else(|| {
223            PtyError::Signal(io::Error::new(io::ErrorKind::InvalidInput, "invalid pid"))
224        })?;
225
226        let signal = Signal::from_named_raw(sig_num).ok_or_else(|| {
227            PtyError::Signal(io::Error::new(
228                io::ErrorKind::InvalidInput,
229                "invalid signal",
230            ))
231        })?;
232
233        kill_process(pid, signal)
234            .map_err(|e| PtyError::Signal(io::Error::from_raw_os_error(e.raw_os_error())))
235    }
236
237    /// Kill the child process (SIGKILL).
238    pub fn kill(&mut self) -> Result<()> {
239        self.signal(PtySignal::Kill)
240    }
241}
242
243impl PtyChild for UnixPtyChild {
244    fn pid(&self) -> u32 {
245        Self::pid(self)
246    }
247
248    fn is_running(&self) -> bool {
249        Self::is_running(self)
250    }
251
252    fn wait(&mut self) -> Pin<Box<dyn Future<Output = Result<ExitStatus>> + Send + '_>> {
253        Box::pin(Self::wait(self))
254    }
255
256    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
257        Self::try_wait(self)
258    }
259
260    fn signal(&self, signal: PtySignal) -> Result<()> {
261        Self::signal(self, signal)
262    }
263
264    fn kill(&mut self) -> Result<()> {
265        Self::kill(self)
266    }
267}
268
269/// Convert rustix `WaitStatus` to our `ExitStatus`.
270fn convert_wait_status(status: WaitStatus) -> ExitStatus {
271    if status.exited() {
272        // Get exit code
273        let code = status.exit_status().unwrap_or(0);
274        ExitStatus::Exited(code)
275    } else if status.signaled() {
276        // Get terminating signal - it's already an i32
277        let signal = status.terminating_signal().unwrap_or(0);
278        ExitStatus::Signaled(signal)
279    } else {
280        // Stopped or continued - process not actually exited
281        ExitStatus::Exited(-1)
282    }
283}
284
285/// Convert `std::process::ExitStatus` to our `ExitStatus`.
286fn convert_exit_status(status: StdExitStatus) -> ExitStatus {
287    #[cfg(unix)]
288    {
289        use std::os::unix::process::ExitStatusExt;
290        if let Some(code) = status.code() {
291            ExitStatus::Exited(code)
292        } else if let Some(signal) = status.signal() {
293            ExitStatus::Signaled(signal)
294        } else {
295            ExitStatus::Exited(-1)
296        }
297    }
298
299    #[cfg(not(unix))]
300    {
301        ExitStatus::Exited(status.code().unwrap_or(-1))
302    }
303}
304
305/// Spawn a child process in a PTY.
306///
307/// This sets up the child's stdin/stdout/stderr to use the slave PTY
308/// and executes the specified program.
309#[allow(unsafe_code)]
310pub async fn spawn_child<S, I>(
311    slave_fd: OwnedFd,
312    program: S,
313    args: I,
314    config: &PtyConfig,
315) -> Result<UnixPtyChild>
316where
317    S: AsRef<OsStr>,
318    I: IntoIterator,
319    I::Item: AsRef<OsStr>,
320{
321    use std::process::Stdio;
322
323    use tokio::process::Command;
324
325    // Convert to raw fd for dup2
326    let slave_raw = slave_fd.as_raw_fd();
327
328    // Build environment
329    let env = config.effective_env();
330
331    // Build command
332    let mut cmd = Command::new(program.as_ref());
333    cmd.args(args);
334    cmd.env_clear();
335    cmd.envs(env);
336
337    if let Some(ref dir) = config.working_directory {
338        cmd.current_dir(dir);
339    }
340
341    // Set up stdio to use the slave PTY. Each `dup` is checked: building a
342    // `Stdio` from an invalid (-1) fd is unsound, and `dup` can fail (e.g.
343    // EMFILE). On failure, close any fds already duplicated here.
344    let stdin_fd = dup_slave(slave_raw)?;
345    let stdout_fd = match dup_slave(slave_raw) {
346        Ok(fd) => fd,
347        Err(e) => {
348            // SAFETY: stdin_fd is a valid fd created just above and owned here.
349            unsafe { libc::close(stdin_fd) };
350            return Err(e);
351        }
352    };
353    let stderr_fd = match dup_slave(slave_raw) {
354        Ok(fd) => fd,
355        Err(e) => {
356            // SAFETY: stdin_fd/stdout_fd are valid owned fds created above.
357            unsafe {
358                libc::close(stdin_fd);
359                libc::close(stdout_fd);
360            }
361            return Err(e);
362        }
363    };
364
365    // SAFETY: the three fds are valid, owned, and ownership transfers to the
366    // `Stdio` values (which close them).
367    unsafe {
368        cmd.stdin(Stdio::from_raw_fd(stdin_fd));
369        cmd.stdout(Stdio::from_raw_fd(stdout_fd));
370        cmd.stderr(Stdio::from_raw_fd(stderr_fd));
371    }
372
373    // Configure process group / session.
374    //
375    // When `controlling_terminal` is set, the `setsid()` in the pre_exec hook
376    // below already creates a new session *and* a new process group with the
377    // child as leader. Calling `process_group(0)` here as well makes the child
378    // a group leader *before* exec, which then makes that `setsid()` fail with
379    // EPERM (a process that is already a group leader cannot start a new
380    // session). So only use `process_group(0)` for the
381    // new-session-without-controlling-terminal case; otherwise `setsid()`
382    // handles both.
383    if config.new_session && !config.controlling_terminal {
384        cmd.process_group(0);
385    }
386
387    // Pre-exec hook to set up controlling terminal
388    #[cfg(unix)]
389    if config.controlling_terminal {
390        // SAFETY: These are async-signal-safe operations
391        unsafe {
392            cmd.pre_exec(move || {
393                // Create new session
394                if libc::setsid() == -1 {
395                    return Err(io::Error::last_os_error());
396                }
397
398                // Set controlling terminal
399                // Cast TIOCSCTTY to c_ulong for macOS compatibility (u32 -> u64)
400                if libc::ioctl(slave_raw, libc::c_ulong::from(libc::TIOCSCTTY), 0) == -1 {
401                    return Err(io::Error::last_os_error());
402                }
403
404                Ok(())
405            });
406        }
407    }
408
409    let child = cmd.spawn().map_err(PtyError::Spawn)?;
410
411    Ok(UnixPtyChild::new(child))
412}
413
414/// Duplicate the slave fd for a child stdio stream, returning an error rather
415/// than a `-1` on failure.
416#[allow(unsafe_code)]
417fn dup_slave(slave_raw: RawFd) -> Result<RawFd> {
418    // SAFETY: slave_raw is a valid, open slave fd for the duration of the call.
419    let fd = unsafe { libc::dup(slave_raw) };
420    if fd == -1 {
421        return Err(PtyError::Spawn(io::Error::last_os_error()));
422    }
423    Ok(fd)
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[tokio::test]
431    async fn child_from_pid() {
432        let child = UnixPtyChild::from_pid(1234);
433        assert_eq!(child.pid(), 1234);
434        assert!(child.is_running());
435    }
436}