build(test): cap verification concurrency
req: test/004
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user