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