diff --git a/hive-ag3nt/src/web_ui/mod.rs b/hive-ag3nt/src/web_ui/mod.rs index 63c25e26..1827e3da 100644 --- a/hive-ag3nt/src/web_ui/mod.rs +++ b/hive-ag3nt/src/web_ui/mod.rs @@ -192,12 +192,16 @@ fn bind_unix(path: &Path) -> Result { Ok(listener) } -/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on -/// `AddrInUse` indefinitely with exponential backoff capped at 2s. -/// First 12 attempts log at WARN; subsequent attempts log at INFO so -/// a long-held stale socket doesn't flood the journal. +/// Maximum bind attempts before `bind_with_retry` gives up on `AddrInUse`. +const MAX_BIND_ATTEMPTS: u32 = 12; + +/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on `AddrInUse` with +/// exponential backoff capped at 2s, up to [`MAX_BIND_ATTEMPTS`] attempts. If +/// the port is still held after the final attempt, returns the `AddrInUse` +/// error rather than looping forever (a genuine collision needs the operator, +/// not an unbounded wait). /// -/// Uncapped retry + dashboard-banner-on-real-collision rationale: +/// Retry rationale + dashboard-banner-on-real-collision: /// see [`docs/web-ui/shape.md::Listener bind`](../../../docs/web-ui/shape.md). async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result { let mut delay_ms = 250u64; @@ -215,17 +219,15 @@ async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result { let attempt = attempts + 1; - if attempt <= 12 { - tracing::warn!( - %addr, attempt, - "{label}: AddrInUse, retrying in {delay_ms}ms" - ); - } else { - tracing::info!( - %addr, attempt, - "{label}: AddrInUse still holding, retrying in {delay_ms}ms" - ); + if attempt >= MAX_BIND_ATTEMPTS { + return Err(e).with_context(|| { + format!("bind {label} on {addr}: still AddrInUse after {attempt} attempts") + }); } + tracing::warn!( + %addr, attempt, + "{label}: AddrInUse, retrying in {delay_ms}ms" + ); tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; attempts += 1; delay_ms = (delay_ms * 2).min(2000);