//! 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 { let Some(raw) = raw else { return Ok(DEFAULT) }; match raw.trim().parse::() { 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}"); } } }