Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/control_plane_starvation.rs

//! The gRPC control plane must not be able to stop git.
//!
//! `ControlPlane` is a *synchronous* trait by design, and `gunnar-grpc`'s
//! service methods call it straight from their `async fn`s. Some of that work is
//! genuinely long: `Admin.StorageInfo` runs an unbounded recursive
//! `std::fs::read_dir` that `stat`s **every loose object file in the
//! repository**, and it is reachable from `Admin.StorageInfo`, which any
//! credentialled caller (an operator, a UI, a dashboard poller) may ask for as
//! often as it likes.
//!
//! While both listeners shared one runtime, those walks occupied the worker
//! threads that the SSH listener's accept, handshake and exec dispatch also run
//! on. The two-listener separation is a separation of *ports*; it was
//! never a separation of *schedulers*.
//!
//! # What is asserted, and why it is asserted this way
//!
//! The probe is the cheapest thing that proves the SSH listener is *running*:
//! connect a socket and read the `SSH-2.0-gunnar_…` identification string the
//! server writes first. If the listener's accept task and session task cannot be
//! scheduled, that line does not arrive. The TCP connection still completes,
//! because the kernel's backlog does that without gunnar, which is precisely why
//! "the port is open" is not evidence of anything.
//!
//! **The probe runs on its own OS thread with `std::net`, outside every tokio
//! runtime.** Measuring from a task on the runtime under test would conflate "the
//! server could not answer" with "my measuring task could not be scheduled", and
//! the number would be right for the wrong reason.
//!
//! The runtime here is deliberately small (two workers). That is not a rigged
//! fixture: it is the same ratio a 32-worker appliance reaches under 32
//! concurrent `StorageInfo` calls, at a size a test can drive in a second. The
//! test also asserts that the fixture is **big enough to block**: a
//! `StorageInfo` that returns in a millisecond could never starve anything, and
//! a green from that fixture would be worthless (measure, never assert).
//!
//! **The hammering clients get a runtime of their own**, separate from the
//! server's. Eight tonic clients signing an sshsig per call is real work, and
//! run on the server's runtime it would starve the SSH listener by itself. The
//! test would then be red before the fix and red after it, for a reason that has
//! nothing to do with the server. What is being measured is the *server's*
//! scheduling, so the load generator is kept off the scheduler under test.
//!
//! # Seen red: MEASURED 2026-08-03
//!
//! Same fixture, same probe, the only difference being which runtime the gRPC
//! listener runs on. 16 callers, 120 000 loose objects (one `Admin.StorageInfo`
//! = ~180 ms), server runtime of 2 workers:
//!
//! | | SSH identification latency | median |
//! |---|---|---|
//! | shared runtime (`HEAD`, pre-fix) | **1.59 s – 3.02 s** | ~2.4 s |
//! | control plane on its own runtime | **221 µs – 3.16 ms** | ~296 µs |
//!
//! Roughly **three orders of magnitude** on the worst case. Note that the
//! pre-fix server never *failed* to answer; it answered seconds late, which is
//! exactly the failure mode an operator reports as "git is slow" and cannot find
//! in a log.

#![cfg(feature = "server")]

