Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-client/tests/mirror_slot_table.rs

//! **A mirror stops being fetchable after thirty-two fetches.**
//!
//! Found by `gunnar.year` on 2026-08-04, at **week 32** of a fifty-two week run
//! on the desktop (load average 3–7): the arm keeps one warm bare mirror open for the
//! whole simulated year and fetches into it every week, and the thirty-third
//! fetch died with
//!
//! ```text
//!   week 32: fetch into the warm mirror: repository: fetch (pack transfer):
//!   The slotmap turned out to be too small with 32 entries, would need 1 more
//! ```
//!
//! # Why
//!
//! `gix_odb::Store` sizes its index slot map **once, at open**, from
//! `Slots::AsNeededByDiskState { multiplier: 1.1, minimum: 32 }`, and never
//! grows it. [`open_or_init_bare`] created the mirror empty, so it got
//! `max(0 × 1.1, 32)` = 32 slots; each fetch lands one more pack; the
//! thirty-third has nowhere to go.
//!
//! This is the **same defect** as the server-side one `gunnar.endurance` hit at
//! 33 packs, now fixed in `gunnar_store::odb::OdbPool::grow_slots_if_full`. The
//! one mercy on this side is that it fails **loudly**: `gix-odb` returns
//! `InsufficientSlots` through a path that propagates it, whereas the server
//! reached the same consolidation through `gix_pack::Find::contains`, which
//! ends in `Err(_) => return false` and turned it into "that object is not in
//! this repository".
//!
//! # What is asserted, and what is only headroom
//!
//! [`a_mirror_opens_with_room_for_more_than_thirty_two_packs`] asserts the slot
//! table itself, out of `gix_odb::Store::metrics()` — the quantity that decides
//! whether the next index can be placed, not a config value echoed back.
//!
//! [`a_mirror_takes_packs_far_past_gix_odbs_minimum`] is the functional half:
//! forty packs written into one open mirror, one at a time, with every object
//! read back through the **same** handle. That is the shape a fetch loop has.
//!
//! Both were seen RED before `mirror_open_options` existed, and the second one
//! did **not** fail the way this file first predicted, which is the whole reason
//! to run a red rather than describe one:
//!
//! ```text
//! a_mirror_opens_with_room_for_more_than_thirty_two_packs
//!   a newly created mirror opened with 32 object-database slot(s).
//!
//! a_mirror_takes_packs_far_past_gix_odbs_minimum
//!   look the object up: LoadIndex(InsufficientSlots { current: 32, needed: 1 })
//! ```
//!
//! The lookup returns the **error**, not a miss. `gix_object::Find::try_find`
//! is fallible and propagates it; `gix_pack::Find::contains`, which is the trait
//! the server reached the same consolidation through, is not — it ends in
//! `Err(_) => return false`. One `Result` in a trait signature is the entire
//! difference between this crashing on the 33rd pack and the server quietly
//! answering "that object is not in this repository" for the rest of its life.
//!
//! **The fix is headroom, not a cure**, and this file says so rather than
//! leaving a reader to find out: `gix`'s slot map cannot grow, so a process
//! that fetches more than `MIRROR_SLOTS` times into one open repository will
//! reach the ceiling again. Re-opening re-sizes it from disk. There is no way
//! to do better through `gix::open`.

use std::path::Path;

use gix::hash::Kind;
use gix::prelude::{Find, Write};
use gix::ObjectId;
use gunnar_client::fetch::open_or_init_bare;

/// gix-odb's floor, and the number the failure reported.
const GIX_ODB_MINIMUM_SLOTS: usize = 32;

/// Packs to write. Past the minimum by enough that the failure is not the last
/// step, which cannot tell "it broke here" from "it ran out of steps".
const PACKS: usize = 40;

