Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175//! A git network call that a knot never answers must not wait forever, and//! the git it started must not outlive atgc.//!//! The fake remote accepts the connection and then says nothing, which is a//! knot that is up but unreachable behind a stalled socket. `git fetch`//! waits on it exactly as it would in production; `ATGC_GIT_TIMEOUT` is what//! turns that wait into a refusal, and a terminal interrupt must reach the//! git and ssh running inside it.
#![cfg(test)]#![cfg(unix)]
mod support;
use std::net::TcpListener;use std::sync::{Arc, Mutex};
use rustix::process::{Pid, Signal, kill_process_group};use support::{REPO_DID, Scenario};
/// Every process whose command line mentions `needle`.////// The timed-out git carries the fake remote's address in its argv, so after/// atgc has exited there should be nothing left that names it. Empty on a/// platform without `/proc`, where the check cannot be made.fn processes_mentioning(needle: &str) -> Vec<String> { let mut found = Vec::new(); let Ok(entries) = std::fs::read_dir("/proc") else { return found; }; let me = std::process::id().to_string(); for entry in entries.flatten() { if entry.file_name().to_string_lossy() == me { continue; } let Ok(bytes) = std::fs::read(entry.path().join("cmdline")) else { continue; }; if bytes.is_empty() { continue; } let cmdline = String::from_utf8_lossy(&bytes).replace('\0', " "); if cmdline.contains(needle) { found.push(cmdline); } } found}
/// A remote that accepts connections and then stalls.fn stalling_remote() -> (std::net::SocketAddr, Arc<Mutex<Vec<std::net::TcpStream>>>) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind a stalling remote"); let addr = listener.local_addr().expect("the listener's address"); let held: Arc<Mutex<Vec<std::net::TcpStream>>> = Arc::default(); let keep = Arc::clone(&held); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(stream) = stream else { break }; keep.lock().expect("the stalled connections").push(stream); } }); (addr, held)}
/// The deadline is short enough to test, the status is the retry code, and/// the git it killed is gone when atgc returns.#[test]fn a_stalled_git_network_call_is_killed_and_exits_unreachable() { let world = Scenario::new("git-timeout"); let (addr, _held) = stalling_remote();
// The repo still resolves from the DID in the path; only the transport // is replaced, so the fetch is the first thing that waits on it. world.checkout.git(&[ "remote", "set-url", "origin", &format!("http://{addr}/{REPO_DID}"), ]); // Without a local base ref, the failed fetch is the error `pr create` // returns instead of a warning it carries on from. world .checkout .git(&["update-ref", "-d", "refs/remotes/origin/main"]); world.checkout.branch("feature");
let run = world .command(&["pr", "create", "--title", "a pull", "--body", "why"]) .env("ATGC_GIT_TIMEOUT", "2") .finish(); run.refused_with(6, "git fetch to origin did not finish within 2s");
let needle = addr.to_string(); let left = processes_mentioning(&needle); assert!(left.is_empty(), "the timed-out git outlived atgc: {left:?}");}
/// Wait until the fetch has started both git and the ssh it runs, because a/// signal sent before then would say nothing about either.fn wait_for_git_and_ssh(needle: &str) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); loop { let found = processes_mentioning(needle); if found.iter().any(|line| line.contains("git")) && found.iter().any(|line| line.contains("ssh")) { return; } assert!( std::time::Instant::now() < deadline, "the fetch never started git and ssh: {found:?}" ); std::thread::sleep(std::time::Duration::from_millis(20)); }}
/// Wait for the signal to take effect, and say what is still there.fn wait_until_gone(needle: &str) -> Vec<String> { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { let left = processes_mentioning(needle); if left.is_empty() || std::time::Instant::now() >= deadline { return left; } std::thread::sleep(std::time::Duration::from_millis(20)); }}
/// Ctrl-C is sent to the foreground process group, and that group must still/// hold git and the ssh it spawned.////// They keep atgc's group on purpose: a separate group would let the deadline/// kill them together, but would also mean a terminal interrupt reached atgc/// alone and left a push or fetch running in the background.#[test]fn an_interrupt_reaches_the_git_and_ssh_it_started() { let world = Scenario::new("git-interrupt"); let (addr, _held) = stalling_remote();
// An ssh remote makes git spawn ssh, so the test covers the grandchild as // well as git itself. The fake server stalls before the SSH banner, which // is where the connection waits. world.checkout.git(&[ "remote", "set-url", "origin", &format!("ssh://{addr}/{REPO_DID}"), ]); world .checkout .git(&["update-ref", "-d", "refs/remotes/origin/main"]); world.checkout.branch("feature");
let waiting = world .command(&["pr", "create", "--title", "a pull", "--body", "why"]) // No deadline: this test is about the signal, not the clock. .env("ATGC_GIT_TIMEOUT", "0") .own_process_group() .start(); let pid = waiting.id(); let needle = addr.port().to_string(); wait_for_git_and_ssh(&needle);
kill_process_group( Pid::from_raw(pid as i32).expect("a process id"), Signal::INT, ) .expect("signal the group"); let run = waiting.finish(); assert_ne!(run.code, Some(0), "atgc was not interrupted");
let left = wait_until_gone(&needle); assert!(left.is_empty(), "git or ssh outlived atgc: {left:?}");}