use std::{process::Stdio, time::Duration};
use tokio::io::AsyncWriteExt;
use super::{
config::HookDefinition,
environment::child_environment,
protocol::MAX_DECISION_BYTES,
supervisor::{ProcessTree, SupervisedTree},
};
pub const MAX_STDERR_BYTES: usize = 4 * 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HookRunOutput {
pub exit_code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub duration: Duration,
pub truncated: bool,
}
impl HookRunOutput {
pub fn succeeded(&self) -> bool {
self.exit_code == Some(0)
}
pub fn stderr_summary(&self) -> String {
String::from_utf8_lossy(&self.stderr)
.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())
.unwrap_or_default()
.to_owned()
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum HookRunError {
#[error("timed out after {}", humantime::format_duration(*timeout))]
TimedOut { timeout: Duration },
#[error("could not start: {0}")]
Spawn(String),
#[error("failed while running: {0}")]
Io(String),
#[error("was cancelled before it finished")]
Cancelled,
}
pub async fn run_hook(
hook: &HookDefinition,
event: &str,
cancellation: rho_sdk::CancellationToken,
) -> Result<HookRunOutput, HookRunError> {
let started = std::time::Instant::now();
let mut command = build_command(hook);
SupervisedTree::prepare(&mut command);
let mut child = command
.spawn()
.map_err(|error| HookRunError::Spawn(error.to_string()))?;
let mut tree = SupervisedTree::attach(&child).map_err(|error| {
let _ = child.start_kill();
HookRunError::Spawn(error.to_string())
})?;
let mut stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let payload = event.to_owned();
let writer = tokio::spawn(async move {
if let Some(stdin) = stdin.as_mut() {
let _ = stdin.write_all(payload.as_bytes()).await;
let _ = stdin.shutdown().await;
}
drop(stdin);
});
let stdout_reader = tokio::spawn(read_bounded(stdout, MAX_DECISION_BYTES + 1));
let stderr_reader = tokio::spawn(read_bounded(stderr, MAX_STDERR_BYTES));
let status = tokio::select! {
status = child.wait() => status,
() = cancellation.cancelled() => {
tree.kill();
let _ = child.start_kill();
let _ = child.wait().await;
writer.abort();
stdout_reader.abort();
stderr_reader.abort();
return Err(HookRunError::Cancelled);
}
() = tokio::time::sleep(hook.timeout()) => {
tree.kill();
let _ = child.start_kill();
let _ = child.wait().await;
writer.abort();
stdout_reader.abort();
stderr_reader.abort();
return Err(HookRunError::TimedOut { timeout: hook.timeout() });
}
};
tree.kill();
let _ = writer.await;
let (stdout, stdout_truncated) = stdout_reader.await.unwrap_or_default();
let (stderr, stderr_truncated) = stderr_reader.await.unwrap_or_default();
let status = status.map_err(|error| HookRunError::Io(error.to_string()))?;
Ok(HookRunOutput {
exit_code: status.code(),
stdout,
stderr,
duration: started.elapsed(),
truncated: stdout_truncated || stderr_truncated,
})
}
fn build_command(hook: &HookDefinition) -> tokio::process::Command {
let argv = hook.command();
let mut command = tokio::process::Command::new(hook.executable());
command
.args(&argv[1..])
.current_dir(hook.working_directory())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.env_clear()
.envs(child_environment(hook.env(), super::environment::ambient));
command
}
async fn read_bounded<R>(reader: Option<R>, limit: usize) -> (Vec<u8>, bool)
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let Some(mut reader) = reader else {
return (Vec::new(), false);
};
let mut captured = Vec::new();
let mut truncated = false;
let mut buffer = [0u8; 4096];
loop {
match reader.read(&mut buffer).await {
Ok(0) | Err(_) => break,
Ok(read) => {
let remaining = limit.saturating_sub(captured.len());
if remaining == 0 {
truncated = true;
continue;
}
let take = read.min(remaining);
captured.extend_from_slice(&buffer[..take]);
truncated |= take < read;
}
}
}
(captured, truncated)
}
#[cfg(test)]
#[path = "command_tests.rs"]
mod tests;