use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use wait_timeout::ChildExt;
use crate::domain::report::{Capture, Status};
#[derive(Clone)]
pub struct Job {
pub argv: Vec<String>,
pub cwd: Option<PathBuf>,
pub env: Vec<(String, String)>,
pub timeout: Duration,
}
pub struct Outcome {
pub capture: Capture,
pub attempts: u32,
}
pub fn run_jobs(jobs: &[Job], max_parallel: usize) -> Vec<Capture> {
run_jobs_with(jobs, max_parallel, |_, _, _| None)
.into_iter()
.map(|o| o.capture)
.collect()
}
pub fn run_jobs_with<F>(jobs: &[Job], max_parallel: usize, retry: F) -> Vec<Outcome>
where
F: Fn(usize, u32, &Capture) -> Option<Vec<String>> + Sync,
{
let n = jobs.len();
if n == 0 {
return Vec::new();
}
let workers = max_parallel.clamp(1, n);
let next = AtomicUsize::new(0);
let slots: Vec<Mutex<Option<Outcome>>> = (0..n).map(|_| Mutex::new(None)).collect();
let retry = &retry;
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| loop {
let i = next.fetch_add(1, Ordering::SeqCst);
if i >= n {
break;
}
let outcome = run_job_with_retry(&jobs[i], i, retry);
*slots[i].lock().expect("slot mutex poisoned") = Some(outcome);
});
}
});
slots
.into_iter()
.map(|m| {
m.into_inner()
.expect("slot mutex poisoned")
.expect("slot unfilled")
})
.collect()
}
fn run_job_with_retry<F>(job: &Job, index: usize, retry: &F) -> Outcome
where
F: Fn(usize, u32, &Capture) -> Option<Vec<String>>,
{
let mut capture = run_job(job);
let mut attempts = 1u32;
while let Some(next_argv) = retry(index, attempts, &capture) {
let next = Job {
argv: next_argv,
cwd: job.cwd.clone(),
env: job.env.clone(),
timeout: job.timeout,
};
capture = run_job(&next);
attempts += 1;
}
Outcome { capture, attempts }
}
fn resolve_program(program: &str) -> std::ffi::OsString {
#[cfg(windows)]
{
if let Ok(path) = which::which(program) {
return path.into_os_string();
}
}
program.into()
}
fn spawn_target(argv: &[String]) -> (std::ffi::OsString, Vec<String>) {
let resolved = resolve_program(&argv[0]);
let rest = argv[1..].to_vec();
#[cfg(windows)]
{
if let Some(plan) = windows_shim_plan(std::path::Path::new(&resolved), &rest) {
return plan;
}
}
(resolved, rest)
}
#[cfg(windows)]
fn windows_shim_plan(
resolved: &std::path::Path,
args: &[String],
) -> Option<(std::ffi::OsString, Vec<String>)> {
if !args.iter().any(|a| a.contains('\n') || a.contains('\r')) {
return None;
}
let ext = resolved.extension()?.to_str()?.to_ascii_lowercase();
if ext != "cmd" && ext != "bat" {
return None;
}
let contents = std::fs::read_to_string(resolved).ok()?;
let dir = resolved.parent()?.to_str()?;
let target = crate::domain::shim::parse_cmd_shim(&contents, dir)?;
let mut full = target.prefix_args;
full.extend_from_slice(args);
Some((resolve_program(&target.interpreter), full))
}
pub fn run_job(job: &Job) -> Capture {
let start = Instant::now();
let (program, args) = spawn_target(&job.argv);
let mut command = Command::new(program);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(cwd) = &job.cwd {
command.current_dir(cwd);
let pwd = if cwd.is_absolute() {
cwd.clone()
} else {
std::env::current_dir()
.map(|base| base.join(cwd))
.unwrap_or_else(|_| cwd.clone())
};
command.env("PWD", pwd);
}
for (key, value) in &job.env {
command.env(key, value);
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
return Capture {
status: Status::SpawnError,
exit_code: None,
duration_ms: Some(start.elapsed().as_millis()),
stdout: String::new(),
stderr: String::new(),
error: Some(format!(
"failed to spawn `{}`: {err}. Suggestion: check the binary exists and is executable (try `oneharness detect`)",
job.argv[0]
)),
};
}
};
let mut out = child.stdout.take().expect("piped stdout");
let mut err = child.stderr.take().expect("piped stderr");
let out_reader = std::thread::spawn(move || read_all(&mut out));
let err_reader = std::thread::spawn(move || read_all(&mut err));
let (status, exit_code, timed_out) = match child.wait_timeout(job.timeout) {
Ok(Some(exit)) => {
let code = exit.code();
let status = if code == Some(0) {
Status::Ok
} else {
Status::Nonzero
};
(status, code, false)
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
(Status::Timeout, None, true)
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
(Status::SpawnError, None, false)
}
};
let stdout = out_reader.join().unwrap_or_default();
let stderr = err_reader.join().unwrap_or_default();
let duration_ms = Some(start.elapsed().as_millis());
let error = if timed_out {
Some(format!(
"`{}` exceeded the {}s timeout and was killed. Suggestion: raise --timeout or simplify the prompt",
job.argv[0],
job.timeout.as_secs()
))
} else if status == Status::SpawnError {
Some(format!("`{}` could not be waited on", job.argv[0]))
} else {
None
};
Capture {
status,
exit_code,
duration_ms,
stdout,
stderr,
error,
}
}
fn read_all<R: std::io::Read>(reader: &mut R) -> String {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(reader, &mut buf);
String::from_utf8_lossy(&buf).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn job(argv: &[&str]) -> Job {
Job {
argv: argv.iter().map(|s| s.to_string()).collect(),
cwd: None,
env: Vec::new(),
timeout: Duration::from_secs(5),
}
}
#[test]
fn empty_jobs_returns_empty_without_spawning_workers() {
assert!(run_jobs(&[], 4).is_empty());
}
#[test]
fn spawn_error_is_data_not_a_panic() {
let jobs = [job(&["/no/such/oneharness-binary-xyz", "arg"])];
let captures = run_jobs(&jobs, 1);
assert_eq!(captures.len(), 1);
let cap = &captures[0];
assert_eq!(cap.status, Status::SpawnError);
assert!(cap.exit_code.is_none());
assert!(cap.stdout.is_empty());
assert!(cap.duration_ms.is_some());
let msg = cap.error.as_deref().unwrap_or_default();
assert!(msg.contains("failed to spawn"), "{msg}");
assert!(msg.contains("oneharness-binary-xyz"), "{msg}");
}
#[test]
fn run_jobs_with_retries_until_the_policy_stops() {
let jobs = [job(&["/no/such/first"])];
let outcomes = run_jobs_with(&jobs, 1, |i, attempt, cap| {
assert_eq!(i, 0);
assert_eq!(cap.status, Status::SpawnError);
(attempt < 3).then(|| vec![format!("/no/such/retry-{attempt}")])
});
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].attempts, 3);
let err = outcomes[0].capture.error.as_deref().unwrap_or_default();
assert!(
err.contains("retry-2"),
"final capture should be last retry: {err}"
);
}
#[test]
fn run_jobs_with_a_no_op_policy_runs_once() {
let jobs = [job(&["/no/such/binary"])];
let outcomes = run_jobs_with(&jobs, 1, |_, _, _| None);
assert_eq!(outcomes[0].attempts, 1);
assert!(run_jobs_with(&[], 4, |_, _, _| None).is_empty());
}
#[test]
fn resolve_program_falls_back_to_the_name_when_unresolvable() {
let name = "oneharness-no-such-binary-zzz";
assert_eq!(resolve_program(name), std::ffi::OsString::from(name));
}
#[cfg(windows)]
#[test]
fn windows_shim_plan_rewrites_a_cmd_only_for_multiline_args() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("oh-shim-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cmd_path = dir.join("claude.cmd");
let mut f = std::fs::File::create(&cmd_path).unwrap();
write!(
f,
"SET \"_prog=node\"\r\n\"%_prog%\" \"%dp0%\\cli.js\" %*\r\n"
)
.unwrap();
drop(f);
let dir_str = dir.to_str().unwrap();
let script = format!("{dir_str}\\cli.js");
let multiline = vec!["-p".to_string(), "a\nb\nc".to_string()];
let (prog, args) = windows_shim_plan(&cmd_path, &multiline).expect("multiline → rewrite");
assert!(
std::path::Path::new(&prog)
.to_string_lossy()
.to_ascii_lowercase()
.ends_with("node.exe"),
"interpreter should resolve to node.exe, got {prog:?}"
);
assert_eq!(args, vec![script, "-p".to_string(), "a\nb\nc".to_string()]);
let single = vec!["-p".to_string(), "hello".to_string()];
assert!(windows_shim_plan(&cmd_path, &single).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(windows)]
#[test]
fn windows_shim_plan_ignores_non_batch_programs() {
let exe = resolve_program("where");
let multiline = vec!["x\ny".to_string()];
assert!(windows_shim_plan(std::path::Path::new(&exe), &multiline).is_none());
}
#[cfg(windows)]
#[test]
fn resolve_program_finds_a_cmd_shim_on_windows() {
let resolved = resolve_program("where");
let path = std::path::Path::new(&resolved);
assert!(
path.is_absolute(),
"expected an absolute path, got {resolved:?}"
);
assert!(path.exists(), "resolved path should exist: {resolved:?}");
}
}