Skip to main content

ic_host_process/child/
mod.rs

1//! Explicit child ownership and cleanup, without application lifecycle policy.
2//!
3//! Callers own executable admission, IO, readiness, cancellation and deadlines.
4//! Group cleanup signals members of a newly created group; it cannot contain
5//! processes that escape that group or prove completion of external effects.
6
7#[cfg(target_os = "macos")]
8mod macos;
9#[cfg(test)]
10mod tests;
11
12use rustix::{
13    io::Errno,
14    process::{Pid, Signal, WaitId, WaitIdOptions, WaitIdStatus, kill_process_group, waitid},
15};
16use std::{
17    fmt, io,
18    os::unix::process::{CommandExt, ExitStatusExt},
19    process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus},
20    time::{Duration, Instant},
21};
22
23/// Caller-selected termination behavior for an owned process group.
24#[derive(Clone, Copy, Debug)]
25pub enum CleanupPolicy {
26    /// Signal KILL immediately, then wait synchronously for the leader.
27    KillAndWait,
28    /// Signal TERM, reserve the leader through the grace period, then signal
29    /// KILL and poll reaping for at most the selected duration.
30    TermThenKill {
31        /// Time allowed after successful TERM signalling, even if the leader exits.
32        grace: Duration,
33        /// Reap allowance after KILL; zero permits one nonblocking observation.
34        reap_timeout: Duration,
35    },
36}
37
38/// One exclusively owned child, normally spawned as a new process-group leader.
39///
40/// [`Self::spawn`] preserves the command's IO, environment and other settings,
41/// replacing its process-group selection with a new group. It performs no
42/// executable admission. The child must not change groups, and callers must
43/// not independently reap it (including through a global SIGCHLD handler).
44///
45/// Ordinary waiting signals remaining group members before reaping the leader.
46/// For a deliberate background handoff, [`Self::poll_exit`] observes without
47/// releasing cleanup ownership, then [`Self::handoff`] reaps a successful leader
48/// without signalling its group. The caller then owns the background lifetime.
49/// Drop makes a best-effort kill/reap attempt, including during unwinding. Use
50/// [`Self::terminate`] to observe cleanup failures. The default cleanup waits
51/// synchronously; [`Self::spawn_with_cleanup`] can select bounded reaping.
52/// Successful signalling is not proof that descendants
53/// have exited or completed external effects. Only the direct child is reaped.
54/// Group signalling can succeed for only some members when credentials differ.
55pub struct OwnedChild {
56    child: Child,
57    group: bool,
58    status: Option<ExitStatus>,
59    owned: bool,
60    cleanup: CleanupPolicy,
61    reap_started: Option<Instant>,
62}
63
64/// Failures observed during one explicit termination attempt.
65///
66/// Keep this separately from the caller's original cancellation/operation error.
67/// If group signalling fails, direct-child kill and reaping are still attempted.
68#[derive(Debug)]
69pub struct CleanupError {
70    /// Status retained if the direct child was reaped despite another failure.
71    pub status: Option<ExitStatus>,
72    /// Failure signalling TERM to the owned process group.
73    pub term_error: Option<io::Error>,
74    /// Failure signalling KILL to the owned process group.
75    pub group_error: Option<io::Error>,
76    /// Failure killing the direct child (including fallback after group failure).
77    pub kill_error: Option<io::Error>,
78    /// Failure reaping the direct child.
79    pub wait_error: Option<io::Error>,
80}
81
82impl fmt::Display for CleanupError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        f.write_str("child cleanup failed")?;
85        for (operation, error) in [
86            ("group TERM", &self.term_error),
87            ("group signal", &self.group_error),
88            ("child kill", &self.kill_error),
89            ("child wait", &self.wait_error),
90        ] {
91            if let Some(error) = error {
92                write!(f, "; {operation}: {error}")?;
93            }
94        }
95        Ok(())
96    }
97}
98
99impl std::error::Error for CleanupError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        self.term_error
102            .as_ref()
103            .or(self.group_error.as_ref())
104            .or(self.kill_error.as_ref())
105            .or(self.wait_error.as_ref())
106            .map(|error| error as &dyn std::error::Error)
107    }
108}
109
110impl OwnedChild {
111    /// Spawn once in a new owned process group, preserving caller-configured IO.
112    ///
113    /// # Errors
114    /// Returns the native spawn/setup failure. No retries are performed.
115    pub fn spawn(command: &mut Command) -> io::Result<Self> {
116        Self::spawn_with_cleanup(command, CleanupPolicy::KillAndWait)
117    }
118
119    /// Spawn a new owned group with caller-selected termination timing.
120    ///
121    /// The policy applies whenever termination is needed, including communication
122    /// failures and Drop during unwinding. It does not change natural waiting or
123    /// successful background handoff. No signal handler or reaper thread is added.
124    ///
125    /// ```no_run
126    /// use ic_host_process::child::{CleanupPolicy, OwnedChild};
127    /// use std::{process::Command, time::Duration};
128    /// let mut child = OwnedChild::spawn_with_cleanup(
129    ///     &mut Command::new("caller-selected-tool"),
130    ///     CleanupPolicy::TermThenKill {
131    ///         grace: Duration::from_secs(5),
132    ///         reap_timeout: Duration::from_secs(5),
133    ///     },
134    /// )?;
135    /// // Configure command IO before spawn; communicate/admit before handoff.
136    /// child.terminate()?;
137    /// # Ok::<(), Box<dyn std::error::Error>>(())
138    /// ```
139    /// # Errors
140    /// Returns the native spawn/setup failure. No retries are performed.
141    pub fn spawn_with_cleanup(command: &mut Command, cleanup: CleanupPolicy) -> io::Result<Self> {
142        command.process_group(0);
143        Self::spawn_inner(command, true, cleanup)
144    }
145
146    pub(crate) fn spawn_direct(command: &mut Command) -> io::Result<Self> {
147        Self::spawn_inner(command, false, CleanupPolicy::KillAndWait)
148    }
149
150    pub(crate) const fn is_owned(&self) -> bool {
151        self.owned && self.reap_started.is_none()
152    }
153
154    fn spawn_inner(command: &mut Command, group: bool, cleanup: CleanupPolicy) -> io::Result<Self> {
155        command.spawn().map(|child| Self {
156            child,
157            group,
158            status: None,
159            owned: true,
160            cleanup,
161            reap_started: None,
162        })
163    }
164
165    /// Direct-child PID, for observation only; it can be reused after reaping.
166    #[must_use]
167    pub fn id(&self) -> u32 {
168        self.child.id()
169    }
170
171    /// Take caller-configured piped stdin. Close it before waiting for EOF-driven children.
172    pub const fn take_stdin(&mut self) -> Option<ChildStdin> {
173        self.child.stdin.take()
174    }
175
176    /// Take caller-configured piped stdout; the caller owns draining and bounds.
177    pub const fn take_stdout(&mut self) -> Option<ChildStdout> {
178        self.child.stdout.take()
179    }
180
181    /// Take caller-configured piped stderr; the caller owns draining and bounds.
182    pub const fn take_stderr(&mut self) -> Option<ChildStderr> {
183        self.child.stderr.take()
184    }
185
186    /// Observe leader exit without signalling or reaping it.
187    ///
188    /// An exited leader stays reserved with `WNOWAIT`, so cancellation, failed IO
189    /// admission and unwinding still clean its group. Repeated observations do
190    /// not release ownership. After a completed wait/termination/handoff, returns
191    /// the cached status. This status alone does not establish a handoff.
192    ///
193    /// Call [`Self::handoff`] only after admitting a successful background start.
194    /// Otherwise use [`Self::wait`] or [`Self::terminate`] to clean and reap.
195    /// # Errors
196    /// Returns native inspection errors; external reaping invalidates ownership.
197    pub fn poll_exit(&mut self) -> io::Result<Option<ExitStatus>> {
198        if let Some(status) = self.status {
199            return Ok(Some(status));
200        }
201        self.observe_exit(true)?
202            .map(|status| {
203                // Unix wait status encoding used by Linux and Darwin. waitid's
204                // siginfo status is an exit code/signal, not an encoded wait status.
205                let raw = if let Some(code) = status.exit_status() {
206                    code << 8
207                } else if let Some(signal) = status.terminating_signal() {
208                    signal | if status.dumped() { 0x80 } else { 0 }
209                } else {
210                    return Err(io::Error::other("waitid returned a non-exit observation"));
211                };
212                Ok(ExitStatus::from_raw(raw))
213            })
214            .transpose()
215    }
216
217    /// Reap an already successful leader without signalling its remaining group.
218    ///
219    /// This is the explicit transfer point for a background lifetime. The caller
220    /// must first admit its IO/result and arrange application-owned readiness,
221    /// cancellation and stop/recovery. Zero exit does not prove those obligations.
222    /// No PID/group handle is transferred: it could be reused after reaping.
223    /// Subsequent wait/termination/Drop never signal the handed-off group.
224    ///
225    /// Use [`Self::poll_exit`] while draining IO and checking cancellation. A
226    /// running or unsuccessful leader is refused without releasing ownership;
227    /// use ordinary wait/termination for failed startup, keeping its original
228    /// failure separate from any cleanup error. Drop still attempts cleanup.
229    /// # Errors
230    /// Returns `InvalidInput` for a running, unsuccessful, terminating or already-reaped
231    /// leader, or native inspection/reap errors. Reap failures retain cleanup
232    /// ownership unless it was lost externally.
233    pub fn handoff(&mut self) -> io::Result<ExitStatus> {
234        if !self.is_owned() || !self.poll_exit()?.is_some_and(|status| status.success()) {
235            return Err(io::Error::new(
236                io::ErrorKind::InvalidInput,
237                "handoff requires an owned, successfully exited leader",
238            ));
239        }
240        self.reap()
241    }
242
243    /// Inspect exit without blocking on a running child; clean its group before reaping.
244    ///
245    /// Repeated successful calls return the cached status without signalling again.
246    /// # Errors
247    /// Returns native inspection, group-signal or reap errors. The leader remains
248    /// reserved on a group-signal failure, so explicit cleanup can still be attempted.
249    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
250        if let Some(status) = self.status {
251            return Ok(Some(status));
252        }
253        if !self.owned {
254            return Err(Errno::CHILD.into());
255        }
256        if self.group {
257            if self.observe_exit(true)?.is_none() {
258                return Ok(None);
259            }
260            self.signal_group(Signal::KILL)?;
261            self.reap().map(Some)
262        } else {
263            let result = retry_interrupted(|| self.child.try_wait());
264            if let Ok(Some(status)) = result {
265                self.status = Some(status);
266                self.owned = false;
267            }
268            self.check_wait_ownership(&result);
269            result
270        }
271    }
272
273    /// Wait for natural leader exit, then clean its group and reap the leader.
274    ///
275    /// Close/drain caller-owned pipes as needed before waiting. No deadline or
276    /// cancellation policy is installed; callers may use polling instead.
277    /// # Errors
278    /// Returns native inspection, group-signal or reap errors.
279    pub fn wait(&mut self) -> io::Result<ExitStatus> {
280        if let Some(status) = self.status {
281            return Ok(status);
282        }
283        if self.group {
284            self.observe_exit(false)?;
285            self.signal_group(Signal::KILL)?;
286        }
287        self.reap()
288    }
289
290    /// Terminate the owned group (or internal direct child), then reap the leader.
291    ///
292    /// Repeated calls after reaping return the cached status and never signal a
293    /// reused PID. A prior group failure still matters even if reaping succeeded;
294    /// later calls cannot recover group ownership and do not erase that evidence.
295    /// With [`CleanupPolicy::TermThenKill`], successful TERM signalling is followed
296    /// by the full grace period without reaping, then KILL even if the leader has
297    /// exited. A TERM error does not prevent KILL or reaping. The reap allowance
298    /// begins after signalling; repeated termination and Drop never restart it or
299    /// repeat escalation. Reap timeout returns `TimedOut` in `wait_error` while
300    /// keeping the unreaped child owned. Callers may explicitly recover with
301    /// `try_wait`/`wait`; dropping after timeout can leave an unreaped child until
302    /// parent exit. No background reaper is installed. Scheduling and native
303    /// syscall latency are outside these polling bounds.
304    /// # Errors
305    /// Retains each failed cleanup step separately. Group failure triggers a
306    /// direct-child kill fallback. Drop cannot report errors; call this explicitly
307    /// when cleanup evidence matters.
308    pub fn terminate(&mut self) -> Result<ExitStatus, CleanupError> {
309        if let Some(status) = self.status {
310            return Ok(status);
311        }
312        let first_attempt = self.reap_started.is_none();
313        let term_error =
314            if first_attempt && let CleanupPolicy::TermThenKill { grace, .. } = self.cleanup {
315                let error = self.signal_group(Signal::TERM).err();
316                if error.is_none() {
317                    let started = Instant::now();
318                    while started.elapsed() < grace {
319                        std::thread::sleep(
320                            grace
321                                .saturating_sub(started.elapsed())
322                                .min(Duration::from_secs(1)),
323                        );
324                    }
325                }
326                error
327            } else {
328                None
329            };
330        let group_error = if first_attempt && self.group {
331            self.signal_group(Signal::KILL).err()
332        } else {
333            None
334        };
335        let kill_error = if first_attempt && self.owned && (!self.group || group_error.is_some()) {
336            retry_interrupted(|| self.child.kill()).err()
337        } else {
338            None
339        };
340        let waited = match self.cleanup {
341            CleanupPolicy::KillAndWait => self.reap(),
342            CleanupPolicy::TermThenKill { reap_timeout, .. } => {
343                let started = *self.reap_started.get_or_insert_with(Instant::now);
344                self.reap_bounded(started, reap_timeout)
345            }
346        };
347        match waited {
348            Ok(status) if term_error.is_none() && group_error.is_none() && kill_error.is_none() => {
349                Ok(status)
350            }
351            other => Err(CleanupError {
352                status: self.status,
353                term_error,
354                group_error,
355                kill_error,
356                wait_error: other.err(),
357            }),
358        }
359    }
360
361    fn pid(&self) -> io::Result<Pid> {
362        if !self.owned {
363            return Err(Errno::CHILD.into());
364        }
365        Pid::from_raw(i32::try_from(self.id()).map_err(io::Error::other)?)
366            .ok_or_else(|| io::Error::other("child PID is zero"))
367    }
368
369    fn observe_exit(&mut self, nonblocking: bool) -> io::Result<Option<WaitIdStatus>> {
370        let pid = self.pid()?;
371        // NOWAIT reserves the leader PID until cleanup or explicit handoff,
372        // avoiding signals to an unrelated group after an early leader exit.
373        let mut options = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT;
374        if nonblocking {
375            options |= WaitIdOptions::NOHANG;
376        }
377        let result = retry_interrupted(|| waitid(WaitId::Pid(pid), options).map_err(Into::into));
378        self.check_wait_ownership(&result);
379        result
380    }
381
382    #[cfg_attr(
383        not(target_os = "macos"),
384        allow(
385            clippy::needless_pass_by_ref_mut,
386            reason = "Darwin inspects and may invalidate child ownership"
387        )
388    )]
389    fn signal_group(&mut self, signal: Signal) -> io::Result<()> {
390        let pid = self.pid()?;
391        match retry_interrupted(|| kill_process_group(pid, signal).map_err(Into::into)) {
392            Ok(()) => Ok(()),
393            Err(error) if error.raw_os_error() == Some(Errno::SRCH.raw_os_error()) => Ok(()),
394            #[cfg(target_os = "macos")]
395            Err(error)
396                if error.raw_os_error() == Some(Errno::PERM.raw_os_error())
397                    && self.observe_exit(true)?.is_some()
398                    && macos::sole_group_member(pid) =>
399            {
400                Ok(())
401            }
402            Err(error) => Err(error),
403        }
404    }
405
406    fn reap(&mut self) -> io::Result<ExitStatus> {
407        if !self.owned {
408            return Err(Errno::CHILD.into());
409        }
410        let result = retry_interrupted(|| self.child.wait());
411        if let Ok(status) = result {
412            self.status = Some(status);
413            self.owned = false;
414        }
415        self.check_wait_ownership(&result);
416        result
417    }
418
419    fn reap_bounded(&mut self, started: Instant, timeout: Duration) -> io::Result<ExitStatus> {
420        if !self.owned {
421            return Err(Errno::CHILD.into());
422        }
423        loop {
424            // Never call blocking wait, including after an interrupted poll.
425            let result = self.child.try_wait();
426            self.check_wait_ownership(&result);
427            match result {
428                Ok(Some(status)) => {
429                    self.status = Some(status);
430                    self.owned = false;
431                    return Ok(status);
432                }
433                Ok(None) => {}
434                Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
435                Err(error) => return Err(error),
436            }
437            let remaining = timeout.saturating_sub(started.elapsed());
438            if remaining.is_zero() {
439                return Err(io::Error::new(
440                    io::ErrorKind::TimedOut,
441                    "child reap timed out",
442                ));
443            }
444            std::thread::sleep(remaining.min(Duration::from_millis(2)));
445        }
446    }
447
448    fn check_wait_ownership<T>(&mut self, result: &io::Result<T>) {
449        if result
450            .as_ref()
451            .is_err_and(|error| error.raw_os_error() == Some(Errno::CHILD.raw_os_error()))
452        {
453            // An external reaper violates exclusive ownership; never signal a
454            // potentially reused PID after observing that ownership was lost.
455            self.owned = false;
456        }
457    }
458}
459
460impl Drop for OwnedChild {
461    fn drop(&mut self) {
462        if self.owned {
463            let _ = self.terminate();
464        }
465    }
466}
467
468fn retry_interrupted<T>(mut operation: impl FnMut() -> io::Result<T>) -> io::Result<T> {
469    loop {
470        match operation() {
471            Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
472            result => return result,
473        }
474    }
475}