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

nordisk/gunnar

crates/gunnar-server/tests/fd_budget.rs

//! **The descriptor budget: 1500 repositories through the store API in one
//! process under a 1024-descriptor `RLIMIT_NOFILE`.**
//!
//! Measured on the rig 2026-08-29: the 202nd repository touched since
//! boot failed with `open journal …: No file descriptors available (os error
//! 24)`. Each open store holds a handful of descriptors and the handle cache
//! kept 256 of them, so the cache alone could hold ~1300 descriptors against
//! a soft limit of 1024. This test is that appliance, in one process.
//!
//! # 🔴 What this asserted until 2026-09-08, and why it was wrong
//!
//! It ended on
//!
//! ```text
//! assert_eq!(stores.open_handles(), gunnar_server::limits::open_repo_budget(1024))
//! ```
//!
//! and was red for days on this box:
//!
//! ```text
//! fds: base 5 after 5 repos [8, 11, 14, 17, 20] peak 152 after 1500 152
//!      (soft limit before test Some(524288))
//! assertion `left == right` failed
//!   left: 49
//!  right: 256
//! ```
//!
//! Nothing leaked — 152 = 5 + 49 × 3, exactly the cache's own size. What the
//! assertion got wrong is **which budget the cache is built from**.
//! [`limits::open_repo_budget`] is the *descriptor* term alone;
//! [`ServerStores::open`] has taken `crate::budget::budgets().open_repos` since
//! the memory term, and that is `min(descriptors, memory)` — the smallest of its
//! terms, because a budget is a promise about the resource that runs out first.
//! On a 35 GB laptop the memory term is 49 handles at
//! [`BYTES_PER_OPEN_REPO`](gunnar_server::budget::BYTES_PER_OPEN_REPO) of 137 MB
//! each, and 49 is not a property of gunnar at all: it moves with
//! `MemAvailable`, which moves between two runs of this very test. A number that
//! is 49 on one laptop, 48 a minute later and 256 only above 184 GB of
//! `MemAvailable` is not something a test may write down.
//!
//! So the arithmetic that IS gunnar's — `open_repo_budget(1024) == 256`,
//! `FDS_PER_OPEN_REPO == 3`, and the earlier 153 for the record — is still asserted
//! literally, because it is a function of constants in this repository and of
//! nothing on the box. Everything about the *machine* is now read from the
//! machine: the limit that was actually installed, the capacity the cache
//! actually took, and the promise that ties them together —
//!
//! * the cache never sizes itself above what the descriptor limit allows,
//! * the peak descriptor count stays inside `base + capacity × per-repo`,
//! * the reservation is still free at the peak,
//! * and 1500 repositories go through a cache of that size holding exactly it.

use std::sync::Arc;

use gunnar_core::{NewRepo, RepoRef};
use gunnar_server::stores::ServerStores;

fn open_fds() -> usize {
    std::fs::read_dir("/proc/self/fd")
        .map(|d| d.count())
        .unwrap_or(0)
}

