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"
);
}