feat(client): capture kernel transmit timestamps
On Linux, best-effort upgrade the socket's SO_TIMESTAMPING flags to TX_SOFTWARE | OPT_ID | OPT_TSONLY after a successful Open and before the first Echo probe. Promote the #103 error-queue foundation to production: MSG_ERRQUEUE is drained opportunistically with a fixed per-call budget at several natural choke points (after a probe send, before processing a reply, before evaluating timeouts), and each automatic OPT_ID timestamp is correlated directly with wire_seq — safe because Linux rolls back its speculative sk_tskey increment on every send failure, wire_seq and the kernel counter start together at every Open, and a socket can never be reopened for a second session. The observed kernel TX wall timestamp is retained on PendingProbe (surviving pending -> timed-out retention) as dormant metadata only; RTT, OWD, and IPDV are unchanged and continue to use the userspace send timestamp exclusively. Capability failure is fully non-fatal and RX timestamping is unaffected either way. Non-Linux and non-ancillary builds are unchanged.
This commit is contained in:
10 files changed
+948
-69
No files matched your search
@@ -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`.
|
||||
}
|
||||
Reference in new issue
Block a user