Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-forge/src/deadline.rs
atlas ccc6993703 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.
2026-10-17 08:01:23 +02:00

73 lines
2.6 KiB
Rust

//! Process-wide deadline for one `hive-forge` invocation.
//!
//! `forgejo-api` builds its own `reqwest` client with no timeout and
//! takes no caller-supplied one, so a forge that accepts a connection
//! and then stalls would block every typed verb forever. The watchdog
//! bounds the whole command instead of each request.
use anyhow::{Result, bail};
use std::time::Duration;
/// Exit code when the deadline expires (`EX_TEMPFAIL`); distinct from the
/// generic failure code 1 and from `timeout(1)`'s 124.
pub const EXIT_CODE: u8 = 75;
/// Env var holding the deadline in whole seconds.
const ENV: &str = "HIVE_FORGE_DEADLINE";
/// Used when [`ENV`] is unset: five minutes above the largest per-request
/// budget in `client.rs`, so a slow-but-live download never trips it.
const DEFAULT: Duration = crate::client::DOWNLOAD_TIMEOUT.saturating_add(Duration::from_mins(5));
/// Parse the value of [`ENV`]: whole seconds, at least 1; `None` is the default.
fn parse(raw: Option<&str>) -> Result<Duration> {
let Some(raw) = raw else { return Ok(DEFAULT) };
match raw.trim().parse::<u64>() {
Ok(secs) if secs > 0 => Ok(Duration::from_secs(secs)),
_ => bail!("{ENV} must be a whole number of seconds, at least 1 (got {raw:?})"),
}
}
/// Start the watchdog thread. When the deadline passes it prints a
/// `hive-forge: FAILED:` line and exits with [`EXIT_CODE`].
///
/// The process dies wherever it is: a write verb may already have reached
/// the forge, so check before retrying.
pub fn start() -> Result<()> {
let deadline = parse(std::env::var(ENV).ok().as_deref())?;
std::thread::spawn(move || {
std::thread::sleep(deadline);
eprintln!(
"hive-forge: FAILED: no result after {}s ({ENV}); the forge may be stalled. \
A write may already have landed, so check before retrying",
deadline.as_secs()
);
std::process::exit(i32::from(EXIT_CODE));
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unset_uses_the_default() {
assert_eq!(parse(None).unwrap(), DEFAULT);
assert!(DEFAULT > crate::client::DOWNLOAD_TIMEOUT);
}
#[test]
fn whole_seconds_are_taken_literally() {
assert_eq!(parse(Some("2")).unwrap(), Duration::from_secs(2));
assert_eq!(parse(Some(" 90\n")).unwrap(), Duration::from_secs(90));
}
#[test]
fn zero_and_garbage_are_rejected() {
for bad in ["0", "", "abc", "-5", "1.5"] {
let err = parse(Some(bad)).unwrap_err().to_string();
assert!(err.contains(ENV), "{bad:?}: {err}");
}
}
}