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:
parent
7214b4c22f
commit
2b0154be28
5 changed files with 111 additions and 41 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue