build(test): cap verification concurrency

req: test/004
This commit is contained in:
slhx agent
2026-05-11 10:27:09 +02:00
parent ebe7f58905
commit 7a2c5ecae0
6 changed files with 296 additions and 2 deletions
+278
View File
@@ -0,0 +1,278 @@
use std::env;
use std::path::Path;
use std::process::{Command, ExitCode};
fn main() -> ExitCode {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("test") | None => run_test_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\nEnvironment overrides:\n SLHX_CI_JOBS=N compile jobs\n SLHX_CI_TEST_THREADS=N Rust test threads\n SLHX_CI_SKIP_BROWSER=1 skip browser E2E"
);
}
fn run_test_plan() -> ExitCode {
// req: test/004
let budget = Budget::detect();
eprintln!(
"slhx-ci: cpus={} mem={}GiB jobs={} test_threads={} browser={}",
budget.cpus,
budget
.mem_gib
.map(|mem| mem.to_string())
.unwrap_or_else(|| "unknown".into()),
budget.jobs,
budget.test_threads,
if budget.skip_browser { "skip" } else { "run" }
);
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
}
#[derive(Clone, Copy, Debug)]
struct Budget {
cpus: usize,
mem_gib: Option<usize>,
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<usize>,
jobs_override: Option<usize>,
test_threads_override: Option<usize>,
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).max(1);
let test_threads = test_threads_override.unwrap_or(jobs.min(4)).max(1);
Self {
cpus,
mem_gib,
jobs,
test_threads,
skip_browser,
}
}
}
#[derive(Clone, Debug)]
struct Step {
name: &'static str,
tool: &'static str,
args: Vec<&'static str>,
test_threads: Option<usize>,
}
impl Step {
fn new<const N: usize>(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<usize> {
env::var(key).ok()?.parse().ok()
}
fn available_mem_gib() -> Option<usize> {
const GIB: u64 = 1024 * 1024 * 1024;
let bytes = available_mem_bytes()?;
Some(((bytes / GIB) as usize).max(1))
}
fn available_mem_bytes() -> Option<u64> {
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<u64> {
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::<u64>().ok()
})?;
kb.checked_mul(1024)
}
fn cgroup_mem_available_bytes() -> Option<u64> {
cgroup_mem_available_v2().or_else(cgroup_mem_available_v1)
}
fn cgroup_mem_available_v2() -> Option<u64> {
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<u64> {
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<u64> {
let raw = std::fs::read_to_string(path).ok()?;
let trimmed = raw.trim();
if trimmed == "max" {
return None;
}
let value = trimmed.parse::<u64>().ok()?;
if value >= (1 << 60) {
None
} else {
Some(value)
}
}
fn read_u64_file(path: &str) -> Option<u64> {
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<Path>) -> 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_allows_explicit_overrides() {
let budget = Budget::from_resources(2, Some(2), Some(8), Some(7), true);
assert_eq!(budget.jobs, 8);
assert_eq!(budget.test_threads, 7);
}
}