gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! **An import's memory, against the memory this server may really use** (IMPORT-2, 2026-10-27).
//!
//! # What was measured
//!
//! torvalds/linux (11 988 094 objects, a 6 602 805 008 B pack) imported through
//! `Mirror.Add --create` + `Mirror.Sync` into the shipped `gunnar-server` on a
//! Mini-shaped KVM guest (4 vCPU, 16 GB, no swap, 4 x 10 GB md-raid0), lane
//! KVM-IMPORT on dwarf, gunnar master a0b0ee50: DONE in 724 s, peak RSS 11.9 GiB,
//! of which ~6.2 GiB is the mmapped pack (file-backed, reclaimable) and
//! **5.05 GiB anonymous** at the peak. That box fits it.
//!
//! The same import in a 6 GiB cgroup on that VM (`MemoryMax=6G`): the guard read the
//! VM's 14.6 GB available and never paused, and the kernel OOM-killed gunnar-server
//! in the index at **anon-rss 6 238 872 kB** — no named error, the job gone with it.
//!
//! # Why the box was not the whole question
//!
//! The indexer's memory guard (`znippy_plugin_git::memguard`) reads `/proc/meminfo`,
//! and so did every byte budget derived from it. Inside a cgroup — a container, a
//! `MemoryMax=` unit — `/proc/meminfo` is the HOST's memory: on oden a
//! `--memory=16g` container read 500 GB, so the guard never paused and the import
//! went to 16.08 GB inside a 16 GB limit (import-big, 2026-10-26). A limit the
//! guard cannot see is a limit nothing keeps the import under.
//!
//! # What this module decides
//!
//! * [`MemoryLimit`]: the memory this process may use — `MemTotal`, or the
//!   cgroup v2 `memory.max` when that is smaller — and which of the two it was.
//! * [`import_room`]: before a fetched pack is indexed, whether indexing it fits
//!   that limit with [`MARGIN_PERCENT`] to spare. If not, the import is refused BY
//!   NAME (`[import-memory]`) before the index starts, and the repository it made
//!   is removed (`mirror_fetch::discard_created`), instead of an index that runs
//!   the box out of memory.
//! * [`cgroup_meminfo`]: under a cgroup limit, a `/proc/meminfo`-shaped text of the
//!   CGROUP's memory (`MemTotal` = `memory.max`, `MemAvailable` = room left plus
//!   reclaimable file pages), which `mirror_fetch` keeps fresh in a file while the
//!   import indexes and hands to the store's memory guard, so the guard pauses and
//!   flushes against the limit that is really there.

use std::path::{Path, PathBuf};

/// Anonymous bytes indexing one object of a fetched pack holds at its peak.
///
/// MEASURED (see the module doc): 5172 MiB anonymous at the peak for 11 988 094
/// objects is 452 B per object, server included. 512 is that figure rounded up,
/// the side that keeps the box alive.
pub const IMPORT_BYTES_PER_OBJECT: u64 = 512;

/// What an import holds whatever the pack's size: the server itself (~50 MiB
/// anonymous when idle, measured), the resolver's workers and their lookahead
/// (4 x 32 MiB), held delta bases (51 MiB peak measured for linux), row batches.
pub const IMPORT_FIXED_BYTES: u64 = 512 * 1024 * 1024;

/// The share of the limit an import must leave free: the same 20 % floor the
/// indexer's memory guard pauses under (`znippy_plugin_git::memguard::DEFAULT_BUDGET_PCT`).
pub const MARGIN_PERCENT: u64 = 20;

/// Where a [`MemoryLimit`] came from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LimitSource {
    /// `/proc/meminfo` `MemTotal`: no cgroup limit, or a looser one.
    Box,
    /// The cgroup v2 `memory.max` of this process, in this directory.
    Cgroup(PathBuf),
}

/// The memory this process may use, and why that number.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MemoryLimit {
    pub bytes: u64,
    pub source: LimitSource,
}

/// One reading of a cgroup v2 directory's memory files.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CgroupMemory {
    /// `memory.max`.
    pub max: u64,
    /// `memory.current`: everything charged, page cache included.
    pub current: u64,
    /// Clean file pages the kernel can drop inside the cgroup: `active_file +
    /// inactive_file - file_dirty - file_writeback` out of `memory.stat`.
    pub reclaimable: u64,
}

impl CgroupMemory {
    /// What the cgroup still has: the room under `memory.max` plus the clean page
    /// cache it could drop. Never more than `max`.
    pub fn available(&self) -> u64 {
        self.max
            .saturating_sub(self.current)
            .saturating_add(self.reclaimable)
            .min(self.max)
    }
}

