use std::fmt;
use std::future::Future;
use std::time::Duration;
use tokio::time::Instant;
use crate::error::TransportError;
const FAR_FUTURE: Duration = Duration::from_secs(86_400 * 365 * 30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SetupStep {
TcpConnect,
TlsHandshake,
#[cfg(feature = "websocket")]
WebSocketUpgrade,
Login,
ExaHandshake,
}
impl fmt::Display for SetupStep {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
SetupStep::TcpConnect => "TCP connect",
SetupStep::TlsHandshake => "TLS handshake",
#[cfg(feature = "websocket")]
SetupStep::WebSocketUpgrade => "WebSocket upgrade",
SetupStep::Login => "login",
SetupStep::ExaHandshake => "EXA handshake",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SetupDeadline {
deadline: Instant,
budget: Duration,
label: &'static str,
}
impl SetupDeadline {
pub(crate) fn start(label: &'static str, budget: Duration) -> Self {
let now = Instant::now();
let deadline = now.checked_add(budget).unwrap_or_else(|| now + FAR_FUTURE);
Self {
deadline,
budget,
label,
}
}
pub(crate) async fn run<T>(
&self,
step: SetupStep,
future: impl Future<Output = Result<T, TransportError>>,
) -> Result<T, TransportError> {
tokio::time::timeout_at(self.deadline, future)
.await
.unwrap_or_else(|_| Err(self.timeout_error(step)))
}
pub(crate) fn has_elapsed(&self) -> bool {
Instant::now() >= self.deadline
}
fn timeout_error(&self, step: SetupStep) -> TransportError {
TransportError::IoError(format!(
"{} timeout after {}ms ({step})",
self.label,
self.budget.as_millis()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn never_finishes() -> impl Future<Output = Result<(), TransportError>> {
std::future::pending()
}
#[tokio::test(start_paused = true)]
async fn a_later_step_gets_only_the_time_left_by_earlier_steps() {
let started = Instant::now();
let deadline = SetupDeadline::start("Connection", Duration::from_millis(1000));
deadline
.run(SetupStep::TcpConnect, async {
tokio::time::sleep(Duration::from_millis(600)).await;
Ok(())
})
.await
.expect("a step that finishes in time returns its own result");
let error = deadline
.run(SetupStep::TlsHandshake, never_finishes())
.await
.expect_err("a step that never finishes must run out of time");
assert_eq!(started.elapsed(), Duration::from_millis(1000));
assert!(error.to_string().contains("(TLS handshake)"), "{error}");
}
#[tokio::test(start_paused = true)]
async fn an_elapsed_deadline_names_the_label_the_budget_and_the_step() {
let connection = SetupDeadline::start("Connection", Duration::from_millis(3000));
let tunnel = SetupDeadline::start("HTTP tunnel setup", Duration::from_millis(300));
let connection_error = connection
.run(SetupStep::TlsHandshake, never_finishes())
.await
.expect_err("the connection deadline must elapse");
let tunnel_error = tunnel
.run(SetupStep::ExaHandshake, never_finishes())
.await
.expect_err("the tunnel deadline must elapse");
assert_eq!(
connection_error.to_string(),
"Network I/O error: Connection timeout after 3000ms (TLS handshake)"
);
assert!(
tunnel_error
.to_string()
.contains("HTTP tunnel setup timeout after 300ms (EXA handshake)"),
"{tunnel_error}"
);
}
#[tokio::test(start_paused = true)]
async fn a_step_error_before_the_deadline_is_returned_unchanged() {
let deadline = SetupDeadline::start("Connection", Duration::from_millis(1000));
let error = deadline
.run(SetupStep::TlsHandshake, async {
Err::<(), _>(TransportError::TlsError("bad certificate".to_string()))
})
.await
.expect_err("the step's own error must reach the caller");
assert!(
matches!(&error, TransportError::TlsError(message) if message == "bad certificate"),
"{error:?}"
);
assert!(!deadline.has_elapsed());
}
#[tokio::test(start_paused = true)]
async fn a_budget_that_overflows_the_clock_does_not_panic() {
let deadline = SetupDeadline::start("HTTP tunnel setup", Duration::MAX);
let outcome = deadline
.run(SetupStep::TcpConnect, async { Ok(42) })
.await
.expect("a step that finishes at once returns its own result");
assert_eq!(outcome, 42);
}
}