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