From f2fce2f3307e3796b0c13fa39b464a7ac73a263d Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 2 Oct 2026 00:03:31 +0700 Subject: [PATCH] refactor(irtt-client): move cadence into managed runner --- crates/irtt-app/src/cmd/client/output.rs | 4 +- crates/irtt-app/src/cmd/client/summary.rs | 8 +- .../irtt-client/examples/blocking_client.rs | 55 +- crates/irtt-client/examples/tokio_client.rs | 54 +- crates/irtt-client/src/async_client.rs | 372 +------ crates/irtt-client/src/async_client/tests.rs | 816 ++------------- crates/irtt-client/src/client.rs | 501 ++------- crates/irtt-client/src/client/tests/close.rs | 378 ++----- crates/irtt-client/src/client/tests/dscp.rs | 77 +- crates/irtt-client/src/client/tests/mod.rs | 9 + .../irtt-client/src/client/tests/no_test.rs | 59 +- crates/irtt-client/src/client/tests/open.rs | 451 ++------- .../probes_replies/interrupted_receive.rs | 80 -- .../src/client/tests/probes_replies/mod.rs | 2 - .../tests/probes_replies/probe_schedule.rs | 487 --------- crates/irtt-client/src/config.rs | 9 +- crates/irtt-client/src/event.rs | 9 +- crates/irtt-client/src/managed/mod.rs | 3 + .../src/{client => managed}/schedule.rs | 186 +--- crates/irtt-client/src/managed/task.rs | 128 ++- crates/irtt-client/src/managed/tests.rs | 957 +----------------- crates/irtt-client/src/probe.rs | 1 - crates/irtt-client/src/session/machine.rs | 111 +- .../examples/finite_vs_continuous.rs | 4 +- crates/irtt-stats/src/core.rs | 6 +- crates/irtt-stats/src/lib.rs | 16 +- crates/irtt-stats/src/normalization.rs | 4 +- crates/irtt-stats/src/retention.rs | 4 +- crates/irtt-stats/tests/common/mod.rs | 6 +- crates/irtt-stats/tests/rolling_properties.rs | 4 +- crates/irtt-stats/tests/stats.rs | 21 + 31 files changed, 716 insertions(+), 4106 deletions(-) delete mode 100644 crates/irtt-client/src/client/tests/probes_replies/interrupted_receive.rs delete mode 100644 crates/irtt-client/src/client/tests/probes_replies/probe_schedule.rs rename crates/irtt-client/src/{client => managed}/schedule.rs (51%) diff --git a/crates/irtt-app/src/cmd/client/output.rs b/crates/irtt-app/src/cmd/client/output.rs index eae15c6..ecc5f16 100644 --- a/crates/irtt-app/src/cmd/client/output.rs +++ b/crates/irtt-app/src/cmd/client/output.rs @@ -207,7 +207,7 @@ enum OutputRow { client_send_wall: SystemTime, bytes: usize, send_call: Duration, - timer_error: Duration, + timer_error: Option, }, 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, }, diff --git a/crates/irtt-app/src/cmd/client/summary.rs b/crates/irtt-app/src/cmd/client/summary.rs index 2f2e25a..b738d6a 100644 --- a/crates/irtt-app/src/cmd/client/summary.rs +++ b/crates/irtt-app/src/cmd/client/summary.rs @@ -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, diff --git a/crates/irtt-client/examples/blocking_client.rs b/crates/irtt-client/examples/blocking_client.rs index d5f103f..055c648 100644 --- a/crates/irtt-client/examples/blocking_client.rs +++ b/crates/irtt-client/examples/blocking_client.rs @@ -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}"), diff --git a/crates/irtt-client/examples/tokio_client.rs b/crates/irtt-client/examples/tokio_client.rs index f46c6b3..74e8ec9 100644 --- a/crates/irtt-client/examples/tokio_client.rs +++ b/crates/irtt-client/examples/tokio_client.rs @@ -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}"), diff --git a/crates/irtt-client/src/async_client.rs b/crates/irtt-client/src/async_client.rs index 37c9cc2..f4999ca 100644 --- a/crates/irtt-client/src/async_client.rs +++ b/crates/irtt-client/src/async_client.rs @@ -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, recv_buffer_len: Option, negotiated_traffic_class: Option, } @@ -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>, - send_attempts: Cell, - receive_would_block: Cell, - /// 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, - fail_open_dscp: Cell, - fail_cleanup_send: Cell, - fail_peer_close_dscp: Cell, - fail_dscp_restore: Cell, - pause_open_cleanup_before_send: Cell, - pause_open_before_writable: Cell, - pause_open_before_readable: Cell, - pause_probe_before_writable: Cell, - pause_close_before_writable: Cell, - pause_close_after_would_block: Cell, - probe_timestamps: RefCell>, - close_sent_at: Cell>, -} - -#[cfg(test)] -impl AsyncClientTestHooks { - fn try_send(&self, socket: &tokio::net::UdpSocket, packet: &[u8]) -> io::Result { - 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 { - 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, remote: SocketAddr, recv_buffer: Vec, applied_traffic_class: Option, @@ -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 { 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 { - if !self.machine.is_open() { - return None; - } - self.schedule.as_ref()?.next_send_deadline() - } - - pub(crate) fn probe_interval(&self) -> Option { - 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 { + /// Earliest timeout deadline among probes still awaiting a reply. + pub fn next_probe_timeout_deadline(&self) -> Option { 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> { - self.socket.poll_recv_ready(cx) - } - - #[cfg(test)] - pub(crate) fn remove_pending_for_test( - &mut self, - wire_seq: u32, - ) -> Option { - 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> { 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, armed: bool, - #[cfg(test)] - fail_restore: &'a Cell, } impl<'a> DscpRollback<'a> { @@ -1126,15 +828,12 @@ impl<'a> DscpRollback<'a> { socket: &'a tokio::net::UdpSocket, remote: SocketAddr, previous_traffic_class: Option, - #[cfg(test)] fail_restore: &'a Cell, ) -> 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)? diff --git a/crates/irtt-client/src/async_client/tests.rs b/crates/irtt-client/src/async_client/tests.rs index eb02a86..761b358 100644 --- a/crates/irtt-client/src/async_client/tests.rs +++ b/crates/irtt-client/src/async_client/tests.rs @@ -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>, 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(¶ms, 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(¶ms, 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); +} diff --git a/crates/irtt-client/src/client.rs b/crates/irtt-client/src/client.rs index f5586cf..39329ec 100644 --- a/crates/irtt-client/src/client.rs +++ b/crates/irtt-client/src/client.rs @@ -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, recv_buffer_len: Option, negotiated_traffic_class: Option, previous_traffic_class: Option, @@ -53,50 +33,6 @@ struct PreparedClientOpenFailure { machine: PreparedOpenAcceptance, } -#[cfg(test)] -#[derive(Debug, Default)] -struct ClientTestHooks { - fail_open_dscp: Cell, - fail_open_timeout_restore: Cell, - fail_close_event_reserve: Cell, - fail_close_dscp_clear: Cell, - fail_close_send: Cell, - close_reported_len: Cell>, - close_send_attempts: Cell, - close_sent_at: Cell>, - fail_cleanup_send: Cell, - recv_buffer_len_override: Cell>, - fail_dscp_restore: Cell, - last_restored_read_timeout: Cell>, - prepared_active_session_before_dscp: Cell, - /// 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, -} - -#[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, socket: UdpSocket, remote: SocketAddr, recv_buffer: Vec, @@ -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, - #[cfg(test)] - probe_send_error: bool, - #[cfg(test)] - probe_send_timestamps: Option, } 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 { 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, 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 { - 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, 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 { - #[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 { - #[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, ClientError> { let Some(datagram) = self.recv_datagram_retrying_interrupted()? else { return Ok(vec![]); @@ -340,11 +243,8 @@ impl Client { datagram.received_at, datagram.meta, )?; - if self.runtime.is_peer_closed() { - self.schedule = None; - if self.clear_close_dscp().is_ok() { - self.applied_traffic_class = None; - } + 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 { + 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, ClientError> { - self.send_probe_transaction(ProbeScheduleMode::Managed { scheduled_at }) - } - - fn send_probe_transaction( - &mut self, - mode: ProbeScheduleMode, - ) -> Result, 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 { 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> { 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 { 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) { - #[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) -> 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 { - #[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, ClientError> { - self.probe_send_timestamps = Some(timestamps); - self.send_probe() - } - - fn send_managed_probe_at( - &mut self, - scheduled_at: Instant, - timestamps: ProbeSendTimestamps, - ) -> Result, ClientError> { - self.probe_send_timestamps = Some(timestamps); - self.send_managed_probe(scheduled_at) - } -} - -#[cfg(test)] -fn bounded_receive_timeout( - deadline: Option, - configured_timeout: Option, - max_wait: Duration, - now: Instant, -) -> Option { - 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; diff --git a/crates/irtt-client/src/client/tests/close.rs b/crates/irtt-client/src/client/tests/close.rs index 46660af..e3c0c35 100644 --- a/crates/irtt-client/src/client/tests/close.rs +++ b/crates/irtt-client/src/client/tests/close.rs @@ -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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, - ¶ms, - &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, ¶ms, 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, - ¶ms, - &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, ¶ms, 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, + ¶ms, + &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(); +} diff --git a/crates/irtt-client/src/client/tests/dscp.rs b/crates/irtt-client/src/client/tests/dscp.rs index dd1301a..cbc43d0 100644 --- a/crates/irtt-client/src/client/tests/dscp.rs +++ b/crates/irtt-client/src/client/tests/dscp.rs @@ -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, ¶ms, 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, ¶ms, 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(), diff --git a/crates/irtt-client/src/client/tests/mod.rs b/crates/irtt-client/src/client/tests/mod.rs index 0f38489..bb13b07 100644 --- a/crates/irtt-client/src/client/tests/mod.rs +++ b/crates/irtt-client/src/client/tests/mod.rs @@ -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, diff --git a/crates/irtt-client/src/client/tests/no_test.rs b/crates/irtt-client/src/client/tests/no_test.rs index 450d5d9..313e897 100644 --- a/crates/irtt-client/src/client/tests/no_test.rs +++ b/crates/irtt-client/src/client/tests/no_test.rs @@ -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, ¶ms, 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(); +} diff --git a/crates/irtt-client/src/client/tests/open.rs b/crates/irtt-client/src/client/tests/open.rs index 142af3b..36e2002 100644 --- a/crates/irtt-client/src/client/tests/open.rs +++ b/crates/irtt-client/src/client/tests/open.rs @@ -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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, None), - peer, - ) - .unwrap(); - let _ = recv_request(&socket, &tx); + let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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(); } diff --git a/crates/irtt-client/src/client/tests/probes_replies/interrupted_receive.rs b/crates/irtt-client/src/client/tests/probes_replies/interrupted_receive.rs deleted file mode 100644 index 64ce90d..0000000 --- a/crates/irtt-client/src/client/tests/probes_replies/interrupted_receive.rs +++ /dev/null @@ -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, ¶ms, 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, ¶ms, &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(); -} diff --git a/crates/irtt-client/src/client/tests/probes_replies/mod.rs b/crates/irtt-client/src/client/tests/probes_replies/mod.rs index 1f779ba..8f84f75 100644 --- a/crates/irtt-client/src/client/tests/probes_replies/mod.rs +++ b/crates/irtt-client/src/client/tests/probes_replies/mod.rs @@ -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; diff --git a/crates/irtt-client/src/client/tests/probes_replies/probe_schedule.rs b/crates/irtt-client/src/client/tests/probes_replies/probe_schedule.rs deleted file mode 100644 index 33de19e..0000000 --- a/crates/irtt-client/src/client/tests/probes_replies/probe_schedule.rs +++ /dev/null @@ -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, ¶ms)); - } - 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, ¶ms); - 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 { .. }) - )); - } -} diff --git a/crates/irtt-client/src/config.rs b/crates/irtt-client/src/config.rs index d136f22..5ac4497 100644 --- a/crates/irtt-client/src/config.rs +++ b/crates/irtt-client/src/config.rs @@ -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, /// 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, diff --git a/crates/irtt-client/src/event.rs b/crates/irtt-client/src/event.rs index fbe113b..0a3f58d 100644 --- a/crates/irtt-client/src/event.rs +++ b/crates/irtt-client/src/event.rs @@ -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, /// 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, }, /// First in-window reply for a pending echo request. diff --git a/crates/irtt-client/src/managed/mod.rs b/crates/irtt-client/src/managed/mod.rs index 203983f..89b449d 100644 --- a/crates/irtt-client/src/managed/mod.rs +++ b/crates/irtt-client/src/managed/mod.rs @@ -18,3 +18,6 @@ pub use task::*; #[cfg(all(feature = "tokio", test))] mod blocking_tests; + +#[cfg(feature = "tokio")] +mod schedule; diff --git a/crates/irtt-client/src/client/schedule.rs b/crates/irtt-client/src/managed/schedule.rs similarity index 51% rename from crates/irtt-client/src/client/schedule.rs rename to crates/irtt-client/src/managed/schedule.rs index 64193b0..b51e8ae 100644 --- a/crates/irtt-client/src/client/schedule.rs +++ b/crates/irtt-client/src/managed/schedule.rs @@ -4,7 +4,6 @@ use crate::{error::ClientError, session::NegotiatedParams}; #[derive(Debug)] pub(crate) struct ProbeSchedule { - start_at: Instant, end_at: Option, interval: Duration, next_send_at: Option, @@ -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 { - 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 { - 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 { - 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 { 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)); - - schedule.commit(commit); + 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(next_ms)) + ); + } + 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)) ); } #[test] - fn finite_duration_uses_an_exclusive_end_boundary() { + 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(); - - let first = schedule.preflight_caller_commit(1).unwrap(); - schedule.commit(first); - assert_eq!( - schedule.next_send_deadline(), - Some(start + Duration::from_millis(10)) - ); - - 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() { - 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) - ); } } diff --git a/crates/irtt-client/src/managed/task.rs b/crates/irtt-client/src/managed/task.rs index 39c4691..2eb4d1e 100644 --- a/crates/irtt-client/src/managed/task.rs +++ b/crates/irtt-client/src/managed/task.rs @@ -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, 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, diff --git a/crates/irtt-client/src/managed/tests.rs b/crates/irtt-client/src/managed/tests.rs index d5a09d1..2579356 100644 --- a/crates/irtt-client/src/managed/tests.rs +++ b/crates/irtt-client/src/managed/tests.rs @@ -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, Condvar)>) { assert!(*value && !timeout.timed_out()); } -fn client_events( - observations: &Mutex)>>, -) -> Vec { - 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>) { - runtime.block_on(poll_fn(|cx| { - assert!(task.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - })); -} - -fn timeout_losses_for( - observations: &Mutex)>>, - 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 { 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::>(); - 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::>(); - 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::>(); - 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()); diff --git a/crates/irtt-client/src/probe.rs b/crates/irtt-client/src/probe.rs index 4d035cf..1cd6856 100644 --- a/crates/irtt-client/src/probe.rs +++ b/crates/irtt-client/src/probe.rs @@ -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 { self.first .and_then(|wire_seq| self.map.get(&wire_seq)) diff --git a/crates/irtt-client/src/session/machine.rs b/crates/irtt-client/src/session/machine.rs index 4398d9d..8ff6326 100644 --- a/crates/irtt-client/src/session/machine.rs +++ b/crates/irtt-client/src/session/machine.rs @@ -315,7 +315,7 @@ impl SessionMachine { self.config.probe_timeout } - pub(crate) fn prepare_probe(&self) -> Result, ClientError> { + pub(crate) fn prepare_probe(&self) -> Result { 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 { 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 Option { - 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 { - 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()); } diff --git a/crates/irtt-stats/examples/finite_vs_continuous.rs b/crates/irtt-stats/examples/finite_vs_continuous.rs index 463be44..23e5887 100644 --- a/crates/irtt-stats/examples/finite_vs_continuous.rs +++ b/crates/irtt-stats/examples/finite_vs_continuous.rs @@ -58,11 +58,11 @@ fn synthetic_probe_stream(count: u32) -> Vec { 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 { diff --git a/crates/irtt-stats/src/core.rs b/crates/irtt-stats/src/core.rs index f8802b6..e3dd5de 100644 --- a/crates/irtt-stats/src/core.rs +++ b/crates/irtt-stats/src/core.rs @@ -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) { 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 { diff --git a/crates/irtt-stats/src/lib.rs b/crates/irtt-stats/src/lib.rs index 6461898..82235f4 100644 --- a/crates/irtt-stats/src/lib.rs +++ b/crates/irtt-stats/src/lib.rs @@ -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)), /// # } /// # } /// # diff --git a/crates/irtt-stats/src/normalization.rs b/crates/irtt-stats/src/normalization.rs index 1c6c11a..4194098 100644 --- a/crates/irtt-stats/src/normalization.rs +++ b/crates/irtt-stats/src/normalization.rs @@ -13,7 +13,7 @@ pub(crate) enum StatsEvent { at: Instant, bytes: usize, send_call_ns: i128, - timer_error_ns: i128, + timer_error_ns: Option, }, UniqueReply { at: Instant, @@ -48,7 +48,7 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option { 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, diff --git a/crates/irtt-stats/src/retention.rs b/crates/irtt-stats/src/retention.rs index 2fad255..f58ad21 100644 --- a/crates/irtt-stats/src/retention.rs +++ b/crates/irtt-stats/src/retention.rs @@ -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))), } } diff --git a/crates/irtt-stats/tests/common/mod.rs b/crates/irtt-stats/tests/common/mod.rs index d08ae0f..628c8f3 100644 --- a/crates/irtt-stats/tests/common/mod.rs +++ b/crates/irtt-stats/tests/common/mod.rs @@ -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 } diff --git a/crates/irtt-stats/tests/rolling_properties.rs b/crates/irtt-stats/tests/rolling_properties.rs index ff888f5..52a60b2 100644 --- a/crates/irtt-stats/tests/rolling_properties.rs +++ b/crates/irtt-stats/tests/rolling_properties.rs @@ -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))); diff --git a/crates/irtt-stats/tests/stats.rs b/crates/irtt-stats/tests/stats.rs index 01528c0..4818210 100644 --- a/crates/irtt-stats/tests/stats.rs +++ b/crates/irtt-stats/tests/stats.rs @@ -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());