Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-16 21:59:13 +02:00 • committed by mara
commit ccc6993703
7 changed files with 192 additions and 2 deletions

View file

@ -92,6 +92,8 @@ Forgejo CLI wrapper for hyperhive
**Usage:** `hive-forge [OPTIONS] <COMMAND>`
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 <show|create|edit|view|comment|comments|close|reopen|labels|assign|dependency|reaction|timeline> …`

View file

@ -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 <n>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

View file

@ -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

View file

@ -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.

View 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}");
}
}
}

View file

@ -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,

View 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}");
}