#[test]
fn fifteen_hundred_repositories_under_a_1024_descriptor_limit() {
    // The rig's PID 1 had the kernel default, 1024. Lower BOTH limits to it:
    // `limits::nofile` raises the soft limit to the hard one, which is the
    // right thing on a developer box and would hide the appliance's shape
    // here. (Irreversible for this process; this binary holds one test.)
    let mut lim = rustix::process::getrlimit(rustix::process::Resource::Nofile);
    let before = lim.current;
    if lim.current.is_none_or(|c| c > 1024) {
        lim.current = Some(1024);
        lim.maximum = Some(1024);
        rustix::process::setrlimit(rustix::process::Resource::Nofile, lim)
            .expect("lower the limit");
    }
    // **The limit that was actually installed**, not the one that was asked
    // for. A box whose hard limit is already under 1024 cannot be lowered TO
    // 1024, and every headroom number below is relative to this — a test that
    // is green on one laptop and red on another is not a test.
    let soft = rustix::process::getrlimit(rustix::process::Resource::Nofile)
        .current
        .expect("a soft descriptor limit");
    let dir = tempfile::tempdir().expect("tempdir");
    let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
    stores.catalog().create_namespace("rig").expect("namespace");
    let base = open_fds();
    let mut peak = base;
    let mut per_repo = Vec::new();
    for i in 1..=1500 {
        let path = RepoRef::new("rig", &format!("z-{i}")).expect("legal");
        let repo = stores.catalog().create(NewRepo::new(path)).expect("create");
        let store = stores
            .repo_store(&repo)
            .unwrap_or_else(|e| panic!("repo {i}: {e:#} (fds open: {})", open_fds()));
        drop(store);
        let now = open_fds();
        peak = peak.max(now);
        if i <= 5 {
            per_repo.push(now);
        }
        if i == 3 {
            for e in std::fs::read_dir("/proc/self/fd").unwrap().flatten() {
                if let Ok(t) = std::fs::read_link(e.path()) {
                    let t = t.display().to_string();
                    if t.contains("znippy") {
                        eprintln!("  fd {} -> {}", e.file_name().to_string_lossy(), t);
                    }
                }
            }
        }
    }
    let after = open_fds();
    eprintln!(
        "fds: base {base} after 5 repos {per_repo:?} peak {peak} after 1500 {after} \
         (soft limit {soft}, was {before:?}); cache capacity {} handles, held {}",
        stores.open_repo_capacity(),
        stores.open_handles()
    );
    // The DESCRIPTOR term under 1024: **256** handles × **3** descriptors since
    // 2026-08-30, 153 × 5 before it — the same 768 descriptors of
    // cache, 67 % more repositories inside it, because `objects.pack` is one
    // open file description per store now instead of three
    // (`znippy-plugin-git/tests/fd_per_store.rs` counts them). An idle
    // repository beyond the bound still holds nothing, or 1500 of them could
    // not have fitted.
    let fd_budget = gunnar_server::limits::open_repo_budget(1024) as u64;
    let per_repo = gunnar_server::limits::FDS_PER_OPEN_REPO;
    assert_eq!((fd_budget, per_repo), (256, 3));
    assert_eq!(
        1024_u64.saturating_sub(gunnar_server::limits::RESERVED_FDS) / 5,
        153,
        "the earlier answer, for the record: the same limit at five descriptors a store"
    );

    // What the cache was ACTUALLY built to hold. `ServerStores::open` takes
    // `budget::budgets().open_repos`, the smallest of the descriptor term and
    // the memory term, so this is a number about this machine and it is read
    // off this machine.
    let budgets = gunnar_server::budget::budgets();
    let capacity = stores.open_repo_capacity() as u64;
    assert_eq!(
        capacity, budgets.open_repos as u64,
        "the cache must be sized by the declared budget and nothing else"
    );
    // The descriptor promise, which is what this file is about: whatever the
    // memory term says, the cache is never allowed to want more descriptors
    // than the limit leaves it.
    assert!(
        capacity <= gunnar_server::limits::open_repo_budget(soft) as u64,
        "the cache took {capacity} handles under a soft limit of {soft}, which allows {}",
        gunnar_server::limits::open_repo_budget(soft)
    );
    assert!(
        (peak as u64) <= base as u64 + capacity * per_repo,
        "peak {peak} descriptors, capacity {capacity} × {per_repo} + {base}"
    );
    // RESERVED_FDS is what the cache is NOT allowed to take. `base` is inside
    // it (the control database, the log, stdio), so the headroom this asserts
    // is the reservation minus what the process already held before the first
    // repository was opened.
    let headroom = soft - peak as u64;
    assert!(
        headroom >= gunnar_server::limits::RESERVED_FDS - base as u64 - 8,
        "headroom {headroom} descriptors above a peak of {peak} (base {base}, soft limit \
         {soft}); the reservation is {}",
        gunnar_server::limits::RESERVED_FDS
    );
    // 1500 repositories went through a cache of `capacity`, and what is left
    // resident is exactly `capacity` — the bound held from both sides: nothing
    // leaked past it, and nothing evicted below it.
    assert_eq!(stores.open_handles() as u64, capacity);
}