use std::fs::File;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
const TIMEOUT_EXIT_CODE: i32 = 124;
type Collected = Arc<Mutex<Vec<(usize, String)>>>;
fn spawn_reader<R: Read + Send + 'static>(
stream: R,
collected: Collected,
seq: Arc<AtomicUsize>,
total_bytes: Arc<AtomicUsize>,
max_output_bytes: usize,
tee: Option<Arc<Mutex<File>>>,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
let mut reader = BufReader::new(stream);
let mut buf = Vec::new();
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf) {
Ok(0) => break, Ok(_) => {
if let Some(t) = tee.as_ref() {
if let Ok(mut f) = t.lock() {
let _ = f.write_all(&buf);
}
}
let line = String::from_utf8_lossy(&buf).into_owned();
let prev = total_bytes.fetch_add(line.len(), Ordering::SeqCst);
if prev + line.len() <= max_output_bytes {
let n = seq.fetch_add(1, Ordering::SeqCst);
collected.lock().unwrap().push((n, line));
}
}
Err(_) => break,
}
}
})
}
fn kill_process_tree(child: &mut std::process::Child) {
#[cfg(unix)]
{
let pid = child.id() as i32;
let _ = unsafe { libc::kill(-pid, libc::SIGKILL) };
}
let _ = child.kill();
let _ = child.wait();
}
pub fn execute_command(
cmd: &str,
args: &[String],
max_output_bytes: usize,
timeout_secs: u64,
tee_path: Option<&Path>,
) -> Result<(String, i32), String> {
let tee_file: Option<Arc<Mutex<File>>> = tee_path.and_then(|p| {
if let Some(parent) = p.parent() {
if !parent.as_os_str().is_empty() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("vallum: tee disabled (mkdir {}: {})", parent.display(), e);
return None;
}
}
}
match crate::fsutil::open_append_private(p) {
Ok(f) => Some(Arc::new(Mutex::new(f))),
Err(e) => {
eprintln!("vallum: tee disabled (open {}: {})", p.display(), e);
None
}
}
});
let mut command = Command::new(cmd);
command
.args(args)
.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = command
.spawn()
.map_err(|e| format!("Failed to spawn command: {}", e))?;
let stdout = child.stdout.take().ok_or("missing stdout pipe")?;
let stderr = child.stderr.take().ok_or("missing stderr pipe")?;
let collected: Collected = Arc::new(Mutex::new(Vec::new()));
let seq = Arc::new(AtomicUsize::new(0));
let total_bytes = Arc::new(AtomicUsize::new(0));
let h_out = spawn_reader(
stdout,
Arc::clone(&collected),
Arc::clone(&seq),
Arc::clone(&total_bytes),
max_output_bytes,
tee_file.as_ref().map(Arc::clone),
);
let h_err = spawn_reader(
stderr,
Arc::clone(&collected),
Arc::clone(&seq),
Arc::clone(&total_bytes),
max_output_bytes,
tee_file.as_ref().map(Arc::clone),
);
let timeout = if timeout_secs == 0 {
None
} else {
Some(Duration::from_secs(timeout_secs))
};
let start = Instant::now();
let mut timed_out = false;
let exit_code;
loop {
match child.try_wait() {
Ok(Some(status)) => {
exit_code = status.code().unwrap_or(1);
break;
}
Ok(None) => {
if let Some(t) = timeout {
if start.elapsed() >= t {
kill_process_tree(&mut child);
timed_out = true;
exit_code = TIMEOUT_EXIT_CODE;
break;
}
}
thread::sleep(Duration::from_millis(20));
}
Err(e) => return Err(format!("Failed to wait: {}", e)),
}
}
let _ = h_out.join();
let _ = h_err.join();
let mut lines = Arc::try_unwrap(collected)
.map(|m| m.into_inner().unwrap())
.unwrap_or_else(|arc| arc.lock().unwrap().clone());
lines.sort_by_key(|(n, _)| *n);
let mut result = String::new();
for (_, line) in lines {
result.push_str(&line);
}
if total_bytes.load(Ordering::SeqCst) > max_output_bytes {
result.push_str(&format!(
"\n[output capped at {} bytes]\n",
max_output_bytes
));
}
if timed_out {
result.push_str(&format!("\n[timed out after {}s]\n", timeout_secs));
}
Ok((result, exit_code))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_execute_echo() {
let (output, exit_code) =
execute_command("echo", &["hello".to_string()], 10 * 1024 * 1024, 0, None).unwrap();
assert_eq!(output, "hello\n");
assert_eq!(exit_code, 0);
}
#[test]
fn test_exit_code_propagates() {
let (_out, code) = execute_command(
"sh",
&["-c".to_string(), "exit 7".to_string()],
10 * 1024 * 1024,
0,
None,
)
.unwrap();
assert_eq!(code, 7);
}
#[test]
fn test_output_cap_marks_truncation() {
let (out, _code) = execute_command(
"sh",
&["-c".to_string(), "seq 1 1000".to_string()],
100,
0,
None,
)
.unwrap();
assert!(out.contains("[output capped at 100 bytes]"));
assert!(out.len() < 400);
}
#[test]
fn test_timeout_kills_child() {
let start = std::time::Instant::now();
let (out, code) = execute_command(
"sh",
&["-c".to_string(), "sleep 30".to_string()],
10 * 1024 * 1024,
1,
None,
)
.unwrap();
assert_eq!(code, 124);
assert!(out.contains("[timed out after 1s]"));
assert!(
start.elapsed().as_secs() < 15,
"took {}s — timeout did not cut the command short",
start.elapsed().as_secs()
);
}
#[test]
fn tee_writes_each_line_to_file_and_returns_normal_output() {
use std::io::Read;
let tmp = std::env::temp_dir().join(format!(
"vallum_exec_tee_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&tmp).unwrap();
let tee = tmp.join("live.log");
let (out, code) = execute_command(
"sh",
&["-c".to_string(), "printf 'line1\\nline2\\n'".to_string()],
10 * 1024 * 1024,
0,
Some(&tee),
)
.unwrap();
assert_eq!(code, 0);
assert!(out.contains("line1"));
assert!(out.contains("line2"));
let mut tee_contents = String::new();
std::fs::File::open(&tee)
.unwrap()
.read_to_string(&mut tee_contents)
.unwrap();
assert!(tee_contents.contains("line1"));
assert!(tee_contents.contains("line2"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn tee_open_failure_falls_back_to_capture() {
let bad = std::path::PathBuf::from("/dev/null/vallum-tee-cannot-exist/live.log");
let (out, code) = execute_command(
"echo",
&["hello".to_string()],
10 * 1024 * 1024,
0,
Some(&bad),
)
.unwrap();
assert_eq!(code, 0);
assert!(out.contains("hello"));
}
}