Skip to main content

rmux_os/
process_tree.rs

1//! Child-process trees with platform-native lifetime isolation.
2
3use std::io;
4use std::process::{Child, Command, ExitStatus};
5
6#[cfg(unix)]
7use std::os::unix::process::CommandExt as _;
8#[cfg(windows)]
9use std::os::windows::process::CommandExt as _;
10#[cfg(any(
11    windows,
12    all(
13        unix,
14        not(any(
15            target_os = "cygwin",
16            target_os = "horizon",
17            target_os = "openbsd",
18            target_os = "redox",
19            target_os = "wasi"
20        ))
21    )
22))]
23use std::sync::Arc;
24
25#[cfg(unix)]
26use rustix::process::Pid;
27#[cfg(all(
28    unix,
29    not(any(
30        target_os = "cygwin",
31        target_os = "horizon",
32        target_os = "openbsd",
33        target_os = "redox",
34        target_os = "wasi"
35    ))
36))]
37use rustix::process::{waitid, WaitId, WaitIdOptions};
38#[cfg(windows)]
39use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
40
41#[cfg(windows)]
42use crate::process::ProcessJob;
43
44#[cfg(unix)]
45mod terminal;
46#[cfg(unix)]
47pub use terminal::ForegroundTerminalGuard;
48mod termination;
49#[cfg(all(
50    unix,
51    not(any(
52        target_os = "cygwin",
53        target_os = "horizon",
54        target_os = "openbsd",
55        target_os = "redox",
56        target_os = "wasi"
57    ))
58))]
59mod unix_controller;
60#[cfg(all(
61    unix,
62    not(any(
63        target_os = "cygwin",
64        target_os = "horizon",
65        target_os = "openbsd",
66        target_os = "redox",
67        target_os = "wasi"
68    ))
69))]
70use unix_controller::UnixProcessGroup;
71#[cfg(all(
72    unix,
73    not(any(
74        target_os = "cygwin",
75        target_os = "horizon",
76        target_os = "openbsd",
77        target_os = "redox",
78        target_os = "wasi"
79    ))
80))]
81mod unix_group_liveness;
82#[cfg(windows)]
83mod windows;
84#[cfg(windows)]
85use windows::resume_suspended_process;
86
87/// A child whose descendants are isolated with a process group on Unix targets
88/// supporting `waitid(WNOWAIT)`, or with a Job Object on Windows. Other Unix
89/// targets retain direct-child cleanup without risking a recycled group ID.
90///
91/// Dropping a live value terminates and reaps the tree. A successful [`Self::wait`]
92/// disarms that cleanup so descendants intentionally left in the background
93/// retain the same normal-completion behavior as `std::process::Child`.
94pub struct ProcessTreeChild {
95    child: Child,
96    armed: bool,
97    #[cfg(unix)]
98    process_group: i32,
99    #[cfg(all(
100        unix,
101        not(any(
102            target_os = "cygwin",
103            target_os = "horizon",
104            target_os = "openbsd",
105            target_os = "redox",
106            target_os = "wasi"
107        ))
108    ))]
109    process_group_control: Arc<UnixProcessGroup>,
110    #[cfg(windows)]
111    job: Arc<ProcessJob>,
112}
113
114/// Controls whether a spawned process tree may create a console window.
115///
116/// This setting only changes process creation on Windows. Other platforms
117/// ignore it because they do not have the corresponding console-window flag.
118#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
119pub enum ConsoleWindowBehavior {
120    /// Preserve the normal console-window behavior of the child command.
121    #[default]
122    Inherit,
123    /// Suppress creation of a console window for a background helper.
124    Suppress,
125}
126
127#[derive(Clone, Copy)]
128enum JobBreakawayBehavior {
129    Disallow,
130    AllowExplicit,
131}
132
133/// A clonable termination handle for a spawned process tree.
134#[derive(Clone)]
135pub struct ProcessTreeController {
136    #[cfg(all(
137        unix,
138        not(any(
139            target_os = "cygwin",
140            target_os = "horizon",
141            target_os = "openbsd",
142            target_os = "redox",
143            target_os = "wasi"
144        ))
145    ))]
146    process_group: Arc<UnixProcessGroup>,
147    #[cfg(windows)]
148    job: Arc<ProcessJob>,
149}
150
151impl ProcessTreeController {
152    /// Force-terminates every process still in the isolated tree.
153    pub fn terminate(&self) -> io::Result<()> {
154        #[cfg(all(
155            unix,
156            not(any(
157                target_os = "cygwin",
158                target_os = "horizon",
159                target_os = "openbsd",
160                target_os = "redox",
161                target_os = "wasi"
162            ))
163        ))]
164        {
165            self.process_group.terminate()
166        }
167
168        #[cfg(all(
169            unix,
170            any(
171                target_os = "cygwin",
172                target_os = "horizon",
173                target_os = "openbsd",
174                target_os = "redox",
175                target_os = "wasi"
176            )
177        ))]
178        {
179            Ok(())
180        }
181
182        #[cfg(windows)]
183        {
184            self.job.terminate(1)
185        }
186
187        #[cfg(not(any(unix, windows)))]
188        {
189            Ok(())
190        }
191    }
192}
193
194impl ProcessTreeChild {
195    /// Spawns `command` with descendant isolation established before user code
196    /// can create another process.
197    pub fn spawn(command: &mut Command) -> io::Result<Self> {
198        Self::spawn_with_console_window(command, ConsoleWindowBehavior::Inherit)
199    }
200
201    /// Spawns a process tree while allowing descendants that explicitly ask
202    /// Windows for job breakaway to outlive the tree.
203    ///
204    /// On non-Windows targets this is identical to [`Self::spawn`]. Ordinary
205    /// descendants remain isolated and are still terminated with the tree.
206    pub fn spawn_allowing_explicit_job_breakaway(command: &mut Command) -> io::Result<Self> {
207        Self::spawn_with_options(
208            command,
209            ConsoleWindowBehavior::Inherit,
210            JobBreakawayBehavior::AllowExplicit,
211        )
212    }
213
214    /// Spawns `command` with descendant isolation and the requested Windows
215    /// console-window behavior.
216    pub fn spawn_with_console_window(
217        command: &mut Command,
218        console_window: ConsoleWindowBehavior,
219    ) -> io::Result<Self> {
220        Self::spawn_with_options(command, console_window, JobBreakawayBehavior::Disallow)
221    }
222
223    fn spawn_with_options(
224        command: &mut Command,
225        console_window: ConsoleWindowBehavior,
226        job_breakaway: JobBreakawayBehavior,
227    ) -> io::Result<Self> {
228        #[cfg(not(windows))]
229        let _ = (console_window, job_breakaway);
230
231        #[cfg(all(
232            unix,
233            not(any(
234                target_os = "cygwin",
235                target_os = "horizon",
236                target_os = "openbsd",
237                target_os = "redox",
238                target_os = "wasi"
239            ))
240        ))]
241        {
242            command.process_group(0);
243            let child = command.spawn()?;
244            let process_group = i32::try_from(child.id())
245                .map_err(|_| io::Error::other("child process id does not fit in i32"))?;
246            Ok(Self {
247                child,
248                armed: true,
249                process_group,
250                process_group_control: Arc::new(UnixProcessGroup::new(process_group)),
251            })
252        }
253
254        #[cfg(windows)]
255        {
256            command.creation_flags(windows_creation_flags(console_window));
257            let mut child = command.spawn()?;
258            let job = match job_breakaway {
259                JobBreakawayBehavior::Disallow => ProcessJob::for_child(&child),
260                JobBreakawayBehavior::AllowExplicit => {
261                    ProcessJob::for_child_allowing_breakaway(&child)
262                }
263            };
264            let job = match job {
265                Ok(job) => Arc::new(job),
266                Err(error) => {
267                    let _ = child.kill();
268                    let _ = child.wait();
269                    return Err(error);
270                }
271            };
272            if let Err(error) = resume_suspended_process(child.id()) {
273                let _ = job.terminate(1);
274                let _ = child.kill();
275                let _ = child.wait();
276                return Err(error);
277            }
278            Ok(Self {
279                child,
280                armed: true,
281                job,
282            })
283        }
284
285        #[cfg(all(
286            unix,
287            any(
288                target_os = "cygwin",
289                target_os = "horizon",
290                target_os = "openbsd",
291                target_os = "redox",
292                target_os = "wasi"
293            )
294        ))]
295        {
296            let child = command.spawn()?;
297            let process_group = i32::try_from(child.id())
298                .map_err(|_| io::Error::other("child process id does not fit in i32"))?;
299            Ok(Self {
300                child,
301                armed: true,
302                process_group,
303            })
304        }
305
306        #[cfg(not(any(unix, windows)))]
307        {
308            Ok(Self {
309                child: command.spawn()?,
310                armed: true,
311            })
312        }
313    }
314
315    /// Returns a clonable handle that can terminate the isolated process tree
316    /// while the direct child is owned by another task.
317    #[must_use]
318    pub fn controller(&self) -> ProcessTreeController {
319        ProcessTreeController {
320            #[cfg(all(
321                unix,
322                not(any(
323                    target_os = "cygwin",
324                    target_os = "horizon",
325                    target_os = "openbsd",
326                    target_os = "redox",
327                    target_os = "wasi"
328                ))
329            ))]
330            process_group: Arc::clone(&self.process_group_control),
331            #[cfg(windows)]
332            job: Arc::clone(&self.job),
333        }
334    }
335
336    /// Returns mutable access to the direct child for transferring configured
337    /// standard-I/O handles to an owning runtime task.
338    pub fn child_mut(&mut self) -> &mut Child {
339        &mut self.child
340    }
341
342    /// Reports whether the direct child has exited without making its Unix
343    /// process-group identifier reusable before a possible tree signal.
344    pub fn has_exited(&mut self) -> io::Result<bool> {
345        if !self.armed {
346            return Ok(true);
347        }
348
349        #[cfg(all(
350            unix,
351            not(any(
352                target_os = "cygwin",
353                target_os = "horizon",
354                target_os = "openbsd",
355                target_os = "redox",
356                target_os = "wasi"
357            ))
358        ))]
359        {
360            let pid = Pid::from_raw(self.process_group)
361                .ok_or_else(|| io::Error::other("child process id is zero"))?;
362            Ok(waitid(
363                WaitId::Pid(pid),
364                WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
365            )?
366            .is_some_and(|status| status.exited() || status.killed() || status.dumped()))
367        }
368
369        #[cfg(any(
370            not(unix),
371            all(
372                unix,
373                any(
374                    target_os = "cygwin",
375                    target_os = "horizon",
376                    target_os = "openbsd",
377                    target_os = "redox",
378                    target_os = "wasi"
379                )
380            )
381        ))]
382        {
383            self.child.try_wait().map(|status| status.is_some())
384        }
385    }
386
387    /// Reports whether the direct Unix child is stopped without consuming the
388    /// status that remains owned by [`Self::wait`].
389    #[cfg(unix)]
390    pub fn has_stopped(&mut self) -> io::Result<bool> {
391        if !self.armed {
392            return Ok(false);
393        }
394
395        #[cfg(not(any(
396            target_os = "cygwin",
397            target_os = "horizon",
398            target_os = "openbsd",
399            target_os = "redox",
400            target_os = "wasi"
401        )))]
402        {
403            let pid = Pid::from_raw(self.process_group)
404                .ok_or_else(|| io::Error::other("child process id is zero"))?;
405            Ok(waitid(
406                WaitId::Pid(pid),
407                WaitIdOptions::STOPPED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
408            )?
409            .is_some_and(|status| status.stopped()))
410        }
411
412        #[cfg(any(
413            target_os = "cygwin",
414            target_os = "horizon",
415            target_os = "openbsd",
416            target_os = "redox",
417            target_os = "wasi"
418        ))]
419        {
420            Ok(false)
421        }
422    }
423
424    /// Forwards a raw Unix signal to the complete child process group.
425    #[cfg(all(
426        unix,
427        not(any(
428            target_os = "cygwin",
429            target_os = "horizon",
430            target_os = "openbsd",
431            target_os = "redox",
432            target_os = "wasi"
433        ))
434    ))]
435    pub fn forward_signal(&mut self, signal: i32) -> io::Result<()> {
436        if !self.armed {
437            return Ok(());
438        }
439        let result = unsafe {
440            // SAFETY: `process_group` is the positive PID of the child group
441            // leader created by this value. A negative PID addresses exactly
442            // that process group, and the caller supplies an OS signal value.
443            libc::kill(-self.process_group, signal)
444        };
445        if result == 0 {
446            return Ok(());
447        }
448        let error = io::Error::last_os_error();
449        if error.raw_os_error() == Some(libc::ESRCH)
450            || (error.raw_os_error() == Some(libc::EPERM) && self.has_exited()?)
451        {
452            Ok(())
453        } else {
454            Err(error)
455        }
456    }
457
458    /// Forwards a raw Unix signal to the direct child on targets that cannot
459    /// inspect an exited group leader without reaping it.
460    #[cfg(all(
461        unix,
462        any(
463            target_os = "cygwin",
464            target_os = "horizon",
465            target_os = "openbsd",
466            target_os = "redox",
467            target_os = "wasi"
468        )
469    ))]
470    pub fn forward_signal(&mut self, signal: i32) -> io::Result<()> {
471        if !self.armed {
472            return Ok(());
473        }
474        let result = unsafe {
475            // SAFETY: `process_group` is also the direct child PID. Limiting
476            // the signal to that PID avoids a recycled-group race on targets
477            // without waitid(WNOWAIT).
478            libc::kill(self.process_group, signal)
479        };
480        if result == 0 {
481            return Ok(());
482        }
483        let error = io::Error::last_os_error();
484        if error.raw_os_error() == Some(libc::ESRCH) {
485            Ok(())
486        } else {
487            Err(error)
488        }
489    }
490
491    /// Force-terminates the direct child and every descendant in its isolated
492    /// process tree.
493    pub fn terminate(&mut self) -> io::Result<()> {
494        if !self.armed {
495            return Ok(());
496        }
497
498        #[cfg(all(
499            unix,
500            not(any(
501                target_os = "cygwin",
502                target_os = "horizon",
503                target_os = "openbsd",
504                target_os = "redox",
505                target_os = "wasi"
506            ))
507        ))]
508        {
509            self.forward_signal(libc::SIGKILL)
510        }
511
512        #[cfg(all(
513            unix,
514            any(
515                target_os = "cygwin",
516                target_os = "horizon",
517                target_os = "openbsd",
518                target_os = "redox",
519                target_os = "wasi"
520            )
521        ))]
522        {
523            self.child.kill()
524        }
525
526        #[cfg(windows)]
527        {
528            self.job.terminate(1)
529        }
530
531        #[cfg(not(any(unix, windows)))]
532        {
533            self.child.kill()
534        }
535    }
536
537    /// Waits for the direct child and disarms tree cleanup after normal
538    /// completion.
539    pub fn wait(&mut self) -> io::Result<ExitStatus> {
540        #[cfg(all(
541            unix,
542            not(any(
543                target_os = "cygwin",
544                target_os = "horizon",
545                target_os = "openbsd",
546                target_os = "redox",
547                target_os = "wasi"
548            ))
549        ))]
550        let status = if let Some(status) = self.process_group_control.try_reap(&mut self.child)? {
551            status
552        } else {
553            let pid = Pid::from_raw(self.process_group)
554                .ok_or_else(|| io::Error::other("child process id is zero"))?;
555            let _ = waitid(
556                WaitId::Pid(pid),
557                WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
558            )?;
559            self.process_group_control.reap_exited(&mut self.child)?
560        };
561
562        #[cfg(not(all(
563            unix,
564            not(any(
565                target_os = "cygwin",
566                target_os = "horizon",
567                target_os = "openbsd",
568                target_os = "redox",
569                target_os = "wasi"
570            ))
571        )))]
572        let status = self.child.wait()?;
573        #[cfg(windows)]
574        self.job.disarm_kill_on_close()?;
575        self.armed = false;
576        Ok(status)
577    }
578}
579
580#[cfg(windows)]
581const fn windows_creation_flags(console_window: ConsoleWindowBehavior) -> u32 {
582    match console_window {
583        ConsoleWindowBehavior::Inherit => CREATE_SUSPENDED,
584        ConsoleWindowBehavior::Suppress => CREATE_SUSPENDED | CREATE_NO_WINDOW,
585    }
586}
587
588#[cfg(all(test, windows))]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn process_tree_creation_flags_preserve_console_window_policy() {
594        let inherited = windows_creation_flags(ConsoleWindowBehavior::Inherit);
595        assert_eq!(inherited & CREATE_SUSPENDED, CREATE_SUSPENDED);
596        assert_eq!(inherited & CREATE_NO_WINDOW, 0);
597
598        let suppressed = windows_creation_flags(ConsoleWindowBehavior::Suppress);
599        assert_eq!(suppressed & CREATE_SUSPENDED, CREATE_SUSPENDED);
600        assert_eq!(suppressed & CREATE_NO_WINDOW, CREATE_NO_WINDOW);
601    }
602}
603
604impl Drop for ProcessTreeChild {
605    fn drop(&mut self) {
606        if !self.armed {
607            return;
608        }
609        let _ = self.terminate();
610        let _ = self.child.kill();
611
612        #[cfg(all(
613            unix,
614            not(any(
615                target_os = "cygwin",
616                target_os = "horizon",
617                target_os = "openbsd",
618                target_os = "redox",
619                target_os = "wasi"
620            ))
621        ))]
622        if self.wait().is_err() {
623            self.process_group_control.disarm();
624        }
625
626        #[cfg(not(all(
627            unix,
628            not(any(
629                target_os = "cygwin",
630                target_os = "horizon",
631                target_os = "openbsd",
632                target_os = "redox",
633                target_os = "wasi"
634            ))
635        )))]
636        let _ = self.child.wait();
637        self.armed = false;
638    }
639}