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,
47}
48
49impl Default for ExecOpts {
50 fn default() -> Self {
51 Self {
52 cwd: None,
53 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
54 check: true,
55 env: Vec::new(),
56 stdin: None,
57 stop_descendants: false,
58 }
59 }
60}
61
62impl ExecOpts {
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn cwd(mut self, path: impl AsRef<Path>) -> Self {
68 self.cwd = Some(path.as_ref().to_path_buf());
69 self
70 }
71
72 pub fn cwd_opt(mut self, path: Option<PathBuf>) -> Self {
73 self.cwd = path;
74 self
75 }
76
77 pub fn timeout_secs(mut self, secs: u64) -> Self {
78 self.timeout = Duration::from_secs(secs);
79 self
80 }
81
82 pub fn check(mut self, value: bool) -> Self {
83 self.check = value;
84 self
85 }
86
87 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
88 self.env.push((key.into(), value.into()));
89 self
90 }
91
92 pub fn stdin(mut self, text: impl Into<String>) -> Self {
93 self.stdin = Some(text.into());
94 self
95 }
96
97 pub fn stop_descendants(mut self, value: bool) -> Self {
98 self.stop_descendants = value;
99 self
100 }
101}
102
103#[derive(Debug, Clone)]
104pub struct Output {
105 pub stdout: String,
106 pub stdout_bytes: Vec<u8>,
108 pub stderr: String,
109 pub code: i32,
110}
111
112impl Output {
113 pub fn ok(&self) -> bool {
114 self.code == 0
115 }
116}
117
118pub fn exec(argv: &[String], opts: &ExecOpts) -> Result<Output> {
123 let input = opts.stdin.as_deref().map(str::as_bytes);
124 exec_with_input(argv, opts, input)
125}
126
127static INTERRUPTED: AtomicBool = AtomicBool::new(false);
131
132pub fn interrupted() -> bool {
134 INTERRUPTED.load(Ordering::SeqCst)
135}
136
137#[cfg(unix)]
138extern "C" fn note_signal(sig: libc::c_int) {
139 INTERRUPTED.store(true, Ordering::SeqCst);
140 unsafe {
144 libc::signal(sig, libc::SIG_DFL);
145 }
146}
147
148#[cfg(unix)]
156pub fn stop_on_signal() -> Result<()> {
157 for sig in [libc::SIGINT, libc::SIGTERM] {
158 let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
161 action.sa_sigaction = note_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
162 action.sa_flags = libc::SA_RESTART;
164 let failed = unsafe {
165 libc::sigemptyset(&mut action.sa_mask);
166 libc::sigaction(sig, &action, std::ptr::null_mut()) != 0
167 };
168 if failed {
169 return Err(SparError::new(format!(
170 "could not watch for Ctrl-C: {}",
171 std::io::Error::last_os_error()
172 )));
173 }
174 }
175 Ok(())
176}
177
178#[cfg(not(unix))]
179pub fn stop_on_signal() -> Result<()> {
180 Ok(())
181}
182
183pub fn sleep_unless_interrupted(total: Duration) -> bool {
186 sleep_until(total, &INTERRUPTED)
187}
188
189fn sleep_until(total: Duration, stop: &AtomicBool) -> bool {
190 let deadline = Instant::now() + total;
191 while !stop.load(Ordering::SeqCst) {
192 let left = deadline.saturating_duration_since(Instant::now());
193 if left.is_zero() {
194 return false;
195 }
196 std::thread::sleep(left.min(Duration::from_secs(1)));
197 }
198 true
199}
200
201fn exec_with_input(argv: &[String], opts: &ExecOpts, input: Option<&[u8]>) -> Result<Output> {
202 exec_until(argv, opts, input, &INTERRUPTED)
203}
204
205fn exec_until(
209 argv: &[String],
210 opts: &ExecOpts,
211 input: Option<&[u8]>,
212 stop: &AtomicBool,
213) -> Result<Output> {
214 if stop.load(Ordering::SeqCst) {
215 return Err(SparError::interrupted(format!(
216 "interrupted before `{}` started",
217 abbreviate(argv)
218 )));
219 }
220 let program = argv
221 .first()
222 .ok_or_else(|| SparError::new("cannot run an empty command"))?;
223
224 let mut command = Command::new(program);
225 command
226 .args(&argv[1..])
227 .stdout(Stdio::piped())
228 .stderr(Stdio::piped());
229
230 if input.is_some() {
231 command.stdin(Stdio::piped());
232 } else {
233 command.stdin(Stdio::null());
235 }
236
237 if let Some(dir) = &opts.cwd {
238 command.current_dir(dir);
239 }
240 for (key, value) in &opts.env {
241 command.env(key, value);
242 }
243
244 #[cfg(unix)]
245 let token = opts.stop_descendants.then(call_token);
246 #[cfg(unix)]
247 if let Some(token) = &token {
248 use std::os::unix::process::CommandExt;
249 command.process_group(0);
250 let outer = std::env::var(CALLS_VAR).ok();
251 command.env(CALLS_VAR, calls_value(outer.as_deref(), token));
252 }
253
254 let mut child = command
255 .spawn()
256 .map_err(|e| SparError::new(format!("could not run `{}`: {e}", abbreviate(argv))))?;
257
258 #[cfg(unix)]
259 let mut tree = token.map(|token| Descendants::new(child.id(), token));
260 #[cfg(not(unix))]
261 let tree: Option<Descendants> = None;
262
263 let out_reader = Reader::spawn(child.stdout.take().expect("stdout piped"));
273 let err_reader = Reader::spawn(child.stderr.take().expect("stderr piped"));
274
275 if let Some(bytes) = input {
276 if let Some(mut pipe) = child.stdin.take() {
277 let owned = bytes.to_vec();
278 std::thread::spawn(move || {
283 let _ = pipe.write_all(&owned);
284 });
285 }
286 }
287
288 let deadline = Instant::now() + opts.timeout;
289 let mut poll = Duration::from_millis(5);
290 let mut timed_out = false;
291 let mut stopped = false;
292 let mut quiet_error = None;
293 let status = loop {
294 match child.try_wait() {
295 Ok(Some(status)) => break Some(status),
296 Ok(None) => {
297 if stop.load(Ordering::SeqCst) {
298 if let Err(error) = stop_and_reap(&mut child, tree.as_ref()) {
299 quiet_error = Some(error);
300 }
301 stopped = true;
302 break None;
303 }
304 if Instant::now() >= deadline {
305 if let Err(error) = stop_and_reap(&mut child, tree.as_ref()) {
306 quiet_error = Some(error);
307 }
308 timed_out = true;
309 break None;
310 }
311 #[cfg(unix)]
312 if let Some(tree) = tree.as_mut() {
313 tree.sample_if_due();
314 }
315 std::thread::sleep(poll);
316 poll = (poll * 2).min(Duration::from_millis(100));
319 }
320 Err(error) => {
321 return Err(poll_failure(error, argv, || {
322 stop_and_reap(&mut child, tree.as_ref())
323 }));
324 }
325 }
326 };
327
328 #[cfg(unix)]
330 if let (Some(_), Some(tree)) = (status, &tree) {
331 if let Err(error) = tree.stop(false) {
332 quiet_error = Some(error);
333 }
334 }
335
336 let stdout = out_reader.collect(DRAIN_GRACE);
344 let stderr = err_reader.collect(DRAIN_GRACE);
345
346 if stopped {
347 let cleanup = quiet_error
348 .map(|error| format!(" Stopping it also failed: {error}."))
349 .unwrap_or_default();
350 return Err(SparError::interrupted(format!(
351 "interrupted: {}.{cleanup}",
352 abbreviate(argv)
353 )));
354 }
355 if let Some(error) = quiet_error {
356 return Err(quiet_point_failure(error, argv));
357 }
358
359 if timed_out {
360 #[cfg(not(unix))]
361 if opts.stop_descendants {
362 return Err(SparError::uncertain_write(format!(
363 "timed out after {}s: {}\nThe direct process was stopped, but remaining child \
364 processes could not be stopped on this platform. The worktree must be inspected \
365 before retrying.",
366 opts.timeout.as_secs(),
367 abbreviate(argv)
368 )));
369 }
370 return Err(SparError::timed_out(format!(
371 "timed out after {}s: {}\nRaise `timeout` on this agent in spar.toml if the model \
372 legitimately needs longer. Not retried: asking again would wait exactly as long a \
373 second time.",
374 opts.timeout.as_secs(),
375 abbreviate(argv)
376 )));
377 }
378
379 Ok(Output {
380 stdout: String::from_utf8_lossy(&stdout).into_owned(),
381 stdout_bytes: stdout,
382 stderr: String::from_utf8_lossy(&stderr).into_owned(),
383 code: status.and_then(|s| s.code()).unwrap_or(-1),
384 })
385}
386
387fn stop_and_reap(
388 child: &mut std::process::Child,
389 tree: Option<&Descendants>,
390) -> std::io::Result<()> {
391 #[cfg(not(unix))]
392 let _ = tree;
393
394 #[cfg(unix)]
395 if let Some(tree) = tree {
396 if let Err(tree_error) = tree.stop(true) {
397 if child.kill().is_ok() {
398 let _ = child.wait();
399 }
400 return Err(tree_error);
401 }
402 return child.wait().map(|_| ());
403 }
404
405 match child.kill() {
406 Ok(()) => child.wait().map(|_| ()),
407 Err(kill_error) => match child.try_wait() {
408 Ok(Some(_)) => Ok(()),
409 Ok(None) => Err(kill_error),
410 Err(poll_error) => Err(std::io::Error::new(
411 poll_error.kind(),
412 format!("could not stop the direct process: {kill_error}; {poll_error}"),
413 )),
414 },
415 }
416}
417
418fn poll_failure(
419 error: std::io::Error,
420 argv: &[String],
421 stop_and_reap: impl FnOnce() -> std::io::Result<()>,
422) -> SparError {
423 let cleanup = stop_and_reap()
424 .err()
425 .map(|cleanup| format!(" Cleanup also failed: {cleanup}."))
426 .unwrap_or_default();
427 SparError::uncertain_write(format!(
428 "could not confirm whether `{}` stopped: {error}. SPAR attempted to stop and reap the \
429 direct process and its descendants.{cleanup} The worktree must be inspected before \
430 retrying.",
431 abbreviate(argv),
432 ))
433}
434
435fn quiet_point_failure(error: std::io::Error, argv: &[String]) -> SparError {
436 SparError::uncertain_write(format!(
437 "could not establish a quiet point after `{}`: {error}. The worktree must be inspected \
438 before retrying.",
439 abbreviate(argv)
440 ))
441}
442
443#[cfg(unix)]
458const CALLS_VAR: &str = "SPAR_CALLS";
459
460#[cfg(unix)]
464const SAMPLE_EVERY: Duration = Duration::from_millis(500);
465
466#[cfg(unix)]
469const FREEZE_ROUNDS: usize = 8;
470
471#[cfg(unix)]
472static NEXT_CALL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
473
474#[cfg(unix)]
477fn call_token() -> String {
478 let nanos = std::time::SystemTime::now()
479 .duration_since(std::time::UNIX_EPOCH)
480 .map(|d| d.as_nanos())
481 .unwrap_or(0);
482 format!(
483 "{}.{nanos}.{}",
484 std::process::id(),
485 NEXT_CALL.fetch_add(1, Ordering::Relaxed)
486 )
487}
488
489#[cfg(unix)]
490fn calls_value(outer: Option<&str>, token: &str) -> String {
491 match outer {
492 Some(outer) if !outer.is_empty() => format!("{outer}:{token}"),
493 _ => token.to_string(),
494 }
495}
496
497#[cfg(unix)]
500fn carries(environment: &[u8], token: &str) -> bool {
501 let prefix = format!("{CALLS_VAR}=");
502 environment
503 .split(|&b| b == 0)
504 .filter_map(|entry| entry.strip_prefix(prefix.as_bytes()))
505 .any(|value| value.split(|&b| b == b':').any(|t| t == token.as_bytes()))
506}
507
508#[cfg(unix)]
510struct Descendants {
511 root: u32,
512 token: String,
513 floor: Option<u64>,
517 seen: std::collections::BTreeMap<libc::pid_t, u64>,
520 sessions: std::collections::BTreeMap<libc::pid_t, Option<u64>>,
525 sampled: Instant,
526}
527
528#[cfg(not(unix))]
529struct Descendants;
530
531#[cfg(unix)]
532impl Descendants {
533 fn new(root: u32, token: String) -> Self {
534 let floor = libc::pid_t::try_from(root)
535 .ok()
536 .and_then(table::entry)
537 .map(|entry| entry.start);
538 Self {
539 root,
540 token,
541 floor,
542 seen: Default::default(),
543 sessions: Default::default(),
544 sampled: Instant::now(),
545 }
546 }
547
548 fn sample_if_due(&mut self) {
551 if self.sampled.elapsed() < SAMPLE_EVERY {
552 return;
553 }
554 self.sampled = Instant::now();
555 let (Ok(root), Ok(procs)) = (libc::pid_t::try_from(self.root), table::list()) else {
556 return;
557 };
558 let found = self.find(&procs, root, true, true, false);
561 let my_session = unsafe { libc::getsid(0) };
562 let start_of = |pid: libc::pid_t| procs.iter().find(|p| p.pid == pid).map(|p| p.start);
563 let found: Vec<&Entry> = procs.iter().filter(|p| found.contains(&p.pid)).collect();
564 self.seen = found.iter().map(|p| (p.pid, p.start)).collect();
565 self.sessions = found
566 .iter()
567 .filter(|p| p.sid != my_session)
568 .map(|p| (p.sid, start_of(p.sid)))
569 .collect();
570 }
571
572 fn stop(&self, root_alive: bool) -> std::io::Result<()> {
580 let root = libc::pid_t::try_from(self.root).map_err(|_| {
581 std::io::Error::new(
582 std::io::ErrorKind::InvalidInput,
583 "the child process id does not fit a platform process-group id",
584 )
585 })?;
586 let mut frozen = std::collections::BTreeSet::new();
587 let mut root_group = true;
588 let searched = self.freeze(root, root_alive, &mut frozen, &mut root_group);
589
590 let mut killed = Ok(());
595 if root_group {
596 killed = signal_group(root, libc::SIGKILL);
597 }
598 for pid in &frozen {
599 killed = killed.and(signal(*pid, libc::SIGKILL));
600 }
601 searched.and(killed)
602 }
603
604 fn freeze(
608 &self,
609 root: libc::pid_t,
610 root_alive: bool,
611 frozen: &mut std::collections::BTreeSet<libc::pid_t>,
612 root_group: &mut bool,
613 ) -> std::io::Result<()> {
614 let mut refused: std::collections::BTreeSet<libc::pid_t> = Default::default();
615 let mut failure = None;
616 if root_alive {
617 if let Err(error) = signal_group(root, libc::SIGSTOP) {
618 failure.get_or_insert(error);
619 }
620 }
621 let mut busy = false;
625 for _ in 0..FREEZE_ROUNDS {
626 let procs = table::list()?;
627 if !root_alive && procs.iter().any(|p| p.pid == root) {
630 *root_group = false;
631 }
632 let found = self.find(&procs, root, root_alive, *root_group, true);
633 let fresh: Vec<libc::pid_t> = found
634 .iter()
635 .filter(|&&pid| !frozen.contains(&pid) && !refused.contains(&pid))
636 .copied()
637 .collect();
638 if fresh.is_empty() {
639 if !busy {
640 return failure.map_or(Ok(()), Err);
641 }
642 busy = false;
643 continue;
644 }
645 busy = true;
646 for pid in fresh {
647 match signal(pid, libc::SIGSTOP) {
648 Ok(()) => {
649 frozen.insert(pid);
650 }
651 Err(error) => {
652 refused.insert(pid);
653 failure.get_or_insert(error);
654 }
655 }
656 }
657 }
658 Err(failure.unwrap_or_else(|| {
659 std::io::Error::other(
660 "processes below the call kept starting while they were being stopped",
661 )
662 }))
663 }
664
665 fn find(
676 &self,
677 procs: &[Entry],
678 root: libc::pid_t,
679 root_alive: bool,
680 root_group: bool,
681 marks: bool,
682 ) -> std::collections::BTreeSet<libc::pid_t> {
683 let me = unsafe { libc::getpid() };
684 let uid = unsafe { libc::geteuid() };
685 let my_session = unsafe { libc::getsid(0) };
686 let by_pid: std::collections::HashMap<libc::pid_t, &Entry> =
687 procs.iter().map(|p| (p.pid, p)).collect();
688 let recorded = |sid: libc::pid_t| match self.sessions.get(&sid) {
691 None => false,
692 Some(leader) => by_pid
693 .get(&sid)
694 .is_none_or(|now| *leader == Some(now.start)),
695 };
696 let marked = |p: &Entry| {
697 marks
698 && p.uid == uid
699 && self.floor.is_none_or(|floor| p.start >= floor)
700 && table::environment(p.pid).is_some_and(|env| carries(&env, &self.token))
701 };
702 let mut ours: std::collections::BTreeSet<libc::pid_t> = procs
703 .iter()
704 .filter(|p| p.pid != me)
705 .filter(|p| {
706 (root_alive && p.pid == root)
707 || (root_group && p.pgid == root)
708 || self.seen.get(&p.pid) == Some(&p.start)
709 || recorded(p.sid)
710 || marked(p)
711 })
712 .map(|p| p.pid)
713 .collect();
714 loop {
715 let sessions: std::collections::BTreeSet<libc::pid_t> = procs
716 .iter()
717 .filter(|p| ours.contains(&p.pid) && p.sid != my_session)
718 .map(|p| p.sid)
719 .collect();
720 let before = ours.len();
721 for p in procs {
722 if p.pid != me
723 && (ours.contains(&p.ppid)
724 || ours.contains(&p.pgid)
725 || sessions.contains(&p.sid))
726 {
727 ours.insert(p.pid);
728 }
729 }
730 if ours.len() == before {
731 return ours;
732 }
733 }
734 }
735}
736
737#[cfg(unix)]
741fn signal(target: libc::pid_t, sig: libc::c_int) -> std::io::Result<()> {
742 let me = unsafe { libc::getpid() };
743 let my_group = unsafe { libc::getpgrp() };
744 if target == 0 || target == -1 || target == me || target == -my_group {
745 return Ok(());
746 }
747 if unsafe { libc::kill(target, sig) } == 0 {
748 return Ok(());
749 }
750 let error = std::io::Error::last_os_error();
751 if error.raw_os_error() == Some(libc::ESRCH) {
752 return Ok(());
753 }
754 Err(error)
755}
756
757#[cfg(unix)]
761fn signal_group(group: libc::pid_t, sig: libc::c_int) -> std::io::Result<()> {
762 match signal(-group, sig) {
763 Err(error) if error.raw_os_error() == Some(libc::EPERM) => table::list()?
764 .iter()
765 .filter(|p| p.pgid == group)
766 .try_for_each(|p| signal(p.pid, sig)),
767 other => other,
768 }
769}
770
771#[cfg(unix)]
773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
774struct Entry {
775 pid: libc::pid_t,
776 ppid: libc::pid_t,
777 pgid: libc::pid_t,
778 sid: libc::pid_t,
779 uid: libc::uid_t,
780 start: u64,
782}
783
784#[cfg(target_os = "macos")]
785mod table {
786 use super::Entry;
787
788 pub(super) fn list() -> std::io::Result<Vec<Entry>> {
789 let estimate = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
791 let mut room = usize::try_from(estimate).unwrap_or(0) + 256;
792 loop {
793 let mut pids: Vec<libc::pid_t> = vec![0; room];
794 let bytes = libc::c_int::try_from(room * std::mem::size_of::<libc::pid_t>())
795 .map_err(std::io::Error::other)?;
796 let listed = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) };
797 let Ok(listed) = usize::try_from(listed) else {
798 return Err(std::io::Error::last_os_error());
799 };
800 if listed >= room {
802 room *= 2;
803 continue;
804 }
805 pids.truncate(listed);
806 return Ok(pids.into_iter().filter_map(entry).collect());
807 }
808 }
809
810 pub(super) fn entry(pid: libc::pid_t) -> Option<Entry> {
813 let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
814 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
815 let filled = unsafe {
816 libc::proc_pidinfo(
817 pid,
818 libc::PROC_PIDTBSDINFO,
819 0,
820 info.as_mut_ptr().cast(),
821 size,
822 )
823 };
824 if filled != size {
825 return None;
826 }
827 let info = unsafe { info.assume_init() };
830 let sid = unsafe { libc::getsid(pid) };
831 if sid < 0 {
832 return None;
833 }
834 Some(Entry {
835 pid,
836 ppid: libc::pid_t::try_from(info.pbi_ppid).ok()?,
837 pgid: libc::pid_t::try_from(info.pbi_pgid).ok()?,
838 sid,
839 uid: info.pbi_uid,
840 start: info
841 .pbi_start_tvsec
842 .saturating_mul(1_000_000)
843 .saturating_add(info.pbi_start_tvusec),
844 })
845 }
846
847 pub(super) fn environment(pid: libc::pid_t) -> Option<Vec<u8>> {
848 let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
849 let mut size: libc::size_t = 0;
850 let sized = unsafe {
852 libc::sysctl(
853 mib.as_mut_ptr(),
854 3,
855 std::ptr::null_mut(),
856 &mut size,
857 std::ptr::null_mut(),
858 0,
859 )
860 };
861 if sized != 0 || size == 0 {
862 return None;
863 }
864 let mut buf = vec![0u8; size];
865 let read = unsafe {
866 libc::sysctl(
867 mib.as_mut_ptr(),
868 3,
869 buf.as_mut_ptr().cast(),
870 &mut size,
871 std::ptr::null_mut(),
872 0,
873 )
874 };
875 if read != 0 {
876 return None;
877 }
878 buf.truncate(size);
879 super::procargs_environment(&buf).map(<[u8]>::to_vec)
880 }
881}
882
883#[cfg(target_os = "linux")]
884mod table {
885 use super::Entry;
886 use std::os::unix::fs::MetadataExt;
887
888 pub(super) fn list() -> std::io::Result<Vec<Entry>> {
889 let mut procs = Vec::new();
890 for item in std::fs::read_dir("/proc")? {
891 let Ok(item) = item else { continue };
892 let Some(pid) = item.file_name().to_str().and_then(|name| name.parse().ok()) else {
894 continue;
895 };
896 procs.extend(entry(pid));
897 }
898 Ok(procs)
899 }
900
901 pub(super) fn entry(pid: libc::pid_t) -> Option<Entry> {
903 let dir = format!("/proc/{pid}");
904 let uid = std::fs::metadata(&dir).ok()?.uid();
905 let stat = std::fs::read(format!("{dir}/stat")).ok()?;
906 super::parse_stat(pid, &String::from_utf8_lossy(&stat), uid)
907 }
908
909 pub(super) fn environment(pid: libc::pid_t) -> Option<Vec<u8>> {
910 std::fs::read(format!("/proc/{pid}/environ")).ok()
911 }
912}
913
914#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))]
917mod table {
918 use super::Entry;
919
920 pub(super) fn list() -> std::io::Result<Vec<Entry>> {
921 Ok(Vec::new())
922 }
923
924 pub(super) fn entry(_: libc::pid_t) -> Option<Entry> {
925 None
926 }
927
928 pub(super) fn environment(_: libc::pid_t) -> Option<Vec<u8>> {
929 None
930 }
931}
932
933#[cfg(any(target_os = "macos", test))]
937fn procargs_environment(buf: &[u8]) -> Option<&[u8]> {
938 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
939 let rest = &buf[4..];
940 let path_end = rest.iter().position(|&b| b == 0)?;
941 let rest = &rest[path_end..];
942 let args_start = rest.iter().position(|&b| b != 0)?;
943 let mut rest = &rest[args_start..];
944 for _ in 0..argc {
945 let end = rest.iter().position(|&b| b == 0)?;
946 rest = &rest[end + 1..];
947 }
948 let mut end = 0;
949 for entry in rest.split(|&b| b == 0) {
950 if entry.is_empty() {
951 break;
952 }
953 end += entry.len() + 1;
954 }
955 Some(&rest[..end.min(rest.len())])
956}
957
958#[cfg(all(unix, any(target_os = "linux", test)))]
962fn parse_stat(pid: libc::pid_t, stat: &str, uid: libc::uid_t) -> Option<Entry> {
963 let (_, rest) = stat.rsplit_once(')')?;
964 let fields: Vec<&str> = rest.split_whitespace().collect();
965 Some(Entry {
966 pid,
967 ppid: fields.get(1)?.parse().ok()?,
968 pgid: fields.get(2)?.parse().ok()?,
969 sid: fields.get(3)?.parse().ok()?,
970 uid,
971 start: fields.get(19)?.parse().ok()?,
972 })
973}
974
975const DRAIN_GRACE: Duration = Duration::from_secs(3);
979
980struct Reader {
985 buf: Arc<Mutex<Vec<u8>>>,
986 done: Arc<AtomicBool>,
987}
988
989impl Reader {
990 fn spawn<R: Read + Send + 'static>(mut pipe: R) -> Self {
991 let buf = Arc::new(Mutex::new(Vec::new()));
992 let done = Arc::new(AtomicBool::new(false));
993 let (buf_w, done_w) = (Arc::clone(&buf), Arc::clone(&done));
994 std::thread::spawn(move || {
995 let mut chunk = [0u8; 16 * 1024];
996 loop {
997 match pipe.read(&mut chunk) {
998 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
999 Ok(0) | Err(_) => break,
1000 Ok(n) => buf_w
1001 .lock()
1002 .unwrap_or_else(|e| e.into_inner())
1003 .extend_from_slice(&chunk[..n]),
1004 }
1005 }
1006 done_w.store(true, Ordering::Release);
1007 });
1008 Self { buf, done }
1009 }
1010
1011 fn len(&self) -> usize {
1012 self.buf.lock().unwrap_or_else(|e| e.into_inner()).len()
1013 }
1014
1015 fn collect(&self, grace: Duration) -> Vec<u8> {
1023 let mut last_len = self.len();
1024 let mut quiet_since = Instant::now();
1025 let mut poll = Duration::from_micros(100);
1026 while !self.done.load(Ordering::Acquire) {
1027 let now_len = self.len();
1028 if now_len != last_len {
1029 last_len = now_len;
1030 quiet_since = Instant::now();
1031 } else if quiet_since.elapsed() >= grace {
1032 break;
1033 }
1034 std::thread::sleep(poll);
1035 poll = (poll * 2).min(Duration::from_millis(10));
1036 }
1037 self.buf.lock().unwrap_or_else(|e| e.into_inner()).clone()
1038 }
1039}
1040
1041pub fn run(argv: &[String], opts: &ExecOpts) -> Result<String> {
1044 let out = exec(argv, opts)?;
1045 if opts.check && !out.ok() {
1046 return Err(SparError::call_failed(failure_message(argv, &out)));
1047 }
1048 Ok(out.stdout)
1049}
1050
1051pub(crate) fn run_with_input_bytes(
1052 argv: &[String],
1053 opts: &ExecOpts,
1054 input: &[u8],
1055) -> Result<String> {
1056 let out = exec_with_input(argv, opts, Some(input))?;
1057 if opts.check && !out.ok() {
1058 return Err(SparError::call_failed(failure_message(argv, &out)));
1059 }
1060 Ok(out.stdout)
1061}
1062
1063pub fn run_bytes(argv: &[String], opts: &ExecOpts) -> Result<Vec<u8>> {
1065 let out = exec(argv, opts)?;
1066 if opts.check && !out.ok() {
1067 return Err(SparError::call_failed(failure_message(argv, &out)));
1068 }
1069 Ok(out.stdout_bytes)
1070}
1071
1072pub fn run_str(argv: &[&str], opts: &ExecOpts) -> Result<String> {
1074 let owned: Vec<String> = argv.iter().map(|s| (*s).to_string()).collect();
1075 run(&owned, opts)
1076}
1077
1078pub fn abbreviate(argv: &[String]) -> String {
1080 argv.iter()
1081 .map(|arg| {
1082 let one_line = arg.split_whitespace().collect::<Vec<_>>().join(" ");
1083 if one_line.chars().count() <= 60 {
1084 one_line
1085 } else {
1086 let head: String = one_line.chars().take(57).collect();
1087 format!("{head}...")
1088 }
1089 })
1090 .collect::<Vec<_>>()
1091 .join(" ")
1092}
1093
1094pub fn failure_message(argv: &[String], out: &Output) -> String {
1099 let mut parts = vec![format!(
1100 "command failed ({}): {}",
1101 out.code,
1102 abbreviate(argv)
1103 )];
1104 for (label, stream) in [("stderr", &out.stderr), ("stdout", &out.stdout)] {
1105 let text = stream.trim();
1106 if !text.is_empty() {
1107 parts.push(format!("--- {label} ---\n{}", tail(text, 1500)));
1108 }
1109 }
1110 if parts.len() == 1 {
1111 parts.push("(no output on either stream)".to_string());
1112 }
1113 parts.join("\n")
1114}
1115
1116fn tail(text: &str, max: usize) -> &str {
1118 let count = text.chars().count();
1119 if count <= max {
1120 return text;
1121 }
1122 let start = text
1123 .char_indices()
1124 .nth(count - max)
1125 .map(|(i, _)| i)
1126 .unwrap_or(0);
1127 &text[start..]
1128}
1129
1130pub fn which(program: &str) -> Option<PathBuf> {
1132 if program.contains(std::path::MAIN_SEPARATOR) {
1133 let path = PathBuf::from(program);
1134 return is_executable(&path).then_some(path);
1135 }
1136 let paths = std::env::var_os("PATH")?;
1137 std::env::split_paths(&paths).find_map(|dir| {
1138 let candidate = dir.join(program);
1139 is_executable(&candidate).then_some(candidate)
1140 })
1141}
1142
1143pub fn is_executable(path: &Path) -> bool {
1144 #[cfg(unix)]
1145 {
1146 use std::os::unix::fs::PermissionsExt;
1147 match std::fs::metadata(path) {
1148 Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
1149 Err(_) => false,
1150 }
1151 }
1152 #[cfg(not(unix))]
1153 {
1154 path.is_file()
1155 }
1156}
1157
1158pub fn expand_tilde(path: &str) -> PathBuf {
1161 if path == "~" {
1162 if let Some(home) = home_dir() {
1163 return home;
1164 }
1165 }
1166 if let Some(rest) = path.strip_prefix("~/") {
1167 if let Some(home) = home_dir() {
1168 return home.join(rest);
1169 }
1170 }
1171 PathBuf::from(path)
1172}
1173
1174pub fn home_dir() -> Option<PathBuf> {
1175 std::env::var_os("HOME").map(PathBuf::from)
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181
1182 fn proc(out: &str, err: &str, code: i32) -> Output {
1183 Output {
1184 stdout: out.into(),
1185 stdout_bytes: out.as_bytes().to_vec(),
1186 stderr: err.into(),
1187 code,
1188 }
1189 }
1190
1191 fn argv(parts: &[&str]) -> Vec<String> {
1192 parts.iter().map(|s| s.to_string()).collect()
1193 }
1194
1195 #[test]
1196 fn a_poll_failure_attempts_cleanup_and_is_uncertain() {
1197 let stopped = std::cell::Cell::new(false);
1198 let error = poll_failure(
1199 std::io::Error::other("poll failed"),
1200 &argv(&["editor"]),
1201 || {
1202 stopped.set(true);
1203 Ok(())
1204 },
1205 );
1206
1207 assert!(stopped.get());
1208 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
1209 assert!(error.to_string().contains("poll failed"), "{error}");
1210 }
1211
1212 #[test]
1213 fn a_stop_or_reap_failure_is_uncertain() {
1214 let error =
1215 quiet_point_failure(std::io::Error::other("could not reap"), &argv(&["editor"]));
1216
1217 assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
1218 assert!(error.to_string().contains("could not reap"), "{error}");
1219 }
1220
1221 #[test]
1222 fn stdin_reaches_the_child_byte_for_byte() {
1223 let body = "line one \n\nline three\n";
1224 let out = run_str(&["/bin/sh", "-c", "cat"], &ExecOpts::new().stdin(body))
1225 .expect("the child reads stdin");
1226 assert_eq!(body, out);
1227 }
1228
1229 #[test]
1230 fn byte_input_reaches_the_child_without_text_conversion() {
1231 let input = [b'f', 0xff, 0];
1232 let command = argv(&["/bin/sh", "-c", "cat"]);
1233 let out = exec_with_input(&command, &ExecOpts::new(), Some(&input))
1234 .expect("the child reads byte input");
1235
1236 assert_eq!(input, out.stdout_bytes.as_slice());
1237 }
1238
1239 #[test]
1240 fn stdout_used_when_stderr_is_empty() {
1241 let msg = failure_message(&argv(&["claude"]), &proc("You've hit your limit.", "", 1));
1242 assert!(msg.contains("hit your limit"), "{msg}");
1243 }
1244
1245 #[test]
1246 fn stderr_shown_when_present() {
1247 let msg = failure_message(&argv(&["gh"]), &proc("noise", "real reason", 1));
1248 assert!(msg.contains("real reason"), "{msg}");
1249 }
1250
1251 #[test]
1252 fn both_streams_are_shown_not_just_one() {
1253 let msg = failure_message(&argv(&["gh"]), &proc("on stdout", "on stderr", 1));
1254 assert!(
1255 msg.contains("on stdout") && msg.contains("on stderr"),
1256 "{msg}"
1257 );
1258 }
1259
1260 #[test]
1261 fn says_something_when_both_are_empty() {
1262 assert!(failure_message(&argv(&["x"]), &proc("", "", 2)).contains("no output"));
1263 }
1264
1265 #[test]
1266 fn long_arguments_are_abbreviated() {
1267 let long = "word ".repeat(500);
1268 let out = abbreviate(&argv(&["claude", "-p", &long]));
1269 assert!(out.len() < 200, "{}", out.len());
1270 }
1271
1272 #[test]
1273 fn newlines_in_arguments_do_not_break_the_line() {
1274 assert!(!abbreviate(&argv(&["claude", "a\nb\nc"])).contains('\n'));
1275 }
1276
1277 #[test]
1278 fn short_arguments_survive_intact() {
1279 assert_eq!(
1280 "gh pr merge 17",
1281 abbreviate(&argv(&["gh", "pr", "merge", "17"]))
1282 );
1283 }
1284
1285 #[test]
1286 fn abbreviation_never_splits_a_character() {
1287 let wide = "\u{1f600}".repeat(200);
1290 let out = abbreviate(&argv(&[&wide]));
1291 assert!(out.ends_with("..."));
1292 }
1293
1294 #[test]
1295 fn exit_code_is_reported() {
1296 let out = exec(
1297 &argv(&["sh", "-c", "exit 3"]),
1298 &ExecOpts::new().check(false),
1299 )
1300 .unwrap();
1301 assert_eq!(3, out.code);
1302 }
1303
1304 #[test]
1305 fn check_false_returns_stdout_on_failure() {
1306 let text = run(
1307 &argv(&["sh", "-c", "echo partial; exit 1"]),
1308 &ExecOpts::new().check(false),
1309 )
1310 .unwrap();
1311 assert_eq!("partial\n", text);
1312 }
1313
1314 #[test]
1315 fn check_true_fails_loudly() {
1316 let err = run(
1317 &argv(&["sh", "-c", "echo why >&2; exit 1"]),
1318 &ExecOpts::new(),
1319 )
1320 .unwrap_err();
1321 assert!(err.to_string().contains("why"), "{err}");
1322 }
1323
1324 #[test]
1325 fn large_output_does_not_deadlock() {
1326 let text = run(
1328 &argv(&["sh", "-c", "yes hello | head -c 400000"]),
1329 &ExecOpts::new().timeout_secs(60),
1330 )
1331 .unwrap();
1332 assert_eq!(400_000, text.len());
1333 }
1334
1335 #[test]
1343 fn large_input_and_large_output_together_do_not_deadlock() {
1344 let line = "a".repeat(199);
1345 let input = std::iter::repeat_n(line.as_str(), 5_000)
1346 .collect::<Vec<_>>()
1347 .join("\n");
1348 assert!(input.len() > 900_000, "the input has to outgrow the pipe");
1349
1350 let start = Instant::now();
1351 let out = run(
1354 &argv(&["sh", "-c", "tee /dev/stderr"]),
1355 &ExecOpts::new().timeout_secs(60).stdin(input.clone()),
1356 )
1357 .unwrap();
1358
1359 assert_eq!(input.len(), out.trim_end().len());
1360 assert!(
1361 start.elapsed() < Duration::from_secs(30),
1362 "the call did not return promptly: {:?}",
1363 start.elapsed()
1364 );
1365 }
1366
1367 #[test]
1371 fn a_surviving_grandchild_holding_the_pipe_cannot_hang_the_timeout() {
1372 let start = Instant::now();
1373 let err = run(
1374 &argv(&["sh", "-c", "sleep 120 & echo parent-output; sleep 60"]),
1375 &ExecOpts::new().timeout_secs(1),
1376 )
1377 .unwrap_err();
1378 let elapsed = start.elapsed();
1379
1380 assert!(err.to_string().contains("timed out"), "{err}");
1381 assert!(
1382 elapsed < Duration::from_secs(20),
1383 "the timeout did not bound the call: {elapsed:?}"
1384 );
1385 }
1386
1387 #[test]
1388 fn a_surviving_grandchild_does_not_hang_a_normal_exit_either() {
1389 let start = Instant::now();
1390 let out = run(
1391 &argv(&["sh", "-c", "sleep 120 & echo done"]),
1392 &ExecOpts::new().timeout_secs(60),
1393 )
1394 .unwrap();
1395 assert!(out.contains("done"), "{out:?}");
1396 assert!(
1397 start.elapsed() < Duration::from_secs(20),
1398 "waited on a grandchild that will never exit"
1399 );
1400 }
1401
1402 #[cfg(unix)]
1403 #[test]
1404 fn a_stopped_process_group_cannot_edit_after_the_parent_exits() {
1405 let late = std::env::temp_dir().join(format!(
1406 "spar-late-child-{}-{}",
1407 std::process::id(),
1408 std::thread::current().name().unwrap_or("test")
1409 ));
1410 let _ = std::fs::remove_file(&late);
1411 let out = run(
1412 &argv(&[
1413 "/bin/sh",
1414 "-c",
1415 "(sleep 1; touch \"$1\") & echo done",
1416 "sh",
1417 late.to_str().unwrap(),
1418 ]),
1419 &ExecOpts::new().stop_descendants(true),
1420 )
1421 .unwrap();
1422
1423 assert!(out.contains("done"), "{out:?}");
1424 std::thread::sleep(Duration::from_millis(1_200));
1425 assert!(!late.exists(), "a descendant edited after the quiet point");
1426 }
1427
1428 #[cfg(unix)]
1430 fn late_file(name: &str) -> PathBuf {
1431 let path = std::env::temp_dir().join(format!("spar-late-{name}-{}", std::process::id()));
1432 let _ = std::fs::remove_file(&path);
1433 path
1434 }
1435
1436 #[cfg(unix)]
1439 fn leaves_then_writes(secs: u32) -> String {
1440 format!(
1441 "perl -MPOSIX -e 'POSIX::setsid() or die; sleep {secs}; \
1442 open(my $f, \">\", $ARGV[0]) or die' \"$1\""
1443 )
1444 }
1445
1446 #[cfg(unix)]
1447 fn run_shell(script: &str, late: &Path, opts: &ExecOpts) -> Result<String> {
1448 run(
1449 &argv(&["/bin/sh", "-c", script, "sh", late.to_str().unwrap()]),
1450 opts,
1451 )
1452 }
1453
1454 #[cfg(unix)]
1458 #[test]
1459 fn a_descendant_in_a_session_of_its_own_cannot_edit_after_the_parent_exits() {
1460 let late = late_file("session");
1461 let script = format!("{} & sleep 1.5; echo done", leaves_then_writes(3));
1462 let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1463
1464 assert!(out.contains("done"), "{out:?}");
1465 std::thread::sleep(Duration::from_millis(2_500));
1466 assert!(!late.exists(), "a descendant edited after the quiet point");
1467 }
1468
1469 #[cfg(unix)]
1473 #[test]
1474 fn a_process_no_sample_saw_is_found_through_the_session_it_was_born_in() {
1475 let late = late_file("born");
1476 let perl = r#"perl -MPOSIX -e 'POSIX::setsid() or die; select(undef, undef, undef, 0.8); if (fork() == 0) { exec("/bin/sh", "-c", "sleep 2; : > \"\$0\"", $ARGV[0]) or die }' "$1""#;
1477 let script = format!("{perl} & sleep 0.9; echo done");
1478 let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1479
1480 assert!(out.contains("done"), "{out:?}");
1481 std::thread::sleep(Duration::from_millis(2_500));
1482 assert!(
1483 !late.exists(),
1484 "a shell born in a descendant's session survived"
1485 );
1486 }
1487
1488 #[cfg(unix)]
1491 #[test]
1492 fn one_call_ending_leaves_another_calls_descendants_alone() {
1493 let proof = late_file("concurrent");
1494 let other = {
1495 let proof = proof.clone();
1496 std::thread::spawn(move || {
1497 let script = format!("{} & sleep 3; echo done", leaves_then_writes(2));
1498 run_shell(&script, &proof, &ExecOpts::new().stop_descendants(true))
1499 })
1500 };
1501 std::thread::sleep(Duration::from_millis(200));
1502 run(
1503 &argv(&["/bin/sh", "-c", "sleep 1"]),
1504 &ExecOpts::new().stop_descendants(true),
1505 )
1506 .unwrap();
1507
1508 let out = other.join().unwrap().unwrap();
1509 assert!(out.contains("done"), "{out:?}");
1510 assert!(
1511 proof.exists(),
1512 "one call's sweep stopped another call's descendant"
1513 );
1514 let _ = std::fs::remove_file(&proof);
1515 }
1516
1517 #[cfg(unix)]
1520 #[test]
1521 fn a_timeout_stops_a_descendant_in_a_session_of_its_own() {
1522 let late = late_file("timeout");
1523 let script = format!("{} & sleep 30", leaves_then_writes(2));
1524 let err = run_shell(
1525 &script,
1526 &late,
1527 &ExecOpts::new().timeout_secs(1).stop_descendants(true),
1528 )
1529 .unwrap_err();
1530
1531 assert!(err.to_string().contains("timed out"), "{err}");
1532 std::thread::sleep(Duration::from_millis(2_500));
1533 assert!(!late.exists(), "a descendant outlived the timeout");
1534 }
1535
1536 #[cfg(unix)]
1539 #[test]
1540 fn a_timeout_stops_a_descendant_that_dropped_its_environment() {
1541 let late = late_file("bare");
1542 let script = format!("env -i {} & sleep 30", leaves_then_writes(2));
1543 let err = run_shell(
1544 &script,
1545 &late,
1546 &ExecOpts::new().timeout_secs(1).stop_descendants(true),
1547 )
1548 .unwrap_err();
1549
1550 assert!(err.to_string().contains("timed out"), "{err}");
1551 std::thread::sleep(Duration::from_millis(2_500));
1552 assert!(!late.exists(), "a descendant without the variable survived");
1553 }
1554
1555 #[cfg(target_os = "linux")]
1558 #[test]
1559 fn a_descendant_no_sample_saw_is_found_by_its_environment() {
1560 let late = late_file("marked");
1561 let script = format!("{} & sleep 0.1; echo done", leaves_then_writes(1));
1562 let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1563
1564 assert!(out.contains("done"), "{out:?}");
1565 std::thread::sleep(Duration::from_millis(1_500));
1566 assert!(
1567 !late.exists(),
1568 "an unsampled descendant edited after the quiet point"
1569 );
1570 }
1571
1572 #[cfg(target_os = "linux")]
1575 #[test]
1576 fn a_descendant_of_a_nested_call_still_belongs_to_the_outer_one() {
1577 let late = late_file("nested");
1578 let script = format!(
1579 "SPAR_CALLS=\"$SPAR_CALLS:inner\" {} & sleep 0.1; echo done",
1580 leaves_then_writes(1)
1581 );
1582 let out = run_shell(&script, &late, &ExecOpts::new().stop_descendants(true)).unwrap();
1583
1584 assert!(out.contains("done"), "{out:?}");
1585 std::thread::sleep(Duration::from_millis(1_500));
1586 assert!(
1587 !late.exists(),
1588 "a nested call's descendant edited after the quiet point"
1589 );
1590 }
1591
1592 #[cfg(any(target_os = "macos", target_os = "linux"))]
1595 #[test]
1596 fn a_process_environment_can_be_read_back() {
1597 let env = table::environment(unsafe { libc::getpid() }).expect("readable");
1598 assert!(
1599 env.split(|&b| b == 0)
1600 .any(|entry| entry.starts_with(b"PATH=")),
1601 "{}",
1602 String::from_utf8_lossy(&env)
1603 );
1604 }
1605
1606 #[cfg(unix)]
1609 #[test]
1610 fn a_process_that_only_started_during_the_call_is_left_alone() {
1611 let stranger = std::thread::spawn(|| {
1612 std::thread::sleep(Duration::from_millis(200));
1613 Command::new("sleep").arg("30").spawn()
1614 });
1615 run(
1616 &argv(&["/bin/sh", "-c", "sleep 1"]),
1617 &ExecOpts::new().stop_descendants(true),
1618 )
1619 .unwrap();
1620
1621 let mut stranger = stranger.join().unwrap().unwrap();
1622 let alive = stranger.try_wait().unwrap().is_none();
1623 let _ = stranger.kill();
1624 let _ = stranger.wait();
1625 assert!(alive, "the sweep killed a process the call did not start");
1626 }
1627
1628 #[cfg(unix)]
1632 #[test]
1633 fn a_group_of_zombies_is_not_a_refusal() {
1634 use std::os::unix::process::CommandExt;
1635 let mut child = Command::new("/bin/sh")
1636 .args(["-c", "exit 0"])
1637 .process_group(0)
1638 .spawn()
1639 .unwrap();
1640 let pid = libc::pid_t::try_from(child.id()).unwrap();
1641 std::thread::sleep(Duration::from_millis(300));
1643 let result = signal_group(pid, libc::SIGKILL);
1644 let _ = child.wait();
1645 result.unwrap();
1646 }
1647
1648 #[cfg(unix)]
1649 #[test]
1650 fn a_nested_call_extends_the_list_rather_than_replacing_it() {
1651 assert_eq!("a", calls_value(None, "a"));
1652 assert_eq!("a", calls_value(Some(""), "a"));
1653 assert_eq!("outer:a", calls_value(Some("outer"), "a"));
1654 }
1655
1656 #[cfg(unix)]
1657 #[test]
1658 fn only_the_whole_token_in_the_variable_marks_a_process() {
1659 let env = b"HOME=/h\0SPAR_CALLS=1.2.3:9.8.7\0PATH=/bin\0";
1660 assert!(carries(env, "1.2.3"));
1661 assert!(carries(env, "9.8.7"));
1662 assert!(!carries(env, "1.2"));
1663 assert!(!carries(env, "2.3"));
1664 assert!(!carries(b"XSPAR_CALLS=1.2.3\0", "1.2.3"));
1665 assert!(!carries(b"OTHER=SPAR_CALLS=1.2.3\0", "1.2.3"));
1666 }
1667
1668 #[test]
1669 fn the_environment_is_read_past_the_arguments_and_stops_at_the_apple_strings() {
1670 let mut buf = 2i32.to_ne_bytes().to_vec();
1671 buf.extend_from_slice(b"/bin/sh\0\0\0\0sh\0SPAR_CALLS=in-an-argument\0");
1672 buf.extend_from_slice(b"SPAR_CALLS=t\0HOME=/h\0\0executable_path=/bin/sh\0");
1673
1674 assert_eq!(
1675 Some(&b"SPAR_CALLS=t\0HOME=/h\0"[..]),
1676 procargs_environment(&buf)
1677 );
1678 assert_eq!(None, procargs_environment(b"\x01\0"));
1679 }
1680
1681 #[cfg(unix)]
1682 #[test]
1683 fn a_stat_line_is_read_past_a_command_name_with_spaces_and_parentheses() {
1684 let stat = "4242 (a) (b c) S 17 4242 4242 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 \
1685 987654 0 0";
1686 let entry = parse_stat(4242, stat, 501).unwrap();
1687
1688 assert_eq!(17, entry.ppid);
1689 assert_eq!(4242, entry.pgid);
1690 assert_eq!(4242, entry.sid);
1691 assert_eq!(501, entry.uid);
1692 assert_eq!(987_654, entry.start);
1693 }
1694
1695 #[test]
1696 fn timeout_kills_and_explains() {
1697 let err = run(
1698 &argv(&["sh", "-c", "sleep 30"]),
1699 &ExecOpts::new().timeout_secs(1),
1700 )
1701 .unwrap_err();
1702 assert!(err.to_string().contains("timed out"), "{err}");
1703 }
1704
1705 #[test]
1706 fn missing_binary_names_the_command() {
1707 let err = exec(
1708 &argv(&["spar-definitely-not-a-real-binary"]),
1709 &ExecOpts::new(),
1710 )
1711 .unwrap_err();
1712 assert!(
1713 err.to_string()
1714 .contains("spar-definitely-not-a-real-binary"),
1715 "{err}"
1716 );
1717 }
1718
1719 #[test]
1720 fn tilde_expands_against_home() {
1721 std::env::set_var("HOME", "/home/someone");
1722 assert_eq!(PathBuf::from("/home/someone/bin"), expand_tilde("~/bin"));
1723 assert_eq!(PathBuf::from("/absolute"), expand_tilde("/absolute"));
1724 assert_eq!(PathBuf::from("~notauser/x"), expand_tilde("~notauser/x"));
1725 }
1726}
1727
1728#[cfg(test)]
1729mod timeout_kind_tests {
1730 use super::*;
1731 use crate::error::ErrorKind;
1732
1733 fn argv(parts: &[&str]) -> Vec<String> {
1734 parts.iter().map(|s| s.to_string()).collect()
1735 }
1736
1737 #[test]
1741 fn a_timeout_is_marked_as_one_and_is_not_worth_retrying() {
1742 let err = run(
1743 &["sh".to_string(), "-c".to_string(), "sleep 30".to_string()],
1744 &ExecOpts::new().timeout_secs(1),
1745 )
1746 .unwrap_err();
1747
1748 assert_eq!(ErrorKind::TimedOut, err.kind());
1749 assert!(!err.worth_retrying());
1750 assert!(err.to_string().contains("Not retried"), "{err}");
1751 }
1752
1753 #[test]
1757 fn a_cancelled_command_is_stopped_and_says_so() {
1758 let stop = Arc::new(AtomicBool::new(false));
1759 let flag = Arc::clone(&stop);
1760 std::thread::spawn(move || {
1761 std::thread::sleep(Duration::from_millis(200));
1762 flag.store(true, Ordering::SeqCst);
1763 });
1764 let started = Instant::now();
1765 let err = exec_until(
1766 &argv(&["sh", "-c", "sleep 30"]),
1767 &ExecOpts::new().timeout_secs(30).stop_descendants(true),
1768 None,
1769 &stop,
1770 )
1771 .unwrap_err();
1772
1773 assert_eq!(ErrorKind::Interrupted, err.kind());
1774 assert!(
1775 err.to_string().starts_with("interrupted: sh -c sleep 30"),
1776 "{err}"
1777 );
1778 assert!(
1779 started.elapsed() < Duration::from_secs(5),
1780 "took {:?}",
1781 started.elapsed()
1782 );
1783 assert!(!err.worth_retrying());
1784 }
1785
1786 #[test]
1787 fn a_command_asked_for_after_the_stop_is_refused_without_running() {
1788 let stop = AtomicBool::new(true);
1789 let err = exec_until(
1790 &argv(&["sh", "-c", "exit 0"]),
1791 &ExecOpts::new(),
1792 None,
1793 &stop,
1794 )
1795 .unwrap_err();
1796 assert_eq!(ErrorKind::Interrupted, err.kind());
1797 assert!(err.to_string().contains("before"), "{err}");
1798 }
1799
1800 #[test]
1801 fn a_sleep_ends_early_once_the_stop_is_set_and_on_time_otherwise() {
1802 let stop = Arc::new(AtomicBool::new(false));
1803 let flag = Arc::clone(&stop);
1804 std::thread::spawn(move || {
1805 std::thread::sleep(Duration::from_millis(100));
1806 flag.store(true, Ordering::SeqCst);
1807 });
1808 let started = Instant::now();
1809 assert!(sleep_until(Duration::from_secs(30), &stop));
1810 assert!(started.elapsed() < Duration::from_secs(5));
1811
1812 let calm = AtomicBool::new(false);
1813 let started = Instant::now();
1814 assert!(!sleep_until(Duration::from_millis(50), &calm));
1815 assert!(started.elapsed() >= Duration::from_millis(50));
1816 }
1817
1818 #[test]
1822 fn a_non_zero_exit_is_the_call_failing_rather_than_the_answer() {
1823 let err = run(
1824 &["sh".to_string(), "-c".to_string(), "exit 1".to_string()],
1825 &ExecOpts::new(),
1826 )
1827 .unwrap_err();
1828
1829 assert_eq!(ErrorKind::CallFailed, err.kind());
1830 assert!(err.worth_retrying());
1834 }
1835}