Skip to main content

gunnar_server/
limits.rs

1//! **The descriptor limit: raised once, read once, and the handle cache sized
2//! to fit under it.**
3//!
4//! # The bug this is written against — MEASURED 2026-08-29 on the rig
5//!
6//! The 202nd repository touched since boot failed on every filesystem shape:
7//!
8//! ```text
9//! opening the git store at /data/repos/rig/z…-202.git/gunnar-git.znippy:
10//!   open journal …/objects.pack.journal: No file descriptors available (os error 24)
11//! ```
12//!
13//! and with it the ssh accept loop died — every later connection got
14//! `Connection reset by peer` until reboot while gRPC kept answering. Two
15//! facts made it: an open znippy store held **five** descriptors
16//! (`objects.pack` ×3, `objects.pack.journal`, `objects.tail` — read off
17//! `/proc/self/fd` in `tests/fd_budget.rs`; **three** since 2026-08-30, see
18//! [`FDS_PER_OPEN_REPO`]) and the handle cache kept
19//! [`DEFAULT_OPEN_REPOS`](crate::stores::DEFAULT_OPEN_REPOS) = 256 of them,
20//! while PID 1 runs with the kernel's default `RLIMIT_NOFILE` soft limit of
21//! **1024** — nothing in the server or in `korp-start` ever raised it. 256 × 5
22//! is 1280: the cache alone was over the limit, and at ~200 repositories the
23//! next open, and the next `accept`, had nothing left.
24//!
25//! # Both halves
26//!
27//! * [`nofile`] raises the soft limit once per process — as PID 1 (which holds
28//!   `CAP_SYS_RESOURCE`) to [`PID1_NOFILE`] on both soft and hard, elsewhere to
29//!   whatever the hard limit already allows. Pure Rust: `rustix::process`.
30//!   A limit that is still under [`COMFORTABLE_NOFILE`] afterwards is a
31//!   `WARN` on `gunnar::limits`, which the dashboard shows as a row.
32//! * [`open_repo_budget`] sizes the handle cache from the limit that resulted:
33//!   `(soft − RESERVED_FDS) / FDS_PER_OPEN_REPO`, clamped to
34//!   `[MIN_OPEN_REPOS, DEFAULT_OPEN_REPOS]`. Under 1024 that is 256 handles
35//!   (765 descriptors) with 256 left for listeners, sessions, the control
36//!   database and the log — and the 1500-repository test passes under
37//!   `ulimit -n 1024`. Under a raised limit it is the old 256.
38//!
39//! The listener half — an `accept` that fails with `EMFILE` must not end the
40//! accept loop — is `gunnar-ssh`'s and lives in its `server.rs`.
41
42use std::sync::OnceLock;
43
44use rustix::process::{getrlimit, setrlimit, Resource, Rlimit};
45
46/// What PID 1 sets both limits to. The kernel's `fs.nr_open` default is
47/// 1 048 576 and a `setrlimit` above it is refused, so this is the ceiling a
48/// stock kernel accepts; a smaller `nr_open` is honoured by the fallback in
49/// [`raise`].
50pub const PID1_NOFILE: u64 = 1 << 20;
51
52/// Below this many descriptors the server says so on the dashboard. 65 536 is
53/// what a distribution's `LimitNOFILE=` for a network daemon typically reads;
54/// 1024 is what `sshd` on a laptop gets and is where the bug lived.
55pub const COMFORTABLE_NOFILE: u64 = 65_536;
56
57/// Descriptors one open repository handle costs on the znippy engine — read
58/// off `/proc/self/fd` (`tests/fd_budget.rs`, and per-store in the engine's own
59/// `znippy-plugin-git/tests/fd_per_store.rs`): `objects.pack`,
60/// `objects.pack.journal`, `objects.tail`. The gix arm holds fewer; budgeting
61/// for the larger of the two is the safe side.
62///
63/// **Five until 2026-08-30**, because `objects.pack` was opened three
64/// times in one process — the writer's read/write handle, the absorber's
65/// read-only `pread` fallback, and the index workers' read-only handle. All
66/// three read and write positionally on an append-only file, so the two extra
67/// descriptors bought a kernel object and a page-cache alias per open
68/// repository and no isolation; they are one shared open file description now
69/// (`ArchiveWrite::blobs_shared`). It is a **divisor**, so the whole of
70/// [`open_repo_budget`] moves with it: under the kernel default soft limit of
71/// 1024 the ceiling goes 153 → 256 handles, +67 % concurrently-open
72/// repositories at the same `RLIMIT_NOFILE`.
73pub const FDS_PER_OPEN_REPO: u64 = 3;
74
75/// Descriptors kept back from the handle cache for everything that is not a
76/// cached repository: the two listeners, every live session (one socket, plus
77/// the transfer's own store handle while it runs), the control database, the
78/// log tap, the scrape socket.
79pub const RESERVED_FDS: u64 = 256;
80
81/// The handle cache never shrinks under this, whatever the limit says: a
82/// server that can keep no repository open re-opens (a `refold`) on every
83/// request, and a limit that low is the WARN row's job, not the cache's.
84pub const MIN_OPEN_REPOS: usize = 8;
85
86/// The descriptor limit this process ended up with.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct Nofile {
89    /// The soft limit after [`nofile`] had its one go at raising it.
90    pub soft: u64,
91    /// The hard limit after the same.
92    pub hard: u64,
93    /// What the soft limit was before.
94    pub was: u64,
95}
96
97impl Nofile {
98    /// Was the soft limit raised at all?
99    pub fn raised(&self) -> bool {
100        self.soft > self.was
101    }
102}
103
104/// One `Rlimit` as two numbers; `None` means unlimited, spelled `u64::MAX`.
105fn as_pair(limit: Rlimit) -> (u64, u64) {
106    (
107        limit.current.unwrap_or(u64::MAX),
108        limit.maximum.unwrap_or(u64::MAX),
109    )
110}
111
112/// Raise the descriptor limit as far as this process may.
113///
114/// PID 1 asks for [`PID1_NOFILE`] on both limits and, if the kernel's
115/// `fs.nr_open` refuses that, for the hard limit it already has. Anyone else
116/// may only move the soft limit up to the hard one, and does. Never lowers.
117fn raise(is_pid1: bool) -> Nofile {
118    let before = getrlimit(Resource::Nofile);
119    let (was, hard) = as_pair(before);
120    let want: &[(u64, u64)] = if is_pid1 {
121        &[(PID1_NOFILE, PID1_NOFILE), (hard, hard)]
122    } else {
123        &[(hard, hard)]
124    };
125    for &(soft, max) in want {
126        if soft <= was && max <= hard {
127            break;
128        }
129        let asked = Rlimit {
130            current: (soft != u64::MAX).then_some(soft),
131            maximum: (max != u64::MAX).then_some(max),
132        };
133        if setrlimit(Resource::Nofile, asked).is_ok() {
134            break;
135        }
136    }
137    let (soft, hard) = as_pair(getrlimit(Resource::Nofile));
138    Nofile { soft, hard, was }
139}
140
141/// **The process's descriptor limit, raised once.**
142///
143/// The first caller does the raising and the logging; every later call reads
144/// the same answer. [`crate::stores::ServerStores::open`] is the first caller
145/// on every server path, so the handle cache is always sized after the raise,
146/// and `serve::run` calls it again for nothing but the guarantee that the WARN
147/// row exists before the listeners bind.
148pub fn nofile() -> Nofile {
149    static LIMIT: OnceLock<Nofile> = OnceLock::new();
150    *LIMIT.get_or_init(|| {
151        let limit = raise(appliance_pid1::net::is_init());
152        announce(&limit);
153        limit
154    })
155}
156
157/// Say what the limit is — one INFO line, or one WARN row when it is low.
158fn announce(limit: &Nofile) {
159    if limit.soft < COMFORTABLE_NOFILE {
160        tracing::warn!(
161            target: "gunnar::limits",
162            event = "limits.nofile.low",
163            soft = limit.soft,
164            hard = limit.hard,
165            was = limit.was,
166            open_repos = open_repo_budget(limit.soft),
167            "fd limit {} is low (hard {}); the handle cache is bounded to fit but a busy box \
168             will re-open repositories — raise LimitNOFILE / ulimit -n",
169            limit.soft,
170            limit.hard
171        );
172    } else {
173        tracing::info!(
174            target: "gunnar::limits",
175            event = "limits.nofile",
176            soft = limit.soft,
177            hard = limit.hard,
178            was = limit.was,
179            raised = limit.raised(),
180            open_repos = open_repo_budget(limit.soft),
181            "fd limit {} (was {}); the handle cache keeps up to {} repositories open",
182            limit.soft,
183            limit.was,
184            open_repo_budget(limit.soft)
185        );
186    }
187}
188
189/// How many repository handles fit under a soft limit of `soft` descriptors.
190///
191/// `(soft − RESERVED_FDS) / FDS_PER_OPEN_REPO`, clamped to
192/// `[MIN_OPEN_REPOS, DEFAULT_OPEN_REPOS]`: 1024 → 256 (the cap, exactly —
193/// 256 × 3 + 256 reserved is 1024 to the descriptor), 512 → 85, 256 → 8 (the
194/// floor). It was 1024 → 153 while a store cost five.
195pub fn open_repo_budget(soft: u64) -> usize {
196    let fits = soft.saturating_sub(RESERVED_FDS) / FDS_PER_OPEN_REPO;
197    usize::try_from(fits)
198        .unwrap_or(usize::MAX)
199        .clamp(MIN_OPEN_REPOS, crate::stores::DEFAULT_OPEN_REPOS)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn the_budget_fits_under_the_kernel_default_and_caps_at_the_old_bound() {
208        // The rig's PID 1: 1024 descriptors. At five per store that was 153
209        // handles = 765 descriptors; at three it is the cap, 256 handles = 768
210        // descriptors, with RESERVED_FDS = 256 left over to the descriptor.
211        // The bug fixed here was 256 handles *at five*, which is 1280.
212        assert_eq!(open_repo_budget(1024), 256);
213        assert!(open_repo_budget(1024) as u64 * FDS_PER_OPEN_REPO + RESERVED_FDS <= 1024);
214        // Below the cap the division is what decides, and the reservation is
215        // still whole: 512 → (512−256)/3 = 85.
216        assert_eq!(open_repo_budget(512), 85);
217        assert!(open_repo_budget(512) as u64 * FDS_PER_OPEN_REPO + RESERVED_FDS <= 512);
218        assert_eq!(open_repo_budget(2048), crate::stores::DEFAULT_OPEN_REPOS);
219        assert_eq!(open_repo_budget(65_536), crate::stores::DEFAULT_OPEN_REPOS);
220        assert_eq!(
221            open_repo_budget(u64::MAX),
222            crate::stores::DEFAULT_OPEN_REPOS
223        );
224        // A limit too small to hold anything still keeps the floor; the WARN
225        // row is what says the box is misconfigured.
226        assert_eq!(open_repo_budget(256), MIN_OPEN_REPOS);
227        assert_eq!(open_repo_budget(0), MIN_OPEN_REPOS);
228    }
229
230    #[test]
231    fn a_non_pid1_process_ends_at_its_hard_limit_and_never_lower() {
232        let before = as_pair(getrlimit(Resource::Nofile));
233        let limit = raise(false);
234        assert_eq!(limit.was, before.0);
235        assert!(
236            limit.soft >= before.0,
237            "raise lowered the soft limit: {limit:?}"
238        );
239        assert_eq!(
240            limit.soft, limit.hard,
241            "an unprivileged raise stops at the hard limit"
242        );
243        assert_eq!(
244            limit.hard, before.1,
245            "an unprivileged raise must not move the hard limit"
246        );
247        assert_eq!(limit.raised(), limit.soft > before.0);
248        // Idempotent: a second go changes nothing.
249        let again = raise(false);
250        assert_eq!((again.soft, again.hard), (limit.soft, limit.hard));
251    }
252
253    #[test]
254    fn nofile_is_one_answer_per_process() {
255        assert_eq!(nofile(), nofile());
256        assert!(nofile().soft >= MIN_OPEN_REPOS as u64);
257    }
258}