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

nordisk/gunnar

crates/gunnar-cli/tests/lfs_ssh_sessions.rs

#![cfg(all(feature = "server", feature = "client"))]

//! **GHUNT-7 (lane OX-GUNHUNT, 2026-10-28): git-lfs over pure SSH on a server whose
//! transfer limit is smaller than one client's sessions.**
//!
//! Stock git-lfs (3.x) multiplexes its pure-SSH transfers over ONE SSH connection
//! (`lfs.ssh.automultiplex`) and opens `lfs.concurrenttransfers` (8 by default) plus one
//! `git-lfs-transfer` channels on it, then keeps them open as a pool for the whole push or
//! clone. Each channel took its own admission slot, held while idle, so a single git-lfs
//! on a box whose limit is under nine waited for a slot its own idle channels held: the
//! push sat for the keyed wait and failed ("pure SSH connection unavailable"), or hung for
//! twenty minutes. Measured with stock git 2.47 + git-lfs 3.6.1 against gunnar master
//! 554a689c: `--max-concurrent-transfers 4`, `lfs.concurrenttransfers=16` → push timed out
//! at 300 s; 16/16 → refused after 30 s; 3 transfers → fine.
//!
//! Everything here is the real thing: a spawned `gunnar serve`, stock git, stock git-lfs.

use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

#[path = "common/served_binary.rs"]
mod served_binary;
use served_binary::{gunnar, mint, Serving};

fn tools() {
    for (tool, arg) in [("git", "--version"), ("ssh", "-V"), ("git-lfs", "version")] {
        assert!(
            Command::new(tool).arg(arg).output().is_ok(),
            "{tool} is required; a suite that skipped would report a pass"
        );
    }
}

/// Run `git args` in `dir` as `home`, through `key`, giving up after `limit`.
fn git(home: &Path, key: &Path, dir: &Path, args: &[&str], limit: Duration) -> (bool, String) {
    static RUN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
    let n = RUN.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    let log = home.join(format!("git-{n}.log"));
    let mut child = Command::new("git")
        .current_dir(dir)
        .args(args)
        .env("HOME", home)
        .env("GIT_CONFIG_NOSYSTEM", "1")
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("SSH_AUTH_SOCK", "")
        .env(
            "GIT_SSH_COMMAND",
            format!(
                "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o IdentitiesOnly=yes -i {}",
                key.display()
            ),
        )
        .stdout(Stdio::null())
        .stderr(std::fs::File::create(&log).unwrap())
        .spawn()
        .expect("spawn git");
    let deadline = Instant::now() + limit;
    loop {
        if let Some(status) = child.try_wait().unwrap() {
            return (
                status.success(),
                std::fs::read_to_string(&log).unwrap_or_default(),
            );
        }
        if Instant::now() > deadline {
            let _ = child.kill();
            let _ = child.wait();
            return (
                false,
                format!(
                    "TIMED OUT after {limit:?}\n{}",
                    std::fs::read_to_string(&log).unwrap_or_default()
                ),
            );
        }
        std::thread::sleep(Duration::from_millis(100));
    }
}

