Skip to main content

rmux_pty/
child.rs

1use std::ffi::OsString;
2#[cfg(unix)]
3use std::fs::File;
4#[cfg(unix)]
5use std::os::unix::process::CommandExt;
6use std::path::PathBuf;
7use std::process::ExitStatus;
8#[cfg(unix)]
9use std::process::{Child, Command, Stdio};
10
11#[cfg(all(not(unix), not(windows)))]
12use crate::unsupported_op;
13#[cfg(all(not(unix), not(windows)))]
14use crate::PtyError;
15#[cfg(any(unix, windows))]
16use crate::{backend, PtyPair};
17use crate::{ProcessId, PtyMaster, Result, Signal, TerminalSize};
18
19/// A command configuration for spawning a process inside a newly allocated PTY.
20#[derive(Clone, Debug)]
21#[cfg_attr(not(unix), allow(dead_code))]
22pub struct ChildCommand {
23    pub(crate) program: PathBuf,
24    pub(crate) arg0: Option<OsString>,
25    pub(crate) args: Vec<OsString>,
26    pub(crate) env: Vec<(OsString, OsString)>,
27    pub(crate) clear_env: bool,
28    pub(crate) current_dir: Option<PathBuf>,
29    pub(crate) size: Option<TerminalSize>,
30}
31
32impl ChildCommand {
33    /// Creates a PTY child command that will execute `program`.
34    #[must_use]
35    pub fn new(program: impl Into<PathBuf>) -> Self {
36        Self {
37            program: program.into(),
38            arg0: None,
39            args: Vec::new(),
40            env: Vec::new(),
41            clear_env: false,
42            current_dir: None,
43            size: None,
44        }
45    }
46
47    /// Overrides `argv[0]` without changing the executable path.
48    #[must_use]
49    pub fn arg0(mut self, arg0: impl Into<OsString>) -> Self {
50        self.arg0 = Some(arg0.into());
51        self
52    }
53
54    /// Appends a single process argument.
55    #[must_use]
56    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
57        self.args.push(arg.into());
58        self
59    }
60
61    /// Appends multiple process arguments.
62    #[must_use]
63    pub fn args<I, S>(mut self, args: I) -> Self
64    where
65        I: IntoIterator<Item = S>,
66        S: Into<OsString>,
67    {
68        self.args.extend(args.into_iter().map(Into::into));
69        self
70    }
71
72    /// Sets or overrides a process environment variable.
73    #[must_use]
74    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
75        self.env.push((key.into(), value.into()));
76        self
77    }
78
79    /// Clears the inherited process environment before applying explicit entries.
80    #[must_use]
81    pub fn clear_env(mut self) -> Self {
82        self.clear_env = true;
83        self
84    }
85
86    /// Sets the child working directory.
87    #[must_use]
88    pub fn current_dir(mut self, path: impl Into<PathBuf>) -> Self {
89        self.current_dir = Some(path.into());
90        self
91    }
92
93    /// Sets an initial PTY size for the child process.
94    #[must_use]
95    pub fn size(mut self, size: TerminalSize) -> Self {
96        self.size = Some(size);
97        self
98    }
99
100    /// Spawns the configured command inside a newly allocated PTY.
101    pub fn spawn(self) -> Result<SpawnedPty> {
102        spawn_child(self)
103    }
104}
105
106/// A spawned process together with the PTY master used to communicate with it.
107#[derive(Debug)]
108pub struct SpawnedPty {
109    master: PtyMaster,
110    child: PtyChild,
111}
112
113impl SpawnedPty {
114    /// Returns the PTY master endpoint.
115    #[must_use]
116    pub fn master(&self) -> &PtyMaster {
117        &self.master
118    }
119
120    /// Returns the child-process handle.
121    #[must_use]
122    pub fn child(&self) -> &PtyChild {
123        &self.child
124    }
125
126    /// Returns the child-process handle mutably for waiting and reaping.
127    #[must_use]
128    pub fn child_mut(&mut self) -> &mut PtyChild {
129        &mut self.child
130    }
131
132    /// Consumes the wrapper and returns the PTY master and child handle.
133    #[must_use]
134    pub fn into_parts(self) -> (PtyMaster, PtyChild) {
135        (self.master, self.child)
136    }
137}
138
139/// A handle for signaling and reaping a PTY-backed child process.
140#[derive(Debug)]
141pub struct PtyChild {
142    #[cfg(unix)]
143    child: Child,
144    #[cfg(windows)]
145    child: backend::WindowsChild,
146    pid: ProcessId,
147}
148
149impl PtyChild {
150    /// Returns the PTY session leader's process identifier.
151    ///
152    /// The spawned child creates a fresh session and foreground process group,
153    /// so this PID is also the PTY process-group identifier used for later
154    /// signal delivery.
155    #[must_use]
156    pub fn pid(&self) -> ProcessId {
157        self.pid
158    }
159
160    /// Waits for the child process to exit and reaps it.
161    pub fn wait(&mut self) -> Result<ExitStatus> {
162        #[cfg(unix)]
163        {
164            Ok(self.child.wait()?)
165        }
166
167        #[cfg(not(unix))]
168        {
169            #[cfg(windows)]
170            {
171                backend::wait_child(&mut self.child)
172            }
173
174            #[cfg(not(windows))]
175            {
176                Err(PtyError::Unsupported(unsupported_op::WAIT_FOR_PTY_CHILD))
177            }
178        }
179    }
180
181    /// Attempts to reap the child process without blocking.
182    pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
183        #[cfg(unix)]
184        {
185            Ok(self.child.try_wait()?)
186        }
187
188        #[cfg(not(unix))]
189        {
190            #[cfg(windows)]
191            {
192                backend::try_wait_child(&mut self.child)
193            }
194
195            #[cfg(not(windows))]
196            {
197                Err(PtyError::Unsupported(
198                    unsupported_op::TRY_WAIT_FOR_PTY_CHILD,
199                ))
200            }
201        }
202    }
203
204    /// Clones a wait-only handle for observing process exit.
205    #[cfg(windows)]
206    pub fn try_clone_for_wait(&self) -> Result<Self> {
207        Ok(Self {
208            child: backend::try_clone_child_for_wait(&self.child)?,
209            pid: self.pid,
210        })
211    }
212
213    /// Closes the backing ConPTY after the child has exited.
214    ///
215    /// Windows keeps the ConPTY output pipe alive while the pseudo console is
216    /// open. The server's exit watcher calls this after `wait()` so the output
217    /// reader observes EOF instead of blocking indefinitely on an already-dead
218    /// child process.
219    #[cfg(windows)]
220    pub fn close_pseudoconsole(&self) {
221        backend::close_child_pseudoconsole(&self.child);
222    }
223
224    /// Sends an interrupt request to the PTY foreground process group.
225    pub fn interrupt(&self) -> Result<()> {
226        self.kill(Signal::INT)
227    }
228
229    /// Sends a forceful kill request to the PTY foreground process group.
230    pub fn terminate_forcefully(&self) -> Result<()> {
231        self.kill(Signal::KILL)
232    }
233
234    /// Sends a signal to the PTY foreground process group.
235    ///
236    /// PTY-backed sessions commonly fan out into multiple processes while
237    /// sharing the foreground group created during spawn. Signaling the group
238    /// preserves teardown correctness even when the session leader has already
239    /// delegated work to descendants.
240    pub fn kill(&self, signal: Signal) -> Result<()> {
241        #[cfg(unix)]
242        {
243            backend::kill_foreground_process_group(self.pid, signal)
244        }
245
246        #[cfg(not(unix))]
247        {
248            #[cfg(windows)]
249            {
250                backend::kill_child(&self.child, signal)
251            }
252
253            #[cfg(not(windows))]
254            {
255                let _ = signal;
256                Err(PtyError::Unsupported(unsupported_op::SIGNAL_PTY_FOREGROUND))
257            }
258        }
259    }
260
261    /// Sends a signal directly to the PTY session leader.
262    ///
263    /// This is a teardown fallback for shells that move foreground jobs into a
264    /// different process group while the session leader is still the child that
265    /// must be reaped by RMUX.
266    pub fn kill_session_leader(&self, signal: Signal) -> Result<()> {
267        #[cfg(unix)]
268        {
269            backend::kill_process(self.pid, signal)?;
270            Ok(())
271        }
272
273        #[cfg(not(unix))]
274        {
275            #[cfg(windows)]
276            {
277                backend::kill_child(&self.child, signal)
278            }
279
280            #[cfg(not(windows))]
281            {
282                let _ = signal;
283                Err(PtyError::Unsupported(
284                    unsupported_op::SIGNAL_PTY_SESSION_LEADER,
285                ))
286            }
287        }
288    }
289
290    /// Continues the PTY foreground process group if the session leader is
291    /// currently stopped.
292    ///
293    /// This mirrors tmux's SIGCHLD policy for stopped panes while leaving
294    /// SIGTTIN/SIGTTOU alone so background terminal I/O remains governed by
295    /// normal Unix job-control rules.
296    #[cfg(unix)]
297    pub fn continue_if_stopped(&self) -> Result<bool> {
298        let Some(stop_signal) = backend::stopped_signal(self.pid)? else {
299            return Ok(false);
300        };
301        if stop_signal == libc::SIGTTIN || stop_signal == libc::SIGTTOU {
302            return Ok(false);
303        }
304
305        backend::kill_foreground_process_group(self.pid, Signal::CONT)
306            .or_else(|_| backend::kill_process(self.pid, Signal::CONT))?;
307        Ok(true)
308    }
309}
310
311#[cfg(unix)]
312fn spawn_child(command: ChildCommand) -> Result<SpawnedPty> {
313    let pair = match command.size {
314        Some(size) => PtyPair::open_with_size(size)?,
315        None => PtyPair::open()?,
316    };
317    let (master, slave) = pair.into_split();
318    let raw_master_fd = master.raw_fd();
319
320    let stdin = File::from(slave.try_clone()?.into_owned_fd());
321    let stdout = File::from(slave.try_clone()?.into_owned_fd());
322    let stderr = File::from(slave.into_owned_fd());
323
324    let mut std_command = Command::new(&command.program);
325    if let Some(arg0) = &command.arg0 {
326        std_command.arg0(arg0);
327    }
328    std_command.args(&command.args);
329    std_command.stdin(Stdio::from(stdin));
330    std_command.stdout(Stdio::from(stdout));
331    std_command.stderr(Stdio::from(stderr));
332    if command.clear_env {
333        std_command.env_clear();
334    }
335    if let Some(current_dir) = &command.current_dir {
336        std_command.current_dir(current_dir);
337    }
338
339    for (key, value) in &command.env {
340        std_command.env(key, value);
341    }
342
343    let pre_exec = move || {
344        rmux_os::signals::reset_child_signal_dispositions()?;
345        backend::setup_child_controlling_terminal(raw_master_fd)
346    };
347
348    // SAFETY: The closure only performs post-fork child setup that is required
349    // for PTY correctness: it closes the child's inherited master fd copy,
350    // creates a new session, installs the slave as the controlling terminal,
351    // and sets the child process group to the foreground process group on the
352    // PTY. The closure does not touch parent-owned Rust state after fork.
353    unsafe {
354        std_command.pre_exec(pre_exec);
355    }
356
357    let child = std_command.spawn()?;
358    let pid = ProcessId::new(child.id())?;
359
360    Ok(SpawnedPty {
361        master,
362        child: PtyChild { child, pid },
363    })
364}
365
366#[cfg(not(unix))]
367fn spawn_child(_command: ChildCommand) -> Result<SpawnedPty> {
368    #[cfg(windows)]
369    {
370        let pair = match _command.size {
371            Some(size) => PtyPair::open_with_size(size)?,
372            None => PtyPair::open()?,
373        };
374        let master = pair.into_master();
375        let child = backend::spawn_child(_command, master.windows_pty())?;
376        let pid = child.pid();
377        Ok(SpawnedPty {
378            master,
379            child: PtyChild { child, pid },
380        })
381    }
382
383    #[cfg(not(windows))]
384    {
385        Err(PtyError::Unsupported(unsupported_op::SPAWN_PTY_CHILD))
386    }
387}