//! 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 { 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>>) { 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>> = 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 { 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:?}"); }