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

nordisk/gunnar

crates/gunnar-wire/tests/index_in_progress.rs

//! **A fetch that arrives while a pack is still being indexed is served by the
//! walk, never refused.**
//!
//! # What this is a regression test for
//!
//! MEASURED on gunnar.rs, 2026-10-25 (the main's own log, read through the
//! installer's log door): eleven `git fetch`es between 11:23 and 12:09 UTC
//! ended in
//!
//! ```text
//! WARN git.upload_pack.failed err=selecting want minus have: index in progress
//!      for this repository: pack 165 0% (0/52 objects, resolve) — reachability
//!      needs the whole graph; retry when the index row reaches 100 %
//! ```
//!
//! and the client read `remote: the repository is unavailable; try again`
//! followed by `fatal: protocol error: bad pack header`; the same fetch worked
//! 20 s later. Many lanes push and fetch the same repositories (holger, nornir,
//! monetize) at once, so a pack was almost always being indexed somewhere.
//!
//! The znippy arm's `GitServe::select` refuses by name while its absorb gate is
//! held. That refusal is right for a reachability index, and wrong as an
//! answer to the CLIENT: the walk beneath `select` reads objects one by one
//! and does not need the index row at all. `select`'s own contract has the
//! word for "this index cannot answer this request right now" — `Ok(None)`,
//! walk it yourself — and `reachable::select` now takes the store's
//! index-in-progress refusal as exactly that.
//!
//! # Seen RED
//!
//! Before the fix, `serve_session` returned the store error and this test
//! failed on its `expect`.

mod common;

use common::Fixture;
use gix_hash::{Kind, ObjectId};
use gunnar_store::git_storage_trait::{
    Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas, RefRow,
    RefUpdate, Stored, TxId,
};
use gunnar_wire::upload_pack::{Config, UploadPack};
use gunnar_wire::{ProtocolVersion, Session};

/// The znippy arm's words, verbatim from the gunnar.rs log line above.
const STORE_SAYS: &str = "index in progress for this repository: pack 165 0% (0/52 objects, \
                          resolve) — reachability needs the whole graph; retry when the index \
                          row reaches 100 %";

/// A store whose reachability index is mid-absorb: every object is readable,
/// `select` refuses by name — the znippy arm while another thread holds its
/// absorb gate.
struct StillIndexing<'a>(&'a dyn GitServe);

impl GitOps for StillIndexing<'_> {
    fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.0.put(pack, refs)
    }
    fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
        self.0.put_pack(bytes)
    }
    fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.0.put_refs(updates)
    }
    fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
        self.0.get(oid)
    }
    fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
        self.0.has(oid)
    }
    fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
        self.0.size(oid)
    }
    fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
        self.0.extents(oids)
    }
    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
        self.0.refs()
    }
    fn update_ref(
        &self,
        name: &str,
        old: Option<Oid<'_>>,
        new: Option<Oid<'_>>,
    ) -> anyhow::Result<TxId> {
        self.0.update_ref(name, old, new)
    }
    fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
        self.0.put_refs_cas(edits)
    }
    fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
        self.0.reachable(want, have)
    }
    fn gc(&self) -> anyhow::Result<GcReport> {
        self.0.gc()
    }
}

impl GitServe for StillIndexing<'_> {
    fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
        self.0.read(oid)
    }
    fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
        self.0.header(oid)
    }
    fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
        self.0.sizes(oids)
    }
    fn head(&self) -> anyhow::Result<Option<RefRow>> {
        self.0.head()
    }
    fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
        self.0.set_head(target)
    }
    fn emit_pack(
        &self,
        want: &[Oid<'_>],
        have: &[Oid<'_>],
        caps: &Caps,
        out: &mut dyn std::io::Write,
    ) -> anyhow::Result<PackStats> {
        self.0.emit_pack(want, have, caps, out)
    }
    /// **The whole point**: the index is being built, and says so.
    fn select(&self, _want: &[Oid<'_>], _have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
        Err(anyhow::anyhow!(STORE_SAYS)).map_err(|e| e.context("selecting want minus have"))
    }
}

/// A store that fails `select` with something that is NOT the index refusal:
/// a real fault, which must still reach the client as one.
struct Broken<'a>(StillIndexing<'a>);

impl GitOps for Broken<'_> {
    fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.0.put(pack, refs)
    }
    fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
        self.0.put_pack(bytes)
    }
    fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
        self.0.put_refs(updates)
    }
    fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
        self.0.get(oid)
    }
    fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
        self.0.has(oid)
    }
    fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
        self.0.size(oid)
    }
    fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
        self.0.extents(oids)
    }
    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
        self.0.refs()
    }
    fn update_ref(
        &self,
        name: &str,
        old: Option<Oid<'_>>,
        new: Option<Oid<'_>>,
    ) -> anyhow::Result<TxId> {
        self.0.update_ref(name, old, new)
    }
    fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
        self.0.put_refs_cas(edits)
    }
    fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
        self.0.reachable(want, have)
    }
    fn gc(&self) -> anyhow::Result<GcReport> {
        self.0.gc()
    }
}

