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| Self {
193 child,
194 group,
195 status: None,
196 owned: true,
197 cleanup,
198 reap_started: None,
199 })
200 }
201
202 /// Direct-child PID, for observation only; it can be reused after reaping.
203 #[must_use]
204 pub fn id(&self) -> u32 {
205 self.child.id()
206 }
207
208 /// Take caller-configured piped stdin. Close it before waiting for EOF-driven children.
209 pub const fn take_stdin(&mut self) -> Option<ChildStdin> {
210 self.child.stdin.take()
211 }
212
213 /// Take caller-configured piped stdout; the caller owns draining and bounds.
214 pub const fn take_stdout(&mut self) -> Option<ChildStdout> {
215 self.child.stdout.take()
216 }
217
218 /// Take caller-configured piped stderr; the caller owns draining and bounds.
219 pub const fn take_stderr(&mut self) -> Option<ChildStderr> {
220 self.child.stderr.take()
221 }
222
223 /// Observe leader exit without signalling or reaping it.
224 ///
225 /// An exited leader stays reserved with `WNOWAIT`, so cancellation, failed IO
226 /// admission and unwinding still clean its group. Repeated observations do
227 /// not release ownership. After a completed wait/termination/handoff, returns
228 /// the cached status. This status alone does not establish a handoff.
229 ///
230 /// Call [`Self::handoff`] only after admitting a successful background start.
231 /// Otherwise use [`Self::wait`] or [`Self::terminate`] to clean and reap.
232 /// # Errors
233 /// Returns native inspection errors; external reaping invalidates ownership.
234 pub fn poll_exit(&mut self) -> io::Result<Option<ExitStatus>> {
235 if let Some(status) = self.status {
236 return Ok(Some(status));
237 }
238 self.observe_exit(true)?
239 .map(|status| {
240 // Unix wait status encoding used by Linux and Darwin. waitid's
241 // siginfo status is an exit code/signal, not an encoded wait status.
242 let raw = if let Some(code) = status.exit_status() {
243 code << 8
244 } else if let Some(signal) = status.terminating_signal() {
245 signal | if status.dumped() { 0x80 } else { 0 }
246 } else {
247 return Err(io::Error::other("waitid returned a non-exit observation"));
248 };
249 Ok(ExitStatus::from_raw(raw))
250 })
251 .transpose()
252 }
253
254 /// Reap an already successful leader without signalling its remaining group.
255 ///
256 /// This is the explicit transfer point for a background lifetime. The caller
257 /// must first admit its IO/result and arrange application-owned readiness,
258 /// cancellation and stop/recovery. Zero exit does not prove those obligations.
259 /// No PID/group handle is transferred: it could be reused after reaping.
260 /// Subsequent wait/termination/Drop never signal the handed-off group.
261 ///
262 /// Use [`Self::poll_exit`] while draining IO and checking cancellation. A
263 /// running or unsuccessful leader is refused without releasing ownership;
264 /// use ordinary wait/termination for failed startup, keeping its original
265 /// failure separate from any cleanup error. Drop still attempts cleanup.
266 /// # Errors
267 /// Returns `InvalidInput` for a running, unsuccessful, terminating or already-reaped
268 /// leader, or native inspection/reap errors. Reap failures retain cleanup
269 /// ownership unless it was lost externally.
270 pub fn handoff(&mut self) -> io::Result<ExitStatus> {
271 if !self.is_owned() || !self.poll_exit()?.is_some_and(|status| status.success()) {
272 return Err(io::Error::new(
273 io::ErrorKind::InvalidInput,
274 "handoff requires an owned, successfully exited leader",
275 ));
276 }
277 self.reap()
278 }
279
280 /// Inspect exit without blocking on a running child; clean its group before reaping.
281 ///
282 /// Repeated successful calls return the cached status without signalling again.
283 /// # Errors
284 /// Returns native inspection, group-signal or reap errors. The leader remains
285 /// reserved on a group-signal failure, so explicit cleanup can still be attempted.
286 pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
287 if let Some(status) = self.status {
288 return Ok(Some(status));
289 }
290 if !self.owned {
291 return Err(Errno::CHILD.into());
292 }
293 if self.group {
294 if self.observe_exit(true)?.is_none() {
295 return Ok(None);
296 }
297 self.signal_group(Signal::KILL)?;
298 self.reap().map(Some)
299 } else {
300 let result = retry_interrupted(|| self.child.try_wait());
301 if let Ok(Some(status)) = result {
302 self.status = Some(status);
303 self.owned = false;
304 }
305 self.check_wait_ownership(&result);
306 result
307 }
308 }
309
310 /// Wait for natural leader exit, then clean its group and reap the leader.
311 ///
312 /// Close/drain caller-owned pipes as needed before waiting. No deadline or
313 /// cancellation policy is installed; callers may use polling instead.
314 /// # Errors
315 /// Returns native inspection, group-signal or reap errors.
316 pub fn wait(&mut self) -> io::Result<ExitStatus> {
317 if let Some(status) = self.status {
318 return Ok(status);
319 }
320 if self.group {
321 self.observe_exit(false)?;
322 self.signal_group(Signal::KILL)?;
323 }
324 self.reap()
325 }
326
327 /// Terminate the owned group or direct child, then reap the child.
328 ///
329 /// Repeated calls after reaping return the cached status and never signal a
330 /// reused PID. A prior group failure still matters even if reaping succeeded;
331 /// later calls cannot recover group ownership and do not erase that evidence.
332 /// With [`CleanupPolicy::TermThenKill`], successful TERM signalling is followed
333 /// by the full grace period without reaping, then KILL even if the leader has
334 /// exited. A TERM error does not prevent KILL or reaping. The reap allowance
335 /// begins after signalling; repeated termination and Drop never restart it or
336 /// repeat escalation. Reap timeout returns `TimedOut` in `wait_error` while
337 /// keeping the unreaped child owned. Callers may explicitly recover with
338 /// `try_wait`/`wait`; dropping after timeout can leave an unreaped child until
339 /// parent exit. No background reaper is installed. Scheduling and native
340 /// syscall latency are outside these polling bounds.
341 /// # Errors
342 /// Retains each failed cleanup step separately. Group failure triggers a
343 /// direct-child kill fallback. Drop cannot report errors; call this explicitly
344 /// when cleanup evidence matters.
345 pub fn terminate(&mut self) -> Result<ExitStatus, CleanupError> {
346 if let Some(status) = self.status {
347 return Ok(status);
348 }
349 let first_attempt = self.reap_started.is_none();
350 let term_error =
351 if first_attempt && let CleanupPolicy::TermThenKill { grace, .. } = self.cleanup {
352 let error = self.signal_group(Signal::TERM).err();
353 if error.is_none() {
354 let started = Instant::now();
355 while started.elapsed() < grace {
356 std::thread::sleep(
357 grace
358 .saturating_sub(started.elapsed())
359 .min(Duration::from_secs(1)),
360 );
361 }
362 }
363 error
364 } else {
365 None
366 };
367 let group_error = if first_attempt && self.group {
368 self.signal_group(Signal::KILL).err()
369 } else {
370 None
371 };
372 let kill_error = if first_attempt && self.owned && (!self.group || group_error.is_some()) {
373 retry_interrupted(|| self.child.kill()).err()
374 } else {
375 None
376 };
377 let waited = match self.cleanup {
378 CleanupPolicy::KillAndWait => self.reap(),
379 CleanupPolicy::TermThenKill { reap_timeout, .. } => {
380 let started = *self.reap_started.get_or_insert_with(Instant::now);
381 self.reap_bounded(started, reap_timeout)
382 }
383 };
384 match waited {
385 Ok(status) if term_error.is_none() && group_error.is_none() && kill_error.is_none() => {
386 Ok(status)
387 }
388 other => Err(CleanupError {
389 status: self.status,
390 term_error,
391 group_error,
392 kill_error,
393 wait_error: other.err(),
394 }),
395 }
396 }
397
398 fn pid(&self) -> io::Result<Pid> {
399 if !self.owned {
400 return Err(Errno::CHILD.into());
401 }
402 Pid::from_raw(i32::try_from(self.id()).map_err(io::Error::other)?)
403 .ok_or_else(|| io::Error::other("child PID is zero"))
404 }
405
406 fn observe_exit(&mut self, nonblocking: bool) -> io::Result<Option<WaitIdStatus>> {
407 let pid = self.pid()?;
408 // NOWAIT reserves the leader PID until cleanup or explicit handoff,
409 // avoiding signals to an unrelated group after an early leader exit.
410 let mut options = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT;
411 if nonblocking {
412 options |= WaitIdOptions::NOHANG;
413 }
414 let result = retry_interrupted(|| waitid(WaitId::Pid(pid), options).map_err(Into::into));
415 self.check_wait_ownership(&result);
416 result
417 }
418
419 #[cfg_attr(
420 not(target_os = "macos"),
421 allow(
422 clippy::needless_pass_by_ref_mut,
423 reason = "Darwin inspects and may invalidate child ownership"
424 )
425 )]
426 fn signal_group(&mut self, signal: Signal) -> io::Result<()> {
427 let pid = self.pid()?;
428 match retry_interrupted(|| kill_process_group(pid, signal).map_err(Into::into)) {
429 Ok(()) => Ok(()),
430 Err(error) if error.raw_os_error() == Some(Errno::SRCH.raw_os_error()) => Ok(()),
431 #[cfg(target_os = "macos")]
432 Err(error)
433 if error.raw_os_error() == Some(Errno::PERM.raw_os_error())
434 && self.observe_exit(true)?.is_some()
435 && macos::sole_group_member(pid) =>
436 {
437 Ok(())
438 }
439 Err(error) => Err(error),
440 }
441 }
442
443 fn reap(&mut self) -> io::Result<ExitStatus> {
444 if !self.owned {
445 return Err(Errno::CHILD.into());
446 }
447 let result = retry_interrupted(|| self.child.wait());
448 if let Ok(status) = result {
449 self.status = Some(status);
450 self.owned = false;
451 }
452 self.check_wait_ownership(&result);
453 result
454 }
455
456 fn reap_bounded(&mut self, started: Instant, timeout: Duration) -> io::Result<ExitStatus> {
457 if !self.owned {
458 return Err(Errno::CHILD.into());
459 }
460 loop {
461 // Never call blocking wait, including after an interrupted poll.
462 let result = self.child.try_wait();
463 self.check_wait_ownership(&result);
464 match result {
465 Ok(Some(status)) => {
466 self.status = Some(status);
467 self.owned = false;
468 return Ok(status);
469 }
470 Ok(None) => {}
471 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
472 Err(error) => return Err(error),
473 }
474 let remaining = timeout.saturating_sub(started.elapsed());
475 if remaining.is_zero() {
476 return Err(io::Error::new(
477 io::ErrorKind::TimedOut,
478 "child reap timed out",
479 ));
480 }
481 std::thread::sleep(remaining.min(Duration::from_millis(2)));
482 }
483 }
484
485 fn check_wait_ownership<T>(&mut self, result: &io::Result<T>) {
486 if result
487 .as_ref()
488 .is_err_and(|error| error.raw_os_error() == Some(Errno::CHILD.raw_os_error()))
489 {
490 // An external reaper violates exclusive ownership; never signal a
491 // potentially reused PID after observing that ownership was lost.
492 self.owned = false;
493 }
494 }
495}
496
497impl Drop for OwnedChild {
498 fn drop(&mut self) {
499 if self.owned {
500 let _ = self.terminate();
501 }
502 }
503}
504
505fn retry_interrupted<T>(mut operation: impl FnMut() -> io::Result<T>) -> io::Result<T> {
506 loop {
507 match operation() {
508 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
509 result => return result,
510 }
511 }
512}