use std::time::{Duration, Instant};
use bon::Builder;
pub use kawauso_process::error::{RequireSuccessError, RunCommandError};
pub use kawauso_process::execution::Capture;
pub use kawauso_process::invocation::{Argument, Program, WorkingDirectory};
pub use kawauso_process::run::{Line, Stream, Text};
pub use kawauso_process::{Execution, Invocation, ProcessId, Run};
pub use self::error::RunProcessError;
use crate::cancellation::Cancellation;
use crate::event::SendError;
use crate::event::process::{Outcome, ProcessEvent, RunId};
use crate::output::Output;
const DEFAULT_GRACE_PERIOD: Duration = Duration::from_secs(5);
mod error;
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
enum Streamed {
Cancelled,
Closed,
}
#[derive(Clone, Debug, Builder)]
pub struct Process {
output: Output,
#[builder(default)]
cancellation: Cancellation,
#[builder(default = DEFAULT_GRACE_PERIOD)]
grace_period: Duration,
}
impl Process {
pub async fn run(&self, invocation: Invocation) -> Result<Execution, RunProcessError> {
let started = Instant::now();
let id = RunId::next();
let mut run = invocation
.start()
.map_err(|source| RunProcessError::UnrunnableCommand { source })?;
let event = ProcessEvent::Started {
id,
invocation: invocation.clone(),
process_id: run.id(),
};
self.report(&invocation, event).await?;
let streamed = self.stream(id, &invocation, &mut run).await;
match streamed {
Ok(Streamed::Closed) => self.complete(id, invocation, run, started).await,
Ok(Streamed::Cancelled) => Err(self.cancel(id, invocation, run, started).await),
Err(error) => {
drop(run.stop(self.grace_period).await);
drop(
self.end(id, &invocation, Outcome::Incomplete, started)
.await,
);
Err(error)
}
}
}
async fn cancel(
&self,
id: RunId,
invocation: Invocation,
run: Run,
started: Instant,
) -> RunProcessError {
drop(run.stop(self.grace_period).await);
drop(self.end(id, &invocation, Outcome::Cancelled, started).await);
RunProcessError::CancelledRun { invocation }
}
async fn stream(
&self,
id: RunId,
invocation: &Invocation,
run: &mut Run,
) -> Result<Streamed, RunProcessError> {
loop {
let line = tokio::select! {
biased;
() = self.cancellation.cancelled() => return Ok(Streamed::Cancelled),
line = run.next_line() => line,
};
let line = line.map_err(|source| RunProcessError::UnrunnableCommand { source })?;
let Some(line) = line else {
return Ok(Streamed::Closed);
};
self.report(invocation, ProcessEvent::Line { id, line })
.await?;
}
}
async fn complete(
&self,
id: RunId,
invocation: Invocation,
mut run: Run,
started: Instant,
) -> Result<Execution, RunProcessError> {
let ended = tokio::select! {
biased;
() = self.cancellation.cancelled() => None,
ended = run.wait_for_end() => Some(ended),
};
let Some(ended) = ended else {
return Err(self.cancel(id, invocation, run, started).await);
};
let waited = match ended {
Ok(()) => run.wait().await,
Err(source) => {
drop(run.stop(self.grace_period).await);
Err(source)
}
};
let execution = match waited {
Ok(execution) => execution,
Err(source) => {
drop(
self.end(id, &invocation, Outcome::Incomplete, started)
.await,
);
return Err(RunProcessError::UnrunnableCommand { source });
}
};
let outcome = Outcome::Exited(execution.status());
self.end(id, &invocation, outcome, started)
.await
.map_err(|source| RunProcessError::UnreportableOutput { invocation, source })?;
Ok(execution)
}
async fn end(
&self,
id: RunId,
invocation: &Invocation,
outcome: Outcome,
started: Instant,
) -> Result<(), SendError> {
let event = ProcessEvent::Finished {
id,
invocation: invocation.clone(),
outcome,
duration: started.elapsed(),
};
self.output.process_event(event).await
}
async fn report(
&self,
invocation: &Invocation,
event: ProcessEvent,
) -> Result<(), RunProcessError> {
self.output.process_event(event).await.map_err(|source| {
RunProcessError::UnreportableOutput {
invocation: invocation.clone(),
source,
}
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::missing_panics_doc)]
#[cfg(unix)]
use std::fs::read_to_string;
use std::time::Duration;
use kawauso_process::run::Stream;
#[cfg(unix)]
use tempfile::TempDir;
use tokio::time::{sleep, timeout};
use super::*;
use crate::event::{Event, EventReceiver, event_channel};
#[cfg(unix)]
const SPIN_UNTIL_SIGNALED: &str = "while :; do :; done";
async fn drain(mut receiver: EventReceiver) -> Vec<ProcessEvent> {
let mut events = Vec::new();
while let Some(event) = receiver.recv().await {
match event {
Event::Process(event) => events.push(*event),
Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Prompt(_) => {}
}
}
events
}
fn is_cancelled(error: &RunProcessError) -> bool {
match error {
RunProcessError::CancelledRun { .. } => true,
RunProcessError::UnrunnableCommand { .. } => false,
RunProcessError::UnreportableOutput { .. } => false,
}
}
fn lines(events: &[ProcessEvent]) -> Vec<(Stream, String)> {
events
.iter()
.filter_map(|event| match event {
ProcessEvent::Line { line, .. } => {
Some((line.stream(), line.text().get().to_owned()))
}
ProcessEvent::Started { .. } => None,
ProcessEvent::Finished { .. } => None,
})
.collect()
}
fn outcome(events: &[ProcessEvent]) -> Option<Outcome> {
events.iter().find_map(|event| match event {
ProcessEvent::Finished { outcome, .. } => Some(outcome.clone()),
ProcessEvent::Started { .. } => None,
ProcessEvent::Line { .. } => None,
})
}
fn shell(commands: &[&str]) -> Invocation {
if cfg!(windows) {
Invocation::new("cmd").arg("/C").arg(commands.join(" & "))
} else {
Invocation::new("sh").arg("-c").arg(commands.join("; "))
}
}
#[tokio::test]
async fn clone_reports_through_the_same_channel() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
let clone = process.clone();
clone.run(shell(&["exit 0"])).await.expect("should run");
drop(process);
drop(clone);
assert_eq!(drain(receiver).await.len(), 2);
}
#[tokio::test]
async fn run_reports_the_command_and_the_time_of_the_run() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
let invocation = shell(&["exit 0"]);
process.run(invocation.clone()).await.expect("should run");
drop(process);
assert_eq!(
drain(receiver).await.iter().find_map(|event| match event {
ProcessEvent::Finished {
invocation,
duration,
..
} => Some((invocation.to_string(), *duration > Duration::ZERO)),
ProcessEvent::Started { .. } => None,
ProcessEvent::Line { .. } => None,
}),
Some((invocation.to_string(), true))
);
}
#[tokio::test]
async fn run_reports_the_lifecycle_of_the_program() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
process
.run(shell(&["echo only"]))
.await
.expect("should run");
drop(process);
assert_eq!(
drain(receiver)
.await
.iter()
.map(|event| match event {
ProcessEvent::Started { .. } => "started",
ProcessEvent::Line { .. } => "line",
ProcessEvent::Finished { .. } => "finished",
})
.collect::<Vec<_>>(),
vec!["started", "line", "finished"]
);
}
#[tokio::test]
async fn run_reports_the_lines_of_the_program() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
process
.run(shell(&["echo one", "echo two"]))
.await
.expect("should run");
drop(process);
assert_eq!(
lines(&drain(receiver).await),
vec![
(Stream::StandardOutput, "one".to_owned()),
(Stream::StandardOutput, "two".to_owned()),
]
);
}
#[tokio::test]
async fn run_reports_the_outcome_of_the_program() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
let execution = process.run(shell(&["exit 0"])).await.expect("should run");
drop(process);
assert_eq!(
outcome(&drain(receiver).await),
Some(Outcome::Exited(execution.status()))
);
}
#[tokio::test]
async fn run_reports_the_identifier_of_the_program() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
process.run(shell(&["exit 0"])).await.expect("should run");
drop(process);
assert_eq!(
drain(receiver).await.iter().find_map(|event| match event {
ProcessEvent::Started { process_id, .. } => Some(process_id.is_some()),
ProcessEvent::Line { .. } => None,
ProcessEvent::Finished { .. } => None,
}),
Some(true)
);
}
#[tokio::test]
async fn run_reports_the_start_of_the_program() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
let invocation = shell(&["exit 0"]);
process.run(invocation.clone()).await.expect("should run");
drop(process);
assert_eq!(
drain(receiver).await.first().map(ProcessEvent::to_string),
Some(format!("$ {invocation}"))
);
}
#[tokio::test]
async fn run_returns_the_capture_of_the_program() {
let (sender, _receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
let execution = process
.run(shell(&["echo hello"]))
.await
.expect("should run");
assert_eq!(execution.stdout().to_string_lossy().trim(), "hello");
}
#[tokio::test]
async fn run_separates_the_streams_of_the_program() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
process
.run(shell(&["echo out", "echo err 1>&2"]))
.await
.expect("should run");
drop(process);
let mut found = lines(&drain(receiver).await);
found.sort();
assert_eq!(
found,
vec![
(Stream::StandardError, "err".to_owned()),
(Stream::StandardOutput, "out".to_owned()),
]
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_with_a_cancelled_token_lets_the_program_end_itself() {
let directory = TempDir::new().expect("should create a directory");
let marker = directory.path().join("cleaned");
let cancellation = Cancellation::new();
let (sender, mut receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.build();
let invocation = shell(&[
&format!("trap 'touch \"{}\"; exit 0' TERM", marker.display()),
"echo ready",
SPIN_UNTIL_SIGNALED,
]);
let handle = tokio::spawn(async move { process.run(invocation).await });
while let Some(event) = receiver.recv().await {
let ready = match event {
Event::Process(event) => match *event {
ProcessEvent::Line { .. } => true,
ProcessEvent::Started { .. } => false,
ProcessEvent::Finished { .. } => false,
},
Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Prompt(_) => {
false
}
};
if ready {
break;
}
}
cancellation.cancel();
timeout(Duration::from_secs(20), handle)
.await
.expect("should not time out")
.expect("should join")
.expect_err("should fail");
assert!(marker.exists());
}
#[cfg(unix)]
#[tokio::test]
async fn run_with_a_cancelled_token_kills_the_program() {
let directory = TempDir::new().expect("should create a directory");
let beats = directory.path().join("beats");
let cancellation = Cancellation::new();
let (sender, _receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.build();
let invocation = shell(&[&format!(
"while true; do echo beat >> '{}'; sleep 0.05; done",
beats.display()
)]);
let handle = tokio::spawn(async move { process.run(invocation).await });
sleep(Duration::from_millis(300)).await;
cancellation.cancel();
timeout(Duration::from_secs(30), handle)
.await
.expect("should not time out")
.expect("should join")
.expect_err("should fail");
let written = read_to_string(&beats).expect("should read the beats");
written
.lines()
.next()
.expect("the program should have written a beat");
sleep(Duration::from_millis(400)).await;
assert_eq!(
read_to_string(&beats).expect("should read the beats"),
written
);
}
#[tokio::test]
async fn run_with_a_cancelled_token_reports_the_cancellation() {
let cancellation = Cancellation::new();
let (sender, receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.build();
cancellation.cancel();
process
.run(shell(&["sleep 30"]))
.await
.expect_err("should fail");
drop(process);
assert_eq!(outcome(&drain(receiver).await), Some(Outcome::Cancelled));
}
#[tokio::test]
async fn run_with_a_cancelled_token_returns_an_error() {
let cancellation = Cancellation::new();
let (sender, _receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.build();
cancellation.cancel();
let error = process
.run(shell(&["sleep 30"]))
.await
.expect_err("should fail");
assert!(is_cancelled(&error));
}
#[tokio::test]
async fn run_with_a_closed_channel_returns_an_error() {
let (sender, receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
drop(receiver);
let error = process
.run(shell(&["exit 0"]))
.await
.expect_err("should fail");
assert!(matches_unreportable_output(&error));
}
#[tokio::test]
async fn run_with_a_missing_program_returns_an_error() {
let (sender, _receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
let error = process
.run(Invocation::new("clawless-no-such-program"))
.await
.expect_err("should fail");
assert!(matches_unrunnable_command(&error));
}
#[cfg(unix)]
#[tokio::test]
async fn run_with_a_program_that_closed_its_streams_ends_itself_on_cancellation() {
let directory = TempDir::new().expect("should create a directory");
let marker = directory.path().join("cleaned");
let cancellation = Cancellation::new();
let (sender, _receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.build();
let invocation = shell(&[
&format!("trap 'touch \"{}\"; exit 0' TERM", marker.display()),
"exec 1>&- 2>&-",
SPIN_UNTIL_SIGNALED,
]);
let handle = tokio::spawn(async move { process.run(invocation).await });
sleep(Duration::from_millis(250)).await;
cancellation.cancel();
timeout(Duration::from_secs(30), handle)
.await
.expect("should not time out")
.expect("should join")
.expect_err("should fail");
assert!(marker.exists());
}
#[cfg(unix)]
#[tokio::test]
async fn run_with_a_program_that_closed_its_streams_stops_on_cancellation() {
let cancellation = Cancellation::new();
let (sender, _receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.build();
let invocation = shell(&["exec 1>&- 2>&-", "sleep 30"]);
let handle = tokio::spawn(async move { process.run(invocation).await });
sleep(Duration::from_millis(250)).await;
cancellation.cancel();
let error = timeout(Duration::from_secs(30), handle)
.await
.expect("should not time out")
.expect("should join")
.expect_err("should fail");
assert!(is_cancelled(&error));
}
#[cfg(unix)]
#[tokio::test]
async fn run_with_a_program_that_leaves_a_child_returns_within_the_grace_period() {
let cancellation = Cancellation::new();
let (sender, _receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.grace_period(Duration::from_millis(200))
.build();
let invocation = shell(&["echo ready", "sleep 20"]);
let handle = tokio::spawn(async move { process.run(invocation).await });
sleep(Duration::from_millis(200)).await;
cancellation.cancel();
let started = Instant::now();
timeout(Duration::from_secs(20), handle)
.await
.expect("should not time out")
.expect("should join")
.expect_err("should fail");
assert!(started.elapsed() < Duration::from_secs(5));
}
#[tokio::test]
async fn run_with_an_unsuccessful_program_returns_the_status() {
let (sender, _receiver) = event_channel();
let process = Process::builder().output(Output::new(sender)).build();
let execution = process.run(shell(&["exit 3"])).await.expect("should run");
assert_eq!(execution.status().code(), Some(3));
}
#[tokio::test]
async fn run_with_cancellation_while_the_program_runs_returns_an_error() {
let cancellation = Cancellation::new();
let (sender, mut receiver) = event_channel();
let process = Process::builder()
.output(Output::new(sender))
.cancellation(cancellation.clone())
.grace_period(Duration::from_millis(200))
.build();
let invocation = shell(&["echo ready", "sleep 30"]);
let handle = tokio::spawn(async move { process.run(invocation).await });
while let Some(event) = receiver.recv().await {
let ready = match event {
Event::Process(event) => match *event {
ProcessEvent::Line { .. } => true,
ProcessEvent::Started { .. } => false,
ProcessEvent::Finished { .. } => false,
},
Event::Message(_) | Event::Detail(_) | Event::Artifact(_) | Event::Prompt(_) => {
false
}
};
if ready {
break;
}
}
cancellation.cancel();
let error = timeout(Duration::from_secs(30), handle)
.await
.expect("should not time out")
.expect("should join")
.expect_err("should fail");
assert!(is_cancelled(&error));
}
#[test]
fn trait_send() {
fn assert_send<T: Send>() {}
assert_send::<Process>();
}
#[test]
fn trait_sync() {
fn assert_sync<T: Sync>() {}
assert_sync::<Process>();
}
#[test]
fn trait_unpin() {
fn assert_unpin<T: Unpin>() {}
assert_unpin::<Process>();
}
fn matches_unreportable_output(error: &RunProcessError) -> bool {
match error {
RunProcessError::UnreportableOutput { .. } => true,
RunProcessError::CancelledRun { .. } => false,
RunProcessError::UnrunnableCommand { .. } => false,
}
}
fn matches_unrunnable_command(error: &RunProcessError) -> bool {
match error {
RunProcessError::UnrunnableCommand { .. } => true,
RunProcessError::CancelledRun { .. } => false,
RunProcessError::UnreportableOutput { .. } => false,
}
}
}