diff --git a/docs/contents.md b/docs/contents.md index a2f01f7a..ee94c1e4 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -59,3 +59,5 @@ the-road-to-wireframe-1-0-feature-set-philosophy-and-capability-maturity.md Strategies for taming code complexity and refactoring. - [Documentation style guide](documentation-style-guide.md) Conventions for writing project documentation. +- [Server configuration](server/configuration.md) Tuning accept loop backoff + behaviour and builder options. diff --git a/docs/server/configuration.md b/docs/server/configuration.md new file mode 100644 index 00000000..c93582b2 --- /dev/null +++ b/docs/server/configuration.md @@ -0,0 +1,34 @@ +# Server configuration + +`WireframeServer` provides a builder API for adjusting runtime behaviour. This +guide focuses on tuning the exponential backoff used when accepting connections +fails. + +## Accept loop backoff + +The accept loop retries failed `accept()` calls using exponential backoff. +`accept_backoff(initial_delay, max_delay)` sets both bounds in one call. These +values are stored in `BackoffConfig`: + +- `initial_delay` – starting delay for the first retry, clamped to at least 1 + millisecond. +- `max_delay` – maximum delay for retries, never less than `initial_delay`. + +### Behaviour + +- If `initial_delay` exceeds `max_delay`, the values are swapped. +- `max_delay` is raised to match `initial_delay` when required. + +### Example + +```rust +use std::time::Duration; + +use wireframe::{app::WireframeApp, server::WireframeServer}; + +let server = WireframeServer::new(|| WireframeApp::default()) + .accept_backoff(Duration::from_millis(5), Duration::from_millis(500)); +``` + +`accept_initial_delay` and `accept_max_delay` allow adjusting each parameter +individually. diff --git a/src/server/config/mod.rs b/src/server/config/mod.rs index 954f50a8..39674a0f 100644 --- a/src/server/config/mod.rs +++ b/src/server/config/mod.rs @@ -10,6 +10,7 @@ use std::{ io, net::{SocketAddr, TcpListener as StdTcpListener}, sync::Arc, + time::Duration, }; use bincode::error::DecodeError; @@ -50,6 +51,7 @@ where on_preamble_failure: None, ready_tx: None, listener: None, + backoff_config: super::runtime::BackoffConfig::default(), _preamble: PhantomData, } } @@ -90,6 +92,7 @@ where on_preamble_failure: None, ready_tx: None, listener: self.listener, + backoff_config: self.backoff_config, _preamble: PhantomData, } } @@ -258,4 +261,78 @@ where self.listener = Some(Arc::new(tokio)); Ok(self) } + + /// Configure the exponential backoff parameters for accept loop retries. + /// + /// # Behaviour + /// - If `initial_delay > max_delay`, the values are swapped. + /// - `initial_delay` is clamped to at least 1 millisecond. + /// - `max_delay` is raised to be at least `initial_delay` to preserve invariants. + /// + /// # Examples + /// + /// ``` + /// use std::time::Duration; + /// + /// use wireframe::{app::WireframeApp, server::WireframeServer}; + /// + /// let server = WireframeServer::new(|| WireframeApp::default()) + /// .accept_backoff(Duration::from_millis(5), Duration::from_millis(500)); + /// ``` + #[must_use] + pub fn accept_backoff(mut self, initial_delay: Duration, max_delay: Duration) -> Self { + let (mut a, mut b) = (initial_delay, max_delay); + if a > b { + core::mem::swap(&mut a, &mut b); + } + let init = a.max(Duration::from_millis(1)); + let maxd = b.max(init); + + self.backoff_config.initial_delay = init; + self.backoff_config.max_delay = maxd; + self + } + + /// Configure the initial delay for accept loop retries. + /// + /// # Examples + /// + /// ``` + /// use std::time::Duration; + /// + /// use wireframe::{app::WireframeApp, server::WireframeServer}; + /// + /// let server = WireframeServer::new(|| WireframeApp::default()) + /// .accept_initial_delay(Duration::from_millis(5)); + /// ``` + #[must_use] + pub fn accept_initial_delay(mut self, delay: Duration) -> Self { + self.backoff_config.initial_delay = delay.max(Duration::from_millis(1)); + if self.backoff_config.initial_delay > self.backoff_config.max_delay { + self.backoff_config.max_delay = self.backoff_config.initial_delay; + } + self + } + + /// Configure the maximum delay cap for accept loop retries. + /// + /// # Examples + /// + /// ``` + /// use std::time::Duration; + /// + /// use wireframe::{app::WireframeApp, server::WireframeServer}; + /// + /// let server = WireframeServer::new(|| WireframeApp::default()) + /// .accept_max_delay(Duration::from_millis(500)); + /// ``` + #[must_use] + pub fn accept_max_delay(mut self, delay: Duration) -> Self { + if delay < self.backoff_config.initial_delay { + self.backoff_config.max_delay = self.backoff_config.initial_delay; + } else { + self.backoff_config.max_delay = delay; + } + self + } } diff --git a/src/server/config/tests.rs b/src/server/config/tests.rs index 93eaa978..c1aa7eb4 100644 --- a/src/server/config/tests.rs +++ b/src/server/config/tests.rs @@ -11,6 +11,7 @@ use std::{ Arc, atomic::{AtomicUsize, Ordering}, }, + time::Duration, }; use rstest::rstest; @@ -196,3 +197,133 @@ async fn test_bind_to_multiple_addresses( assert_ne!(first.port(), second.port()); assert_eq!(second.ip(), addr2.ip()); } + +#[rstest] +fn test_accept_backoff_configuration( + factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static, +) { + let initial = Duration::from_millis(5); + let max = Duration::from_millis(500); + let server = WireframeServer::new(factory).accept_backoff(initial, max); + assert_eq!(server.backoff_config.initial_delay, initial); + assert_eq!(server.backoff_config.max_delay, max); +} + +/// Behaviour test verifying exponential delay doubling and capping. +#[test] +fn test_accept_exponential_backoff_doubles_and_caps() { + use std::{ + thread, + time::{Duration, Instant}, + }; + + let initial = Duration::from_millis(10); + let max = Duration::from_millis(80); + let mut backoff = initial; + let mut delays = Vec::new(); + let attempts = 5; + + let start = Instant::now(); + let mut last = start; + + for _i in 0..attempts { + thread::sleep(backoff); + let now = Instant::now(); + let elapsed = now.duration_since(last); + delays.push(elapsed); + last = now; + + backoff = std::cmp::min(backoff * 2, max); + } + + let expected_delays = [ + initial, + std::cmp::min(initial * 2, max), + std::cmp::min(initial * 4, max), + std::cmp::min(initial * 8, max), + max, + ]; + + for (i, (actual, expected)) in delays.iter().zip(expected_delays.iter()).enumerate() { + assert!( + *actual >= *expected, + "Delay {i} was {actual:?}, expected at least {expected:?}" + ); + let max_expected = *expected + Duration::from_millis(20); + assert!( + *actual < max_expected, + "Delay {i} was {actual:?}, expected less than {max_expected:?}" + ); + } +} + +#[rstest] +fn test_accept_initial_delay_configuration( + factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static, +) { + let delay = Duration::from_millis(20); + let server = WireframeServer::new(factory).accept_initial_delay(delay); + assert_eq!(server.backoff_config.initial_delay, delay); +} + +#[rstest] +fn test_accept_max_delay_configuration( + factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static, +) { + let delay = Duration::from_millis(2000); + let server = WireframeServer::new(factory).accept_max_delay(delay); + assert_eq!(server.backoff_config.max_delay, delay); +} + +#[rstest] +fn test_backoff_validation(factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static) { + let server = WireframeServer::new(factory.clone()).accept_initial_delay(Duration::ZERO); + assert_eq!( + server.backoff_config.initial_delay, + Duration::from_millis(1) + ); + + let server = WireframeServer::new(factory) + .accept_initial_delay(Duration::from_millis(100)) + .accept_max_delay(Duration::from_millis(50)); + assert_eq!(server.backoff_config.max_delay, Duration::from_millis(100)); +} + +#[rstest] +fn test_backoff_default_values(factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static) { + let server = WireframeServer::new(factory); + assert_eq!( + server.backoff_config.initial_delay, + Duration::from_millis(10) + ); + assert_eq!(server.backoff_config.max_delay, Duration::from_secs(1)); +} + +#[rstest] +fn test_initial_delay_exceeds_default_max( + factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static, +) { + let server = WireframeServer::new(factory).accept_initial_delay(Duration::from_secs(2)); + assert_eq!(server.backoff_config.initial_delay, Duration::from_secs(2)); + assert_eq!(server.backoff_config.max_delay, Duration::from_secs(2)); +} + +#[rstest] +fn test_accept_backoff_parameter_swapping( + factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static, +) { + let server = WireframeServer::new(factory.clone()) + .accept_backoff(Duration::from_millis(5), Duration::from_millis(1)); + assert_eq!( + server.backoff_config.initial_delay, + Duration::from_millis(1) + ); + assert_eq!(server.backoff_config.max_delay, Duration::from_millis(5)); + + let server = WireframeServer::new(factory).accept_backoff(Duration::ZERO, Duration::ZERO); + assert_eq!( + server.backoff_config.initial_delay, + Duration::from_millis(1) + ); + assert_eq!(server.backoff_config.max_delay, Duration::from_millis(1)); +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 57b9c4ab..ee4f0678 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -58,6 +58,9 @@ where /// provided each time the server is started. pub(crate) ready_tx: Option>, pub(crate) listener: Option>, + /// Configuration for exponential backoff when `accept()` fails. + /// Defaults to 10ms initial delay with 1s maximum. + pub(crate) backoff_config: runtime::BackoffConfig, pub(crate) _preamble: PhantomData, } @@ -65,5 +68,8 @@ mod config; mod connection; mod runtime; +/// Re-exported configuration types for server backoff behavior. +pub use runtime::BackoffConfig; + #[cfg(test)] pub(crate) mod test_util; diff --git a/src/server/runtime.rs b/src/server/runtime.rs index 7ae3a02f..f5345174 100644 --- a/src/server/runtime.rs +++ b/src/server/runtime.rs @@ -19,8 +19,35 @@ use super::{ }; use crate::{app::WireframeApp, preamble::Preamble}; -const ACCEPT_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(10); -const ACCEPT_RETRY_MAX_DELAY: Duration = Duration::from_secs(1); +/// +/// +/// +/// Configuration for exponential backoff timing in the accept loop. +/// +/// Controls retry behavior when `accept()` calls fail on the server's TCP listener. +/// The backoff starts at `initial_delay` and doubles on each failure, capped at `max_delay`. +/// +/// # Default Values +/// - `initial_delay`: 10 milliseconds +/// - `max_delay`: 1 second +/// +/// # Invariants +/// - `initial_delay` must not exceed `max_delay` +/// - `initial_delay` must be at least 1 millisecond +#[derive(Clone, Copy, Debug)] +pub struct BackoffConfig { + pub initial_delay: Duration, + pub max_delay: Duration, +} + +impl Default for BackoffConfig { + fn default() -> Self { + Self { + initial_delay: Duration::from_millis(10), + max_delay: Duration::from_secs(1), + } + } +} impl WireframeServer where @@ -98,6 +125,7 @@ where on_preamble_failure, ready_tx, listener, + backoff_config, .. } = self; @@ -120,7 +148,13 @@ where let token = shutdown_token.clone(); let t = tracker.clone(); tracker.spawn(accept_loop( - listener, factory, on_success, on_failure, token, t, + listener, + factory, + on_success, + on_failure, + token, + t, + backoff_config, )); } @@ -142,11 +176,12 @@ pub(super) async fn accept_loop( on_failure: Option, shutdown: CancellationToken, tracker: TaskTracker, + backoff_config: BackoffConfig, ) where F: Fn() -> WireframeApp + Send + Sync + Clone + 'static, T: Preamble, { - let mut delay = ACCEPT_RETRY_INITIAL_DELAY; + let mut delay = backoff_config.initial_delay; loop { select! { biased; @@ -162,13 +197,13 @@ pub(super) async fn accept_loop( on_failure.clone(), &tracker, ); - delay = ACCEPT_RETRY_INITIAL_DELAY; + delay = backoff_config.initial_delay; } Err(e) => { let local_addr = listener.local_addr().ok(); tracing::warn!(error = ?e, ?local_addr, "accept error"); sleep(delay).await; - delay = (delay * 2).min(ACCEPT_RETRY_MAX_DELAY); + delay = (delay * 2).min(backoff_config.max_delay); } }, } @@ -271,6 +306,7 @@ mod tests { None, token.clone(), tracker.clone(), + BackoffConfig::default(), )); token.cancel();