#[test]
fn one_git_lfs_over_ssh_is_served_on_a_box_whose_limit_is_below_its_session_pool() {
    tools();
    let dir = tempfile::tempdir().expect("a temp dir");
    let home = dir.path().join("home");
    let keys = home.join("keys");
    std::fs::create_dir_all(&keys).unwrap();
    let (key, public) = mint(&keys, "alice");
    // A box sized for four transfers at once; git-lfs below asks for 8 + 1 sessions.
    let serving = Serving::start_with(
        &dir.path().join("data"),
        &home,
        &dir.path().join("server.log"),
        &["--max-concurrent-transfers", "4"],
    );
    assert!(
        gunnar(
            &home,
            &[
                "control",
                "bootstrap",
                "alice",
                "--key",
                &format!("@{}", public.display()),
                "--at",
                &serving.grpc
            ]
        )
        .ok
    );
    let made = gunnar(
        &home,
        &[
            "control",
            "repo",
            "create",
            "alice/assets",
            "--hash",
            "sha1",
            "-i",
            key.to_str().unwrap(),
            "--at",
            &serving.grpc,
        ],
    );
    assert!(made.ok, "{}", made.out);

    std::fs::write(
        home.join(".gitconfig"),
        "[user]\n\tname = t\n\temail = t@mock.invalid\n[init]\n\tdefaultBranch = main\n",
    )
    .unwrap();
    let work = dir.path().join("work");
    std::fs::create_dir_all(&work).unwrap();
    let t = Duration::from_secs(30);
    assert!(git(&home, &key, &work, &["init", "-q"], t).0);
    // The filters in HOME, so the clone below smudges as well as the push cleans.
    assert!(git(&home, &key, &work, &["lfs", "install", "--skip-repo"], t).0);
    assert!(git(&home, &key, &work, &["lfs", "track", "*.bin"], t).0);
    let mut expected = Vec::new();
    for i in 0..40u32 {
        let body: Vec<u8> = (0..20_000u32)
            .map(|j| (j.wrapping_mul(2_654_435_761).wrapping_add(i * 97) >> 13) as u8)
            .collect();
        std::fs::write(work.join(format!("a{i}.bin")), &body).unwrap();
        expected.push(body);
    }
    assert!(git(&home, &key, &work, &["add", "-A"], t).0);
    assert!(git(&home, &key, &work, &["commit", "-qm", "assets"], t).0);
    let url = format!("ssh://git@{}/alice/assets.git", serving.ssh);

    // The push: git-lfs's whole pool on one connection, against four slots.
    let (pushed, said) = git(
        &home,
        &key,
        &work,
        &[
            "-c",
            "lfs.concurrenttransfers=8",
            "push",
            "-q",
            &url,
            "main",
        ],
        Duration::from_secs(120),
    );
    assert!(
        pushed,
        "one git-lfs push over SSH failed on a box sized for four transfers:\n{said}"
    );

    // And the clone back, the same way: every object, byte for byte.
    let back = dir.path().join("back");
    let (cloned, said) = git(
        &home,
        &key,
        dir.path(),
        &[
            "-c",
            "lfs.concurrenttransfers=8",
            "clone",
            "-q",
            &url,
            back.to_str().unwrap(),
        ],
        Duration::from_secs(120),
    );
    assert!(cloned, "the clone over SSH failed:\n{said}");
    for (i, body) in expected.iter().enumerate() {
        assert_eq!(
            &std::fs::read(back.join(format!("a{i}.bin"))).unwrap(),
            body,
            "a{i}.bin came back different (a pointer?)"
        );
    }
}

/// A free loopback port (bound, read, released).
fn free_port() -> u16 {
    std::net::TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port()
}

/// The scrape body of `/metrics` at `addr`.
fn scrape(addr: &str) -> String {
    use std::io::{Read as _, Write as _};
    let mut s = std::net::TcpStream::connect(addr).expect("the metrics listener");
    write!(s, "GET /metrics HTTP/1.0\r\nHost: x\r\n\r\n").unwrap();
    let mut body = String::new();
    s.read_to_string(&mut body).unwrap();
    body
}

