use std::future::Future;
use std::process::{Output, Stdio};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::{ChildStderr, ChildStdout, Command};
use super::contained::{ContainedChild, PROCESS_GROUP_TERMINATION_GRACE, ProcessGroupError};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandStream {
Stdout,
Stderr,
}
impl CommandStream {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::Stderr => "stderr",
}
}
}
pub trait CommandOutputObserver: Send + Sync {
fn on_line(&self, stream: CommandStream, line: &str);
}
#[derive(Debug)]
pub enum CancellableCommandOutput {
Completed(Output),
Cancelled,
}
pub async fn run_cancellable_command<C, O>(
mut command: Command,
cancellation: C,
observer: &O,
) -> Result<CancellableCommandOutput, ProcessGroupError>
where
C: Future<Output = ()>,
O: CommandOutputObserver + ?Sized,
{
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut contained = ContainedChild::spawn(command)?;
let stdout = contained.take_stdout();
let stderr = contained.take_stderr();
tokio::pin!(cancellation);
let state = {
let completion = collect_output(&mut contained, stdout, stderr, observer);
tokio::pin!(completion);
tokio::select! {
biased;
() = &mut cancellation => RunState::Cancelled,
result = &mut completion => RunState::Completed(result),
}
};
match state {
RunState::Completed(Ok(output)) => {
contained.disarm();
Ok(CancellableCommandOutput::Completed(output))
}
RunState::Completed(Err(original)) => match stop_and_confirm(&mut contained).await {
Ok(()) => Err(original),
Err(cleanup) => Err(ProcessGroupError::CleanupAfterFailure {
original: Box::new(original),
cleanup: Box::new(cleanup),
}),
},
RunState::Cancelled => {
stop_and_confirm(&mut contained).await?;
Ok(CancellableCommandOutput::Cancelled)
}
}
}
async fn stop_and_confirm(contained: &mut ContainedChild) -> Result<(), ProcessGroupError> {
contained.terminate(PROCESS_GROUP_TERMINATION_GRACE).await?;
contained
.confirm_group_gone(PROCESS_GROUP_TERMINATION_GRACE)
.await
}
enum RunState {
Completed(Result<Output, ProcessGroupError>),
Cancelled,
}
async fn collect_output<O>(
contained: &mut ContainedChild,
stdout: Option<ChildStdout>,
stderr: Option<ChildStderr>,
observer: &O,
) -> Result<Output, ProcessGroupError>
where
O: CommandOutputObserver + ?Sized,
{
let capture = async {
let stdout = stdout.ok_or(ProcessGroupError::MissingPipe {
stream: CommandStream::Stdout.name(),
})?;
let stderr = stderr.ok_or(ProcessGroupError::MissingPipe {
stream: CommandStream::Stderr.name(),
})?;
tokio::try_join!(
read_stream(stdout, CommandStream::Stdout, observer),
read_stream(stderr, CommandStream::Stderr, observer)
)
};
let (status, (stdout, stderr)) = tokio::try_join!(contained.wait(), capture)?;
Ok(Output {
status,
stdout,
stderr,
})
}
async fn read_stream<R, O>(
stream: R,
which: CommandStream,
observer: &O,
) -> Result<Vec<u8>, ProcessGroupError>
where
R: AsyncRead + Unpin,
O: CommandOutputObserver + ?Sized,
{
let mut reader = BufReader::new(stream);
let mut bytes = Vec::new();
loop {
let line_start = bytes.len();
let read = reader
.read_until(b'\n', &mut bytes)
.await
.map_err(|source| ProcessGroupError::Read {
stream: which.name(),
source,
})?;
if read == 0 {
return Ok(bytes);
}
let line = bytes.get(line_start..).unwrap_or_default();
observer.on_line(which, &String::from_utf8_lossy(strip_line_ending(line)));
}
}
fn strip_line_ending(line: &[u8]) -> &[u8] {
let line = line.strip_suffix(b"\n").unwrap_or(line);
line.strip_suffix(b"\r").unwrap_or(line)
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
use super::{
CancellableCommandOutput, CommandOutputObserver, CommandStream, read_stream,
run_cancellable_command,
};
use tokio::process::Command;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[derive(Default)]
struct Recorder {
lines: Mutex<Vec<(CommandStream, String)>>,
}
impl Recorder {
fn lines(&self) -> Vec<(CommandStream, String)> {
self.lines
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn on(&self, stream: CommandStream) -> Vec<String> {
self.lines()
.into_iter()
.filter(|(observed, _)| *observed == stream)
.map(|(_, line)| line)
.collect()
}
fn saw(&self, stream: CommandStream, line: &str) -> bool {
self.on(stream).iter().any(|observed| observed == line)
}
}
impl CommandOutputObserver for Recorder {
fn on_line(&self, stream: CommandStream, line: &str) {
self.lines
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push((stream, line.to_owned()));
}
}
#[tokio::test]
async fn output_lines_are_observed_before_the_command_exits() -> TestResult {
let recorder = Recorder::default();
let mut command = Command::new("sh");
command.arg("-c").arg("echo out; echo err >&2; sleep 30");
let run = run_cancellable_command(command, std::future::pending::<()>(), &recorder);
tokio::pin!(run);
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
tokio::select! {
biased;
result = &mut run => {
drop(result);
return Err(
"the command ran to completion before its output was observed".into(),
);
}
() = tokio::time::sleep(Duration::from_millis(5)) => {
if recorder.saw(CommandStream::Stdout, "out")
&& recorder.saw(CommandStream::Stderr, "err")
{
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"the running command's output never arrived: {:?}",
recorder.lines()
)
.into());
}
}
}
}
}
#[tokio::test]
async fn the_completion_capture_is_unchanged_by_streaming() -> TestResult {
let recorder = Recorder::default();
let mut command = Command::new("sh");
command
.arg("-c")
.arg("printf 'first\\nsecond\\n'; printf 'warned\\n' >&2");
let outcome =
run_cancellable_command(command, std::future::pending::<()>(), &recorder).await?;
let CancellableCommandOutput::Completed(output) = outcome else {
return Err("the command must complete".into());
};
assert_eq!(output.stdout, b"first\nsecond\n");
assert_eq!(output.stderr, b"warned\n");
assert_eq!(recorder.on(CommandStream::Stdout), vec!["first", "second"]);
assert_eq!(recorder.on(CommandStream::Stderr), vec!["warned"]);
Ok(())
}
#[tokio::test]
async fn every_line_shape_is_delivered_and_the_bytes_are_returned_verbatim() -> TestResult {
let source: &[u8] = b"plain\ncrlf\r\n\n\xffbad\nno trailing newline";
let recorder = Recorder::default();
let bytes = read_stream(source, CommandStream::Stdout, &recorder).await?;
assert_eq!(bytes, source, "the capture returns every byte, untouched");
assert_eq!(
recorder.on(CommandStream::Stdout),
vec![
"plain".to_owned(),
"crlf".to_owned(),
String::new(),
String::from_utf8_lossy(b"\xffbad").into_owned(),
"no trailing newline".to_owned(),
]
);
Ok(())
}
#[tokio::test]
async fn an_empty_stream_observes_nothing() -> TestResult {
let recorder = Recorder::default();
let bytes = read_stream(&b""[..], CommandStream::Stderr, &recorder).await?;
assert!(bytes.is_empty());
assert!(recorder.lines().is_empty());
Ok(())
}
}