refactor(irtt-client): move cadence into managed runner

This commit is contained in:
Nils Andreas Svee committed 2026-10-02 00:31:17 +07:00
1 parent f350c82dae
commit f2fce2f330
31 files changed
+710 -4100

No files matched your search

+2 -2
View File
@@ -207,7 +207,7 @@ enum OutputRow {
client_send_wall: SystemTime,
bytes: usize,
send_call: Duration,
timer_error: Duration,
timer_error: Option<Duration>,
},
EchoReply(ReplyRow),
Loss {
@@ -950,7 +950,7 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
},
Column::TimerErrorUs => match row {
OutputRow::EchoSent { timer_error, .. } => {
Some(CellValue::Unsigned(duration_us(*timer_error)))
timer_error.map(|error| CellValue::Unsigned(duration_us(error)))
}
_ => None,
},
+4 -4
View File
@@ -170,11 +170,11 @@ mod tests {
collector.process(&ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2),
timer_error: Some(Duration::from_micros(2)),
});
collector.process(&ClientEvent::EchoReply {
seq: 1,
@@ -251,11 +251,11 @@ mod tests {
collector.process(&ClientEvent::EchoSent {
seq,
remote: test_remote(),
scheduled_at: sent_at.mono,
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 64,
send_call: Duration::from_micros(send_call_us),
timer_error: Duration::from_micros(timer_error_us),
timer_error: Some(Duration::from_micros(timer_error_us)),
});
collector.process(&ClientEvent::EchoReply {
seq,
+34 -21
View File
@@ -18,7 +18,7 @@
use std::time::{Duration, Instant};
use irtt_client::{Client, ClientConfig, ClientEvent};
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome};
const RUN_DURATION: Duration = Duration::from_secs(2);
@@ -45,32 +45,42 @@ fn main() {
}
};
match client.open() {
Ok(outcome) => println!("session opened: {outcome:?}"),
let negotiated = match client.open() {
Ok(OpenOutcome::Started {
event, negotiated, ..
}) => {
println!("session opened: {event:?}");
negotiated
}
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
println!("{event:?}");
return;
}
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
}
};
// The run itself lasts `duration`, but a lost probe isn't classified as
// `EchoLoss` until `probe_timeout()` after it was sent; wait at least
// that much past the run's own end so a loss near the end can still be
// observed and printed before this example gives up and closes.
let deadline = Instant::now() + RUN_DURATION + client.probe_timeout();
while Instant::now() < deadline && !client.is_run_complete() {
// send_probe is caller-paced: only call it once its own schedule
// says a probe is due, rather than as fast as the loop spins.
if client
.next_send_deadline()
.is_some_and(|send_at| Instant::now() >= send_at)
{
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry silently until the
// safety deadline instead of reporting it.
// This loop owns cadence and duration. The transport sends whenever called.
let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
let start = Instant::now();
let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
let deadline = end + client.probe_timeout();
let mut next_send = start;
while Instant::now() < deadline && !client.is_peer_closed() {
let now = Instant::now();
if now >= end && !client.has_pending_probes() {
break;
}
if now < end && now >= next_send {
match client.send_probe() {
Ok(events) => events.iter().for_each(print_event),
Ok(events) => {
events.iter().for_each(print_event);
while next_send <= Instant::now() {
next_send += interval;
}
}
Err(err) => {
eprintln!("send failed: {err}");
break;
@@ -93,6 +103,9 @@ fn main() {
}
}
if client.is_peer_closed() {
return;
}
match client.close() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
+37 -17
View File
@@ -18,7 +18,7 @@
use std::time::{Duration, Instant};
use irtt_client::{AsyncClient, ClientConfig, ClientEvent};
use irtt_client::{AsyncClient, ClientConfig, ClientEvent, OpenOutcome};
/// Upper bound on how long the receive wait can go without also calling
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
@@ -58,24 +58,43 @@ async fn run() {
}
};
match client.open().await {
Ok(outcome) => println!("session opened: {outcome:?}"),
let negotiated = match client.open().await {
Ok(OpenOutcome::Started {
event, negotiated, ..
}) => {
println!("session opened: {event:?}");
negotiated
}
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
println!("{event:?}");
return;
}
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
}
};
while !client.is_run_complete() {
// This loop owns cadence and duration. The transport sends whenever called.
let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
let start = Instant::now();
let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
let deadline = end + client.probe_timeout();
let mut next_send = start;
while Instant::now() < deadline && !client.is_peer_closed() {
if Instant::now() >= end && !client.has_pending_probes() {
break;
}
// Bound the receive wait by the next send/timeout deadline, rather
// than awaiting recv() unconditionally: a lost or rate-limited reply
// must not stall pacing or timeout classification indefinitely.
let wake_at = client
.next_send_deadline()
let wake_at = (next_send < end)
.then_some(next_send)
.into_iter()
.chain(client.next_probe_timeout_deadline())
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
.min()
.expect("the fixed poll-interval deadline is always present");
.unwrap();
let recv_result = tokio::select! {
events = client.recv() => Some(events),
@@ -91,16 +110,14 @@ async fn run() {
}
}
if client
.next_send_deadline()
.is_some_and(|send_at| Instant::now() >= send_at)
{
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry forever instead of
// reporting it.
if Instant::now() < end && Instant::now() >= next_send {
match client.send_probe().await {
Ok(events) => events.iter().for_each(print_event),
Ok(events) => {
events.iter().for_each(print_event);
while next_send <= Instant::now() {
next_send += interval;
}
}
Err(err) => {
eprintln!("send failed: {err}");
break;
@@ -116,6 +133,9 @@ async fn run() {
}
}
if client.is_peer_closed() {
return;
}
match client.close().await {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
+32 -340
View File
@@ -7,20 +7,10 @@ use std::{
time::{Duration, Instant},
};
#[cfg(test)]
use std::{
cell::{Cell, RefCell},
collections::VecDeque,
};
use tokio::time;
use crate::{
client::{
echo_sent_event,
schedule::{instant_abs_diff, ProbeSchedule},
validate_datagram_length,
},
client::{echo_sent_event, validate_datagram_length},
config::ClientConfig,
error::ClientError,
event::{ClientEvent, OpenOutcome},
@@ -54,13 +44,9 @@ const OPEN_POLL_WORK_BUDGET: usize = 64;
/// this crate already bounds open-attempt work keeps that impossible.
const RECV_POLL_WORK_BUDGET: usize = 64;
#[cfg(test)]
use crate::client::ProbeSendTimestamps;
#[derive(Debug)]
struct PreparedAsyncOpen {
machine: PreparedOpenAcceptance,
schedule: Option<ProbeSchedule>,
recv_buffer_len: Option<usize>,
negotiated_traffic_class: Option<u8>,
}
@@ -156,11 +142,6 @@ impl AsyncOpenState {
self.stopped_after_current_attempt
}
#[cfg(test)]
pub(crate) fn has_retained_cleanup(&self) -> bool {
self.cleanup.is_some()
}
fn deadline(&self) -> Instant {
self.deadline
.expect("an open attempt has an active deadline")
@@ -173,70 +154,6 @@ impl AsyncOpenState {
}
}
#[cfg(test)]
#[derive(Debug)]
enum InjectedSend {
WouldBlock,
Error,
ReportedLength(usize),
}
#[cfg(test)]
#[derive(Debug, Default)]
struct AsyncClientTestHooks {
sends: RefCell<VecDeque<InjectedSend>>,
send_attempts: Cell<usize>,
receive_would_block: Cell<usize>,
/// Number of subsequent receive attempts (`poll_open` or `poll_recv`)
/// that should report `io::ErrorKind::Interrupted` instead of performing
/// a real nonblocking receive, deterministically exercising interrupted-
/// syscall retry behavior.
inject_recv_interrupted: Cell<usize>,
fail_open_dscp: Cell<bool>,
fail_cleanup_send: Cell<bool>,
fail_peer_close_dscp: Cell<bool>,
fail_dscp_restore: Cell<bool>,
pause_open_cleanup_before_send: Cell<bool>,
pause_open_before_writable: Cell<bool>,
pause_open_before_readable: Cell<bool>,
pause_probe_before_writable: Cell<bool>,
pause_close_before_writable: Cell<bool>,
pause_close_after_would_block: Cell<bool>,
probe_timestamps: RefCell<VecDeque<ProbeSendTimestamps>>,
close_sent_at: Cell<Option<ClientTimestamp>>,
}
#[cfg(test)]
impl AsyncClientTestHooks {
fn try_send(&self, socket: &tokio::net::UdpSocket, packet: &[u8]) -> io::Result<usize> {
self.send_attempts.set(self.send_attempts.get() + 1);
if let Some(injected) = self.sends.borrow_mut().pop_front() {
return match injected {
InjectedSend::WouldBlock => Err(io::Error::from(io::ErrorKind::WouldBlock)),
InjectedSend::Error => Err(io::Error::other("injected send failure")),
InjectedSend::ReportedLength(bytes) => Ok(bytes),
};
}
socket.try_send(packet)
}
fn take_probe_timestamps(&self) -> Option<ProbeSendTimestamps> {
self.probe_timestamps.borrow_mut().pop_front()
}
/// Consumes one pending injected interruption, if any, reporting whether
/// the caller should report `io::ErrorKind::Interrupted` instead of
/// attempting a real receive.
fn take_injected_interrupted(&self) -> bool {
let remaining = self.inject_recv_interrupted.get();
if remaining == 0 {
return false;
}
self.inject_recv_interrupted.set(remaining - 1);
true
}
}
/// Low-level Tokio IRTT client for one connected UDP target.
///
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
@@ -271,7 +188,6 @@ impl AsyncClientTestHooks {
pub struct AsyncClient {
socket: tokio::net::UdpSocket,
machine: SessionMachine,
schedule: Option<ProbeSchedule>,
remote: SocketAddr,
recv_buffer: Vec<u8>,
applied_traffic_class: Option<u8>,
@@ -280,8 +196,6 @@ pub struct AsyncClient {
/// See `Client::tx_timestamping_enabled` in `client.rs` for the
/// invariant this tracks; it is the same adapter-owned capability flag.
tx_timestamping_enabled: bool,
#[cfg(test)]
test_hooks: AsyncClientTestHooks,
}
impl AsyncClient {
@@ -302,15 +216,12 @@ impl AsyncClient {
Ok(Self {
socket,
machine,
schedule: None,
remote,
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
applied_traffic_class: None,
prepared_open: Some(prepared_open),
prepared_probe: None,
tx_timestamping_enabled: false,
#[cfg(test)]
test_hooks: AsyncClientTestHooks::default(),
})
}
@@ -322,16 +233,16 @@ impl AsyncClient {
/// ignored without resending the request.
///
/// Dropping this future before trusted acceptance leaves the local machine
/// connected with no committed schedule or negotiated adapter state. If it
/// is dropped during best-effort post-token cleanup, cleanup may remain
/// incomplete because this low-level client never detaches cleanup work.
/// connected with no negotiated adapter state. If it is dropped during
/// best-effort post-token cleanup, cleanup may remain incomplete because
/// this low-level client never detaches cleanup work.
pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
let mut state = AsyncOpenState::new();
poll_fn(|cx| self.poll_open(&mut state, cx)).await
}
/// Send one caller-paced probe with transactional protocol and schedule
/// commits.
/// Send one echo probe now, committing protocol state only after acceptance.
/// The caller owns pacing and run duration.
///
/// A prepared packet is retained across readiness false positives,
/// cancellation, and socket errors so a later call retries the same logical
@@ -412,10 +323,6 @@ impl AsyncClient {
}
if !state.request_submitted {
#[cfg(test)]
if self.test_hooks.pause_open_before_writable.replace(false) {
return Poll::Pending;
}
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
@@ -430,9 +337,6 @@ impl AsyncClient {
.prepared_open
.as_ref()
.expect("connected clients retain their prepared open request");
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, &request.bytes);
#[cfg(not(test))]
let send_result = self.socket.try_send(&request.bytes);
match send_result {
Ok(bytes) => {
@@ -446,10 +350,6 @@ impl AsyncClient {
}
}
#[cfg(test)]
if self.test_hooks.pause_open_before_readable.replace(false) {
return Poll::Pending;
}
match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
@@ -463,10 +363,6 @@ impl AsyncClient {
Poll::Ready(Ok(true)) => {}
}
#[cfg(test)]
if self.test_hooks.take_injected_interrupted() {
continue;
}
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
Ok(datagram) => datagram,
// WouldBlock is a readiness false positive; Interrupted
@@ -514,7 +410,7 @@ impl AsyncClient {
continue;
}
};
let prepared = match self.prepare_async_open(machine, datagram.received_at) {
let prepared = match self.prepare_async_open(machine) {
Ok(prepared) => prepared,
Err(failure) => {
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
@@ -539,19 +435,11 @@ impl AsyncClient {
}
if self.prepared_probe.is_none() {
match self.machine.prepare_probe() {
Ok(prepared) => self.prepared_probe = prepared,
Ok(prepared) => self.prepared_probe = Some(prepared),
Err(error) => return Poll::Ready(Err(error)),
}
}
if self.prepared_probe.is_none() {
return Poll::Ready(Ok(Vec::new()));
}
loop {
#[cfg(test)]
if self.test_hooks.pause_probe_before_writable.replace(false) {
return Poll::Pending;
}
match self.socket.poll_send_ready(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {}
@@ -560,23 +448,6 @@ impl AsyncClient {
}
}
#[cfg(test)]
let test_timestamps = self.test_hooks.take_probe_timestamps();
#[cfg(not(test))]
let permission_at = Instant::now();
#[cfg(test)]
let permission_at = test_timestamps
.map(|timestamps| timestamps.permission_at)
.unwrap_or_else(Instant::now);
let schedule = self
.schedule
.as_mut()
.expect("open sessions always have a probe schedule");
if !schedule.permit_probe_at(permission_at) {
self.prepared_probe = None;
return Poll::Ready(Ok(Vec::new()));
}
let prepared = self
.prepared_probe
.as_ref()
@@ -585,11 +456,6 @@ impl AsyncClient {
Ok(preflight) => preflight,
Err(error) => return Poll::Ready(Err(error)),
};
let schedule_commit =
match schedule.preflight_caller_commit(machine_preflight.next_packets_sent) {
Ok(commit) => commit,
Err(error) => return Poll::Ready(Err(error)),
};
let mut events = Vec::new();
if let Err(source) = events.try_reserve(1) {
return Poll::Ready(Err(ClientError::AllocationFailed {
@@ -597,20 +463,14 @@ impl AsyncClient {
source,
}));
}
let expected_bytes = prepared.bytes.len();
let scheduled_at = schedule_commit.scheduled_at;
// Private pre-send anchor: finalizes all fallible commit work
// (timeout deadline arithmetic, kernel TX lower bound) before
// the send attempt. A WouldBlock retry samples a fresh one on
// the next loop iteration; only the successful attempt's anchor
// is retained (via `machine_commit`/`commit_probe_sent` below).
#[cfg(not(test))]
let send_anchor = ClientTimestamp::now();
#[cfg(test)]
let send_anchor = test_timestamps
.map(|timestamps| timestamps.send_anchor)
.unwrap_or_else(ClientTimestamp::now);
let machine_commit = match self
.machine
.finalize_probe_commit(machine_preflight, send_anchor)
@@ -618,20 +478,11 @@ impl AsyncClient {
Ok(commit) => commit,
Err(error) => return Poll::Ready(Err(error)),
};
#[cfg(not(test))]
let send_call_start = Instant::now();
#[cfg(test)]
let send_call_start = test_timestamps
.map(|timestamps| timestamps.send_call_start)
.unwrap_or_else(Instant::now);
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, &prepared.bytes);
#[cfg(not(test))]
let send_result = self.socket.try_send(&prepared.bytes);
let bytes = match send_result {
Ok(bytes) => bytes,
// No sent_at measurement, no machine commit, no schedule
// commit: the transaction is fully discarded and retried
// No measurement or machine commit: retry the transaction
// with a fresh send_anchor on the next loop iteration.
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
self.machine.invalidate_kernel_tx_correlation();
@@ -642,43 +493,25 @@ impl AsyncClient {
return Poll::Ready(Err(ClientError::Socket(error)));
}
};
#[cfg(not(test))]
let send_finished_at = Instant::now();
#[cfg(test)]
let send_finished_at = test_timestamps
.map(|timestamps| timestamps.send_finished_at)
.unwrap_or_else(Instant::now);
// Public post-send measurement, captured only after the
// successful send above. The subsequent machine/schedule commit
// successful send above. The subsequent machine commit
// is infallible.
#[cfg(not(test))]
let sent_at = ClientTimestamp::now();
#[cfg(test)]
let sent_at = test_timestamps
.map(|timestamps| timestamps.sent_at)
.unwrap_or_else(ClientTimestamp::now);
let sent = self
.machine
.commit_probe_sent(machine_commit, sent_at, bytes);
schedule.commit(schedule_commit);
self.prepared_probe = None;
let send_call = send_finished_at.saturating_duration_since(send_call_start);
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
return Poll::Ready(Err(error));
}
if let Err(error) = self.drain_tx_timestamps() {
return Poll::Ready(Err(error));
}
events.push(echo_sent_event(
self.remote,
sent,
send_call,
scheduled_at,
timer_error,
));
events.push(echo_sent_event(self.remote, sent, send_call));
return Poll::Ready(Ok(events));
}
}
@@ -699,17 +532,7 @@ impl AsyncClient {
return Poll::Ready(Err(ClientError::Socket(error)));
}
}
#[cfg(test)]
if self.test_hooks.receive_would_block.get() > 0 {
self.test_hooks
.receive_would_block
.set(self.test_hooks.receive_would_block.get() - 1);
continue;
}
#[cfg(test)]
if self.test_hooks.take_injected_interrupted() {
continue;
}
let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
Ok(datagram) => datagram,
// See `RECV_POLL_WORK_BUDGET`: both a readiness false
@@ -743,7 +566,6 @@ impl AsyncClient {
Err(error) => return Poll::Ready(Err(error)),
};
if self.machine.is_peer_closed() {
self.schedule = None;
self.prepared_probe = None;
if self.clear_peer_close_dscp().is_ok() {
self.applied_traffic_class = None;
@@ -778,10 +600,6 @@ impl AsyncClient {
let previous_traffic_class = self.applied_traffic_class;
let expected_bytes = prepared.bytes.len();
#[cfg(test)]
if self.test_hooks.pause_close_before_writable.replace(false) {
return Poll::Pending;
}
match self.socket.poll_send_ready(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {}
@@ -792,16 +610,8 @@ impl AsyncClient {
if let Err(error) = self.clear_close_dscp() {
return Poll::Ready(Err(error));
}
let mut rollback = DscpRollback::armed(
&self.socket,
self.remote,
previous_traffic_class,
#[cfg(test)]
&self.test_hooks.fail_dscp_restore,
);
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, prepared.bytes);
#[cfg(not(test))]
let mut rollback =
DscpRollback::armed(&self.socket, self.remote, previous_traffic_class);
let send_result = self.socket.try_send(prepared.bytes);
let bytes = match send_result {
Ok(bytes) => bytes,
@@ -809,28 +619,18 @@ impl AsyncClient {
if let Err(error) = rollback.restore() {
return Poll::Ready(Err(error));
}
#[cfg(test)]
if self.test_hooks.pause_close_after_would_block.replace(false) {
return Poll::Pending;
}
continue;
}
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
};
#[cfg(not(test))]
let close_sent_at = ClientTimestamp::now();
#[cfg(test)]
let close_sent_at = self
.test_hooks
.close_sent_at
.take()
.unwrap_or_else(ClientTimestamp::now);
let event = self
.machine
.commit_local_close(prepared.commit, close_sent_at);
rollback.disarm();
self.schedule = None;
self.prepared_probe = None;
self.applied_traffic_class = None;
@@ -842,33 +642,19 @@ impl AsyncClient {
}
}
/// Return the next scheduled probe deadline.
pub fn next_send_deadline(&self) -> Option<Instant> {
if !self.machine.is_open() {
return None;
}
self.schedule.as_ref()?.next_send_deadline()
}
pub(crate) fn probe_interval(&self) -> Option<Duration> {
if !self.machine.is_open() {
return None;
}
self.schedule.as_ref().map(ProbeSchedule::interval)
}
/// Return the configured local probe timeout.
pub fn probe_timeout(&self) -> Duration {
self.machine.probe_timeout()
}
/// Return whether the current run has completed.
pub fn is_run_complete(&self) -> bool {
self.machine.is_terminal()
|| self
.schedule
.as_ref()
.is_some_and(|schedule| schedule.is_finished() && self.machine.pending_is_empty())
/// Negotiated parameters of the currently open session.
pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
self.machine.negotiated_params()
}
/// Whether any sent probes still await a reply or timeout.
pub fn has_pending_probes(&self) -> bool {
!self.machine.pending_is_empty()
}
/// Return whether an authenticated peer close ended the session.
@@ -884,7 +670,8 @@ impl AsyncClient {
self.machine.packets_sent()
}
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
/// Earliest timeout deadline among probes still awaiting a reply.
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
self.machine.next_probe_timeout_deadline()
}
@@ -896,64 +683,17 @@ impl AsyncClient {
self.prepared_probe = None;
}
#[cfg(test)]
pub(crate) fn retain_open_cleanup_once(&self) {
self.test_hooks.fail_open_dscp.set(true);
self.test_hooks.pause_open_cleanup_before_send.set(true);
}
#[cfg(test)]
pub(crate) fn poll_recv_ready_for_test(&self, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.socket.poll_recv_ready(cx)
}
#[cfg(test)]
pub(crate) fn remove_pending_for_test(
&mut self,
wire_seq: u32,
) -> Option<crate::probe::PendingProbe> {
self.machine.remove_pending_for_test(wire_seq)
}
#[cfg(test)]
pub(crate) fn replace_pending_for_test(&mut self, probe: crate::probe::PendingProbe) {
self.machine.replace_pending_for_test(probe);
}
pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
let Some(schedule) = self.schedule.as_mut() else {
return Ok(());
};
let Some(deadline) = schedule.next_send_deadline() else {
return Ok(());
};
if deadline > now {
return Ok(());
}
let commit = schedule.preflight_managed_commit(deadline, now)?;
schedule.commit(commit);
Ok(())
}
fn prepare_async_open(
&mut self,
machine: PreparedOpenAcceptance,
opened_at: ClientTimestamp,
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
let Some(negotiated) = machine.normal_negotiated() else {
return Ok(PreparedAsyncOpen {
machine,
schedule: None,
recv_buffer_len: None,
negotiated_traffic_class: None,
});
};
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
Ok(schedule) => schedule,
Err(primary) => {
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
}
};
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
Ok(size) => size,
Err(primary) => {
@@ -981,17 +721,7 @@ impl AsyncClient {
machine,
}));
}
#[cfg(test)]
let dscp_result = if self.test_hooks.fail_open_dscp.replace(false) {
Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected negotiated DSCP failure"),
})
} else {
apply_traffic_class_to_tokio_socket(&self.socket, self.remote, negotiated_traffic_class)
};
#[cfg(not(test))]
let dscp_result = apply_traffic_class_to_tokio_socket(
&self.socket,
self.remote,
@@ -1004,7 +734,6 @@ impl AsyncClient {
Ok(PreparedAsyncOpen {
machine,
schedule: Some(schedule),
recv_buffer_len: Some(recv_buffer_len),
negotiated_traffic_class: Some(negotiated_traffic_class),
})
@@ -1015,7 +744,7 @@ impl AsyncClient {
self.recv_buffer.resize(recv_buffer_len, 0);
}
let outcome = self.machine.commit_open(prepared.machine);
self.schedule = prepared.schedule;
self.applied_traffic_class = prepared.negotiated_traffic_class;
self.prepared_open = None;
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
@@ -1048,23 +777,6 @@ impl AsyncClient {
else {
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
};
#[cfg(test)]
if self
.test_hooks
.pause_open_cleanup_before_send
.replace(false)
{
return Poll::Pending;
}
#[cfg(test)]
if self.test_hooks.fail_cleanup_send.replace(false) {
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
}
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, packet);
#[cfg(not(test))]
let send_result = self.socket.try_send(packet);
match send_result {
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
@@ -1087,14 +799,6 @@ impl AsyncClient {
}
fn clear_peer_close_dscp(&self) -> Result<(), ClientError> {
#[cfg(test)]
if self.test_hooks.fail_peer_close_dscp.replace(false) {
return Err(ClientError::SocketOption {
operation: "clear DSCP before close",
remote: self.remote,
source: io::Error::other("injected peer-close DSCP cleanup failure"),
});
}
clear_dscp_on_tokio_socket(&self.socket, self.remote)
}
@@ -1117,8 +821,6 @@ struct DscpRollback<'a> {
remote: SocketAddr,
previous_traffic_class: Option<u8>,
armed: bool,
#[cfg(test)]
fail_restore: &'a Cell<bool>,
}
impl<'a> DscpRollback<'a> {
@@ -1126,15 +828,12 @@ impl<'a> DscpRollback<'a> {
socket: &'a tokio::net::UdpSocket,
remote: SocketAddr,
previous_traffic_class: Option<u8>,
#[cfg(test)] fail_restore: &'a Cell<bool>,
) -> Self {
Self {
socket,
remote,
previous_traffic_class,
armed: true,
#[cfg(test)]
fail_restore,
}
}
@@ -1142,14 +841,7 @@ impl<'a> DscpRollback<'a> {
if !self.armed {
return Ok(());
}
#[cfg(test)]
if self.fail_restore.replace(false) {
return Err(ClientError::SocketOption {
operation: "restore negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected DSCP restore failure"),
});
}
match self.previous_traffic_class {
Some(traffic_class) => {
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
+67 -749
View File
@@ -5,7 +5,6 @@ use std::{
sync::mpsc,
task::{Context, Poll, Waker},
thread::{self, JoinHandle},
time::SystemTime,
};
use irtt_proto::{
@@ -18,10 +17,7 @@ use tokio::runtime::{Builder, Runtime};
mod in_tree_server;
use super::*;
use crate::{
probe::PendingProbe, socket_options::tokio_socket_traffic_class, Client, ClientTimestamp,
RunMode, SocketConfig,
};
use crate::{socket_options::tokio_socket_traffic_class, Client, RunMode, SocketConfig};
use in_tree_server::InTreeServer;
use irtt_server::ServerConfig;
@@ -72,10 +68,6 @@ async fn opened_client(addr: SocketAddr, dscp: u8) -> AsyncClient {
client
}
fn inject_send(client: &AsyncClient, result: InjectedSend) {
client.test_hooks.sends.borrow_mut().push_back(result);
}
fn socket_dscp(client: &AsyncClient) -> u32 {
tokio_socket_traffic_class(&client.socket, client.remote).unwrap() >> 2
}
@@ -208,15 +200,6 @@ fn echo_reply(
.unwrap()
}
fn start_open_server(key: Option<Vec<u8>>, packets_after_open: usize) -> TestServer {
start_server(move |socket, tx| {
open_session(&socket, &tx, key.as_deref());
for _ in 0..packets_after_open {
let _ = recv_packet(&socket, &tx);
}
})
}
fn start_no_test_server() -> TestServer {
start_server(move |socket, tx| {
let (open_packet, peer) = recv_packet(&socket, &tx);
@@ -285,124 +268,6 @@ fn recv_before_open_fails_on_first_poll_without_socket_readiness() {
});
}
#[test]
fn open_ignores_malformed_and_bad_hmac_without_resending() {
let key = b"open-filter-key".to_vec();
let server_key = key.clone();
let server = start_server(move |socket, tx| {
let (open_packet, peer) = recv_packet(&socket, &tx);
let (params, _) = open_request(&open_packet, Some(&server_key));
socket.send_to(&[0_u8], peer).unwrap();
let mut bad_hmac = encode_open_reply(
&OpenReply {
flags: flags::FLAG_OPEN | flags::FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
Some(&server_key),
)
.unwrap();
bad_hmac[4] ^= 0xff;
socket.send_to(&bad_hmac, peer).unwrap();
send_open_reply(
&socket,
peer,
params,
Some(&server_key),
flags::FLAG_OPEN | flags::FLAG_REPLY,
TOKEN,
);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, Some(key), 0))
.await
.unwrap();
inject_send(&client, InjectedSend::WouldBlock);
let attempts = client.test_hooks.send_attempts.get();
assert!(matches!(
client.open().await.unwrap(),
OpenOutcome::Started { .. }
));
assert_eq!(client.test_hooks.send_attempts.get(), attempts + 2);
assert!(client.prepared_open.is_none());
assert!(client.schedule.is_some());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
let server = start_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_packet(&socket, &tx);
for _ in 0..3 {
socket.send_to(&[0_u8], peer).unwrap();
}
}
});
let mut client_config = config(server.addr, None, 0);
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
runtime().block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
assert!(matches!(client.open().await, Err(ClientError::OpenTimeout)));
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn opening_poll_budget_yields_then_public_open_completes() {
let (queued, queue_ready) = mpsc::channel();
let server = start_server(move |socket, tx| {
let (open_packet, peer) = recv_packet(&socket, &tx);
let (params, _) = open_request(&open_packet, None);
for _ in 0..=OPEN_POLL_WORK_BUDGET {
socket.send_to(&[0_u8], peer).unwrap();
}
send_open_reply(
&socket,
peer,
params,
None,
flags::FLAG_OPEN | flags::FLAG_REPLY,
TOKEN,
);
queued.send(()).unwrap();
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.socket.writable().await.unwrap();
client.test_hooks.pause_open_before_readable.set(true);
let mut opening = Box::pin(client.open());
// Submit the one opening request, then let the server queue all noise
// before performing the explicit bounded poll below.
assert!(matches!(
poll_once(Pin::as_mut(&mut opening)),
Poll::Pending
));
queue_ready.recv_timeout(Duration::from_secs(2)).unwrap();
// The valid reply is behind more ignored datagrams than one opening
// poll may consume, so this cannot complete without the budget yield.
assert!(matches!(
poll_once(Pin::as_mut(&mut opening)),
Poll::Pending
));
assert!(matches!(
opening.await.unwrap(),
OpenOutcome::Started { .. }
));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() {
let server = start_server(move |socket, tx| {
@@ -485,478 +350,6 @@ fn authenticated_rejection_is_terminal_without_retry() {
assert_eq!(server.finish().len(), 1);
}
#[test]
fn opening_cancellation_before_submission_is_transactional() {
let server = start_server(move |socket, tx| {
socket
.set_read_timeout(Some(Duration::from_millis(50)))
.unwrap();
let mut buffer = [0_u8; 512];
if let Ok((len, _)) = socket.recv_from(&mut buffer) {
tx.send(buffer[..len].to_vec()).unwrap();
}
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.test_hooks.pause_open_before_writable.set(true);
let mut open = Box::pin(client.open());
assert!(matches!(poll_once(Pin::as_mut(&mut open)), Poll::Pending));
drop(open);
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.prepared_open.is_some());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
});
assert!(server.finish().is_empty());
}
#[test]
fn opening_cancellation_after_submission_can_retry() {
let server = start_server(move |socket, tx| {
let (first, _) = recv_packet(&socket, &tx);
let (first_params, _) = open_request(&first, None);
let (_, peer) = recv_packet(&socket, &tx);
send_open_reply(
&socket,
peer,
first_params,
None,
flags::FLAG_OPEN | flags::FLAG_REPLY,
TOKEN,
);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.socket.writable().await.unwrap();
client.test_hooks.pause_open_before_readable.set(true);
let mut open = Box::pin(client.open());
assert!(matches!(poll_once(Pin::as_mut(&mut open)), Poll::Pending));
drop(open);
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.prepared_open.is_some());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert!(matches!(
client.open().await.unwrap(),
OpenOutcome::Started { .. }
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn adapter_failure_sends_preencoded_cleanup_without_committing_open() {
let server = start_server(move |socket, tx| {
open_session(&socket, &tx, None);
let (close, _) = recv_packet(&socket, &tx);
assert_eq!(close_request_token(&close, None), TOKEN);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 46))
.await
.unwrap();
client.test_hooks.fail_open_dscp.set(true);
assert!(matches!(
client.open().await,
Err(ClientError::SocketOption { .. })
));
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn cleanup_failure_preserves_primary_open_error() {
let server = start_server(move |socket, tx| {
open_session(&socket, &tx, None);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 46))
.await
.unwrap();
client.test_hooks.fail_open_dscp.set(true);
client.test_hooks.fail_cleanup_send.set(true);
assert!(matches!(
client.open().await,
Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
..
})
));
assert!(client.machine.prepare_open_request().is_ok());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn no_test_open_has_no_schedule_or_dscp_state() {
let server = start_no_test_server();
runtime().block_on(async {
let mut client_config = config(server.addr, None, 46);
client_config.run_mode = RunMode::NoTest;
let mut client = AsyncClient::connect(client_config).await.unwrap();
assert!(matches!(
client.open().await.unwrap(),
OpenOutcome::NoTestCompleted { .. }
));
assert!(client.prepared_open.is_none());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert!(client.is_run_complete());
assert!(matches!(
client.close().await,
Err(ClientError::AlreadyCompleted)
));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn probe_cancellation_would_block_and_error_retain_one_preparation() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let deadline = client.next_send_deadline();
let attempts = client.test_hooks.send_attempts.get();
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
let retained = client.prepared_probe.as_ref().unwrap().bytes.clone();
let retained_ptr = client.prepared_probe.as_ref().unwrap().bytes.as_ptr();
assert_eq!(client.machine.packets_sent(), 0);
assert!(client.machine.pending_is_empty());
assert_eq!(client.next_send_deadline(), deadline);
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
client
.test_hooks
.sends
.borrow_mut()
.extend([InjectedSend::WouldBlock, InjectedSend::Error]);
assert!(matches!(
client.send_probe().await,
Err(ClientError::Socket(_))
));
assert_eq!(client.prepared_probe.as_ref().unwrap().bytes, retained);
assert_eq!(
client.prepared_probe.as_ref().unwrap().bytes.as_ptr(),
retained_ptr
);
assert_eq!(client.machine.packets_sent(), 0);
assert!(client.machine.pending_is_empty());
assert_eq!(client.next_send_deadline(), deadline);
assert!(matches!(
client.send_probe().await.unwrap().as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
assert_eq!(client.machine.packets_sent(), 1);
assert!(client.prepared_probe.is_none());
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn probe_false_readiness_resamples_all_timestamps() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let scheduled_at = client.next_send_deadline().unwrap();
let first_sent = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1),
mono: scheduled_at + Duration::from_millis(3),
};
let second_sent = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(2),
mono: scheduled_at + Duration::from_millis(11),
};
client.test_hooks.probe_timestamps.borrow_mut().extend([
ProbeSendTimestamps {
permission_at: scheduled_at + Duration::from_millis(1),
send_anchor: first_sent,
sent_at: first_sent,
send_call_start: scheduled_at + Duration::from_millis(4),
send_finished_at: scheduled_at + Duration::from_millis(6),
},
ProbeSendTimestamps {
permission_at: scheduled_at + Duration::from_millis(8),
send_anchor: second_sent,
sent_at: second_sent,
send_call_start: scheduled_at + Duration::from_millis(12),
send_finished_at: scheduled_at + Duration::from_millis(17),
},
]);
inject_send(&client, InjectedSend::WouldBlock);
let events = client.send_probe().await.unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoSent {
scheduled_at: event_scheduled,
sent_at,
send_call,
timer_error,
..
}] if *event_scheduled == scheduled_at
&& *sent_at == second_sent
&& *send_call == Duration::from_millis(5)
&& *timer_error == Duration::from_millis(11)
));
assert!(matches!(
client
.poll_timeouts_at(second_sent.mono + client.probe_timeout())
.unwrap()
.as_slice(),
[ClientEvent::EchoLoss { sent_at, .. }] if *sent_at == second_sent
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn public_timeout_polling_remains_exhaustive() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let now = Instant::now();
for seq in 0..3 {
client.replace_pending_for_test(PendingProbe {
wire_seq: seq,
sent_at: ClientTimestamp {
mono: now - Duration::from_secs(2),
wall: SystemTime::UNIX_EPOCH,
},
timeout_at: now - Duration::from_secs(1),
tx_not_before_wall: SystemTime::UNIX_EPOCH,
kernel_tx_timestamp: None,
});
}
assert!(matches!(
client.poll_timeouts_at(now).unwrap().as_slice(),
[
ClientEvent::EchoLoss { seq: 0, .. },
ClientEvent::EchoLoss { seq: 1, .. },
ClientEvent::EchoLoss { seq: 2, .. },
]
));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn successful_short_probe_commits_before_length_error() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
let probe_len = client.prepared_probe.as_ref().unwrap().bytes.len();
let deadline = client.next_send_deadline().unwrap();
inject_send(&client, InjectedSend::ReportedLength(probe_len - 1));
assert!(matches!(
client.send_probe().await,
Err(ClientError::DatagramLengthMismatch { .. })
));
assert_eq!(client.machine.packets_sent(), 1);
assert!(!client.machine.pending_is_empty());
assert!(client.prepared_probe.is_none());
assert_eq!(
client.next_send_deadline(),
Some(deadline + client.machine.config().interval)
);
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn stale_retained_probe_is_rejected_before_send() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
let advanced = client.machine.prepare_probe().unwrap().unwrap();
let preflight = client.machine.preflight_probe_commit(&advanced).unwrap();
let commit = client
.machine
.finalize_probe_commit(preflight, ClientTimestamp::now())
.unwrap();
client
.machine
.commit_probe_sent(commit, ClientTimestamp::now(), advanced.bytes.len());
let attempts = client.test_hooks.send_attempts.get();
assert!(matches!(
client.send_probe().await,
Err(ClientError::StalePreparedProbe { .. })
));
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
assert!(client.prepared_probe.is_some());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn finite_duration_boundary_clears_preparation_without_send() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client_config = config(server.addr, None, 0);
client_config.duration = Some(Duration::from_millis(20));
let mut client = AsyncClient::connect(client_config).await.unwrap();
client.open().await.unwrap();
let start = client.next_send_deadline().unwrap();
let attempts = client.test_hooks.send_attempts.get();
client
.test_hooks
.probe_timestamps
.borrow_mut()
.push_back(ProbeSendTimestamps {
permission_at: start + Duration::from_millis(20),
send_anchor: ClientTimestamp {
wall: SystemTime::UNIX_EPOCH,
mono: start + Duration::from_millis(21),
},
sent_at: ClientTimestamp {
wall: SystemTime::UNIX_EPOCH,
mono: start + Duration::from_millis(21),
},
send_call_start: start + Duration::from_millis(22),
send_finished_at: start + Duration::from_millis(23),
});
assert!(client.send_probe().await.unwrap().is_empty());
assert!(client.prepared_probe.is_none());
assert_eq!(client.machine.packets_sent(), 0);
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
assert!(client.is_run_complete());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn receive_retries_false_readiness_and_shares_packet_classification() {
let server = start_server(move |socket, tx| {
let (params, peer) = open_session(&socket, &tx, None);
let (probe_packet, _) = recv_packet(&socket, &tx);
let sequence = echo_request_sequence(&probe_packet, None);
socket
.send_to(
&echo_reply(&params, sequence, TOKEN, flags::FLAG_REPLY, None),
peer,
)
.unwrap();
});
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.send_probe().await.unwrap();
client.test_hooks.receive_would_block.set(2);
assert!(matches!(
client.recv().await.unwrap().as_slice(),
[ClientEvent::EchoReply { seq: 0, .. }]
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn receive_retries_interrupted_syscall_and_shares_packet_classification() {
let server = start_server(move |socket, tx| {
let (params, peer) = open_session(&socket, &tx, None);
let (probe_packet, _) = recv_packet(&socket, &tx);
let sequence = echo_request_sequence(&probe_packet, None);
socket
.send_to(
&echo_reply(&params, sequence, TOKEN, flags::FLAG_REPLY, None),
peer,
)
.unwrap();
});
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.send_probe().await.unwrap();
client.test_hooks.inject_recv_interrupted.set(2);
assert!(matches!(
client.recv().await.unwrap().as_slice(),
[ClientEvent::EchoReply { seq: 0, .. }]
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn open_retries_interrupted_syscall_within_the_same_attempt() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.test_hooks.inject_recv_interrupted.set(3);
assert!(matches!(
client.open().await,
Ok(OpenOutcome::Started { .. })
));
// Only one open request reached the server: interruptions were
// absorbed inside the single open attempt, with no retransmit.
assert_eq!(client.test_hooks.send_attempts.get(), 1);
});
server.finish();
}
#[test]
fn peer_close_dscp_failure_preserves_events_and_logical_ownership() {
let server = start_peer_close_server();
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
client.send_probe().await.unwrap();
client.prepared_probe = client.machine.prepare_probe().unwrap();
client.test_hooks.fail_peer_close_dscp.set(true);
assert!(matches!(
client.recv().await.unwrap().as_slice(),
[
ClientEvent::EchoReply { .. },
ClientEvent::SessionClosed { .. }
]
));
assert!(client.is_peer_closed());
assert!(client.is_run_complete());
assert!(client.schedule.is_none());
assert!(client.prepared_probe.is_none());
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(client.next_send_deadline(), None);
assert_eq!(socket_dscp(&client), 46);
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn recv_after_local_close_and_no_test_fails_on_first_poll() {
let close_server = start_server(move |socket, tx| {
@@ -988,141 +381,6 @@ fn recv_after_local_close_and_no_test_fails_on_first_poll() {
assert_eq!(no_test_server.finish().len(), 1);
}
#[test]
fn close_cancellation_and_would_block_restore_dscp_before_suspend() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
let deadline = client.next_send_deadline();
let attempts = client.test_hooks.send_attempts.get();
assert_eq!(socket_dscp(&client), 46);
client.test_hooks.pause_close_before_writable.set(true);
let mut close = Box::pin(client.close());
assert!(matches!(poll_once(Pin::as_mut(&mut close)), Poll::Pending));
drop(close);
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.next_send_deadline(), deadline);
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
assert_eq!(socket_dscp(&client), 46);
inject_send(&client, InjectedSend::WouldBlock);
client.test_hooks.pause_close_after_would_block.set(true);
let mut close = Box::pin(client.close());
assert!(matches!(poll_once(Pin::as_mut(&mut close)), Poll::Pending));
drop(close);
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(socket_dscp(&client), 46);
assert!(matches!(
client.close().await.unwrap().as_slice(),
[ClientEvent::SessionClosed { .. }]
));
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn close_errors_preserve_open_state_and_primary_send_error() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
let deadline = client.next_send_deadline();
inject_send(&client, InjectedSend::Error);
assert!(matches!(client.close().await, Err(ClientError::Socket(_))));
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.next_send_deadline(), deadline);
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(socket_dscp(&client), 46);
client.test_hooks.fail_dscp_restore.set(true);
inject_send(&client, InjectedSend::Error);
assert!(matches!(client.close().await, Err(ClientError::Socket(_))));
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn close_would_block_restore_failure_stops_without_committing() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
client.test_hooks.fail_dscp_restore.set(true);
inject_send(&client, InjectedSend::WouldBlock);
assert!(matches!(
client.close().await,
Err(ClientError::SocketOption {
operation: "restore negotiated DSCP",
..
})
));
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(184));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn successful_short_close_commits_before_length_error() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
assert!(client.prepared_probe.is_some());
let close_len = client.machine.prepare_close().unwrap().bytes.len();
inject_send(&client, InjectedSend::ReportedLength(close_len - 1));
assert!(matches!(
client.close().await,
Err(ClientError::DatagramLengthMismatch { .. })
));
assert!(!client.machine.is_open());
assert!(client.schedule.is_none());
assert!(client.prepared_probe.is_none());
assert_eq!(client.applied_traffic_class, None);
assert!(matches!(
client.machine.prepare_close(),
Err(ClientError::AlreadyClosed)
));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn successful_close_uses_post_acceptance_timestamp() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let close_sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(44),
mono: Instant::now() + Duration::from_secs(1),
};
client.test_hooks.close_sent_at.set(Some(close_sent_at));
assert!(matches!(
client.close().await.unwrap().as_slice(),
[ClientEvent::SessionClosed { at, .. }] if *at == close_sent_at
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn blocking_and_async_hmac_dscp_lifecycle_are_semantically_equivalent() {
let key = b"async-equivalence-key".to_vec();
@@ -1178,7 +436,7 @@ fn blocking_and_async_no_test_are_semantically_equivalent() {
blocking_no_test_config.run_mode = RunMode::NoTest;
let mut blocking_no_test = Client::connect(blocking_no_test_config).unwrap();
let blocking_no_test_open = blocking_no_test.open().unwrap();
assert!(blocking_no_test.is_run_complete());
assert!(blocking_no_test.negotiated_params().is_none());
drop(blocking_no_test_server);
let async_no_test_server = InTreeServer::start(ServerConfig::default());
@@ -1187,7 +445,7 @@ fn blocking_and_async_no_test_are_semantically_equivalent() {
let async_no_test_open = runtime().block_on(async {
let mut client = AsyncClient::connect(async_no_test_config).await.unwrap();
let opened = client.open().await.unwrap();
assert!(client.is_run_complete());
assert!(client.negotiated_params().is_none());
opened
});
drop(async_no_test_server);
@@ -1332,8 +590,11 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
// must be able to open the same connected client.
let blocking_server = start_silent_then_open_server(1);
let mut blocking = Client::connect(config(blocking_server.addr, None, 0)).unwrap();
assert!(!blocking.has_pending_probes());
let blocking_first = blocking.open().unwrap_err();
assert!(!blocking.has_pending_probes());
let blocking_second = blocking.open().unwrap();
assert!(!blocking.has_pending_probes());
let blocking_sent = blocking.send_probe().unwrap();
let blocking_reply = blocking.recv_once().unwrap();
let blocking_close = blocking.close().unwrap();
@@ -1345,8 +606,11 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
let mut client = AsyncClient::connect(config(async_server.addr, None, 0))
.await
.unwrap();
assert!(!client.has_pending_probes());
let first = client.open().await.unwrap_err();
assert!(!client.has_pending_probes());
let second = client.open().await.unwrap();
assert!(!client.has_pending_probes());
let sent = client.send_probe().await.unwrap();
let reply = client.recv().await.unwrap();
let closed = client.close().await.unwrap();
@@ -1380,30 +644,53 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
#[test]
fn blocking_and_async_complete_every_caller_paced_probe() {
const PROBES: u32 = 3;
fn caller_config(addr: SocketAddr) -> ClientConfig {
ClientConfig {
duration: Some(Duration::from_millis(1)),
interval: Duration::from_secs(1),
..config(addr, None, 0)
}
}
let blocking_server = InTreeServer::start(ServerConfig::default());
let mut blocking = Client::connect(config(blocking_server.addr, None, 0)).unwrap();
blocking.open().unwrap();
let mut blocking = Client::connect(caller_config(blocking_server.addr)).unwrap();
let outcome = blocking.open().unwrap();
assert_eq!(
blocking.negotiated_params(),
Some(open_negotiated(&outcome))
);
assert!(!blocking.has_pending_probes());
// Oversleeping cannot invalidate this: low-level sends remain caller-owned
// after the negotiated duration. The compliant server has no duration cap.
thread::sleep(Duration::from_millis(2));
let mut blocking_sent = Vec::new();
let mut blocking_replies = Vec::new();
for _ in 0..PROBES {
blocking_sent.extend(blocking.send_probe().unwrap());
assert!(blocking.has_pending_probes());
assert!(blocking.next_probe_timeout_deadline().is_some());
blocking_replies.extend(blocking.recv_once().unwrap());
assert!(!blocking.has_pending_probes());
}
blocking.close().unwrap();
drop(blocking_server);
let async_server = InTreeServer::start(ServerConfig::default());
let (async_sent, async_replies) = runtime().block_on(async {
let mut client = AsyncClient::connect(config(async_server.addr, None, 0))
let mut client = AsyncClient::connect(caller_config(async_server.addr))
.await
.unwrap();
client.open().await.unwrap();
let outcome = client.open().await.unwrap();
assert_eq!(client.negotiated_params(), Some(open_negotiated(&outcome)));
tokio::time::sleep(Duration::from_millis(2)).await;
let mut sent = Vec::new();
let mut replies = Vec::new();
for _ in 0..PROBES {
sent.extend(client.send_probe().await.unwrap());
assert!(client.has_pending_probes());
assert!(client.next_probe_timeout_deadline().is_some());
replies.extend(client.recv().await.unwrap());
assert!(!client.has_pending_probes());
}
client.close().await.unwrap();
(sent, replies)
@@ -1419,6 +706,16 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
// the sequence helpers keep: an extra event on either side is a difference.
assert_eq!(blocking_sent.len(), async_sent.len());
assert_eq!(blocking_replies.len(), async_replies.len());
for event in blocking_sent.iter().chain(&async_sent) {
assert!(matches!(
event,
ClientEvent::EchoSent {
scheduled_at: None,
timer_error: None,
..
}
));
}
for (blocking, asynchronous) in blocking_sent.iter().zip(&async_sent) {
assert_matching_event_shape(blocking, asynchronous);
}
@@ -1470,3 +767,24 @@ fn blocking_and_async_reject_probes_before_open_and_after_close() {
error_name(&async_after_close)
);
}
#[test]
fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
let server = start_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_packet(&socket, &tx);
for _ in 0..3 {
socket.send_to(&[0_u8], peer).unwrap();
}
}
});
let mut client_config = config(server.addr, None, 0);
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
runtime().block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
assert!(matches!(client.open().await, Err(ClientError::OpenTimeout)));
assert!(client.machine.prepare_open_request().is_ok());
});
assert_eq!(server.finish().len(), 2);
}
+80 -419
View File
@@ -4,12 +4,6 @@ use std::{
time::{Duration, Instant},
};
#[cfg(test)]
use std::cell::Cell;
#[cfg(test)]
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
use crate::{
config::{ClientConfig, RecvBudget},
error::ClientError,
@@ -24,23 +18,9 @@ use crate::{
timing::ClientTimestamp,
};
pub(crate) mod schedule;
use schedule::{instant_abs_diff, ProbeSchedule};
#[derive(Debug, Clone, Copy)]
enum ProbeScheduleMode {
CallerPaced,
#[cfg(test)]
Managed {
scheduled_at: Instant,
},
}
#[derive(Debug)]
struct PreparedClientOpen {
machine: PreparedOpenAcceptance,
schedule: Option<ProbeSchedule>,
recv_buffer_len: Option<usize>,
negotiated_traffic_class: Option<u8>,
previous_traffic_class: Option<u8>,
@@ -53,50 +33,6 @@ struct PreparedClientOpenFailure {
machine: PreparedOpenAcceptance,
}
#[cfg(test)]
#[derive(Debug, Default)]
struct ClientTestHooks {
fail_open_dscp: Cell<bool>,
fail_open_timeout_restore: Cell<bool>,
fail_close_event_reserve: Cell<bool>,
fail_close_dscp_clear: Cell<bool>,
fail_close_send: Cell<bool>,
close_reported_len: Cell<Option<usize>>,
close_send_attempts: Cell<usize>,
close_sent_at: Cell<Option<ClientTimestamp>>,
fail_cleanup_send: Cell<bool>,
recv_buffer_len_override: Cell<Option<usize>>,
fail_dscp_restore: Cell<bool>,
last_restored_read_timeout: Cell<Option<Duration>>,
prepared_active_session_before_dscp: Cell<bool>,
/// Number of subsequent receive attempts that should report
/// `io::ErrorKind::Interrupted` before a real receive is attempted.
/// Decremented on every injected receive; `0` means inject nothing.
inject_recv_interrupted: Cell<usize>,
}
#[cfg(test)]
#[derive(Debug, Clone, Copy)]
pub(crate) struct ProbeSendTimestamps {
pub(crate) permission_at: Instant,
/// Private pre-send anchor: timeout deadline / kernel TX lower bound.
pub(crate) send_anchor: ClientTimestamp,
pub(crate) send_call_start: Instant,
pub(crate) send_finished_at: Instant,
/// Public post-send measurement: RTT / OWD fallback / timer_error.
pub(crate) sent_at: ClientTimestamp,
}
#[cfg(test)]
use crate::{
session::machine::{
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
unix_epoch_ns_i64, update_highest_received,
},
session::negotiate_params,
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
};
/// Low-level synchronous IRTT client.
///
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
@@ -131,7 +67,6 @@ use crate::{
#[derive(Debug)]
pub struct Client {
runtime: SessionMachine,
schedule: Option<ProbeSchedule>,
socket: UdpSocket,
remote: SocketAddr,
recv_buffer: Vec<u8>,
@@ -143,14 +78,6 @@ pub struct Client {
/// unnecessary error-queue drains are skipped once it is known there is
/// nothing to drain.
tx_timestamping_enabled: bool,
#[cfg(test)]
test_hooks: ClientTestHooks,
#[cfg(test)]
probe_reported_len: Option<usize>,
#[cfg(test)]
probe_send_error: bool,
#[cfg(test)]
probe_send_timestamps: Option<ProbeSendTimestamps>,
}
impl Client {
@@ -167,20 +94,11 @@ impl Client {
Ok(Self {
runtime,
schedule: None,
socket,
remote,
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
applied_traffic_class: None,
tx_timestamping_enabled: false,
#[cfg(test)]
test_hooks: ClientTestHooks::default(),
#[cfg(test)]
probe_reported_len: None,
#[cfg(test)]
probe_send_error: false,
#[cfg(test)]
probe_send_timestamps: None,
})
}
@@ -203,8 +121,9 @@ impl Client {
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
let result = self.open_transaction();
if result.is_err() {
let _ =
self.restore_open_read_timeout(self.runtime.config().socket_config.recv_timeout);
let _ = self
.socket
.set_read_timeout(self.runtime.config().socket_config.recv_timeout);
}
result
}
@@ -214,19 +133,11 @@ impl Client {
/// The close event means the client has sent its close packet and stopped
/// tracking the session locally; it is not a server acknowledgement. If the
/// send fails, the negotiated DSCP is restored best-effort and the session
/// and probe schedule remain open.
/// remains open.
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
let prepared = self.runtime.prepare_close()?;
let mut events = Vec::new();
#[cfg(test)]
if self.test_hooks.fail_close_event_reserve.replace(false) {
events
.try_reserve(usize::MAX)
.map_err(|source| ClientError::AllocationFailed {
operation: "close event result",
source,
})?;
}
events
.try_reserve(1)
.map_err(|source| ClientError::AllocationFailed {
@@ -237,51 +148,74 @@ impl Client {
let expected_bytes = prepared.bytes.len();
self.clear_close_dscp()?;
let bytes = match self.send_close_datagram(prepared.bytes) {
let bytes = match self.socket.send(prepared.bytes) {
Ok(bytes) => bytes,
Err(err) => {
self.restore_dscp_best_effort(previous_traffic_class);
return Err(ClientError::Socket(err));
}
};
#[cfg(not(test))]
let sent_at = ClientTimestamp::now();
#[cfg(test)]
let sent_at = self
.test_hooks
.close_sent_at
.take()
.unwrap_or_else(ClientTimestamp::now);
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
self.schedule = None;
self.applied_traffic_class = None;
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
self.applied_traffic_class = None;
validate_datagram_length(expected_bytes, bytes)?;
events.push(event);
Ok(events)
}
/// Return the monotonic deadline for the next probe send, if another probe
/// is scheduled.
pub fn next_send_deadline(&self) -> Option<Instant> {
if !self.runtime.is_open() {
return None;
}
self.schedule.as_ref()?.next_send_deadline()
}
/// Return the local timeout used to classify pending probes as lost.
pub fn probe_timeout(&self) -> Duration {
self.runtime.probe_timeout()
}
/// Send one echo probe if the negotiated run is still active.
/// Send one echo probe now. The caller owns pacing and run duration.
///
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
/// list when the run duration has elapsed and no further probe should be
/// sent.
/// The negotiated interval and duration do not gate low-level sends.
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_transaction(ProbeScheduleMode::CallerPaced)
let remote = self.remote;
let (runtime, socket) = (&mut self.runtime, &self.socket);
let prepared = runtime.prepare_probe()?;
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
let mut events = Vec::new();
events
.try_reserve(1)
.map_err(|source| ClientError::AllocationFailed {
operation: "probe event result",
source,
})?;
let expected_bytes = prepared.bytes.len();
// All fallible probe-commit preflight (timeout deadline arithmetic,
// capacity/collision checks already done above) is finalized from
// this PRIVATE pre-send anchor, before the socket send. It is not
// the public measurement `sent_at` captured further below.
let send_anchor = ClientTimestamp::now();
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
let send_call_start = Instant::now();
let bytes = match socket.send(&prepared.bytes) {
Ok(bytes) => bytes,
Err(error) => {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(error));
}
};
let send_finished_at = Instant::now();
// The public measurement timestamp: paired wall/monotonic sample
// captured immediately after the successful socket send completed.
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
// endpoint. The infallible machine commit below never fails.
let sent_at = ClientTimestamp::now();
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
let send_call = send_finished_at.saturating_duration_since(send_call_start);
validate_datagram_length(expected_bytes, bytes)?;
self.drain_tx_timestamps()?;
events.push(echo_sent_event(remote, sent, send_call));
Ok(events)
}
/// Receive and classify at most one datagram from the socket.
@@ -294,37 +228,6 @@ impl Client {
self.recv_once_inner()
}
/// Receive one datagram into `self.recv_buffer`. In test builds, a
/// pending `inject_recv_interrupted` count reports
/// `io::ErrorKind::Interrupted` instead of performing a real receive,
/// deterministically exercising interrupted-syscall retry behavior.
fn recv_datagram_once(&mut self) -> Result<ReceivedDatagram, io::Error> {
#[cfg(test)]
{
let remaining = self.test_hooks.inject_recv_interrupted.get();
if remaining > 0 {
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
}
recv_datagram(&self.socket, &mut self.recv_buffer)
}
/// Same as [`Self::recv_datagram_once`], receiving into a caller-supplied
/// buffer instead of `self.recv_buffer` (used before the negotiated
/// receive buffer size is known, i.e. during Open).
fn recv_datagram_once_into(&self, buf: &mut [u8]) -> Result<ReceivedDatagram, io::Error> {
#[cfg(test)]
{
let remaining = self.test_hooks.inject_recv_interrupted.get();
if remaining > 0 {
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
}
recv_datagram(&self.socket, buf)
}
fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
return Ok(vec![]);
@@ -340,12 +243,9 @@ impl Client {
datagram.received_at,
datagram.meta,
)?;
if self.runtime.is_peer_closed() {
self.schedule = None;
if self.clear_close_dscp().is_ok() {
if self.runtime.is_peer_closed() && self.clear_close_dscp().is_ok() {
self.applied_traffic_class = None;
}
}
Ok(events)
}
@@ -377,7 +277,7 @@ impl Client {
let mut timeout_narrowed = false;
let result = loop {
match self.recv_datagram_once() {
match recv_datagram(&self.socket, &mut self.recv_buffer) {
Ok(datagram) => break Ok(Some(datagram)),
Err(err)
if matches!(
@@ -450,17 +350,19 @@ impl Client {
self.runtime.poll_timeouts_at(now)
}
/// Return whether the current run has completed.
///
/// A normal run is complete once no more probes will be sent and all
/// pending probes have either replied or timed out. No-test and closed
/// sessions are also considered complete.
pub fn is_run_complete(&self) -> bool {
self.runtime.is_terminal()
|| self
.schedule
.as_ref()
.is_some_and(|schedule| schedule.is_finished() && self.runtime.pending_is_empty())
/// Negotiated parameters of the currently open session.
pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
self.runtime.negotiated_params()
}
/// Earliest timeout deadline among probes still awaiting a reply.
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
self.runtime.next_probe_timeout_deadline()
}
/// Whether any sent probes still await a reply or timeout.
pub fn has_pending_probes(&self) -> bool {
!self.runtime.pending_is_empty()
}
/// Return whether the session was closed by a peer close-flagged reply.
@@ -473,130 +375,6 @@ impl Client {
self.runtime.is_peer_closed()
}
#[cfg(test)]
pub(crate) fn send_managed_probe(
&mut self,
scheduled_at: Instant,
) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_transaction(ProbeScheduleMode::Managed { scheduled_at })
}
fn send_probe_transaction(
&mut self,
mode: ProbeScheduleMode,
) -> Result<Vec<ClientEvent>, ClientError> {
self.runtime.ensure_open()?;
#[cfg(test)]
let test_timestamps = self.probe_send_timestamps.take();
#[cfg(test)]
let fail_send = std::mem::take(&mut self.probe_send_error);
let remote = self.remote;
let (runtime, schedule, socket) = (&mut self.runtime, &mut self.schedule, &self.socket);
let schedule = schedule
.as_mut()
.expect("open sessions always have a probe schedule");
#[cfg(not(test))]
let permission_at = Instant::now();
#[cfg(test)]
let permission_at = test_timestamps
.map(|timestamps| timestamps.permission_at)
.unwrap_or_else(Instant::now);
if !schedule.permit_probe_at(permission_at) {
return Ok(Vec::new());
}
let Some(prepared) = runtime.prepare_probe()? else {
return Ok(Vec::new());
};
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
let schedule_commit = match mode {
ProbeScheduleMode::CallerPaced => {
schedule.preflight_caller_commit(machine_preflight.next_packets_sent)?
}
#[cfg(test)]
ProbeScheduleMode::Managed { scheduled_at } => {
schedule.preflight_managed_commit(scheduled_at, permission_at)?
}
};
let mut events = Vec::new();
events
.try_reserve(1)
.map_err(|source| ClientError::AllocationFailed {
operation: "probe event result",
source,
})?;
let expected_bytes = prepared.bytes.len();
let scheduled_at = schedule_commit.scheduled_at;
#[cfg(test)]
let reported_bytes = self.probe_reported_len.take();
// All fallible probe-commit preflight (timeout deadline arithmetic,
// capacity/collision checks already done above) is finalized from
// this PRIVATE pre-send anchor, before the socket send. It is not
// the public measurement `sent_at` captured further below.
#[cfg(not(test))]
let send_anchor = ClientTimestamp::now();
#[cfg(test)]
let send_anchor = test_timestamps
.map(|timestamps| timestamps.send_anchor)
.unwrap_or_else(ClientTimestamp::now);
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
#[cfg(not(test))]
let send_call_start = Instant::now();
#[cfg(test)]
let send_call_start = test_timestamps
.map(|timestamps| timestamps.send_call_start)
.unwrap_or_else(Instant::now);
#[cfg(test)]
if fail_send {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(io::Error::other(
"injected probe send failure",
)));
}
let bytes = match socket.send(&prepared.bytes) {
Ok(bytes) => bytes,
Err(error) => {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(error));
}
};
#[cfg(not(test))]
let send_finished_at = Instant::now();
#[cfg(test)]
let send_finished_at = test_timestamps
.map(|timestamps| timestamps.send_finished_at)
.unwrap_or_else(Instant::now);
// The public measurement timestamp: paired wall/monotonic sample
// captured immediately after the successful socket send completed.
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
// endpoint. The infallible machine/schedule commit below never fails.
#[cfg(not(test))]
let sent_at = ClientTimestamp::now();
#[cfg(test)]
let sent_at = test_timestamps
.map(|timestamps| timestamps.sent_at)
.unwrap_or_else(ClientTimestamp::now);
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
schedule.commit(schedule_commit);
let send_call = send_finished_at.saturating_duration_since(send_call_start);
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
#[cfg(test)]
let bytes = reported_bytes.unwrap_or(bytes);
validate_datagram_length(expected_bytes, bytes)?;
self.drain_tx_timestamps()?;
events.push(echo_sent_event(
remote,
sent,
send_call,
scheduled_at,
timer_error,
));
Ok(events)
}
fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
let request = self.runtime.prepare_open_request()?;
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
@@ -620,7 +398,7 @@ impl Client {
}
self.socket.set_read_timeout(Some(remaining))?;
let datagram = match self.recv_datagram_once_into(&mut buf) {
let datagram = match recv_datagram(&self.socket, &mut buf) {
Ok(datagram) => datagram,
// WouldBlock/TimedOut mean the attempt's own deadline
// elapsed; Interrupted (EINTR) means the syscall was
@@ -663,7 +441,7 @@ impl Client {
return Err(failure.primary);
}
};
let prepared = match self.prepare_client_open(machine, datagram.received_at) {
let prepared = match self.prepare_client_open(machine) {
Ok(prepared) => prepared,
Err(failure) => {
self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
@@ -680,32 +458,21 @@ impl Client {
fn prepare_client_open(
&self,
machine: PreparedOpenAcceptance,
opened_at: ClientTimestamp,
) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
let Some(negotiated) = machine.normal_negotiated() else {
return Ok(PreparedClientOpen {
machine,
schedule: None,
recv_buffer_len: None,
negotiated_traffic_class: None,
previous_traffic_class: self.applied_traffic_class,
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
});
};
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
Ok(schedule) => schedule,
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
};
let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
Ok(size) => size,
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
};
#[cfg(test)]
let recv_buffer_len = self
.test_hooks
.recv_buffer_len_override
.get()
.unwrap_or(recv_buffer_len);
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
Ok(traffic_class) => traffic_class,
Err(_) => {
@@ -720,7 +487,6 @@ impl Client {
Ok(PreparedClientOpen {
machine,
schedule: Some(schedule),
recv_buffer_len: Some(recv_buffer_len),
negotiated_traffic_class: Some(negotiated_traffic_class),
previous_traffic_class: self.applied_traffic_class,
@@ -734,7 +500,6 @@ impl Client {
) -> Result<OpenOutcome, ClientError> {
let PreparedClientOpen {
machine,
schedule,
recv_buffer_len,
negotiated_traffic_class,
previous_traffic_class,
@@ -755,17 +520,15 @@ impl Client {
}
self.recv_buffer.resize(recv_buffer_len, 0);
#[cfg(test)]
self.test_hooks
.prepared_active_session_before_dscp
.set(machine.has_prepared_active_session());
if let Err(primary) = self.apply_open_dscp(negotiated_traffic_class) {
if let Err(primary) =
apply_traffic_class_to_socket(&self.socket, self.remote, negotiated_traffic_class)
{
self.recv_buffer.truncate(previous_len);
self.restore_dscp_best_effort(previous_traffic_class);
self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
return Err(primary);
}
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
let primary = ClientError::ReadTimeoutRestore { source };
self.recv_buffer.truncate(previous_len);
self.restore_dscp_best_effort(previous_traffic_class);
@@ -774,17 +537,16 @@ impl Client {
}
let outcome = self.runtime.commit_open(machine);
self.schedule = schedule;
self.applied_traffic_class = Some(negotiated_traffic_class);
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
Ok(outcome)
} else {
debug_assert!(schedule.is_none());
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
return Err(ClientError::ReadTimeoutRestore { source });
}
let outcome = self.runtime.commit_open(machine);
self.schedule = None;
self.applied_traffic_class = None;
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
Ok(outcome)
@@ -809,10 +571,6 @@ impl Client {
}
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
#[cfg(test)]
if self.test_hooks.fail_dscp_restore.replace(false) {
return;
}
let _ = match previous_traffic_class {
Some(traffic_class) => {
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
@@ -822,85 +580,29 @@ impl Client {
}
fn clear_close_dscp(&self) -> Result<(), ClientError> {
#[cfg(test)]
if self.test_hooks.fail_close_dscp_clear.replace(false) {
return Err(ClientError::SocketOption {
operation: "clear negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected negotiated DSCP clear failure"),
});
}
clear_dscp_on_socket(&self.socket, self.remote)
}
fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
if let Some(packet) = packet {
#[cfg(test)]
if self.test_hooks.fail_cleanup_send.replace(false) {
return;
}
let _ = self.socket.send(packet);
}
}
fn apply_open_dscp(&self, traffic_class: u8) -> Result<(), ClientError> {
#[cfg(test)]
if self.test_hooks.fail_open_dscp.replace(false) {
return Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected negotiated DSCP failure"),
});
}
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
}
fn restore_open_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
#[cfg(test)]
self.test_hooks.last_restored_read_timeout.set(timeout);
#[cfg(test)]
if self.test_hooks.fail_open_timeout_restore.replace(false) {
return Err(io::Error::other(
"injected configured read timeout restoration failure",
));
}
self.socket.set_read_timeout(timeout)
}
fn send_close_datagram(&self, packet: &[u8]) -> io::Result<usize> {
#[cfg(test)]
{
self.test_hooks
.close_send_attempts
.set(self.test_hooks.close_send_attempts.get() + 1);
if self.test_hooks.fail_close_send.replace(false) {
return Err(io::Error::other("injected close send failure"));
}
}
let bytes = self.socket.send(packet)?;
#[cfg(test)]
if let Some(reported) = self.test_hooks.close_reported_len.take() {
return Ok(reported);
}
Ok(bytes)
}
}
pub(crate) fn echo_sent_event(
remote: SocketAddr,
sent: ProbeSent,
send_call: Duration,
scheduled_at: Instant,
timer_error: Duration,
) -> ClientEvent {
ClientEvent::EchoSent {
seq: sent.seq,
remote,
scheduled_at,
scheduled_at: None,
sent_at: sent.sent_at,
bytes: sent.bytes,
send_call,
timer_error,
timer_error: None,
}
}
@@ -912,46 +614,5 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result
}
}
#[cfg(test)]
impl Client {
fn send_probe_at(
&mut self,
timestamps: ProbeSendTimestamps,
) -> Result<Vec<ClientEvent>, ClientError> {
self.probe_send_timestamps = Some(timestamps);
self.send_probe()
}
fn send_managed_probe_at(
&mut self,
scheduled_at: Instant,
timestamps: ProbeSendTimestamps,
) -> Result<Vec<ClientEvent>, ClientError> {
self.probe_send_timestamps = Some(timestamps);
self.send_managed_probe(scheduled_at)
}
}
#[cfg(test)]
fn bounded_receive_timeout(
deadline: Option<Instant>,
configured_timeout: Option<Duration>,
max_wait: Duration,
now: Instant,
) -> Option<Duration> {
if max_wait.is_zero() {
return None;
}
let mut timeout = configured_timeout.unwrap_or(max_wait).min(max_wait);
if let Some(deadline) = deadline {
let remaining = deadline.checked_duration_since(now)?;
if remaining.is_zero() {
return None;
}
timeout = timeout.min(remaining);
}
(!timeout.is_zero()).then_some(timeout)
}
#[cfg(test)]
mod tests;
+63 -315
View File
@@ -17,191 +17,6 @@ fn open_fails_after_close() {
server.join();
}
#[test]
fn close_sends_one_close_packet_with_negotiated_token() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1_234),
mono: Instant::now() + Duration::from_secs(5),
};
client.test_hooks.close_sent_at.set(Some(sent_at));
let events = client.close().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::SessionClosed {
token: TOKEN,
at,
..
}] if *at == sent_at
));
assert!(!client.runtime.is_open());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
assert_eq!(close[3], flags::FLAG_CLOSE);
assert_eq!(u64::from_le_bytes(close[4..12].try_into().unwrap()), TOKEN);
server.join();
}
#[test]
fn close_event_reservation_failure_precedes_dscp_clear_and_send() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_event_reserve.set(true);
client.test_hooks.fail_close_dscp_clear.set(true);
assert!(matches!(
client.close(),
Err(ClientError::AllocationFailed {
operation: "close event result",
..
})
));
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
assert!(client.test_hooks.fail_close_dscp_clear.get());
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
client.test_hooks.fail_close_dscp_clear.set(false);
client.close().unwrap();
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
server.join();
}
#[test]
fn close_dscp_clear_failure_precedes_send_and_preserves_open_state() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_dscp_clear.set(true);
assert!(matches!(
client.close(),
Err(ClientError::SocketOption { .. })
));
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
client.close().unwrap();
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
server.join();
}
#[test]
fn close_send_failure_leaves_machine_and_schedule_open_for_retry() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(2_345),
mono: Instant::now() + Duration::from_secs(6),
};
client.test_hooks.close_sent_at.set(Some(sent_at));
client.test_hooks.fail_close_send.set(true);
assert!(matches!(client.close(), Err(ClientError::Socket(_))));
assert_eq!(client.test_hooks.close_sent_at.get(), Some(sent_at));
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
assert!(matches!(
client.close().unwrap().as_slice(),
[ClientEvent::SessionClosed {
token: TOKEN,
at,
..
}] if *at == sent_at
));
assert_eq!(client.test_hooks.close_sent_at.get(), None);
assert_eq!(client.test_hooks.close_send_attempts.get(), 2);
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
server.join();
}
#[test]
fn short_successful_close_commits_before_length_mismatch() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let expected = client.runtime.prepare_close().unwrap().bytes.len();
let sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(3_456),
mono: Instant::now() + Duration::from_secs(7),
};
client.test_hooks.close_sent_at.set(Some(sent_at));
client.test_hooks.close_reported_len.set(Some(expected - 1));
assert!(matches!(
client.close(),
Err(ClientError::DatagramLengthMismatch {
expected: error_expected,
actual,
}) if error_expected == expected && actual + 1 == expected
));
assert!(!client.runtime.is_open());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert_eq!(client.test_hooks.close_sent_at.get(), None);
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
server.join();
}
#[test]
fn send_probe_fails_after_close() {
let params = default_params();
@@ -232,135 +47,6 @@ fn send_probe_fails_after_close() {
server.join();
}
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&TimestampFields::default(),
None,
FLAG_REPLY | flags::FLAG_CLOSE,
);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.first(),
Some(ClientEvent::EchoReply { .. })
));
assert!(matches!(
events.get(1),
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
));
assert_eq!(events.len(), 2);
assert!(client.next_send_deadline().is_none());
assert!(client.schedule.is_none());
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
));
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
server.join();
}
#[test]
fn peer_close_dscp_cleanup_failure_preserves_events_and_closed_state() {
let params = default_params();
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
socket
.send_to(
&echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&TimestampFields::default(),
None,
FLAG_REPLY | flags::FLAG_CLOSE,
),
peer,
)
.unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let mut client = Client::connect(ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
})
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.test_hooks.fail_close_dscp_clear.set(true);
assert!(matches!(
client.recv_once().unwrap().as_slice(),
[
ClientEvent::EchoReply { .. },
ClientEvent::SessionClosed { token: TOKEN, .. }
]
));
assert!(client.is_peer_closed());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, Some(0));
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
let open = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
let echo = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
assert_ne!(open[3] & FLAG_OPEN, 0);
assert_eq!(echo[3] & flags::FLAG_CLOSE, 0);
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
server.join();
}
#[test]
fn recv_available_stops_after_peer_close() {
let params = default_params();
@@ -415,7 +101,6 @@ fn normal_echo_reply_does_not_close_session() {
let events = client.recv_once().unwrap();
assert!(matches!(events.as_slice(), [ClientEvent::EchoReply { .. }]));
assert!(client.next_send_deadline().is_some());
assert!(client.send_probe().is_ok());
client.close().unwrap();
@@ -752,3 +437,66 @@ fn wrong_token_close_flag_does_not_close_session() {
client.close().unwrap();
server.join();
}
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&TimestampFields::default(),
None,
FLAG_REPLY | flags::FLAG_CLOSE,
);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.first(),
Some(ClientEvent::EchoReply { .. })
));
assert!(matches!(
events.get(1),
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
));
assert_eq!(events.len(), 2);
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
));
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
server.join();
}
+3 -74
View File
@@ -82,80 +82,9 @@ fn normal_open_uses_negotiated_traffic_class_not_requested_dscp() {
target_os = "redox",
target_os = "solaris",
target_os = "illumos",
target_os = "haiku",
target_os = "haiku"
)))]
fn failed_close_send_restores_negotiated_traffic_class_and_keeps_session_open() {
let mut params = default_params();
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
params.dscp = 184;
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut config = default_test_config(server.addr);
config.dscp = 46;
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_send.set(true);
let error = client.close().unwrap_err();
assert!(matches!(error, ClientError::Socket(_)));
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
184
);
client.close().unwrap();
server.join();
}
#[test]
fn failed_close_keeps_send_error_primary_when_dscp_restoration_also_fails() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_send.set(true);
client.test_hooks.fail_dscp_restore.set(true);
let error = client.close().unwrap_err();
assert!(matches!(error, ClientError::Socket(_)));
assert!(error.to_string().contains("injected close send failure"));
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
client.close().unwrap();
server.join();
}
#[test]
#[cfg(not(any(
target_os = "fuchsia",
target_os = "redox",
target_os = "solaris",
target_os = "illumos",
target_os = "haiku",
)))]
fn authenticated_peer_close_clears_schedule_and_negotiated_traffic_class() {
fn authenticated_peer_close_clears_negotiated_traffic_class() {
let mut params = default_params();
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
params.dscp = 184;
@@ -198,7 +127,7 @@ fn authenticated_peer_close_clears_schedule_and_negotiated_traffic_class() {
ClientEvent::SessionClosed { .. }
]
));
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(),
@@ -1,5 +1,14 @@
use super::*;
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
use crate::{
session::machine::{
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
unix_epoch_ns_i64, update_highest_received,
},
session::negotiate_params,
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
};
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
use irtt_proto::{
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock,
ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
+16 -43
View File
@@ -1,22 +1,5 @@
use super::*;
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(client.next_send_deadline().is_none());
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert!(client.next_send_deadline().is_none());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
server.join();
}
#[test]
fn no_test_rejects_non_close_open_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -48,32 +31,6 @@ fn no_test_rejects_non_close_open_reply() {
server.join();
}
#[test]
fn no_test_cleanup_send_failure_preserves_unexpected_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
client.test_hooks.fail_cleanup_send.set(true);
assert!(matches!(
client.open(),
Err(ClientError::UnexpectedNoTestReply)
));
assert!(client.runtime.prepare_open_request().is_ok());
server.join();
}
#[test]
fn no_test_rejects_non_zero_token_with_close_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -156,3 +113,19 @@ fn open_fails_after_no_test_completed() {
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
server.join();
}
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(client.applied_traffic_class, None);
server.join();
}
+104 -347
View File
@@ -1,37 +1,5 @@
use super::*;
#[test]
fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
server.join();
}
#[test]
fn successful_open_restores_configured_receive_timeout() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params);
let mut config = default_test_config(server.addr);
config.socket_config.recv_timeout = Some(Duration::from_millis(75));
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
assert_eq!(
client.test_hooks.last_restored_read_timeout.get(),
Some(Duration::from_millis(75))
);
server.join();
}
#[test]
fn open_fails_when_already_open() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -64,84 +32,6 @@ fn open_retries_after_first_timeout() {
server.join();
}
#[test]
fn open_survives_interrupted_receive_within_same_attempt() {
let server = start_fake_server(|socket, tx| {
let (first, peer) = recv_request(&socket, &tx);
let params = Params::decode(&first[4..]).unwrap();
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(500)],
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
// Several interrupted receive attempts before the real reply arrives
// must not be treated as a timeout, must not retransmit the open
// request, and must not advance to a next configured attempt.
client.test_hooks.inject_recv_interrupted.set(5);
let outcome = client.open().unwrap();
assert_open_started(outcome);
// Exactly one request reached the server: interruptions were absorbed
// inside the single configured attempt.
assert_eq!(server.rx.iter().take(1).count(), 1);
server.join();
}
#[test]
fn open_interrupted_receive_does_not_extend_the_attempt_deadline() {
// A fake server that never replies: with a short attempt timeout, the
// real question is whether interruptions can push the effective wait
// past the configured deadline. They must not: the deadline stays
// absolute across any number of interruptions.
let server = start_fake_server(|socket, tx| {
let _ = recv_request(&socket, &tx);
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200)],
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
// Each interruption carries no timing information of its own, so the
// loop must keep re-checking the real deadline rather than treating an
// interruption as "more time available" or a reason to fail outright.
client.test_hooks.inject_recv_interrupted.set(20);
let started = Instant::now();
let result = client.open();
let elapsed = started.elapsed();
assert!(matches!(result, Err(ClientError::OpenTimeout)));
assert!(
elapsed < Duration::from_millis(800),
"interrupted receives must not extend the attempt deadline, took {elapsed:?}"
);
server.join();
}
#[test]
fn open_timeout_after_all_timeouts() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
assert_eq!(
client.test_hooks.last_restored_read_timeout.get(),
Some(Duration::from_millis(50))
);
server.join();
}
#[test]
fn protocol_version_mismatch_fails() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -175,41 +65,6 @@ fn server_rejection_fails_in_normal_mode() {
server.join();
}
#[test]
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
let mut wrong_type = valid.clone();
wrong_type[3] = FLAG_REPLY;
let mut reserved_flags = valid.clone();
reserved_flags[3] |= 0x10;
let mut bad_magic = valid.clone();
bad_magic[0] ^= 0xff;
let unexpected_hmac =
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(b"unexpected"));
for packet in [
vec![0_u8],
bad_magic,
wrong_type,
reserved_flags,
unexpected_hmac,
valid,
] {
socket.send_to(&packet, peer).unwrap();
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(server.rx.try_recv().is_err());
assert!(client.schedule.is_some());
server.join();
}
#[test]
fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
let server = start_fake_server(move |socket, tx| {
@@ -237,60 +92,6 @@ fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
server.join();
}
#[test]
fn only_ignored_open_datagrams_eventually_time_out() {
let server = start_fake_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_request(&socket, &tx);
socket.send_to(&[0_u8], peer).unwrap();
socket.send_to(&MAGIC, peer).unwrap();
}
thread::sleep(Duration::from_millis(300));
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
assert!(server.rx.try_recv().is_err());
assert_eq!(
client.test_hooks.last_restored_read_timeout.get(),
Some(Duration::from_millis(50))
);
server.join();
}
#[test]
fn trusted_zero_token_normal_reply_is_terminal() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
// deliberately non-compliant reply is built by encoding a normal
// reply with a placeholder token and then zeroing the token field.
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
socket.send_to(&reply, peer).unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
));
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
server.join();
}
#[test]
fn opening_deadline_overflow_occurs_before_send() {
let server = timeout_server(Duration::from_millis(250));
@@ -305,47 +106,6 @@ fn opening_deadline_overflow_occurs_before_send() {
server.join();
}
#[test]
fn schedule_duration_overflow_cannot_partially_open() {
let server = start_fake_server(|_socket, _tx| {});
let client = Client::connect(default_test_config(server.addr)).unwrap();
let reply = irtt_proto::OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params_from_config(client.runtime.config()).unwrap(),
};
let mut latest = Instant::now();
let mut step = Duration::MAX;
while !step.is_zero() {
if let Some(candidate) = latest.checked_add(step) {
latest = candidate;
}
step /= 2;
}
let opened_at = ClientTimestamp {
mono: latest,
wall: SystemTime::now(),
};
let machine = client
.runtime
.prepare_open_acceptance(reply, opened_at)
.unwrap();
let previous_recv_len = client.recv_buffer.len();
let failure = client.prepare_client_open(machine, opened_at).unwrap_err();
assert!(matches!(
failure.primary,
ClientError::NegotiationRejected { .. }
));
assert!(failure.machine.cleanup_close_packet().is_some());
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert_eq!(client.recv_buffer.len(), previous_recv_len);
server.join();
}
#[test]
fn post_token_negotiation_failure_sends_cleanup_close() {
let mut returned = default_params();
@@ -377,146 +137,143 @@ fn post_token_negotiation_failure_sends_cleanup_close() {
}
#[test]
fn cleanup_send_failure_does_not_replace_primary_open_error() {
let mut returned = default_params();
returned.interval_ns += 1;
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
peer,
)
.unwrap();
});
fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
client.test_hooks.fail_cleanup_send.set(true);
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
assert!(client.runtime.prepare_open_request().is_ok());
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(client.applied_traffic_class, Some(0));
server.join();
}
#[test]
fn receive_buffer_reservation_failure_precedes_dscp_application() {
let mut returned = default_params();
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF), matching
// what the client below requests so negotiation succeeds unchanged.
returned.dscp = 184;
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
fn successful_open_restores_configured_receive_timeout() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params);
let mut config = default_test_config(server.addr);
config.dscp = 46;
config.socket_config.recv_timeout = Some(Duration::from_millis(75));
let mut client = Client::connect(config).unwrap();
client
.test_hooks
.recv_buffer_len_override
.set(Some(usize::MAX));
client.test_hooks.fail_open_dscp.set(true);
assert!(matches!(
client.open(),
Err(ClientError::AllocationFailed {
operation: "negotiated receive buffer",
..
})
));
assert!(client.test_hooks.fail_open_dscp.get());
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert_open_started(client.open().unwrap());
assert_eq!(
client.socket.read_timeout().unwrap(),
Some(Duration::from_millis(75))
);
server.join();
}
#[test]
fn dscp_application_failure_leaves_machine_connected() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
client.test_hooks.fail_open_dscp.set(true);
assert!(matches!(
client.open(),
Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
..
})
));
assert!(client.test_hooks.prepared_active_session_before_dscp.get());
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
fn open_timeout_after_all_timeouts() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
assert_eq!(
client.socket.read_timeout().unwrap(),
Some(Duration::from_millis(50))
);
server.join();
}
#[test]
fn read_timeout_restoration_failure_leaves_machine_connected() {
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
let mut wrong_type = valid.clone();
wrong_type[3] = FLAG_REPLY;
let mut reserved_flags = valid.clone();
reserved_flags[3] |= 0x10;
let mut bad_magic = valid.clone();
bad_magic[0] ^= 0xff;
let unexpected_hmac =
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(b"unexpected"));
for packet in [
vec![0_u8],
bad_magic,
wrong_type,
reserved_flags,
unexpected_hmac,
valid,
] {
socket.send_to(&packet, peer).unwrap();
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
client.test_hooks.fail_open_timeout_restore.set(true);
assert!(matches!(
client.open(),
Err(ClientError::ReadTimeoutRestore { .. })
));
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert_open_started(client.open().unwrap());
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(server.rx.try_recv().is_err());
server.join();
}
#[test]
fn dscp_rollback_failure_keeps_timeout_restoration_primary() {
fn only_ignored_open_datagrams_eventually_time_out() {
let server = start_fake_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_request(&socket, &tx);
socket.send_to(&[0_u8], peer).unwrap();
socket.send_to(&MAGIC, peer).unwrap();
}
thread::sleep(Duration::from_millis(300));
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
assert!(server.rx.try_recv().is_err());
assert_eq!(
client.socket.read_timeout().unwrap(),
Some(Duration::from_millis(50))
);
server.join();
}
#[test]
fn trusted_zero_token_normal_reply_is_terminal() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
// deliberately non-compliant reply is built by encoding a normal
// reply with a placeholder token and then zeroing the token field.
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
socket.send_to(&reply, peer).unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
client.test_hooks.fail_open_timeout_restore.set(true);
client.test_hooks.fail_dscp_restore.set(true);
let error = client.open().unwrap_err();
assert!(matches!(error, ClientError::ReadTimeoutRestore { .. }));
assert!(matches!(
client.open(),
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
));
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
server.join();
}
@@ -1,80 +0,0 @@
use super::*;
/// An interrupted receive (`io::ErrorKind::Interrupted`, e.g. `EINTR`) must be
/// invisible to the caller: retried transparently, with the eventual reply
/// classified exactly as if no interruption had occurred.
#[test]
fn interrupted_receive_is_retried_and_the_reply_is_still_classified() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let _ = size;
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_secs(2)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
client.test_hooks.inject_recv_interrupted.set(10);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], ClientEvent::EchoReply { seq: 0, .. }),
"expected a normal EchoReply despite interrupted receive attempts, got {:?}",
events[0]
);
client.close().unwrap();
server.join();
}
/// One logical receive keeps one timeout budget: repeated interruptions must
/// not extend a `recv_once` call past its configured receive timeout.
#[test]
fn interrupted_receive_does_not_extend_the_configured_timeout() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(100)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
// No reply is ever coming; only interrupted attempts occur before the
// real socket takes over and legitimately times out.
client.test_hooks.inject_recv_interrupted.set(20);
let started = Instant::now();
let events = client.recv_once().unwrap();
let elapsed = started.elapsed();
assert!(events.is_empty());
assert!(
elapsed < Duration::from_millis(500),
"interrupted receives must not extend the configured receive timeout, took {elapsed:?}"
);
client.close().unwrap();
server.join();
}
@@ -20,9 +20,7 @@ fn kernel_rx_timestamp_unavailable_skip(test_name: &str) {
}
mod burst_recv;
mod interrupted_receive;
mod packet_length;
mod probe_schedule;
mod reply_classification;
mod reply_decode;
mod reply_metadata;
@@ -1,487 +0,0 @@
use super::*;
fn probe_timestamps(permission_at: Instant, sent_at: ClientTimestamp) -> ProbeSendTimestamps {
ProbeSendTimestamps {
permission_at,
send_anchor: sent_at,
sent_at,
send_call_start: sent_at.mono,
send_finished_at: sent_at.mono,
}
}
#[test]
fn send_probe_fails_before_open() {
let server = start_fake_server(|_socket, _tx| {});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
server.join();
}
#[test]
fn send_probe_sends_valid_echo_request() {
let params = default_params();
let server = silent_open_server(params.clone());
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(client.next_send_deadline().is_none());
assert_open_started(client.open().unwrap());
let events = client.send_probe().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoSent {
seq, remote, bytes, ..
} => {
assert_eq!(*seq, 0);
assert_eq!(*remote, server.addr);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
thread::sleep(Duration::from_millis(30));
let packets: Vec<_> = server.rx.try_iter().collect();
let echo_reqs: Vec<_> = packets
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
.collect();
let echo_req = echo_reqs.first().unwrap();
assert_eq!(&echo_req[..3], &MAGIC);
assert_eq!(echo_req[3], 0x00);
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
assert_eq!(req_token, TOKEN);
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
assert_eq!(seq, 0);
client.close().unwrap();
server.join();
}
#[test]
fn blocking_short_probe_commits_before_presentation_length_error() {
let params = default_params();
let server = silent_open_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let expected = echo_packet_len(false, &params);
let initial_deadline = client.next_send_deadline().unwrap();
client.probe_reported_len = Some(expected - 1);
assert!(matches!(
client.send_probe(),
Err(ClientError::DatagramLengthMismatch {
expected: error_expected,
actual,
}) if error_expected == expected && actual + 1 == expected
));
assert_eq!(client.runtime.packets_sent(), 1);
assert_eq!(
client.next_send_deadline(),
Some(initial_deadline + client.runtime.config().interval)
);
client.close().unwrap();
server.join();
}
#[test]
fn blocking_failed_probe_send_preserves_machine_and_schedule() {
let params = default_params();
let server = silent_open_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let initial_deadline = client.next_send_deadline().unwrap();
client.probe_send_error = true;
assert!(matches!(client.send_probe(), Err(ClientError::Socket(_))));
assert_eq!(client.runtime.packets_sent(), 0);
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
assert!(matches!(
client.send_probe().unwrap().as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
client.close().unwrap();
server.join();
}
#[test]
fn blocking_timeout_finalization_overflow_does_not_send_or_commit() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::MAX,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let initial_deadline = client.next_send_deadline().unwrap();
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::now(),
};
assert!(matches!(
client.send_probe_at(probe_timestamps(initial_deadline, sent_at)),
Err(ClientError::DurationOverflow)
));
assert_eq!(client.runtime.packets_sent(), 0);
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
thread::sleep(Duration::from_millis(30));
assert!(server
.rx
.try_iter()
.all(|packet| packet.len() >= 4 && packet[3] & FLAG_OPEN != 0));
client.close().unwrap();
server.join();
}
#[test]
fn blocking_probe_separates_permission_committed_send_and_send_call_timing() {
let duration = Duration::from_secs(1);
let interval = Duration::from_millis(100);
let params = Params {
duration_ns: 1_000_000_000,
interval_ns: 100_000_000,
..default_params()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(duration),
interval,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let opened_mono = Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("test host monotonic clock has at least two seconds of history");
client.schedule = Some(
ProbeSchedule::new(
opened_mono,
&NegotiatedParams {
params,
restrictions: Vec::new(),
},
)
.unwrap(),
);
let permission_at = opened_mono + Duration::from_millis(500);
// Distinct pre-send anchor and post-send `sent_at`: the anchor drives
// the operational timeout deadline, while `sent_at` (captured after the
// send call) is the public measurement/timer_error endpoint. They are
// deliberately decoupled by this PR.
let send_anchor = ClientTimestamp {
wall: SystemTime::now(),
mono: opened_mono + Duration::from_millis(525),
};
let sent_at = ClientTimestamp {
wall: SystemTime::now(),
mono: opened_mono + Duration::from_millis(534),
};
let timestamps = ProbeSendTimestamps {
permission_at,
send_anchor,
sent_at,
send_call_start: opened_mono + Duration::from_millis(526),
send_finished_at: opened_mono + Duration::from_millis(533),
};
let events = client.send_probe_at(timestamps).unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoSent {
scheduled_at,
sent_at: event_sent_at,
send_call,
timer_error,
..
}] if *scheduled_at == opened_mono
&& *event_sent_at == sent_at
&& *send_call == Duration::from_millis(7)
&& *timer_error == Duration::from_millis(534)
));
assert_eq!(client.next_send_deadline(), Some(opened_mono + interval));
assert_eq!(client.runtime.packets_sent(), 1);
// The timeout deadline is anchored to the pre-send `send_anchor`, not
// to the post-send `sent_at` captured 9ms later.
let timeout_at = send_anchor.mono + client.probe_timeout();
assert_ne!(timeout_at, sent_at.mono + client.probe_timeout());
assert!(client
.poll_timeouts_at(timeout_at - Duration::from_nanos(1))
.unwrap()
.is_empty());
assert!(matches!(
client.poll_timeouts_at(timeout_at).unwrap().as_slice(),
[ClientEvent::EchoLoss {
sent_at: loss_sent_at,
..
}] if *loss_sent_at == sent_at
));
client.close().unwrap();
server.join();
}
#[test]
fn blocking_send_call_excludes_wrapped_history_cleanup() {
let params = default_params();
let server = silent_open_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let scheduled_at = client.next_send_deadline().unwrap();
let sent_at = ClientTimestamp {
wall: SystemTime::now(),
mono: scheduled_at + Duration::from_millis(5),
};
client.runtime.seed_wrapped_probe_history_for_test(sent_at);
let timestamps = ProbeSendTimestamps {
permission_at: scheduled_at,
send_anchor: sent_at,
sent_at,
send_call_start: scheduled_at + Duration::from_millis(6),
send_finished_at: scheduled_at + Duration::from_millis(13),
};
let events = client.send_probe_at(timestamps).unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoSent {
seq: 0,
send_call,
..
}] if *send_call == Duration::from_millis(7)
));
assert!(!client.runtime.has_timed_out_metadata());
client.close().unwrap();
server.join();
}
#[test]
fn send_probe_respects_finite_duration_exclusive_end() {
let params = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 500_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(500),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let start = client.next_send_deadline().unwrap();
let interval = Duration::from_millis(500);
let now0 = ClientTimestamp {
mono: start,
wall: SystemTime::now(),
};
assert!(client
.send_probe_at(probe_timestamps(now0.mono, now0))
.is_ok());
let now1 = ClientTimestamp {
mono: start + interval,
wall: SystemTime::now(),
};
assert!(client
.send_probe_at(probe_timestamps(now1.mono, now1))
.is_ok());
let now2 = ClientTimestamp {
mono: start + Duration::from_secs(1),
wall: SystemTime::now(),
};
let events = client
.send_probe_at(probe_timestamps(now2.mono, now2))
.unwrap();
assert!(events.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn managed_probe_skips_missed_schedule_slots() {
let interval = Duration::from_millis(10);
let params = Params {
protocol_version: 1,
duration_ns: 0,
interval_ns: 10_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = echo_server(params);
let config = ClientConfig {
duration: None,
interval,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let first_deadline = client.next_send_deadline().unwrap();
let permission_at = first_deadline + Duration::from_millis(45);
let delayed_send = ClientTimestamp {
mono: first_deadline + Duration::from_millis(48),
wall: SystemTime::now(),
};
let timestamps = ProbeSendTimestamps {
permission_at,
send_anchor: delayed_send,
sent_at: delayed_send,
send_call_start: first_deadline + Duration::from_millis(49),
send_finished_at: first_deadline + Duration::from_millis(55),
};
let events = client
.send_managed_probe_at(first_deadline, timestamps)
.unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
ClientEvent::EchoSent {
seq: 0,
scheduled_at,
sent_at,
send_call,
timer_error,
..
} if scheduled_at == first_deadline + Duration::from_millis(40)
&& sent_at == delayed_send
&& send_call == Duration::from_millis(6)
&& timer_error == Duration::from_millis(8)
));
assert_eq!(
client.next_send_deadline(),
Some(first_deadline + Duration::from_millis(50))
);
client.close().unwrap();
server.join();
}
#[test]
fn managed_probe_skip_preserves_finite_run_end() {
let interval = Duration::from_millis(10);
let duration = Duration::from_millis(50);
let params = Params {
protocol_version: 1,
duration_ns: 50_000_000,
interval_ns: 10_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = echo_server(params);
let config = ClientConfig {
duration: Some(duration),
interval,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let first_deadline = client.next_send_deadline().unwrap();
let delayed_send = ClientTimestamp {
mono: first_deadline + Duration::from_millis(45),
wall: SystemTime::now(),
};
let first = client
.send_managed_probe_at(
first_deadline,
probe_timestamps(delayed_send.mono, delayed_send),
)
.unwrap();
let second_sent_at = ClientTimestamp {
mono: first_deadline + Duration::from_millis(46),
wall: SystemTime::now(),
};
let second = client
.send_managed_probe_at(
first_deadline + duration,
probe_timestamps(second_sent_at.mono, second_sent_at),
)
.unwrap();
assert_eq!(first.len(), 1);
assert!(second.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn receive_wait_is_bounded_by_next_ten_millisecond_deadline() {
let now = Instant::now();
let deadline = now + Duration::from_millis(10);
assert_eq!(
bounded_receive_timeout(
Some(deadline),
Some(Duration::from_millis(20)),
Duration::from_millis(20),
now,
),
Some(Duration::from_millis(10))
);
assert_eq!(
bounded_receive_timeout(
Some(now),
Some(Duration::from_millis(20)),
Duration::from_millis(20),
now,
),
None
);
}
#[test]
fn connect_rejects_invalid_probe_limits() {
for config in [
ClientConfig {
max_pending_probes: 0,
..ClientConfig::default()
},
ClientConfig {
probe_timeout: Duration::ZERO,
..ClientConfig::default()
},
] {
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
+8 -1
View File
@@ -85,12 +85,18 @@ pub struct ClientConfig {
/// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes.
///
/// The managed driver stops sending after the negotiated duration.
/// Low-level adapters leave run duration to the caller.
pub duration: Option<Duration>,
/// Requested spacing between probe sends.
///
/// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions.
///
/// The managed driver uses the negotiated interval for cadence.
/// Low-level adapters send whenever called.
pub interval: Duration,
/// Requested echo packet payload length, in bytes.
///
@@ -273,7 +279,8 @@ pub enum NegotiationPolicy {
/// Mode requested during the IRTT open exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
/// Open a session and send echo probes for the negotiated run duration.
/// Open a probe session. The managed driver runs for the negotiated duration;
/// low-level adapters leave sending and stopping to the caller.
Normal,
/// Complete negotiation without running the echo probe test.
NoTest,
+5 -4
View File
@@ -92,8 +92,9 @@ pub enum ClientEvent {
seq: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Monotonic deadline at which the probe was scheduled to be sent.
scheduled_at: Instant,
/// Intended send deadline, supplied by the managed runner.
/// Low-level adapters leave this absent.
scheduled_at: Option<Instant>,
/// Paired client wall/monotonic timestamp captured immediately after
/// the successful UDP send completed. This is the RTT and userspace
/// upstream one-way-delay fallback endpoint. The operational probe
@@ -106,8 +107,8 @@ pub enum ClientEvent {
/// scheduler commit work.
send_call: Duration,
/// Absolute difference between the scheduled send time and the
/// post-send `sent_at` timestamp above.
timer_error: Duration,
/// post-send `sent_at` timestamp above; absent for low-level sends.
timer_error: Option<Duration>,
},
/// First in-window reply for a pending echo request.
+3
View File
@@ -18,3 +18,6 @@ pub use task::*;
#[cfg(all(feature = "tokio", test))]
mod blocking_tests;
#[cfg(feature = "tokio")]
mod schedule;
@@ -4,7 +4,6 @@ use crate::{error::ClientError, session::NegotiatedParams};
#[derive(Debug)]
pub(crate) struct ProbeSchedule {
start_at: Instant,
end_at: Option<Instant>,
interval: Duration,
next_send_at: Option<Instant>,
@@ -39,7 +38,6 @@ impl ProbeSchedule {
};
Ok(Self {
start_at,
end_at,
interval,
next_send_at: Some(start_at),
@@ -50,7 +48,6 @@ impl ProbeSchedule {
self.next_send_at
}
#[cfg(feature = "tokio")]
pub(crate) fn interval(&self) -> Duration {
self.interval
}
@@ -66,26 +63,12 @@ impl ProbeSchedule {
true
}
pub(crate) fn preflight_caller_commit(
&self,
next_packets_sent: u64,
) -> Result<ScheduleCommit, ClientError> {
let scheduled_at = self
.next_send_at
.expect("a caller schedule commit requires a permitted probe");
let interval_ns =
u64::try_from(self.interval.as_nanos()).map_err(|_| ClientError::DurationOverflow)?;
let next_send_at = next_probe_deadline(self.start_at, interval_ns, next_packets_sent)?;
Ok(self.finish_commit(scheduled_at, next_send_at))
}
#[cfg(any(feature = "tokio", test))]
pub(crate) fn preflight_managed_commit(
&self,
scheduled_at: Instant,
permission_at: Instant,
) -> Result<ScheduleCommit, ClientError> {
let (scheduled_at, next_send_at, _) =
let (scheduled_at, next_send_at) =
advance_cadence(scheduled_at, self.interval, permission_at)?;
Ok(self.finish_commit(scheduled_at, next_send_at))
}
@@ -94,6 +77,16 @@ impl ProbeSchedule {
self.next_send_at = commit.next_send_at;
}
/// Reclaim slots crossed while a successful socket submission was in flight.
/// This follows the send commit; it never advances a failed submission.
pub(crate) fn skip_missed_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
if let Some(deadline) = self.next_send_at.filter(|deadline| *deadline <= now) {
let commit = self.preflight_managed_commit(deadline, now)?;
self.commit(commit);
}
Ok(())
}
pub(crate) fn is_finished(&self) -> bool {
self.next_send_at.is_none()
}
@@ -110,25 +103,11 @@ impl ProbeSchedule {
}
}
fn next_probe_deadline(
start: Instant,
interval_ns: u64,
packets_sent: u64,
) -> Result<Instant, ClientError> {
let offset_ns = interval_ns
.checked_mul(packets_sent)
.ok_or(ClientError::DurationOverflow)?;
start
.checked_add(Duration::from_nanos(offset_ns))
.ok_or(ClientError::DurationOverflow)
}
#[cfg(any(feature = "tokio", test))]
pub(crate) fn advance_cadence(
deadline: Instant,
interval: Duration,
now: Instant,
) -> Result<(Instant, Instant, u128), ClientError> {
) -> Result<(Instant, Instant), ClientError> {
if interval.is_zero() {
return Err(ClientError::InvalidConfig {
reason: "probe interval must be greater than zero".to_owned(),
@@ -160,10 +139,9 @@ pub(crate) fn advance_cadence(
let next_at = deadline
.checked_add(next_offset)
.ok_or(ClientError::DurationOverflow)?;
Ok((scheduled_at, next_at, elapsed_slots))
Ok((scheduled_at, next_at))
}
#[cfg(any(feature = "tokio", test))]
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
const NANOS_PER_SECOND: u128 = 1_000_000_000;
let seconds =
@@ -178,16 +156,15 @@ pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
.or_else(|| right.checked_duration_since(left))
.unwrap_or(Duration::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_proto::Params;
fn negotiated(interval_ns: i64, duration_ns: i64) -> NegotiatedParams {
fn negotiated(duration_ns: i64) -> NegotiatedParams {
NegotiatedParams {
params: Params {
interval_ns,
interval_ns: 10_000_000,
duration_ns,
..Params::default()
},
@@ -196,123 +173,56 @@ mod tests {
}
#[test]
fn initial_deadline_is_the_opening_time() {
let opened_at = Instant::now();
let schedule = ProbeSchedule::new(opened_at, &negotiated(10_000_000, 0)).unwrap();
assert_eq!(schedule.next_send_deadline(), Some(opened_at));
assert!(!schedule.is_finished());
}
#[test]
fn caller_preflight_keeps_absolute_cadence_without_mutation() {
fn missed_slots_skip_on_the_absolute_grid_only_after_commit() {
let start = Instant::now();
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
let commit = schedule.preflight_caller_commit(3).unwrap();
assert_eq!(commit.scheduled_at, start);
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(30)));
assert_eq!(schedule.next_send_deadline(), Some(start));
}
#[test]
fn caller_commit_advances_exactly_one_cadence_slot() {
let start = Instant::now();
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
let commit = schedule.preflight_caller_commit(1).unwrap();
assert_eq!(schedule.next_send_deadline(), Some(start));
let mut schedule = ProbeSchedule::new(start, &negotiated(0)).unwrap();
for (now_ms, intended_ms, next_ms) in [(0, 0, 10), (45, 40, 50), (50, 50, 60)] {
let deadline = schedule.next_send_deadline().unwrap();
let commit = schedule
.preflight_managed_commit(deadline, start + Duration::from_millis(now_ms))
.unwrap();
assert_eq!(
commit.scheduled_at,
start + Duration::from_millis(intended_ms)
);
// A failed or pending submission discards this preflight.
assert_eq!(schedule.next_send_deadline(), Some(deadline));
schedule.commit(commit);
assert_eq!(
schedule.next_send_deadline(),
Some(start + Duration::from_millis(10))
Some(start + Duration::from_millis(next_ms))
);
}
#[test]
fn finite_duration_uses_an_exclusive_end_boundary() {
let start = Instant::now();
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
let first = schedule.preflight_caller_commit(1).unwrap();
schedule.commit(first);
schedule
.skip_missed_slots_at(start + Duration::from_millis(85))
.unwrap();
assert_eq!(
schedule.next_send_deadline(),
Some(start + Duration::from_millis(10))
Some(start + Duration::from_millis(90))
);
let second = schedule.preflight_caller_commit(2).unwrap();
schedule.commit(second);
assert!(schedule.is_finished());
assert_eq!(schedule.next_send_deadline(), None);
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
}
#[test]
fn permit_at_exclusive_end_finishes_schedule_without_a_send() {
fn finite_duration_excludes_its_end_even_when_a_slot_is_missed() {
let start = Instant::now();
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
assert!(schedule.is_finished());
assert_eq!(schedule.next_send_deadline(), None);
}
#[test]
fn managed_preflight_skips_missed_slots_without_mutation() {
let start = Instant::now();
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
let sent_at = start + Duration::from_millis(45);
let commit = schedule.preflight_managed_commit(start, sent_at).unwrap();
assert_eq!(commit.scheduled_at, start + Duration::from_millis(40));
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(50)));
assert_eq!(schedule.next_send_deadline(), Some(start));
}
#[test]
fn delayed_managed_commit_does_not_replay_catch_up_slots() {
let start = Instant::now();
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
let delayed_send = start + Duration::from_millis(45);
let mut schedule = ProbeSchedule::new(start, &negotiated(20_000_000)).unwrap();
assert!(schedule.permit_probe_at(start + Duration::from_millis(19)));
let commit = schedule
.preflight_managed_commit(start, delayed_send)
.preflight_managed_commit(start, start + Duration::from_millis(19))
.unwrap();
schedule.commit(commit);
assert!(schedule.is_finished());
assert_eq!(
schedule.next_send_deadline(),
Some(start + Duration::from_millis(50))
);
assert!(schedule.next_send_deadline().unwrap() > delayed_send);
let mut missed = ProbeSchedule::new(start, &negotiated(20_000_000)).unwrap();
assert!(!missed.permit_probe_at(start + Duration::from_millis(20)));
assert!(missed.is_finished());
}
#[test]
fn deadline_arithmetic_overflow_is_detected_before_commit() {
let start = Instant::now();
let schedule = ProbeSchedule::new(start, &negotiated(2, 0)).unwrap();
fn cadence_rejects_overflow_before_commit() {
assert!(matches!(
schedule.preflight_caller_commit(u64::MAX),
advance_cadence(Instant::now(), Duration::MAX, Instant::now()),
Err(ClientError::DurationOverflow)
));
assert_eq!(schedule.next_send_deadline(), Some(start));
}
#[test]
fn timer_error_is_adapter_owned() {
let scheduled_at = Instant::now();
let sent_at = scheduled_at + Duration::from_millis(7);
assert_eq!(
instant_abs_diff(sent_at, scheduled_at),
Duration::from_millis(7)
);
}
}
+114 -14
View File
@@ -25,6 +25,8 @@ use crate::{
ClientConfig, ClientError, ClientEvent, OpenOutcome,
};
use super::schedule::{instant_abs_diff, ProbeSchedule};
use super::{
classify_client_error, ManagedClientConfig, ManagedCommandAcknowledgement,
ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError,
@@ -504,6 +506,7 @@ impl TargetCounters {
}
struct TargetRuntime {
schedule: Option<ProbeSchedule>,
instance: TargetInstance,
config: ManagedTargetConfig,
desired: bool,
@@ -991,6 +994,7 @@ impl ManagedClientTask {
};
created_instances.push(instance.clone());
self.targets.push(TargetRuntime {
schedule: None,
instance,
server_addr: Arc::from(planned.target.server_addr.clone()),
config: planned.target,
@@ -1243,7 +1247,25 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Opening { client, open };
false
}
Poll::Ready(Ok(OpenOutcome::Started { event, .. })) => {
Poll::Ready(Ok(OpenOutcome::Started {
event, negotiated, ..
})) => {
let ClientEvent::SessionStarted { at: opened_at, .. } = &event else {
unreachable!("Started carries SessionStarted");
};
match ProbeSchedule::new(opened_at.mono, &negotiated) {
Ok(schedule) => self.targets[index].schedule = Some(schedule),
Err(error) => {
return self.begin_open_session_failure(
index,
client,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Close,
)
}
}
self.publish_client_events(index, vec![event]);
if self.state == DriverState::Stopping
|| self.effective_retirement(index).is_some()
@@ -1340,7 +1362,15 @@ impl ManagedClientTask {
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
return false;
}
if client.is_run_complete() {
if self.targets[index]
.schedule
.as_mut()
.is_some_and(|schedule| {
schedule.permit_probe_at(now);
schedule.is_finished()
})
&& !client.has_pending_probes()
{
self.targets[index].sync_packets_sent(&client);
return self.begin_drain(
index,
@@ -1620,13 +1650,15 @@ impl ManagedClientTask {
if !target.desired || target.retirement.is_some() {
continue;
}
let TargetState::Active { client } = &target.state else {
let TargetState::Active { .. } = &target.state else {
continue;
};
active += 1;
let interval = client
.probe_interval()
.expect("active managed targets have a committed probe schedule");
let interval = target
.schedule
.as_ref()
.expect("active managed targets have a probe schedule")
.interval();
minimum = minimum.into_iter().chain(Some(interval)).min();
}
minimum.map(|interval| (active, stagger_spacing(interval, active)))
@@ -1709,6 +1741,9 @@ impl ManagedClientTask {
self.targets[index].state = state;
return SendResult::NotAttempted;
};
// A burst pass may span several slots; validate this target against
// fresh time rather than the pass's earlier scheduling snapshot.
let now = now.max(Instant::now());
if client
.next_probe_timeout_deadline()
.is_some_and(|deadline| deadline <= now)
@@ -1717,7 +1752,21 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted;
}
if client
let schedule = self.targets[index]
.schedule
.as_mut()
.expect("active managed targets have a probe schedule");
if !schedule.permit_probe_at(now) {
self.targets[index].send_waiting = false;
if client.has_pending_probes() {
self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted;
}
self.targets[index].sync_packets_sent(&client);
self.begin_drain(index, client, ManagedTargetEndReason::TestComplete, now);
return SendResult::Ready { accepted: false };
}
if schedule
.next_send_deadline()
.is_none_or(|deadline| deadline > now)
{
@@ -1725,8 +1774,25 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted;
}
let schedule = self.targets[index].schedule.as_ref().unwrap();
let commit =
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
Ok(commit) => commit,
Err(error) => {
self.begin_open_session_failure(
index,
client,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Drain,
);
return SendResult::Failed { accepted: false };
}
};
let scheduled_at = commit.scheduled_at;
let before = client.packets_sent();
let result = client.poll_send_probe(cx);
let mut result = client.poll_send_probe(cx);
let after = client.packets_sent();
let accepted = after > before;
let send_result = match &result {
@@ -1735,10 +1801,37 @@ impl ManagedClientTask {
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
};
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
let schedule_error = accepted
.then(|| client.skip_missed_probe_slots_at(Instant::now()))
.transpose()
.err();
if accepted {
self.targets[index]
.schedule
.as_mut()
.unwrap()
.commit(commit);
if let Poll::Ready(Ok(events)) = &mut result {
for event in events {
if let ClientEvent::EchoSent {
scheduled_at: intended,
sent_at,
timer_error,
..
} = event
{
*intended = Some(scheduled_at);
*timer_error = Some(instant_abs_diff(sent_at.mono, scheduled_at));
}
}
}
}
let schedule_error = if accepted {
self.targets[index]
.schedule
.as_mut()
.unwrap()
.skip_missed_slots_at(Instant::now())
.err()
} else {
None
};
self.targets[index].record_packets_sent(after);
match result {
Poll::Pending => {
@@ -1754,7 +1847,7 @@ impl ManagedClientTask {
let TargetState::Active { client } =
mem::replace(&mut self.targets[index].state, TargetState::Terminal)
else {
unreachable!("probe sender remained active while publishing send events");
unreachable!("sender remained active")
};
self.begin_open_session_failure(
index,
@@ -1769,6 +1862,7 @@ impl ManagedClientTask {
SendResult::Ready { accepted }
}
}
Poll::Ready(Err(error)) => {
self.targets[index].send_waiting = false;
self.begin_open_session_failure(
@@ -2032,7 +2126,12 @@ impl ManagedClientTask {
if target.desired && target.retirement.is_none() && !target.send_waiting {
send_deadline = send_deadline
.into_iter()
.chain(client.next_send_deadline())
.chain(
target
.schedule
.as_ref()
.and_then(ProbeSchedule::next_send_deadline),
)
.min();
}
}
@@ -2349,6 +2448,7 @@ fn build_task(
}
})?;
runtimes.push(TargetRuntime {
schedule: None,
instance: TargetInstance {
id: target.id.clone(),
generation,
+8 -949
View File
@@ -6,7 +6,7 @@ use std::{
sync::{Arc, Condvar, Mutex},
task::{Context, Poll, Waker},
thread::{self, JoinHandle},
time::{Duration, Instant, SystemTime},
time::{Duration, Instant},
};
use irtt_proto::{
@@ -16,10 +16,7 @@ use irtt_proto::{
use tokio::runtime::{Builder, Runtime};
use super::*;
use crate::{
probe::PendingProbe, socket::resolution_call_counts, ClientAuthConfig, ClientTimestamp,
NegotiationPolicy, RunMode,
};
use crate::{socket::resolution_call_counts, ClientAuthConfig, NegotiationPolicy, RunMode};
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
@@ -337,110 +334,6 @@ fn wait_flag(flag: &Arc<(Mutex<bool>, Condvar)>) {
assert!(*value && !timeout.timed_out());
}
fn client_events(
observations: &Mutex<Vec<(ManagedEvent, Arc<ManagedStatus>)>>,
) -> Vec<ClientEvent> {
observations
.lock()
.unwrap()
.iter()
.filter_map(|(event, _)| match event {
ManagedEvent::Client { event, .. } => Some(event.clone()),
_ => None,
})
.collect()
}
fn timeout_probe(seq: u32, sent_at: Instant, timeout_at: Instant) -> PendingProbe {
PendingProbe {
wire_seq: seq,
sent_at: ClientTimestamp {
mono: sent_at,
wall: SystemTime::UNIX_EPOCH,
},
timeout_at,
tx_not_before_wall: SystemTime::UNIX_EPOCH,
kernel_tx_timestamp: None,
}
}
fn open_target_for_timeout_test(runtime: &Runtime, task: &mut ManagedClientTask, index: usize) {
let TargetState::Pending { client_config } =
mem::replace(&mut task.targets[index].state, TargetState::Terminal)
else {
panic!("timeout test target must start pending");
};
let client = runtime.block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
client.open().await.unwrap();
client
});
task.targets[index].desired = false;
task.install_target_state(index, TargetState::Active { client });
}
fn replace_pending_for_timeout_test(
task: &mut ManagedClientTask,
index: usize,
probe: PendingProbe,
) {
let client = match &mut task.targets[index].state {
TargetState::Active { client } | TargetState::Draining { client, .. } => client,
_ => panic!("timeout test target must be active or draining"),
};
client.replace_pending_for_test(probe);
}
fn remove_pending_for_timeout_test(
task: &mut ManagedClientTask,
index: usize,
wire_seq: u32,
) -> PendingProbe {
let client = match &mut task.targets[index].state {
TargetState::Active { client } | TargetState::Draining { client, .. } => client,
_ => panic!("timeout test target must be active or draining"),
};
client
.remove_pending_for_test(wire_seq)
.expect("timeout test pending probe must exist")
}
fn close_timeout_test_target(runtime: &Runtime, task: &mut ManagedClientTask, index: usize) {
let (TargetState::Active { mut client } | TargetState::Draining { mut client, .. }) =
mem::replace(&mut task.targets[index].state, TargetState::Terminal)
else {
panic!("timeout test target did not remain active or draining");
};
runtime.block_on(client.close()).unwrap();
}
fn poll_task_once(runtime: &Runtime, task: &mut Pin<Box<ManagedClientTask>>) {
runtime.block_on(poll_fn(|cx| {
assert!(task.as_mut().poll(cx).is_pending());
Poll::Ready(())
}));
}
fn timeout_losses_for(
observations: &Mutex<Vec<(ManagedEvent, Arc<ManagedStatus>)>>,
id: &str,
) -> usize {
observations
.lock()
.unwrap()
.iter()
.filter(|(event, _)| {
matches!(
event,
ManagedEvent::Client {
target,
event: ClientEvent::EchoLoss { .. },
} if target.id.as_ref() == id
)
})
.count()
}
fn probes(records: &[PacketRecord]) -> Vec<Instant> {
records
.iter()
@@ -471,7 +364,12 @@ fn echo_sends(
sent_at,
..
},
} => Some((target.clone(), *seq, *scheduled_at, sent_at.mono)),
} => Some((
target.clone(),
*seq,
scheduled_at.expect("managed sends carry schedule metadata"),
sent_at.mono,
)),
_ => None,
})
.collect()
@@ -1638,83 +1536,6 @@ fn peer_close_during_stop_drain_remains_authoritative() {
assert!(!has_close(&records));
}
#[test]
fn stop_during_opening_finishes_in_flight_work() {
let open_gate = Arc::new(PacketGate::default());
let server = start_server_with_gates(
ServerBehavior::Echo,
None,
None,
Some(Arc::clone(&open_gate)),
None,
);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
managed.client.open_timeouts = vec![Duration::from_secs(1), Duration::from_secs(1)];
let (task, handle) =
ManagedClient::task(managed, vec![target("success", server.addr)]).unwrap();
let runtime = runtime();
let mut task = Box::pin(task);
drive_task_until(&runtime, &mut task, |task| match &task.targets[0].state {
TargetState::Opening { open, .. } => open.has_in_flight_work(),
_ => false,
});
drop(handle.stop());
open_gate.release();
let outcome = runtime.block_on(task);
let records = server.finish();
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
assert!(matches!(
outcome.recent_target_outcomes[0].end_reason,
ManagedTargetEndReason::Stopped
));
assert_eq!(
records
.iter()
.filter(|record| matches!(record.kind, PacketKind::Open))
.count(),
1
);
assert!(probes(&records).is_empty() && has_close(&records));
let key = b"managed-retained-open-cleanup".to_vec();
let cleanup_server = start_server(ServerBehavior::Echo, Some(key.clone()));
let mut configured = target("cleanup", cleanup_server.addr);
configured.auth = Some(ClientAuthConfig {
hmac_key: Some(key),
});
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (task, handle) = ManagedClient::task(managed, vec![configured]).unwrap();
let mut task = Box::pin(task);
drive_task_until(&runtime, &mut task, |task| {
matches!(task.targets[0].state, TargetState::Opening { .. })
});
let TargetState::Opening { client, .. } = &task.as_ref().get_ref().targets[0].state else {
unreachable!()
};
client.retain_open_cleanup_once();
drive_task_until(&runtime, &mut task, |task| match &task.targets[0].state {
TargetState::Opening { open, .. } => open.has_retained_cleanup(),
_ => false,
});
drop(handle.stop());
let outcome = runtime.block_on(task);
let records = cleanup_server.finish();
let target = &outcome.recent_target_outcomes[0];
assert!(matches!(target.end_reason, ManagedTargetEndReason::Stopped));
assert_eq!(outcome.failed_target_outcomes, 0);
assert!(matches!(
target.cleanup_failure,
Some(ManagedTargetFailure {
phase: ManagedTargetFailurePhase::Opening,
..
})
));
assert!(probes(&records).is_empty() && has_close(&records));
}
#[test]
fn static_multi_target_completion() {
let first = start_server(ServerBehavior::Echo, None);
@@ -1983,768 +1804,6 @@ fn timeout_discovery_inspects_at_most_one_budget_of_targets() {
assert_eq!(task.timeout_inspections, TIMEOUT_WORK_BUDGET);
}
#[test]
fn sparse_timeout_backlog_persists_across_polls() {
let server = start_server(ServerBehavior::Echo, None);
let target_count = (2 * TIMEOUT_WORK_BUDGET) + 1;
let mut managed = config(ManagedPacing::Burst);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
managed.max_live_target_generations = target_count;
let targets = std::iter::once(target("sparse", server.addr))
.chain(
(1..target_count)
.map(|index| ManagedTargetConfig::new(format!("target-{index}"), "127.0.0.1:9")),
)
.collect();
let (mut task, _) = ManagedClient::task(managed, targets).unwrap();
for target in &mut task.targets[1..] {
target.state = TargetState::Terminal;
}
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let now = Instant::now();
for seq in 0..u32::try_from((2 * TIMEOUT_WORK_BUDGET) + 64).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
assert!(task.poll_timeout_pass(now));
assert_eq!(
timeout_losses_for(&observations, "sparse"),
TIMEOUT_WORK_BUDGET
);
assert_eq!(task.timeout_backlog.len(), 1);
assert!(task.poll_timeout_pass(now));
assert_eq!(
timeout_losses_for(&observations, "sparse"),
2 * TIMEOUT_WORK_BUDGET
);
assert_eq!(task.timeout_backlog.len(), 1);
close_timeout_test_target(&runtime, &mut task, 0);
drop(task);
server.finish();
}
#[test]
fn timeout_backlog_does_not_starve_discovery() {
let first = start_server(ServerBehavior::Echo, None);
let second = start_server(ServerBehavior::Echo, None);
let target_count = (2 * TIMEOUT_WORK_BUDGET) + 1;
let late_index = target_count - 1;
let mut managed = config(ManagedPacing::Burst);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
managed.max_live_target_generations = target_count;
let targets = (0..target_count)
.map(|index| match index {
0 => target("backlog", first.addr),
index if index == late_index => target("late", second.addr),
_ => ManagedTargetConfig::new(format!("target-{index}"), "127.0.0.1:9"),
})
.collect();
let (mut task, _) = ManagedClient::task(managed, targets).unwrap();
for target in &mut task.targets[1..late_index] {
target.state = TargetState::Terminal;
}
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
open_target_for_timeout_test(&runtime, &mut task, late_index);
let now = Instant::now();
for seq in 0..u32::try_from(3 * TIMEOUT_WORK_BUDGET).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
replace_pending_for_timeout_test(
&mut task,
late_index,
timeout_probe(
0,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
task.timeout_backlog.push_back(TimeoutBacklogEntry {
index: 0,
generation: task.targets[0].instance.generation,
});
task.timeout_cursor = 1;
assert!(task.poll_timeout_pass(now));
assert_eq!(timeout_losses_for(&observations, "late"), 0);
assert!(task.poll_timeout_pass(now));
assert_eq!(timeout_losses_for(&observations, "late"), 1);
assert_eq!(task.timeout_backlog.len(), 1);
close_timeout_test_target(&runtime, &mut task, 0);
close_timeout_test_target(&runtime, &mut task, late_index);
drop(task);
first.finish();
second.finish();
}
#[test]
fn stale_timeout_backlog_entry_is_dropped_after_pruning() {
let server = start_server(ServerBehavior::Echo, None);
let target_count = TIMEOUT_WORK_BUDGET + 2;
let mut managed = config(ManagedPacing::Burst);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
managed.max_live_target_generations = target_count;
let targets = vec![
ManagedTargetConfig::new("retired", "127.0.0.1:9"),
target("survivor", server.addr),
]
.into_iter()
.chain(
(2..target_count)
.map(|index| ManagedTargetConfig::new(format!("target-{index}"), "127.0.0.1:9")),
)
.collect();
let (mut task, _) = ManagedClient::task(managed, targets).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 1);
let now = Instant::now();
replace_pending_for_timeout_test(
&mut task,
1,
timeout_probe(
0,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
task.timeout_backlog.push_back(TimeoutBacklogEntry {
index: 0,
generation: task.targets[0].instance.generation,
});
task.targets[0].desired = false;
task.targets[0].state = TargetState::Terminal;
task.prune_undesired_terminal();
task.timeout_cursor = 1;
assert!(!task.poll_timeout_pass(now));
assert!(task.timeout_backlog.is_empty());
assert_eq!(timeout_losses_for(&observations, "survivor"), 0);
task.timeout_cursor = 0;
assert!(!task.poll_timeout_pass(now));
assert_eq!(timeout_losses_for(&observations, "survivor"), 1);
close_timeout_test_target(&runtime, &mut task, 0);
drop(task);
server.finish();
}
#[test]
fn immediate_timeout_backlog_skips_global_deadline_scan() {
let server = start_server(ServerBehavior::Echo, None);
let mut managed = config(ManagedPacing::Burst);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, _) = ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let now = Instant::now();
for seq in 0..u32::try_from(TIMEOUT_WORK_BUDGET + 1).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
task.timeout_backlog.push_back(TimeoutBacklogEntry {
index: 0,
generation: task.targets[0].instance.generation,
});
task.deadline_inspections = 0;
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(task.deadline_inspections, 0);
assert_eq!(task.timeout_backlog.len(), 1);
assert!(!task.poll_timeout_pass(now));
assert!(task.timeout_backlog.is_empty());
task.deadline_inspections = 0;
poll_task_once(&runtime, &mut task);
assert_eq!(task.deadline_inspections, 1);
close_timeout_test_target(&runtime, &mut task, 0);
drop(task);
server.finish();
}
#[test]
fn timeout_discovery_reaches_later_target_after_budget_slice() {
let server = start_server(ServerBehavior::Echo, None);
let target_count = TIMEOUT_WORK_BUDGET + 1;
let late_index = target_count - 1;
let mut managed = config(ManagedPacing::Burst);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
managed.max_live_target_generations = target_count;
let targets = (0..target_count)
.map(|index| {
if index == late_index {
target("late", server.addr)
} else {
ManagedTargetConfig::new(format!("target-{index}"), "127.0.0.1:9")
}
})
.collect();
let (mut task, _) = ManagedClient::task(managed, targets).unwrap();
for target in &mut task.targets[..late_index] {
target.state = TargetState::Terminal;
}
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, late_index);
let now = Instant::now();
replace_pending_for_timeout_test(
&mut task,
late_index,
timeout_probe(0, now - Duration::from_secs(1), now),
);
task.timeout_inspections = 0;
assert!(!task.poll_timeout_pass(now));
assert_eq!(task.timeout_inspections, TIMEOUT_WORK_BUDGET);
assert_eq!(timeout_losses_for(&observations, "late"), 0);
task.timeout_inspections = 0;
assert!(!task.poll_timeout_pass(now));
assert_eq!(task.timeout_inspections, TIMEOUT_WORK_BUDGET);
assert_eq!(timeout_losses_for(&observations, "late"), 1);
let TargetState::Active { mut client } =
mem::replace(&mut task.targets[late_index].state, TargetState::Terminal)
else {
panic!("later timeout target did not remain active");
};
runtime.block_on(client.close()).unwrap();
drop(task);
server.finish();
}
#[test]
fn timeout_work_is_bounded_per_task_poll_and_eventually_drains() {
let server = start_server(ServerBehavior::Echo, None);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) =
ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let now = Instant::now();
for seq in 0..u32::try_from(TIMEOUT_WORK_BUDGET + 1).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET
);
assert!(matches!(
&task.targets[0].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_some()
));
drive_task_until(&runtime, &mut task, |task| {
matches!(
&task.targets[0].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_none()
)
});
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET + 1
);
drop(handle.stop());
runtime.block_on(task);
server.finish();
}
#[test]
fn timeout_work_round_robins_simultaneous_target_backlogs() {
let first = start_server(ServerBehavior::Echo, None);
let second = start_server(ServerBehavior::Echo, None);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) = ManagedClient::task(
managed,
vec![target("first", first.addr), target("second", second.addr)],
)
.unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
open_target_for_timeout_test(&runtime, &mut task, 1);
let now = Instant::now();
let per_target = TIMEOUT_WORK_BUDGET + 16;
for index in 0..2 {
for seq in 0..u32::try_from(per_target).unwrap() {
replace_pending_for_timeout_test(
&mut task,
index,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
}
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "first"),
TIMEOUT_WORK_BUDGET / 2
);
assert_eq!(
timeout_losses_for(&observations, "second"),
TIMEOUT_WORK_BUDGET / 2
);
assert!(matches!(
&task.targets[0].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_some()
));
assert!(matches!(
&task.targets[1].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_some()
));
drive_task_until(&runtime, &mut task, |task| {
task.targets.iter().all(|target| {
matches!(
&target.state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_none()
)
})
});
assert_eq!(timeout_losses_for(&observations, "first"), per_target);
assert_eq!(timeout_losses_for(&observations, "second"), per_target);
drop(handle.stop());
runtime.block_on(task);
first.finish();
second.finish();
}
#[test]
fn budget_deferred_timeout_blocks_send_until_timeout_processing() {
let reply_gate = Arc::new(PacketGate::default());
let server = start_server_with_gates(
ServerBehavior::Echo,
None,
None,
None,
Some(Arc::clone(&reply_gate)),
);
let target_count = TIMEOUT_WORK_BUDGET + 1;
let late_index = target_count - 1;
let mut managed = config(ManagedPacing::Burst);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
managed.client.interval = Duration::from_nanos(1);
managed.client.probe_timeout = Duration::from_secs(1);
managed.client.max_pending_probes = 1;
managed.max_live_target_generations = target_count;
let targets = (0..target_count)
.map(|index| {
if index == late_index {
target("late", server.addr)
} else {
ManagedTargetConfig::new(format!("target-{index}"), "127.0.0.1:9")
}
})
.collect();
let (mut task, _) = ManagedClient::task(managed, targets).unwrap();
for target in &mut task.targets[..late_index] {
target.state = TargetState::Terminal;
}
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, late_index);
task.targets[late_index].desired = true;
let sent = match &mut task.targets[late_index].state {
TargetState::Active { client } => runtime.block_on(client.send_probe()).unwrap(),
_ => panic!("deferred-send target did not open"),
};
assert!(matches!(
sent.as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
wait_flag(&server.probe_seen);
let overdue = Instant::now();
replace_pending_for_timeout_test(
&mut task,
late_index,
timeout_probe(0, overdue - Duration::from_secs(1), overdue),
);
task.state = DriverState::Running;
task.lifecycle = ManagedLifecycle::Running;
task.timeout_cursor = 0;
task.send_cursor = late_index;
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(task.timeout_inspections, TIMEOUT_WORK_BUDGET);
assert_eq!(timeout_losses_for(&observations, "late"), 0);
assert!(matches!(
&task.targets[late_index].state,
TargetState::Active { client }
if client.packets_sent() == 1
&& client.next_probe_timeout_deadline() == Some(overdue)
));
assert!(!task.targets[late_index].send_waiting);
poll_task_once(&runtime, &mut task);
assert_eq!(timeout_losses_for(&observations, "late"), 1);
assert!(matches!(
&task.targets[late_index].state,
TargetState::Active { client }
if client.next_probe_timeout_deadline().is_none()
));
poll_task_once(&runtime, &mut task);
assert!(matches!(
&task.targets[late_index].state,
TargetState::Active { client } if client.packets_sent() == 2
));
reply_gate.release();
let TargetState::Active { mut client } =
mem::replace(&mut task.targets[late_index].state, TargetState::Terminal)
else {
panic!("deferred-send target did not remain active");
};
runtime.block_on(client.close()).unwrap();
drop(task);
server.finish();
}
#[test]
fn partial_timeout_batch_blocks_active_receive_until_loss_commits() {
let reply_gate = Arc::new(PacketGate::default());
let server = start_server_with_gates(
ServerBehavior::Echo,
None,
None,
None,
Some(Arc::clone(&reply_gate)),
);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) =
ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let sent = match &mut task.targets[0].state {
TargetState::Active { client } => runtime.block_on(client.send_probe()).unwrap(),
_ => panic!("timeout test target did not open"),
};
assert!(matches!(
sent.as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
wait_flag(&server.probe_seen);
let now = Instant::now();
let _ = remove_pending_for_timeout_test(&mut task, 0, 0);
for seq in 1..=u32::try_from(TIMEOUT_WORK_BUDGET).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(0, now - Duration::from_secs(1), now),
);
reply_gate.release();
wait_flag(&server.reply_sent);
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET
);
assert!(!client_events(&observations).iter().any(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
}));
drive_task_until(&runtime, &mut task, |_| {
client_events(&observations)
.iter()
.any(|event| matches!(event, ClientEvent::LateReply { seq: 0, .. }))
});
let classified = client_events(&observations)
.into_iter()
.filter(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
})
.collect::<Vec<_>>();
assert!(matches!(
classified.as_slice(),
[
ClientEvent::EchoLoss { seq: 0, .. },
ClientEvent::LateReply { seq: 0, .. }
]
));
drop(handle.stop());
runtime.block_on(task);
server.finish();
}
#[test]
fn partial_timeout_batch_blocks_draining_receive_until_loss_commits() {
let reply_gate = Arc::new(PacketGate::default());
let server = start_server_with_gates(
ServerBehavior::Echo,
None,
None,
None,
Some(Arc::clone(&reply_gate)),
);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (mut task, handle) =
ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
open_target_for_timeout_test(&runtime, &mut task, 0);
let sent = match &mut task.targets[0].state {
TargetState::Active { client } => runtime.block_on(client.send_probe()).unwrap(),
_ => panic!("timeout test target did not open"),
};
assert!(matches!(
sent.as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
wait_flag(&server.probe_seen);
let now = Instant::now();
let _ = remove_pending_for_timeout_test(&mut task, 0, 0);
for seq in 1..=u32::try_from(TIMEOUT_WORK_BUDGET).unwrap() {
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(
seq,
now - Duration::from_secs(2),
now - Duration::from_secs(1),
),
);
}
replace_pending_for_timeout_test(
&mut task,
0,
timeout_probe(0, now - Duration::from_secs(1), now),
);
let TargetState::Active { client } =
mem::replace(&mut task.targets[0].state, TargetState::Terminal)
else {
panic!("timeout test target did not remain active");
};
assert!(task.begin_drain(0, client, ManagedTargetEndReason::Stopped, now));
reply_gate.release();
wait_flag(&server.reply_sent);
let mut task = Box::pin(task);
poll_task_once(&runtime, &mut task);
assert_eq!(
timeout_losses_for(&observations, "one"),
TIMEOUT_WORK_BUDGET
);
assert!(matches!(
&task.targets[0].state,
TargetState::Draining {
primary_end: ManagedTargetEndReason::Stopped,
cleanup_failure: None,
..
}
));
assert!(!client_events(&observations).iter().any(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
}));
drive_task_until(&runtime, &mut task, |_| {
client_events(&observations)
.iter()
.any(|event| matches!(event, ClientEvent::LateReply { seq: 0, .. }))
});
let classified = client_events(&observations)
.into_iter()
.filter(|event| {
matches!(
event,
ClientEvent::EchoLoss { seq: 0, .. }
| ClientEvent::LateReply { seq: 0, .. }
| ClientEvent::EchoReply { seq: 0, .. }
)
})
.collect::<Vec<_>>();
assert!(matches!(
classified.as_slice(),
[
ClientEvent::EchoLoss { seq: 0, .. },
ClientEvent::LateReply { seq: 0, .. }
]
));
drop(handle.stop());
let outcome = runtime.block_on(task);
let target = &outcome.recent_target_outcomes[0];
assert!(matches!(target.end_reason, ManagedTargetEndReason::Stopped));
assert!(target.cleanup_failure.is_none());
server.finish();
}
#[test]
fn overdue_queued_reply_is_lost_then_late() {
let reply_gate = Arc::new(PacketGate::default());
let server = start_server_with_gates(
ServerBehavior::Echo,
None,
None,
None,
Some(Arc::clone(&reply_gate)),
);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
managed.client.interval = Duration::from_secs(1);
managed.client.probe_timeout = Duration::from_millis(30);
let (mut task, handle) =
ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
let observations = Arc::new(Mutex::new(Vec::new()));
task.event_observations = Some(Arc::clone(&observations));
let runtime = runtime();
let mut task = Box::pin(task);
drive_task_until(&runtime, &mut task, |task| {
task.targets[0].counters.packets_sent == 1
});
let timeout = match &task.targets[0].state {
TargetState::Active { client } => client.next_probe_timeout_deadline().unwrap(),
_ => panic!("probe sender did not remain active"),
};
thread::sleep(timeout.saturating_duration_since(Instant::now()));
while Instant::now() <= timeout {
thread::yield_now();
}
reply_gate.release();
wait_flag(&server.reply_sent);
runtime.block_on(poll_fn(|cx| {
let ready = match &task.as_ref().get_ref().targets[0].state {
TargetState::Active { client } => client.poll_recv_ready_for_test(cx),
_ => panic!("probe sender did not remain active"),
};
ready.map(|result| result.unwrap())
}));
runtime.block_on(poll_fn(|cx| {
assert!(task.as_mut().poll(cx).is_pending());
Poll::Ready(())
}));
let classified = client_events(&observations)
.into_iter()
.filter(|event| {
matches!(
event,
ClientEvent::EchoLoss { .. }
| ClientEvent::LateReply { .. }
| ClientEvent::EchoReply { .. }
)
})
.collect::<Vec<_>>();
assert!(matches!(
classified.as_slice(),
[ClientEvent::EchoLoss { .. }, ClientEvent::LateReply { .. }]
));
drop(handle.stop());
let outcome = runtime.block_on(task);
let target = &outcome.recent_target_outcomes[0];
assert_eq!(target.replies_received, 0);
assert_eq!(target.late, 1);
server.finish();
}
#[test]
fn retained_probe_deadline_shortens_after_late_reply() {
let reply_gate = Arc::new(PacketGate::default());
-1
View File
@@ -198,7 +198,6 @@ impl PendingMap {
self.map.get_mut(&wire_seq).map(|entry| &mut entry.probe)
}
#[cfg(any(feature = "tokio", test))]
pub fn next_timeout_deadline(&self) -> Option<Instant> {
self.first
.and_then(|wire_seq| self.map.get(&wire_seq))
+34 -77
View File
@@ -315,7 +315,7 @@ impl SessionMachine {
self.config.probe_timeout
}
pub(crate) fn prepare_probe(&self) -> Result<Option<PreparedProbe>, ClientError> {
pub(crate) fn prepare_probe(&self) -> Result<PreparedProbe, ClientError> {
let session = self.open_session()?;
let bytes = encode_request(
RequestToEncode::Echo {
@@ -326,10 +326,10 @@ impl SessionMachine {
},
self.config.hmac_key.as_deref(),
)?;
Ok(Some(PreparedProbe {
Ok(PreparedProbe {
bytes: bytes.into_boxed_slice(),
seq: session.next_wire_seq,
}))
})
}
pub(crate) fn preflight_probe_commit(
@@ -550,14 +550,21 @@ impl SessionMachine {
}
}
pub(crate) fn pending_is_empty(&self) -> bool {
pub(crate) fn negotiated_params(&self) -> Option<&NegotiatedParams> {
match &self.state {
MachineState::Open(session) => session.pending.len() == 0,
MachineState::NoTestCompleted | MachineState::Closed { .. } => true,
MachineState::Connected => false,
MachineState::Open(session) => Some(&session.negotiated),
_ => None,
}
}
pub(crate) fn pending_is_empty(&self) -> bool {
match &self.state {
MachineState::Open(session) => session.pending.len() == 0,
_ => true,
}
}
#[cfg(test)]
pub(crate) fn is_terminal(&self) -> bool {
matches!(
self.state,
@@ -575,14 +582,6 @@ impl SessionMachine {
)
}
#[cfg(test)]
pub(crate) fn has_timed_out_metadata(&self) -> bool {
matches!(
&self.state,
MachineState::Open(session) if session.timed_out.len() > 0
)
}
#[cfg(any(feature = "tokio", test))]
pub(crate) fn packets_sent(&self) -> u64 {
match &self.state {
@@ -592,7 +591,6 @@ impl SessionMachine {
}
}
#[cfg(feature = "tokio")]
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
match &self.state {
MachineState::Open(session) => session.pending.next_timeout_deadline(),
@@ -617,6 +615,7 @@ impl SessionMachine {
}
}
#[cfg(test)]
pub(crate) fn is_open(&self) -> bool {
matches!(self.state, MachineState::Open(_))
}
@@ -1178,43 +1177,6 @@ fn config_duration_to_ns(field: &str, duration: Duration) -> Result<i64, ClientE
})
}
#[cfg(test)]
impl SessionMachine {
pub(crate) fn seed_wrapped_probe_history_for_test(&mut self, now: ClientTimestamp) {
let session = self
.open_session_mut()
.expect("wrapped probe history requires an open session");
session.next_wire_seq = 0;
session.timed_out.insert(PendingProbe {
wire_seq: 0,
sent_at: now,
timeout_at: now.mono,
tx_not_before_wall: now.wall,
kernel_tx_timestamp: None,
});
session.completed.insert(0);
}
}
#[cfg(all(test, feature = "tokio"))]
impl SessionMachine {
pub(crate) fn remove_pending_for_test(&mut self, wire_seq: u32) -> Option<PendingProbe> {
self.open_session_mut()
.expect("test pending probes require an open session")
.pending
.remove(wire_seq)
}
pub(crate) fn replace_pending_for_test(&mut self, probe: PendingProbe) {
let session = self
.open_session_mut()
.expect("test pending probes require an open session");
session.pending.remove(probe.wire_seq);
session.pending.preflight_insert(probe.wire_seq).unwrap();
session.pending.commit_insert(probe);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1744,7 +1706,7 @@ mod tests {
#[test]
fn uncommitted_probe_preparation_changes_no_authoritative_state() {
let mut machine = open_machine(4, Duration::from_secs(1));
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
assert_eq!(prepared.seq, 0);
// Repeating preflight, and finalizing a commit that is never applied,
@@ -1768,8 +1730,8 @@ mod tests {
#[test]
fn stale_prepared_probe_is_rejected_without_changing_state() {
let mut machine = open_machine(4, Duration::from_secs(1));
let stale = machine.prepare_probe().unwrap().unwrap();
let accepted = machine.prepare_probe().unwrap().unwrap();
let stale = machine.prepare_probe().unwrap();
let accepted = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&accepted).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
@@ -1794,7 +1756,7 @@ mod tests {
#[test]
fn repeated_would_block_style_preflight_commits_once() {
let mut machine = open_machine(4, Duration::from_secs(1));
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now());
for _ in 0..3 {
@@ -1815,7 +1777,7 @@ mod tests {
#[test]
fn probe_commit_does_not_require_presentation_timing() {
let mut machine = open_machine(4, Duration::from_secs(1));
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
let send_anchor = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine
@@ -1840,7 +1802,7 @@ mod tests {
fn commit_uses_reserved_capacity_and_prevalidated_counter() {
let mut machine = open_machine(2, Duration::from_secs(1));
active_mut(&mut machine).packets_sent = u64::MAX - 1;
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let reserved_capacity = active(&machine).pending.capacity();
assert_eq!(preflight.next_packets_sent, u64::MAX);
@@ -1860,13 +1822,13 @@ mod tests {
#[test]
fn pending_capacity_exhaustion_is_detected_before_commit() {
let mut machine = open_machine(1, Duration::from_secs(1));
let first = machine.prepare_probe().unwrap().unwrap();
let first = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&first).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine.commit_probe_sent(commit, sent_at, first.bytes.len());
let second = machine.prepare_probe().unwrap().unwrap();
let second = machine.prepare_probe().unwrap();
assert!(matches!(
machine.preflight_probe_commit(&second),
Err(ClientError::PendingLimitExceeded { limit: 1 })
@@ -1877,14 +1839,14 @@ mod tests {
#[test]
fn pending_sequence_collision_is_detected_before_commit() {
let mut machine = open_machine(2, Duration::from_secs(1));
let first = machine.prepare_probe().unwrap().unwrap();
let first = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&first).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine.commit_probe_sent(commit, sent_at, first.bytes.len());
active_mut(&mut machine).next_wire_seq = 0;
let reused = machine.prepare_probe().unwrap().unwrap();
let reused = machine.prepare_probe().unwrap();
assert!(matches!(
machine.preflight_probe_commit(&reused),
Err(ClientError::PendingSequenceCollision { seq: 0 })
@@ -1896,7 +1858,7 @@ mod tests {
fn counter_overflow_is_detected_before_commit() {
let mut machine = open_machine(2, Duration::from_secs(1));
active_mut(&mut machine).packets_sent = u64::MAX;
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
assert!(matches!(
machine.preflight_probe_commit(&prepared),
@@ -1910,7 +1872,7 @@ mod tests {
#[test]
fn timeout_overflow_is_detected_before_commit() {
let mut machine = open_machine(2, Duration::MAX);
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap();
assert!(matches!(
@@ -1954,7 +1916,7 @@ mod tests {
fn wrapping_sequence_from_max_to_zero_remains_valid() {
let mut machine = open_machine(2, Duration::from_secs(1));
active_mut(&mut machine).next_wire_seq = u32::MAX;
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine
.finalize_probe_commit(preflight, timestamp(Instant::now()))
@@ -1962,7 +1924,7 @@ mod tests {
machine.commit_probe_sent(commit, timestamp(Instant::now()), prepared.bytes.len());
assert_eq!(active(&machine).next_wire_seq, 0);
assert_eq!(machine.prepare_probe().unwrap().unwrap().seq, 0);
assert_eq!(machine.prepare_probe().unwrap().seq, 0);
}
#[test]
@@ -1982,7 +1944,7 @@ mod tests {
session.timed_out.insert(obsolete);
session.completed.insert(0);
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine
.finalize_probe_commit(preflight, timestamp(now))
@@ -2395,7 +2357,7 @@ mod tests {
}
fn send_probe(machine: &mut SessionMachine, sent_at: ClientTimestamp) -> u32 {
let prepared = machine.prepare_probe().unwrap().unwrap();
let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine
@@ -3520,9 +3482,7 @@ mod tests {
machine: &mut SessionMachine,
now: ClientTimestamp,
) -> Result<ProbeSent, ClientError> {
let prepared = machine
.prepare_probe()?
.expect("prepare_probe always returns Some when Ok");
let prepared = machine.prepare_probe()?;
let preflight = machine.preflight_probe_commit(&prepared)?;
let commit = machine.finalize_probe_commit(preflight, now)?;
let bytes = prepared.bytes.len();
@@ -3630,10 +3590,7 @@ mod tests {
result
);
} else {
let prepared_a = machine
.prepare_probe()
.unwrap()
.expect("open session prepares probes");
let prepared_a = machine.prepare_probe().unwrap();
prop_assert_eq!(prepared_a.seq, model.next_wire_seq);
let sent = do_send(machine, *now).expect(
@@ -3665,7 +3622,7 @@ mod tests {
let result = machine.prepare_probe();
match model.state {
ModelState::Open => {
let prepared = result.unwrap().expect("open session prepares probes");
let prepared = result.unwrap();
prop_assert_eq!(prepared.seq, model.next_wire_seq);
prop_assert!(!prepared.bytes.is_empty());
}
@@ -58,11 +58,11 @@ fn synthetic_probe_stream(count: u32) -> Vec<ClientEvent> {
events.push(ClientEvent::EchoSent {
seq,
remote,
scheduled_at: sent_at.mono,
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 64,
send_call: Duration::from_micros(50),
timer_error: Duration::from_micros(20),
timer_error: Some(Duration::from_micros(20)),
});
if seq == count - 1 {
+4 -2
View File
@@ -100,12 +100,14 @@ impl CoreStats {
}
}
fn apply_sent(&mut self, bytes: usize, send_call_ns: i128, timer_error_ns: i128) {
fn apply_sent(&mut self, bytes: usize, send_call_ns: i128, timer_error_ns: Option<i128>) {
self.events.sent_events += 1;
self.packets.packets_sent += 1;
self.packets.bytes_sent = self.packets.bytes_sent.saturating_add(bytes as u64);
self.send_call.push_ns(send_call_ns);
self.timer_error.push_ns(timer_error_ns);
if let Some(error) = timer_error_ns {
self.timer_error.push_ns(error);
}
}
fn apply_unique_reply(&mut self, is_late: bool, sample: ReplySample) -> EventStatsUpdate {
+8 -8
View File
@@ -89,11 +89,11 @@ impl StatsConfig {
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
@@ -142,11 +142,11 @@ impl StatsConfig {
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
@@ -418,11 +418,11 @@ impl StatsCollector {
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
@@ -475,11 +475,11 @@ impl StatsCollector {
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
+2 -2
View File
@@ -13,7 +13,7 @@ pub(crate) enum StatsEvent {
at: Instant,
bytes: usize,
send_call_ns: i128,
timer_error_ns: i128,
timer_error_ns: Option<i128>,
},
UniqueReply {
at: Instant,
@@ -48,7 +48,7 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
at: sent_at.mono,
bytes: *bytes,
send_call_ns: duration_ns_i128(*send_call),
timer_error_ns: duration_ns_i128(*timer_error),
timer_error_ns: timer_error.map(duration_ns_i128),
}),
ClientEvent::EchoReply {
seq,
+2 -2
View File
@@ -146,11 +146,11 @@ mod tests {
ClientEvent::EchoSent {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
scheduled_at: sent_at.mono,
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 32,
send_call: Duration::from_micros(10 + u64::from(seq)),
timer_error: Duration::from_micros(2 + u64::from(seq)),
timer_error: Some(Duration::from_micros(2 + u64::from(seq))),
}
}
+3 -3
View File
@@ -34,11 +34,11 @@ pub fn sent(seq: u32, sent_at: ClientTimestamp) -> ClientEvent {
ClientEvent::EchoSent {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
scheduled_at: sent_at.mono,
scheduled_at: Some(sent_at.mono),
sent_at,
bytes: 32,
send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2),
timer_error: Some(Duration::from_micros(2)),
}
}
@@ -117,7 +117,7 @@ pub fn sent_with_timings(
unreachable!();
};
*send_call = Duration::from_micros(send_call_us);
*timer_error = Duration::from_micros(timer_error_us);
*timer_error = Some(Duration::from_micros(timer_error_us));
event
}
@@ -129,11 +129,11 @@ fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent {
Op::Send => ClientEvent::EchoSent {
seq,
remote: ADDR.parse().unwrap(),
scheduled_at: ts_at(base, clock_ms).mono,
scheduled_at: Some(ts_at(base, clock_ms).mono),
sent_at: ts_at(base, clock_ms),
bytes: 32,
send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2),
timer_error: Some(Duration::from_micros(2)),
},
Op::Reply { raw_ms } => {
let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms)));
+21
View File
@@ -13,6 +13,27 @@ use irtt_stats::{
EventStatsUpdate, IpdvPairUpdate, LateReplyMode, SampleMode, StatsCollector, StatsConfig,
};
#[test]
fn caller_paced_sends_do_not_fabricate_timer_error_samples() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let mut event = sent(0, ts(0));
let ClientEvent::EchoSent {
scheduled_at,
timer_error,
..
} = &mut event
else {
unreachable!()
};
*scheduled_at = None;
*timer_error = None;
collector.process(&event);
let snapshot = collector.snapshot();
assert_eq!(snapshot.packets.packets_sent, 1);
assert_eq!(snapshot.send_call.count, 1);
assert_eq!(snapshot.timer_error.count, 0);
}
#[test]
fn exact_mode_reports_a_timer_error_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());