/// ★ **GUN-SSH-SLOT-HANG** (git.gunnar.rs 2026-11-01 ~16:00Z): four parallel clones sat in
/// `git-lfs-transfer` with 0 CPU and held the key's transfer slots for ~35 minutes while
/// every other transfer of that key waited and was refused. A git transfer that moves NO
/// bytes for `--transfer-idle-secs` is closed and gives its slot back; the client is told
/// why (stderr); a transfer waiting for a slot is told it is waiting, by name, not left
/// in silence; and `gunnar_transfers_idle_closed` counts it.
///
/// A box with ONE transfer slot: a `git-lfs-transfer` session opened and then silent
/// holds it; a `git ls-remote` of the same key waits — told so at once — and is served
/// once the silent session is closed for idleness, well inside the keyed wait.
#[test]
fn a_silent_lfs_transfer_gives_its_slot_back_and_the_waiting_client_is_told() {
    tools();
    let dir = tempfile::tempdir().expect("a temp dir");
    let home = dir.path().join("home");
    let keys = home.join("keys");
    std::fs::create_dir_all(&keys).unwrap();
    let (key, public) = mint(&keys, "alice");
    let metrics = format!("127.0.0.1:{}", free_port());
    let log = dir.path().join("server.log");
    let serving = Serving::start_with(
        &dir.path().join("data"),
        &home,
        &log,
        &[
            "--max-concurrent-transfers",
            "1",
            "--transfer-idle-secs",
            "3",
            "--metrics-bind",
            &metrics,
        ],
    );
    assert!(
        gunnar(
            &home,
            &[
                "control",
                "bootstrap",
                "alice",
                "--key",
                &format!("@{}", public.display()),
                "--at",
                &serving.grpc
            ]
        )
        .ok
    );
    let made = gunnar(
        &home,
        &[
            "control",
            "repo",
            "create",
            "alice/assets",
            "--hash",
            "sha1",
            "-i",
            key.to_str().unwrap(),
            "--at",
            &serving.grpc,
        ],
    );
    assert!(made.ok, "{}", made.out);
    let (host, port) = serving.ssh.rsplit_once(':').expect("host:port");

    // The silent session: git-lfs-transfer opened, its stdin held open, nothing sent.
    let held_err = dir.path().join("held.err");
    let started = Instant::now();
    let mut held = Command::new("ssh")
        .args([
            "-o",
            "BatchMode=yes",
            "-o",
            "StrictHostKeyChecking=no",
            "-o",
            "UserKnownHostsFile=/dev/null",
            "-o",
            "LogLevel=ERROR",
            "-o",
            "IdentitiesOnly=yes",
            "-i",
            key.to_str().unwrap(),
            "-p",
            port,
            &format!("git@{host}"),
            "git-lfs-transfer alice/assets.git download",
        ])
        .env("HOME", &home)
        .env("SSH_AUTH_SOCK", "")
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(std::fs::File::create(&held_err).unwrap())
        .spawn()
        .expect("spawn ssh");
    std::thread::sleep(Duration::from_millis(800));

    // The waiting client: same key, the one slot is held.
    let url = format!("ssh://git@{}/alice/assets.git", serving.ssh);
    let work = dir.path().join("work");
    std::fs::create_dir_all(&work).unwrap();
    let (listed, said) = git(
        &home,
        &key,
        &work,
        &["ls-remote", &url],
        Duration::from_secs(25),
    );
    assert!(
        listed,
        "the waiting ls-remote was not served after the silent session was closed:\n{said}"
    );
    assert!(
        said.contains("waiting for a transfer slot"),
        "the waiting client was told nothing (a silent hang):\n{said}"
    );

    // The silent session was closed by the server, and told why.
    let deadline = Instant::now() + Duration::from_secs(15);
    let status = loop {
        if let Some(status) = held.try_wait().unwrap() {
            break Some(status);
        }
        if Instant::now() > deadline {
            break None;
        }
        std::thread::sleep(Duration::from_millis(100));
    };
    let _ = held.kill();
    let _ = held.wait();
    let told = std::fs::read_to_string(&held_err).unwrap_or_default();
    assert!(
        status.is_some(),
        "the silent git-lfs-transfer session was never closed: it holds its slot for good"
    );
    assert!(
        started.elapsed() < Duration::from_secs(20),
        "closed only after {:?}",
        started.elapsed()
    );
    assert!(
        told.contains("no bytes moved"),
        "the closed session was not told why:\n{told}"
    );
    let body = scrape(&metrics);
    assert!(
        body.lines().any(|l| l == "gunnar_transfers_idle_closed 1"),
        "the idle close is not counted:\n{body}"
    );
    let server_log = std::fs::read_to_string(&log).unwrap_or_default();
    assert!(
        server_log.contains("git.transfer.idle_closed"),
        "the server log does not name the idle close"
    );
}