1use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17use crate::error::{Result, SparError};
18
19pub const DEFAULT_TIMEOUT_SECS: u64 = 3600;
26
27const _: () = assert!(DEFAULT_TIMEOUT_SECS >= 3600);
31
32#[derive(Debug, Clone)]
33pub struct ExecOpts {
34 pub cwd: Option<PathBuf>,
35 pub timeout: Duration,
36 pub check: bool,
38 pub env: Vec<(String, String)>,
39 pub stdin: Option<String>,
40 pub stop_descendants: bool,
44}
45
46impl Default for ExecOpts {
47 fn default() -> Self {
48 Self {
49 cwd: None,
50 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
51 check: true,
52 env: Vec::new(),
53 stdin: None,
54 stop_descendants: false,
55 }
56 }
57}
58
59impl ExecOpts {
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 pub fn cwd(mut self, path: impl AsRef<Path>) -> Self {
65 self.cwd = Some(path.as_ref().to_path_buf());
66 self
67 }
68
69 pub fn cwd_opt(mut self, path: Option<PathBuf>) -> Self {
70 self.cwd = path;
71 self
72 }
73
74 pub fn timeout_secs(mut self, secs: u64) -> Self {
75 self.timeout = Duration::from_secs(secs);
76 self
77 }
78
79 pub fn check(mut self, value: bool) -> Self {
80 self.check = value;
81 self
82 }
83
84 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
85 self.env.push((key.into(), value.into()));
86 self
87 }
88
89 pub fn stdin(mut self, text: impl Into<String>) -> Self {
90 self.stdin = Some(text.into());
91 self
92 }
93
94 pub fn stop_descendants(mut self, value: bool) -> Self {
95 self.stop_descendants = value;
96 self
97 }
98}
99
100#[derive(Debug, Clone)]
101pub struct Output {
102 pub stdout: String,
103 pub stdout_bytes: Vec<u8>,
105 pub stderr: String,
106 pub code: i32,
107}
108
109impl Output {
110 pub fn ok(&self) -> bool {
111 self.code == 0
112 }
113}
114
115pub fn exec(argv: &[String], opts: &ExecOpts) -> Result<Output> {
120 let input = opts.stdin.as_deref().map(str::as_bytes);
121 exec_with_input(argv, opts, input)
122}
123
124static INTERRUPTED: AtomicBool = AtomicBool::new(false);
128
129pub fn interrupted() -> bool {
131 INTERRUPTED.load(Ordering::SeqCst)
132}
133
134#[cfg(unix)]
135extern "C" fn note_signal(sig: libc::c_int) {
136 INTERRUPTED.store(true, Ordering::SeqCst);
137 unsafe {
141 libc::signal(sig, libc::SIG_DFL);
142 }
143}
144
145#[cfg(unix)]
153pub fn stop_on_signal() -> Result<()> {
154 for sig in [libc::SIGINT, libc::SIGTERM] {
155 let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
158 action.sa_sigaction = note_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
159 action.sa_flags = libc::SA_RESTART;
161 let failed = unsafe {
162 libc::sigemptyset(&mut action.sa_mask);
163 libc::sigaction(sig, &action, std::ptr::null_mut()) != 0
164 };
165 if failed {
166 return Err(SparError::new(format!(
167 "could not watch for Ctrl-C: {}",
168 std::io::Error::last_os_error()
169 )));
170 }
171 }
172 Ok(())
173}
174
175#[cfg(not(unix))]
176pub fn stop_on_signal() -> Result<()> {
177 Ok(())
178}
179
180pub fn sleep_unless_interrupted(total: Duration) -> bool {
183 sleep_until(total, &INTERRUPTED)
184}
185
186fn sleep_until(total: Duration, stop: &AtomicBool) -> bool {
187 let deadline = Instant::now() + total;
188 while !stop.load(Ordering::SeqCst) {
189 let left = deadline.saturating_duration_since(Instant::now());
190 if left.is_zero() {
191 return false;
192 }
193 std::thread::sleep(left.min(Duration::from_secs(1)));
194 }
195 true
196}
197
198fn exec_with_input(argv: &[String], opts: &ExecOpts, input: Option<&[u8]>) -> Result<Output> {
199 exec_until(argv, opts, input, &INTERRUPTED)
200}
201
202fn exec_until(
206 argv: &[String],
207 opts: &ExecOpts,
208 input: Option<&[u8]>,
209 stop: &AtomicBool,
210) -> Result<Output> {
211 if stop.load(Ordering::SeqCst) {
212 return Err(SparError::interrupted(format!(
213 "interrupted before `{}` started",
214 abbreviate(argv)
215 )));
216 }
217 let program = argv
218 .first()
219 .ok_or_else(|| SparError::new("cannot run an empty command"))?;
220
221 let mut command = Command::new(program);
222 command
223 .args(&argv[1..])
224 .stdout(Stdio::piped())
225 .stderr(Stdio::piped());
226
227 if input.is_some() {
228 command.stdin(Stdio::piped());
229 } else {
230 command.stdin(Stdio::null());
232 }
233
234 if let Some(dir) = &opts.cwd {
235 command.current_dir(dir);
236 }
237 for (key, value) in &opts.env {
238 command.env(key, value);
239 }
240
241 #[cfg(unix)]
242 if opts.stop_descendants {
243 use std::os::unix::process::CommandExt;
244 command.process_group(0);
245 }
246
247 let mut child = command
248 .spawn()
249 .map_err(|e| SparError::new(format!("could not run `{}`: {e}", abbreviate(argv))))?;
250
251 let out_reader = Reader::spawn(child.stdout.take().expect("stdout piped"));
261 let err_reader = Reader::spawn(child.stderr.take().expect("stderr piped"));
262
263 if let Some(bytes) = input {
264 if let Some(mut pipe) = child.stdin.take() {
265 let owned = bytes.to_vec();
266 std::thread::spawn(move || {
271 let _ = pipe.write_all(&owned);
272 });
273 }
274 }
275
276 let deadline = Instant::now() + opts.timeout;
277 let mut poll = Duration::from_millis(5);
278 let mut timed_out = false;
279 let mut stopped = false;
280 let mut quiet_error = None;
281 let status = loop {
282 match child.try_wait() {
283 Ok(Some(status)) => break Some(status),
284 Ok(None) => {
285 if stop.load(Ordering::SeqCst) {
286 if let Err(error) = stop_and_reap(&mut child, opts.stop_descendants) {
287 quiet_error = Some(error);
288 }
289 stopped = true;
290 break None;
291 }
292 if Instant::now() >= deadline {
293 if let Err(error) = stop_and_reap(&mut child, opts.stop_descendants) {
294 quiet_error = Some(error);
295 }
296 timed_out = true;
297 break None;
298 }
299 std::thread::sleep(poll);
300 poll = (poll * 2).min(Duration::from_millis(100));
303 }
304 Err(error) => {
305 return Err(poll_failure(error, argv, || {
306 stop_and_reap(&mut child, opts.stop_descendants)
307 }));
308 }
309 }
310 };
311
312 #[cfg(unix)]
313 if !timed_out && opts.stop_descendants {
314 if let Err(error) = stop_process_group(child.id()) {
315 quiet_error = Some(error);
316 }
317 }
318
319 let stdout = out_reader.collect(DRAIN_GRACE);
327 let stderr = err_reader.collect(DRAIN_GRACE);
328
329 if stopped {
330 let cleanup = quiet_error
331 .map(|error| format!(" Stopping it also failed: {error}."))
332 .unwrap_or_default();
333 return Err(SparError::interrupted(format!(
334 "interrupted: {}.{cleanup}",
335 abbreviate(argv)
336 )));
337 }
338 if let Some(error) = quiet_error {
339 return Err(quiet_point_failure(error, argv));
340 }
341
342 if timed_out {
343 #[cfg(not(unix))]
344 if opts.stop_descendants {
345 return Err(SparError::uncertain_write(format!(
346 "timed out after {}s: {}\nThe direct process was stopped, but remaining child \
347 processes could not be stopped on this platform. The worktree must be inspected \
348 before retrying.",
349 opts.timeout.as_secs(),
350 abbreviate(argv)
351 )));
352 }
353 return Err(SparError::timed_out(format!(
354 "timed out after {}s: {}\nRaise `timeout` on this agent in spar.toml if the model \
355 legitimately needs longer. Not retried: asking again would wait exactly as long a \
356 second time.",
357 opts.timeout.as_secs(),
358 abbreviate(argv)
359 )));
360 }
361
362 Ok(Output {
363 stdout: String::from_utf8_lossy(&stdout).into_owned(),
364 stdout_bytes: stdout,
365 stderr: String::from_utf8_lossy(&stderr).into_owned(),
366 code: status.and_then(|s| s.code()).unwrap_or(-1),
367 })
368}
369
370fn stop_and_reap(child: &mut std::process::Child, stop_descendants: bool) -> std::io::Result<()> {
371 #[cfg(not(unix))]
372 let _ = stop_descendants;
373
374 #[cfg(unix)]
375 if stop_descendants {
376 if let Err(group_error) = stop_process_group(child.id()) {
377 if child.kill().is_ok() {
378 let _ = child.wait();
379 }
380 return Err(group_error);
381 }
382 return child.wait().map(|_| ());
383 }
384
385 match child.kill() {
386 Ok(()) => child.wait().map(|_| ()),
387 Err(kill_error) => match child.try_wait() {
388 Ok(Some(_)) => Ok(()),
389 Ok(None) => Err(kill_error),
390 Err(poll_error) => Err(std::io::Error::new(
391 poll_error.kind(),
392 format!("could not stop the direct process: {kill_error}; {poll_error}"),
393 )),
394 },
395 }
396}
397
398fn poll_failure(
399 error: std::io::Error,
400 argv: &[String],
401 stop_and_reap: impl FnOnce() -> std::io::Result<()>,
402) -> SparError {
403 let cleanup = stop_and_reap()
404 .err()
405 .map(|cleanup| format!(" Cleanup also failed: {cleanup}."))
406 .unwrap_or_default();
407 SparError::uncertain_write(format!(
408 "could not confirm whether `{}` stopped: {error}. SPAR attempted to stop and reap the \
409 direct process and its descendants.{cleanup} The worktree must be inspected before \
410 retrying.",
411 abbreviate(argv),
412 ))
413}
414
415fn quiet_point_failure(error: std::io::Error, argv: &[String]) -> SparError {
416 SparError::uncertain_write(format!(
417 "could not establish a quiet point after `{}`: {error}. The worktree must be inspected \
418 before retrying.",
419 abbreviate(argv)
420 ))
421}
422
423#[cfg(unix)]
424fn stop_process_group(id: u32) -> std::io::Result<()> {
425 let id = i32::try_from(id).map_err(|_| {
426 std::io::Error::new(
427 std::io::ErrorKind::InvalidInput,
428 "the child process id does not fit a platform process-group id",
429 )
430 })?;
431 if unsafe { libc::kill(-id, libc::SIGKILL) } == 0 {
435 return Ok(());
436 }
437 let error = std::io::Error::last_os_error();
438 if error.raw_os_error() == Some(libc::ESRCH) {
439 return Ok(());
440 }
441 Err(error)
442}
443
444const DRAIN_GRACE: Duration = Duration::from_secs(3);
448
449struct Reader {
454 buf: Arc<Mutex<Vec<u8>>>,
455 done: Arc<AtomicBool>,
456}
457
458impl Reader {
459 fn spawn<R: Read + Send + 'static>(mut pipe: R) -> Self {
460 let buf = Arc::new(Mutex::new(Vec::new()));
461 let done = Arc::new(AtomicBool::new(false));
462 let (buf_w, done_w) = (Arc::clone(&buf), Arc::clone(&done));
463 std::thread::spawn(move || {
464 let mut chunk = [0u8; 16 * 1024];
465 loop {
466 match pipe.read(&mut chunk) {
467 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
468 Ok(0) | Err(_) => break,
469 Ok(n) => buf_w
470 .lock()
471 .unwrap_or_else(|e| e.into_inner())
472 .extend_from_slice(&chunk[..n]),
473 }
474 }
475 done_w.store(true, Ordering::Release);
476 });
477 Self { buf, done }
478 }
479
480 fn len(&self) -> usize {
481 self.buf.lock().unwrap_or_else(|e| e.into_inner()).len()
482 }
483
484 fn collect(&self, grace: Duration) -> Vec<u8> {
492 let mut last_len = self.len();
493 let mut quiet_since = Instant::now();
494 let mut poll = Duration::from_micros(100);
495 while !self.done.load(Ordering::Acquire) {
496 let now_len = self.len();
497 if now_len != last_len {
498 last_len = now_len;
499 quiet_since = Instant::now();
500 } else if quiet_since.elapsed() >= grace {
501 break;
502 }
503 std::thread::sleep(poll);
504 poll = (poll * 2).min(Duration::from_millis(10));
505 }
506 self.buf.lock().unwrap_or_else(|e| e.into_inner()).clone()
507 }
508}
509
510pub fn run(argv: &[String], opts: &ExecOpts) -> Result<String> {
513 let out = exec(argv, opts)?;
514 if opts.check && !out.ok() {
515 return Err(SparError::call_failed(failure_message(argv, &out)));
516 }
517 Ok(out.stdout)
518}
519
520pub(crate) fn run_with_input_bytes(
521 argv: &[String],
522 opts: &ExecOpts,
523 input: &[u8],
524) -> Result<String> {
525 let out = exec_with_input(argv, opts, Some(input))?;
526 if opts.check && !out.ok() {
527 return Err(SparError::call_failed(failure_message(argv, &out)));
528 }
529 Ok(out.stdout)
530}
531
532pub fn run_bytes(argv: &[String], opts: &ExecOpts) -> Result<Vec<u8>> {
534 let out = exec(argv, opts)?;
535 if opts.check && !out.ok() {
536 return Err(SparError::call_failed(failure_message(argv, &out)));
537 }
538 Ok(out.stdout_bytes)
539}
540
541pub fn run_str(argv: &[&str], opts: &ExecOpts) -> Result<String> {
543 let owned: Vec<String> = argv.iter().map(|s| (*s).to_string()).collect();
544 run(&owned, opts)
545}
546
547pub fn abbreviate(argv: &[String]) -> String {
549 argv.iter()
550 .map(|arg| {
551 let one_line = arg.split_whitespace().collect::<Vec<_>>().join(" ");
552 if one_line.chars().count() <= 60 {
553 one_line
554 } else {
555 let head: String = one_line.chars().take(57).collect();
556 format!("{head}...")
557 }
558 })
559 .collect::<Vec<_>>()
560 .join(" ")
561}
562
563pub fn failure_message(argv: &[String], out: &Output) -> String {
568 let mut parts = vec![format!(
569 "command failed ({}): {}",
570 out.code,
571 abbreviate(argv)
572 )];
573 for (label, stream) in [("stderr", &out.stderr), ("stdout", &out.stdout)] {
574 let text = stream.trim();
575 if !text.is_empty() {
576 parts.push(format!("--- {label} ---\n{}", tail(text, 1500)));
577 }
578 }
579 if parts.len() == 1 {
580 parts.push("(no output on either stream)".to_string());
581 }
582 parts.join("\n")
583}
584
585fn tail(text: &str, max: usize) -> &str {
587 let count = text.chars().count();
588 if count <= max {
589 return text;
590 }
591 let start = text
592 .char_indices()
593 .nth(count - max)
594 .map(|(i, _)| i)
595 .unwrap_or(0);
596 &text[start..]
597}
598
599pub fn which(program: &str) -> Option<PathBuf> {
601 if program.contains(std::path::MAIN_SEPARATOR) {
602 let path = PathBuf::from(program);
603 return is_executable(&path).then_some(path);
604 }
605 let paths = std::env::var_os("PATH")?;
606 std::env::split_paths(&paths).find_map(|dir| {
607 let candidate = dir.join(program);
608 is_executable(&candidate).then_some(candidate)
609 })
610}
611
612pub fn is_executable(path: &Path) -> bool {
613 #[cfg(unix)]
614 {
615 use std::os::unix::fs::PermissionsExt;
616 match std::fs::metadata(path) {
617 Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
618 Err(_) => false,
619 }
620 }
621 #[cfg(not(unix))]
622 {
623 path.is_file()
624 }
625}
626
627pub fn expand_tilde(path: &str) -> PathBuf {
630 if path == "~" {
631 if let Some(home) = home_dir() {
632 return home;
633 }
634 }
635 if let Some(rest) = path.strip_prefix("~/") {
636 if let Some(home) = home_dir() {
637 return home.join(rest);
638 }
639 }
640 PathBuf::from(path)
641}
642
643pub fn home_dir() -> Option<PathBuf> {
644 std::env::var_os("HOME").map(PathBuf::from)
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 fn proc(out: &str, err: &str, code: i32) -> Output {
652 Output {
653 stdout: out.into(),
654 stdout_bytes: out.as_bytes().to_vec(),
655 stderr: err.into(),
656 code,
657 }
658 }
659
660 fn argv(parts: &[&str]) -> Vec<String> {
661 parts.iter().map(|s| s.to_string()).collect()
662 }
663
664 #[test]
665 fn a_poll_failure_attempts_cleanup_and_is_uncertain() {
666 let stopped = std::cell::Cell::new(false);
667 let error = poll_failure(
668 std::io::Error::other("poll failed"),
669 &argv(&["editor"]),
670 || {
671 stopped.set(true);
672 Ok(())
673 },
674 );
675
676 assert!(stopped.get());
677 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
678 assert!(error.to_string().contains("poll failed"), "{error}");
679 }
680
681 #[test]
682 fn a_stop_or_reap_failure_is_uncertain() {
683 let error =
684 quiet_point_failure(std::io::Error::other("could not reap"), &argv(&["editor"]));
685
686 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
687 assert!(error.to_string().contains("could not reap"), "{error}");
688 }
689
690 #[test]
691 fn stdin_reaches_the_child_byte_for_byte() {
692 let body = "line one \n\nline three\n";
693 let out = run_str(&["/bin/sh", "-c", "cat"], &ExecOpts::new().stdin(body))
694 .expect("the child reads stdin");
695 assert_eq!(body, out);
696 }
697
698 #[test]
699 fn byte_input_reaches_the_child_without_text_conversion() {
700 let input = [b'f', 0xff, 0];
701 let command = argv(&["/bin/sh", "-c", "cat"]);
702 let out = exec_with_input(&command, &ExecOpts::new(), Some(&input))
703 .expect("the child reads byte input");
704
705 assert_eq!(input, out.stdout_bytes.as_slice());
706 }
707
708 #[test]
709 fn stdout_used_when_stderr_is_empty() {
710 let msg = failure_message(&argv(&["claude"]), &proc("You've hit your limit.", "", 1));
711 assert!(msg.contains("hit your limit"), "{msg}");
712 }
713
714 #[test]
715 fn stderr_shown_when_present() {
716 let msg = failure_message(&argv(&["gh"]), &proc("noise", "real reason", 1));
717 assert!(msg.contains("real reason"), "{msg}");
718 }
719
720 #[test]
721 fn both_streams_are_shown_not_just_one() {
722 let msg = failure_message(&argv(&["gh"]), &proc("on stdout", "on stderr", 1));
723 assert!(
724 msg.contains("on stdout") && msg.contains("on stderr"),
725 "{msg}"
726 );
727 }
728
729 #[test]
730 fn says_something_when_both_are_empty() {
731 assert!(failure_message(&argv(&["x"]), &proc("", "", 2)).contains("no output"));
732 }
733
734 #[test]
735 fn long_arguments_are_abbreviated() {
736 let long = "word ".repeat(500);
737 let out = abbreviate(&argv(&["claude", "-p", &long]));
738 assert!(out.len() < 200, "{}", out.len());
739 }
740
741 #[test]
742 fn newlines_in_arguments_do_not_break_the_line() {
743 assert!(!abbreviate(&argv(&["claude", "a\nb\nc"])).contains('\n'));
744 }
745
746 #[test]
747 fn short_arguments_survive_intact() {
748 assert_eq!(
749 "gh pr merge 17",
750 abbreviate(&argv(&["gh", "pr", "merge", "17"]))
751 );
752 }
753
754 #[test]
755 fn abbreviation_never_splits_a_character() {
756 let wide = "\u{1f600}".repeat(200);
759 let out = abbreviate(&argv(&[&wide]));
760 assert!(out.ends_with("..."));
761 }
762
763 #[test]
764 fn exit_code_is_reported() {
765 let out = exec(
766 &argv(&["sh", "-c", "exit 3"]),
767 &ExecOpts::new().check(false),
768 )
769 .unwrap();
770 assert_eq!(3, out.code);
771 }
772
773 #[test]
774 fn check_false_returns_stdout_on_failure() {
775 let text = run(
776 &argv(&["sh", "-c", "echo partial; exit 1"]),
777 &ExecOpts::new().check(false),
778 )
779 .unwrap();
780 assert_eq!("partial\n", text);
781 }
782
783 #[test]
784 fn check_true_fails_loudly() {
785 let err = run(
786 &argv(&["sh", "-c", "echo why >&2; exit 1"]),
787 &ExecOpts::new(),
788 )
789 .unwrap_err();
790 assert!(err.to_string().contains("why"), "{err}");
791 }
792
793 #[test]
794 fn large_output_does_not_deadlock() {
795 let text = run(
797 &argv(&["sh", "-c", "yes hello | head -c 400000"]),
798 &ExecOpts::new().timeout_secs(60),
799 )
800 .unwrap();
801 assert_eq!(400_000, text.len());
802 }
803
804 #[test]
812 fn large_input_and_large_output_together_do_not_deadlock() {
813 let line = "a".repeat(199);
814 let input = std::iter::repeat_n(line.as_str(), 5_000)
815 .collect::<Vec<_>>()
816 .join("\n");
817 assert!(input.len() > 900_000, "the input has to outgrow the pipe");
818
819 let start = Instant::now();
820 let out = run(
823 &argv(&["sh", "-c", "tee /dev/stderr"]),
824 &ExecOpts::new().timeout_secs(60).stdin(input.clone()),
825 )
826 .unwrap();
827
828 assert_eq!(input.len(), out.trim_end().len());
829 assert!(
830 start.elapsed() < Duration::from_secs(30),
831 "the call did not return promptly: {:?}",
832 start.elapsed()
833 );
834 }
835
836 #[test]
840 fn a_surviving_grandchild_holding_the_pipe_cannot_hang_the_timeout() {
841 let start = Instant::now();
842 let err = run(
843 &argv(&["sh", "-c", "sleep 120 & echo parent-output; sleep 60"]),
844 &ExecOpts::new().timeout_secs(1),
845 )
846 .unwrap_err();
847 let elapsed = start.elapsed();
848
849 assert!(err.to_string().contains("timed out"), "{err}");
850 assert!(
851 elapsed < Duration::from_secs(20),
852 "the timeout did not bound the call: {elapsed:?}"
853 );
854 }
855
856 #[test]
857 fn a_surviving_grandchild_does_not_hang_a_normal_exit_either() {
858 let start = Instant::now();
859 let out = run(
860 &argv(&["sh", "-c", "sleep 120 & echo done"]),
861 &ExecOpts::new().timeout_secs(60),
862 )
863 .unwrap();
864 assert!(out.contains("done"), "{out:?}");
865 assert!(
866 start.elapsed() < Duration::from_secs(20),
867 "waited on a grandchild that will never exit"
868 );
869 }
870
871 #[cfg(unix)]
872 #[test]
873 fn a_stopped_process_group_cannot_edit_after_the_parent_exits() {
874 let late = std::env::temp_dir().join(format!(
875 "spar-late-child-{}-{}",
876 std::process::id(),
877 std::thread::current().name().unwrap_or("test")
878 ));
879 let _ = std::fs::remove_file(&late);
880 let out = run(
881 &argv(&[
882 "/bin/sh",
883 "-c",
884 "(sleep 1; touch \"$1\") & echo done",
885 "sh",
886 late.to_str().unwrap(),
887 ]),
888 &ExecOpts::new().stop_descendants(true),
889 )
890 .unwrap();
891
892 assert!(out.contains("done"), "{out:?}");
893 std::thread::sleep(Duration::from_millis(1_200));
894 assert!(!late.exists(), "a descendant edited after the quiet point");
895 }
896
897 #[test]
898 fn timeout_kills_and_explains() {
899 let err = run(
900 &argv(&["sh", "-c", "sleep 30"]),
901 &ExecOpts::new().timeout_secs(1),
902 )
903 .unwrap_err();
904 assert!(err.to_string().contains("timed out"), "{err}");
905 }
906
907 #[test]
908 fn missing_binary_names_the_command() {
909 let err = exec(
910 &argv(&["spar-definitely-not-a-real-binary"]),
911 &ExecOpts::new(),
912 )
913 .unwrap_err();
914 assert!(
915 err.to_string()
916 .contains("spar-definitely-not-a-real-binary"),
917 "{err}"
918 );
919 }
920
921 #[test]
922 fn tilde_expands_against_home() {
923 std::env::set_var("HOME", "/home/someone");
924 assert_eq!(PathBuf::from("/home/someone/bin"), expand_tilde("~/bin"));
925 assert_eq!(PathBuf::from("/absolute"), expand_tilde("/absolute"));
926 assert_eq!(PathBuf::from("~notauser/x"), expand_tilde("~notauser/x"));
927 }
928}
929
930#[cfg(test)]
931mod timeout_kind_tests {
932 use super::*;
933 use crate::error::ErrorKind;
934
935 fn argv(parts: &[&str]) -> Vec<String> {
936 parts.iter().map(|s| s.to_string()).collect()
937 }
938
939 #[test]
943 fn a_timeout_is_marked_as_one_and_is_not_worth_retrying() {
944 let err = run(
945 &["sh".to_string(), "-c".to_string(), "sleep 30".to_string()],
946 &ExecOpts::new().timeout_secs(1),
947 )
948 .unwrap_err();
949
950 assert_eq!(ErrorKind::TimedOut, err.kind());
951 assert!(!err.worth_retrying());
952 assert!(err.to_string().contains("Not retried"), "{err}");
953 }
954
955 #[test]
959 fn a_cancelled_command_is_stopped_and_says_so() {
960 let stop = Arc::new(AtomicBool::new(false));
961 let flag = Arc::clone(&stop);
962 std::thread::spawn(move || {
963 std::thread::sleep(Duration::from_millis(200));
964 flag.store(true, Ordering::SeqCst);
965 });
966 let started = Instant::now();
967 let err = exec_until(
968 &argv(&["sh", "-c", "sleep 30"]),
969 &ExecOpts::new().timeout_secs(30).stop_descendants(true),
970 None,
971 &stop,
972 )
973 .unwrap_err();
974
975 assert_eq!(ErrorKind::Interrupted, err.kind());
976 assert!(
977 err.to_string().starts_with("interrupted: sh -c sleep 30"),
978 "{err}"
979 );
980 assert!(
981 started.elapsed() < Duration::from_secs(5),
982 "took {:?}",
983 started.elapsed()
984 );
985 assert!(!err.worth_retrying());
986 }
987
988 #[test]
989 fn a_command_asked_for_after_the_stop_is_refused_without_running() {
990 let stop = AtomicBool::new(true);
991 let err = exec_until(
992 &argv(&["sh", "-c", "exit 0"]),
993 &ExecOpts::new(),
994 None,
995 &stop,
996 )
997 .unwrap_err();
998 assert_eq!(ErrorKind::Interrupted, err.kind());
999 assert!(err.to_string().contains("before"), "{err}");
1000 }
1001
1002 #[test]
1003 fn a_sleep_ends_early_once_the_stop_is_set_and_on_time_otherwise() {
1004 let stop = Arc::new(AtomicBool::new(false));
1005 let flag = Arc::clone(&stop);
1006 std::thread::spawn(move || {
1007 std::thread::sleep(Duration::from_millis(100));
1008 flag.store(true, Ordering::SeqCst);
1009 });
1010 let started = Instant::now();
1011 assert!(sleep_until(Duration::from_secs(30), &stop));
1012 assert!(started.elapsed() < Duration::from_secs(5));
1013
1014 let calm = AtomicBool::new(false);
1015 let started = Instant::now();
1016 assert!(!sleep_until(Duration::from_millis(50), &calm));
1017 assert!(started.elapsed() >= Duration::from_millis(50));
1018 }
1019
1020 #[test]
1024 fn a_non_zero_exit_is_the_call_failing_rather_than_the_answer() {
1025 let err = run(
1026 &["sh".to_string(), "-c".to_string(), "exit 1".to_string()],
1027 &ExecOpts::new(),
1028 )
1029 .unwrap_err();
1030
1031 assert_eq!(ErrorKind::CallFailed, err.kind());
1032 assert!(err.worth_retrying());
1036 }
1037}