1mod fake;
6
7pub use fake::FakeProcessRunner;
8
9use anyhow::Context;
10use perfgate_sha256::sha256_hex;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14#[derive(Debug, Clone)]
15pub struct CommandSpec {
16 pub argv: Vec<String>,
17 pub cwd: Option<PathBuf>,
18 pub env: Vec<(String, String)>,
19 pub timeout: Option<Duration>,
20 pub output_cap_bytes: usize,
21}
22
23#[derive(Debug, Clone)]
24pub struct RunResult {
25 pub wall_ms: u64,
26 pub exit_code: i32,
27 pub timed_out: bool,
28 pub cpu_ms: Option<u64>,
31 pub page_faults: Option<u64>,
33 pub ctx_switches: Option<u64>,
35 pub max_rss_kb: Option<u64>,
38 pub binary_bytes: Option<u64>,
40 pub stdout: Vec<u8>,
41 pub stderr: Vec<u8>,
42}
43
44#[derive(Debug, thiserror::Error)]
45pub enum AdapterError {
46 #[error("command argv must not be empty")]
47 EmptyArgv,
48
49 #[error("command timed out")]
50 Timeout,
51
52 #[error("timeout is not supported on this platform")]
53 TimeoutUnsupported,
54
55 #[error(transparent)]
56 Other(#[from] anyhow::Error),
57}
58
59pub trait ProcessRunner {
60 fn run(&self, spec: &CommandSpec) -> Result<RunResult, AdapterError>;
61}
62
63#[derive(Debug, Default, Clone)]
64pub struct StdProcessRunner;
65
66impl ProcessRunner for StdProcessRunner {
67 fn run(&self, spec: &CommandSpec) -> Result<RunResult, AdapterError> {
68 if spec.argv.is_empty() {
69 return Err(AdapterError::EmptyArgv);
70 }
71
72 #[cfg(unix)]
73 {
74 run_unix(spec)
75 }
76
77 #[cfg(windows)]
78 {
79 if spec.timeout.is_some() {
80 return Err(AdapterError::TimeoutUnsupported);
81 }
82 run_windows(spec)
83 }
84
85 #[cfg(all(not(unix), not(windows)))]
86 {
87 if spec.timeout.is_some() {
88 return Err(AdapterError::TimeoutUnsupported);
89 }
90 run_portable(spec)
91 }
92 }
93}
94
95#[allow(dead_code)]
96fn truncate(mut bytes: Vec<u8>, cap: usize) -> Vec<u8> {
97 if bytes.len() > cap {
98 bytes.truncate(cap);
99 }
100 bytes
101}
102
103#[cfg(all(not(unix), not(windows)))]
104fn run_portable(spec: &CommandSpec) -> Result<RunResult, AdapterError> {
105 use std::process::Command;
106
107 let start = Instant::now();
108 let binary_bytes = binary_bytes_for_command(spec);
109 let mut cmd = Command::new(&spec.argv[0]);
110 if spec.argv.len() > 1 {
111 cmd.args(&spec.argv[1..]);
112 }
113
114 if let Some(cwd) = &spec.cwd {
115 cmd.current_dir(cwd);
116 }
117
118 for (k, v) in &spec.env {
119 cmd.env(k, v);
120 }
121
122 let out = cmd
123 .output()
124 .with_context(|| format!("failed to run {:?}", spec.argv))
125 .map_err(AdapterError::Other)?;
126
127 let wall_ms = start.elapsed().as_millis() as u64;
128 let exit_code = out.status.code().unwrap_or(-1);
129
130 Ok(RunResult {
131 wall_ms,
132 exit_code,
133 timed_out: false,
134 cpu_ms: None,
135 page_faults: None,
136 ctx_switches: None,
137 max_rss_kb: None,
138 binary_bytes,
139 stdout: truncate(out.stdout, spec.output_cap_bytes),
140 stderr: truncate(out.stderr, spec.output_cap_bytes),
141 })
142}
143
144#[cfg(windows)]
145fn run_windows(spec: &CommandSpec) -> Result<RunResult, AdapterError> {
146 use std::os::windows::io::AsRawHandle;
147 use std::process::{Command, Stdio};
148 use std::thread;
149
150 let start = Instant::now();
151 let binary_bytes = binary_bytes_for_command(spec);
152
153 let mut cmd = Command::new(&spec.argv[0]);
154 if spec.argv.len() > 1 {
155 cmd.args(&spec.argv[1..]);
156 }
157 if let Some(cwd) = &spec.cwd {
158 cmd.current_dir(cwd);
159 }
160 for (k, v) in &spec.env {
161 cmd.env(k, v);
162 }
163
164 cmd.stdin(Stdio::null());
165 cmd.stdout(Stdio::piped());
166 cmd.stderr(Stdio::piped());
167
168 let mut child = cmd
169 .spawn()
170 .with_context(|| format!("failed to spawn {:?}", spec.argv))
171 .map_err(AdapterError::Other)?;
172
173 let mut stdout = child.stdout.take().expect("stdout piped");
174 let mut stderr = child.stderr.take().expect("stderr piped");
175 let cap = spec.output_cap_bytes;
176
177 let out_handle = thread::spawn(move || read_with_cap(&mut stdout, cap));
178 let err_handle = thread::spawn(move || read_with_cap(&mut stderr, cap));
179
180 let status = child
181 .wait()
182 .with_context(|| format!("failed to wait for {:?}", spec.argv))
183 .map_err(AdapterError::Other)?;
184
185 let (cpu_ms, max_rss_kb, page_faults) = probe_process_usage_windows(child.as_raw_handle());
186
187 let stdout = out_handle.join().unwrap_or_default();
188 let stderr = err_handle.join().unwrap_or_default();
189
190 let wall_ms = start.elapsed().as_millis() as u64;
191 let exit_code = status.code().unwrap_or(-1);
192
193 Ok(RunResult {
194 wall_ms,
195 exit_code,
196 timed_out: false,
197 cpu_ms,
198 page_faults,
199 ctx_switches: None,
200 max_rss_kb,
201 binary_bytes,
202 stdout,
203 stderr,
204 })
205}
206
207#[cfg(unix)]
208fn run_unix(spec: &CommandSpec) -> Result<RunResult, AdapterError> {
209 use std::os::unix::process::ExitStatusExt;
210 use std::process::{Command, Stdio};
211 use std::thread;
212
213 let start = Instant::now();
214 let binary_bytes = binary_bytes_for_command(spec);
215
216 let mut cmd = Command::new(&spec.argv[0]);
217 if spec.argv.len() > 1 {
218 cmd.args(&spec.argv[1..]);
219 }
220
221 if let Some(cwd) = &spec.cwd {
222 cmd.current_dir(cwd);
223 }
224
225 for (k, v) in &spec.env {
226 cmd.env(k, v);
227 }
228
229 cmd.stdin(Stdio::null());
230 cmd.stdout(Stdio::piped());
231 cmd.stderr(Stdio::piped());
232
233 let mut child = cmd
234 .spawn()
235 .with_context(|| format!("failed to spawn {:?}", spec.argv))
236 .map_err(AdapterError::Other)?;
237
238 let pid = child.id() as libc::pid_t;
239
240 let mut stdout = child.stdout.take().expect("stdout piped");
241 let mut stderr = child.stderr.take().expect("stderr piped");
242
243 let cap = spec.output_cap_bytes;
244
245 let out_handle = thread::spawn(move || read_with_cap(&mut stdout, cap));
246 let err_handle = thread::spawn(move || read_with_cap(&mut stderr, cap));
247
248 let (status_raw, rusage, timed_out) = wait4_with_timeout(pid, spec.timeout)?;
249
250 drop(child);
252
253 let stdout = out_handle.join().unwrap_or_default();
254 let stderr = err_handle.join().unwrap_or_default();
255
256 let wall_ms = start.elapsed().as_millis() as u64;
257
258 let exit_status = std::process::ExitStatus::from_raw(status_raw);
259 let exit_code = exit_status.code().unwrap_or(-1);
260
261 let cpu_ms = rusage.map(|ru| ru_cpu_ms(&ru));
262 let page_faults = rusage.map(|ru| ru_page_faults(&ru));
263 let ctx_switches = rusage.map(|ru| ru_ctx_switches(&ru));
264 let max_rss_kb = rusage.map(|ru| ru_maxrss_kb(&ru));
265
266 Ok(RunResult {
267 wall_ms,
268 exit_code,
269 timed_out,
270 cpu_ms,
271 page_faults,
272 ctx_switches,
273 max_rss_kb,
274 binary_bytes,
275 stdout,
276 stderr,
277 })
278}
279
280fn read_with_cap<R: std::io::Read>(reader: &mut R, cap: usize) -> Vec<u8> {
281 let mut buf: Vec<u8> = Vec::new();
282 let mut tmp = [0u8; 8192];
283
284 loop {
285 match reader.read(&mut tmp) {
286 Ok(0) => break,
287 Ok(n) => {
288 if buf.len() < cap {
289 let remaining = cap - buf.len();
290 let take = remaining.min(n);
291 buf.extend_from_slice(&tmp[..take]);
292 }
293 }
294 Err(_) => break,
295 }
296 }
297
298 buf
299}
300
301#[cfg(windows)]
302fn probe_process_usage_windows(
303 handle: std::os::windows::io::RawHandle,
304) -> (Option<u64>, Option<u64>, Option<u64>) {
305 use std::ffi::c_void;
306 use std::mem;
307
308 #[repr(C)]
309 #[allow(non_snake_case)]
310 struct FileTime {
311 dwLowDateTime: u32,
312 dwHighDateTime: u32,
313 }
314
315 #[repr(C)]
316 #[allow(non_snake_case)]
317 struct ProcessMemoryCounters {
318 cb: u32,
319 PageFaultCount: u32,
320 PeakWorkingSetSize: usize,
321 WorkingSetSize: usize,
322 QuotaPeakPagedPoolUsage: usize,
323 QuotaPagedPoolUsage: usize,
324 QuotaPeakNonPagedPoolUsage: usize,
325 QuotaNonPagedPoolUsage: usize,
326 PagefileUsage: usize,
327 PeakPagefileUsage: usize,
328 }
329
330 #[link(name = "kernel32")]
331 unsafe extern "system" {
332 fn GetProcessTimes(
333 hProcess: *mut c_void,
334 lpCreationTime: *mut FileTime,
335 lpExitTime: *mut FileTime,
336 lpKernelTime: *mut FileTime,
337 lpUserTime: *mut FileTime,
338 ) -> i32;
339 }
340
341 #[link(name = "psapi")]
342 unsafe extern "system" {
343 fn GetProcessMemoryInfo(
344 Process: *mut c_void,
345 ppsmemCounters: *mut ProcessMemoryCounters,
346 cb: u32,
347 ) -> i32;
348 }
349
350 fn filetime_to_u64(ft: &FileTime) -> u64 {
351 ((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64)
352 }
353
354 let raw = handle.cast::<c_void>();
355
356 let mut creation: FileTime = unsafe { mem::zeroed() };
357 let mut exit: FileTime = unsafe { mem::zeroed() };
358 let mut kernel: FileTime = unsafe { mem::zeroed() };
359 let mut user: FileTime = unsafe { mem::zeroed() };
360
361 let cpu_ms =
362 if unsafe { GetProcessTimes(raw, &mut creation, &mut exit, &mut kernel, &mut user) } != 0 {
363 let total_100ns = filetime_to_u64(&kernel).saturating_add(filetime_to_u64(&user));
364 Some(total_100ns / 10_000)
365 } else {
366 None
367 };
368
369 let mut counters: ProcessMemoryCounters = unsafe { mem::zeroed() };
370 counters.cb = mem::size_of::<ProcessMemoryCounters>() as u32;
371 let (max_rss_kb, page_faults) =
372 if unsafe { GetProcessMemoryInfo(raw, &mut counters, counters.cb) } != 0 {
373 (
374 Some((counters.PeakWorkingSetSize as u64) / 1024),
375 Some(counters.PageFaultCount as u64),
376 )
377 } else {
378 (None, None)
379 };
380
381 (cpu_ms, max_rss_kb, page_faults)
382}
383
384#[cfg(unix)]
385fn wait4_with_timeout(
386 pid: libc::pid_t,
387 timeout: Option<Duration>,
388) -> Result<(libc::c_int, Option<libc::rusage>, bool), AdapterError> {
389 use std::mem;
390
391 let start = Instant::now();
392 let mut status: libc::c_int = 0;
393 let mut ru: libc::rusage = unsafe { mem::zeroed() };
394
395 let mut timed_out = false;
396
397 loop {
398 let options = if timeout.is_some() { libc::WNOHANG } else { 0 };
399
400 let res = unsafe { libc::wait4(pid, &mut status as *mut libc::c_int, options, &mut ru) };
401
402 if res == pid {
403 break;
404 }
405
406 if res == 0 {
407 if let Some(t) = timeout
409 && start.elapsed() >= t
410 {
411 timed_out = true;
412 unsafe {
413 libc::kill(pid, libc::SIGKILL);
414 }
415 let res2 = unsafe { libc::wait4(pid, &mut status as *mut libc::c_int, 0, &mut ru) };
417 if res2 != pid {
418 return Err(AdapterError::Other(anyhow::anyhow!(
419 "wait4 after kill failed: {:?}",
420 std::io::Error::last_os_error()
421 )));
422 }
423 break;
424 }
425 std::thread::sleep(Duration::from_millis(10));
426 continue;
427 }
428
429 if res == -1 {
430 let err = std::io::Error::last_os_error();
431 if err.kind() == std::io::ErrorKind::Interrupted {
432 continue;
433 }
434 return Err(AdapterError::Other(anyhow::anyhow!("wait4 failed: {err}")));
435 }
436
437 return Err(AdapterError::Other(anyhow::anyhow!(
439 "wait4 returned unexpected pid: {res}"
440 )));
441 }
442
443 Ok((status, Some(ru), timed_out))
444}
445
446#[cfg(unix)]
447fn ru_cpu_ms(ru: &libc::rusage) -> u64 {
448 let user_ms = (ru.ru_utime.tv_sec as u64) * 1000 + (ru.ru_utime.tv_usec as u64) / 1000;
450 let sys_ms = (ru.ru_stime.tv_sec as u64) * 1000 + (ru.ru_stime.tv_usec as u64) / 1000;
451 user_ms + sys_ms
452}
453
454#[cfg(unix)]
455fn ru_page_faults(ru: &libc::rusage) -> u64 {
456 clamp_nonnegative_c_long(ru.ru_majflt)
458}
459
460#[cfg(unix)]
461fn ru_ctx_switches(ru: &libc::rusage) -> u64 {
462 clamp_nonnegative_c_long(ru.ru_nvcsw).saturating_add(clamp_nonnegative_c_long(ru.ru_nivcsw))
464}
465
466#[cfg(unix)]
467fn clamp_nonnegative_c_long(v: libc::c_long) -> u64 {
468 if v < 0 { 0 } else { v as u64 }
469}
470
471#[cfg(unix)]
472fn ru_maxrss_kb(ru: &libc::rusage) -> u64 {
473 let raw = ru.ru_maxrss as u64;
474
475 #[cfg(target_os = "macos")]
478 {
479 raw / 1024
480 }
481
482 #[cfg(not(target_os = "macos"))]
483 {
484 raw
485 }
486}
487
488fn binary_bytes_for_command(spec: &CommandSpec) -> Option<u64> {
489 let cmd = spec.argv.first()?;
490 let path = resolve_command_path(cmd, spec.cwd.as_deref())?;
491 std::fs::metadata(path).ok().map(|m| m.len())
492}
493
494fn resolve_command_path(command: &str, cwd: Option<&Path>) -> Option<PathBuf> {
495 let command_path = Path::new(command);
496
497 if command_path.is_absolute() || command_path.components().count() > 1 {
499 let candidate = if command_path.is_absolute() {
500 command_path.to_path_buf()
501 } else if let Some(dir) = cwd {
502 dir.join(command_path)
503 } else {
504 command_path.to_path_buf()
505 };
506 return candidate.is_file().then_some(candidate);
507 }
508
509 let path_var = std::env::var_os("PATH")?;
511 for dir in std::env::split_paths(&path_var) {
512 let candidate = dir.join(command);
513 if candidate.is_file() {
514 return Some(candidate);
515 }
516
517 #[cfg(windows)]
518 {
519 if candidate.extension().is_none() {
520 let pathext = std::env::var_os("PATHEXT").unwrap_or(".COM;.EXE;.BAT;.CMD".into());
521 for ext in pathext.to_string_lossy().split(';') {
522 let ext = ext.trim();
523 if ext.is_empty() {
524 continue;
525 }
526 let mut with_ext = candidate.clone();
527 let normalized = ext.trim_start_matches('.');
528 with_ext.set_extension(normalized);
529 if with_ext.is_file() {
530 return Some(with_ext);
531 }
532 }
533 }
534 }
535 }
536
537 None
538}
539
540use perfgate_types::HostInfo;
545
546#[derive(Debug, Clone, Default)]
548pub struct HostProbeOptions {
549 pub include_hostname_hash: bool,
552}
553
554pub trait HostProbe {
556 fn probe(&self, options: &HostProbeOptions) -> HostInfo;
558}
559
560#[derive(Debug, Default, Clone)]
562pub struct StdHostProbe;
563
564impl HostProbe for StdHostProbe {
565 fn probe(&self, options: &HostProbeOptions) -> HostInfo {
566 HostInfo {
567 os: std::env::consts::OS.to_string(),
568 arch: std::env::consts::ARCH.to_string(),
569 cpu_count: probe_cpu_count(),
570 memory_bytes: probe_memory_bytes(),
571 hostname_hash: if options.include_hostname_hash {
572 probe_hostname_hash()
573 } else {
574 None
575 },
576 }
577 }
578}
579
580fn probe_cpu_count() -> Option<u32> {
583 std::thread::available_parallelism()
585 .ok()
586 .map(|n| n.get() as u32)
587}
588
589fn probe_memory_bytes() -> Option<u64> {
592 #[cfg(target_os = "linux")]
593 {
594 probe_memory_linux()
595 }
596
597 #[cfg(target_os = "macos")]
598 {
599 probe_memory_macos()
600 }
601
602 #[cfg(target_os = "windows")]
603 {
604 probe_memory_windows()
605 }
606
607 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
608 {
609 None
610 }
611}
612
613#[cfg(target_os = "linux")]
614fn probe_memory_linux() -> Option<u64> {
615 let content = std::fs::read_to_string("/proc/meminfo").ok()?;
617 for line in content.lines() {
618 if line.starts_with("MemTotal:") {
619 let parts: Vec<&str> = line.split_whitespace().collect();
621 if parts.len() >= 2
622 && let Ok(kb) = parts[1].parse::<u64>()
623 {
624 return Some(kb * 1024); }
626 }
627 }
628 None
629}
630
631#[cfg(target_os = "macos")]
632fn probe_memory_macos() -> Option<u64> {
633 use std::mem;
635
636 let mut memsize: u64 = 0;
637 let mut size = mem::size_of::<u64>();
638 let name = c"hw.memsize";
639
640 let ret = unsafe {
641 libc::sysctlbyname(
642 name.as_ptr(),
643 &mut memsize as *mut u64 as *mut libc::c_void,
644 &mut size,
645 std::ptr::null_mut(),
646 0,
647 )
648 };
649
650 if ret == 0 { Some(memsize) } else { None }
651}
652
653#[cfg(target_os = "windows")]
654fn probe_memory_windows() -> Option<u64> {
655 use std::mem;
656
657 #[repr(C)]
658 #[allow(non_snake_case)]
659 struct MemoryStatusEx {
660 dwLength: u32,
661 dwMemoryLoad: u32,
662 ullTotalPhys: u64,
663 ullAvailPhys: u64,
664 ullTotalPageFile: u64,
665 ullAvailPageFile: u64,
666 ullTotalVirtual: u64,
667 ullAvailVirtual: u64,
668 ullAvailExtendedVirtual: u64,
669 }
670
671 #[link(name = "kernel32")]
672 unsafe extern "system" {
673 fn GlobalMemoryStatusEx(lpBuffer: *mut MemoryStatusEx) -> i32;
674 }
675
676 let mut status: MemoryStatusEx = unsafe { mem::zeroed() };
677 status.dwLength = mem::size_of::<MemoryStatusEx>() as u32;
678
679 let ret = unsafe { GlobalMemoryStatusEx(&mut status) };
680
681 if ret != 0 {
682 Some(status.ullTotalPhys)
683 } else {
684 None
685 }
686}
687
688fn probe_hostname_hash() -> Option<String> {
691 let hostname = hostname::get().ok()?;
692 let hostname_str = hostname.to_string_lossy();
693 Some(sha256_hex(hostname_str.as_bytes()))
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use proptest::prelude::*;
700
701 proptest! {
706 #![proptest_config(ProptestConfig::with_cases(100))]
707
708 #[test]
710 fn truncate_length_equals_min_of_original_and_cap(
711 bytes in proptest::collection::vec(any::<u8>(), 0..1000),
712 cap in 0usize..2000
713 ) {
714 let original_len = bytes.len();
715 let result = truncate(bytes, cap);
716 let expected_len = original_len.min(cap);
717 prop_assert_eq!(
718 result.len(),
719 expected_len,
720 "truncated length should be min({}, {}) = {}, but got {}",
721 original_len,
722 cap,
723 expected_len,
724 result.len()
725 );
726 }
727
728 #[test]
730 fn truncate_preserves_prefix(
731 bytes in proptest::collection::vec(any::<u8>(), 0..1000),
732 cap in 0usize..2000
733 ) {
734 let original = bytes.clone();
735 let result = truncate(bytes, cap);
736
737 prop_assert!(
739 original.starts_with(&result),
740 "truncated output should be a prefix of the original"
741 );
742 }
743
744 #[test]
746 fn truncate_no_op_when_cap_exceeds_length(
747 bytes in proptest::collection::vec(any::<u8>(), 0..500)
748 ) {
749 let original = bytes.clone();
750 let original_len = original.len();
751 let cap = original_len + 100;
753 let result = truncate(bytes, cap);
754
755 prop_assert_eq!(
756 result,
757 original,
758 "when cap ({}) >= original_length ({}), output should equal original",
759 cap,
760 original_len
761 );
762 }
763 }
764
765 #[test]
767 fn truncate_empty_vec() {
768 let result = truncate(vec![], 10);
769 assert_eq!(result, Vec::<u8>::new());
770 }
771
772 #[test]
773 fn truncate_with_zero_cap() {
774 let result = truncate(vec![1, 2, 3, 4, 5], 0);
775 assert_eq!(result, Vec::<u8>::new());
776 }
777
778 #[test]
779 fn truncate_exact_cap() {
780 let bytes = vec![1, 2, 3, 4, 5];
781 let result = truncate(bytes.clone(), 5);
782 assert_eq!(result, bytes);
783 }
784
785 #[test]
786 fn truncate_one_over_cap() {
787 let bytes = vec![1, 2, 3, 4, 5];
788 let result = truncate(bytes, 4);
789 assert_eq!(result, vec![1, 2, 3, 4]);
790 }
791
792 #[test]
799 fn empty_argv_returns_error() {
800 let runner = StdProcessRunner;
801 let spec = CommandSpec {
802 argv: vec![],
803 cwd: None,
804 env: vec![],
805 timeout: None,
806 output_cap_bytes: 1024,
807 };
808
809 let result = runner.run(&spec);
810 assert!(result.is_err(), "Expected error for empty argv");
811
812 let err = result.unwrap_err();
813 assert!(
814 matches!(err, AdapterError::EmptyArgv),
815 "Expected AdapterError::EmptyArgv, got {:?}",
816 err
817 );
818 }
819
820 #[test]
822 fn empty_argv_error_message_is_descriptive() {
823 let err = AdapterError::EmptyArgv;
824 let msg = err.to_string();
825 assert!(
826 msg.contains("argv") && msg.contains("empty"),
827 "Error message should mention 'argv' and 'empty', got: {}",
828 msg
829 );
830 }
831
832 #[test]
834 fn timeout_error_message_is_descriptive() {
835 let err = AdapterError::Timeout;
836 let msg = err.to_string();
837 assert!(
838 msg.contains("timed out") || msg.contains("timeout"),
839 "Error message should mention 'timeout', got: {}",
840 msg
841 );
842 }
843
844 #[test]
846 fn timeout_unsupported_error_message_is_descriptive() {
847 let err = AdapterError::TimeoutUnsupported;
848 let msg = err.to_string();
849 assert!(
850 msg.contains("timeout") && msg.contains("not supported"),
851 "Error message should mention 'timeout' and 'not supported', got: {}",
852 msg
853 );
854 }
855
856 #[cfg(not(unix))]
859 #[test]
860 fn timeout_on_non_unix_returns_unsupported() {
861 let runner = StdProcessRunner;
862 let spec = CommandSpec {
863 argv: vec!["echo".to_string(), "hello".to_string()],
864 cwd: None,
865 env: vec![],
866 timeout: Some(Duration::from_secs(10)),
867 output_cap_bytes: 1024,
868 };
869
870 let result = runner.run(&spec);
871 assert!(result.is_err(), "Expected error for timeout on non-Unix");
872
873 let err = result.unwrap_err();
874 assert!(
875 matches!(err, AdapterError::TimeoutUnsupported),
876 "Expected AdapterError::TimeoutUnsupported, got {:?}",
877 err
878 );
879 }
880
881 #[cfg(windows)]
883 #[test]
884 fn windows_collects_best_effort_metrics() {
885 let runner = StdProcessRunner;
886 let spec = CommandSpec {
887 argv: vec![
888 "cmd".to_string(),
889 "/c".to_string(),
890 "echo".to_string(),
891 "hello".to_string(),
892 ],
893 cwd: None,
894 env: vec![],
895 timeout: None,
896 output_cap_bytes: 1024,
897 };
898
899 let result = runner.run(&spec).expect("windows run should succeed");
900 assert_eq!(result.exit_code, 0, "command should succeed");
901 assert!(
902 result.cpu_ms.is_some(),
903 "cpu_ms should be available on Windows (best-effort)"
904 );
905 assert!(
906 result.max_rss_kb.is_some(),
907 "max_rss_kb should be available on Windows (best-effort)"
908 );
909 assert!(
910 result.page_faults.is_some(),
911 "page_faults should be available on Windows (best-effort)"
912 );
913 }
914
915 #[cfg(unix)]
917 #[test]
918 fn timeout_on_unix_is_supported() {
919 let runner = StdProcessRunner;
920 let spec = CommandSpec {
922 argv: vec!["echo".to_string(), "hello".to_string()],
923 cwd: None,
924 env: vec![],
925 timeout: Some(Duration::from_secs(10)),
926 output_cap_bytes: 1024,
927 };
928
929 let result = runner.run(&spec);
930 assert!(
931 result.is_ok(),
932 "Timeout should be supported on Unix, got error: {:?}",
933 result.err()
934 );
935
936 let run_result = result.unwrap();
937 assert!(!run_result.timed_out, "Command should not have timed out");
938 assert_eq!(run_result.exit_code, 0, "Command should have succeeded");
939 }
940
941 #[cfg(unix)]
943 #[test]
944 fn timeout_kills_long_running_command() {
945 let runner = StdProcessRunner;
946 let spec = CommandSpec {
948 argv: vec!["sleep".to_string(), "10".to_string()],
949 cwd: None,
950 env: vec![],
951 timeout: Some(Duration::from_millis(100)),
952 output_cap_bytes: 1024,
953 };
954
955 let start = std::time::Instant::now();
956 let result = runner.run(&spec);
957 let elapsed = start.elapsed();
958
959 assert!(
960 result.is_ok(),
961 "Should return Ok with timed_out flag, got error: {:?}",
962 result.err()
963 );
964
965 let run_result = result.unwrap();
966 assert!(run_result.timed_out, "Command should have timed out");
967
968 assert!(
970 elapsed < Duration::from_secs(2),
971 "Command should have been killed quickly, but took {:?}",
972 elapsed
973 );
974 }
975
976 #[test]
978 fn other_error_wraps_anyhow() {
979 let inner_err = anyhow::anyhow!("test error message");
980 let err = AdapterError::Other(inner_err);
981 let msg = err.to_string();
982 assert!(
983 msg.contains("test error message"),
984 "Error message should contain the inner error, got: {}",
985 msg
986 );
987 }
988
989 #[test]
991 fn empty_argv_check_is_immediate() {
992 let runner = StdProcessRunner;
993 let spec = CommandSpec {
994 argv: vec![],
995 cwd: Some(std::path::PathBuf::from("/nonexistent/path")),
996 env: vec![("SOME_VAR".to_string(), "value".to_string())],
997 timeout: Some(Duration::from_secs(1)),
998 output_cap_bytes: 1024,
999 };
1000
1001 let result = runner.run(&spec);
1004 assert!(
1005 matches!(result, Err(AdapterError::EmptyArgv)),
1006 "Should return EmptyArgv before checking other parameters"
1007 );
1008 }
1009
1010 #[test]
1017 fn host_probe_returns_valid_os_arch() {
1018 let probe = StdHostProbe;
1019 let options = HostProbeOptions::default();
1020 let info = probe.probe(&options);
1021
1022 assert!(!info.os.is_empty(), "os should not be empty");
1024 assert_eq!(info.os, std::env::consts::OS);
1025
1026 assert!(!info.arch.is_empty(), "arch should not be empty");
1028 assert_eq!(info.arch, std::env::consts::ARCH);
1029 }
1030
1031 #[test]
1033 fn host_probe_returns_cpu_count() {
1034 let probe = StdHostProbe;
1035 let options = HostProbeOptions::default();
1036 let info = probe.probe(&options);
1037
1038 if let Some(count) = info.cpu_count {
1041 assert!(count >= 1, "cpu_count should be at least 1, got {}", count);
1042 assert!(
1043 count <= 1024,
1044 "cpu_count should be at most 1024, got {}",
1045 count
1046 );
1047 }
1048 }
1049
1050 #[test]
1052 fn host_probe_returns_memory() {
1053 let probe = StdHostProbe;
1054 let options = HostProbeOptions::default();
1055 let info = probe.probe(&options);
1056
1057 if let Some(bytes) = info.memory_bytes {
1060 assert!(
1061 bytes >= 128 * 1024 * 1024,
1062 "memory_bytes should be at least 128MB, got {}",
1063 bytes
1064 );
1065 assert!(
1066 bytes <= 128 * 1024 * 1024 * 1024 * 1024,
1067 "memory_bytes should be at most 128TB, got {}",
1068 bytes
1069 );
1070 }
1071 }
1072
1073 #[test]
1075 fn host_probe_no_hostname_by_default() {
1076 let probe = StdHostProbe;
1077 let options = HostProbeOptions {
1078 include_hostname_hash: false,
1079 };
1080 let info = probe.probe(&options);
1081
1082 assert!(
1083 info.hostname_hash.is_none(),
1084 "hostname_hash should be None when not requested"
1085 );
1086 }
1087
1088 #[test]
1090 fn host_probe_returns_hostname_hash_when_requested() {
1091 let probe = StdHostProbe;
1092 let options = HostProbeOptions {
1093 include_hostname_hash: true,
1094 };
1095 let info = probe.probe(&options);
1096
1097 if let Some(hash) = &info.hostname_hash {
1099 let hash_len = hash.len();
1100 assert_eq!(
1101 hash_len, 64,
1102 "hostname_hash should be 64 hex chars, got {}",
1103 hash_len
1104 );
1105 assert!(
1106 hash.chars().all(|c| c.is_ascii_hexdigit()),
1107 "hostname_hash should be hex, got {}",
1108 hash
1109 );
1110 }
1111 }
1113
1114 #[test]
1116 fn hostname_hash_is_deterministic() {
1117 let probe = StdHostProbe;
1118 let options = HostProbeOptions {
1119 include_hostname_hash: true,
1120 };
1121
1122 let info1 = probe.probe(&options);
1123 let info2 = probe.probe(&options);
1124
1125 assert_eq!(
1126 info1.hostname_hash, info2.hostname_hash,
1127 "hostname_hash should be deterministic"
1128 );
1129 }
1130
1131 #[test]
1136 fn std_runner_executes_real_command() {
1137 let runner = StdProcessRunner;
1138 let spec = CommandSpec {
1139 argv: if cfg!(windows) {
1140 vec!["cmd".into(), "/c".into(), "echo".into(), "hello".into()]
1141 } else {
1142 vec!["/bin/sh".into(), "-c".into(), "echo hello".into()]
1143 },
1144 cwd: None,
1145 env: vec![],
1146 timeout: None,
1147 output_cap_bytes: 4096,
1148 };
1149
1150 let result = runner.run(&spec).expect("echo should succeed");
1151 assert_eq!(result.exit_code, 0);
1152 assert!(!result.timed_out);
1153
1154 let stdout_str = String::from_utf8_lossy(&result.stdout);
1155 assert!(
1156 stdout_str.contains("hello"),
1157 "stdout should contain 'hello', got: {:?}",
1158 stdout_str
1159 );
1160 }
1161
1162 #[test]
1163 fn std_runner_populates_samples_fields() {
1164 let runner = StdProcessRunner;
1165 let spec = CommandSpec {
1166 argv: if cfg!(windows) {
1167 vec![
1168 "cmd".into(),
1169 "/c".into(),
1170 "echo".into(),
1171 "test_output".into(),
1172 ]
1173 } else {
1174 vec!["/bin/sh".into(), "-c".into(), "echo test_output".into()]
1175 },
1176 cwd: None,
1177 env: vec![],
1178 timeout: None,
1179 output_cap_bytes: 4096,
1180 };
1181
1182 let result = runner.run(&spec).expect("run should succeed");
1183 assert_eq!(result.exit_code, 0);
1184 assert!(!result.timed_out);
1185 assert!(
1187 !result.stdout.is_empty(),
1188 "stdout should not be empty for echo command"
1189 );
1190 let stdout_str = String::from_utf8_lossy(&result.stdout);
1191 assert!(stdout_str.contains("test_output"));
1192 }
1193
1194 #[test]
1195 fn std_runner_with_env_vars() {
1196 let runner = StdProcessRunner;
1197 let spec = CommandSpec {
1198 argv: if cfg!(windows) {
1199 vec![
1200 "cmd".into(),
1201 "/c".into(),
1202 "echo".into(),
1203 "%PERFGATE_TEST_VAR%".into(),
1204 ]
1205 } else {
1206 vec![
1207 "/bin/sh".into(),
1208 "-c".into(),
1209 "echo $PERFGATE_TEST_VAR".into(),
1210 ]
1211 },
1212 cwd: None,
1213 env: vec![("PERFGATE_TEST_VAR".to_string(), "custom_value".to_string())],
1214 timeout: None,
1215 output_cap_bytes: 4096,
1216 };
1217
1218 let result = runner.run(&spec).expect("run with env vars should succeed");
1219 assert_eq!(result.exit_code, 0);
1220 let stdout_str = String::from_utf8_lossy(&result.stdout);
1221 assert!(
1222 stdout_str.contains("custom_value"),
1223 "stdout should contain env var value, got: {:?}",
1224 stdout_str
1225 );
1226 }
1227
1228 #[test]
1229 fn std_runner_invalid_command_returns_error() {
1230 let runner = StdProcessRunner;
1231 let spec = CommandSpec {
1232 argv: vec!["nonexistent_binary_that_does_not_exist_12345".to_string()],
1233 cwd: None,
1234 env: vec![],
1235 timeout: None,
1236 output_cap_bytes: 4096,
1237 };
1238
1239 let result = runner.run(&spec);
1240 assert!(
1241 result.is_err(),
1242 "running a nonexistent binary should return an error"
1243 );
1244 }
1245
1246 #[test]
1247 fn std_runner_captures_stderr() {
1248 let runner = StdProcessRunner;
1249 let spec = CommandSpec {
1250 argv: if cfg!(windows) {
1251 vec![
1252 "cmd".into(),
1253 "/c".into(),
1254 "echo".into(),
1255 "err_msg".into(),
1256 "1>&2".into(),
1257 ]
1258 } else {
1259 vec!["/bin/sh".into(), "-c".into(), "echo err_msg >&2".into()]
1260 },
1261 cwd: None,
1262 env: vec![],
1263 timeout: None,
1264 output_cap_bytes: 4096,
1265 };
1266
1267 let result = runner.run(&spec).expect("run should succeed");
1268 let stderr_str = String::from_utf8_lossy(&result.stderr);
1269 assert!(
1270 stderr_str.contains("err_msg"),
1271 "stderr should contain 'err_msg', got: {:?}",
1272 stderr_str
1273 );
1274 }
1275
1276 #[test]
1277 fn std_runner_nonzero_exit_code() {
1278 let runner = StdProcessRunner;
1279 let spec = CommandSpec {
1280 argv: if cfg!(windows) {
1281 vec!["cmd".into(), "/c".into(), "exit".into(), "42".into()]
1282 } else {
1283 vec!["/bin/sh".into(), "-c".into(), "exit 42".into()]
1284 },
1285 cwd: None,
1286 env: vec![],
1287 timeout: None,
1288 output_cap_bytes: 4096,
1289 };
1290
1291 let result = runner
1292 .run(&spec)
1293 .expect("run should succeed even with nonzero exit");
1294 assert_eq!(result.exit_code, 42);
1295 assert!(!result.timed_out);
1296 }
1297
1298 #[test]
1304 fn nonexistent_command_returns_other_error() {
1305 let runner = StdProcessRunner;
1306 let spec = CommandSpec {
1307 argv: vec!["__perfgate_nonexistent_cmd_xyz__".into()],
1308 cwd: None,
1309 env: vec![],
1310 timeout: None,
1311 output_cap_bytes: 4096,
1312 };
1313
1314 let err = runner.run(&spec).unwrap_err();
1315 assert!(
1316 matches!(err, AdapterError::Other(_)),
1317 "Expected AdapterError::Other for missing binary, got: {err:?}",
1318 );
1319 }
1320
1321 #[test]
1323 fn nonzero_exit_empty_output() {
1324 let runner = StdProcessRunner;
1325 let spec = CommandSpec {
1326 argv: if cfg!(windows) {
1327 vec!["cmd".into(), "/c".into(), "exit".into(), "1".into()]
1328 } else {
1329 vec!["/bin/sh".into(), "-c".into(), "exit 1".into()]
1330 },
1331 cwd: None,
1332 env: vec![],
1333 timeout: None,
1334 output_cap_bytes: 4096,
1335 };
1336
1337 let result = runner.run(&spec).expect("should succeed with nonzero exit");
1338 assert_eq!(result.exit_code, 1);
1339 assert!(result.stdout.is_empty(), "stdout should be empty");
1340 }
1342
1343 #[test]
1345 fn command_with_no_stdout() {
1346 let runner = StdProcessRunner;
1347 let spec = CommandSpec {
1348 argv: if cfg!(windows) {
1349 vec!["cmd".into(), "/c".into(), "rem".into()]
1351 } else {
1352 vec!["/bin/sh".into(), "-c".into(), "true".into()]
1353 },
1354 cwd: None,
1355 env: vec![],
1356 timeout: None,
1357 output_cap_bytes: 4096,
1358 };
1359
1360 let result = runner.run(&spec).expect("silent command should succeed");
1361 assert_eq!(result.exit_code, 0);
1362 assert!(
1363 result.stdout.is_empty(),
1364 "stdout should be empty for silent command"
1365 );
1366 }
1367
1368 #[test]
1370 fn invalid_cwd_returns_error() {
1371 let runner = StdProcessRunner;
1372 let spec = CommandSpec {
1373 argv: if cfg!(windows) {
1374 vec!["cmd".into(), "/c".into(), "echo".into(), "hi".into()]
1375 } else {
1376 vec!["/bin/sh".into(), "-c".into(), "echo hi".into()]
1377 },
1378 cwd: Some(PathBuf::from(
1379 "__perfgate_nonexistent_dir_xyz__/deeply/nested",
1380 )),
1381 env: vec![],
1382 timeout: None,
1383 output_cap_bytes: 4096,
1384 };
1385
1386 let result = runner.run(&spec);
1387 assert!(result.is_err(), "invalid cwd should cause an error");
1388 }
1389
1390 #[test]
1392 fn output_cap_truncates_large_stdout() {
1393 let runner = StdProcessRunner;
1394 let spec = CommandSpec {
1396 argv: if cfg!(windows) {
1397 vec![
1399 "cmd".into(),
1400 "/c".into(),
1401 "for /L %i in (1,1,500) do @echo AAAAAAAAAA".into(),
1402 ]
1403 } else {
1404 vec![
1405 "/bin/sh".into(),
1406 "-c".into(),
1407 "yes AAAAAAAAAA | head -n 500".into(),
1408 ]
1409 },
1410 cwd: None,
1411 env: vec![],
1412 timeout: None,
1413 output_cap_bytes: 64,
1414 };
1415
1416 let result = runner.run(&spec).expect("command should succeed");
1417 assert!(
1418 result.stdout.len() <= 64,
1419 "stdout should be capped at 64 bytes, got {}",
1420 result.stdout.len()
1421 );
1422 }
1423
1424 #[test]
1426 fn zero_output_cap_captures_nothing() {
1427 let runner = StdProcessRunner;
1428 let spec = CommandSpec {
1429 argv: if cfg!(windows) {
1430 vec!["cmd".into(), "/c".into(), "echo".into(), "hello".into()]
1431 } else {
1432 vec!["/bin/sh".into(), "-c".into(), "echo hello".into()]
1433 },
1434 cwd: None,
1435 env: vec![],
1436 timeout: None,
1437 output_cap_bytes: 0,
1438 };
1439
1440 let result = runner.run(&spec).expect("command should succeed");
1441 assert_eq!(result.exit_code, 0);
1442 assert!(
1443 result.stdout.is_empty(),
1444 "stdout should be empty with zero cap"
1445 );
1446 assert!(
1447 result.stderr.is_empty(),
1448 "stderr should be empty with zero cap"
1449 );
1450 }
1451
1452 #[test]
1458 fn instant_exit_process_has_valid_metrics() {
1459 let runner = StdProcessRunner;
1460 let spec = CommandSpec {
1461 argv: if cfg!(windows) {
1462 vec!["cmd".into(), "/c".into(), "exit".into(), "0".into()]
1463 } else {
1464 vec!["/bin/sh".into(), "-c".into(), "true".into()]
1465 },
1466 cwd: None,
1467 env: vec![],
1468 timeout: None,
1469 output_cap_bytes: 4096,
1470 };
1471
1472 let result = runner.run(&spec).expect("instant exit should succeed");
1473 assert_eq!(result.exit_code, 0);
1474 assert!(!result.timed_out);
1475 assert!(
1477 result.wall_ms < 5000,
1478 "wall_ms should be small for instant process, got {}",
1479 result.wall_ms,
1480 );
1481 }
1482
1483 #[test]
1485 fn instant_exit_cpu_ms_is_small() {
1486 let runner = StdProcessRunner;
1487 let spec = CommandSpec {
1488 argv: if cfg!(windows) {
1489 vec!["cmd".into(), "/c".into(), "exit".into(), "0".into()]
1490 } else {
1491 vec!["/bin/sh".into(), "-c".into(), "true".into()]
1492 },
1493 cwd: None,
1494 env: vec![],
1495 timeout: None,
1496 output_cap_bytes: 4096,
1497 };
1498
1499 let result = runner.run(&spec).expect("instant exit should succeed");
1500 if let Some(cpu) = result.cpu_ms {
1501 assert!(
1502 cpu < 1000,
1503 "cpu_ms should be under 1s for instant process, got {}",
1504 cpu,
1505 );
1506 }
1507 }
1508
1509 #[test]
1511 fn binary_bytes_populated_for_known_command() {
1512 let runner = StdProcessRunner;
1513 let spec = CommandSpec {
1514 argv: if cfg!(windows) {
1515 vec!["cmd".into(), "/c".into(), "echo".into(), "x".into()]
1516 } else {
1517 vec!["/bin/sh".into(), "-c".into(), "true".into()]
1518 },
1519 cwd: None,
1520 env: vec![],
1521 timeout: None,
1522 output_cap_bytes: 4096,
1523 };
1524
1525 let result = runner.run(&spec).expect("should succeed");
1526 if let Some(bytes) = result.binary_bytes {
1528 assert!(bytes > 0, "binary_bytes should be > 0 when present");
1529 }
1530 }
1531
1532 #[cfg(windows)]
1538 #[test]
1539 fn windows_does_not_collect_unix_only_metrics() {
1540 let runner = StdProcessRunner;
1541 let spec = CommandSpec {
1542 argv: vec!["cmd".into(), "/c".into(), "echo".into(), "hi".into()],
1543 cwd: None,
1544 env: vec![],
1545 timeout: None,
1546 output_cap_bytes: 4096,
1547 };
1548
1549 let result = runner.run(&spec).expect("should succeed on Windows");
1550 assert!(
1551 result.ctx_switches.is_none(),
1552 "ctx_switches should be None on Windows"
1553 );
1554 }
1555
1556 #[cfg(windows)]
1558 #[test]
1559 fn windows_timeout_returns_unsupported() {
1560 let runner = StdProcessRunner;
1561 let spec = CommandSpec {
1562 argv: vec!["cmd".into(), "/c".into(), "echo".into(), "hi".into()],
1563 cwd: None,
1564 env: vec![],
1565 timeout: Some(Duration::from_secs(5)),
1566 output_cap_bytes: 4096,
1567 };
1568
1569 let err = runner.run(&spec).unwrap_err();
1570 assert!(
1571 matches!(err, AdapterError::TimeoutUnsupported),
1572 "Expected TimeoutUnsupported on Windows, got: {err:?}",
1573 );
1574 }
1575
1576 #[cfg(unix)]
1578 #[test]
1579 fn unix_collects_all_rusage_metrics() {
1580 let runner = StdProcessRunner;
1581 let spec = CommandSpec {
1582 argv: vec!["/bin/sh".into(), "-c".into(), "echo hi".into()],
1583 cwd: None,
1584 env: vec![],
1585 timeout: None,
1586 output_cap_bytes: 4096,
1587 };
1588
1589 let result = runner.run(&spec).expect("should succeed on Unix");
1590 assert!(result.cpu_ms.is_some(), "cpu_ms should be Some on Unix");
1591 assert!(
1592 result.page_faults.is_some(),
1593 "page_faults should be Some on Unix"
1594 );
1595 assert!(
1596 result.ctx_switches.is_some(),
1597 "ctx_switches should be Some on Unix"
1598 );
1599 assert!(
1600 result.max_rss_kb.is_some(),
1601 "max_rss_kb should be Some on Unix"
1602 );
1603 }
1604
1605 #[test]
1607 fn host_probe_os_matches_platform() {
1608 let probe = StdHostProbe;
1609 let info = probe.probe(&HostProbeOptions::default());
1610
1611 if cfg!(windows) {
1612 assert_eq!(info.os, "windows");
1613 } else if cfg!(target_os = "linux") {
1614 assert_eq!(info.os, "linux");
1615 } else if cfg!(target_os = "macos") {
1616 assert_eq!(info.os, "macos");
1617 }
1618
1619 if cfg!(target_arch = "x86_64") {
1620 assert_eq!(info.arch, "x86_64");
1621 } else if cfg!(target_arch = "aarch64") {
1622 assert_eq!(info.arch, "aarch64");
1623 }
1624 }
1625
1626 #[test]
1628 fn read_with_cap_empty_reader() {
1629 let mut reader: &[u8] = &[];
1630 let result = read_with_cap(&mut reader, 1024);
1631 assert!(result.is_empty());
1632 }
1633
1634 #[test]
1636 fn read_with_cap_zero_cap() {
1637 let mut reader: &[u8] = b"hello world";
1638 let result = read_with_cap(&mut reader, 0);
1639 assert!(result.is_empty());
1640 }
1641
1642 #[test]
1644 fn read_with_cap_truncates() {
1645 let mut reader: &[u8] = b"hello world";
1646 let result = read_with_cap(&mut reader, 5);
1647 assert_eq!(result, b"hello");
1648 }
1649}