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