impl MemoryLimit {
    /// This process's limit: `/proc/meminfo`, `/proc/self/cgroup`, `/sys/fs/cgroup`.
    /// `None` when neither the box nor a cgroup can be read.
    pub fn read() -> Option<MemoryLimit> {
        Self::read_from(Path::new("/proc/meminfo"), own_cgroup_dir().as_deref())
    }

    /// The same rule over named files: what a test hands a fake box and a fake cgroup.
    pub fn read_from(meminfo: &Path, cgroup_dir: Option<&Path>) -> Option<MemoryLimit> {
        let total = std::fs::read_to_string(meminfo)
            .ok()
            .and_then(|t| crate::dashboard::probe::parse_meminfo(&t))
            .map(|(total, _)| total)
            .filter(|t| *t > 0);
        let cgroup = cgroup_dir.and_then(tightest_cgroup);
        match (total, cgroup) {
            (Some(total), Some((dir, c))) if c.max < total => Some(MemoryLimit {
                bytes: c.max,
                source: LimitSource::Cgroup(dir),
            }),
            (None, Some((dir, c))) => Some(MemoryLimit {
                bytes: c.max,
                source: LimitSource::Cgroup(dir),
            }),
            (Some(total), _) => Some(MemoryLimit {
                bytes: total,
                source: LimitSource::Box,
            }),
            (None, None) => None,
        }
    }

    /// The cgroup directory when the limit is a cgroup's.
    pub fn cgroup_dir(&self) -> Option<&Path> {
        match &self.source {
            LimitSource::Cgroup(dir) => Some(dir),
            LimitSource::Box => None,
        }
    }

    /// What the limit is, in words: `16.0G (MemTotal)`, `6.0G (cgroup memory.max)`.
    pub fn words(&self) -> String {
        let source = match self.source {
            LimitSource::Box => "this box's MemTotal",
            LimitSource::Cgroup(_) => "this server's cgroup memory.max",
        };
        format!("{} ({source})", crate::budget::human(self.bytes))
    }
}

/// This process's own cgroup v2 directory, out of `/proc/self/cgroup`'s `0::` line.
pub fn own_cgroup_dir() -> Option<PathBuf> {
    let own = std::fs::read_to_string("/proc/self/cgroup").ok()?;
    let rel = own
        .lines()
        .find_map(|line| line.strip_prefix("0::"))?
        .trim()
        .trim_start_matches('/');
    Some(Path::new("/sys/fs/cgroup").join(rel))
}

/// **The tightest limit over `leaf`** (RA5-40): `leaf` and every parent that holds a
/// `memory.max` (cgroup v2's root holds none, and the walk ends there), the one with the
/// smallest `memory.max`, with its directory. A slice's `MemoryMax=` or a pod's limit above
/// the container is a limit as much as the leaf's own. `None`: no limit anywhere.
pub fn tightest_cgroup(leaf: &Path) -> Option<(PathBuf, CgroupMemory)> {
    let mut best: Option<(PathBuf, CgroupMemory)> = None;
    let mut dir = Some(leaf);
    while let Some(d) = dir {
        if !d.join("memory.max").exists() {
            break;
        }
        if let Some(c) = cgroup_memory_in(d) {
            if best.as_ref().is_none_or(|(_, b)| c.max < b.max) {
                best = Some((d.to_path_buf(), c));
            }
        }
        dir = d.parent();
    }
    best
}

