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);
}