use std::collections::BTreeMap;
use std::path::Path;
#[cfg(feature = "local-shell")]
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use async_trait::async_trait;
#[cfg(feature = "local-shell")]
use tokio::io::{AsyncBufReadExt, BufReader};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Executed {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub code: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ShellError {
#[error("command timed out after {0}s and was killed")]
Timeout(u64),
#[error("cancelled")]
Cancelled,
#[error("failed to run bash: {0}")]
Failed(String),
}
#[async_trait]
pub trait ShellExecutor: Send + Sync {
async fn run(
&self,
command: &str,
cwd: &Path,
timeout: Duration,
cancelled: &AtomicBool,
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> Result<Executed, ShellError>;
}
#[cfg(feature = "local-shell")]
const CANCEL_TICK: Duration = Duration::from_millis(100);
#[cfg(feature = "local-shell")]
const CAPTURE_WINDOW: usize = 50 * 1024 + 1024 * 1024;
#[cfg(feature = "local-shell")]
const CAPTURE_EVICT: usize = 256 * 1024;
#[cfg(feature = "local-shell")]
#[derive(Debug, Clone)]
pub struct LocalShell {
env: Arc<[(String, String)]>,
}
#[cfg(feature = "local-shell")]
impl Default for LocalShell {
fn default() -> Self {
Self { env: Arc::from([]) }
}
}
#[cfg(feature = "local-shell")]
#[async_trait]
impl ShellExecutor for LocalShell {
async fn run(
&self,
command: &str,
cwd: &Path,
timeout: Duration,
cancelled: &AtomicBool,
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> Result<Executed, ShellError> {
use std::process::Stdio;
use std::sync::atomic::Ordering;
let mut child = tokio::process::Command::new("bash")
.arg("-c")
.arg(command)
.current_dir(cwd)
.envs(
self.env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|err| ShellError::Failed(format!("{err}")))?;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
if let Some(stdout) = child.stdout.take() {
pump(stdout, false, tx.clone());
}
if let Some(stderr) = child.stderr.take() {
pump(stderr, true, tx.clone());
}
drop(tx);
let deadline = tokio::time::Instant::now() + timeout;
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
loop {
tokio::select! {
_ = tokio::time::sleep_until(deadline) => {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(ShellError::Timeout(timeout.as_secs()));
}
line = rx.recv() => match line {
Some((is_stderr, line)) => {
let sink = if is_stderr { &mut stderr } else { &mut stdout };
sink.extend_from_slice(line.as_bytes());
sink.push(b'\n');
if sink.len() >= CAPTURE_WINDOW + CAPTURE_EVICT {
let keep_from = sink.len() - CAPTURE_WINDOW;
let boundary = sink[keep_from..]
.iter()
.position(|&byte| byte == b'\n')
.map_or(keep_from, |position| keep_from + position + 1);
sink.drain(..boundary);
}
progress(&format!("{line}\n"));
}
None => break,
},
_ = tokio::time::sleep(CANCEL_TICK) => {
if cancelled.load(Ordering::Relaxed) {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(ShellError::Cancelled);
}
}
}
if cancelled.load(Ordering::Relaxed) {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(ShellError::Cancelled);
}
}
let status = match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => status,
_ => return Err(ShellError::Timeout(timeout.as_secs())),
};
Ok(Executed {
stdout,
stderr,
code: status.code(),
})
}
}
#[cfg(feature = "local-shell")]
fn pump<R>(
stream: R,
is_stderr: bool,
tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
) -> tokio::task::JoinHandle<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx.send((is_stderr, line)).is_err() {
break; }
}
})
}
#[cfg(not(feature = "local-shell"))]
#[derive(Debug, Clone, Copy, Default)]
pub struct NoShell;
#[cfg(not(feature = "local-shell"))]
#[async_trait]
impl ShellExecutor for NoShell {
async fn run(
&self,
_command: &str,
_cwd: &Path,
_timeout: Duration,
_cancelled: &AtomicBool,
_progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> Result<Executed, ShellError> {
Err(ShellError::Failed(
"this build has no local shell; install a shell executor \
(ShellTools::with_executor)"
.into(),
))
}
}
#[cfg(feature = "local-shell")]
pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(LocalShell::default())
}
#[cfg(feature = "local-shell")]
pub fn default_executor_with(env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(LocalShell {
env: Arc::from(
env.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Vec<_>>(),
),
})
}
#[cfg(not(feature = "local-shell"))]
pub fn default_executor_with(_env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(NoShell)
}
#[cfg(not(feature = "local-shell"))]
pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(NoShell)
}
#[cfg(all(test, feature = "local-shell"))]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
fn silent(_line: &str) {}
#[tokio::test]
async fn a_chatty_command_cannot_grow_the_capture_without_bound() {
let shell = LocalShell::default();
let cancelled = Arc::new(AtomicBool::new(false));
let executed = shell
.run(
"seq 1 300000",
Path::new("/tmp"),
Duration::from_secs(60),
&cancelled,
&mut silent,
)
.await
.unwrap();
assert_eq!(executed.code, Some(0));
assert!(
executed.stdout.len() < CAPTURE_WINDOW + CAPTURE_EVICT,
"capture must stay bounded, got {} bytes",
executed.stdout.len()
);
let tail = String::from_utf8_lossy(&executed.stdout);
assert!(
tail.ends_with("300000\n"),
"tail lost: …{}",
&tail[tail.len().saturating_sub(40)..]
);
}
}