Skip to main content

aria_primitives/process/
child.rs

1//! Bounded waiting and mandatory cleanup for child process groups.
2
3use std::io;
4use std::os::unix::process::CommandExt;
5use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus};
6use std::thread;
7use std::time::Instant;
8
9use rustix::io::Errno;
10use rustix::process::{Pid, Signal, kill_process_group};
11
12use super::POLL_INTERVAL;
13
14pub struct ChildProcess {
15    child: Option<Child>,
16    /// Kept once the child is reaped, so a later look still sees the exit.
17    exit_status: Option<ExitStatus>,
18    process_group: u32,
19    owns_process_group: bool,
20}
21
22impl ChildProcess {
23    // This is the single process-spawn boundary protected by `clippy.toml`.
24    #[allow(clippy::disallowed_methods)]
25    pub fn spawn(command: &mut Command) -> io::Result<Self> {
26        command.process_group(0);
27        command.spawn().map(|child| {
28            let process_group = child.id();
29            Self {
30                child: Some(child),
31                exit_status: None,
32                process_group,
33                owns_process_group: true,
34            }
35        })
36    }
37
38    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
39        self.child.as_mut()?.stdin.take()
40    }
41
42    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
43        self.child.as_mut()?.stdout.take()
44    }
45
46    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
47        self.child.as_mut()?.stderr.take()
48    }
49
50    pub fn wait_until(
51        &mut self,
52        deadline: Instant,
53        mut should_cancel: impl FnMut() -> bool,
54    ) -> Result<WaitOutcome, ChildProcessError> {
55        loop {
56            if let Some(status) = self.try_wait()? {
57                self.terminate_group()?;
58                return Ok(WaitOutcome::Exited(status));
59            }
60            if should_cancel() {
61                return Ok(WaitOutcome::Cancelled);
62            }
63            if Instant::now() >= deadline {
64                return Ok(WaitOutcome::TimedOut);
65            }
66            thread::sleep(POLL_INTERVAL);
67        }
68    }
69
70    #[cfg(target_os = "macos")]
71    pub fn terminate(mut self) -> Result<(), ChildProcessError> {
72        self.terminate_inner()
73    }
74
75    /// Stops the group while keeping ownership, so a caller blocked on the
76    /// child's output can unblock and still tidy up afterwards.
77    pub fn terminate_in_place(&mut self) -> Result<(), ChildProcessError> {
78        self.terminate_inner()
79    }
80
81    fn terminate_inner(&mut self) -> Result<(), ChildProcessError> {
82        if self.child.is_none() {
83            return self.terminate_group();
84        }
85        let inspect_error = match self.try_wait() {
86            Ok(Some(_status)) => {
87                self.terminate_group()?;
88                return Ok(());
89            }
90            Ok(None) => None,
91            Err(ChildProcessError::Inspect(source)) => Some(source),
92            Err(error) => return Err(error),
93        };
94
95        if let Err(group_error) = self.terminate_group() {
96            if let Some(_status) = self.try_wait()? {
97                return Err(group_error);
98            }
99            if let Err(source) = self.child_mut()?.kill() {
100                if let Some(_status) = self.try_wait()? {
101                    return Err(group_error);
102                }
103                return Err(ChildProcessError::TerminateProcess(source));
104            }
105            self.reap()?;
106            return Err(group_error);
107        }
108        self.reap()?;
109
110        if let Some(source) = inspect_error {
111            return Err(ChildProcessError::Inspect(source));
112        }
113        Ok(())
114    }
115
116    pub fn try_wait(&mut self) -> Result<Option<ExitStatus>, ChildProcessError> {
117        if self.child.is_none() {
118            return Ok(self.exit_status);
119        }
120        if !self.child_exited()? {
121            return Ok(None);
122        }
123        // The unreaped leader still holds the group id, so this kill cannot
124        // reach a group that reused it.
125        self.terminate_group()?;
126        let status = self
127            .child_mut()?
128            .try_wait()
129            .map_err(ChildProcessError::Inspect)?;
130        if status.is_some() {
131            self.child = None;
132            self.exit_status = status;
133        }
134        Ok(status)
135    }
136
137    fn reap(&mut self) -> Result<(), ChildProcessError> {
138        let status = self.child_mut()?.wait().map_err(ChildProcessError::Reap)?;
139        self.child = None;
140        self.exit_status = Some(status);
141        Ok(())
142    }
143
144    /// Asks the group to stop with SIGTERM, so a child with a signal handler
145    /// can tidy up. Reaping stays with a later wait or terminate; a group
146    /// already gone counts as asked.
147    pub fn request_termination(&self) -> Result<(), ChildProcessError> {
148        if !self.owns_process_group {
149            return Ok(());
150        }
151        self.signal_group(Signal::TERM)
152    }
153
154    fn terminate_group(&mut self) -> Result<(), ChildProcessError> {
155        if !self.owns_process_group {
156            return Ok(());
157        }
158
159        self.signal_group(Signal::KILL)?;
160        self.owns_process_group = false;
161        Ok(())
162    }
163
164    /// A group already gone counts as signalled.
165    fn signal_group(&self, signal: Signal) -> Result<(), ChildProcessError> {
166        match kill_process_group(self.group_pid()?, signal) {
167            Ok(()) | Err(Errno::SRCH) => Ok(()),
168            // macOS answers EPERM, not ESRCH, once every member has exited.
169            // It answers the same for a live member it may not signal, which
170            // this cannot tell apart once the child itself has exited.
171            #[cfg(target_os = "macos")]
172            Err(Errno::PERM) if self.child_exited()? => Ok(()),
173            Err(source) => Err(ChildProcessError::TerminateGroup(source.into())),
174        }
175    }
176
177    /// Whether the child has exited, without reaping it, so its status stays
178    /// for whoever waits.
179    fn child_exited(&self) -> Result<bool, ChildProcessError> {
180        use rustix::process::{WaitId, WaitIdOptions, waitid};
181
182        let Some(child) = &self.child else {
183            return Ok(true);
184        };
185        let options = WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT;
186        waitid(WaitId::Pid(Pid::from_child(child)), options)
187            .map(|status| status.is_some())
188            .map_err(|source| ChildProcessError::Inspect(source.into()))
189    }
190
191    fn group_pid(&self) -> Result<Pid, ChildProcessError> {
192        let raw_pid = i32::try_from(self.process_group)
193            .map_err(|_error| io::Error::other("child process identifier is out of range"))
194            .map_err(ChildProcessError::TerminateGroup)?;
195        Pid::from_raw(raw_pid)
196            .ok_or_else(|| io::Error::other("child process identifier is invalid"))
197            .map_err(ChildProcessError::TerminateGroup)
198    }
199
200    fn child_mut(&mut self) -> Result<&mut Child, ChildProcessError> {
201        self.child.as_mut().ok_or(ChildProcessError::MissingChild)
202    }
203}
204
205impl Drop for ChildProcess {
206    fn drop(&mut self) {
207        if (self.child.is_some() || self.owns_process_group)
208            && let Err(error) = self.terminate_inner()
209        {
210            tracing::error!(
211                error = %crate::error_chain(&error),
212                "failed to clean up child process before drop"
213            );
214        }
215    }
216}
217
218pub enum WaitOutcome {
219    Exited(ExitStatus),
220    Cancelled,
221    TimedOut,
222}
223
224#[derive(Debug, thiserror::Error)]
225pub enum ChildProcessError {
226    #[error("child process ownership ended before cleanup completed")]
227    MissingChild,
228
229    #[error("failed to inspect the child process")]
230    Inspect(#[source] io::Error),
231
232    #[error("failed to terminate the child process group")]
233    TerminateGroup(#[source] io::Error),
234
235    #[error("failed to terminate the child process")]
236    TerminateProcess(#[source] io::Error),
237
238    #[error("failed to reap the child process")]
239    Reap(#[source] io::Error),
240}
241
242#[cfg(test)]
243mod tests {
244    use std::io::{BufRead, BufReader};
245    use std::process::Stdio;
246    use std::thread;
247    use std::time::{Duration, Instant};
248
249    use rustix::process::{WaitId, WaitIdOptions, test_kill_process, waitid};
250
251    use super::super::POLL_INTERVAL;
252    use super::*;
253
254    const PATIENCE: Duration = Duration::from_secs(5);
255
256    fn shell(script: &str) -> Command {
257        let mut command = Command::new("/bin/sh");
258        command.arg("-c").arg(script);
259        command
260    }
261
262    fn spawn(script: &str) -> ChildProcess {
263        ChildProcess::spawn(&mut shell(script)).expect("the shell should start")
264    }
265
266    // The shell leaves a child running and exits. Only a group signal reaches
267    // that orphan, so a pass means the cleanup really killed the group.
268    fn spawn_group() -> (ChildProcess, Pid) {
269        let mut command = shell("sleep 300 & echo $!");
270        command.stdout(Stdio::piped());
271        let mut process = ChildProcess::spawn(&mut command).expect("the shell should start");
272
273        let stdout = process.take_stdout().expect("stdout should be piped");
274        let mut line = String::new();
275        BufReader::new(stdout)
276            .read_line(&mut line)
277            .expect("the shell should report its child");
278        let raw = line.trim().parse().expect("the report should be a pid");
279
280        (
281            process,
282            Pid::from_raw(raw).expect("the pid should be valid"),
283        )
284    }
285
286    // Processes exit and get reaped on their own schedule, so give the
287    // condition a while to come true rather than checking once.
288    fn eventually(mut condition: impl FnMut() -> bool) -> bool {
289        let deadline = Instant::now() + PATIENCE;
290        while Instant::now() < deadline {
291            if condition() {
292                return true;
293            }
294            thread::sleep(POLL_INTERVAL);
295        }
296        false
297    }
298
299    fn gone(pid: Pid) -> bool {
300        eventually(|| test_kill_process(pid).is_err())
301    }
302
303    fn exited(process: &mut ChildProcess) -> bool {
304        eventually(|| {
305            process
306                .try_wait()
307                .expect("inspection should succeed")
308                .is_some()
309        })
310    }
311
312    #[test]
313    fn dropping_a_process_kills_its_whole_group() {
314        let (process, grandchild) = spawn_group();
315
316        drop(process);
317
318        assert!(gone(grandchild), "the grandchild outlived its group");
319    }
320
321    #[test]
322    fn seeing_the_leader_exit_kills_the_rest_of_its_group() {
323        let (mut process, grandchild) = spawn_group();
324
325        assert!(exited(&mut process), "the shell should exit on its own");
326
327        assert!(
328            gone(grandchild),
329            "the grandchild outlived its reaped leader"
330        );
331    }
332
333    #[test]
334    fn terminating_kills_the_whole_group() {
335        let (mut process, grandchild) = spawn_group();
336
337        process
338            .terminate_in_place()
339            .expect("cleanup should succeed");
340
341        assert!(gone(grandchild), "the grandchild outlived its group");
342    }
343
344    #[test]
345    fn cleanup_succeeds_once_the_child_has_exited() {
346        let mut process = spawn("exit 0");
347        assert!(exited(&mut process), "the shell should exit on its own");
348
349        process
350            .terminate_in_place()
351            .expect("cleanup should succeed");
352    }
353
354    #[test]
355    fn cleanup_succeeds_when_the_child_died_after_the_last_check() {
356        let mut process = spawn("exit 0");
357        let leader = process.group_pid().expect("the pid should be valid");
358        waitid(
359            WaitId::Pid(leader),
360            WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
361        )
362        .expect("the shell should exit");
363
364        process
365            .terminate_group()
366            .expect("a group of exited processes is already gone");
367    }
368
369    #[test]
370    fn asking_an_exited_group_to_stop_keeps_its_status() {
371        let mut process = spawn("exit 3");
372        let leader = process.group_pid().expect("the pid should be valid");
373        waitid(
374            WaitId::Pid(leader),
375            WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
376        )
377        .expect("the shell should exit");
378
379        process
380            .request_termination()
381            .expect("a group of exited processes counts as asked");
382
383        let outcome = process
384            .wait_until(Instant::now() + PATIENCE, || false)
385            .expect("waiting should succeed");
386        let WaitOutcome::Exited(status) = outcome else {
387            panic!("the shell should have exited");
388        };
389        assert_eq!(status.code(), Some(3));
390    }
391
392    #[cfg(target_os = "macos")]
393    #[test]
394    fn waiting_reports_the_exit_status() {
395        let mut process = spawn("exit 3");
396
397        let outcome = process
398            .wait_until(Instant::now() + PATIENCE, || false)
399            .expect("waiting should succeed");
400
401        let WaitOutcome::Exited(status) = outcome else {
402            panic!("the shell should have exited");
403        };
404        assert_eq!(status.code(), Some(3));
405    }
406
407    #[cfg(target_os = "macos")]
408    #[test]
409    fn waiting_gives_up_at_the_deadline() {
410        let mut process = spawn("sleep 300");
411
412        let outcome = process
413            .wait_until(Instant::now() + Duration::from_millis(50), || false)
414            .expect("waiting should succeed");
415
416        assert!(matches!(outcome, WaitOutcome::TimedOut));
417    }
418
419    #[cfg(target_os = "macos")]
420    #[test]
421    fn waiting_stops_when_the_caller_cancels() {
422        let mut process = spawn("sleep 300");
423
424        let outcome = process
425            .wait_until(Instant::now() + PATIENCE, || true)
426            .expect("waiting should succeed");
427
428        assert!(matches!(outcome, WaitOutcome::Cancelled));
429    }
430}