/// `memory.max`, `memory.current` and the reclaimable file pages of `dir`. `None`
/// when a file is missing or `memory.max` is `max` (no limit).
pub fn cgroup_memory_in(dir: &Path) -> Option<CgroupMemory> {
    let max = std::fs::read_to_string(dir.join("memory.max")).ok()?;
    let max: u64 = match max.trim() {
        "max" => return None,
        n => n.parse().ok()?,
    };
    let current: u64 = std::fs::read_to_string(dir.join("memory.current"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    let stat = std::fs::read_to_string(dir.join("memory.stat")).unwrap_or_default();
    let field = |name: &str| -> u64 {
        stat.lines()
            .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
            .and_then(|v| v.trim().parse().ok())
            .unwrap_or(0)
    };
    let reclaimable = (field("active_file") + field("inactive_file"))
        .saturating_sub(field("file_dirty") + field("file_writeback"));
    Some(CgroupMemory {
        max,
        current,
        reclaimable,
    })
}

/// A `/proc/meminfo`-shaped text of a cgroup's memory, for a reader that only
/// reads `/proc/meminfo` (the indexer's memory guard): `MemTotal` is the cgroup's
/// `memory.max`, `MemAvailable` its [`CgroupMemory::available`].
pub fn cgroup_meminfo(c: &CgroupMemory) -> String {
    format!(
        "MemTotal:       {} kB\nMemAvailable:   {} kB\n",
        c.max / 1024,
        c.available() / 1024
    )
}

/// Anonymous memory indexing a fetched pack of `objects` objects is budgeted at.
pub const fn import_need(objects: u64) -> u64 {
    IMPORT_FIXED_BYTES + objects * IMPORT_BYTES_PER_OBJECT
}

/// The most an import may hold under `limit`: the limit less [`MARGIN_PERCENT`].
pub const fn import_ceiling(limit: u64) -> u64 {
    limit - limit / 100 * MARGIN_PERCENT
}

/// **Does indexing a pack of `objects` objects fit `limit`, with the margin?**
///
/// `Err` is the refusal, said by name before the index starts: what the pack is,
/// what indexing it needs, and what this server may use.
pub fn import_room(path: &str, objects: u64, limit: &MemoryLimit) -> Result<(), String> {
    let need = import_need(objects);
    let ceiling = import_ceiling(limit.bytes);
    if need <= ceiling {
        return Ok(());
    }
    Err(format!(
        "[import-memory] {path}: the fetched pack has {objects} objects, and indexing it needs \
         about {} of memory; this server may use {}, and an import keeps {MARGIN_PERCENT} % of \
         that free, so at most {}. Nothing was imported and the repository was not kept. A \
         server with more memory imports it.",
        crate::budget::human(need),
        limit.words(),
        crate::budget::human(ceiling),
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    const GIB: u64 = 1024 * 1024 * 1024;
    /// torvalds/linux as GitHub served it on 2026-10-27.
    const LINUX_OBJECTS: u64 = 11_988_094;

    fn fake_box(dir: &Path, total: u64) -> PathBuf {
        let p = dir.join("meminfo");
        std::fs::write(
            &p,
            format!(
                "MemTotal: {} kB\nMemFree: 1 kB\nMemAvailable: {} kB\n",
                total / 1024,
                total / 1024
            ),
        )
        .unwrap();
        p
    }

    fn fake_cgroup(dir: &Path, max: &str, current: u64, stat: &str) -> PathBuf {
        let cg = dir.join("cg");
        std::fs::create_dir_all(&cg).unwrap();
        std::fs::write(cg.join("memory.max"), format!("{max}\n")).unwrap();
        std::fs::write(cg.join("memory.current"), format!("{current}\n")).unwrap();
        std::fs::write(cg.join("memory.stat"), stat).unwrap();
        cg
    }

    /// ★ A cgroup limit below the box is the limit, and says it is the cgroup's.
    #[test]
    fn a_cgroup_limit_below_the_box_is_the_limit() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 500 * GIB);
        let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
        let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).expect("a limit");
        assert_eq!(
            limit.bytes,
            6 * GIB,
            "a 6 GiB cgroup on a 500 GiB host may use 6 GiB: {limit:?}"
        );
        assert_eq!(limit.source, LimitSource::Cgroup(cg.clone()));
        assert!(
            limit.words().contains("cgroup memory.max"),
            "{}",
            limit.words()
        );
    }

    /// No cgroup limit (`max`), or one above the box: the box is the limit.
    #[test]
    fn without_a_cgroup_limit_the_box_is_the_limit() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 16 * GIB);
        let cg = fake_cgroup(d.path(), "max", GIB, "");
        assert_eq!(
            MemoryLimit::read_from(&meminfo, Some(&cg)),
            Some(MemoryLimit {
                bytes: 16 * GIB,
                source: LimitSource::Box
            })
        );
        let loose = fake_cgroup(d.path(), &(64 * GIB).to_string(), GIB, "");
        assert_eq!(
            MemoryLimit::read_from(&meminfo, Some(&loose)).map(|l| l.bytes),
            Some(16 * GIB)
        );
        assert_eq!(
            MemoryLimit::read_from(&meminfo, None).map(|l| l.source),
            Some(LimitSource::Box)
        );
    }

    /// ★ The model covers what the Mini VM measured, and linux fits the Mini.
    #[test]
    fn linux_fits_the_mini_with_the_margin_and_the_model_covers_the_measurement() {
        // The 5 s sampler's peak on the Mini VM, and the anon-rss the 6 GiB cgroup was
        // OOM-killed at (still climbing): the budget must cover both.
        for (measured, what) in [
            (5172 * 1024 * 1024, "the Mini VM's anonymous peak"),
            (6_238_872 * 1024, "the anon-rss of the OOM kill"),
        ] {
            assert!(
                import_need(LINUX_OBJECTS) >= measured,
                "the budget for linux ({}) is below {what} ({})",
                crate::budget::human(import_need(LINUX_OBJECTS)),
                crate::budget::human(measured)
            );
        }
        let mini = MemoryLimit {
            bytes: 16 * GIB,
            source: LimitSource::Box,
        };
        assert_eq!(import_room("alice/linux", LINUX_OBJECTS, &mini), Ok(()));
    }

    /// ★ The same import inside a 6 GiB cgroup on a big host is refused by name,
    /// before the index, and the sentence says why in numbers.
    #[test]
    fn linux_in_a_6g_cgroup_is_refused_by_name_before_the_index() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 500 * GIB);
        let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "");
        let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).unwrap();
        let err =
            import_room("alice/linux", LINUX_OBJECTS, &limit).expect_err("must not fit 6 GiB");
        for word in [
            "[import-memory]",
            "alice/linux",
            "11988094 objects",
            "6.0G",
            "cgroup memory.max",
            "4.8G",
        ] {
            assert!(err.contains(word), "the refusal must say {word:?}: {err}");
        }
        // …and a small repository in the same cgroup is not refused.
        assert_eq!(import_room("alice/small", 100_000, &limit), Ok(()));
    }

    /// ★ **RA5-40: a limit on a PARENT cgroup is the limit.** IMPORT-2 read only the leaf's
    /// `memory.max`; a systemd slice's `MemoryMax=` (or a pod above the container) was missed,
    /// and the import was admitted against MemTotal, into the OOM kill it exists to prevent. The
    /// walk goes up while a `memory.max` is there, and the smallest one is the limit.
    #[test]
    fn a_parent_cgroups_limit_is_the_limit() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 500 * GIB);
        let slice = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
        let leaf = fake_cgroup(&slice, "max", GIB, "anon 1\n");
        let limit = MemoryLimit::read_from(&meminfo, Some(&leaf)).expect("a limit");
        assert_eq!(limit.bytes, 6 * GIB, "the slice's MemoryMax");
        assert_eq!(
            limit.cgroup_dir(),
            Some(slice.as_path()),
            "named as the slice's"
        );
        assert!(import_room("alice/linux", LINUX_OBJECTS, &limit).is_err());
        // A leaf tighter than its slice is the limit.
        let tight = fake_cgroup(&slice.join("cg"), &(2 * GIB).to_string(), GIB, "anon 1\n");
        assert_eq!(
            MemoryLimit::read_from(&meminfo, Some(&tight))
                .unwrap()
                .bytes,
            2 * GIB
        );
    }

    /// The 4 GB twin shape cannot take linux, and says so.
    #[test]
    fn a_4g_box_refuses_linux() {
        let twin = MemoryLimit {
            bytes: 4 * GIB,
            source: LimitSource::Box,
        };
        let err = import_room("alice/linux", LINUX_OBJECTS, &twin).unwrap_err();
        assert!(err.contains("MemTotal"), "{err}");
    }

    /// ★ The cgroup meminfo the guard reads is the cgroup's, not the host's: a
    /// cgroup near its limit reads near-empty; clean page cache counts as room.
    #[test]
    fn the_cgroup_meminfo_is_the_cgroups_memory() {
        let d = tempfile::tempdir().unwrap();
        let cg = fake_cgroup(
            d.path(),
            &(6 * GIB).to_string(),
            6 * GIB - 100 * 1024 * 1024,
            "anon 5000000000\nactive_file 209715200\ninactive_file 104857600\nfile_dirty 104857600\nfile_writeback 0\n",
        );
        let c = cgroup_memory_in(&cg).expect("a limited cgroup");
        // 100 MiB of room + (200 + 100 - 100) MiB clean page cache.
        assert_eq!(c.available(), 300 * 1024 * 1024);
        let text = cgroup_meminfo(&c);
        assert_eq!(
            crate::dashboard::probe::parse_meminfo(&text),
            Some((6 * GIB, 300 * 1024 * 1024)),
            "{text}"
        );
        assert_eq!(cgroup_memory_in(&fake_cgroup(d.path(), "max", 1, "")), None);
    }

    // The indexer's own guard reading that text inside the cgroup is tested where the engine may
    // be named: `storage/znippy_engine.rs`, `the_indexers_guard_sees_the_cgroup_through_the_text`.
}