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}