use std::io::{BufRead, BufReader, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

use gunnar_cli::server::{ServeConfig, Server, ServerStores};
use gunnar_core::{NewRepo, RepoRef};

/// A fresh single-use gRPC credential, signed with the bootstrap admin key.
///
/// The gRPC authentication hole was closed while this test was being written, so
/// every control-plane call now carries an sshsig. Each one needs a new nonce:
/// the server's replay guard refuses a repeat, which is the point of it.
fn credential(
    key: &gunnar_core::ssh_key::PrivateKey,
    seq: u64,
) -> tonic::metadata::MetadataValue<tonic::metadata::Ascii> {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("after 1970")
        .as_millis() as i64;
    let nonce = format!("gunnar-starvation-{seq:012}-{now:016x}");
    gunnar_grpc::sign_credential_for(key, now, &nonce, "Admin.StorageInfo")
        .expect("sign a gRPC credential")
        .to_header_value()
        .parse()
        .expect("an ascii credential")
}

/// `message`, with a fresh credential for `Admin.StorageInfo` attached.
fn authed<T>(key: &gunnar_core::ssh_key::PrivateKey, seq: u64, message: T) -> tonic::Request<T> {
    let mut request = tonic::Request::new(message);
    request
        .metadata_mut()
        .insert(gunnar_grpc::CREDENTIAL_METADATA_KEY, credential(key, seq));
    request
}

/// How many loose objects the fixture repository gets.
///
/// Enough that one `Admin.StorageInfo` is a measurable amount of `stat`ting;
/// small enough that building the fixture is a second, not a minute. The test
/// asserts the resulting call duration rather than trusting this number.
const LOOSE_OBJECTS: usize = 120_000;

/// How many concurrent callers hammer the control plane.
const HAMMERS: usize = 16;

/// Worker threads the server's runtime gets. Two, so eight blocking callers
/// oversubscribe it by 4×, the same shape as 128 callers against a 32-core
/// appliance.
const WORKERS: usize = 2;

/// How much slower the SSH listener may be **while the control plane is
/// hammered** than it is on the same server, on the same box, seconds earlier.
///
/// # This used to be an absolute deadline, and that is why it was flaky
///
/// It was `worst <= 500 ms`. A wall clock measures the machine: at load average
/// 58 with eight other builds running, a perfectly healthy listener misses
/// 500 ms and the test reports starvation that is not there. The ruling is
/// explicit that widening it is not the fix — a starvation test with a generous
/// timeout cannot detect starvation.
///
/// So the assertion is a **ratio against a baseline taken in the same run**.
/// Load raises both sides and cancels; starvation raises only one. The measured
/// numbers say how much room that leaves: with the control plane on a shared
/// runtime the probe took **1.59–3.02 s**, and with the separate runtime
/// **86 µs–2.8 ms**, a ratio of roughly **16 000×**. 200 is two orders of
/// magnitude below the signal and two orders above ordinary contention.
const STARVATION_RATIO: u32 = 200;

/// The latency the starvation bug actually produced, and therefore the level the
/// ratio bound has to stay below to be able to see it.
///
/// Not an assertion — a **usability check on the baseline**. If the box is so
/// loaded that `baseline × STARVATION_RATIO` reaches this, the comparison could
/// no longer catch the bug, and the test says the box could not be measured
/// rather than passing on a bound that has gone blind. That is the identity-value
/// trap in this test's own shape: a big enough baseline makes any ratio green.
const STARVED_LATENCY: Duration = Duration::from_millis(1500);

/// How many times to re-take the baseline before giving up on the box.
///
/// A transient spike from a neighbouring build should cost a retry, not a
/// failure. A sustained one is reported as what it is.
const BASELINE_ATTEMPTS: usize = 5;

/// Probes per baseline sample.
const BASELINE_PROBES: usize = 20;

/// Measurements to take under load before deciding anything.
const MIN_PROBES: usize = 10;

/// Upper bound on measurements, so a fast box does not probe forever.
const MAX_PROBES: usize = 200;

/// Calls the control plane must actually have served before the SSH numbers
/// mean anything. Waited for, **not** asserted against a clock — see the
/// probing loop.
const MIN_HAMMERED_CALLS: u64 = 2 * HAMMERS as u64;

/// How long to wait for both quotas before giving up on the box.
///
/// A give-up bound, not a measurement: no assertion compares anything against
/// it. Generous on purpose — under starvation each probe takes seconds, and the
/// test still has to reach its quota to be able to report the starvation.
const PROBE_BUDGET: Duration = Duration::from_secs(90);

/// How long the probe waits before calling a socket dead.
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);

