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