use std::env; use std::path::Path; use std::process::{Command, ExitCode}; use std::time::Instant; fn main() -> ExitCode { let mut args = env::args().skip(1); match args.next().as_deref() { Some("test") | None => run_test_plan(), Some("bench") => run_bench_plan(), Some("help") | Some("--help") | Some("-h") => { print_help(); ExitCode::SUCCESS } Some(command) => { eprintln!("unknown slhx-ci command `{command}`\n"); print_help(); ExitCode::from(2) } } } fn print_help() { println!( "slhx-ci — resource-aware project checks\n\n cargo run -p slhx-xtask -- test\n cargo run -p slhx-xtask -- bench\n\nEnvironment overrides:\n SLHX_CI_JOBS=N compile jobs, capped by detected resources\n SLHX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n SLHX_CI_SKIP_BROWSER=1 skip browser E2E" ); } fn run_test_plan() -> ExitCode { // req: test/004 let budget = Budget::detect(); budget.report(); let mut steps = vec![ Step::new( "workspace-no-techdemo", ["test", "--workspace", "--exclude", "slhx-techdemo"], ), Step::new( "techdemo-unit-http", ["test", "-p", "slhx-techdemo", "--test", "e2e"], ), Step::new("redgate", ["health", "--strict"]).tool("redgate"), ]; if !budget.skip_browser { steps.insert( 2, Step::new( "techdemo-browser", ["test", "-p", "slhx-techdemo", "--test", "browser_e2e"], ) .test_threads(1), ); } for step in steps { if let Err(code) = step.run(&budget) { return code; } } ExitCode::SUCCESS } fn run_bench_plan() -> ExitCode { // req: test/004 let budget = Budget::detect(); budget.report(); eprintln!( "slhx-ci: benchmarking small safe test slices from 1 to {} job(s)", budget.jobs ); let steps = [ Step::new("bench-xtask", ["test", "-p", "slhx-xtask"]), Step::new("bench-runtime", ["test", "-p", "slhx-js"]), ]; for jobs in bench_values(budget.jobs) { let bench_budget = budget.with_jobs(jobs); for step in &steps { let started = Instant::now(); if let Err(code) = step.run(&bench_budget) { return code; } println!( "bench\t{}\tjobs={}\ttest_threads={}\telapsed_ms={}", step.name, bench_budget.jobs, bench_budget.test_threads, started.elapsed().as_millis() ); } } ExitCode::SUCCESS } #[derive(Clone, Copy, Debug)] struct Budget { cpus: usize, mem_gib: Option, jobs: usize, test_threads: usize, skip_browser: bool, } impl Budget { fn detect() -> Self { let cpus = std::thread::available_parallelism() .map_or(1, usize::from) .max(1); let mem_gib = available_mem_gib(); let skip_browser = env::var_os("SLHX_CI_SKIP_BROWSER").is_some() || env::var_os("CI_NO_BROWSER").is_some() || !has_command("geckodriver"); Self::from_resources( cpus, mem_gib, env_usize("SLHX_CI_JOBS"), env_usize("SLHX_CI_TEST_THREADS"), skip_browser, ) } fn from_resources( cpus: usize, mem_gib: Option, jobs_override: Option, test_threads_override: Option, skip_browser: bool, ) -> Self { let cpus = cpus.max(1); let mem_jobs = mem_gib.map_or(cpus, |gib| (gib / 2).max(1)); let auto_jobs = cpus.min(mem_jobs).clamp(1, 6); let jobs = jobs_override.unwrap_or(auto_jobs).clamp(1, auto_jobs); let max_test_threads = jobs.min(4); let test_threads = test_threads_override .unwrap_or(max_test_threads) .clamp(1, max_test_threads); Self { cpus, mem_gib, jobs, test_threads, skip_browser, } } fn report(&self) { eprintln!( "slhx-ci: cpus={} mem={}GiB jobs={} test_threads={} browser={}", self.cpus, self.mem_gib .map(|mem| mem.to_string()) .unwrap_or_else(|| "unknown".into()), self.jobs, self.test_threads, if self.skip_browser { "skip" } else { "run" } ); } fn with_jobs(self, jobs: usize) -> Self { let jobs = jobs.clamp(1, self.jobs); Self { jobs, test_threads: self.test_threads.min(jobs.min(4)).max(1), ..self } } } fn bench_values(limit: usize) -> Vec { let limit = limit.max(1); let mut values = Vec::new(); let mut value = 1; while value < limit { values.push(value); value *= 2; } values.push(limit); values.dedup(); values } #[derive(Clone, Debug)] struct Step { name: &'static str, tool: &'static str, args: Vec<&'static str>, test_threads: Option, } impl Step { fn new(name: &'static str, args: [&'static str; N]) -> Self { Self { name, tool: "cargo", args: args.into(), test_threads: None, } } fn tool(mut self, tool: &'static str) -> Self { self.tool = tool; self } fn test_threads(mut self, threads: usize) -> Self { self.test_threads = Some(threads); self } fn run(&self, budget: &Budget) -> Result<(), ExitCode> { eprintln!("\n==> {}", self.name); let mut command = Command::new(self.tool); command.args(&self.args); if self.tool == "cargo" { command.env("CARGO_BUILD_JOBS", budget.jobs.to_string()); command.env( "RUST_TEST_THREADS", self.test_threads.unwrap_or(budget.test_threads).to_string(), ); } let status = command.status().map_err(|err| { eprintln!("failed to run {}: {err}", self.name); ExitCode::FAILURE })?; if status.success() { Ok(()) } else { eprintln!("{} failed with {status}", self.name); Err(ExitCode::from(status.code().unwrap_or(1) as u8)) } } } fn env_usize(key: &str) -> Option { env::var(key).ok()?.parse().ok() } fn available_mem_gib() -> Option { const GIB: u64 = 1024 * 1024 * 1024; let bytes = available_mem_bytes()?; Some(((bytes / GIB) as usize).max(1)) } fn available_mem_bytes() -> Option { let mut candidates = Vec::new(); if let Some(bytes) = proc_mem_available_bytes() { candidates.push(bytes); } if let Some(bytes) = cgroup_mem_available_bytes() { candidates.push(bytes); } candidates.into_iter().min() } fn proc_mem_available_bytes() -> Option { let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?; let kb = meminfo.lines().find_map(|line| { let rest = line.strip_prefix("MemAvailable:")?; rest.split_whitespace().next()?.parse::().ok() })?; kb.checked_mul(1024) } fn cgroup_mem_available_bytes() -> Option { cgroup_mem_available_v2().or_else(cgroup_mem_available_v1) } fn cgroup_mem_available_v2() -> Option { let limit = read_cgroup_limit("/sys/fs/cgroup/memory.max")?; let current = read_u64_file("/sys/fs/cgroup/memory.current").unwrap_or(0); Some(limit.saturating_sub(current).max(1)) } fn cgroup_mem_available_v1() -> Option { let limit = read_cgroup_limit("/sys/fs/cgroup/memory/memory.limit_in_bytes")?; let current = read_u64_file("/sys/fs/cgroup/memory/memory.usage_in_bytes").unwrap_or(0); Some(limit.saturating_sub(current).max(1)) } fn read_cgroup_limit(path: &str) -> Option { let raw = std::fs::read_to_string(path).ok()?; let trimmed = raw.trim(); if trimmed == "max" { return None; } let value = trimmed.parse::().ok()?; if value >= (1 << 60) { None } else { Some(value) } } fn read_u64_file(path: &str) -> Option { std::fs::read_to_string(path).ok()?.trim().parse().ok() } fn has_command(name: &str) -> bool { let Some(paths) = env::var_os("PATH") else { return false; }; env::split_paths(&paths).any(|dir| is_executable(dir.join(name))) } fn is_executable(path: impl AsRef) -> bool { path.as_ref().is_file() } #[cfg(test)] mod tests { use super::Budget; #[test] fn budget_is_capped_by_available_memory() { // req: test/004 let budget = Budget::from_resources(22, Some(1), None, None, true); assert_eq!(budget.jobs, 1); assert_eq!(budget.test_threads, 1); } #[test] fn budget_caps_large_machines_by_default() { let budget = Budget::from_resources(64, Some(128), None, None, true); assert_eq!(budget.jobs, 6); assert_eq!(budget.test_threads, 4); } #[test] fn budget_clamps_explicit_overrides_to_resource_budget() { let budget = Budget::from_resources(2, Some(2), Some(8), Some(7), true); assert_eq!(budget.jobs, 1); assert_eq!(budget.test_threads, 1); } #[test] fn bench_values_grow_gradually_and_include_limit() { assert_eq!(super::bench_values(1), vec![1]); assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]); } }