use std::io;
use std::process::ExitStatus;
use std::time::Duration;
use thiserror::Error;
use tokio::process::{Child, ChildStderr, ChildStdout, Command};
pub const PROCESS_GROUP_TERMINATION_GRACE: Duration = Duration::from_secs(2);
const GROUP_PROBE_INTERVAL: Duration = Duration::from_millis(10);
#[derive(Debug, Error)]
pub enum ProcessGroupError {
#[error("process-group containment is unsupported on this operating system")]
Unsupported,
#[error("contained command could not be spawned: {source}")]
Spawn {
#[source]
source: io::Error,
},
#[error("contained command spawned without a process id")]
MissingProcessId,
#[error("contained command process id {pid} is outside the supported range")]
ProcessIdOutOfRange {
pid: u32,
},
#[error("contained command did not expose its piped {stream}")]
MissingPipe {
stream: &'static str,
},
#[error("failed to read contained command {stream}: {source}")]
Read {
stream: &'static str,
#[source]
source: io::Error,
},
#[error("failed to reap contained command: {source}")]
Reap {
#[source]
source: io::Error,
},
#[cfg(unix)]
#[error("failed to send {signal} to process group {process_group}: {source}")]
Signal {
process_group: i32,
signal: &'static str,
#[source]
source: nix::errno::Errno,
},
#[cfg(unix)]
#[error("failed to probe process group {process_group}: {source}")]
Probe {
process_group: i32,
#[source]
source: nix::errno::Errno,
},
#[error(
"process group {process_group} was still observable {grace:?} after termination; \
it could not be confirmed empty"
)]
GroupStillAlive {
process_group: i32,
grace: Duration,
},
#[error("{original}; mandatory process-group cleanup also failed: {cleanup}")]
CleanupAfterFailure {
original: Box<ProcessGroupError>,
cleanup: Box<ProcessGroupError>,
},
}
#[cfg(unix)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ProcessGroupId(nix::unistd::Pid);
#[cfg(not(unix))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ProcessGroupId;
#[cfg(unix)]
impl ProcessGroupId {
#[must_use]
pub const fn as_raw(self) -> i32 {
self.0.as_raw()
}
}
#[derive(Debug)]
pub struct ContainedChild {
child: Child,
process_group: ProcessGroupId,
armed: bool,
reaped: bool,
exit_status: Option<ExitStatus>,
}
impl ContainedChild {
pub fn spawn(command: Command) -> Result<Self, ProcessGroupError> {
#[cfg(unix)]
{
spawn_unix(command)
}
#[cfg(not(unix))]
{
drop(command);
Err(ProcessGroupError::Unsupported)
}
}
#[must_use]
pub fn id(&self) -> Option<u32> {
self.child.id()
}
#[must_use]
pub const fn process_group_id(&self) -> Option<i32> {
#[cfg(unix)]
{
Some(self.process_group.as_raw())
}
#[cfg(not(unix))]
{
None
}
}
#[must_use]
pub const fn exit_status(&self) -> Option<ExitStatus> {
self.exit_status
}
pub fn take_stdout(&mut self) -> Option<ChildStdout> {
self.child.stdout.take()
}
pub fn take_stderr(&mut self) -> Option<ChildStderr> {
self.child.stderr.take()
}
pub async fn wait(&mut self) -> Result<ExitStatus, ProcessGroupError> {
let status = self
.child
.wait()
.await
.map_err(|source| ProcessGroupError::Reap { source })?;
self.reaped = true;
self.exit_status = Some(status);
Ok(status)
}
pub async fn terminate(&mut self, grace: Duration) -> Result<(), ProcessGroupError> {
#[cfg(unix)]
{
if !self.reaped {
signal_group(
self.process_group.0,
nix::sys::signal::Signal::SIGTERM,
"SIGTERM",
)?;
tokio::time::sleep(grace).await;
signal_group(
self.process_group.0,
nix::sys::signal::Signal::SIGKILL,
"SIGKILL",
)?;
self.wait().await?;
}
self.armed = false;
Ok(())
}
#[cfg(not(unix))]
{
drop(grace);
Err(ProcessGroupError::Unsupported)
}
}
pub async fn confirm_group_gone(&self, within: Duration) -> Result<(), ProcessGroupError> {
#[cfg(unix)]
{
let deadline = tokio::time::Instant::now() + within;
loop {
if group_is_gone(self.process_group.0)? {
return Ok(());
}
let now = tokio::time::Instant::now();
if now >= deadline {
return Err(ProcessGroupError::GroupStillAlive {
process_group: self.process_group.0.as_raw(),
grace: within,
});
}
tokio::time::sleep(GROUP_PROBE_INTERVAL.min(deadline - now)).await;
}
}
#[cfg(not(unix))]
{
drop(within);
Err(ProcessGroupError::Unsupported)
}
}
pub const fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for ContainedChild {
fn drop(&mut self) {
if !self.armed || self.reaped {
return;
}
#[cfg(unix)]
match nix::sys::signal::killpg(self.process_group.0, nix::sys::signal::Signal::SIGKILL) {
Ok(()) | Err(nix::errno::Errno::ESRCH) => {}
Err(source) => tracing::error!(
process_group = self.process_group.0.as_raw(),
%source,
"failed to kill contained process group while dropping its owner"
),
}
}
}
#[cfg(unix)]
fn spawn_unix(mut command: Command) -> Result<ContainedChild, ProcessGroupError> {
use std::os::unix::process::CommandExt;
command.as_std_mut().process_group(0);
command.kill_on_drop(true);
let child = command
.spawn()
.map_err(|source| ProcessGroupError::Spawn { source })?;
let raw_pid = child.id().ok_or(ProcessGroupError::MissingProcessId)?;
let process_group = i32::try_from(raw_pid)
.map_err(|_| ProcessGroupError::ProcessIdOutOfRange { pid: raw_pid })?;
Ok(ContainedChild {
child,
process_group: ProcessGroupId(nix::unistd::Pid::from_raw(process_group)),
armed: true,
reaped: false,
exit_status: None,
})
}
#[cfg(unix)]
fn signal_group(
process_group: nix::unistd::Pid,
signal: nix::sys::signal::Signal,
signal_name: &'static str,
) -> Result<(), ProcessGroupError> {
match nix::sys::signal::killpg(process_group, signal) {
Ok(()) | Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => Ok(()),
Err(source) => Err(ProcessGroupError::Signal {
process_group: process_group.as_raw(),
signal: signal_name,
source,
}),
}
}
#[cfg(unix)]
fn group_is_gone(process_group: nix::unistd::Pid) -> Result<bool, ProcessGroupError> {
match nix::sys::signal::killpg(process_group, None::<nix::sys::signal::Signal>) {
Ok(()) => Ok(false),
Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => Ok(true),
Err(source) => Err(ProcessGroupError::Probe {
process_group: process_group.as_raw(),
source,
}),
}
}
#[cfg(test)]
mod tests {
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use super::{ContainedChild, PROCESS_GROUP_TERMINATION_GRACE};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn shell(script: &str) -> Command {
let mut command = Command::new("/bin/sh");
command.args(["-c", script]);
command.stdout(Stdio::null()).stderr(Stdio::null());
command
}
fn group_alive(process_group: i32) -> bool {
std::process::Command::new("/bin/sh")
.args(["-c", &format!("kill -0 -{process_group} 2>/dev/null")])
.status()
.is_ok_and(|status| status.success())
}
#[tokio::test]
async fn terminate_takes_the_whole_group() -> TestResult {
let mut child = ContainedChild::spawn(shell("sleep 300 & sleep 300"))?;
let group = child
.process_group_id()
.ok_or("a spawned child leads a group")?;
assert!(group_alive(group));
child.terminate(Duration::from_millis(20)).await?;
child
.confirm_group_gone(PROCESS_GROUP_TERMINATION_GRACE)
.await?;
assert!(!group_alive(group), "the group survived a confirmed stop");
assert!(
child.exit_status().is_some(),
"terminate must reap the leader it killed"
);
Ok(())
}
#[tokio::test]
async fn a_reaped_child_is_never_signalled_again() -> TestResult {
let mut child = ContainedChild::spawn(shell("exit 0"))?;
let status = child.wait().await?;
assert_eq!(status.code(), Some(0));
let started = std::time::Instant::now();
child.terminate(Duration::from_secs(30)).await?;
assert!(
started.elapsed() < Duration::from_secs(5),
"a reaped child must skip the ladder entirely, not sleep through it"
);
Ok(())
}
#[tokio::test]
async fn the_exit_status_is_recorded_by_whichever_call_reaped() -> TestResult {
let mut child = ContainedChild::spawn(shell("exit 5"))?;
assert_eq!(child.wait().await?.code(), Some(5));
assert_eq!(
child.exit_status().and_then(|status| status.code()),
Some(5)
);
child.terminate(Duration::from_millis(20)).await?;
assert_eq!(
child.exit_status().and_then(|status| status.code()),
Some(5),
"a terminate after the reap must not disturb the recorded status"
);
Ok(())
}
}