Skip to main content

gunnar_server/
budget.rs

1//! **The declared budgets**: how many git transfers may be in flight, how many
2//! repository handles may be open, and how much memory the two of them may
3//! hold — every one of them derived from the box and said out loud at start.
4//!
5//! # The numbers this replaces were not chosen, they were emergent
6//!
7//! Three separate ceilings decided gunnar's concurrency before 2026-08-30, and
8//! not one of them was a number anybody picked for the machine it was running
9//! on:
10//!
11//! | the ceiling | what actually set it | measured at |
12//! |---|---|---|
13//! | **~19** concurrent transfers | a point on the CoDel controller's descent: band `[10, 256]`, `limit -= limit/8` every 100 ms window that stands. `256 → 224 → … → 21 → 19 → 17` | the appliance, `already serving its limit of 19`, 268/1000 admitted at 64 clients |
14//! | **25** concurrent pushes | `8 GiB budget / 320 MiB flat per-push reservation`, on any box, for any push | the native rig: 1969 of 3000 push-creates refused `MemoryBudget` at 64 clients while RSS never passed 1.6 GB of 8 GiB |
15//! | **256** open repositories | `DEFAULT_OPEN_REPOS`, bounded by descriptors only — no memory term at all | the 2 GB appliance: collapse at ~1200 repositories, 256 handles holding the RAM the indexer watchdog then could not find |
16//!
17//! Two of the three are *arithmetic*, not decisions, and the third counted the
18//! wrong resource. This module is the decision, in one place, with the three
19//! facts it is derived from named beside it in the log:
20//!
21//! ```text
22//! transfers 64 · open repos 200 · git memory 3.9G  (cores 4, fds 1048576, MemAvailable 7.8G)
23//! ```
24//!
25//! # Why it lives here and not in `gunnar-ssh`
26//!
27//! `gunnar-ssh` may not read the operating system — `crate::governor`'s
28//! "OS-less, by law" section is explicit about it, and it is why the memory
29//! budget over there is a *reservation the server owns* rather than a
30//! measurement of the box. That law is kept. This crate is the one that already
31//! reads `/proc` (`crate::limits` for `RLIMIT_NOFILE`, `crate::dashboard::probe`
32//! for `/proc/meminfo`), so the derivation happens here and the *result* is
33//! handed to `gunnar-ssh` as configuration — which is exactly the shape
34//! `SshConfig::memory_budget_bytes` already documented as "the number an
35//! operator sets".
36//!
37//! # Overridable, and the override is honoured exactly
38//!
39//! `--max-concurrent-transfers N`, or `GUNNAR_MAX_TRANSFERS=N` in the
40//! environment (which is how the appliance's kernel command line reaches it:
41//! `korp.env.GUNNAR_MAX_TRANSFERS=N`, since Linux hands every unrecognised
42//! `key=value` boot parameter to `init` as an environment variable). An
43//! operator's number is never raised and never lowered — see
44//! [`gunnar_ssh::governor::LimitBand::for_budget`] for the one place that used
45//! to do both.
46
47use std::sync::OnceLock;
48
49use crate::limits::{self, Nofile};
50
51/// Concurrent transfers per core.
52///
53/// **A transfer is not a core's worth of work end to end.** It waits on the
54/// client's socket, on the disk, and on the indexer behind it; the packing is
55/// the only part that saturates. MEASURED on the rig (2026-08-30, 4 vCPU):
56/// a push-create phase at 8 concurrent clients ran the appliance at **48 %**
57/// CPU and a parallel-clone phase at 87 %, so at 2 transfers per core the box
58/// was already half idle during the phase the gate refuses in.
59///
60/// Sixteen is where the native rig stopped getting faster per accepted
61/// operation, measured 2026-08-30: a 4-core box
62/// holds 64 concurrent push-creates at 162 ms each against 69 ms at 8, and 128
63/// buys nothing but the extra latency. It is a *ceiling*, not a promise: the
64/// governor still sheds inside it, the memory ledger still refuses inside it,
65/// and the descriptor term below can be smaller.
66pub const TRANSFERS_PER_CORE: usize = 16;
67
68/// Descriptors one in-flight transfer costs on top of the handle cache: the
69/// client socket, its channel, and headroom for the store handle it opens if
70/// the cache misses.
71///
72/// Four, against [`limits::FDS_PER_OPEN_REPO`]'s five for a *cached* handle:
73/// a transfer's own descriptors are the socket and the pipe pair, and the
74/// repository it touches is normally already in the cache and already counted
75/// there.
76pub const FDS_PER_TRANSFER: u64 = 4;
77
78/// The transfer ceiling never goes below this, whatever the box says.
79///
80/// A server that admits fewer than eight concurrent transfers is not a forge,
81/// it is a queue with a socket. If the machine genuinely cannot carry eight,
82/// the honest answer is the WARN row this floor produces and not a gate that
83/// refuses everything.
84pub const MIN_TRANSFERS: usize = 8;
85
86/// The transfer ceiling never goes above this.
87///
88/// Half of `gunnar_server::serve::GIT_BLOCKING_THREADS` (512): every in-flight
89/// transfer occupies one blocking thread for its whole life, and the pool also
90/// carries pack indexing, ref transactions and the arrow log. A ceiling at the
91/// pool size would make the pool the real limit again — the exact defect
92/// the unbounded listener was about — and `serve.rs`'s
93/// `the_blocking_pool_is_larger_than_the_transfer_limit` asserts the ordering
94/// rather than leaving it to this paragraph.
95pub const MAX_TRANSFERS: usize = crate::serve::GIT_BLOCKING_THREADS / 2;
96
97/// The share of `MemAvailable` the **admission ledger** may give out to git
98/// operations in flight, in percent.
99///
100/// Half. The other half is not spare: gunnar's own resident baseline sits
101/// outside this budget by construction (`gunnar_ssh::governor`'s
102/// `DEFAULT_MEMORY_BUDGET_BYTES` says so), the handle cache takes
103/// [`HANDLE_CACHE_PERCENT`], and the indexer's watchdog holds its own floor at
104/// 20 % of `MemTotal` (the memory limit). Two budgets
105/// that add to 100 % of the same memory are one budget with extra steps.
106pub const TRANSFER_MEMORY_PERCENT: u64 = 50;
107
108/// The share of `MemAvailable` the **open-repository handle cache** may hold,
109/// in percent.
110///
111/// Twenty. This is the term [`limits::open_repo_budget`] did not have:
112/// it bounded the cache by descriptors and nothing else, and on the 2 GB
113/// appliance 256 cached handles held the RAM that the indexer's watchdog then
114/// spent every push trying and failing to reclaim — `scanned_rows_dropped=0` on
115/// every store, because the memory was in the *other* 255 handles. The box
116/// crawled at 12 s per create, and before the console fix it wedged
117/// outright.
118pub const HANDLE_CACHE_PERCENT: u64 = 20;
119
120/// **What one indexed object costs in a cached open repository**, in bytes.
121///
122/// MEASURED on the reference desktop, 2026-08-31,
123/// `znippy-plugin-git --example rss_per_open_store`: N identical stores opened
124/// one at a time, each read from, all held — the state the handle cache is
125/// actually in. Anonymous RSS out of `/proc/self/smaps_rollup`, with glibc's
126/// `mallinfo2` in-use figure beside it as the control, and the two agree to
127/// within a few percent on every row, so these are **live reachable bytes** and
128/// not allocator retention.
129///
130/// | corpus | objects | MB per additional open store (live, `mallinfo`) | B/object | spread over the per-open steps |
131/// |---|---:|---:|---:|---:|
132/// | synthetic blobs | 10 000 | 4.3 | 430 | 51 % |
133/// | synthetic blobs | 50 000 | 18.7 | 392 | 14 % |
134/// | synthetic blobs | 200 000 | 73.4 | 385 | 16 % |
135/// | synthetic blobs | 400 000 | 146.2 | 383 | 10 % |
136/// | `znippy` pack | 7 314 | 6.8 | **966** | 1.7 % |
137/// | `gunnar` pack | 12 849 | 9.2 | **744** | 2.5 % |
138///
139/// Dead straight over a 40× range of object counts, intercept ≈ 0.5 MB: the
140/// cost of a cached handle **is not a constant, it is the repository's object
141/// count**. Blobs alone are the floor at 382 B; a real repository, which also
142/// folds a commit graph and an ordinal space at open
143/// (`znippy_plugin_git::git_ops::Absorber::refold`), measured 744–966 B, so the
144/// budget takes 768 — the side that keeps the box alive, as the figure this
145/// replaces already said a budget must be.
146pub const BYTES_PER_INDEXED_OBJECT: u64 = 768;
147
148/// **The page cache one store's redb may additionally hold**, once reads have
149/// warmed it: `znippy_plugin_git::arms::DEFAULT_REDB_CACHE_BYTES`.
150///
151/// **One database per store, not two.** The "two per repository" sentence in
152/// `arms.rs` and in `storage::store_cache`'s docs is stale and it is what
153/// the 2026-08-31 reading doubled: `git_ops::open_with_arms` opens exactly
154/// one `ObjectReadStack`, and `znippy-plugin-git`'s own `tests/fd_per_store.rs`
155/// counts exactly one `objects.tail` descriptor per store.
156///
157/// It is a ceiling and not a reservation — which is why the table above, taken
158/// with only 64 reads per store, does not contain it. It IS reached under real
159/// traffic: `znippy-plugin-git`'s `tests/rss_retention_probe.rs`, run on the
160/// same box on the same day, measured **61.8 MB per stack** over 20 stacks of
161/// 300 000 rows with ~43 000 lookups each. A budget must assume the warm case.
162pub const REDB_CACHE_BYTES_PER_OPEN_REPO: u64 = 64 * 1024 * 1024;
163
164/// **The repository size the handle ceiling is derived against**, in objects.
165///
166/// A stated assumption, not a measurement, and it is stated here precisely
167/// because it is the one thing in this module that a box can be wrong about:
168/// `derive` has no catalog and cannot know how big the repositories on this
169/// machine are. A hundred thousand objects is an ordinary mid-sized repository
170/// — `znippy` is 7 314, `gunnar` 12 849, `alice/linux-mirror` is 13 948 244.
171///
172/// **The by-construction fix this is standing in for** is a byte ledger in
173/// [`crate::stores::ServerStores`]'s handle cache: charge each cached handle
174/// [`bytes_per_open_repo`] of *its own* object count and evict when the sum
175/// passes [`HANDLE_CACHE_PERCENT`] of `MemAvailable`, so that one 13.9-million
176/// object repository costs what it costs instead of one slot out of N. That
177/// needs an object count on the storage trait, which is a change in a second
178/// repository; this constant is what makes the ceiling honest until it lands.
179pub const BUDGETED_OBJECTS_PER_REPO: u64 = 100_000;
180
181/// What one **cached open repository** of `objects` objects holds in memory.
182///
183/// # The figure this replaces, and why it was not merely too small
184///
185/// It was `8 * 1024 * 1024`, from the native rig on 2026-08-30: 256 cached
186/// handles against a 1 114 MB RSS, **4.1 MB per handle**, rounded up to 8 MiB.
187/// That measurement was not wrong — it was taken over 15 000 *small*
188/// repositories, and 8 MiB is very nearly the right answer for a repository of
189/// ~10 000 objects (measured above: 6.8 MB at 7 314, 9.2 MB at 12 849).
190///
191/// The defect is the **shape**. A constant cannot be the price of a thing whose
192/// cost is linear in a number the constant does not mention. Priced at 8 MiB, a
193/// 400 000-object repository is under-counted 18×, and `alice/linux-mirror` at
194/// 13 948 244 objects is under-counted **1 300×** — one handle for it is
195/// ~10.7 GB, which is two thirds of the 16 GB box the product ships on. The
196/// derived ceiling on that box was 256 handles; 256 × the real price is not a
197/// budget, it is a number with a unit.
198pub const fn bytes_per_open_repo(objects: u64) -> u64 {
199    REDB_CACHE_BYTES_PER_OPEN_REPO + objects * BYTES_PER_INDEXED_OBJECT
200}
201
202/// What the handle ceiling is derived against: one cached open repository of
203/// [`BUDGETED_OBJECTS_PER_REPO`] objects, with its redb page cache warm.
204pub const BYTES_PER_OPEN_REPO: u64 = bytes_per_open_repo(BUDGETED_OBJECTS_PER_REPO);
205
206/// The environment variable that overrides the derived transfer ceiling.
207///
208/// Reachable from the appliance's kernel command line as
209/// `korp.env.GUNNAR_MAX_TRANSFERS=N` — Linux passes unrecognised `key=value`
210/// boot parameters to `init` as environment variables, which is the same
211/// channel `GUNNAR_NET` and `GUNNAR_DATA_ROOT` already arrive on.
212pub const MAX_TRANSFERS_ENV: &str = "GUNNAR_MAX_TRANSFERS";
213
214/// The environment variable that overrides the derived open-handle ceiling.
215pub const OPEN_REPOS_ENV: &str = "GUNNAR_MAX_OPEN_REPOS";
216
217/// The environment variable that overrides the derived memory budget, in bytes.
218pub const MEMORY_BUDGET_ENV: &str = "GUNNAR_GIT_MEMORY_BYTES";
219
220/// The three facts a budget is derived from.
221///
222/// Taken as a value rather than read inside the derivation so the whole rule is
223/// a pure function of numbers a test can supply — including the boxes this one
224/// is not (2 GB, one core, a thousand descriptors).
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226pub struct Machine {
227    /// CPUs this process may run on (`std::thread::available_parallelism`,
228    /// which reads the affinity mask, so a `taskset` or a cpuset is honoured).
229    pub cores: usize,
230    /// The descriptor soft limit **after** [`limits::nofile`] raised it.
231    pub nofile_soft: u64,
232    /// `MemTotal`, bytes. `0` when `/proc/meminfo` could not be read.
233    pub mem_total: u64,
234    /// `MemAvailable`, bytes. `0` when `/proc/meminfo` could not be read.
235    pub mem_available: u64,
236}
237
238impl Machine {
239    /// Read the box. Never fails: a missing `/proc/meminfo` leaves the memory
240    /// terms at zero, and [`derive`] then simply does not apply them — a budget
241    /// that refused to exist because one input was unreadable would be worse
242    /// than one derived from the two that were.
243    pub fn read(limit: Nofile) -> Machine {
244        let (mut mem_total, mut mem_available) =
245            crate::dashboard::probe::memory().unwrap_or((0, 0));
246        // A cgroup limit is the smaller truth, and gunnar runs in one whenever
247        // it is a container rather than the appliance. `/proc/meminfo` inside a
248        // cgroup still reports the HOST's memory: measured on the reference desktop, a server in
249        // a `MemoryMax=8G` scope read `MemAvailable 432.9G` and would have
250        // budgeted 216 GiB of a box it may use 8 of. The appliance is PID 1
251        // with no limit set, so there it changes nothing.
252        if let Some((cap, used)) = cgroup_memory() {
253            mem_total = if mem_total == 0 {
254                cap
255            } else {
256                mem_total.min(cap)
257            };
258            let free = cap.saturating_sub(used);
259            mem_available = if mem_available == 0 {
260                free
261            } else {
262                mem_available.min(free)
263            };
264        }
265        Machine {
266            cores: std::thread::available_parallelism()
267                .map(|n| n.get())
268                .unwrap_or(1),
269            nofile_soft: limit.soft,
270            mem_total,
271            mem_available,
272        }
273    }
274}
275
276/// `(memory.max, memory.current)` from this process's cgroup v2, in bytes.
277///
278/// `None` when there is no cgroup v2, no limit set (`max`), or the files cannot
279/// be read — every one of which means "the box is the limit", which is what the
280/// caller then uses. The same `/proc/self/cgroup` walk `serve::cpu_quota` does
281/// for `cpu.max`; pure Rust, three file reads, once per process — and once per
282/// `Admin.Capacity`, which clamps the RAM it quotes by the same rule
283/// ([`crate::capacity::MachineFacts::measure`]).
284pub(crate) fn cgroup_memory() -> Option<(u64, u64)> {
285    let own = std::fs::read_to_string("/proc/self/cgroup").ok()?;
286    let rel = own
287        .lines()
288        .find_map(|line| line.strip_prefix("0::"))?
289        .trim()
290        .trim_start_matches('/');
291    let dir = std::path::Path::new("/sys/fs/cgroup").join(rel);
292    let max = std::fs::read_to_string(dir.join("memory.max")).ok()?;
293    let max = max.trim();
294    if max == "max" {
295        return None;
296    }
297    let max: u64 = max.parse().ok()?;
298    let current: u64 = std::fs::read_to_string(dir.join("memory.current"))
299        .ok()?
300        .trim()
301        .parse()
302        .ok()?;
303    Some((max, current))
304}
305
306/// Where a budget's number came from.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum Source {
309    /// Derived from the machine by [`derive`].
310    Derived,
311    /// An operator's flag or environment variable, honoured exactly.
312    Operator,
313}
314
315/// The three ceilings, and the box they were derived from.
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
317pub struct Budgets {
318    /// Concurrent git transfers. Becomes
319    /// `SshConfig::max_concurrent_transfers`, which is the **ceiling of the
320    /// governor's band**; the floor is half of it
321    /// (`gunnar_ssh::governor::LimitBand::for_budget`).
322    pub transfers: usize,
323    /// Repository handles the cache may keep open.
324    pub open_repos: usize,
325    /// Bytes the admission ledger may give out to operations in flight.
326    pub memory_bytes: u64,
327    /// How long a transfer may wait for a slot, in milliseconds.
328    pub wait_ms: u64,
329    /// **What [`Budgets::open_repos`] handles can hold**, at
330    /// [`BYTES_PER_OPEN_REPO`] each, against
331    /// [`Budgets::handle_cache_share`] — the memory the cache is allowed.
332    ///
333    /// Carried as a value rather than left as arithmetic in a doc comment
334    /// because it is the number that was wrong: the ceiling said 256 handles on
335    /// a 16 GB box while one handle really held 137 MB, and nothing anywhere
336    /// multiplied those two together. Now [`announce`] does, out loud.
337    pub handle_cache_bytes: u64,
338    /// `MemAvailable × HANDLE_CACHE_PERCENT / 100`: what the cache may hold.
339    pub handle_cache_share: u64,
340    /// **The floor could not be afforded.** `true` when
341    /// [`limits::MIN_OPEN_REPOS`] handles cost more than
342    /// [`Budgets::handle_cache_share`] — a box too small for the minimum cache
343    /// this server insists on having.
344    ///
345    /// It is not an error and it does not refuse to serve: a server with no
346    /// handle cache is not a server, so the floor still wins and the process
347    /// still starts. What changes is that it is now *said*. The 2 GB
348    /// appliance reached the indexer watchdog's floor with the cache holding the
349    /// memory it was looking for, and every log line on the way there looked
350    /// ordinary.
351    pub over_promised: bool,
352    /// Where [`Budgets::transfers`] came from.
353    pub transfers_from: Source,
354    /// The box.
355    pub machine: Machine,
356}
357
358/// Bytes as `1.5G` / `820M` / `64K`, for one log line that a human reads.
359pub fn human(bytes: u64) -> String {
360    const K: u64 = 1024;
361    match bytes {
362        b if b >= K * K * K => format!("{:.1}G", b as f64 / (K * K * K) as f64),
363        b if b >= K * K => format!("{}M", b / (K * K)),
364        b if b >= K => format!("{}K", b / K),
365        b => format!("{b}"),
366    }
367}
368
369/// **The rule.** Pure, so every box can be tested from any box.
370///
371/// Each ceiling is the *smallest* of its terms, because a budget is a promise
372/// about the resource that runs out first:
373///
374/// ```text
375/// open_repos = min( (soft − RESERVED_FDS) / FDS_PER_OPEN_REPO ,      // unchanged
376///                   MemAvailable × HANDLE_CACHE_PERCENT / BYTES_PER_OPEN_REPO )
377///              clamped to [MIN_OPEN_REPOS, DEFAULT_OPEN_REPOS]
378///
379/// git_memory = MemAvailable × TRANSFER_MEMORY_PERCENT
380///
381/// transfers  = min( cores × TRANSFERS_PER_CORE ,
382///                   (soft − RESERVED_FDS − open_repos × FDS_PER_OPEN_REPO) / FDS_PER_TRANSFER )
383///              clamped to [MIN_TRANSFERS, MAX_TRANSFERS]
384/// ```
385///
386/// The descriptor term subtracts the handle cache it just sized, because those
387/// descriptors are spoken for: that subtraction is the whole of the bug
388/// stated the other way round — 256 handles at five descriptors each were over
389/// the limit *before* a single transfer had a socket.
390///
391/// There is deliberately **no** memory term on `transfers`. Memory is metered
392/// per operation by the ledger, which now charges what a transfer actually
393/// moves (`gunnar_ssh::governor::WIRE_MEMORY_NUMERATOR`) instead of a flat
394/// per-class constant; turning the same budget into a second, static
395/// concurrency cap is how `8 GiB / 320 MiB = 25` happened.
396pub fn derive(machine: &Machine) -> Budgets {
397    let by_fds_repos =
398        machine.nofile_soft.saturating_sub(limits::RESERVED_FDS) / limits::FDS_PER_OPEN_REPO;
399    let by_mem_repos = if machine.mem_available == 0 {
400        u64::MAX
401    } else {
402        machine.mem_available / 100 * HANDLE_CACHE_PERCENT / BYTES_PER_OPEN_REPO
403    };
404    let open_repos = usize::try_from(by_fds_repos.min(by_mem_repos))
405        .unwrap_or(usize::MAX)
406        .clamp(limits::MIN_OPEN_REPOS, crate::stores::DEFAULT_OPEN_REPOS);
407
408    let memory_bytes = if machine.mem_available == 0 {
409        gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES
410    } else {
411        machine.mem_available / 100 * TRANSFER_MEMORY_PERCENT
412    };
413
414    let by_cores = machine.cores.saturating_mul(TRANSFERS_PER_CORE);
415    let spoken_for = limits::RESERVED_FDS + open_repos as u64 * limits::FDS_PER_OPEN_REPO;
416    let by_fds = usize::try_from(machine.nofile_soft.saturating_sub(spoken_for) / FDS_PER_TRANSFER)
417        .unwrap_or(usize::MAX);
418    let transfers = by_cores.min(by_fds).clamp(MIN_TRANSFERS, MAX_TRANSFERS);
419
420    // The multiplication nobody was doing. `open_repos` is a count and
421    // `BYTES_PER_OPEN_REPO` is a price; the product is the promise, and it is
422    // the promise that has to fit.
423    let handle_cache_share = machine.mem_available / 100 * HANDLE_CACHE_PERCENT;
424    let handle_cache_bytes = (open_repos as u64).saturating_mul(BYTES_PER_OPEN_REPO);
425
426    Budgets {
427        transfers,
428        open_repos,
429        memory_bytes,
430        wait_ms: gunnar_ssh::governor::DEFAULT_ADMISSION_WAIT.as_millis() as u64,
431        transfers_from: Source::Derived,
432        machine: *machine,
433        handle_cache_bytes,
434        handle_cache_share,
435        // Only when the FLOOR is what over-promises. Above the floor the
436        // division above already fits by construction, so a `true` here means
437        // exactly one thing: this box is too small for the minimum cache.
438        over_promised: machine.mem_available > 0 && handle_cache_bytes > handle_cache_share,
439    }
440}
441
442/// Read `name` as a positive `usize`, or `None`.
443fn env_usize(name: &str) -> Option<usize> {
444    std::env::var(name)
445        .ok()?
446        .trim()
447        .parse()
448        .ok()
449        .filter(|n| *n > 0)
450}
451
452/// Read `name` as a positive `u64`, or `None`.
453fn env_u64(name: &str) -> Option<u64> {
454    std::env::var(name)
455        .ok()?
456        .trim()
457        .parse()
458        .ok()
459        .filter(|n| *n > 0)
460}
461
462/// **This process's budgets, derived once and announced once.**
463///
464/// The first caller does the deriving and writes the INFO row; every later call
465/// reads the same answer. It calls [`limits::nofile`], so the descriptor limit
466/// is raised before it is budgeted against — the ordering that made the
467/// 153-handle answer correct.
468pub fn budgets() -> Budgets {
469    static BUDGETS: OnceLock<Budgets> = OnceLock::new();
470    *BUDGETS.get_or_init(|| {
471        let machine = Machine::read(limits::nofile());
472        let mut budgets = derive(&machine);
473        if let Some(n) = env_usize(MAX_TRANSFERS_ENV) {
474            budgets.transfers = n;
475            budgets.transfers_from = Source::Operator;
476        }
477        if let Some(n) = env_usize(OPEN_REPOS_ENV) {
478            budgets.open_repos = n;
479        }
480        if let Some(n) = env_u64(MEMORY_BUDGET_ENV) {
481            budgets.memory_bytes = n;
482        }
483        budgets
484    })
485}
486
487/// One INFO row naming the budgets **and the three facts they came from**.
488///
489/// The facts are the point. A ceiling in a log that a reader cannot tie to
490/// anything about the machine is the same mystery `19` was; with the inputs
491/// beside it, "why 64?" is answered in the line that states it.
492///
493/// **It says what is IN FORCE, not what was derived.** `serve` calls it after
494/// an operator's `--max-concurrent-transfers` has been applied, because a row
495/// that printed the derivation while the server ran on something else would be
496/// a worse lie than no row: the first thing anybody does with this line is
497/// compare it to the refusals underneath it.
498pub fn announce(b: &Budgets) {
499    tracing::info!(
500        target: "gunnar::limits",
501        event = "limits.budget",
502        transfers = b.transfers,
503        transfers_from = ?b.transfers_from,
504        open_repos = b.open_repos,
505        git_memory_bytes = b.memory_bytes,
506        cores = b.machine.cores,
507        fds = b.machine.nofile_soft,
508        mem_total = b.machine.mem_total,
509        mem_available = b.machine.mem_available,
510        wait_ms = b.wait_ms,
511        handle_cache_bytes = b.handle_cache_bytes,
512        handle_cache_share = b.handle_cache_share,
513        bytes_per_open_repo = BYTES_PER_OPEN_REPO,
514        "transfers {} · open repos {} (≤ {} of {} at {}/repo) · git memory {} · admission wait \
515         {} ms (cores {}, fds {}, MemAvailable {})",
516        b.transfers,
517        b.open_repos,
518        human(b.handle_cache_bytes),
519        human(b.handle_cache_share),
520        human(BYTES_PER_OPEN_REPO),
521        human(b.memory_bytes),
522        b.wait_ms,
523        b.machine.cores,
524        b.machine.nofile_soft,
525        human(b.machine.mem_available),
526    );
527    if b.over_promised {
528        tracing::warn!(
529            target: "gunnar::limits",
530            event = "limits.handles.over_promised",
531            open_repos = b.open_repos,
532            handle_cache_bytes = b.handle_cache_bytes,
533            handle_cache_share = b.handle_cache_share,
534            mem_available = b.machine.mem_available,
535            "this box cannot afford the handle-cache floor: {} handles is the minimum this \
536             server keeps, they hold {} at {} each, and the cache's share of {} MemAvailable \
537             is only {}. Expect the indexer watchdog to pause pushes looking for memory the \
538             handle cache is holding — that is the 2 GB box's collapse, and it is now said at start \
539             instead of found at 12 s per create.",
540            b.open_repos,
541            human(b.handle_cache_bytes),
542            human(BYTES_PER_OPEN_REPO),
543            human(b.machine.mem_available),
544            human(b.handle_cache_share),
545        );
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn machine(cores: usize, fds: u64, mem_gb: u64) -> Machine {
554        Machine {
555            cores,
556            nofile_soft: fds,
557            mem_total: mem_gb * 1024 * 1024 * 1024,
558            mem_available: mem_gb * 1024 * 1024 * 1024,
559        }
560    }
561
562    /// ★ **The appliance, priced at what a handle was MEASURED to hold.**
563    ///
564    /// The box the product ships on: 4 cores, 16 GB, a raised descriptor limit.
565    /// [`Machine`] takes its inputs as a value precisely so a test can supply a
566    /// box this one is not, and this is the case that was missing: the handle
567    /// ceiling this module derives, times the memory one handle actually holds,
568    /// against the share the module says the cache may have.
569    ///
570    /// **SEEN RED, 2026-08-31, against `BYTES_PER_OPEN_REPO = 8 * 1024 * 1024`:**
571    ///
572    /// ```text
573    /// the derived ceiling of 256 open handles, at the 137M one cached
574    /// repository of 100000 objects was measured to hold, is 34.3G — but the
575    /// handle cache's share of this 16.0G box is 3.2G. The ceiling over-promises
576    /// by 10.7x.
577    /// ```
578    ///
579    /// 256 is what the descriptor term gives, because the memory term divided a
580    /// box by a per-handle price that was 18× too small for the repositories on
581    /// it. Nothing in the log said so: `limits.budget` printed `open repos 256`
582    /// and every one of those handles looked affordable.
583    #[test]
584    fn the_handle_ceiling_may_not_promise_more_memory_than_its_own_share() {
585        let appliance = machine(4, 1 << 20, 16);
586        let b = derive(&appliance);
587        let share = appliance.mem_available / 100 * HANDLE_CACHE_PERCENT;
588        // **The MEASURED price on the right, `derive`'s own count on the left.**
589        // Not `BYTES_PER_OPEN_REPO` on both sides: a test that priced the
590        // ceiling with the same constant the ceiling was derived from is the
591        // identity value in its exact form — it would pass against ANY value of
592        // that constant, including the one that was 18× too small. What binds
593        // here is that `derive`'s divisor must BE the measurement.
594        let measured = bytes_per_open_repo(BUDGETED_OBJECTS_PER_REPO);
595        let promised = b.open_repos as u64 * measured;
596        assert!(
597            promised <= share,
598            "the derived ceiling of {} open handles, at the {} one cached repository of {} \
599             objects was measured to hold, is {} — but the handle cache's share of this {} \
600             box is {}. The ceiling over-promises by {:.1}x.",
601            b.open_repos,
602            human(measured),
603            BUDGETED_OBJECTS_PER_REPO,
604            human(promised),
605            human(appliance.mem_available),
606            human(share),
607            promised as f64 / share as f64,
608        );
609    }
610
611    /// ★ **A handle's price is the repository's object count, and the numbers
612    /// that says are not small.**
613    ///
614    /// The second half of the same defect: a *constant* cannot be the price of a
615    /// linear thing, so the arithmetic is stated here against the two
616    /// repositories that bracket the range the appliance actually holds. Red
617    /// against the old constant on both rows — 8 MiB claimed to be the price of
618    /// a 13.9-million-object repository as readily as of a 7 314-object one.
619    #[test]
620    fn a_handle_is_priced_per_object_and_linux_mirror_does_not_fit_the_box() {
621        // `znippy` itself: 7 314 objects. Measured 6.8 MB of live bytes, which
622        // is what the cold price below should be in the same neighbourhood of.
623        let small = bytes_per_open_repo(7_314);
624        assert!(
625            small < 128 * 1024 * 1024,
626            "an ordinary repository must still be cheap: {}",
627            human(small)
628        );
629        // `alice/linux-mirror`: 13 948 244 objects (the count `memguard`'s
630        // module docs record from the push that panicked the appliance).
631        let mirror = bytes_per_open_repo(13_948_244);
632        assert!(
633            mirror > 10 * 1024 * 1024 * 1024,
634            "one open handle on linux-mirror is {} — the point of this assertion is that it \
635             is larger than the 16 GB box, not smaller",
636            human(mirror)
637        );
638        // …and therefore it cannot be held on the 4 GB standby it has never
639        // replicated to, nor beside 24 other repositories on the 16 GB one.
640        let standby = machine(4, 1 << 20, 4);
641        assert!(
642            mirror > standby.mem_available,
643            "one open handle on linux-mirror ({}) must not fit a 4 GB box ({})",
644            human(mirror),
645            human(standby.mem_available)
646        );
647    }
648
649    /// The rig the 2026-08-30 run measured on: 4 cores, a raised descriptor limit, 8 GiB.
650    /// This is the row the log prints and the row the bench table is against.
651    ///
652    /// **The handle count moved 204 → 11 on 2026-08-31** and that is the fix,
653    /// not a regression: 204 was `8 GiB × 20 % / 8 MiB`, and 8 MiB was the price
654    /// of a repository a twentieth the size of the one the ceiling is now
655    /// derived against ([`BUDGETED_OBJECTS_PER_REPO`]). Eleven handles at 137 MB
656    /// is the same 1.6 GB of memory 204 handles were *supposed* to be; the
657    /// difference is that this number is true. A repository touched past the
658    /// eleventh re-opens, which costs a re-open and never a failure
659    /// (`crate::stores::DEFAULT_OPEN_REPOS`'s own note).
660    #[test]
661    fn the_rig_gets_sixty_four_transfers_and_a_cache_that_fits_its_memory() {
662        let b = derive(&machine(4, 1 << 20, 8));
663        assert_eq!(b.transfers, 64, "4 cores x TRANSFERS_PER_CORE");
664        // 8 GiB x 20 % = 1.6 G, / 137 MB per repository = 11.
665        assert_eq!(b.open_repos, 11);
666        assert_eq!(b.memory_bytes, 8 * 1024 * 1024 * 1024 / 100 * 50);
667        assert!(!b.over_promised, "8 GiB affords the floor");
668        assert!(b.handle_cache_bytes <= b.handle_cache_share);
669    }
670
671    /// **The 2 GB box's collapse, as arithmetic — and the box that cannot afford even
672    /// the floor.**
673    ///
674    /// A 2 GB appliance may not keep 256 repositories open; the descriptor term
675    /// alone said 256, which is exactly the bug. Priced at 8 MiB it said 51,
676    /// which was the same bug with a smaller number. Priced at what a handle
677    /// holds it says **2**, which is under [`limits::MIN_OPEN_REPOS`] — so the
678    /// floor wins, and eight handles at 137 MB is 1.07 GiB of a box with 429 MB
679    /// to give the cache.
680    ///
681    /// That is not made to pass by weakening the assertion. It is
682    /// [`Budgets::over_promised`], and [`announce`] says it in a WARN at start.
683    /// The honest statement about the 2 GB box is that it is too small for this
684    /// server's minimum cache, and the defect was that nothing anywhere said so.
685    #[test]
686    fn a_two_gigabyte_box_cannot_afford_even_the_handle_floor_and_now_says_so() {
687        let small = machine(4, 1 << 20, 2);
688        let by_fds_only = limits::open_repo_budget(small.nofile_soft);
689        let b = derive(&small);
690        assert_eq!(by_fds_only, crate::stores::DEFAULT_OPEN_REPOS);
691        assert_eq!(
692            b.open_repos,
693            limits::MIN_OPEN_REPOS,
694            "the floor, not the division"
695        );
696        assert!(
697            b.over_promised,
698            "8 handles x {} is {}, and this box's share is {} — the budget must NOTICE that",
699            human(BYTES_PER_OPEN_REPO),
700            human(b.handle_cache_bytes),
701            human(b.handle_cache_share)
702        );
703        // Red for the flag itself: a box that CAN afford the floor must not
704        // raise it, or the warning would be noise on every appliance.
705        assert!(!derive(&machine(4, 1 << 20, 16)).over_promised);
706    }
707
708    /// **The 1024-descriptor rig — and what the cheaper store
709    /// buys there.**
710    ///
711    /// The descriptor term is the smaller one on that box, so it is the term
712    /// that decides, and it moved: an open store costs three descriptors
713    /// instead of five ([`limits::FDS_PER_OPEN_REPO`]), so `(1024 - 256) / 3`
714    /// is 256 where `(1024 - 256) / 5` was 153. **+67 % concurrently-open
715    /// repositories at the same `RLIMIT_NOFILE`**, which is the whole point of
716    /// the change: the divisor was three kernel objects and three page-cache
717    /// aliases per repository where one would do.
718    ///
719    /// `transfers` still lands on its floor and that is not a regression - it
720    /// was on the floor before (3 descriptors left over) and is on it now (0),
721    /// because [`limits::RESERVED_FDS`], not the leftovers, is what a session
722    /// draws from. A box that wants more than eight concurrent transfers raises
723    /// its limit; [`limits::nofile`] does exactly that, and this is the shape of
724    /// the box that could not.
725    #[test]
726    fn a_thousand_descriptors_buys_sixty_seven_percent_more_open_repositories() {
727        // 256 GiB and not 64: this test is about the DESCRIPTOR term, so the
728        // memory term must not be the one that binds. At the price a handle was
729        // measured to hold (137 MB), 64 GiB × 20 % buys 95 handles and the
730        // memory term would decide — which would make this test quietly stop
731        // testing descriptors while still passing on the transfers line below.
732        let b = derive(&machine(4, 1024, 256));
733        assert_eq!(b.open_repos, limits::open_repo_budget(1024));
734        assert_eq!(b.open_repos, 256);
735        assert_eq!(
736            1024_u64.saturating_sub(limits::RESERVED_FDS) / 5,
737            153,
738            "the descriptor-only answer"
739        );
740        // The reservation is still whole: 256 handles x 3 + 256 reserved = 1024.
741        assert!(b.open_repos as u64 * limits::FDS_PER_OPEN_REPO + limits::RESERVED_FDS <= 1024);
742        assert_eq!(b.transfers, MIN_TRANSFERS);
743    }
744
745    /// A one-core box is still a server, and a 128-core box does not get 2048.
746    #[test]
747    fn the_floor_and_the_ceiling_both_bind() {
748        assert_eq!(
749            derive(&machine(1, 1 << 20, 64)).transfers,
750            MIN_TRANSFERS.max(16)
751        );
752        assert_eq!(derive(&machine(128, 1 << 20, 512)).transfers, MAX_TRANSFERS);
753        const { assert!(MAX_TRANSFERS < crate::serve::GIT_BLOCKING_THREADS) };
754    }
755
756    /// An unreadable `/proc/meminfo` costs the memory terms and nothing else.
757    #[test]
758    fn no_meminfo_falls_back_rather_than_refusing_to_have_a_budget() {
759        let blind = Machine {
760            cores: 4,
761            nofile_soft: 1 << 20,
762            mem_total: 0,
763            mem_available: 0,
764        };
765        let b = derive(&blind);
766        assert_eq!(b.transfers, 64);
767        assert_eq!(b.open_repos, crate::stores::DEFAULT_OPEN_REPOS);
768        assert_eq!(
769            b.memory_bytes,
770            gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES
771        );
772    }
773
774    #[test]
775    fn bytes_read_like_bytes() {
776        assert_eq!(human(0), "0");
777        assert_eq!(human(2048), "2K");
778        assert_eq!(human(8 * 1024 * 1024), "8M");
779        assert_eq!(human(3 * 1024 * 1024 * 1024 + 512 * 1024 * 1024), "3.5G");
780    }
781}