feat(client): capture kernel transmit timestamps #104

Merged
Lochnair merged 1 commits from feat/client-kernel-tx-capture into main 2026-08-15 11:41:37 +00:00
10 changed files with 947 additions and 68 deletions
Showing only changes of commit 9e3477e617 - Show all commits

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
# `irtt-client` guidance
Repository-wide guidance in the root `AGENTS.md` also applies here.
## Linux kernel TX timestamp capture
On Linux, with the `ancillary` feature, the client best-effort upgrades its
socket from RX-only `SO_TIMESTAMPING` to RX+TX immediately after a successful
Open, before the first Echo probe can be sent:
```
socket created
-> RX timestamping configured (RX_SOFTWARE | SOFTWARE)
-> Open attempts/retries (untimestamped)
-> successful Open
-> best-effort enable TX timestamping (+ TX_SOFTWARE | OPT_ID | OPT_TSONLY)
-> first Echo probe
```
`TX_SOFTWARE` asks the kernel for a software send timestamp; `OPT_ID` asks it
to tag each notification with an automatically incrementing per-socket ID;
`OPT_TSONLY` means the notification never needs to carry the original
payload, so a zero-length receive buffer is enough. The upgrade is entirely
best-effort: if it fails, the client keeps operating on the original RX-only
configuration (`try_enable_tx_timestamping` restores it explicitly) and
timing falls back to userspace `sent_at` as it always has. TX capability
failure is never a public error and is never logged from the library.
### Correlation invariant: kernel ID == wire sequence
The client uses the kernel's automatic `OPT_ID` directly as the probe's wire
sequence number — no separate correlation map. This is safe because:
- **A failed/nonblocking send never consumes an ID.** Linux increments the
per-socket ID counter (`sk->sk_tskey`) speculatively while building a
datagram and rolls the increment back (`atomic_dec`) on every error path
out of `__ip_append_data`/`__ip6_append_data`
(`net/ipv4/ip_output.c`, `net/ipv6/ip6_output.c`), guarded by a local
`hold_tskey` flag. A `WouldBlock` `try_send` therefore can never leave a
gap in, or otherwise advance, the ID the kernel will assign to the next
*successful* submission. (`include/net/sock.h`'s `_sock_tx_timestamp`
performs the same increment for the non-corked path.) Client-side, the
existing send transaction only calls `commit_probe_sent` — which is the
only thing that advances `next_wire_seq` — after a successful socket send;
a `WouldBlock` result never reaches it.
- **The two counters start together and advance together.** `wire_seq`
starts at 0 on every successful Open (`SessionMachine::commit_open` builds
a fresh `ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1
only via `commit_probe_sent`, which only runs after a confirmed successful
send — the same condition under which the kernel's counter advances by 1.
TX timestamping is enabled only after that same successful Open and before
any probe is sent, so both counters' zero points line up exactly.
- **One socket, one session, no reopen.** `SessionMachine`'s state machine
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
second session on the same socket, so there is no second epoch of wire
sequence numbers to collide with a stale kernel ID.
- **The one extra send (Close) is harmless.** `Close` sends one datagram
after TX timestamping is already enabled, generating one TX timestamp
record with no corresponding `wire_seq` (`Close` does not advance
`next_wire_seq`). Because the session is terminal immediately after,
nothing will ever look up that ID; `record_kernel_tx_timestamp`'s
"unknown ID -> ignore" rule discards it safely.
## MSG_ERRQUEUE drains
`receive::linux::error_queue` decodes one `MSG_ERRQUEUE` record at a time
(`try_recv_error_queue_record`) and classifies it: a genuine send-timestamp
completion (`TxTimestamp`), a timestamp-origin record that is not a usable
`SND` completion (`MalformedOrUnsupportedTimestamp` — never treated as
fatal, since this client only ever requests `TX_SOFTWARE`/`SND`, so anything
else on that origin is unexpected metadata, not a network error), a genuine
non-timestamp socket/network error (`SocketError`), or nothing (`Ignored`).
Origin (`SO_EE_ORIGIN_TIMESTAMPING` or not) is the only thing that decides
between the last two categories.
`receive::linux::drain_tx_timestamps` reads up to
`MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN` (32) records per call: nonblocking, one
record at a time, no heap allocation, stopping early once the queue is
empty. It is a starvation guard, not a queue-capacity promise — one TX
timestamp is generated per successful probe, and drains happen at several
natural choke points, so a modest constant comfortably covers realistic
backlog. `Client`/`AsyncClient` call it after a successful probe send,
before processing an inbound reply, and before evaluating timeouts; it never
waits for a timestamp; an absent timestamp after all of those opportunities
simply stays absent. A genuine `SocketError` record stops the drain and is
surfaced through the ordinary `ClientError::Socket` path.
The Tokio adapter uses the same raw nonblocking `recvmsg(MSG_ERRQUEUE |
MSG_DONTWAIT)` rather than `try_io`/`poll_recv_ready`: error-queue readiness
is not ordinary read readiness, and driving it through the Tokio reactor
risks clearing readiness state that the normal Echo receive path depends on.
There is no background task or second `AsyncFd` around the Tokio-owned
socket.
## Retained state
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
alongside the authoritative userspace `sent_at`. It survives pending ->
timed-out retention (`SessionMachine::record_kernel_tx_timestamp` updates
whichever of `PendingMap`/`TimedOutMap` still holds the probe) and is
otherwise bounded by the same limits those maps already enforce — no new
unbounded state. First valid observation wins on duplicates. **It is
currently dormant metadata only**: no RTT, OWD, or IPDV computation reads
it. `sent_at` remains the sole input to all of those, exactly as before this
capability existed. A later change may select it as a preferred send
timestamp; until then, treat any code path that reads
`kernel_tx_timestamp` for a measurement as a bug.
+32 -1
View File
@@ -24,7 +24,7 @@ use crate::{
config::ClientConfig,
error::ClientError,
event::{ClientEvent, OpenOutcome},
receive::try_recv_tokio_datagram,
receive::{drain_tx_timestamps, try_enable_tx_timestamping, try_recv_tokio_datagram},
session::machine::{
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
@@ -222,6 +222,9 @@ pub struct AsyncClient {
applied_traffic_class: Option<u8>,
prepared_open: Option<PreparedOpenRequest>,
prepared_probe: Option<PreparedProbe>,
/// 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,
}
@@ -250,6 +253,7 @@ impl AsyncClient {
applied_traffic_class: None,
prepared_open: Some(prepared_open),
prepared_probe: None,
tx_timestamping_enabled: false,
#[cfg(test)]
test_hooks: AsyncClientTestHooks::default(),
})
@@ -297,6 +301,7 @@ impl AsyncClient {
/// Poll protocol timeouts using the caller's monotonic timestamp.
pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
self.drain_tx_timestamps()?;
self.machine.poll_timeouts_at(now)
}
@@ -305,6 +310,7 @@ impl AsyncClient {
now: Instant,
limit: usize,
) -> Result<TimeoutBatch, ClientError> {
self.drain_tx_timestamps()?;
self.machine.poll_timeouts_bounded_at(now, limit)
}
@@ -566,6 +572,9 @@ impl AsyncClient {
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,
@@ -606,6 +615,14 @@ impl AsyncClient {
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
};
// One final bounded drain before processing the reply, so a TX
// timestamp that raced the reply is still associated with its
// probe before that probe is looked up and possibly removed
// below.
if let Err(error) = self.drain_tx_timestamps() {
return Poll::Ready(Err(error));
}
let events = match self.machine.process_received_echo_packet(
&self.recv_buffer[..datagram.len],
datagram.received_at,
@@ -884,9 +901,23 @@ impl AsyncClient {
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);
outcome
}
/// See `Client::drain_tx_timestamps` in `client.rs`: same bounded,
/// nonblocking, best-effort drain, adapted to the Tokio socket.
fn drain_tx_timestamps(&mut self) -> Result<(), ClientError> {
if !self.tx_timestamping_enabled {
return Ok(());
}
let machine = &mut self.machine;
drain_tx_timestamps(&self.socket, |id, timestamp| {
machine.record_kernel_tx_timestamp(id, timestamp);
})
.map_err(ClientError::Socket)
}
fn poll_open_cleanup(
&self,
state: &mut AsyncOpenState,
@@ -741,6 +741,7 @@ fn public_timeout_polling_remains_exhaustive() {
wall: SystemTime::UNIX_EPOCH,
},
timeout_at: now - Duration::from_secs(1),
kernel_tx_timestamp: None,
});
}
+35 -1
View File
@@ -14,7 +14,7 @@ use crate::{
config::{ClientConfig, RecvBudget},
error::ClientError,
event::{ClientEvent, OpenOutcome},
receive::recv_datagram,
receive::{drain_tx_timestamps, recv_datagram, try_enable_tx_timestamping},
session::machine::{
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, ProbeSent,
SessionMachine, MAX_OPEN_PACKET_SIZE,
@@ -104,6 +104,13 @@ pub struct Client {
remote: SocketAddr,
recv_buffer: Vec<u8>,
applied_traffic_class: Option<u8>,
/// Whether the socket's `SO_TIMESTAMPING` flags carry `TX_SOFTWARE` +
/// `OPT_ID` + `OPT_TSONLY`. Always `false` off Linux, without the
/// `ancillary` feature, or before the best-effort post-Open upgrade;
/// tracked here (adapter-owned capability, not protocol state) so
/// 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)]
@@ -133,6 +140,7 @@ impl Client {
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)]
@@ -267,6 +275,11 @@ impl Client {
Err(err) => return Err(ClientError::Socket(err)),
};
// One final bounded drain before processing the reply, so a TX
// timestamp that raced the reply is still associated with its probe
// before that probe is looked up and possibly removed below.
self.drain_tx_timestamps()?;
let events = self.runtime.process_received_echo_packet(
&self.recv_buffer[..datagram.len],
datagram.received_at,
@@ -313,6 +326,7 @@ impl Client {
///
/// `now` is monotonic time only; wall-clock time is not used for timeout expiry.
pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
self.drain_tx_timestamps()?;
self.runtime.poll_timeouts_at(now)
}
@@ -430,6 +444,7 @@ impl Client {
#[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,
@@ -610,6 +625,7 @@ 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());
@@ -619,10 +635,28 @@ impl Client {
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)
}
}
/// Best-effort, bounded, nonblocking drain of the socket's
/// `MSG_ERRQUEUE`. A no-op unless [`Self::apply_prepared_open`]
/// successfully upgraded the socket to TX timestamping. Any TX
/// timestamp found is handed to the session machine to attach to its
/// matching probe; a genuine socket/network error found on the queue is
/// surfaced through the normal socket-error path.
fn drain_tx_timestamps(&mut self) -> Result<(), ClientError> {
if !self.tx_timestamping_enabled {
return Ok(());
}
let runtime = &mut self.runtime;
drain_tx_timestamps(&self.socket, |id, timestamp| {
runtime.record_kernel_tx_timestamp(id, timestamp);
})
.map_err(ClientError::Socket)
}
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
#[cfg(test)]
if self.test_hooks.fail_dscp_restore.replace(false) {
+1
View File
@@ -354,6 +354,7 @@ fn timeout_probe(seq: u32, sent_at: Instant, timeout_at: Instant) -> PendingProb
wall: SystemTime::UNIX_EPOCH,
},
timeout_at,
kernel_tx_timestamp: None,
}
}
+23 -1
View File
@@ -1,6 +1,6 @@
use std::{
collections::{HashMap, HashSet, TryReserveError, VecDeque},
time::Instant,
time::{Instant, SystemTime},
};
use crate::{error::ClientError, timing::ClientTimestamp};
@@ -10,6 +10,12 @@ pub(crate) struct PendingProbe {
pub wire_seq: u32,
pub sent_at: ClientTimestamp,
pub timeout_at: Instant,
/// Observed Linux kernel TX_SOFTWARE wall timestamp for this probe's
/// send, when the socket has TX timestamping enabled and a matching
/// `MSG_ERRQUEUE` record has been drained. Dormant metadata only: no
/// measurement in this change reads it. `sent_at` remains authoritative
/// for RTT, timeout, and current one-way delay.
pub kernel_tx_timestamp: Option<SystemTime>,
}
#[derive(Debug)]
@@ -158,6 +164,14 @@ impl PendingMap {
self.map.len()
}
/// Mutable access to a still-pending probe by wire sequence, without
/// disturbing its position in the timeout order. Used to attach an
/// observed kernel TX timestamp to a probe that has not yet completed or
/// timed out.
pub fn get_mut(&mut self, wire_seq: u32) -> Option<&mut PendingProbe> {
self.map.get_mut(&wire_seq).map(|entry| &mut entry.probe)
}
#[cfg(any(feature = "tokio", test))]
pub fn next_timeout_deadline(&self) -> Option<Instant> {
self.first
@@ -266,6 +280,13 @@ impl TimedOutMap {
removed
}
/// Mutable access to a still-retained timed-out probe by wire sequence,
/// without disturbing eviction order. Used to attach an observed kernel
/// TX timestamp that arrives after the probe has already timed out.
pub fn get_mut(&mut self, wire_seq: u32) -> Option<&mut PendingProbe> {
self.map.get_mut(&wire_seq)
}
pub fn clear(&mut self) {
self.map.clear();
self.insertion_order.clear();
@@ -367,6 +388,7 @@ mod tests {
wire_seq: seq,
sent_at: ts(timeout_at - Duration::from_secs(1)),
timeout_at,
kernel_tx_timestamp: None,
}
}
+35
View File
@@ -1,6 +1,7 @@
use std::{
io,
net::{SocketAddr, UdpSocket},
time::SystemTime,
};
use crate::{metadata::ReceiveMeta, timing::ClientTimestamp};
@@ -77,6 +78,40 @@ pub(crate) fn configure_receive_metadata(socket: &UdpSocket, remote: SocketAddr)
linux::configure_receive_metadata(socket, remote)
}
/// Best-effort upgrade a socket from RX-only to RX+TX kernel software
/// timestamping. Returns whether the upgrade succeeded; a caller should
/// track this to skip later drains entirely rather than perform harmless
/// no-op ones. Always returns `false` off Linux or without the `ancillary`
/// feature, so callers never need their own platform `cfg`.
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
pub(crate) fn try_enable_tx_timestamping<S>(_socket: &S) -> bool {
false
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
pub(crate) fn try_enable_tx_timestamping<S: std::os::fd::AsFd>(socket: &S) -> bool {
linux::try_enable_tx_timestamping(socket)
}
/// Bounded, nonblocking drain of `socket`'s `MSG_ERRQUEUE`, reporting each
/// usable TX timestamp completion to `on_timestamp`. Always a no-op off
/// Linux or without the `ancillary` feature.
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
pub(crate) fn drain_tx_timestamps<S>(
_socket: &S,
_on_timestamp: impl FnMut(u32, SystemTime),
) -> io::Result<()> {
Ok(())
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
pub(crate) fn drain_tx_timestamps<S: std::os::fd::AsRawFd>(
socket: &S,
on_timestamp: impl FnMut(u32, SystemTime),
) -> io::Result<()> {
linux::drain_tx_timestamps(socket, on_timestamp)
}
#[cfg(test)]
mod tests {
use std::net::UdpSocket;
+135 -14
View File
@@ -1,25 +1,30 @@
//! Linux ancillary receive metadata.
//! Linux ancillary receive and transmit metadata.
//!
//! Receive kernel timing uses `SO_TIMESTAMPING` with `RX_SOFTWARE` +
//! `SOFTWARE` (reported via `SCM_TIMESTAMPING`), not the older
//! `SO_TIMESTAMPNS`/`SCM_TIMESTAMPNS`. Linux documents fallback quirks when
//! `SO_TIMESTAMP[NS]` and `SO_TIMESTAMPING` are both enabled on one socket,
//! so this establishes one unambiguous software-timestamp facility before
//! TX-side timing is introduced in a follow-up change. `ReceiveMeta`'s
//! so this establishes one unambiguous software-timestamp facility. `ReceiveMeta`'s
//! semantics — an optional observed kernel receive wall time, used for
//! downstream one-way delay only when plausible relative to the userspace
//! receive sample — are unchanged by this.
//! receive sample — are unchanged by any of this module's TX-side additions.
//!
//! `SOF_TIMESTAMPING_TX_SOFTWARE` is never requested on the production
//! socket in [`configure_receive_metadata`]. TX flag construction,
//! `MSG_ERRQUEUE` parsing, and extended-error classification exist as
//! test-only primitives in [`error_queue`] for a follow-up change to build
//! on; production does not generate or drain TX timestamp records yet.
//! After a successful Open, the client adapter best-effort upgrades the
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
//! `SOF_TIMESTAMPING_OPT_ID` counter the kernel assigns to each successfully
//! submitted datagram is used directly as the probe's wire sequence number
//! (see the client crate's `AGENTS.md` for the correlation invariant this
//! relies on). [`drain_tx_timestamps`] performs a small bounded, nonblocking
//! read of `MSG_ERRQUEUE` so the adapter can opportunistically collect those
//! timestamps without ever waiting for one. The observed kernel TX wall time
//! is retained as metadata only; it is not consumed by any measurement in
//! this change.
use std::{
io::{self, IoSliceMut},
net::{SocketAddr, UdpSocket},
os::fd::{AsRawFd, RawFd},
os::fd::{AsFd, AsRawFd, RawFd},
time::{Duration, SystemTime, UNIX_EPOCH},
};
@@ -33,10 +38,74 @@ use nix::sys::{
use crate::{metadata::ReceiveMeta, receive::ReceivedDatagram, timing::ClientTimestamp};
#[cfg(test)]
mod error_queue;
pub(crate) mod error_queue;
/// `SO_TIMESTAMPING` flags requested on the production receive socket.
use error_queue::ErrorQueueRecord;
/// Starvation guard on one opportunistic `MSG_ERRQUEUE` drain.
///
/// Each successfully submitted, TX-timestamped datagram generates at most
/// one record, and drains happen at several natural choke points (after a
/// probe send, after a normal receive, before timeout expiry), so this is
/// not a queue-capacity promise: it just bounds the work one drain call can
/// do. A handful of in-flight probes' worth of backlog is the realistic
/// case between choke points, so a small constant comfortably covers it
/// without any unbounded loop.
pub(crate) const MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN: usize = 32;
/// Best-effort upgrade from RX-only to RX+TX `SO_TIMESTAMPING` flags.
///
/// Returns `true` when the upgrade succeeded and the caller may now expect
/// `MSG_ERRQUEUE` TX timestamp records for subsequent sends. On failure, the
/// original [`RX_TIMESTAMPING_FLAGS`] are best-effort restored so a failed
/// upgrade cannot leave the socket without the RX timestamping this client
/// already depends on; that restore's own failure is not observable here
/// (both leave the caller without TX capability, which this return value
/// already reports).
pub(crate) fn try_enable_tx_timestamping<S: AsFd>(socket: &S) -> bool {
if setsockopt(
socket,
sockopt::Timestamping,
&error_queue::TX_TIMESTAMPING_FLAGS,
)
.is_ok()
{
return true;
}
let _ = setsockopt(socket, sockopt::Timestamping, &RX_TIMESTAMPING_FLAGS);
false
}
/// Bounded, nonblocking drain of `fd`'s `MSG_ERRQUEUE`.
///
/// Reads and classifies up to [`MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN`] records,
/// one at a time with no intermediate allocation. Each usable TX timestamp
/// completion is reported to `on_timestamp` immediately. Malformed or
/// unsupported timestamp notifications and unrelated records are silently
/// dropped: timestamp metadata is optional and never fails a probe. A
/// genuine non-timestamp socket/network error stops the drain and is
/// returned to the caller, which decides how to surface it.
pub(crate) fn drain_tx_timestamps<S: AsRawFd>(
socket: &S,
mut on_timestamp: impl FnMut(u32, SystemTime),
) -> io::Result<()> {
let fd = socket.as_raw_fd();
for _ in 0..MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN {
match error_queue::try_recv_error_queue_record(fd)? {
None => return Ok(()),
Some(ErrorQueueRecord::TxTimestamp { id, timestamp }) => on_timestamp(id, timestamp),
Some(ErrorQueueRecord::MalformedOrUnsupportedTimestamp | ErrorQueueRecord::Ignored) => {
}
Some(ErrorQueueRecord::SocketError { errno, .. }) => {
return Err(io::Error::from_raw_os_error(errno));
}
}
}
Ok(())
}
/// `SO_TIMESTAMPING` flags requested on every production socket at
/// connect time, before any TX upgrade is attempted.
///
/// `SOF_TIMESTAMPING_RX_SOFTWARE` asks the kernel to generate a software
/// receive timestamp for each datagram; `SOF_TIMESTAMPING_SOFTWARE` asks it
@@ -44,7 +113,8 @@ mod error_queue;
/// the older `SO_TIMESTAMP`/`SO_TIMESTAMPNS` on the same socket has
/// documented fallback quirks, so the client uses `SO_TIMESTAMPING`
/// exclusively for receive timing. Hardware and TX-side flags are
/// deliberately excluded: this socket is receive-only.
/// deliberately excluded here: a fresh socket is receive-only until (and
/// unless) [`try_enable_tx_timestamping`] later upgrades it.
const RX_TIMESTAMPING_FLAGS: TimestampingFlag = TimestampingFlag::SOF_TIMESTAMPING_RX_SOFTWARE
.union(TimestampingFlag::SOF_TIMESTAMPING_SOFTWARE);
@@ -430,4 +500,55 @@ mod tests {
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
)
}
#[test]
fn try_enable_tx_timestamping_then_drain_reports_ids_through_the_adapter_api() {
let (socket, _peer) = connected_ipv4_loopback_pair();
// Mirrors production: RX-only first, then a best-effort upgrade.
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
if !super::try_enable_tx_timestamping(&socket) {
eprintln!(
"skipping adapter-level TX timestamping smoke test: kernel/container denied \
SO_TIMESTAMPING TX flags"
);
return;
}
if let Err(error) = socket.send(b"one") {
eprintln!("skipping adapter-level TX timestamping smoke test: send failed: {error}");
return;
}
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut observed = None;
while observed.is_none() && std::time::Instant::now() < deadline {
super::drain_tx_timestamps(&socket, |id, timestamp| {
observed = Some((id, timestamp));
})
.unwrap();
std::thread::yield_now();
}
let Some((id, timestamp)) = observed else {
eprintln!(
"skipping adapter-level TX timestamping smoke test: no TX timestamp record \
arrived within the poll deadline"
);
return;
};
assert_eq!(id, 0);
assert!(timestamp.duration_since(UNIX_EPOCH).unwrap() > Duration::ZERO);
}
#[test]
fn drain_tx_timestamps_is_a_bounded_nonblocking_no_op_on_an_untouched_socket() {
let (socket, _peer) = connected_ipv4_loopback_pair();
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
let mut calls = 0_usize;
super::drain_tx_timestamps(&socket, |_, _| calls += 1).unwrap();
assert_eq!(calls, 0);
}
}
@@ -1,11 +1,12 @@
//! Safe `MSG_ERRQUEUE` primitives for the future client TX timestamp work
//! (see the parent module's TX-foundation note).
//! Safe `MSG_ERRQUEUE` primitives backing the client's TX timestamp capture.
//!
//! Test-only for this PR: production sockets never enable
//! `SOF_TIMESTAMPING_TX_SOFTWARE` and never drain the error queue, so
//! nothing here runs outside `#[cfg(test)]`. It exists so the follow-up PR
//! that wires TX timestamps into probes does not need to rediscover flag
//! construction, error-queue parsing, or extended-error classification.
//! A production socket that successfully upgraded to
//! [`TX_TIMESTAMPING_FLAGS`] receives one extended-error record per
//! successfully submitted timestamped datagram on `MSG_ERRQUEUE`.
//! [`try_recv_error_queue_record`] performs one nonblocking read of that
//! queue and [`classify`] turns its ancillary data into an
//! [`ErrorQueueRecord`] without ever panicking or requiring the caller to
//! understand cmsg layout.
use std::{
io::{self, IoSliceMut},
@@ -22,13 +23,11 @@ use nix::{
use super::system_time_from_timespec;
/// Desired `SO_TIMESTAMPING` configuration for a future TX-timestamped
/// socket. Combines this PR's RX flags with the TX-side flags the next PR
/// needs: `TX_SOFTWARE` to generate a send timestamp, `OPT_ID` for automatic
/// per-datagram correlation, and `OPT_TSONLY` so the notification does not
/// need to retain or copy the original payload.
///
/// Not applied to any production socket in this PR.
/// `SO_TIMESTAMPING` configuration for a TX-timestamped socket. Combines the
/// module's RX flags with the TX-side flags TX capture needs: `TX_SOFTWARE`
/// to generate a send timestamp, `OPT_ID` for automatic per-datagram
/// correlation, and `OPT_TSONLY` so the notification does not need to
/// retain or copy the original payload.
pub(crate) const TX_TIMESTAMPING_FLAGS: TimestampingFlag =
TimestampingFlag::SOF_TIMESTAMPING_RX_SOFTWARE
.union(TimestampingFlag::SOF_TIMESTAMPING_SOFTWARE)
@@ -70,14 +69,18 @@ pub(crate) enum ErrorQueueRecord {
/// `SO_EE_ORIGIN_TIMESTAMPING`, its kind was `SCM_TSTAMP_SND`, and a
/// usable software timestamp accompanied it.
TxTimestamp { id: u32, timestamp: SystemTime },
/// A timestamp-origin extended error was present, but no usable
/// timestamp accompanied it: either the `SCM_TIMESTAMPING` cmsg was
/// missing, or its value failed to convert to a `SystemTime`. An
/// accuracy failure, not a socket failure.
MalformedTimestamp,
/// An extended error that is not a timestamp completion: a real
/// network, local, or ICMP error surfaced on the error queue. Must not
/// be misclassified as malformed timing metadata.
/// The extended error's origin was `SO_EE_ORIGIN_TIMESTAMPING` (i.e. this
/// is unambiguously a timestamp facility record, never a real
/// socket/network error), but it was not a usable send-timestamp
/// completion: the completion kind was not `SCM_TSTAMP_SND` (this client
/// requests only `TX_SOFTWARE`, so only `SND` is expected, but an
/// unrequested kind must not be mistaken for a socket failure), the
/// `SCM_TIMESTAMPING` cmsg was missing, or its value failed to convert
/// to a `SystemTime`. An accuracy failure, not a socket failure.
MalformedOrUnsupportedTimestamp,
/// An extended error whose origin was not `SO_EE_ORIGIN_TIMESTAMPING`: a
/// real network, local, or ICMP error surfaced on the error queue. Must
/// not be misclassified as malformed timing metadata.
SocketError { errno: i32, origin: u8 },
/// No extended error was present in this record's control data (e.g.
/// only unrelated/unknown cmsgs).
@@ -87,10 +90,13 @@ pub(crate) enum ErrorQueueRecord {
/// Classify a decoded set of cmsgs from one `MSG_ERRQUEUE` datagram.
///
/// Collects the extended error and any accompanying timestamp
/// independently before classifying, so cmsg order never matters. A
/// timestamp is only ever attached to a timestamp-origin extended error;
/// an ID is never reported without a timestamp, and a timestamp is never
/// accepted without a valid timestamp-origin extended error.
/// independently before classifying, so cmsg order never matters. Origin is
/// the sole discriminator between a timestamp facility record and a real
/// socket error: any `SO_EE_ORIGIN_TIMESTAMPING` record is timing metadata,
/// however unexpected its `errno`/`ee_info`, and only ever becomes
/// [`ErrorQueueRecord::MalformedOrUnsupportedTimestamp`] on the way to
/// `None` — never [`ErrorQueueRecord::SocketError`]. A timestamp is only
/// ever attached to a timestamp-origin record reporting `SCM_TSTAMP_SND`.
pub(crate) fn classify(cmsgs: impl Iterator<Item = ControlMessageOwned>) -> ErrorQueueRecord {
let mut extended_error: Option<(u32, u8, u32, u32)> = None;
let mut timestamp: Option<Timestamps> = None;
@@ -114,20 +120,21 @@ pub(crate) fn classify(cmsgs: impl Iterator<Item = ControlMessageOwned>) -> Erro
return ErrorQueueRecord::Ignored;
};
let is_timestamp_origin = origin == nix::libc::SO_EE_ORIGIN_TIMESTAMPING
&& errno == nix::libc::ENOMSG as u32
&& info == SCM_TSTAMP_SND;
if !is_timestamp_origin {
if origin != nix::libc::SO_EE_ORIGIN_TIMESTAMPING {
return ErrorQueueRecord::SocketError {
errno: errno as i32,
origin,
};
}
let is_send_completion = errno == nix::libc::ENOMSG as u32 && info == SCM_TSTAMP_SND;
if !is_send_completion {
return ErrorQueueRecord::MalformedOrUnsupportedTimestamp;
}
match timestamp.and_then(|observed| system_time_from_timespec(observed.system)) {
Some(timestamp) => ErrorQueueRecord::TxTimestamp { id, timestamp },
None => ErrorQueueRecord::MalformedTimestamp,
None => ErrorQueueRecord::MalformedOrUnsupportedTimestamp,
}
}
@@ -248,7 +255,7 @@ mod tests {
assert_eq!(
classify(cmsgs.into_iter()),
ErrorQueueRecord::MalformedTimestamp
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
);
}
@@ -261,7 +268,7 @@ mod tests {
assert_eq!(
classify(cmsgs.into_iter()),
ErrorQueueRecord::MalformedTimestamp
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
);
}
@@ -287,10 +294,12 @@ mod tests {
}
#[test]
fn classifies_timestamping_origin_with_wrong_completion_kind_as_socket_error() {
fn classifies_timestamping_origin_with_wrong_completion_kind_as_malformed() {
// Same origin byte as a send-timestamp completion, but a different
// `ee_info` (e.g. SCM_TSTAMP_SCHED/ACK) must not be mistaken for
// SCM_TSTAMP_SND.
// `ee_info` (e.g. SCM_TSTAMP_SCHED/ACK, which this client never
// requests). A timestamping-origin record must never become a
// fatal SocketError merely because it reports a completion kind we
// did not ask for; it stays timing metadata.
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
extended_error(
nix::libc::ENOMSG as u32,
@@ -303,10 +312,29 @@ mod tests {
assert_eq!(
classify(cmsgs.into_iter()),
ErrorQueueRecord::SocketError {
errno: nix::libc::ENOMSG,
origin: nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
);
}
#[test]
fn classifies_timestamping_origin_with_unexpected_errno_as_malformed() {
// Origin is the sole discriminator: even an origin==TIMESTAMPING
// record with a surprising errno (the kernel always reports ENOMSG
// for this origin, but classification must not assume that) is
// still timing metadata, never a real socket error.
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
extended_error(
nix::libc::ECONNREFUSED as u32,
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
SCM_TSTAMP_SND,
0,
),
None,
)];
assert_eq!(
classify(cmsgs.into_iter()),
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
);
}
@@ -436,15 +464,170 @@ mod tests {
);
}
// NOTE: a deterministic test proving that a failed nonblocking `sendmsg`
// does not consume an automatic OPT_ID is not included here. Forcing a
// reliable, non-flaky `WouldBlock` on a connected loopback UDP socket
// (e.g. via socket-buffer saturation) has no deterministic OS-level
// mechanism available in this environment, and the repository's testing
// policy explicitly forbids faking coverage with sleep/saturation-based
// flakiness. This remains an open assumption for the follow-up PR that
// wires automatic ID correlation into production: it must verify, by
// construction or by a reliable test double, that a `WouldBlock` send
// never advances the ID the kernel will assign to the next
// successfully transmitted timestamped datagram.
#[test]
fn rx_only_sends_before_tx_enable_do_not_consume_the_later_tx_id_namespace() {
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
socket.connect(peer.local_addr().unwrap()).unwrap();
setsockopt(
&socket,
sockopt::Timestamping,
&super::super::RX_TIMESTAMPING_FLAGS,
)
.unwrap();
// Ordinary pre-enable sends: TX_SOFTWARE is not yet requested, so
// none of these may leave anything on the error queue for the
// later-enabled OPT_ID counter to have skipped past.
for _ in 0..3 {
if let Err(error) = socket.send(b"pre-enable") {
eprintln!("skipping pre-enable send/RX-only smoke test: {error}");
return;
}
}
assert_eq!(
try_recv_error_queue_record(socket.as_raw_fd()).unwrap(),
None,
"RX-only sends must never populate MSG_ERRQUEUE"
);
// This mirrors production: RX-only is configured first (e.g. at
// connect time), and only later, after a successful Open, is the
// socket upgraded to TX_TIMESTAMPING_FLAGS.
if setsockopt(&socket, sockopt::Timestamping, &TX_TIMESTAMPING_FLAGS).is_err() {
eprintln!(
"skipping pre-enable send/RX-only smoke test: kernel/container denied \
SO_TIMESTAMPING TX flags"
);
return;
}
if let Err(error) = socket.send(b"first-timestamped") {
eprintln!(
"skipping pre-enable send/RX-only smoke test: post-upgrade send failed: {error}"
);
return;
}
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
eprintln!(
"skipping pre-enable send/RX-only smoke test: no TX timestamp record was \
delivered within the poll deadline"
);
return;
};
assert!(
matches!(record, ErrorQueueRecord::TxTimestamp { id: 0, .. }),
"expected the first send after upgrade to report id 0 (pre-enable sends must not \
have consumed part of the namespace), got {record:?}"
);
}
#[test]
fn normal_rx_scm_timestamping_still_works_after_tx_flags_are_enabled() {
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
return;
};
peer.send_to(b"reply", socket.local_addr().unwrap())
.unwrap();
let mut buf = [0_u8; 16];
let Ok(datagram) = super::super::recv_datagram(&socket, &mut buf) else {
eprintln!("skipping post-TX-enable RX smoke test: receive failed");
return;
};
assert_eq!(&buf[..datagram.len], b"reply");
let Some(timestamp) = datagram.meta.kernel_rx_timestamp else {
eprintln!(
"skipping post-TX-enable RX smoke test: kernel did not provide SCM_TIMESTAMPING"
);
return;
};
assert!(timestamp.duration_since(std::time::UNIX_EPOCH).unwrap() > Duration::ZERO);
}
#[test]
fn tx_timestamp_and_normal_reply_streams_do_not_interfere() {
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
return;
};
let fd = socket.as_raw_fd();
if let Err(error) = socket.send(b"ping") {
eprintln!("skipping TX/RX separation smoke test: ping send failed: {error}");
return;
}
let mut incoming = [0_u8; 16];
let Ok((len, from)) = peer.recv_from(&mut incoming) else {
eprintln!("skipping TX/RX separation smoke test: peer never observed ping");
return;
};
assert_eq!(&incoming[..len], b"ping");
peer.send_to(b"pong", from).unwrap();
// Reading the normal payload must not require, wait for, or be
// disturbed by draining MSG_ERRQUEUE first.
let mut reply_buf = [0_u8; 16];
let Ok(datagram) = super::super::recv_datagram(&socket, &mut reply_buf) else {
eprintln!("skipping TX/RX separation smoke test: normal receive failed");
return;
};
assert_eq!(&reply_buf[..datagram.len], b"pong");
// The TX timestamp for "ping" is still independently available on
// the error queue, unconsumed by the normal receive above.
let Some(record) = poll_for_record(fd, Duration::from_secs(2)) else {
eprintln!("skipping TX/RX separation smoke test: no TX timestamp record arrived");
return;
};
assert!(matches!(
record,
ErrorQueueRecord::TxTimestamp { id: 0, .. }
));
}
#[test]
fn tx_timestamp_correlation_does_not_depend_on_original_payload() {
let Some((socket, _peer)) = tx_timestamped_loopback_socket() else {
return;
};
// OPT_TSONLY notifications never carry the original payload; a
// larger payload must classify identically to the zero-length one
// used elsewhere, since correlation is by `id` alone.
let payload = vec![0x5A_u8; 512];
if let Err(error) = socket.send(&payload) {
eprintln!("skipping OPT_TSONLY payload-independence smoke test: send failed: {error}");
return;
}
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
eprintln!(
"skipping OPT_TSONLY payload-independence smoke test: no TX timestamp record \
arrived"
);
return;
};
assert!(matches!(
record,
ErrorQueueRecord::TxTimestamp { id: 0, .. }
));
}
// NOTE: a live test proving that a failed nonblocking `sendmsg` does not
// consume an automatic OPT_ID is not included here. Forcing a reliable,
// non-flaky `WouldBlock` on a connected loopback UDP socket (e.g. via
// socket-buffer saturation) has no deterministic OS-level mechanism
// available in this environment, and the repository's testing policy
// explicitly forbids faking coverage with sleep/saturation-based
// flakiness. That safety is instead established from Linux kernel
// source: `_sock_tx_timestamp` (include/net/sock.h) and
// `__ip_append_data`/`__ip6_append_data` (net/ipv4/ip_output.c,
// net/ipv6/ip6_output.c) increment `sk->sk_tskey` speculatively before
// a datagram is fully built and explicitly `atomic_dec` it back out on
// every error path (tracked via a local `hold_tskey` flag), so a failed
// send can never leave a gap in — or otherwise advance — the ID space
// the kernel hands out to successfully submitted datagrams. See the
// client crate's `AGENTS.md` for the full citation. The client-side
// half of this invariant (a `WouldBlock` `try_send` never advances the
// local wire-sequence counter used as the correlation ID) is covered by
// a deterministic test in the adapter module that injects `WouldBlock`.
}
+343
View File
@@ -366,6 +366,7 @@ impl SessionMachine {
wire_seq: preflight.seq,
sent_at,
timeout_at,
kernel_tx_timestamp: None,
},
next_wire_seq: preflight.next_wire_seq,
next_packets_sent: preflight.next_packets_sent,
@@ -392,6 +393,35 @@ impl SessionMachine {
}
}
/// Record an observed Linux kernel TX timestamp for `wire_seq`, the
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
/// datagram was submitted.
///
/// `wire_seq` doubles as that correlation ID: both counters start at
/// zero for a session's first timestamped send and advance by exactly
/// one only on a confirmed successful submission, so they stay
/// identical for the life of one open session (see the client crate's
/// `AGENTS.md` for the full invariant).
///
/// Updates a still-pending or already-timed-out probe in place. Never
/// resurrects a completed, evicted, or unknown probe, and never touches
/// `sent_at`, timeout/loss/reply state, or any counter — this is purely
/// dormant metadata attachment. A probe that already has a timestamp
/// keeps it: first usable observation wins, so a later duplicate cannot
/// silently overwrite it.
pub(crate) fn record_kernel_tx_timestamp(&mut self, wire_seq: u32, timestamp: SystemTime) {
let MachineState::Open(session) = &mut self.state else {
return;
};
if let Some(probe) = session.pending.get_mut(wire_seq) {
probe.kernel_tx_timestamp.get_or_insert(timestamp);
return;
}
if let Some(probe) = session.timed_out.get_mut(wire_seq) {
probe.kernel_tx_timestamp.get_or_insert(timestamp);
}
}
pub(crate) fn process_received_echo_packet(
&mut self,
packet: &[u8],
@@ -1053,6 +1083,7 @@ impl SessionMachine {
wire_seq: 0,
sent_at: now,
timeout_at: now.mono,
kernel_tx_timestamp: None,
});
session.completed.insert(0);
}
@@ -1789,6 +1820,7 @@ mod tests {
wire_seq: seq,
sent_at,
timeout_at: sent_at.mono + Duration::from_secs(1),
kernel_tx_timestamp: None,
});
}
@@ -1828,6 +1860,7 @@ mod tests {
wire_seq: 0,
sent_at: timestamp(now - Duration::from_secs(1)),
timeout_at: now,
kernel_tx_timestamp: None,
};
let session = active_mut(&mut machine);
session.next_wire_seq = 0;
@@ -1916,6 +1949,7 @@ mod tests {
wire_seq: 0,
sent_at,
timeout_at: sent_at.mono + Duration::from_secs(1),
kernel_tx_timestamp: None,
});
session.next_wire_seq = 1;
machine
@@ -1930,6 +1964,7 @@ mod tests {
wire_seq: 0,
sent_at,
timeout_at: sent_at.mono + Duration::from_secs(1),
kernel_tx_timestamp: None,
});
session.next_wire_seq = 1;
machine
@@ -2217,4 +2252,312 @@ mod tests {
Some(SignedDuration::from_nanos(15_000_000))
);
}
// Kernel TX timestamp correlation (`record_kernel_tx_timestamp`).
//
// These probe `SessionMachine`'s private association state directly.
// The kernel TX timestamp is dormant metadata in this change: none of
// these tests assert anything about RTT/OWD/IPDV output, only about
// where `PendingProbe::kernel_tx_timestamp` ends up.
fn tx_ts(offset_secs: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(offset_secs)
}
fn send_probe(machine: &mut SessionMachine, sent_at: ClientTimestamp) -> u32 {
let prepared = machine.prepare_probe().unwrap().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine.commit_probe_sent(commit, prepared.bytes.len()).seq
}
#[test]
fn kernel_tx_timestamp_attaches_to_pending_probe() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq = send_probe(&mut machine, timestamp(now));
machine.record_kernel_tx_timestamp(seq, tx_ts(1));
assert_eq!(
active_mut(&mut machine)
.pending
.get_mut(seq)
.unwrap()
.kernel_tx_timestamp,
Some(tx_ts(1))
);
}
#[test]
fn kernel_tx_timestamp_for_unknown_id_does_nothing() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq = send_probe(&mut machine, timestamp(now));
// No probe was ever sent for sequence 41; recording against it must
// not panic, allocate a phantom entry, or affect the real probe.
machine.record_kernel_tx_timestamp(41, tx_ts(9));
assert!(active_mut(&mut machine)
.pending
.get_mut(seq)
.unwrap()
.kernel_tx_timestamp
.is_none());
assert_eq!(active(&machine).pending.len(), 1);
}
#[test]
fn kernel_tx_timestamps_associate_correctly_when_observed_out_of_order() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq10 = send_probe(&mut machine, timestamp(now));
let seq11 = send_probe(&mut machine, timestamp(now));
let seq12 = send_probe(&mut machine, timestamp(now));
assert_eq!((seq10, seq11, seq12), (0, 1, 2));
// Linux does not guarantee MSG_ERRQUEUE dequeue order matches send
// order; observe them out of order (12, 10, 11).
machine.record_kernel_tx_timestamp(seq12, tx_ts(12));
machine.record_kernel_tx_timestamp(seq10, tx_ts(10));
machine.record_kernel_tx_timestamp(seq11, tx_ts(11));
let session = active_mut(&mut machine);
assert_eq!(
session.pending.get_mut(seq10).unwrap().kernel_tx_timestamp,
Some(tx_ts(10))
);
assert_eq!(
session.pending.get_mut(seq11).unwrap().kernel_tx_timestamp,
Some(tx_ts(11))
);
assert_eq!(
session.pending.get_mut(seq12).unwrap().kernel_tx_timestamp,
Some(tx_ts(12))
);
}
#[test]
fn first_valid_kernel_tx_timestamp_wins_over_a_later_duplicate() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq = send_probe(&mut machine, timestamp(now));
machine.record_kernel_tx_timestamp(seq, tx_ts(1));
machine.record_kernel_tx_timestamp(seq, tx_ts(2));
assert_eq!(
active_mut(&mut machine)
.pending
.get_mut(seq)
.unwrap()
.kernel_tx_timestamp,
Some(tx_ts(1))
);
}
#[test]
fn probe_timeout_preserves_an_existing_kernel_tx_timestamp() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq = send_probe(&mut machine, timestamp(now));
machine.record_kernel_tx_timestamp(seq, tx_ts(1));
let events = machine
.poll_timeouts_at(now + Duration::from_secs(2))
.unwrap();
assert!(matches!(events.as_slice(), [ClientEvent::EchoLoss { .. }]));
let session = active_mut(&mut machine);
assert!(!session.pending.contains(seq));
assert_eq!(
session.timed_out.get_mut(seq).unwrap().kernel_tx_timestamp,
Some(tx_ts(1))
);
}
#[test]
fn kernel_tx_timestamp_arriving_after_timeout_attaches_to_timed_out_probe() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq = send_probe(&mut machine, timestamp(now));
machine
.poll_timeouts_at(now + Duration::from_secs(2))
.unwrap();
assert!(active(&machine).timed_out.contains(seq));
machine.record_kernel_tx_timestamp(seq, tx_ts(3));
assert_eq!(
active_mut(&mut machine)
.timed_out
.get_mut(seq)
.unwrap()
.kernel_tx_timestamp,
Some(tx_ts(3))
);
}
#[test]
fn measurable_late_reply_retains_the_kernel_tx_timestamp() {
let now = Instant::now();
let mut machine = machine_with_timed_out_probe(owd_sent_at(now));
machine.record_kernel_tx_timestamp(0, tx_ts(4));
assert_eq!(
active_mut(&mut machine)
.timed_out
.get_mut(0)
.unwrap()
.kernel_tx_timestamp,
Some(tx_ts(4))
);
// The reply is measurable (a late reply with a retained sent_at);
// OWD/RTT still come from sent_at only, per NO_OWD_CHANGE, but the
// dormant kernel timestamp must have already been retained above
// and this call must not disturb that.
let events = process_owd_reply(
&mut machine,
owd_timestamps(),
kernel_rx_meta(25_000_000),
owd_received_at(now),
);
assert!(matches!(events.as_slice(), [ClientEvent::LateReply { .. }]));
}
#[test]
fn completed_probe_ignores_a_later_kernel_tx_timestamp() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq = send_probe(&mut machine, timestamp(now));
let reply = EchoReply {
flags: 0,
token: active(&machine).token,
sequence: seq,
recv_count: None,
recv_window: None,
timestamps: TimestampFields::default(),
payload: Vec::new(),
};
machine
.process_echo_reply(
reply,
64,
ClientTimestamp {
mono: now,
wall: SystemTime::now(),
},
ReceiveMeta::default(),
)
.unwrap();
assert!(active(&machine).completed.contains(seq));
assert!(!active(&machine).pending.contains(seq));
// Must not resurrect the probe into either map.
machine.record_kernel_tx_timestamp(seq, tx_ts(5));
let session = active_mut(&mut machine);
assert!(session.pending.get_mut(seq).is_none());
assert!(session.timed_out.get_mut(seq).is_none());
}
#[test]
fn evicted_timed_out_probe_ignores_a_later_kernel_tx_timestamp() {
let mut machine = open_machine(1, Duration::from_secs(1));
let now = Instant::now();
let first = send_probe(&mut machine, timestamp(now));
machine
.poll_timeouts_at(now + Duration::from_secs(2))
.unwrap();
assert!(active(&machine).timed_out.contains(first));
// capacity 1: sending and timing out a second probe evicts the first
// from the bounded TimedOutMap.
let second = send_probe(&mut machine, timestamp(now + Duration::from_secs(2)));
machine
.poll_timeouts_at(now + Duration::from_secs(4))
.unwrap();
assert!(active(&machine).timed_out.contains(second));
assert!(!active(&machine).timed_out.contains(first));
// Recording against the evicted ID must not panic or resurrect it.
machine.record_kernel_tx_timestamp(first, tx_ts(6));
assert!(active_mut(&mut machine).timed_out.get_mut(first).is_none());
}
#[test]
fn kernel_tx_timestamp_recording_is_a_no_op_once_closed() {
let mut machine = open_machine(4, Duration::from_secs(1));
let now = Instant::now();
let seq = send_probe(&mut machine, timestamp(now));
let prepared = machine.prepare_close().unwrap();
let commit = prepared.commit;
machine.commit_local_close(commit, timestamp(now));
assert!(machine.is_terminal());
// Recording after close must not panic (there is no open session to
// index into) and must remain inert.
machine.record_kernel_tx_timestamp(seq, tx_ts(7));
}
#[test]
fn kernel_tx_timestamp_ids_wrap_like_wire_seq() {
let mut machine = open_machine(4, Duration::from_secs(1));
active_mut(&mut machine).next_wire_seq = u32::MAX;
let now = Instant::now();
let last = send_probe(&mut machine, timestamp(now));
let wrapped = send_probe(&mut machine, timestamp(now));
assert_eq!(last, u32::MAX);
assert_eq!(wrapped, 0);
machine.record_kernel_tx_timestamp(u32::MAX, tx_ts(1));
machine.record_kernel_tx_timestamp(0, tx_ts(2));
let session = active_mut(&mut machine);
assert_eq!(
session
.pending
.get_mut(u32::MAX)
.unwrap()
.kernel_tx_timestamp,
Some(tx_ts(1))
);
assert_eq!(
session.pending.get_mut(0).unwrap().kernel_tx_timestamp,
Some(tx_ts(2))
);
}
#[test]
fn kernel_tx_timestamp_presence_does_not_change_rtt_or_owd() {
let mono = Instant::now();
let mut without = machine_with_pending_probe(owd_sent_at(mono));
let mut with = machine_with_pending_probe(owd_sent_at(mono));
with.record_kernel_tx_timestamp(0, tx_ts(1));
let without_events = process_owd_reply(
&mut without,
owd_timestamps(),
kernel_rx_meta(25_000_000),
owd_received_at(mono),
);
let with_events = process_owd_reply(
&mut with,
owd_timestamps(),
kernel_rx_meta(25_000_000),
owd_received_at(mono),
);
let without_reply = match without_events.as_slice() {
[ClientEvent::EchoReply { rtt, one_way, .. }] => (*rtt, *one_way),
other => panic!("expected one EchoReply, got {other:?}"),
};
let with_reply = match with_events.as_slice() {
[ClientEvent::EchoReply { rtt, one_way, .. }] => (*rtt, *one_way),
other => panic!("expected one EchoReply, got {other:?}"),
};
assert_eq!(without_reply, with_reply);
}
}