plugins/native/znippy-plugin-git/tests/fd_per_store.rs
//! **Descriptors per open store — counted off `/proc/self/fd`, not read off the code.**
//!
//! # Why this test exists
//!
//! An open store used to cost **five** kernel file descriptors, and gunnar's
//! server budget divides by exactly that number:
//!
//! ```text
//! open_repos = min((soft − 256) / FDS_PER_OPEN_REPO, MemAvailable × 20 % / 8 MiB)
//! ```
//!
//! (`gunnar_server::limits::open_repo_budget`). So the count is not a detail of
//! this crate — it is the divisor of the concurrent-repository ceiling of every
//! appliance, measured on the rig by lane O when the 202nd repository since boot
//! failed with `open journal …: No file descriptors available (os error 24)`.
//!
//! MEASURED here, before the change (lane AH, 2026-08-30):
//!
//! | file | handles | who held them |
//! |---|---:|---|
//! | `objects.pack` | **3** | the writer's `open_blobs` (read/write + `flock`), `Absorber::reader`, `IndexerPool::archive` |
//! | `objects.pack.journal` | 1 | the durable arm's append handle |
//! | `objects.tail` | 1 | redb |
//!
//! Two of those three were **read-only handles on the same immutable file in
//! the same process** — the blob file is append-only and `gc` truncates nothing
//! (§14), every read through them is a `pread`/`read_exact_at` at an explicit
//! offset, so no cursor is shared and no ordering is observable. They are one
//! handle, and the writer's own handle already serves reads: `FastWriter`,
//! `SafeWriter` and `UringWriter` have each carried a `blobs()` accessor
//! documented *"for a reader that wants to `pread` an extent back"* all along.
//!
//! Nothing about durability moves: the writer still owns the append cursor, the
//! `flock`, and the blob-then-journal fsync order before the ack. Sharing the
//! *open file description* with two readers changes when the description is
//! closed, not what is written or in which order.
//!
//! # Why `/proc/self/fd` and not a review of the opens
//!
//! A descriptor is a kernel object. Reading the code counts the `File::open`
//! calls you can find; reading `/proc/self/fd` counts the ones that exist —
//! including the ones a library takes (redb) and excluding the ones that are
//! opened and dropped (`ArchiveMap` maps and closes: an mmap keeps the mapping
//! alive without the descriptor, which is why `objects.pack` never appears here
//! on the mapping's account).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use znippy_plugin_git::git_ops::GitStore;
/// Every open descriptor of this process that points inside `root`, counted by
/// the file it names.
fn fds_under(root: &Path) -> BTreeMap<String, usize> {
let root = root.display().to_string();
let mut by_name: BTreeMap<String, usize> = BTreeMap::new();
for entry in std::fs::read_dir("/proc/self/fd").expect("/proc/self/fd").flatten() {
let Ok(target) = std::fs::read_link(entry.path()) else {
continue;
};
let target = target.display().to_string();
if !target.starts_with(&root) {
continue;
}
let name = target.rsplit('/').next().unwrap_or(&target).to_string();
*by_name.entry(name).or_default() += 1;
}
by_name
}
fn total(m: &BTreeMap<String, usize>) -> usize {
m.values().sum()
}
/// ★ **Three descriptors per open store, and exactly one of them on the blob
/// file.**
///
/// Probed RED against the pre-change tree: `objects.pack` counted 3 and the
/// total 5 per store, on every one of the four.
#[test]
fn an_open_store_costs_three_descriptors_and_one_handle_on_the_blob_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut open = Vec::new();
for i in 0..4 {
let root: PathBuf = tmp.path().join(format!("repo-{i}"));
open.push(GitStore::open(&root, "alice").expect("opening a store"));
let per_store = fds_under(&root);
eprintln!("store {i}: {} descriptors {per_store:?}", total(&per_store));
assert_eq!(
per_store.get("objects.pack").copied().unwrap_or(0),
1,
"store {i}: the blob file must be held by ONE open file description — the \
writer's. A second read-only handle on an append-only file buys nothing and \
costs a kernel object and a page-cache alias per repository. Got {per_store:?}"
);
assert_eq!(
per_store.get("objects.pack.journal").copied().unwrap_or(0),
1,
"store {i}: the durable arm keeps its journal open; that one is the ack path \
and is untouchable. Got {per_store:?}"
);
assert_eq!(
per_store.get("objects.tail").copied().unwrap_or(0),
1,
"store {i}: redb's own handle on the index tail. Got {per_store:?}"
);
assert_eq!(
total(&per_store),
3,
"store {i}: an open store costs three descriptors, no more and no fewer — \
gunnar's `open_repo_budget` divides its RLIMIT by this number. Got {per_store:?}"
);
}
// …and they do not accumulate: four stores are four times three, so the
// budget's multiplication is the real shape and not a first-store artefact.
let all = fds_under(tmp.path());
assert_eq!(
total(&all),
12,
"four open stores hold four × three descriptors; got {all:?}"
);
drop(open);
let after = fds_under(tmp.path());
assert_eq!(
total(&after),
0,
"a dropped store releases every descriptor it held; got {after:?}"
);
}