fn write_loose_objects(git_dir: &std::path::Path, count: usize) {
    let objects = git_dir.join("objects");
    // Real loose-object layout: 256 two-hex shards, because the walk decides
    // what counts as a loose object from the shard name.
    for shard in 0..256u16 {
        std::fs::create_dir_all(objects.join(format!("{shard:02x}")))
            .expect("create a loose-object shard");
    }
    for i in 0..count {
        let shard = format!("{:02x}", i % 256);
        let name = format!("{:038x}", i);
        std::fs::write(objects.join(shard).join(name), b"not a real object")
            .expect("write a loose object");
    }
}

/// Connect, read the server's identification line, and report how long it took.
///
/// `Err` means the line never arrived within [`PROBE_TIMEOUT`].
fn ssh_ident_latency(addr: std::net::SocketAddr) -> Result<Duration, String> {
    let started = Instant::now();
    let stream = std::net::TcpStream::connect_timeout(&addr, PROBE_TIMEOUT)
        .map_err(|e| format!("connect: {e}"))?;
    stream
        .set_read_timeout(Some(PROBE_TIMEOUT))
        .map_err(|e| format!("set_read_timeout: {e}"))?;
    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    reader
        .read_line(&mut line)
        .map_err(|e| format!("reading the identification string: {e}"))?;
    let elapsed = started.elapsed();
    if !line.starts_with("SSH-2.0-gunnar") {
        return Err(format!(
            "the server answered something that is not a gunnar identification string: {line:?}"
        ));
    }
    // Politely close: send our own ident so russh's session ends on a protocol
    // event rather than on a reset it will log as an error.
    let _ = reader.get_mut().write_all(b"SSH-2.0-gunnar_probe\r\n");
    Ok(elapsed)
}

