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
71 changes: 71 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ serial_test = "3.2.0"
cucumber = "0.20.2"
metrics-util = "0.20.0"
tracing-test = "0.2.5"
mockall = "0.13.1"

[features]
default = ["metrics"]
Expand Down
2 changes: 1 addition & 1 deletion docs/mocking-network-outages-in-rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ for mocking might be higher-level components:
- **Accept Loop Simulation:** We could define a trait for the listener:

```rust
trait Listener {
trait AcceptListener {
async fn accept(&self) -> io::Result<(Box<dyn Stream>, SocketAddr)>;
}
```
Expand Down
174 changes: 162 additions & 12 deletions src/server/runtime.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! Runtime control for [`WireframeServer`].

use std::sync::Arc;
use std::{io, net::SocketAddr, sync::Arc};

use async_trait::async_trait;
use futures::Future;
use tokio::{
net::TcpListener,
net::{TcpListener, TcpStream},
select,
signal,
time::{Duration, sleep},
Expand All @@ -21,13 +22,30 @@ use super::{
};
use crate::{app::WireframeApp, preamble::Preamble};

/// Abstraction for sources of incoming connections consumed by the accept loop.
///
/// Implementations must be cancellation-safe: dropping a pending `accept()`
/// future must not leak resources.
#[async_trait]
#[cfg_attr(test, mockall::automock)]
pub(super) trait AcceptListener: Send + Sync {
async fn accept(&self) -> io::Result<(TcpStream, SocketAddr)>;
fn local_addr(&self) -> io::Result<SocketAddr>;
}

#[async_trait]
impl AcceptListener for TcpListener {
async fn accept(&self) -> io::Result<(TcpStream, SocketAddr)> {
TcpListener::accept(self).await
}

fn local_addr(&self) -> io::Result<SocketAddr> { TcpListener::local_addr(self) }
}

/// Configuration for exponential back-off timing in the accept loop.
///
///
/// 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`.
/// Controls retry behaviour when `accept()` calls fail on the server's TCP listener.
/// The back-off starts at `initial_delay` and doubles on each failure, capped at `max_delay`.
///
/// # Default Values
/// - `initial_delay`: 10 milliseconds
Expand Down Expand Up @@ -168,8 +186,56 @@ where
}
}

