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