#[test]
fn hammering_the_control_plane_does_not_stop_the_ssh_listener() {
    let dir = tempfile::tempdir().expect("a temp dir");
    let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("open the stores"));
    stores
        .catalog()
        .create_namespace("team")
        .expect("namespace");
    let path = RepoRef::new("team", "project").expect("a legal path");
    let created = stores
        .catalog()
        .create(NewRepo::new(path))
        .expect("create the repository");
    let git_dir = stores
        .repo_store(&created)
        .expect("materialise the repository")
        .git_dir()
        .to_path_buf();
    write_loose_objects(&git_dir, LOOSE_OBJECTS);

    let private = gunnar_core::mint_ed25519().expect("mint an admin key");
    let admin_key =
        gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("the minted key parses");
    let admin = admin_key
        .public_key()
        .to_openssh()
        .expect("render the admin public key");

    let runtime = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(WORKERS)
        .enable_all()
        .build()
        .expect("a small runtime");

    // The load generator's own runtime: see the module docs. Its work must not
    // land on the scheduler whose responsiveness is being measured.
    let clients = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(4)
        .enable_all()
        .build()
        .expect("a runtime for the load generator");

    let server = runtime
        .block_on(Server::start(
            Arc::clone(&stores),
            ServeConfig {
                admin_key: Some(admin),
                ..ServeConfig::ephemeral()
            },
        ))
        .expect("both listeners bind");
    let ssh_addr = server.ssh_addr();
    let grpc_addr = server.grpc_addr().expect("the gRPC listener is bound");

    // ── the fixture has to be able to block something ──────────────────────
    //
    // Without this, a repository with three loose objects would produce a
    // perfectly green test that proved nothing at all.
    let one_call = clients.block_on(async {
        let channel = tonic::transport::Channel::from_shared(format!("http://{grpc_addr}"))
            .expect("endpoint")
            .connect()
            .await
            .expect("connect to the control plane");
        let mut admin = gunnar_proto::admin_client::AdminClient::new(channel);
        let started = Instant::now();
        let info = admin
            .storage_info(authed(
                &admin_key,
                0,
                gunnar_proto::StorageInfoRequest { repo: None },
            ))
            .await
            .expect("Admin.StorageInfo")
            .into_inner();
        let elapsed = started.elapsed();
        assert_eq!(
            info.repos.first().map(|r| r.loose_object_count),
            Some(LOOSE_OBJECTS as u64),
            "the walk did not see the fixture, so its cost is not the cost being measured"
        );
        elapsed
    });
    eprintln!("[fixture] one Admin.StorageInfo over {LOOSE_OBJECTS} loose objects: {one_call:?}");
    assert!(
        one_call >= Duration::from_millis(10),
        "one Admin.StorageInfo over {LOOSE_OBJECTS} loose objects took only {one_call:?}; this \
         fixture is too small to occupy a worker thread, so a green below would be vacuous"
    );

    // ── the baseline: the same probe, the same server, no load ─────────────
    //
    // Taken here rather than derived from a constant, because the number this
    // test needs is "what does this box do when nothing is starving it", and
    // that is a property of the box on the day.
    let mut baseline = Duration::ZERO;
    let mut baseline_attempts = Vec::new();
    for attempt in 1..=BASELINE_ATTEMPTS {
        let mut idle: Vec<Duration> = (0..BASELINE_PROBES)
            .filter_map(|_| ssh_ident_latency(ssh_addr).ok())
            .collect();
        assert!(
            idle.len() * 2 >= BASELINE_PROBES,
            "the SSH listener failed {} of {BASELINE_PROBES} probes with NOTHING loading it; \
             the server is broken, not starved",
            BASELINE_PROBES - idle.len()
        );
        idle.sort();
        baseline = idle[idle.len() / 2];
        baseline_attempts.push(baseline);
        if baseline * STARVATION_RATIO < STARVED_LATENCY {
            break;
        }
        eprintln!(
            "[baseline] attempt {attempt}: median {baseline:?} — too slow for the ratio to see \
             a {STARVED_LATENCY:?} stall; waiting and re-measuring"
        );
        std::thread::sleep(Duration::from_millis(500));
    }
    eprintln!("[baseline] idle ssh identification, median of {BASELINE_PROBES}: {baseline:?}");
    assert!(
        baseline * STARVATION_RATIO < STARVED_LATENCY,
        "this box could not produce a usable baseline in {BASELINE_ATTEMPTS} attempts \
         ({baseline_attempts:?}). An idle median of {baseline:?} makes the {STARVATION_RATIO}× \
         bound {:?}, which is past the {STARVED_LATENCY:?} that the starvation actually \
         produced — so the comparison could not have caught the bug and a green would be \
         meaningless. THIS IS A MEASUREMENT OF THE MACHINE, NOT A REGRESSION: the box was too \
         loaded to decide. Re-run it somewhere quieter; do not widen the ratio.",
        baseline * STARVATION_RATIO
    );

    // ── hammer the control plane ───────────────────────────────────────────
    let stop = Arc::new(AtomicBool::new(false));
    let calls = Arc::new(AtomicU64::new(0));
    let mut hammers = Vec::new();
    for hammer in 0..HAMMERS {
        let stop = Arc::clone(&stop);
        let calls = Arc::clone(&calls);
        let key = admin_key.clone();
        hammers.push(clients.spawn(async move {
            let channel = tonic::transport::Channel::from_shared(format!("http://{grpc_addr}"))
                .expect("endpoint")
                .connect()
                .await
                .expect("connect to the control plane");
            let mut admin = gunnar_proto::admin_client::AdminClient::new(channel);
            let mut seq = (hammer as u64 + 1) * 1_000_000;
            while !stop.load(Ordering::Relaxed) {
                seq += 1;
                if admin
                    .storage_info(authed(
                        &key,
                        seq,
                        gunnar_proto::StorageInfoRequest { repo: None },
                    ))
                    .await
                    .is_ok()
                {
                    calls.fetch_add(1, Ordering::Relaxed);
                }
            }
        }));
    }

    // ── measure the SSH listener, from outside every runtime ───────────────
    //
    // Give the hammers a moment to be in flight; measuring the instant they
    // spawn would measure an idle server.
    std::thread::sleep(Duration::from_millis(300));
    let mut latencies = Vec::new();
    let mut failures = Vec::new();
    let probing_since = Instant::now();
    // Probe until BOTH quotas are met: enough measurements, and enough calls
    // actually served by the control plane.
    //
    // # This loop used to run for a fixed second, and that was the flake
    //
    //
    // The old condition was "ten probes, and at least a second of load", and
    // the vacuity check below then *asserted* that the control plane had served
    // `2 × HAMMERS` calls in that second. On a saturated box it has not — 18
    // calls in 4.3 s, measured here at load average 105 — and the test failed
    // saying the control plane "was never actually loaded". That is a true
    // statement about the machine reported as a regression, which is exactly
    // what the relative bound is about, and it is a *different* assertion from the wall
    // clock everyone assumed was the cause.
    //
    // Waiting for the quota instead of asserting it makes a slow box take
    // longer rather than fail. The budget below is a give-up bound, not a
    // measurement: nothing is compared against it, and every number this test
    // asserts on is relative.
    loop {
        let probed = latencies.len() + failures.len();
        if (probed >= MIN_PROBES && calls.load(Ordering::Relaxed) >= MIN_HAMMERED_CALLS)
            || probing_since.elapsed() > PROBE_BUDGET
        {
            break;
        }
        // `MAX_PROBES` caps the SAMPLE, not the wait. Breaking the loop on it
        // was a bug in the first version of this fix: on a fast box the probes
        // fill up in a second while the loaded control plane has served 31 of
        // the 32 calls the measurement needs, and the test then failed one call
        // short — a new flake in the code written to remove one.
        if probed < MAX_PROBES {
            match ssh_ident_latency(ssh_addr) {
                Ok(elapsed) => latencies.push(elapsed),
                Err(err) => failures.push(err),
            }
        }
        std::thread::sleep(Duration::from_millis(5));
    }

    stop.store(true, Ordering::Relaxed);
    for hammer in hammers {
        let _ = clients.block_on(hammer);
    }

    let hammered = calls.load(Ordering::Relaxed);
    assert!(
        hammered >= MIN_HAMMERED_CALLS,
        "the control plane answered only {hammered} calls in {:?} of probing — the loop above \
         waited for {MIN_HAMMERED_CALLS} and gave up at its {PROBE_BUDGET:?} budget. The SSH \
         measurement below would be a measurement of an idle server, so it is not made. This is \
         a statement about the BOX, not about the code: a machine that cannot serve \
         {MIN_HAMMERED_CALLS} StorageInfo calls in {PROBE_BUDGET:?} is saturated. Do not lower \
         the quota — the point of it is that the server was really under load.",
        probing_since.elapsed()
    );

    let worst = latencies.iter().copied().max().unwrap_or(PROBE_TIMEOUT);
    let (best, median) = {
        let mut sorted = latencies.clone();
        sorted.sort();
        (
            sorted.first().copied().unwrap_or(PROBE_TIMEOUT),
            sorted
                .get(sorted.len() / 2)
                .copied()
                .unwrap_or(PROBE_TIMEOUT),
        )
    };
    eprintln!(
        "[probe] {} ssh handshakes during {hammered} concurrent Admin.StorageInfo calls: \
         best {best:?}, median {median:?}, worst {worst:?} ({} failed)",
        latencies.len(),
        failures.len()
    );

    runtime.block_on(server.shutdown()).expect("shut down");

    assert!(
        failures.is_empty(),
        "the SSH listener failed to answer at all while the control plane was being hammered \
         ({hammered} calls in flight): {failures:?}"
    );
    let bound = baseline * STARVATION_RATIO;
    assert!(
        median <= bound,
        "SSH identification had a median of {median:?} (worst {worst:?}) while {HAMMERS} callers \
         hammered Admin.StorageInfo ({hammered} calls served) on a {WORKERS}-worker runtime — \
         against {baseline:?} on the SAME server seconds earlier with nothing loading it. That \
         is {:.0}× slower under load, past the {STARVATION_RATIO}× bound ({bound:?}). The \
         control plane is starving the git listener.\n\
         The comparison is relative on purpose: both numbers come from this box in this \
         run, so a loaded machine moves them together and only starvation moves one. \
         Latencies: {latencies:?}",
        median.as_secs_f64() / baseline.as_secs_f64().max(f64::MIN_POSITIVE)
    );
}