use std::future::Future;
use std::io;
use std::process::{Output, Stdio};
use std::time::Duration;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt};
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)]
pub enum CancellableCommandOutput {
Completed(Output),
Cancelled,
}
#[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} remained alive {grace:?} after SIGKILL; cancellation is not complete"
)]
GroupStillAlive {
process_group: i32,
grace: Duration,
},
#[error("{original}; mandatory process-group cleanup also failed: {cleanup}")]
CleanupAfterFailure {
original: Box<ProcessGroupError>,
cleanup: Box<ProcessGroupError>,
},
}
pub async fn run_cancellable_command<C>(
command: Command,
cancellation: C,
) -> Result<CancellableCommandOutput, ProcessGroupError>
where
C: Future<Output = ()>,
{
#[cfg(unix)]
{
run_unix(command, cancellation).await
}
#[cfg(not(unix))]
{
let _ = (command, cancellation);
Err(ProcessGroupError::Unsupported)
}
}
#[cfg(unix)]
async fn run_unix<C>(
mut command: Command,
cancellation: C,
) -> Result<CancellableCommandOutput, ProcessGroupError>
where
C: Future<Output = ()>,
{
use std::os::unix::process::CommandExt;
command.as_std_mut().process_group(0);
command
.kill_on_drop(true)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut 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 })?;
let process_group = nix::unistd::Pid::from_raw(process_group);
let mut guard = ProcessGroupGuard::new(process_group);
let stdout = child.stdout.take();
let stderr = child.stderr.take();
tokio::pin!(cancellation);
let state = {
let completion = collect_output(&mut child, stdout, stderr);
tokio::pin!(completion);
tokio::select! {
biased;
() = &mut cancellation => RunState::Cancelled,
result = &mut completion => RunState::Completed(result),
}
};
match state {
RunState::Completed(Ok(output)) => {
guard.disarm();
Ok(CancellableCommandOutput::Completed(output))
}
RunState::Completed(Err(original)) => {
match terminate_process_group(&mut child, process_group).await {
Ok(()) => {
guard.disarm();
Err(original)
}
Err(cleanup) => Err(ProcessGroupError::CleanupAfterFailure {
original: Box::new(original),
cleanup: Box::new(cleanup),
}),
}
}
RunState::Cancelled => {
terminate_process_group(&mut child, process_group).await?;
guard.disarm();
Ok(CancellableCommandOutput::Cancelled)
}
}
}
#[cfg(unix)]
enum RunState {
Completed(Result<Output, ProcessGroupError>),
Cancelled,
}
async fn collect_output(
child: &mut Child,
stdout: Option<ChildStdout>,
stderr: Option<ChildStderr>,
) -> Result<Output, ProcessGroupError> {
let wait = async {
child
.wait()
.await
.map_err(|source| ProcessGroupError::Reap { source })
};
let capture = async {
let stdout = stdout.ok_or(ProcessGroupError::MissingPipe { stream: "stdout" })?;
let stderr = stderr.ok_or(ProcessGroupError::MissingPipe { stream: "stderr" })?;
tokio::try_join!(read_stream(stdout, "stdout"), read_stream(stderr, "stderr"))
};
let (status, (stdout, stderr)) = tokio::try_join!(wait, capture)?;
Ok(Output {
status,
stdout,
stderr,
})
}
async fn read_stream<R>(mut stream: R, name: &'static str) -> Result<Vec<u8>, ProcessGroupError>
where
R: AsyncRead + Unpin,
{
let mut bytes = Vec::new();
stream
.read_to_end(&mut bytes)
.await
.map_err(|source| ProcessGroupError::Read {
stream: name,
source,
})?;
Ok(bytes)
}
#[cfg(unix)]
async fn terminate_process_group(
child: &mut Child,
process_group: nix::unistd::Pid,
) -> Result<(), ProcessGroupError> {
use nix::sys::signal::Signal;
signal_group(process_group, Signal::SIGTERM, "SIGTERM")?;
let deadline = tokio::time::Instant::now() + PROCESS_GROUP_TERMINATION_GRACE;
if wait_for_group_to_disappear(child, process_group, deadline).await? {
return Ok(());
}
signal_group(process_group, Signal::SIGKILL, "SIGKILL")?;
let deadline = tokio::time::Instant::now() + PROCESS_GROUP_TERMINATION_GRACE;
if wait_for_group_to_disappear(child, process_group, deadline).await? {
return Ok(());
}
Err(ProcessGroupError::GroupStillAlive {
process_group: process_group.as_raw(),
grace: PROCESS_GROUP_TERMINATION_GRACE,
})
}
#[cfg(unix)]
async fn wait_for_group_to_disappear(
child: &mut Child,
process_group: nix::unistd::Pid,
deadline: tokio::time::Instant,
) -> Result<bool, ProcessGroupError> {
loop {
child
.try_wait()
.map_err(|source| ProcessGroupError::Reap { source })?;
if group_is_gone(process_group)? {
return Ok(true);
}
let now = tokio::time::Instant::now();
if now >= deadline {
return Ok(false);
}
tokio::time::sleep(GROUP_PROBE_INTERVAL.min(deadline - now)).await;
}
}
#[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) => 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) => Ok(true),
Err(source) => Err(ProcessGroupError::Probe {
process_group: process_group.as_raw(),
source,
}),
}
}
#[cfg(unix)]
struct ProcessGroupGuard {
process_group: nix::unistd::Pid,
armed: bool,
}
#[cfg(unix)]
impl ProcessGroupGuard {
const fn new(process_group: nix::unistd::Pid) -> Self {
Self {
process_group,
armed: true,
}
}
const fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(unix)]
impl Drop for ProcessGroupGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
match nix::sys::signal::killpg(self.process_group, nix::sys::signal::Signal::SIGKILL) {
Ok(()) | Err(nix::errno::Errno::ESRCH) => {}
Err(source) => tracing::error!(
process_group = self.process_group.as_raw(),
%source,
"failed to kill contained process group while dropping its owner"
),
}
}
}