Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/contents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions docs/server/configuration.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions src/server/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use std::{
io,
net::{SocketAddr, TcpListener as StdTcpListener},
sync::Arc,
time::Duration,
};

use bincode::error::DecodeError;
Expand Down Expand Up @@ -50,6 +51,7 @@ where
on_preamble_failure: None,
ready_tx: None,
listener: None,
backoff_config: super::runtime::BackoffConfig::default(),
_preamble: PhantomData,
Comment thread
leynos marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -90,6 +92,7 @@ where
on_preamble_failure: None,
ready_tx: None,
listener: self.listener,
backoff_config: self.backoff_config,
_preamble: PhantomData,
}
}
Expand Down Expand Up @@ -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
}
Comment thread
leynos marked this conversation as resolved.

/// 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
}
}
131 changes: 131 additions & 0 deletions src/server/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};

use rstest::rstest;
Expand Down Expand Up @@ -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);
}
Comment thread
leynos marked this conversation as resolved.

/// 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));
}
Comment thread
leynos marked this conversation as resolved.

#[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));
}
6 changes: 6 additions & 0 deletions src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,18 @@ where
/// provided each time the server is started.
pub(crate) ready_tx: Option<oneshot::Sender<()>>,
pub(crate) listener: Option<Arc<TcpListener>>,
/// 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<T>,
Comment thread
leynos marked this conversation as resolved.
}

mod config;
mod connection;
mod runtime;

/// Re-exported configuration types for server backoff behavior.
pub use runtime::BackoffConfig;

Comment thread
leynos marked this conversation as resolved.
#[cfg(test)]
pub(crate) mod test_util;
Loading
Loading