crates/gunnar-wire/tests/walk_limit.rs
//! **What happens at the memory ceiling, from the client's side of the wire.**
//!
//! `crates/gunnar-wire/src/graph.rs`'s unit tests establish that the charge is
//! taken against what a traversal holds and that the server-wide
//! [`WalkPool`](gunnar_wire::WalkPool) refuses a walk because of what another
//! one is holding. Neither of them looks at a socket, and a refusal nobody can
//! read is the failure mode this whole item exists against: `report_error` was
//! found on 2026-08-05 with **zero** call sites, so every refusal gunnar made
//! reached `git` as
//!
//! ```text
//! fatal: the remote end hung up unexpectedly
//! ```
//!
//! So the assertion here is on the **bytes the server wrote**, not on the `Err`
//! it returned. The `Err` was always there; it is exactly the thing nobody
//! could see.
//!
//! # The two refusals are deliberately different sentences
//!
//! [`Error::WalkTooLarge`](gunnar_wire::Error::WalkTooLarge) says *this request
//! will never fit*. [`Error::WalkPoolFull`](gunnar_wire::Error::WalkPoolFull)
//! says *the machine is busy, come back*. A client that cannot tell them apart
//! retries the first forever and gives up on the second, which is the wrong
//! answer in both directions.
mod common;
use common::{Fixture, Unindexed};
use gix_hash::Kind;
use gunnar_wire::upload_pack::{Config, UploadPack, WantPolicy};
use gunnar_wire::{Limits, ProtocolVersion, Session};
/// Did the server tell the client *anything* about why it stopped, and what did
/// it say?
///
/// Both channels, because `UploadPack::report_error` uses whichever the session
/// has: an `ERR` pkt-line before the sideband is open, a band-3 message after.
///
/// # It reads pkt-lines rather than searching the bytes
///
/// The obvious version is `text.contains('\u{3}')`, and it is wrong in the one
/// direction that matters: a **successful** clone carries a deflated packfile
/// down band 1, and deflated bytes contain `0x03` constantly. That version
/// reported the green arm of [`the_refusal_reaches_the_client_as_a_sentence`]
/// as a server that had refused a clone it had in fact just served. Framing is
/// what tells a band byte from a payload byte, so the framing is what this
/// walks.
fn refusal_to_client(bytes: &[u8]) -> Option<String> {
let mut at = 0usize;
while at + 4 <= bytes.len() {
let header = std::str::from_utf8(&bytes[at..at + 4]).ok()?;
let len = usize::from_str_radix(header, 16).ok()?;
// A flush, delimiter or response-end pkt has no payload.
if len < 4 {
at += 4;
continue;
}
let end = (at + len).min(bytes.len());
let payload = &bytes[at + 4..end];
at = end;
let text = match payload.first() {
// Band 3 is the sideband's "fatal error" channel.
Some(3) => String::from_utf8_lossy(&payload[1..]).into_owned(),
// Bands 1 and 2 are the pack and progress; never a refusal.
Some(1 | 2) => continue,
_ => String::from_utf8_lossy(payload).into_owned(),
};
if payload.first() == Some(&3) || text.starts_with("ERR ") {
return Some(text.trim_end().to_owned());
}
}
None
}
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` for `want`.
fn clone_request(want: gix_hash::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
}
/// Serve one clone of `want` under `config` and hand back what went to the
/// client.
fn served(
fixture: &Fixture,
want: gix_hash::ObjectId,
config: Config,
) -> (Vec<u8>, Option<gunnar_wire::Error>) {
// Laid over the fixture's store so step 0 declines and the traversal this
// whole file is about actually runs — see `common::Unindexed`. Without it a
// one-byte ceiling admits a clone, because `MemGitServe::select` answers
// the request before a single id is charged.
let store = Unindexed(&fixture.git);
let upload = UploadPack::new(&store, fixture.kind).with_config(config);
let mut input = std::io::Cursor::new(clone_request(want));
let mut out = Vec::new();
let outcome = upload.serve_session(
ProtocolVersion::V2,
Session::Connected,
&mut input,
&mut out,
);
(out, outcome.err())
}
/// The config a fetch is really served with, with one ceiling changed.
///
/// `WantPolicy::Any` keeps every arm about the ceiling under test:
/// `ReachableFromRefs` walks the repository *before* the pack is selected and
/// is charged for it too, so a refusal from there is a true fact about a
/// different walk.
fn config(
walk_bytes: Option<usize>,
pool: Option<std::sync::Arc<gunnar_wire::WalkPool>>,
) -> Config {
Config {
want_policy: WantPolicy::Any,
limits: Limits {
walk_bytes,
..Limits::default()
},
walk_pool: pool,
..Config::default()
}
}
/// **The refusal is a sentence on the wire, and it names both numbers.**
///
/// Seen RED by making `charge_walk` never compare against the ceiling: the
/// clone then succeeds and there is no `ERR` line at all.
#[test]
fn the_refusal_reaches_the_client_as_a_sentence() {
let fixture = Fixture::build(Kind::Sha1);
// One byte. Any walk that holds a single id is over it, which keeps the
// fixture small and the subject the ceiling rather than the corpus.
let (text, err) = served(&fixture, fixture.four, config(Some(1), None));
let err = err.expect("a one-byte ceiling must refuse a clone");
assert!(
matches!(err, gunnar_wire::Error::WalkTooLarge { .. }),
"the clone failed with {err}, not WalkTooLarge, so this test is measuring another fault"
);
// An `ERR` pkt-line **or** a band-3 progress message: `report_error` picks
// by whether the sideband is already open, and by the time a walk is
// charged it is. Asserting only on `ERR ` is what this test did first, and
// it reported red against a server that was telling the client perfectly
// well — on the other channel.
let said = refusal_to_client(&text).unwrap_or_else(|| {
panic!(
"a refusal owes the client an ERR line or a band-3 message, or git prints only \"the \
remote end hung up unexpectedly\": {:?}",
String::from_utf8_lossy(&text)
)
});
assert!(
said.contains("object bookkeeping") && said.contains("1-byte ceiling"),
"the refusal does not say what was refused or against what number, so the operator still \
has to be on the server to find out: {said:?}"
);
// ── the green half ──────────────────────────────────────────────────────
//
// Not decoration: a ceiling that refused every clone would satisfy every
// assertion above and would be a far worse defect than the one being
// guarded — a server that clones nothing.
let (text, err) = served(&fixture, fixture.four, config(None, None));
assert!(err.is_none(), "an unbounded clone must serve: {err:?}");
assert_eq!(
refusal_to_client(&text),
None,
"the unbounded clone told the client it had been refused"
);
}
/// **A busy server says so, and says something different from a request that
/// will never fit.**
///
/// A pool of zero bytes refuses the first walk that reaches the floor. It is a
/// configuration an operator would not choose, and it is the smallest one that
/// makes the difference between the two refusals observable without building a
/// twenty-thousand-object fixture twice.
///
/// Seen RED by giving `WalkPoolFull` the same text as `WalkTooLarge`: the
/// assertion that the two sentences differ is the only thing standing between
/// a client that retries and a client that gives up.
#[test]
fn a_full_pool_is_a_different_sentence_from_a_request_that_will_never_fit() {
// Past `WalkPool::FLOOR_BYTES`, because below it a walk does not touch the
// pool at all — see `a_small_clone_does_not_touch_the_pool`, which is the
// other half of the same fact. The shipped constants are used rather than
// lowered for the test: a floor only ever crossed by a number the test
// chose is not the floor the server has.
let fixture = Fixture::build(Kind::Sha1);
let history = gunnar_wire::WalkPool::FLOOR_BYTES / gunnar_wire::WALK_BYTES_PER_OBJECT / 3 + 500;
let big = fixture.extend(fixture.four, history, "pool");
let pool = gunnar_wire::WalkPool::new(0);
// A ceiling that would admit this clone on its own, so the pool is what
// refuses it and not the per-request number.
let (busy, err) = served(&fixture, big, config(Some(usize::MAX), Some(pool.clone())));
let err = err.expect("a pool of zero bytes must refuse");
assert!(
matches!(err, gunnar_wire::Error::WalkPoolFull { .. }),
"a full pool refused with {err}, not WalkPoolFull"
);
let busy = refusal_to_client(&busy).expect("a full pool owes the client a reason");
assert!(
busy.contains("retry"),
"the busy-server refusal does not tell the client it is worth coming back: {busy:?}"
);
assert_eq!(pool.refusals(), 1, "the refusal was not counted");
let (bytes, _) = served(&fixture, fixture.four, config(Some(1), None));
let too_big = refusal_to_client(&bytes).expect("a ceiling of one byte owes a reason too");
assert!(
!too_big.contains("retry"),
"a request that will never fit told the client to retry, which is an infinite loop with \
a sentence in front of it: {too_big:?}"
);
assert_ne!(
busy, too_big,
"the two refusals are the same bytes, so a client cannot tell a busy server from a \
request it must never send again"
);
}
/// A walk under [`WalkPool::FLOOR_BYTES`] never touches the pool.
///
/// The property that keeps an ordinary clone off a shared atomic. Seen RED by
/// dropping the floor test from `charge_walk`: `in_flight_bytes` then moves for
/// a five-object fixture.
#[test]
fn a_small_clone_does_not_touch_the_pool() {
let fixture = Fixture::build(Kind::Sha1);
let pool = gunnar_wire::WalkPool::new(gunnar_wire::WalkPool::FLOOR_BYTES);
let (_, err) = served(&fixture, fixture.four, config(None, Some(pool.clone())));
assert!(err.is_none(), "the small clone must serve: {err:?}");
assert_eq!(
pool.peak_bytes(),
0,
"a clone holding a handful of ids reserved from the server-wide pool, so every transfer \
on this box pays an atomic and a share of the purse for memory it does not hold"
);
}