pub(super) async fn accept_loop<F, T>(
listener: Arc<TcpListener>,
/// Accepts incoming connections and spawns handler tasks.
///
/// The loop accepts connections from `listener`, creates a new
/// [`WireframeApp`] via `factory` for each one, and spawns a task to handle
/// the connection. Failures to accept a connection trigger an exponential
/// back-off governed by `backoff_config`. The loop terminates when `shutdown`
/// is cancelled, and all spawned tasks are tracked by `tracker` for graceful
/// shutdown.
///
/// # Parameters
///
/// - `listener`: Source of incoming TCP connections.
/// - `factory`: Creates a fresh [`WireframeApp`] for each connection.
/// - `on_success`: Callback invoked after a successful preamble.
/// - `on_failure`: Callback invoked when the preamble fails.
/// - `shutdown`: Signal used to stop the accept loop.
/// - `tracker`: Task tracker used for graceful shutdown.
/// - `backoff_config`: Controls exponential back-off behaviour.
///
/// # Type Parameters
///
/// - `F`: Factory function that creates [`WireframeApp`] instances.
/// - `T`: Preamble type for connection handshaking.
/// - `L`: Listener type implementing [`AcceptListener`].
///
/// # Examples
///
/// ```ignore
/// use std::sync::Arc;
///
/// use tokio_util::{sync::CancellationToken, task::TaskTracker};
/// use wireframe::{app::WireframeApp /*, server::runtime::{AcceptListener, BackoffConfig, accept_loop} */};
///
/// async fn run<L: AcceptListener + Send + Sync + 'static>(listener: Arc<L>) {
/// let tracker = TaskTracker::new();
/// let token = CancellationToken::new();
/// accept_loop::<_, (), _>(
/// listener,
/// || WireframeApp::default(),
/// None,
/// None,
/// token,
/// tracker,
/// BackoffConfig::default(),
/// )
/// .await;
/// }
/// ```
pub(super) async fn accept_loop<F, T, L>(
listener: Arc<L>,
factory: F,
on_success: Option<PreambleHandler<T>>,
on_failure: Option<PreambleErrorHandler>,
Expand All @@ -179,7 +245,16 @@ pub(super) async fn accept_loop<F, T>(
) where
F: Fn() -> WireframeApp + Send + Sync + Clone + 'static,
T: Preamble,
L: AcceptListener + Send + Sync + 'static,
{
debug_assert!(
backoff_config.initial_delay >= Duration::from_millis(1),
"initial_delay must be at least 1ms",
);
debug_assert!(
backoff_config.initial_delay <= backoff_config.max_delay,
"initial_delay must not exceed max_delay",
);
let mut delay = backoff_config.initial_delay;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
loop {
select! {
Expand Down Expand Up @@ -213,16 +288,18 @@ pub(super) async fn accept_loop<F, T>(
mod tests {
use std::sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
};

use rstest::rstest;
use tokio::{
sync::oneshot,
time::{Duration, timeout},
task::yield_now,
time::{Duration, Instant, advance, timeout},
};

use super::*;
use super::{MockAcceptListener, *};
use crate::server::test_util::{bind_server, factory, free_listener};

#[rstest]
Expand Down Expand Up @@ -298,7 +375,7 @@ mod tests {
.expect("failed to bind test listener"),
);

tracker.spawn(accept_loop::<_, ()>(
tracker.spawn(accept_loop::<_, (), _>(
listener,
factory,
None,
Expand All @@ -314,4 +391,77 @@ mod tests {
let result = timeout(Duration::from_millis(100), tracker.wait()).await;
assert!(result.is_ok());
}

#[rstest]
#[tokio::test(start_paused = true)]
async fn test_accept_loop_exponential_backoff_async(
factory: impl Fn() -> WireframeApp + Send + Sync + Clone + 'static,
) {
let calls = Arc::new(Mutex::new(Vec::new()));
let mut listener = MockAcceptListener::new();
let call_log = calls.clone();
listener
.expect_accept()
.returning(move || {
let call_log = Arc::clone(&call_log);
Box::pin(async move {
call_log.lock().expect("lock").push(Instant::now());
Err(io::Error::other("mock error"))
})
})
.times(4);
listener
.expect_local_addr()
.returning(|| Ok("127.0.0.1:0".parse().expect("addr parse")))
.times(4);
let listener = Arc::new(listener);
let token = CancellationToken::new();
let tracker = TaskTracker::new();
let backoff = BackoffConfig {
initial_delay: Duration::from_millis(5),
max_delay: Duration::from_millis(20),
};

tracker.spawn(accept_loop::<_, (), _>(
listener,
factory,
None,
None,
token.clone(),
tracker.clone(),
backoff,
));

yield_now().await;

let first_call = {
let calls = calls.lock().expect("lock");
assert_eq!(calls.len(), 1);
calls[0]
};

for ms in [5, 10, 20] {
advance(Duration::from_millis(ms)).await;
yield_now().await;
}

token.cancel();
advance(Duration::from_millis(20)).await;
yield_now().await;
tracker.close();
tracker.wait().await;

let calls = calls.lock().expect("lock");
assert_eq!(calls.len(), 4);
assert_eq!(calls[0], first_call);
let intervals: Vec<_> = calls.windows(2).map(|w| w[1] - w[0]).collect();
let expected = [
Duration::from_millis(5),
Duration::from_millis(10),
Duration::from_millis(20),
];
for (interval, expected) in intervals.into_iter().zip(expected) {
assert_eq!(interval, expected);
}
}
}
Loading