impl GitServe for Broken<'_> {
    fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
        self.0.read(oid)
    }
    fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
        self.0.header(oid)
    }
    fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
        self.0.sizes(oids)
    }
    fn head(&self) -> anyhow::Result<Option<RefRow>> {
        self.0.head()
    }
    fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
        self.0.set_head(target)
    }
    fn emit_pack(
        &self,
        want: &[Oid<'_>],
        have: &[Oid<'_>],
        caps: &Caps,
        out: &mut dyn std::io::Write,
    ) -> anyhow::Result<PackStats> {
        self.0.emit_pack(want, have, caps, out)
    }
    fn select(&self, _want: &[Oid<'_>], _have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
        Err(anyhow::anyhow!(
            "the reachability table is corrupt: ordinal 7 names no object"
        ))
    }
}

fn write_line(out: &mut Vec<u8>, payload: &str) {
    out.extend_from_slice(format!("{:04x}", payload.len() + 4).as_bytes());
    out.extend_from_slice(payload.as_bytes());
}

/// A v2 `fetch` of `want`, as `git fetch` sends it.
fn fetch_request(want: ObjectId) -> Vec<u8> {
    let mut request = Vec::new();
    write_line(&mut request, "command=fetch\n");
    write_line(&mut request, "object-format=sha1\n");
    request.extend_from_slice(b"0001");
    write_line(&mut request, &format!("want {want}\n"));
    write_line(&mut request, "done\n");
    request.extend_from_slice(b"0000");
    request
}

#[test]
fn a_fetch_during_an_index_is_served_by_the_walk() {
    let fixture = Fixture::build(Kind::Sha1);
    let control = UploadPack::new(&fixture.git, fixture.kind)
        .with_config(Config::default())
        .serve_session(
            ProtocolVersion::V2,
            Session::Connected,
            &mut std::io::Cursor::new(fetch_request(fixture.four)),
            &mut Vec::new(),
        )
        .expect("the control fetch, index answering");

    let indexing = StillIndexing(&fixture.git);
    let mut out = Vec::new();
    let summary = UploadPack::new(&indexing, fixture.kind)
        .with_config(Config::default())
        .serve_session(ProtocolVersion::V2, Session::Connected, &mut std::io::Cursor::new(fetch_request(fixture.four)), &mut out)
        .expect(
            "a fetch that met `index in progress` was REFUSED — the client reads `the repository is \
             unavailable; try again` and `bad pack header` (gunnar.rs 2026-10-25), although every \
             object it asked for is readable and the walk can serve it",
        );
    assert!(
        !summary.answered_from_bitmap,
        "the index was mid-absorb, so the walk answered: {summary:?}"
    );
    assert_eq!(
        summary.objects_selected, control.objects_selected,
        "the walk must select what the index would have: walk={summary:?} index={control:?}"
    );
    assert!(
        summary.objects_sent > 0 && !out.is_empty(),
        "no pack went out: {summary:?}"
    );
}

#[test]
fn a_real_select_fault_still_reaches_the_client() {
    let fixture = Fixture::build(Kind::Sha1);
    let broken = Broken(StillIndexing(&fixture.git));
    let served = UploadPack::new(&broken, fixture.kind)
        .with_config(Config::default())
        .serve_session(
            ProtocolVersion::V2,
            Session::Connected,
            &mut std::io::Cursor::new(fetch_request(fixture.four)),
            &mut Vec::new(),
        );
    assert!(
        served.is_err(),
        "a corrupt reachability table is a fault, not a busy index; walking past it would hide it"
    );
}