diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 925b5ec5..94d6d4b6 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -1563,8 +1563,23 @@ impl Client { /// /// Returns the first failure. A listing failure is not fatal — it falls /// through to the create attempt, which is idempotent server-side by way - /// of the already-exists fold. + /// of the already-exists fold. A forge that does not answer within + /// [`FORGE_DEADLINE`] is a failure too. pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> { + within_forge_deadline( + "register swarm webhooks", + self.ensure_all_swarm_hooks(public_base, secret), + ) + .await + .unwrap_or_else(|| { + anyhow::bail!( + "the forge did not answer within {}s", + FORGE_DEADLINE.as_secs() + ) + }) + } + + async fn ensure_all_swarm_hooks(&self, public_base: &str, secret: &str) -> Result<()> { for kind in DeliveryKind::ALL { let target_url = kind.target_url(public_base); let (event, scope) = match kind { @@ -2078,6 +2093,28 @@ mod tests { assert_eq!(within_forge_deadline("test", async { 7 }).await, Some(7)); } + /// A forge that accepts the connection and never answers fails the + /// registration at the deadline, so the caller's retry advances. The + /// paused clock jumps to the deadline once nothing else can run. + #[tokio::test(start_paused = true)] + async fn swarm_webhook_registration_fails_at_the_deadline_when_the_forge_never_answers() { + // Bound but never accepted: the handshake completes, no response comes. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); + let client = Client::for_test_forge(url); + let start = tokio::time::Instant::now(); + // The outer bound turns a missing deadline into a failure, not a hang. + let err = tokio::time::timeout( + FORGE_DEADLINE * 2, + client.ensure_swarm_webhooks("https://swarm.example", "s3cret"), + ) + .await + .expect("registration returns by itself") + .expect_err("no answer is a failure"); + assert_eq!(start.elapsed(), FORGE_DEADLINE); + assert!(format!("{err:#}").contains("did not answer"), "{err:#}"); + } + #[tokio::test] async fn page_search_results_pages_past_a_full_first_page() { #[derive(Clone)]