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
|
|
@ -31,7 +31,7 @@ const JSON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
|||
/// `reqwest::blocking` has no separate read/idle timeout, so this bounds
|
||||
/// the whole download rather than cutting it off partway through like
|
||||
/// [`JSON_TIMEOUT`] would.
|
||||
const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
|
||||
pub(crate) const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
|
||||
|
||||
/// Forgejo client pair: the typed `/api/v1` client plus a raw
|
||||
/// `reqwest` client for web-router-only routes.
|
||||
|
|
|
|||
73
hive-forge/src/deadline.rs
Normal file
73
hive-forge/src/deadline.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
//!
|
||||
//! `HIVE_FORGE_URL` — base URL, e.g. `http://localhost:3000`
|
||||
//! `HYPERHIVE_STATE_DIR` — state dir; `forge-token` lives here
|
||||
//! `HIVE_FORGE_DEADLINE` — optional; whole seconds one invocation may run
|
||||
//! before it exits with code 75 (default 900)
|
||||
//!
|
||||
//! The active repo resolves `-r/--repo` > the `origin` remote of the
|
||||
//! cwd's git checkout > `HIVE_FORGE_REPO` (last-resort override, unset
|
||||
|
|
@ -26,6 +28,7 @@
|
|||
|
||||
mod body;
|
||||
mod client;
|
||||
mod deadline;
|
||||
mod notify;
|
||||
mod verbs;
|
||||
|
||||
|
|
@ -37,6 +40,9 @@ use std::process::ExitCode;
|
|||
#[command(
|
||||
name = "hive-forge",
|
||||
about = "Forgejo CLI wrapper for hyperhive",
|
||||
after_help = "Deadline: hive-forge stops after HIVE_FORGE_DEADLINE seconds (default 900), \
|
||||
prints `hive-forge: FAILED: ...` and exits 75. A write may already have \
|
||||
reached the forge, so check before retrying.",
|
||||
disable_help_subcommand = true
|
||||
)]
|
||||
struct Cli {
|
||||
|
|
@ -235,6 +241,7 @@ fn run() -> Result<()> {
|
|||
print!("{}", clap_markdown::help_markdown_custom::<Cli>(&options));
|
||||
return Ok(());
|
||||
}
|
||||
deadline::start()?;
|
||||
let client = client::Client::from_env(cli.repo, cli.json, cli.forge)
|
||||
.context("initialize forge client")?;
|
||||
// Attach the resolved repo to every verb's error uniformly here,
|
||||
|
|
|
|||
Loading…
Reference in a new issue