aria_primitives/process/
child.rs1use 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 exit_status: Option<ExitStatus>,
18 process_group: u32,
19 owns_process_group: bool,
20}
21
22impl ChildProcess {
23 #[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 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 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 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 fn signal_group(&self, signal: Signal) -> Result<(), ChildProcessError> {
166 match kill_process_group(self.group_pid()?, signal) {
167 Ok(()) | Err(Errno::SRCH) => Ok(()),
168 #[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 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 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 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}