diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ef0829..6241a96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,3 +127,48 @@ jobs: SOULSEEK_E2E_REQUIRED: "1" SOULSEEK_E2E_MODE: daemon run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture --test-threads=1 + + # A mixed load of mock peers around one real client. Every ceiling this + # project has hit only showed under simultaneous searches, downloads, + # uploads and browses, and three of the four lost work silently, so the + # gate is the functional dimensions at 100%, never the throughput number. + stress: + name: Stress (soulfind) + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install SQLite runtime/dev libraries + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev + + - name: Install D toolchain (LDC) + uses: dlang-community/setup-dlang@v2 + with: + compiler: ldc-latest + + - name: Build soulfind server + run: | + git clone --depth 1 https://github.com/soulfind-dev/soulfind.git soulfind + dub build :server --root soulfind + test -x soulfind/bin/soulfind + echo "SOULFIND_BIN=$PWD/soulfind/bin/soulfind" >> "$GITHUB_ENV" + + # An eighth of the local profile: soulfind on a four-core runner brings + # about 150 mock logins online, and the point is lost work, not load. + - name: Run the stress benchmark with the functional gate + env: + STRESS_REQUIRE_FUNCTIONAL: "1" + STRESS_PEERS: "128" + STRESS_SEARCHES: "32" + STRESS_DOWNLOADS: "128" + STRESS_LEECHERS: "64" + STRESS_BROWSES: "32" + run: | + ulimit -n 20000 + cargo run --release -p soulseek-rs-lib --example stress diff --git a/benchmarks/README.md b/benchmarks/README.md index f8d7b71..248181d 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -32,6 +32,13 @@ with headroom, so the score still moves when transfer handling regresses. `--save` only overwrites `stress-baseline.json` when the run beats it, so the baseline ratchets upward. Without `--save` a run just prints the delta. +## The gate + +`STRESS_REQUIRE_FUNCTIONAL=1` makes the run exit 1 when any of the four +functional dimensions is below 100%. CI runs it that way on every pull request +with an eighth of the default load; throughput never fails the gate because it +measures the machine, not the client. + ## Tuning the load Every knob is an env var; the defaults are in `Config::from_env`. diff --git a/soulseek-rs-lib/Cargo.toml b/soulseek-rs-lib/Cargo.toml index 34c8298..ebc7d14 100644 --- a/soulseek-rs-lib/Cargo.toml +++ b/soulseek-rs-lib/Cargo.toml @@ -21,3 +21,7 @@ workspace = true [target.'cfg(unix)'.dependencies] libc = "0.2.189" + +[[example]] +name = "stress" +test = true diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index a14f750..5c0d30e 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -17,6 +17,7 @@ //! STRESS_FILE_KB size of every transferred file //! STRESS_TIMEOUT seconds before the run is called off //! STRESS_SAVE=1 store this run as the new baseline +//! STRESS_REQUIRE_FUNCTIONAL=1 exit 1 if any transfer, search or browse was lost use std::collections::HashMap; use std::io::{Read, Write}; @@ -766,6 +767,18 @@ struct Score { parts: Vec<(&'static str, f64, f64)>, // name, achieved 0-1, weight } +/// The functional dimensions that fell short of 100%. Throughput is excluded: +/// it measures how fast the machine is, the others measure whether the client +/// silently lost work, which is the only failure a gate should stop on. +fn gate_failures(score: &Score) -> Vec { + score + .parts + .iter() + .filter(|(name, value, _)| *name != "throughput" && *value < 1.0) + .map(|(name, value, _)| format!("{name} {:.1}%", value * 100.0)) + .collect() +} + fn compute_score(m: &Metrics, elapsed: Duration) -> Score { let bytes = Metrics::get(&m.bytes_downloaded) + Metrics::get(&m.bytes_uploaded); @@ -1248,4 +1261,38 @@ fn main() { let _ = std::fs::remove_dir_all(&share_dir); let _ = std::fs::remove_dir_all(&download_dir); + + let failures = gate_failures(&score); + if std::env::var("STRESS_REQUIRE_FUNCTIONAL").is_ok_and(|v| v == "1") + && !failures.is_empty() + { + eprintln!("stress: work was lost: {}", failures.join(", ")); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn score(parts: &[(&'static str, f64)]) -> Score { + Score { + total: 0.0, + parts: parts.iter().map(|&(n, v)| (n, v, 0.2)).collect(), + } + } + + #[test] + fn lost_work_fails_the_gate_but_slow_transfers_do_not() { + let clean = + score(&[("downloads", 1.0), ("uploads", 1.0), ("throughput", 0.1)]); + assert!(gate_failures(&clean).is_empty()); + + let lossy = score(&[ + ("downloads", 0.98), + ("uploads", 1.0), + ("throughput", 1.0), + ]); + assert_eq!(gate_failures(&lossy), ["downloads 98.0%"]); + } }