Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: bound forge API calls so a stalled forge can't wedge the swarm event loop

forgejo-api 0.11.1 takes no caller-built client, so a request to a forge that
accepts the connection and never answers waited forever. Every forge::api()
call site now goes through forge::bounded, a 10s timeout that errors naming the
operation. The PR-comment calls run inline in swarm_status::drain_swarm_events,
the one task serving both swarm subscriptions, so a stalled forge stopped both.

Refs #5066
This commit is contained in:
atlas 2026-10-09 15:30:57 +02:00 • committed by mara
commit 2b0154be28
5 changed files with 111 additions and 41 deletions

View file

@ -20,6 +20,7 @@ pub use repos::{
};
pub use users::core_token;
use std::future::IntoFuture;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
@ -195,6 +196,25 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
}
/// Upper bound on one Forgejo API request. `forgejo-api` takes no
/// caller-built client, so the request itself has no timeout; every call
/// through [`api`] is wrapped in [`bounded`].
const API_TIMEOUT: Duration = Duration::from_secs(10);
/// Await one Forgejo API request for at most [`API_TIMEOUT`].
///
/// The outer `Err` is the deadline, naming `op`; the inner `Result` is the
/// request's own outcome, left to the caller so it can still match on the
/// error (`409 already exists`, `401`, ...).
async fn bounded<F, T, E>(op: &str, request: F) -> Result<std::result::Result<T, E>>
where
F: IntoFuture<Output = std::result::Result<T, E>>,
{
tokio::time::timeout(API_TIMEOUT, request)
.await
.map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}"))
}
/// Per-agent forge sync: grant read access to `core/meta`, and wire the
/// `meta` remote in its proposed repo. All operations are idempotent;
/// failures are logged as warnings but don't abort the caller.
@ -271,8 +291,6 @@ async fn ensure_all_orgs_and_repos(token: &str) {
/// matching by path: the same path on another base is another hive's hook.
/// Idempotent, and a no-op when `HYPERHIVE_HIVE_DOMAIN` is unset.
async fn remove_config_pr_webhook(core_token: &str) -> Result<()> {
const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
let Some(domain) = std::env::var("HYPERHIVE_HIVE_DOMAIN")
.ok()
.filter(|v| !v.is_empty())
@ -281,17 +299,19 @@ async fn remove_config_pr_webhook(core_token: &str) -> Result<()> {
};
let own_url = config_pr_hook_url(&domain);
let client = api(core_token)?;
let hooks = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send())
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| format!("list webhooks for org {CONFIG_ORG}"))?;
let hooks = bounded(
&format!("list webhooks for org {CONFIG_ORG}"),
client.org_list_hooks(CONFIG_ORG).send(),
)
.await?
.with_context(|| format!("list webhooks for org {CONFIG_ORG}"))?;
for id in hook_ids_at(&hooks, &own_url) {
tokio::time::timeout(HTTP_TIMEOUT, client.org_delete_hook(CONFIG_ORG, id).send())
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| format!("delete webhook {id} on org {CONFIG_ORG}"))?;
bounded(
&format!("delete webhook {id} on org {CONFIG_ORG}"),
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await?
.with_context(|| format!("delete webhook {id} on org {CONFIG_ORG}"))?;
tracing::info!(%own_url, id, "forge: removed this hive's config-pr webhook");
}
Ok(())
@ -423,13 +443,44 @@ pub async fn ensure_all() {
#[cfg(test)]
mod tests {
use super::{config_pr_hook_url, describe_forge_admin, git_url_with_base, hook_ids_at};
use super::{
API_TIMEOUT, bounded, config_pr_hook_url, describe_forge_admin, git_url_with_base,
hook_ids_at,
};
fn hook(id: i64, url: &str) -> forgejo_api::structs::Hook {
serde_json::from_value(serde_json::json!({ "id": id, "config": { "url": url } }))
.expect("hook json")
}
/// A forge that never answers surfaces as an error naming the operation,
/// at exactly [`API_TIMEOUT`] — not as a hang.
#[tokio::test(start_paused = true)]
async fn a_request_the_forge_never_answers_errors_at_the_deadline() {
let started = tokio::time::Instant::now();
let err = bounded(
"post PR comment",
std::future::pending::<Result<(), forgejo_api::ForgejoError>>(),
)
.await
.expect_err("a pending request must hit the deadline");
assert_eq!(started.elapsed(), API_TIMEOUT);
assert!(err.to_string().contains("post PR comment"), "{err}");
}
/// The request's own result passes through for the caller to classify.
#[tokio::test(start_paused = true)]
async fn an_answered_request_keeps_its_own_result() {
let ok = bounded("op", async { Ok::<_, ()>(7) })
.await
.expect("in time");
assert_eq!(ok, Ok(7));
let failed = bounded("op", async { Err::<(), _>("409") })
.await
.expect("in time");
assert_eq!(failed, Err("409"));
}
/// The same path on another base is another hive's hook, and removing it
/// would cut that hive off. Only an exact URL match is this hive's.
#[test]