hive-forge: exit after HIVE_FORGE_DEADLINE instead of hanging on a stalled forge
forgejo-api 0.11.1's sync client builds its own reqwest client with no timeout and takes no caller-supplied client (sync.rs: new and with_user_agent are the only constructors), so a forge that accepts a connection and then stalls blocked every typed verb forever. A watchdog thread now starts before the client is built. After HIVE_FORGE_DEADLINE seconds (default 900, five minutes above DOWNLOAD_TIMEOUT) it prints a hive-forge: FAILED: line and exits 75. It bounds the whole command, not each request, and a write may already have landed when it fires; the help text and docs/tools/forge.md say so.
This commit is contained in:
parent
e7a46d9728
commit
ccc6993703
7 changed files with 192 additions and 2 deletions
100
hive-forge/tests/deadline.rs
Normal file
100
hive-forge/tests/deadline.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
//! `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<i32>, 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}");
|
||||
}
|
||||
Loading…
Reference in a new issue