1use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::Mutex;
11use std::time::{Duration, Instant};
12
13use wait_timeout::ChildExt;
14
15use crate::domain::report::{Capture, Status};
16
17#[derive(Clone)]
19pub struct Job {
20 pub argv: Vec<String>,
21 pub cwd: Option<PathBuf>,
22 pub env: Vec<(String, String)>,
23 pub timeout: Duration,
24}
25
26pub struct Outcome {
29 pub capture: Capture,
30 pub attempts: u32,
31}
32
33pub fn run_jobs(jobs: &[Job], max_parallel: usize) -> Vec<Capture> {
35 run_jobs_with(jobs, max_parallel, |_, _, _| None)
36 .into_iter()
37 .map(|o| o.capture)
38 .collect()
39}
40
41pub fn run_jobs_with<F>(jobs: &[Job], max_parallel: usize, retry: F) -> Vec<Outcome>
53where
54 F: Fn(usize, u32, &Capture) -> Option<Vec<String>> + Sync,
55{
56 let n = jobs.len();
57 if n == 0 {
58 return Vec::new();
59 }
60 let workers = max_parallel.clamp(1, n);
61 let next = AtomicUsize::new(0);
62 let slots: Vec<Mutex<Option<Outcome>>> = (0..n).map(|_| Mutex::new(None)).collect();
63 let retry = &retry;
64
65 std::thread::scope(|scope| {
66 for _ in 0..workers {
67 scope.spawn(|| loop {
68 let i = next.fetch_add(1, Ordering::SeqCst);
69 if i >= n {
70 break;
71 }
72 let outcome = run_job_with_retry(&jobs[i], i, retry);
73 *slots[i].lock().expect("slot mutex poisoned") = Some(outcome);
74 });
75 }
76 });
77
78 slots
79 .into_iter()
80 .map(|m| {
81 m.into_inner()
82 .expect("slot mutex poisoned")
83 .expect("slot unfilled")
84 })
85 .collect()
86}
87
88fn run_job_with_retry<F>(job: &Job, index: usize, retry: &F) -> Outcome
90where
91 F: Fn(usize, u32, &Capture) -> Option<Vec<String>>,
92{
93 let mut capture = run_job(job);
94 let mut attempts = 1u32;
95 while let Some(next_argv) = retry(index, attempts, &capture) {
96 let next = Job {
97 argv: next_argv,
98 cwd: job.cwd.clone(),
99 env: job.env.clone(),
100 timeout: job.timeout,
101 };
102 capture = run_job(&next);
103 attempts += 1;
104 }
105 Outcome { capture, attempts }
106}
107
108fn resolve_program(program: &str) -> std::ffi::OsString {
123 #[cfg(windows)]
124 {
125 if let Ok(path) = which::which(program) {
126 return path.into_os_string();
127 }
128 }
129 program.into()
130}
131
132fn spawn_target(argv: &[String]) -> (std::ffi::OsString, Vec<String>) {
151 let resolved = resolve_program(&argv[0]);
152 let rest = argv[1..].to_vec();
153 #[cfg(windows)]
154 {
155 if let Some(plan) = windows_shim_plan(std::path::Path::new(&resolved), &rest) {
156 return plan;
157 }
158 }
159 (resolved, rest)
160}
161
162#[cfg(windows)]
166fn windows_shim_plan(
167 resolved: &std::path::Path,
168 args: &[String],
169) -> Option<(std::ffi::OsString, Vec<String>)> {
170 if !args.iter().any(|a| a.contains('\n') || a.contains('\r')) {
172 return None;
173 }
174 let ext = resolved.extension()?.to_str()?.to_ascii_lowercase();
175 if ext != "cmd" && ext != "bat" {
176 return None;
177 }
178 let contents = std::fs::read_to_string(resolved).ok()?;
179 let dir = resolved.parent()?.to_str()?;
180 let target = crate::domain::shim::parse_cmd_shim(&contents, dir)?;
181 let mut full = target.prefix_args;
182 full.extend_from_slice(args);
183 Some((resolve_program(&target.interpreter), full))
184}
185
186pub fn run_job(job: &Job) -> Capture {
188 let start = Instant::now();
189 let (program, args) = spawn_target(&job.argv);
190 let mut command = Command::new(program);
191 command
192 .args(&args)
193 .stdin(Stdio::null())
194 .stdout(Stdio::piped())
195 .stderr(Stdio::piped());
196 if let Some(cwd) = &job.cwd {
197 command.current_dir(cwd);
198 let pwd = if cwd.is_absolute() {
204 cwd.clone()
205 } else {
206 std::env::current_dir()
207 .map(|base| base.join(cwd))
208 .unwrap_or_else(|_| cwd.clone())
209 };
210 command.env("PWD", pwd);
211 }
212 for (key, value) in &job.env {
214 command.env(key, value);
215 }
216
217 let mut child = match command.spawn() {
218 Ok(child) => child,
219 Err(err) => {
220 return Capture {
221 status: Status::SpawnError,
222 exit_code: None,
223 duration_ms: Some(start.elapsed().as_millis()),
224 stdout: String::new(),
225 stderr: String::new(),
226 error: Some(format!(
227 "failed to spawn `{}`: {err}. Suggestion: check the binary exists and is executable (try `oneharness detect`)",
228 job.argv[0]
229 )),
230 };
231 }
232 };
233
234 let mut out = child.stdout.take().expect("piped stdout");
236 let mut err = child.stderr.take().expect("piped stderr");
237 let out_reader = std::thread::spawn(move || read_all(&mut out));
238 let err_reader = std::thread::spawn(move || read_all(&mut err));
239
240 let (status, exit_code, timed_out) = match child.wait_timeout(job.timeout) {
241 Ok(Some(exit)) => {
242 let code = exit.code();
243 let status = if code == Some(0) {
244 Status::Ok
245 } else {
246 Status::Nonzero
247 };
248 (status, code, false)
249 }
250 Ok(None) => {
251 let _ = child.kill();
252 let _ = child.wait();
253 (Status::Timeout, None, true)
254 }
255 Err(_) => {
256 let _ = child.kill();
257 let _ = child.wait();
258 (Status::SpawnError, None, false)
259 }
260 };
261
262 let stdout = out_reader.join().unwrap_or_default();
263 let stderr = err_reader.join().unwrap_or_default();
264 let duration_ms = Some(start.elapsed().as_millis());
265
266 let error = if timed_out {
267 Some(format!(
268 "`{}` exceeded the {}s timeout and was killed. Suggestion: raise --timeout or simplify the prompt",
269 job.argv[0],
270 job.timeout.as_secs()
271 ))
272 } else if status == Status::SpawnError {
273 Some(format!("`{}` could not be waited on", job.argv[0]))
274 } else {
275 None
276 };
277
278 Capture {
279 status,
280 exit_code,
281 duration_ms,
282 stdout,
283 stderr,
284 error,
285 }
286}
287
288fn read_all<R: std::io::Read>(reader: &mut R) -> String {
289 let mut buf = Vec::new();
290 let _ = std::io::Read::read_to_end(reader, &mut buf);
291 String::from_utf8_lossy(&buf).into_owned()
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum StreamStep {
297 Continue,
299 Stop,
302}
303
304pub fn run_job_streaming<F>(job: &Job, mut on_line: F) -> Capture
318where
319 F: FnMut(&str) -> StreamStep,
320{
321 use std::io::BufRead;
322 use std::sync::mpsc;
323
324 let start = Instant::now();
325 let (program, args) = spawn_target(&job.argv);
326 let mut command = Command::new(program);
327 command
328 .args(&args)
329 .stdin(Stdio::null())
330 .stdout(Stdio::piped())
331 .stderr(Stdio::piped());
332 if let Some(cwd) = &job.cwd {
333 command.current_dir(cwd);
334 let pwd = if cwd.is_absolute() {
335 cwd.clone()
336 } else {
337 std::env::current_dir()
338 .map(|base| base.join(cwd))
339 .unwrap_or_else(|_| cwd.clone())
340 };
341 command.env("PWD", pwd);
342 }
343 for (key, value) in &job.env {
344 command.env(key, value);
345 }
346
347 let mut child = match command.spawn() {
348 Ok(child) => child,
349 Err(err) => {
350 return Capture {
351 status: Status::SpawnError,
352 exit_code: None,
353 duration_ms: Some(start.elapsed().as_millis()),
354 stdout: String::new(),
355 stderr: String::new(),
356 error: Some(format!(
357 "failed to spawn `{}`: {err}. Suggestion: check the binary exists and is executable (try `oneharness detect`)",
358 job.argv[0]
359 )),
360 };
361 }
362 };
363
364 let out = child.stdout.take().expect("piped stdout");
365 let mut err = child.stderr.take().expect("piped stderr");
366 let err_reader = std::thread::spawn(move || read_all(&mut err));
367
368 let (tx, rx) = mpsc::channel::<String>();
373 let out_reader = std::thread::spawn(move || {
374 let mut reader = std::io::BufReader::new(out);
375 loop {
376 let mut line = String::new();
377 match reader.read_line(&mut line) {
378 Ok(0) => break, Ok(_) => {
380 if tx.send(line).is_err() {
381 break; }
383 }
384 Err(_) => break,
385 }
386 }
387 });
388
389 let deadline = start + job.timeout;
390 let mut stdout = String::new();
391 let mut stopped = false;
392 let mut timed_out = false;
393 loop {
394 let now = Instant::now();
395 if now >= deadline {
396 timed_out = true;
397 break;
398 }
399 match rx.recv_timeout(deadline - now) {
400 Ok(line) => {
401 stdout.push_str(&line);
402 if on_line(line.trim_end_matches(['\n', '\r'])) == StreamStep::Stop {
404 stopped = true;
405 break;
406 }
407 }
408 Err(mpsc::RecvTimeoutError::Timeout) => {
409 timed_out = true;
410 break;
411 }
412 Err(mpsc::RecvTimeoutError::Disconnected) => break, }
414 }
415
416 let (status, exit_code) = if stopped || timed_out {
417 let _ = child.kill();
418 let _ = child.wait();
419 if timed_out {
420 (Status::Timeout, None)
421 } else {
422 (Status::Ok, None)
426 }
427 } else {
428 match child.wait() {
429 Ok(exit) => {
430 let code = exit.code();
431 let status = if code == Some(0) {
432 Status::Ok
433 } else {
434 Status::Nonzero
435 };
436 (status, code)
437 }
438 Err(_) => (Status::SpawnError, None),
439 }
440 };
441
442 let _ = out_reader.join();
445 while let Ok(line) = rx.try_recv() {
446 stdout.push_str(&line);
447 }
448 let stderr = err_reader.join().unwrap_or_default();
449
450 let error = if timed_out {
451 Some(format!(
452 "`{}` exceeded the {}s timeout and was killed. Suggestion: raise --timeout or simplify the prompt",
453 job.argv[0],
454 job.timeout.as_secs()
455 ))
456 } else {
457 None
458 };
459
460 Capture {
461 status,
462 exit_code,
463 duration_ms: Some(start.elapsed().as_millis()),
464 stdout,
465 stderr,
466 error,
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn job(argv: &[&str]) -> Job {
475 Job {
476 argv: argv.iter().map(|s| s.to_string()).collect(),
477 cwd: None,
478 env: Vec::new(),
479 timeout: Duration::from_secs(5),
480 }
481 }
482
483 #[test]
484 fn empty_jobs_returns_empty_without_spawning_workers() {
485 assert!(run_jobs(&[], 4).is_empty());
487 }
488
489 #[test]
490 fn spawn_error_is_data_not_a_panic() {
491 let jobs = [job(&["/no/such/oneharness-binary-xyz", "arg"])];
495 let captures = run_jobs(&jobs, 1);
496 assert_eq!(captures.len(), 1);
497 let cap = &captures[0];
498 assert_eq!(cap.status, Status::SpawnError);
499 assert!(cap.exit_code.is_none());
500 assert!(cap.stdout.is_empty());
501 assert!(cap.duration_ms.is_some());
502 let msg = cap.error.as_deref().unwrap_or_default();
503 assert!(msg.contains("failed to spawn"), "{msg}");
504 assert!(msg.contains("oneharness-binary-xyz"), "{msg}");
505 }
506
507 #[test]
508 fn run_jobs_with_retries_until_the_policy_stops() {
509 let jobs = [job(&["/no/such/first"])];
514 let outcomes = run_jobs_with(&jobs, 1, |i, attempt, cap| {
515 assert_eq!(i, 0);
516 assert_eq!(cap.status, Status::SpawnError);
517 (attempt < 3).then(|| vec![format!("/no/such/retry-{attempt}")])
518 });
519 assert_eq!(outcomes.len(), 1);
520 assert_eq!(outcomes[0].attempts, 3);
521 let err = outcomes[0].capture.error.as_deref().unwrap_or_default();
522 assert!(
523 err.contains("retry-2"),
524 "final capture should be last retry: {err}"
525 );
526 }
527
528 #[test]
529 fn run_jobs_with_a_no_op_policy_runs_once() {
530 let jobs = [job(&["/no/such/binary"])];
531 let outcomes = run_jobs_with(&jobs, 1, |_, _, _| None);
532 assert_eq!(outcomes[0].attempts, 1);
533 assert!(run_jobs_with(&[], 4, |_, _, _| None).is_empty());
535 }
536
537 fn three_line_job() -> Job {
540 #[cfg(not(windows))]
541 let argv = vec![
542 "sh".to_string(),
543 "-c".to_string(),
544 "printf 'a\\nb\\nc\\n'".to_string(),
545 ];
546 #[cfg(windows)]
547 let argv = vec![
548 "cmd".to_string(),
549 "/c".to_string(),
550 "echo a& echo b& echo c".to_string(),
551 ];
552 Job {
553 argv,
554 cwd: None,
555 env: Vec::new(),
556 timeout: Duration::from_secs(10),
557 }
558 }
559
560 #[test]
561 fn streaming_delivers_each_line_and_accumulates_stdout() {
562 let mut lines = Vec::new();
565 let cap = run_job_streaming(&three_line_job(), |line| {
566 lines.push(line.to_string());
567 StreamStep::Continue
568 });
569 assert_eq!(cap.status, Status::Ok);
570 assert_eq!(cap.exit_code, Some(0));
571 assert_eq!(lines.len(), 3, "got {lines:?}");
572 let trimmed: Vec<_> = lines.iter().map(|l| l.trim()).collect();
574 assert_eq!(trimmed, vec!["a", "b", "c"]);
575 for token in ["a", "b", "c"] {
576 assert!(cap.stdout.contains(token), "stdout: {:?}", cap.stdout);
577 }
578 }
579
580 #[test]
581 fn streaming_stops_and_tears_down_on_callback_stop() {
582 let mut count = 0u32;
585 let cap = run_job_streaming(&three_line_job(), |_| {
586 count += 1;
587 StreamStep::Stop
588 });
589 assert_eq!(count, 1, "should stop after the first line");
590 assert_eq!(cap.status, Status::Ok);
591 }
592
593 #[test]
594 fn streaming_spawn_error_is_data_not_a_panic() {
595 let job = job(&["/no/such/oneharness-stream-binary"]);
597 let cap = run_job_streaming(&job, |_| StreamStep::Continue);
598 assert_eq!(cap.status, Status::SpawnError);
599 assert!(cap.error.as_deref().unwrap_or_default().contains("spawn"));
600 }
601
602 #[test]
603 fn resolve_program_falls_back_to_the_name_when_unresolvable() {
604 let name = "oneharness-no-such-binary-zzz";
607 assert_eq!(resolve_program(name), std::ffi::OsString::from(name));
608 }
609
610 #[cfg(windows)]
611 #[test]
612 fn windows_shim_plan_rewrites_a_cmd_only_for_multiline_args() {
613 use std::io::Write;
614
615 let dir = std::env::temp_dir().join(format!("oh-shim-{}", std::process::id()));
618 std::fs::create_dir_all(&dir).unwrap();
619 let cmd_path = dir.join("claude.cmd");
620 let mut f = std::fs::File::create(&cmd_path).unwrap();
621 write!(
622 f,
623 "SET \"_prog=node\"\r\n\"%_prog%\" \"%dp0%\\cli.js\" %*\r\n"
624 )
625 .unwrap();
626 drop(f);
627
628 let dir_str = dir.to_str().unwrap();
629 let script = format!("{dir_str}\\cli.js");
630
631 let multiline = vec!["-p".to_string(), "a\nb\nc".to_string()];
633 let (prog, args) = windows_shim_plan(&cmd_path, &multiline).expect("multiline → rewrite");
634 assert!(
636 std::path::Path::new(&prog)
637 .to_string_lossy()
638 .to_ascii_lowercase()
639 .ends_with("node.exe"),
640 "interpreter should resolve to node.exe, got {prog:?}"
641 );
642 assert_eq!(args, vec![script, "-p".to_string(), "a\nb\nc".to_string()]);
643
644 let single = vec!["-p".to_string(), "hello".to_string()];
646 assert!(windows_shim_plan(&cmd_path, &single).is_none());
647
648 std::fs::remove_dir_all(&dir).ok();
649 }
650
651 #[cfg(windows)]
652 #[test]
653 fn windows_shim_plan_ignores_non_batch_programs() {
654 let exe = resolve_program("where");
657 let multiline = vec!["x\ny".to_string()];
658 assert!(windows_shim_plan(std::path::Path::new(&exe), &multiline).is_none());
659 }
660
661 #[cfg(windows)]
662 #[test]
663 fn resolve_program_finds_a_cmd_shim_on_windows() {
664 let resolved = resolve_program("where");
668 let path = std::path::Path::new(&resolved);
669 assert!(
670 path.is_absolute(),
671 "expected an absolute path, got {resolved:?}"
672 );
673 assert!(path.exists(), "resolved path should exist: {resolved:?}");
674 }
675}