diff --git a/docs/tools/forge-cli.md b/docs/tools/forge-cli.md index 37f674de..9c588470 100644 --- a/docs/tools/forge-cli.md +++ b/docs/tools/forge-cli.md @@ -92,6 +92,8 @@ Forgejo CLI wrapper for hyperhive **Usage:** `hive-forge [OPTIONS] ` +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. + ###### **Subcommands:** * `issue` — Issue-scoped commands: `issue …` diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 8ad403cf..4970fe63 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -20,6 +20,12 @@ markdown-docs > docs/tools/forge-cli.md`. > the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO` (last-resort override, unset by default) > a hard error. `client::Client::from_env` assumes no single repo by default. +- Deadline: one invocation runs for at most `$HIVE_FORGE_DEADLINE` seconds + (default 900, five minutes above the largest per-request budget). Past + it, `hive-forge` prints `hive-forge: FAILED: no result after s …` and + exits with code 75, so a forge that accepts a connection and then stalls + can't block the caller forever. The command stops wherever it was: a write verb + may already have reached the forge, so check its result before retrying. ## Verbs diff --git a/hive-forge/README.md b/hive-forge/README.md index 91bf7c51..ccc44d44 100644 --- a/hive-forge/README.md +++ b/hive-forge/README.md @@ -12,7 +12,9 @@ Reads credentials from the environment (`HIVE_FORGE_URL`, swarm-UI-linked external forge account instead. The active repo resolves `-r/--repo` > the `origin` remote of the cwd's git checkout > `HIVE_FORGE_REPO` (last-resort override, unset by default) > a hard -error — see `client::Client::from_env`. +error — see `client::Client::from_env`. `HIVE_FORGE_DEADLINE` (seconds, +default 900) caps one invocation; past it the binary exits 75 (see +`src/deadline.rs`). ## When to use it diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index b694fade..60d60648 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -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. diff --git a/hive-forge/src/deadline.rs b/hive-forge/src/deadline.rs new file mode 100644 index 00000000..7fc6522a --- /dev/null +++ b/hive-forge/src/deadline.rs @@ -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 { + 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}"); + } + } +} diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 50d9af4b..ccc7d7bd 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -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::(&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, diff --git a/hive-forge/tests/deadline.rs b/hive-forge/tests/deadline.rs new file mode 100644 index 00000000..619a714e --- /dev/null +++ b/hive-forge/tests/deadline.rs @@ -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, 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}"); +}