//! `HIVE_FORGE_DEADLINE` against a forge that accepts a connection and //! never answers: a typed verb must exit with the deadline code instead of //! blocking forever. use std::net::TcpListener; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; /// Exit code `hive-forge` uses when the deadline expires. const DEADLINE_EXIT: i32 = 75; /// A unique scratch `HYPERHIVE_STATE_DIR` holding a `forge-token` file, /// same manual-tmpdir pattern as `pr_edit.rs`. fn scratch_state_dir(tag: &str) -> PathBuf { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_nanos()); let dir = std::env::temp_dir().join(format!("hive-forge-deadline-test-{tag}-{ts}")); std::fs::create_dir_all(&dir).expect("create scratch state dir"); std::fs::write(dir.join("forge-token"), "test-token\n").expect("seed forge-token"); dir } /// A forge that accepts connections and holds them open without reading or /// replying. Returns its base URL. fn stalled_forge() -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("bind stalled forge"); let addr = listener.local_addr().expect("stalled forge addr"); std::thread::spawn(move || { let mut held = Vec::new(); for stream in listener.incoming() { held.push(stream.expect("accept")); } }); format!("http://{addr}") } /// Run `hive-forge -r o/r issue show 1` against `url` with the given /// `HIVE_FORGE_DEADLINE`. Returns `(exit code, elapsed, stderr)`; fails the /// test, killing the child, if it is still running after `give_up`. fn run(tag: &str, url: &str, deadline: &str, give_up: Duration) -> (Option, Duration, String) { let start = Instant::now(); let mut child = Command::new(env!("CARGO_BIN_EXE_hive-forge")) .args(["-r", "o/r", "issue", "show", "1"]) .env("HIVE_FORGE_URL", url) .env("HIVE_FORGE_DEADLINE", deadline) .env("HYPERHIVE_STATE_DIR", scratch_state_dir(tag)) .env_remove("HIVE_FORGE_TOKEN_FILE") .env_remove("HIVE_LABEL") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .expect("spawn hive-forge"); let status = loop { if let Some(status) = child.try_wait().expect("poll hive-forge") { break status; } if start.elapsed() > give_up { child.kill().expect("kill hung hive-forge"); child.wait().expect("reap hung hive-forge"); panic!( "hive-forge still running after {give_up:?} with HIVE_FORGE_DEADLINE={deadline}" ); } std::thread::sleep(Duration::from_millis(50)); }; let elapsed = start.elapsed(); let mut stderr = String::new(); std::io::Read::read_to_string(&mut child.stderr.take().expect("piped stderr"), &mut stderr) .expect("read stderr"); (status.code(), elapsed, stderr) } #[test] fn stalled_forge_ends_in_the_deadline_exit() { let (code, elapsed, stderr) = run("stalled", &stalled_forge(), "2", Duration::from_secs(20)); assert_eq!(code, Some(DEADLINE_EXIT), "stderr:\n{stderr}"); assert!( elapsed >= Duration::from_secs(2), "fired before the deadline: {elapsed:?}" ); assert!(elapsed < Duration::from_secs(10), "fired late: {elapsed:?}"); assert!( stderr.starts_with("hive-forge: FAILED: no result after 2s (HIVE_FORGE_DEADLINE)"), "stderr:\n{stderr}" ); assert!(stderr.contains("write may already have landed"), "{stderr}"); } /// Control: an unusable deadline value is a plain failure at startup, not /// the deadline exit, so the exit code in the test above is the watchdog's. #[test] fn bad_deadline_value_fails_with_the_ordinary_code() { let (code, elapsed, stderr) = run("bad", &stalled_forge(), "soon", Duration::from_secs(20)); assert_eq!(code, Some(1), "stderr:\n{stderr}"); assert!(elapsed < Duration::from_secs(5), "{elapsed:?}"); assert!(stderr.contains("HIVE_FORGE_DEADLINE must be"), "{stderr}"); }