/// Write one object into `repo` and fold it into a pack of its own, the way a
/// fetch lands one — straight into `objects/pack`, telling the open repository
/// nothing.
fn land_a_pack(git_dir: &Path, repo: &gix::Repository, hash: Kind, n: usize) -> ObjectId {
    let body = format!("mirror slot probe, pack {n}\n").repeat(4);
    let id = repo
        .objects
        .write_buf(gix::object::Kind::Blob, body.as_bytes())
        .expect("write a blob");
    let pack_dir = git_dir.join("objects").join("pack");
    let (bytes, _) = gunnar_pack::write_pack_to_vec(
        std::slice::from_ref(&id),
        &gunnar_client::pack_source::PlumbingObjects(&repo.objects),
        &gunnar_pack::WriteOptions::new(hash),
    )
    .expect("write a pack");
    let indexed = gunnar_pack::index_pack(
        &mut std::io::Cursor::new(&bytes),
        Some(&pack_dir),
        &gunnar_pack::IndexOptions::new(hash),
        None,
    )
    .expect("index the pack");
    if let Some(keep) = indexed.keep_path.as_deref() {
        let _ = std::fs::remove_file(keep);
    }
    // Remove the loose copy, so the object exists ONLY in the new pack and a
    // lookup that finds it has had to see that pack.
    let hex = id.to_string();
    let _ = std::fs::remove_file(git_dir.join("objects").join(&hex[..2]).join(&hex[2..]));
    id
}

fn packs_on_disk(git_dir: &Path) -> usize {
    std::fs::read_dir(git_dir.join("objects").join("pack"))
        .map(|rd| {
            rd.filter_map(std::result::Result::ok)
                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("pack"))
                .count()
        })
        .unwrap_or(0)
}

/// The slot table a freshly created mirror gets, read out of the store rather
/// than out of the options that asked for it.
///
/// `Metrics::unused_slots` counts slots holding no index. On a repository with
/// nothing in it that is the whole table, and it is exactly the quantity
/// `consolidate_with_disk_state` runs out of.
#[test]
fn a_mirror_opens_with_room_for_more_than_thirty_two_packs() {
    let dir = tempfile::tempdir().expect("a temporary directory");
    let git_dir = dir.path().join("mirror.git");
    let repo = open_or_init_bare(&git_dir, Kind::Sha256).expect("init the mirror");
    let slots = repo.objects.store_ref().metrics().unused_slots;
    assert!(
        slots > GIX_ODB_MINIMUM_SLOTS,
        "a newly created mirror opened with {slots} object-database slot(s). `gix::open`'s \
         default sizes the table ONCE from the packs on disk — none, here — so it lands on \
         gix-odb's minimum of {GIX_ODB_MINIMUM_SLOTS}, and the 33rd fetch into this mirror \
         fails with `The slotmap turned out to be too small`. gunnar.year hit exactly that at \
         week 32 of 52."
    );
}

/// The functional half: forty packs into **one open mirror**, one at a time,
/// every object read back through the same handle.
#[test]
fn a_mirror_takes_packs_far_past_gix_odbs_minimum() {
    let dir = tempfile::tempdir().expect("a temporary directory");
    let git_dir = dir.path().join("mirror.git");
    let hash = Kind::Sha256;
    let repo = open_or_init_bare(&git_dir, hash).expect("init the mirror");

    let mut written = Vec::new();
    let mut buf = Vec::new();
    for n in 1..=PACKS {
        let id = land_a_pack(&git_dir, &repo, hash, n);
        written.push(id);

        // THE NON-VACUITY FLOOR. Without it, "the object is absent" could be
        // true because the pack never landed, and this test would be reporting
        // on its own fixture instead of on the slot table.
        assert_eq!(
            packs_on_disk(&git_dir),
            n,
            "pack {n} of {PACKS}: the mirror holds {} pack(s) on disk, not {n}",
            packs_on_disk(&git_dir)
        );

        let found = repo
            .objects
            .try_find(&id, &mut buf)
            .expect("look the object up");
        assert!(
            found.is_some(),
            "pack {n} of {PACKS}: the object written into it is in objects/pack and this \
             repository — open since before pack 1, the way a fetch loop holds one — reports \
             it ABSENT. gix_odb's slot table was sized once at open and cannot grow; see this \
             file's header."
        );
    }

    // Read every object back by content, not by presence: a store answering
    // from a stale index it cannot actually read through would pass a
    // `is_some()` on `contains` and fail here.
    for (i, id) in written.iter().enumerate() {
        let data = repo
            .objects
            .try_find(id, &mut buf)
            .expect("look up")
            .unwrap_or_else(|| panic!("pack {}: {id} vanished on the second pass", i + 1));
        assert_eq!(
            data.kind,
            gix::object::Kind::Blob,
            "pack {}: wrong kind",
            i + 1
        );
        assert!(
            data.data.starts_with(b"mirror slot probe, pack "),
            "pack {}: {id} came back with somebody else's bytes",
            i + 1
        );
    }
    assert_eq!(
        written.len(),
        PACKS,
        "the loop wrote {} pack(s)",
        written.len()
    );
}