build(test): cap verification concurrency
req: test/004
This commit is contained in:
@@ -48,7 +48,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
|||||||
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run.
|
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run.
|
||||||
- Prefer links or pointers to canonical sources over copied lists.
|
- Prefer links or pointers to canonical sources over copied lists.
|
||||||
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
|
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
|
||||||
- Stable commands: `cargo check --workspace`, `cargo test --workspace`, `redgate health --strict`.
|
- Stable commands: `cargo run -p slhx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004
|
||||||
- slhx core stays small: effects, typed ids, registries, and wire schema only.
|
- slhx core stays small: effects, typed ids, registries, and wire schema only.
|
||||||
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
|
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
|
||||||
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
|
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
|
||||||
|
|||||||
Generated
+4
@@ -1452,6 +1452,10 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-xtask"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0", "examples/kanban", "examples/techdemo"]
|
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "slhx-xtask", "examples/v0", "examples/kanban", "examples/techdemo"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -596,6 +596,9 @@ what a valid business email is.
|
|||||||
### req: test/003
|
### req: test/003
|
||||||
003 Generated registries are validated by compile-time tests: missing handler implementations produce test failures with actionable messages.
|
003 Generated registries are validated by compile-time tests: missing handler implementations produce test failures with actionable messages.
|
||||||
|
|
||||||
|
### req: test/004
|
||||||
|
004 Repository-wide verification uses a resource-aware runner that caps Cargo build jobs and Rust test threads from available CPU and memory. Browser E2E runs as an isolated step and can be skipped explicitly when browser infrastructure is unavailable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## check
|
## check
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-xtask"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "slhx-ci"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -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