#[cfg(target_os = "macos")]
mod macos;
#[cfg(test)]
mod tests;
use rustix::{
io::Errno,
process::{Pid, Signal, WaitId, WaitIdOptions, WaitIdStatus, kill_process_group, waitid},
};
use std::{
fmt, io,
os::unix::process::{CommandExt, ExitStatusExt},
process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus},
};
pub struct OwnedChild {
child: Child,
group: bool,
status: Option<ExitStatus>,
owned: bool,
}
#[derive(Debug)]
pub struct CleanupError {
pub status: Option<ExitStatus>,
pub group_error: Option<io::Error>,
pub kill_error: Option<io::Error>,
pub wait_error: Option<io::Error>,
}
impl fmt::Display for CleanupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("child cleanup failed")?;
for (operation, error) in [
("group signal", &self.group_error),
("child kill", &self.kill_error),
("child wait", &self.wait_error),
] {
if let Some(error) = error {
write!(f, "; {operation}: {error}")?;
}
}
Ok(())
}
}
impl std::error::Error for CleanupError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.group_error
.as_ref()
.or(self.kill_error.as_ref())
.or(self.wait_error.as_ref())
.map(|error| error as &dyn std::error::Error)
}
}
impl OwnedChild {
pub fn spawn(command: &mut Command) -> io::Result<Self> {
command.process_group(0);
Self::spawn_inner(command, true)
}
pub(crate) fn spawn_direct(command: &mut Command) -> io::Result<Self> {
Self::spawn_inner(command, false)
}
fn spawn_inner(command: &mut Command, group: bool) -> io::Result<Self> {
command.spawn().map(|child| Self {
child,
group,
status: None,
owned: true,
})
}
#[must_use]
pub fn id(&self) -> u32 {
self.child.id()
}
pub const fn take_stdin(&mut self) -> Option<ChildStdin> {
self.child.stdin.take()
}
pub const fn take_stdout(&mut self) -> Option<ChildStdout> {
self.child.stdout.take()
}
pub const fn take_stderr(&mut self) -> Option<ChildStderr> {
self.child.stderr.take()
}
pub fn poll_exit(&mut self) -> io::Result<Option<ExitStatus>> {
if let Some(status) = self.status {
return Ok(Some(status));
}
self.observe_exit(true)?
.map(|status| {
let raw = if let Some(code) = status.exit_status() {
code << 8
} else if let Some(signal) = status.terminating_signal() {
signal | if status.dumped() { 0x80 } else { 0 }
} else {
return Err(io::Error::other("waitid returned a non-exit observation"));
};
Ok(ExitStatus::from_raw(raw))
})
.transpose()
}
pub fn handoff(&mut self) -> io::Result<ExitStatus> {
if !self.owned || !self.poll_exit()?.is_some_and(|status| status.success()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"handoff requires an owned, successfully exited leader",
));
}
self.reap()
}
pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
if let Some(status) = self.status {
return Ok(Some(status));
}
if !self.owned {
return Err(Errno::CHILD.into());
}
if self.group {
if self.observe_exit(true)?.is_none() {
return Ok(None);
}
self.signal_group()?;
self.reap().map(Some)
} else {
let result = retry_interrupted(|| self.child.try_wait());
if let Ok(Some(status)) = result {
self.status = Some(status);
self.owned = false;
}
self.check_wait_ownership(&result);
result
}
}
pub fn wait(&mut self) -> io::Result<ExitStatus> {
if let Some(status) = self.status {
return Ok(status);
}
if self.group {
self.observe_exit(false)?;
self.signal_group()?;
}
self.reap()
}
pub fn terminate(&mut self) -> Result<ExitStatus, CleanupError> {
if let Some(status) = self.status {
return Ok(status);
}
let group_error = if self.group {
self.signal_group().err()
} else {
None
};
let kill_error = if self.owned && (!self.group || group_error.is_some()) {
retry_interrupted(|| self.child.kill()).err()
} else {
None
};
let waited = self.reap();
match waited {
Ok(status) if group_error.is_none() && kill_error.is_none() => Ok(status),
other => Err(CleanupError {
status: self.status,
group_error,
kill_error,
wait_error: other.err(),
}),
}
}
fn pid(&self) -> io::Result<Pid> {
if !self.owned {
return Err(Errno::CHILD.into());
}
Pid::from_raw(i32::try_from(self.id()).map_err(io::Error::other)?)
.ok_or_else(|| io::Error::other("child PID is zero"))
}
fn observe_exit(&mut self, nonblocking: bool) -> io::Result<Option<WaitIdStatus>> {
let pid = self.pid()?;
let mut options = WaitIdOptions::EXITED | WaitIdOptions::NOWAIT;
if nonblocking {
options |= WaitIdOptions::NOHANG;
}
let result = retry_interrupted(|| waitid(WaitId::Pid(pid), options).map_err(Into::into));
self.check_wait_ownership(&result);
result
}
#[cfg_attr(
not(target_os = "macos"),
allow(
clippy::needless_pass_by_ref_mut,
reason = "Darwin inspects and may invalidate child ownership"
)
)]
fn signal_group(&mut self) -> io::Result<()> {
let pid = self.pid()?;
match retry_interrupted(|| kill_process_group(pid, Signal::KILL).map_err(Into::into)) {
Ok(()) => Ok(()),
Err(error) if error.raw_os_error() == Some(Errno::SRCH.raw_os_error()) => Ok(()),
#[cfg(target_os = "macos")]
Err(error)
if error.raw_os_error() == Some(Errno::PERM.raw_os_error())
&& self.observe_exit(true)?.is_some()
&& macos::sole_group_member(pid) =>
{
Ok(())
}
Err(error) => Err(error),
}
}
fn reap(&mut self) -> io::Result<ExitStatus> {
if !self.owned {
return Err(Errno::CHILD.into());
}
let result = retry_interrupted(|| self.child.wait());
if let Ok(status) = result {
self.status = Some(status);
self.owned = false;
}
self.check_wait_ownership(&result);
result
}
fn check_wait_ownership<T>(&mut self, result: &io::Result<T>) {
if result
.as_ref()
.is_err_and(|error| error.raw_os_error() == Some(Errno::CHILD.raw_os_error()))
{
self.owned = false;
}
}
}
impl Drop for OwnedChild {
fn drop(&mut self) {
if self.owned {
let _ = self.terminate();
}
}
}
fn retry_interrupted<T>(mut operation: impl FnMut() -> io::Result<T>) -> io::Result<T> {
loop {
match operation() {
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
result => return result,
}
}
}