From c6b1fd1ed5523293eca4768d6a24ab714357b0a7 Mon Sep 17 00:00:00 2001 From: HarpePluie Date: Mon, 9 Nov 2026 15:53:10 +0100 Subject: [PATCH] fix(tunnel): retry heartbeat status persistence Retry transient heartbeat publication failures with bounded backoff while preserving explicit failure after exhaustion. --- src/server_tunnels.rs | 70 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/server_tunnels.rs b/src/server_tunnels.rs index 49d7ef8c6..bd48370e2 100644 --- a/src/server_tunnels.rs +++ b/src/server_tunnels.rs @@ -20,6 +20,12 @@ use webcodex_environment::{ }; use webcodex_openai_tunnel::ControlPlaneProxy; +const PERSIST_STATUS_RETRY_DELAYS: [Duration; 3] = [ + Duration::from_millis(25), + Duration::from_millis(50), + Duration::from_millis(100), +]; + /// Only safe observations; no Tunnel IDs, proxy URLs, tokens, payloads or headers. #[derive(Clone, Debug, Serialize)] pub(crate) struct ProfileStatus { @@ -259,8 +265,9 @@ async fn run_profile( state.state = if reached_ready { TunnelState::Running } else { TunnelState::Starting }; state.tunnel_ready = ready; state.local_mcp_ready = local; }); - if persist_status(&profile, &status, index) - .is_err() + if persist_status_with_retry(&profile, &status, index) + .await + .is_err() { break Some("tunnel_health_unavailable".into()); } @@ -303,3 +310,62 @@ fn persist_status( state.diagnostic.as_deref(), ) } + +async fn persist_status_with_retry( + profile: &EmbeddedTunnelProfile, + status: &TunnelStatus, + index: usize, +) -> webcodex_environment::SetupResultValue<()> { + retry_persist_status(|| persist_status(profile, status, index)).await +} + +async fn retry_persist_status(mut persist: impl FnMut() -> Result<(), E>) -> Result<(), E> { + let mut retries = PERSIST_STATUS_RETRY_DELAYS.into_iter(); + loop { + match persist() { + Ok(()) => return Ok(()), + Err(error) => match retries.next() { + Some(delay) => tokio::time::sleep(delay).await, + None => return Err(error), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test] + async fn persist_status_retry_recovers_after_transient_failure() { + let attempts = AtomicUsize::new(0); + + let result = retry_persist_status(|| { + let attempt = attempts.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + Err("transient") + } else { + Ok(()) + } + }) + .await; + + assert_eq!(result, Ok(())); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn persist_status_retry_returns_persistent_failure_after_bounded_attempts() { + let attempts = AtomicUsize::new(0); + + let result = retry_persist_status(|| { + attempts.fetch_add(1, Ordering::SeqCst); + Err("persistent") + }) + .await; + + assert_eq!(result, Err("persistent")); + assert_eq!(attempts.load(Ordering::SeqCst), 4); + } +}