diff --git a/README.md b/README.md index 301ac10a..8abe2f7e 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ That uses GitHub Actions cache by default. For S3-backed caching shared across r ## C/C++ caching -Alongside the rustc wrapper, kache can cache **C/C++ object compiles** as a `cc` / `c++` wrapper. It recognizes `cc`, `c++`, `gcc`, `g++`, `clang`, and `clang++` (plus versioned variants like `gcc-13` and target-prefixed cross compilers like `arm-linux-gnueabihf-gcc`), and `clang-cl` or any `--driver-mode=cl` invocation (clang in MSVC driver mode) — on every OS, not just Windows, since recognition keys off the driver mode rather than the host. clang-cl is what mozconfigs and the `cc` crate use on Windows: +Alongside the rustc wrapper, kache can cache **C/C++ object compiles** as a `cc` / `c++` wrapper. It recognizes `cc`, `c++`, `gcc`, `g++`, `clang`, and `clang++` (plus versioned variants like `gcc-13` and target-prefixed cross compilers like `arm-linux-gnueabihf-gcc`), and `clang-cl` or any `--driver-mode=cl` invocation (clang in MSVC driver mode) — on every OS, not just Windows, since recognition keys off the driver mode rather than the host. For unrecognized compiler wrapper names (such as custom scripts, symlinks/copies of gcc, or thin shims), kache dynamically detects the compiler family via `-E` preprocessor probing. The wrapper executable itself must accept the usual preprocessor CLI (forwarding `-E` and defining `__clang__` or `__GNUC__`). Note that nested multi-token wrappers (e.g., `kache ccache gcc`) are not expanded. clang-cl is what mozconfigs and the `cc` crate use on Windows: ```sh # POSIX (gcc / clang) diff --git a/docs/getting-started/c-cpp.mdx b/docs/getting-started/c-cpp.mdx index b12ace89..f0442cf2 100644 --- a/docs/getting-started/c-cpp.mdx +++ b/docs/getting-started/c-cpp.mdx @@ -28,7 +28,7 @@ For clang-cl / MSVC-driver mode: set "CC=kache clang-cl" ``` -kache recognizes `cc`, `c++`, `gcc`, `g++`, `clang`, `clang++`, versioned variants like `gcc-13`, target-prefixed cross compilers like `arm-linux-gnueabihf-gcc`, `clang-cl`, and `clang --driver-mode=cl`. +kache recognizes `cc`, `c++`, `gcc`, `g++`, `clang`, `clang++`, versioned variants like `gcc-13`, target-prefixed cross compilers like `arm-linux-gnueabihf-gcc`, `clang-cl`, and `clang --driver-mode=cl`. Any other compiler wrapper names that miss this allowlist are dynamically detected via `-E` preprocessor probing. ## What Is Cached diff --git a/src/compiler/cc.rs b/src/compiler/cc.rs index de1d8db3..c7fd5c8e 100644 --- a/src/compiler/cc.rs +++ b/src/compiler/cc.rs @@ -3671,6 +3671,16 @@ fn named_tool_family(name: &str) -> Option { }) } +fn is_unresolvable_bare_program(program: &str) -> bool { + if program.contains('/') { + return false; + } + if program.contains('\\') { + return false; + } + super::resolve_program_on_path(program).is_none() +} + impl CcCompiler { pub fn new() -> Self { Self::default() @@ -3703,6 +3713,9 @@ impl CcCompiler { /// Owns its own detection rule; `super::detect_compiler` reaches it /// through this module's [`ADAPTER`] descriptor. pub fn recognizes(args: &[String]) -> bool { + if super::is_workspace_wrapper_chain(args) { + return false; + } let Some(arg0) = args.first() else { return false; }; @@ -3734,7 +3747,15 @@ impl CcCompiler { return true; } - false + // ── Slow path: `-E` probe for unknown binaries ── + if super::is_kache_subcommand_or_flag(&name) { + return false; + } + if is_unresolvable_bare_program(arg0) { + return false; + } + + crate::probe::probe_compiler_family(arg0).is_some() } /// Does this argv match the `cc` Rust crate's compiler-family @@ -4811,6 +4832,124 @@ mod tests { } } + #[test] + fn recognizes_unknown_wrapper_via_probe() { + if cfg!(target_os = "macos") { + return; // Apple's /usr/bin/cc re-dispatches on argv[0] via xcode-select + } + + let _lock = crate::config::config_path_lock(); + let temp = tempfile::TempDir::new().unwrap(); + // Find a compiler on the system PATH to copy. + let compilers = ["cc", "gcc", "clang"]; + let source_compiler = compilers.iter().find_map(|&c| { + let path = crate::compiler::resolve_program_on_path(c)?; + if crate::probe::probe_compiler_family(path.to_str()?).is_some() { + Some(path) + } else { + None + } + }); + let Some(source_path) = source_compiler else { + return; // Skip if no GCC/Clang C compiler is installed. + }; + + // Copy or symlink it to an unrecognized name in temp directory. + let custom_name = if cfg!(windows) { + "my custom & compiler.cmd" + } else { + "my-custom-compiler" + }; + let dest_path = temp.path().join(custom_name); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&source_path, &dest_path).unwrap(); + } + #[cfg(windows)] + { + std::fs::write( + &dest_path, + format!("@echo off\r\n\"{}\" %*", source_path.display()), + ) + .unwrap(); + } + + // recognizes() should successfully probe and return true! + let dest_str = dest_path.to_str().unwrap().to_string(); + assert!(CcCompiler::recognizes(std::slice::from_ref(&dest_str))); + + // The same wrapper must be detected when it is found by PATH. This + // exercises the bare-name guard and the OS's safe argument handling + // for the Windows `.cmd` name containing spaces and `&`. + let previous_path = std::env::var_os("PATH"); + let mut path_entries = vec![temp.path().to_path_buf()]; + if let Some(previous) = previous_path.as_deref() { + path_entries.extend(std::env::split_paths(previous)); + } + let joined_path = std::env::join_paths(path_entries).unwrap(); + unsafe { + std::env::set_var("PATH", joined_path); + } + let recognized_by_bare_name = CcCompiler::recognizes(&s(&[custom_name])); + unsafe { + match previous_path { + Some(previous) => std::env::set_var("PATH", previous), + None => std::env::remove_var("PATH"), + } + } + assert!(recognized_by_bare_name); + + // Must also succeed during actual wrapper dispatch when KACHE_ACTIVE is set in wrapper mode + let recognized_during_dispatch = { + let prev = std::env::var_os("KACHE_ACTIVE"); + unsafe { + std::env::set_var("KACHE_ACTIVE", "1"); + } + struct Guard(Option); + impl Drop for Guard { + fn drop(&mut self) { + unsafe { + match self.0.as_ref() { + Some(val) => std::env::set_var("KACHE_ACTIVE", val), + None => std::env::remove_var("KACHE_ACTIVE"), + } + } + } + } + let _guard = Guard(prev); + CcCompiler::recognizes(std::slice::from_ref(&dest_str)) + }; + assert!( + recognized_during_dispatch, + "unknown compiler wrapper must be recognized during wrapper dispatch when KACHE_ACTIVE is set" + ); + } + + #[test] + fn recognizes_does_not_probe_kache_subcommands() { + assert!(!CcCompiler::recognizes(&s(&["list"]))); + assert!(!CcCompiler::recognizes(&s(&["gc"]))); + assert!(!CcCompiler::recognizes(&s(&["monitor"]))); + assert!(!CcCompiler::recognizes(&s(&["config"]))); + } + + #[test] + fn recognizes_checks_path_separators_and_path_resolution() { + // Bare name not on PATH -> returns false without probing + assert!(!CcCompiler::recognizes(&s(&[ + "kache_nonexistent_cc_binary_12345" + ]))); + + // Path with separators that does not exist -> returns false + let nonexistent_path = if cfg!(windows) { + r"C:\nonexistent\path\to\mycc" + } else { + "/nonexistent/path/to/mycc" + }; + assert!(!CcCompiler::recognizes(&s(&[nonexistent_path]))); + } + #[test] fn recognizes_family_probe_matches_dash_e_with_file_arg() { assert!(CcCompiler::recognizes_family_probe(&s(&[ diff --git a/src/config.rs b/src/config.rs index 2adb7115..7d919226 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1823,6 +1823,9 @@ pub(crate) fn parse_size_checked(value: &str, source: &str) -> Option { parsed } +#[cfg(test)] +pub(crate) use tests::config_path_lock; + #[cfg(test)] mod tests { use super::*; @@ -1843,7 +1846,7 @@ mod tests { ); } - fn config_path_lock() -> std::sync::MutexGuard<'static, ()> { + pub(crate) fn config_path_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() } diff --git a/src/daemon.rs b/src/daemon.rs index bcd862f8..bafc4eec 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -5589,8 +5589,8 @@ mod tests { } #[cfg(windows)] { - std::process::Command::new("cmd") - .args(["/c", "ping", "-n", "31", "127.0.0.1"]) + std::process::Command::new("ping") + .args(["-n", "31", "127.0.0.1"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() diff --git a/src/main.rs b/src/main.rs index ce9a8793..ee32c1c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -402,6 +402,11 @@ fn init_logging(mode: LogMode) { } fn main() -> Result<()> { + if std::env::var_os("KACHE_FAMILY_PROBE_ACTIVE").is_some() { + // Prevent unbounded recursion when a probed wrapper calls back into kache. + return Ok(()); + } + let env_args: Vec = std::env::args().collect(); let log_mode = detect_log_mode(&env_args); diff --git a/src/platform.rs b/src/platform.rs index a23b01e4..bf209a94 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -82,6 +82,20 @@ pub fn kill_process(pid: u32) { } } +/// Forcefully kill a process and all its descendants (process group on Unix, process tree on Windows). +pub fn kill_process_group(pid: u32) { + #[cfg(unix)] + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .output(); + } +} + #[cfg(windows)] fn windows_terminate(pid: u32) { use windows_sys::Win32::Foundation::CloseHandle; diff --git a/src/probe/cache.rs b/src/probe/cache.rs index cccdfe42..a7af0ee6 100644 --- a/src/probe/cache.rs +++ b/src/probe/cache.rs @@ -52,6 +52,25 @@ pub fn probe_key(prober_id: &str, req: &ProbeRequest<'_>) -> Option { Some(h.finalize().to_hex().to_string()) } +/// A narrower key for probes that don't depend on the process environment or +/// arguments (e.g. `cc-family` detecting `__clang__` vs `__GNUC__`). +pub fn probe_key_isolated(prober_id: &str, program: &str) -> Option { + let resolved = resolve_program(program)?; + let meta = std::fs::metadata(&resolved).ok()?; + let fingerprint = compiler_fingerprint(&meta); + + let mut h = blake3::Hasher::new(); + h.update(b"probe_schema:"); + h.update(PROBE_SCHEMA_VERSION.to_string().as_bytes()); + h.update(b"\nprober:"); + h.update(prober_id.as_bytes()); + h.update(b"\ncompiler_path:"); + h.update(resolved.to_string_lossy().as_bytes()); + h.update(b"\ncompiler_stat:"); + h.update(fingerprint.as_bytes()); + Some(h.finalize().to_hex().to_string()) +} + /// Hash of the process environment — see [`fingerprint_env`]. /// /// `cc -###` inherits this environment, so a change to it (a different @@ -81,18 +100,14 @@ fn fingerprint_env(vars: impl Iterator) -> String { } /// True for environment-variable names that vary between otherwise -/// identical build invocations and so must stay out of the probe key. +/// identical build invocations or are kache-internal/test environment +/// controls and so must stay out of the probe key. /// -/// Windows' process environment carries hidden cmd.exe bookkeeping -/// variables whose names begin with `=`: the per-drive working directory -/// (`=C:`, `=D:`, …) and the previous child's exit status (`=ExitCode`). -/// `std::env::vars()` surfaces them, yet they are never inputs to -/// `cc -###` and they shift between the cold and warm builds — which made -/// the on-disk probe memo miss on the warm build, re-running the probe -/// (#201). A Unix variable name cannot contain `=`, so this never fires -/// there. +/// This excludes kache-internal variables (`KACHE_*`) which configure kache +/// or tests, as well as Windows' hidden cmd.exe bookkeeping variables whose +/// names begin with `=` (`=C:`, `=ExitCode`, …). fn is_volatile_env_name(name: &str) -> bool { - name.starts_with('=') + name.starts_with('=') || name.starts_with("KACHE_") } /// Load a probe record by key, or `None` on any miss: file absent, @@ -268,13 +283,15 @@ mod tests { } #[test] - fn is_volatile_env_name_flags_only_equals_prefixed() { + fn is_volatile_env_name_flags_equals_and_kache_internal_vars() { assert!(is_volatile_env_name("=C:")); assert!(is_volatile_env_name("=ExitCode")); + assert!(is_volatile_env_name("KACHE_ACTIVE")); + assert!(is_volatile_env_name("KACHE_FAMILY_PROBE_ACTIVE")); + assert!(is_volatile_env_name("KACHE_CACHE_DIR")); + assert!(is_volatile_env_name("KACHE_CONFIG")); assert!(!is_volatile_env_name("PATH")); assert!(!is_volatile_env_name("SDKROOT")); - // A normal name that merely contains `=` later cannot occur, but - // guard the boundary: only a leading `=` is volatile. assert!(!is_volatile_env_name("A=B")); } @@ -308,6 +325,23 @@ mod tests { assert!(probe_key("cc", &req("/nonexistent/kache-cc-xyz")).is_none()); } + #[test] + fn probe_key_isolated_returns_valid_hash_and_bails_on_missing() { + let compiler = NamedTempFile::new().unwrap(); + let path_str = compiler.path().to_str().unwrap(); + + let k1 = probe_key_isolated("cc-family", path_str).unwrap(); + assert_eq!(k1.len(), 64, "Blake3 hex digest must be 64 characters"); + + let k2 = probe_key_isolated("cc-family", path_str).unwrap(); + assert_eq!(k1, k2); + + let k_other = probe_key_isolated("other-id", path_str).unwrap(); + assert_ne!(k1, k_other, "different prober_id must yield different key"); + + assert!(probe_key_isolated("cc-family", "/nonexistent/kache-cc-xyz").is_none()); + } + #[test] fn with_exe_suffix_appends_when_set_and_absent() { assert_eq!( diff --git a/src/probe/mod.rs b/src/probe/mod.rs index 196546c2..0d10819c 100644 --- a/src/probe/mod.rs +++ b/src/probe/mod.rs @@ -159,6 +159,186 @@ impl Prober for CcProber { } } +/// Compiler family detected via `-E` preprocessing probe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbedFamily { + Gnu, + Clang, +} + +/// Probe an unknown binary via `-E -P -x c ` to detect its +/// compiler family. +/// +/// Writes a small C snippet containing `#if defined(__clang__)` / +/// `#elif defined(__GNUC__)` markers to a temporary source file and scans +/// the preprocessor output. +/// +/// Results are memoized in the existing probe cache under prober id +/// `"cc-family"`. No changes to `ResolvedConfig` — the family string +/// is stored in the `version_line` field of the existing record format. +/// +/// Returns `None` if the binary isn't a recognized C compiler. +pub fn probe_compiler_family(program: &str) -> Option { + // Avoid parsing the full TOML config just to get the cache directory on the fast path. + let cache_dir = std::env::var_os("KACHE_CACHE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(crate::config::default_cache_dir); + + let key = cache::probe_key_isolated("cc-family", program); + + // Cache hit: read family from version_line. + if let Some(ref k) = key + && let Some(hit) = cache::load(&cache_dir, k) + { + match hit.version_line.as_str() { + "clang" => return Some(ProbedFamily::Clang), + "gnu" => return Some(ProbedFamily::Gnu), + "none" => return None, // Cached negative! + _ => {} // Invalid/corrupted, treat as miss and re-probe + } + } + + // Miss: run the probe. + let family = run_family_probe(program); + let family_str = match family { + Ok(Some(ProbedFamily::Clang)) => "clang", + Ok(Some(ProbedFamily::Gnu)) => "gnu", + Ok(None) => "none", + Err(_) => return None, // Do not cache transient failures + }; + + // Store in the existing probe cache. Family (or "none") is encoded in + // version_line — no ResolvedConfig changes needed. + if let Some(ref k) = key { + cache::store( + &cache_dir, + k, + &ResolvedConfig { + schema_version: PROBE_SCHEMA_VERSION, + prober: "cc-family".to_string(), + compiler_name: std::path::Path::new(program) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(program) + .to_string(), + version_line: family_str.to_string(), + resolved_tokens: None, + }, + ); + } + + match family { + Ok(Some(f)) => Some(f), + _ => None, + } +} + +const FAMILY_PROBE_SOURCE: &[u8] = b"\ +#if defined(__clang__)\n\ +KACHE_PROBE_CLANG\n\ +#elif defined(__GNUC__)\n\ +KACHE_PROBE_GNU\n\ +#endif\n"; + +fn run_family_probe(program: &str) -> Result, ()> { + use std::io::Read; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + // Use a file rather than stdin: Windows batch wrappers pass ordinary + // arguments through reliably, but stdin is consumed by cmd.exe instead + // of reaching the compiler invoked by the wrapper. + let source_file = tempfile::NamedTempFile::new().map_err(|_| ())?; + std::fs::write(source_file.path(), FAMILY_PROBE_SOURCE).map_err(|_| ())?; + + let mut child_cmd = Command::new(program); + child_cmd + .args(["-E", "-P", "-x", "c"]) + .arg(source_file.path()) + .env("KACHE_FAMILY_PROBE_ACTIVE", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + crate::platform::configure_detached_process(&mut child_cmd); + let mut child = match child_cmd.spawn() { + Ok(c) => c, + Err(_) => return Err(()), + }; + let pid = child.id(); + + let mut stdout_handle = match child.stdout.take() { + Some(s) => s, + None => return Err(()), + }; + let (tx, rx) = std::sync::mpsc::channel(); + + let tx_read = tx.clone(); + std::thread::spawn(move || { + let mut buf = vec![0u8; 8192]; + let mut nread = 0; + loop { + if nread == buf.len() { + break; + } + match stdout_handle.read(&mut buf[nread..]) { + Ok(0) => break, + Ok(n) => nread += n, + Err(_) => break, + } + } + buf.truncate(nread); + let _ = tx_read.send(Ok(buf)); + }); + + let tx_wait = tx.clone(); + std::thread::spawn(move || { + let status = child.wait().ok(); + let _ = tx_wait.send(Err(status)); + }); + + let mut output = None; + let mut exit_status = None; + let start = Instant::now(); + let timeout = Duration::from_secs(5); + + loop { + if matches!((output.is_some(), exit_status.is_some()), (true, true)) { + break; + } + if start.elapsed() >= timeout { + break; + } + let remaining = timeout.saturating_sub(start.elapsed()); + match rx.recv_timeout(remaining) { + Ok(Ok(buf)) => output = Some(buf), + Ok(Err(status)) => exit_status = Some(status), + Err(_) => break, + } + } + + let Some(output_buf) = output.as_ref() else { + crate::platform::kill_process_group(pid); + return Err(()); + }; + let Some(Some(status)) = exit_status.as_ref() else { + crate::platform::kill_process_group(pid); + return Err(()); + }; + if !status.success() { + return Ok(None); + } + + let stdout_str = String::from_utf8_lossy(output_buf); + let clang = stdout_str.contains("KACHE_PROBE_CLANG"); + let gnu = stdout_str.contains("KACHE_PROBE_GNU"); + match (clang, gnu) { + (true, false) => Ok(Some(ProbedFamily::Clang)), + (false, true) => Ok(Some(ProbedFamily::Gnu)), + _ => Ok(None), + } +} + /// Run `cc -### ` and reduce the resolved `-cc1` invocation to /// its codegen-semantic token list. /// @@ -258,6 +438,14 @@ pub fn probe( #[cfg(test)] mod tests { use super::*; + + fn parse_family(s: &str) -> Option { + match s { + "clang" => Some(ProbedFamily::Clang), + "gnu" => Some(ProbedFamily::Gnu), + _ => None, + } + } use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::{NamedTempFile, TempDir}; @@ -298,6 +486,7 @@ mod tests { #[test] fn probe_runs_prober_once_then_serves_from_cache() { + let _lock = crate::config::config_path_lock(); let cache = TempDir::new().unwrap(); // A real, stat-able file stands in for the compiler binary — // the CountingProber never actually execs it. @@ -318,6 +507,7 @@ mod tests { #[test] fn probe_falls_back_to_running_when_compiler_is_unresolvable() { + let _lock = crate::config::config_path_lock(); // A path that doesn't exist cannot be keyed, so every call // re-probes — but each call still succeeds. Correctness is // never sacrificed for memoization. @@ -424,4 +614,236 @@ mod tests { let head = super::probe_stderr_head("clang version 19\nTarget: x86_64\n"); assert_eq!(head, "clang version 19\nTarget: x86_64"); } + + struct TestCacheDirGuard { + _lock: std::sync::MutexGuard<'static, ()>, + previous: Option, + } + + impl Drop for TestCacheDirGuard { + fn drop(&mut self) { + unsafe { + match self.previous.as_ref() { + Some(prev) => std::env::set_var("KACHE_CACHE_DIR", prev), + None => std::env::remove_var("KACHE_CACHE_DIR"), + } + } + } + } + + fn set_test_cache_dir(path: &std::path::Path) -> TestCacheDirGuard { + let lock = crate::config::config_path_lock(); + let previous = std::env::var_os("KACHE_CACHE_DIR"); + unsafe { + std::env::set_var("KACHE_CACHE_DIR", path); + } + TestCacheDirGuard { + _lock: lock, + previous, + } + } + + #[test] + fn parse_family_handles_valid_and_invalid_inputs() { + assert_eq!(parse_family("clang"), Some(ProbedFamily::Clang)); + assert_eq!(parse_family("gnu"), Some(ProbedFamily::Gnu)); + assert_eq!(parse_family("invalid"), None); + assert_eq!(parse_family(""), None); + } + + #[test] + fn family_probe_detects_system_cc() { + let temp = TempDir::new().unwrap(); + let _guard = set_test_cache_dir(temp.path()); + let res = probe_compiler_family("cc"); + if res.is_none() { + return; + } + assert!(matches!( + res, + Some(ProbedFamily::Clang) | Some(ProbedFamily::Gnu) + )); + } + + #[test] + fn family_probe_returns_none_for_non_compiler() { + let temp = TempDir::new().unwrap(); + let _guard = set_test_cache_dir(temp.path()); + let res = probe_compiler_family("cargo"); + assert_eq!(res, None); + } + + #[test] + fn family_probe_cached_result_roundtrips() { + let temp = TempDir::new().unwrap(); + let _guard = set_test_cache_dir(temp.path()); + + let res1 = probe_compiler_family("cc"); + if res1.is_none() { + return; + } + + // Locate the cached file on disk. + let files: Vec<_> = std::fs::read_dir(temp.path().join("probes")) + .unwrap() + .map(|r| r.unwrap().path()) + .collect(); + assert_eq!(files.len(), 1); + let cached_path = &files[0]; + + // Read the file, modify the family, and write it back. + let bytes = std::fs::read(cached_path).unwrap(); + let mut hit: ResolvedConfig = serde_json::from_slice(&bytes).unwrap(); + + // Invert the family in the cached record. + let original_family = hit.version_line.clone(); + let inverted_family = if original_family == "clang" { + "gnu" + } else { + "clang" + }; + hit.version_line = inverted_family.to_string(); + + std::fs::write(cached_path, serde_json::to_vec(&hit).unwrap()).unwrap(); + + // Call the probe again. It should return the inverted family from the cache hit! + let res2 = probe_compiler_family("cc").unwrap(); + assert_ne!(res1.unwrap(), res2); + assert_eq!(res2, parse_family(inverted_family).unwrap()); + } + + #[test] + fn family_probe_reads_cached_gnu_clang_none_and_corrupt() { + let temp = TempDir::new().unwrap(); + let _guard = set_test_cache_dir(temp.path()); + + let compiler = + create_mock_probe_script(temp.path(), "mock_cached_none", "echo KACHE_PROBE_GNU"); + let prog = compiler.to_str().unwrap(); + let key = cache::probe_key_isolated("cc-family", prog).unwrap(); + + // 1. Cached "gnu" + cache::store( + temp.path(), + &key, + &ResolvedConfig { + schema_version: PROBE_SCHEMA_VERSION, + prober: "cc-family".to_string(), + compiler_name: "dummy".to_string(), + version_line: "gnu".to_string(), + resolved_tokens: None, + }, + ); + assert_eq!(probe_compiler_family(prog), Some(ProbedFamily::Gnu)); + + // 2. Cached "clang" + cache::store( + temp.path(), + &key, + &ResolvedConfig { + schema_version: PROBE_SCHEMA_VERSION, + prober: "cc-family".to_string(), + compiler_name: "dummy".to_string(), + version_line: "clang".to_string(), + resolved_tokens: None, + }, + ); + assert_eq!(probe_compiler_family(prog), Some(ProbedFamily::Clang)); + + // 3. Cached "none" (negative hit) + cache::store( + temp.path(), + &key, + &ResolvedConfig { + schema_version: PROBE_SCHEMA_VERSION, + prober: "cc-family".to_string(), + compiler_name: "dummy".to_string(), + version_line: "none".to_string(), + resolved_tokens: None, + }, + ); + assert_eq!(probe_compiler_family(prog), None); + } + + fn create_mock_probe_script( + dir: &std::path::Path, + name: &str, + body: &str, + ) -> std::path::PathBuf { + #[cfg(unix)] + { + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + #[cfg(windows)] + { + let path = dir.join(format!("{name}.bat")); + std::fs::write(&path, format!("@echo off\r\n{body}\r\n")).unwrap(); + path + } + } + + #[test] + fn family_probe_executes_scripts_and_parses_outputs() { + let temp = TempDir::new().unwrap(); + let _guard = set_test_cache_dir(temp.path()); + + // 1. Script emitting GNU marker + let gnu_script = create_mock_probe_script(temp.path(), "mock_gnu", "echo KACHE_PROBE_GNU"); + let gnu_str = gnu_script.to_str().unwrap(); + assert_eq!(probe_compiler_family(gnu_str), Some(ProbedFamily::Gnu)); + assert_eq!(probe_compiler_family(gnu_str), Some(ProbedFamily::Gnu)); + + // 2. Script emitting Clang marker + let clang_script = + create_mock_probe_script(temp.path(), "mock_clang", "echo KACHE_PROBE_CLANG"); + let clang_str = clang_script.to_str().unwrap(); + assert_eq!(probe_compiler_family(clang_str), Some(ProbedFamily::Clang)); + assert_eq!(probe_compiler_family(clang_str), Some(ProbedFamily::Clang)); + + // 3. Script emitting BOTH markers (ambiguous) + let both_script = create_mock_probe_script( + temp.path(), + "mock_both", + if cfg!(windows) { + "echo KACHE_PROBE_CLANG\r\necho KACHE_PROBE_GNU" + } else { + "echo KACHE_PROBE_CLANG\necho KACHE_PROBE_GNU" + }, + ); + let both_str = both_script.to_str().unwrap(); + assert_eq!(probe_compiler_family(both_str), None); + + // 4. Script emitting NEITHER marker + let unk_script = create_mock_probe_script(temp.path(), "mock_unk", "echo UNKNOWN_COMPILER"); + let unk_str = unk_script.to_str().unwrap(); + assert_eq!(probe_compiler_family(unk_str), None); + + // 5. Script exiting with non-zero status + let fail_script = create_mock_probe_script( + temp.path(), + "mock_fail", + if cfg!(windows) { "exit /b 1" } else { "exit 1" }, + ); + let fail_str = fail_script.to_str().unwrap(); + assert_eq!(probe_compiler_family(fail_str), None); + } + + #[test] + fn run_family_probe_handles_large_output() { + let temp = TempDir::new().unwrap(); + let large_body = if cfg!(windows) { + "echo KACHE_PROBE_GNU\r\nfor /L %%i in (1,1,200) do echo 01234567890123456789012345678901234567890123456789" + } else { + "echo KACHE_PROBE_GNU\nyes '0123456789012345678901234567890123456789' | head -n 300" + }; + let script = create_mock_probe_script(temp.path(), "mock_large", large_body); + let started = std::time::Instant::now(); + let res = run_family_probe(script.to_str().unwrap()); + assert_eq!(res, Ok(Some(ProbedFamily::Gnu))); + assert!(started.elapsed() < std::time::Duration::from_secs(4)); + } } diff --git a/tests/unknown_compiler_probe_test.rs b/tests/unknown_compiler_probe_test.rs new file mode 100644 index 00000000..029cad4b --- /dev/null +++ b/tests/unknown_compiler_probe_test.rs @@ -0,0 +1,111 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use std::time::Instant; + +fn kache_binary() -> &'static str { + env!("CARGO_BIN_EXE_kache") +} + +#[test] +fn probe_recovers_when_wrapper_fork_bombs() { + let dir = tempfile::tempdir().unwrap(); + let wrapper = dir.path().join("my-compiler"); + + fs::write( + &wrapper, + format!("#!/bin/sh\nexec {} \"$0\" \"$@\"\n", kache_binary()), + ) + .unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)).unwrap(); + + let start = Instant::now(); + let _ = Command::new(kache_binary()) + .arg(&wrapper) + .arg("-c") + .arg("foo.c") + .env("KACHE_CACHE_DIR", dir.path().join("cache")) + .output() + .expect("failed to run kache"); + + assert!( + start.elapsed().as_secs() < 10, + "probe should not hang on fork bomb" + ); +} + +#[test] +fn probe_recovers_when_wrapper_emits_8kb_then_hangs() { + let dir = tempfile::tempdir().unwrap(); + let wrapper = dir.path().join("my-compiler-hang"); + + fs::write( + &wrapper, + "#!/bin/sh\n\ + head -c 9000 /dev/zero\n\ + sleep 60\n", + ) + .unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)).unwrap(); + + let start = Instant::now(); + let _ = Command::new(kache_binary()) + .arg(&wrapper) + .arg("-c") + .arg("foo.c") + .env("KACHE_CACHE_DIR", dir.path().join("cache")) + .output() + .expect("failed to run kache"); + + assert!( + start.elapsed().as_secs() < 15, + "probe must kill hanging wrapper after reading 8KB" + ); +} + +#[test] +fn probe_recovers_when_wrapper_leaves_descendant_on_stdout() { + let dir = tempfile::tempdir().unwrap(); + let wrapper = dir.path().join("my-compiler-descendant"); + let pid_file = dir.path().join("descendant.pid"); + + fs::write( + &wrapper, + format!( + "#!/bin/sh\n\ + sleep 60 &\n\ + echo $! > \"{}\"\n\ + exit 0\n", + pid_file.display() + ), + ) + .unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)).unwrap(); + + let start = Instant::now(); + let _ = Command::new(kache_binary()) + .arg(&wrapper) + .arg("-c") + .arg("foo.c") + .env("KACHE_CACHE_DIR", dir.path().join("cache")) + .output() + .expect("failed to run kache"); + + assert!( + start.elapsed().as_secs() < 15, + "probe must kill descendants holding stdout" + ); + + if let Ok(pid_str) = fs::read_to_string(&pid_file) + && let Ok(pid) = pid_str.trim().parse::() + { + let still_alive = unsafe { libc::kill(pid, 0) == 0 }; + assert!( + !still_alive, + "descendant process {} should have been killed", + pid + ); + } +}