diff --git a/crates/irtt-server/AGENTS.md b/crates/irtt-server/AGENTS.md index 00650f0..576be03 100644 --- a/crates/irtt-server/AGENTS.md +++ b/crates/irtt-server/AGENTS.md @@ -177,17 +177,74 @@ the mirror image of destination metadata, and the contrast is the point. on an explicit listener, or one that describes no representable instant, all mean the datagram is served with `kernel_rx_timestamp: None`. Only the destination rules a datagram out, and only for the listener that needs one. -- **Nothing consumes it yet.** It stops at the runtime boundary. Do not thread it - into `ServerCore`, add a timestamp argument or variant to `handle_datagram`, - stash it in a "next receive" side channel, or wrap the core's clock. How a - kernel-observed arrival time should enter the core is an open design question, - to be settled deliberately and not as a side effect of some other change. - **Do not map wall to monotonic.** It is a realtime reading with no monotonic counterpart, and `ClockSample` is a paired userspace instant. Synthesizing the missing half would invent precision the kernel never reported. -- **Do not judge plausibility here.** Conversion is structural only. Whether a - representable reading is close enough to the server's own sample to measure - against belongs with whoever measures. +- **Do not judge plausibility in the socket layer.** Conversion there is + structural only. Whether a representable reading is close enough to the + server's own sample to measure against belongs with whoever measures, which is + the core. + +### How it reaches the core, and what it may change + +There are two receive entry points and one implementation behind them. + +- **`ServerCore::handle_datagram(peer, packet)` is the public one and takes no + timing.** A caller hands over bytes and an endpoint; every instant the reply + reports is one the core sampled itself. This is exactly the behavior it had + before the kernel timestamp was consumed, and it is what a direct or manual + `ServerCore` user gets. +- **The Tokio runtime uses a crate-private entry point** that additionally takes + the optional kernel-observed wall receive time, passed with the datagram it + describes. That is the whole mechanism: an explicit per-datagram argument. Do + **not** replace it with a "next receive timestamp" setter, a pending-metadata + field, thread-local or global state, or a `ClockSource` wrapper that returns + transport metadata on the next sample — those hide a per-datagram input behind + ambient mutable state. Do not make the runtime path a second public API, and + do not pass `ReplySource` or the whole `ReceivedDatagram` into the core: it + needs a peer, bytes and an arrival observation, not socket addressing. +- **`ClockSample` is unchanged and still one paired userspace instant.** The + preferred receive wall value is carried beside it on the classified echo, never + inside it. A `ClockSample` whose `wall_ns` came from the kernel and whose + `mono_ns` came from userspace would claim two physical instants were one + sample. + +The acceptance rule is the client's, in principle and in its bound, and lives in +the core as `preferred_receive_wall_ns`. A reading is used only when it converts +into the wire's signed-nanosecond vocabulary, is not later than the core's own +paired receive sample, and lags it by no more than `MAX_KERNEL_RX_LAG` — one +second, inclusive, and a sanity guard rather than an expected wakeup latency. +Anything else falls back to the userspace wall reading. **A rejected reading is +never a rejected datagram**: no error reply, no dropped packet, no session state +moved. Keep the two implementations separate from the client's; a shared helper +is not worth a crate dependency for a dozen lines of timing policy. + +What the observation may change is one field: + +- **Measurement policy**: `recv_wall` under `StampAt::Receive` and `StampAt::Both` + on a clock that reports wall time. Nothing else. +- **Lifecycle policy**: always `received_at.mono_ns`, the core's own userspace + monotonic reading. Idle expiry, rate allowance and the maximum-duration + deadline never see a kernel value, including for the rate-limited echo that + produces no reply and for the final echo that carries the close. +- **Midpoint**: always the paired userspace receive and send samples. Moving only + the wall receive endpoint would improve apparent upstream delay by exactly what + it cost apparent downstream delay, and leave `midpoint_wall` and + `midpoint_mono` describing different midpoints — with no residency interval + recoverable from a midpoint to show for it. +- **`StampAt::Send`, `StampAt::None` and `Clock::Monotonic`** emit no arrival wall + field, so an observation cannot reach them. An open or a close ignores it too. +- **Ordering still holds.** The reported arrival wall value is held back to the + send reading in its own right, because it may not have come from the receive + sample `ClockSample::not_after` orders. + +One consequence is intended: under `Clock::Wall` with `StampAt::Both`, the +server-processing interval a client computes now runs from the kernel's arrival +reading rather than the server's wakeup, so it includes the kernel-to-userspace +portion of the server's residency and the adjusted round-trip shrinks to match. +Under `Clock::Both` the monotonic pair still yields the unchanged userspace +interval. Do not force the two to agree, and in particular do not estimate a +kernel monotonic instant to do it. - Linux only, and receive only. No macOS, FreeBSD or Windows implementation, and no transmit timestamping, without a reason and agreement. - Control-buffer capacity is derived from the cmsg payload types and asserted at @@ -283,7 +340,9 @@ effective parameters must be safe for the server to run. clock source and is stable for its life. - A reply's receive instant is held back to its send instant where the wall clock stepped backwards between the two readings, so the required ordering - holds. That correction is per reply and stateless on purpose: latching a + holds — and so is a reported arrival wall value that came from the kernel + instead, which needs the rule applied in its own right because it was not part + of the pair. That correction is per reply and stateless on purpose: latching a pre-step wall value across packets would keep reporting a time the host has corrected away, which is the smoothing the specification forbids, and anchoring the wall clock to the monotonic origin would drift and never pick up diff --git a/crates/irtt-server/src/clock.rs b/crates/irtt-server/src/clock.rs index b977632..e377733 100644 --- a/crates/irtt-server/src/clock.rs +++ b/crates/irtt-server/src/clock.rs @@ -8,9 +8,18 @@ //! //! This is the clock counterpart of [`TokenSource`](crate::token::TokenSource) //! and nothing more. It is **not** a runtime abstraction, not a transport -//! boundary and not a pluggable product API: it is crate-private, the public -//! constructor still takes only a [`ServerConfig`](crate::ServerConfig), and -//! callers never pass timestamps into the core. +//! boundary and not a pluggable product API: it is crate-private and the public +//! constructor still takes only a [`ServerConfig`](crate::ServerConfig). +//! +//! The public [`ServerCore::handle_datagram`](crate::ServerCore::handle_datagram) +//! entry point takes no timestamp: the core samples every instant it reports. +//! The Tokio runtime uses a crate-private entry point that may additionally hand +//! over a kernel-observed wall receive time for one datagram, which this module +//! contributes only the [`wall_ns_of`] conversion for. Whether such an +//! observation is usable, and which reply field it may reach, is measurement +//! policy and lives in the core — this stays the module that reads clocks, not +//! one that models transport metadata. A [`ClockSample`] remains one paired +//! userspace instant and never carries a kernel reading. use std::{ fmt, @@ -123,6 +132,28 @@ fn wall_ns() -> i64 { } } +/// An observed instant as nanoseconds since the Unix epoch, or `None` when it +/// does not fit the wire's signed-nanosecond field. +/// +/// This is the conversion for an instant the server did **not** read from its +/// own [`ClockSource`] — today, a kernel receive timestamp the transport +/// observed. It reports unrepresentability rather than saturating, because a +/// clamped instant is indistinguishable from a real one at the boundary and +/// would then be compared against a genuine sample as though it were plausible. +/// A local reading has no such problem and keeps [`saturating_ns`]. +/// +/// The negation cannot fail for any [`SystemTime`] a host can hold: it is +/// reachable only from a value already inside `i64`, and only `i64::MIN` has no +/// negation. It is checked anyway so that no path here can panic. +pub(crate) fn wall_ns_of(at: SystemTime) -> Option { + match at.duration_since(UNIX_EPOCH) { + Ok(since_epoch) => i64::try_from(since_epoch.as_nanos()).ok(), + Err(before_epoch) => i64::try_from(before_epoch.duration().as_nanos()) + .ok() + .and_then(i64::checked_neg), + } +} + /// A duration as nanoseconds, saturating rather than panicking. /// /// This is the crate's one [`Duration`] → wire-nanoseconds conversion, used for @@ -215,4 +246,25 @@ mod tests { assert_eq!(saturating_ns(Duration::from_nanos(1_500)), 1_500); assert_eq!(saturating_ns(Duration::MAX), i64::MAX); } + + #[test] + fn an_observed_instant_converts_or_reports_that_it_cannot() { + assert_eq!( + wall_ns_of(UNIX_EPOCH + Duration::from_nanos(1_500)), + Some(1_500) + ); + assert_eq!(wall_ns_of(UNIX_EPOCH), Some(0)); + + // Before the epoch is negative, not a wrap and not a rejection. + assert_eq!( + wall_ns_of(UNIX_EPOCH - Duration::from_nanos(1_500)), + Some(-1_500) + ); + + // Beyond the field, in either direction, is `None` rather than a + // clamped value that would then read as an ordinary instant. + let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1); + assert_eq!(wall_ns_of(UNIX_EPOCH + beyond), None); + assert_eq!(wall_ns_of(UNIX_EPOCH - beyond), None); + } } diff --git a/crates/irtt-server/src/core.rs b/crates/irtt-server/src/core.rs index a840205..455c1c8 100644 --- a/crates/irtt-server/src/core.rs +++ b/crates/irtt-server/src/core.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, net::SocketAddr}; +use std::{ + collections::HashMap, + net::SocketAddr, + time::{Duration, SystemTime}, +}; use irtt_proto::{ decode_request, echo_packet_len, encode_echo_reply, encode_open_reply, verify_packet_hmac, @@ -7,7 +11,7 @@ use irtt_proto::{ }; use crate::{ - clock::{saturating_ns, ClockSample, ClockSource, SystemClock}, + clock::{saturating_ns, wall_ns_of, ClockSample, ClockSource, SystemClock}, config::ServerConfig, error::ServerError, fill::{echo_payload_len, negotiate_server_fill}, @@ -154,6 +158,12 @@ impl ServerCore { /// data or consulting the session table, and no reply behavior may reveal /// which stage rejected it. /// + /// Every instant this reports is one the core sampled itself: a caller hands + /// over bytes and an endpoint, not timings. The Tokio runtime, which does + /// have transport metadata, reaches the same implementation through a + /// crate-private entry point that may add a kernel-observed arrival time; + /// this path behaves exactly as it does with none. + /// /// # Errors /// /// Returns [`ServerError`] only for an internal failure — the random source @@ -164,6 +174,49 @@ impl ServerCore { &mut self, peer: SocketAddr, packet: &[u8], + ) -> Result, ServerError> { + self.process(peer, packet, None) + } + + /// Handles one received datagram together with what the transport observed + /// about its arrival. + /// + /// This is the entry point the Tokio runtime uses, and the only difference + /// from [`handle_datagram`](Self::handle_datagram) is `kernel_rx_timestamp`: + /// a wall-clock arrival instant the kernel recorded for this datagram, where + /// the listener could obtain one. Both paths run the same implementation, + /// and `None` reproduces the public path exactly. + /// + /// The observation is measurement input and nothing else. It may become the + /// receive wall value of an echo reply that negotiated one, and it can never + /// reach lifecycle, rate or deadline policy, the monotonic domain, or a + /// midpoint — those stay on the core's own paired userspace sample. An + /// implausible or unrepresentable value is not a reason to reject the + /// datagram; it simply is not used. See + /// [`preferred_receive_wall_ns`]. + /// + /// It is crate-private on purpose: transport metadata reaches the core by + /// being passed with the datagram it describes, and there is no second + /// public API for it. + /// + /// # Errors + /// + /// As [`handle_datagram`](Self::handle_datagram). + pub(crate) fn handle_received_datagram( + &mut self, + peer: SocketAddr, + packet: &[u8], + kernel_rx_timestamp: Option, + ) -> Result, ServerError> { + self.process(peer, packet, kernel_rx_timestamp) + } + + /// The one implementation behind both receive entry points. + fn process( + &mut self, + peer: SocketAddr, + packet: &[u8], + kernel_rx_timestamp: Option, ) -> Result, ServerError> { let Ok(request) = decode_request(packet) else { return Ok(None); @@ -176,7 +229,7 @@ impl ServerCore { // protocol state, and it is a strictly better trade than looking a // session up before authentication to discover whether it even wanted // timestamps. - let kind = self.classify(&request.kind, packet.len()); + let kind = self.classify(&request.kind, packet.len(), kernel_rx_timestamp); // The HMAC flag must agree with this server's configuration in both // directions of mismatch, and the MAC itself is verified separately. @@ -248,10 +301,17 @@ impl ServerCore { /// Pairs a structurally decoded request with the clock sample an echo /// needs, leaving every other kind untouched. + /// + /// An echo also settles here which wall instant its reply may report as the + /// arrival time, because that is the moment both candidates are in hand. + /// `kernel_rx_timestamp` is ignored for every other kind: an open or a close + /// emits no receive timestamp, so having observed one changes nothing about + /// it. fn classify<'a>( &mut self, kind: &DecodedRequestKind<'a>, datagram_len: usize, + kernel_rx_timestamp: Option, ) -> ClassifiedKind<'a> { match *kind { DecodedRequestKind::Open { no_test, params } => { @@ -262,12 +322,16 @@ impl ServerCore { // read no further than its length. DecodedRequestKind::Echo { token, sequence, .. - } => ClassifiedKind::Echo(EchoRequest { - token, - sequence, - datagram_len, - received_at: self.clock.sample(), - }), + } => { + let received_at = self.clock.sample(); + ClassifiedKind::Echo(EchoRequest { + token, + sequence, + datagram_len, + received_at, + receive_wall_ns: preferred_receive_wall_ns(kernel_rx_timestamp, received_at), + }) + } } } @@ -377,7 +441,12 @@ impl ServerCore { sequence: request.sequence, recv_count: params.received_stats.has_count().then_some(next.count), recv_window: params.received_stats.has_window().then_some(next.window), - timestamps: timestamp_fields(params, request.received_at, sent_at), + timestamps: timestamp_fields( + params, + request.received_at, + sent_at, + request.receive_wall_ns, + ), // The session's negotiated fill, generated fresh for this reply and // sized from the layout rather than from `params.length`. A `none` // session contributes no bytes and the encoder's zero-fill stands, @@ -621,12 +690,67 @@ enum ClassifiedKind<'a> { /// fields is carried forward. `datagram_len` is the length the datagram /// actually arrived with, which the configured maximum judges; it is not /// required to match the negotiated length, and it never sizes the reply. +/// +/// The two timing fields are deliberately separate and are not interchangeable. +/// `received_at` is the core's own paired userspace instant and is the sole +/// authority for everything about this request that is a same-host question: +/// idle expiry, rate allowance, the maximum-duration deadline, and the +/// monotonic timestamps a reply may carry. `receive_wall_ns` is only the +/// wall-clock arrival value a reply may *report*, which is the kernel's reading +/// where the transport observed a usable one. Keeping it out of `ClockSample` +/// is the point: a kernel wall reading has no monotonic counterpart, and pairing +/// it with `received_at.mono_ns` would claim two different physical instants +/// were one sample. #[derive(Debug, Clone, Copy)] struct EchoRequest { token: u64, sequence: u32, datagram_len: usize, received_at: ClockSample, + receive_wall_ns: i64, +} + +/// Largest lag behind the core's own receive sample that a kernel receive +/// timestamp may show and still be used as a measurement endpoint. +/// +/// This is a sanity guard rather than an expected kernel-to-userspace wakeup +/// latency, which is orders of magnitude smaller. A reading this far behind the +/// server's own sample most likely reflects a realtime clock discontinuity or +/// otherwise unusable metadata, and falling back to a value this server knows it +/// read is better than publishing a bad cross-host timing endpoint. The client +/// applies the same bound to its own receive metadata. +pub(crate) const MAX_KERNEL_RX_LAG: Duration = Duration::from_secs(1); + +/// The wall instant an echo reply should report as the request's arrival time. +/// +/// A kernel receive timestamp is taken before the socket queue and the runtime's +/// wakeup, so preferring it removes that delay from the upstream one-way delay a +/// client computes. It is used only when it is plausible *for this datagram*: it +/// must convert into the wire's signed-nanosecond vocabulary, it cannot be later +/// than the sample that observed the same datagram, and it may not lag that +/// sample by more than [`MAX_KERNEL_RX_LAG`]. The bound is inclusive. +/// +/// Anything else falls back to the userspace wall reading. A failed check is +/// never a reason to drop the datagram, refuse a reply or disturb session state: +/// implausible metadata means the server measures as it did before the kernel +/// offered any. +/// +/// The comparison goes through `i128` because the difference of two `i64` +/// nanosecond values can exceed `i64`. +pub(crate) fn preferred_receive_wall_ns( + kernel_rx_timestamp: Option, + received_at: ClockSample, +) -> i64 { + let userspace_wall_ns = received_at.wall_ns; + let Some(kernel_wall_ns) = kernel_rx_timestamp.and_then(wall_ns_of) else { + return userspace_wall_ns; + }; + let lag_ns = i128::from(userspace_wall_ns) - i128::from(kernel_wall_ns); + if (0..=i128::from(saturating_ns(MAX_KERNEL_RX_LAG))).contains(&lag_ns) { + kernel_wall_ns + } else { + userspace_wall_ns + } } /// Builds the timestamp fields a session negotiated, and only those. @@ -650,20 +774,65 @@ struct EchoRequest { /// receive instant must not be later than its send instant and the wall clock /// can be stepped backwards between the two readings. See /// [`ClockSample::not_after`]; the correction is confined to this one reply. -fn timestamp_fields(params: &Params, received: ClockSample, sent: ClockSample) -> TimestampFields { +/// +/// `receive_wall_ns` is the wall instant a reported arrival time should use — +/// [`preferred_receive_wall_ns`], so the kernel's reading where there was a +/// usable one and the userspace sample's own otherwise. It reaches exactly two +/// fields, `recv_wall` under [`StampAt::Receive`] and under [`StampAt::Both`], +/// and it is held to the send reading by the same rule the paired sample is. +/// Everything else keeps the paired userspace samples: +/// +/// - **Monotonic never moves.** A kernel wall reading has no monotonic +/// counterpart, and inventing one would be precision the kernel never +/// reported. So under [`Clock::Both`] a `recv_wall`/`recv_mono` pair may +/// describe two different physical instants. That is a deliberate wire +/// policy — each field is honest about the domain it names, and nothing +/// computes across the two — and it is why the core does not model them as +/// one sample. +/// - **A midpoint is untouched.** It is the mean of the receive and send +/// instants, so substituting only the wall receive endpoint would move +/// `midpoint_wall` earlier by half the kernel-to-userspace delay: apparent +/// upstream delay would improve by exactly as much as apparent downstream +/// delay worsened, `midpoint_wall` and `midpoint_mono` would stop describing +/// the same midpoint, and no server residency interval is recoverable from a +/// midpoint to make up for it. +/// - **[`StampAt::Send`] and [`StampAt::None`] report no arrival time at all**, +/// so an observation cannot reach them. +/// +/// One consequence is intended and worth stating: under [`Clock::Wall`] with +/// [`StampAt::Both`], the interval a client computes from the two reported +/// instants now spans from the kernel's arrival reading rather than from the +/// server's wakeup, so it includes the kernel-to-userspace portion of the +/// server's residency. Under [`Clock::Both`] the monotonic pair still yields the +/// unchanged userspace interval. +fn timestamp_fields( + params: &Params, + received: ClockSample, + sent: ClockSample, + receive_wall_ns: i64, +) -> TimestampFields { let received = received.not_after(sent); + // The reported arrival wall value obeys the same ordering rule, and needs + // its own application of it: it may not have come from `received`, so a + // backward wall step between the kernel's reading and the send sample would + // otherwise leave the reply claiming it was received after it was sent. + let receive_wall_ns = receive_wall_ns.min(sent.wall_ns); let per_clock = |sample: ClockSample| { ( params.clock.has_wall().then_some(sample.wall_ns), params.clock.has_mono().then_some(sample.mono_ns), ) }; + let arrival = ( + params.clock.has_wall().then_some(receive_wall_ns), + params.clock.has_mono().then_some(received.mono_ns), + ); let absent = (None, None); let (recv, midpoint, send) = match params.stamp_at { StampAt::None => (absent, absent, absent), - StampAt::Receive => (per_clock(received), absent, absent), + StampAt::Receive => (arrival, absent, absent), StampAt::Send => (absent, absent, per_clock(sent)), - StampAt::Both => (per_clock(received), absent, per_clock(sent)), + StampAt::Both => (arrival, absent, per_clock(sent)), StampAt::Midpoint => (absent, per_clock(received.midpoint(sent)), absent), }; TimestampFields { diff --git a/crates/irtt-server/src/runtime.rs b/crates/irtt-server/src/runtime.rs index 4037479..1a71d62 100644 --- a/crates/irtt-server/src/runtime.rs +++ b/crates/irtt-server/src/runtime.rs @@ -229,14 +229,21 @@ impl Server { len, peer, reply_source, - // Observed by the transport and deliberately unused. - // The core samples its own receive instant, and this - // is where a kernel-observed arrival time stops until a - // follow-up change decides how it should enter the core - // — feeding it in here would change protocol timing. - kernel_rx_timestamp: _kernel_rx_timestamp, + // Handed to the core with the datagram it describes, as + // an explicit per-datagram input. The core samples its + // own receive instant regardless and decides whether + // this one is usable; nothing here judges it, and there + // is no "next receive" state between the two. + kernel_rx_timestamp, } = received; - if let Some(reply) = self.core.handle_datagram(peer, &self.recv_buffer[..len])? { + // `reply_source` stays on this side of the boundary: which + // of the host's addresses a request arrived on is transport + // state, not session policy. + if let Some(reply) = self.core.handle_received_datagram( + peer, + &self.recv_buffer[..len], + kernel_rx_timestamp, + )? { if !self.prepare_reply_traffic_class(reply.traffic_class()) { continue; } diff --git a/crates/irtt-server/src/tests/kernel_rx.rs b/crates/irtt-server/src/tests/kernel_rx.rs new file mode 100644 index 0000000..8185201 --- /dev/null +++ b/crates/irtt-server/src/tests/kernel_rx.rs @@ -0,0 +1,668 @@ +//! Consuming a kernel-observed receive timestamp. +//! +//! A Linux listener reports the kernel's arrival time for each datagram, and an +//! echo reply may report it as the wall-clock instant the request was received. +//! These tests pin what that is allowed to change and — mostly — what it is +//! not: only `recv_wall`, only under [`StampAt::Receive`] and [`StampAt::Both`], +//! only on a clock that reports wall time, and never a monotonic value, a +//! midpoint, or any lifecycle, rate or deadline decision. +//! +//! Whether the transport actually captures a timestamp is settled by the socket +//! layer's own tests; nothing here recertifies `SCM_TIMESTAMPNS`. These drive +//! the core with fabricated values, so they run identically on every target. + +use std::{ + net::SocketAddr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields}; + +use super::support::{ + close_request, core_with_sources, echo_at, echo_params, echo_request, + expect_closing_echo_reply, expect_echo_reply, expect_normal_open_reply, manual_core, + open_negotiated, open_request, peer, sample, ManualClock, ScriptedClock, ScriptedTokens, +}; +use crate::{ + clock::ClockSample, + core::{preferred_receive_wall_ns, OutboundDatagram, ServerCore, MAX_KERNEL_RX_LAG}, + ServerConfig, +}; + +const TOKEN_A: u64 = 0x0102_0304_0506_0708; +const MS: i64 = 1_000_000; +const SECOND: i64 = 1_000 * MS; + +/// The lifecycle instant the session-creating open consumes. +fn open_sample() -> ClockSample { + sample(900_000, 9_000) +} + +/// The core's own receive reading for the echo every matrix test sends. +fn received() -> ClockSample { + sample(1_000_000, 10_000) +} + +/// The core's send reading for the same echo. +fn sent() -> ClockSample { + sample(1_200_000, 10_300) +} + +/// A kernel arrival reading 100 µs before [`received`], which is the ordinary +/// shape: earlier than the server's own sample and well inside the bound. +fn kernel_rx() -> SystemTime { + at_epoch_ns(900_000) +} + +/// The [`SystemTime`] `ns` nanoseconds from the Unix epoch, before it when +/// negative. +fn at_epoch_ns(ns: i64) -> SystemTime { + let magnitude = Duration::from_nanos(ns.unsigned_abs()); + if ns.is_negative() { + UNIX_EPOCH - magnitude + } else { + UNIX_EPOCH + magnitude + } +} + +/// Sends one echo carrying `kernel_rx_timestamp` through the runtime's entry +/// point and returns the reply's timestamp fields. +fn echo_timestamps( + stamp_at: StampAt, + clock: Clock, + kernel_rx_timestamp: Option, +) -> TimestampFields { + let mut core = core_with_sources( + ServerConfig::default(), + ScriptedTokens::new([TOKEN_A]), + ScriptedClock::new([open_sample(), received(), sent()]), + ); + let params = echo_params(ReceivedStats::None, stamp_at, clock, /* length */ 0); + let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); + + let packet = core + .handle_received_datagram( + peer(), + &echo_request(token, 0, &negotiated, &[], None), + kernel_rx_timestamp, + ) + .unwrap() + .unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered")); + expect_echo_reply(&packet, &negotiated, None).timestamps +} + +#[test] +fn the_preferred_receive_wall_is_the_kernel_reading_only_when_it_is_plausible() { + let userspace = received(); + let userspace_wall = userspace.wall_ns; + let bound_ns = i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap(); + + // No observation at all: the server's own reading, which is the whole of + // the pre-existing behavior and what every non-Linux target sees. + assert_eq!(preferred_receive_wall_ns(None, userspace), userspace_wall); + + // Earlier, and inside the bound. + assert_eq!( + preferred_receive_wall_ns(Some(kernel_rx()), userspace), + 900_000 + ); + + // Equal is accepted: the two clocks may simply have read the same + // nanosecond. + assert_eq!( + preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall)), userspace), + userspace_wall + ); + + // Later than the sample that observed the same datagram is impossible, so + // it is metadata this server will not measure against. This is the shape a + // backward wall step between the kernel's reading and the core's sample + // produces. + assert_eq!( + preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall + 1)), userspace), + userspace_wall + ); + + // The lag bound is inclusive. + assert_eq!( + preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns)), userspace), + userspace_wall - bound_ns + ); + assert_eq!( + preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns - 1)), userspace), + userspace_wall + ); + + // A pre-epoch reading is representable as a negative value and is judged + // by exactly the same rule, so against a host clock set anywhere near the + // present it simply lags far too much. Nothing here special-cases the sign. + let present = sample(1_700_000_000 * SECOND, 10_000); + assert_eq!( + preferred_receive_wall_ns(Some(UNIX_EPOCH - Duration::from_nanos(1)), present), + present.wall_ns + ); + assert_eq!( + preferred_receive_wall_ns(Some(at_epoch_ns(present.wall_ns - bound_ns)), present), + present.wall_ns - bound_ns, + "and a realistic wall clock accepts a reading at the bound like any other" + ); +} + +#[test] +fn an_unrepresentable_kernel_reading_falls_back_without_panicking() { + // Far outside the wire's signed-nanosecond field in both directions, and + // judged against a userspace sample at the field's own limits, so nothing + // here can be rescued by a lucky comparison. A conversion that saturated + // instead of reporting failure would turn one of these into an ordinary + // instant. + let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1); + + for userspace_wall in [0, i64::MAX, i64::MIN] { + let userspace = sample(userspace_wall, 10_000); + for kernel in [UNIX_EPOCH + beyond, UNIX_EPOCH - beyond] { + assert_eq!( + preferred_receive_wall_ns(Some(kernel), userspace), + userspace_wall, + "an unrepresentable reading is never a measurement endpoint" + ); + } + } +} + +#[test] +fn only_the_receive_wall_field_can_come_from_the_kernel() { + // The whole StampAt x Clock matrix, each asserted as one struct so that a + // field appearing where it should not is as loud as a wrong instant. Every + // expectation below is what the same session emits with no kernel metadata, + // except `recv_wall` under Receive and Both on a wall-reporting clock. + for (stamp_at, clock, expected) in [ + // A: the kernel reading is the reported arrival time. + ( + StampAt::Receive, + Clock::Wall, + TimestampFields { + recv_wall: Some(900_000), + ..TimestampFields::default() + }, + ), + // B: and the monotonic half of the same arrival stays the core's own. + // The two fields deliberately name different physical instants. + ( + StampAt::Receive, + Clock::Both, + TimestampFields { + recv_wall: Some(900_000), + recv_mono: Some(10_000), + ..TimestampFields::default() + }, + ), + // C: nothing about the departure side moves, in either domain. + ( + StampAt::Both, + Clock::Both, + TimestampFields { + recv_wall: Some(900_000), + recv_mono: Some(10_000), + send_wall: Some(1_200_000), + send_mono: Some(10_300), + ..TimestampFields::default() + }, + ), + // D: wall only. The interval between these two now includes the + // kernel-to-userspace part of the server's residency, which is the one + // intended behavior change. + ( + StampAt::Both, + Clock::Wall, + TimestampFields { + recv_wall: Some(900_000), + send_wall: Some(1_200_000), + ..TimestampFields::default() + }, + ), + // E: no wall field exists, so there is nothing to improve. + ( + StampAt::Both, + Clock::Monotonic, + TimestampFields { + recv_mono: Some(10_000), + send_mono: Some(10_300), + ..TimestampFields::default() + }, + ), + ( + StampAt::Receive, + Clock::Monotonic, + TimestampFields { + recv_mono: Some(10_000), + ..TimestampFields::default() + }, + ), + // F: a reply reporting only its departure never learns of an arrival + // observation. + ( + StampAt::Send, + Clock::Wall, + TimestampFields { + send_wall: Some(1_200_000), + ..TimestampFields::default() + }, + ), + ( + StampAt::Send, + Clock::Both, + TimestampFields { + send_wall: Some(1_200_000), + send_mono: Some(10_300), + ..TimestampFields::default() + }, + ), + // G: no timestamp at all. + (StampAt::None, Clock::Both, TimestampFields::default()), + // H and I: a midpoint is the mean of the core's own paired samples and + // stays exactly that. Substituting only the wall arrival endpoint would + // pull `midpoint_wall` to 1_050_000 — half the kernel-to-userspace + // delay early — improving apparent upstream delay by precisely as much + // as it worsened apparent downstream delay, and leaving the two + // midpoint fields describing different midpoints. + ( + StampAt::Midpoint, + Clock::Wall, + TimestampFields { + midpoint_wall: Some(1_100_000), + ..TimestampFields::default() + }, + ), + ( + StampAt::Midpoint, + Clock::Both, + TimestampFields { + midpoint_wall: Some(1_100_000), + midpoint_mono: Some(10_150), + ..TimestampFields::default() + }, + ), + ( + StampAt::Midpoint, + Clock::Monotonic, + TimestampFields { + midpoint_mono: Some(10_150), + ..TimestampFields::default() + }, + ), + ] { + assert_eq!( + echo_timestamps(stamp_at, clock, Some(kernel_rx())), + expected, + "{stamp_at:?} on {clock:?} with a plausible kernel reading" + ); + } +} + +#[test] +fn a_datagram_with_no_kernel_reading_emits_what_it_always_did() { + // The same matrix without metadata, which is every non-Linux target and + // every Linux listener whose kernel offered nothing. `recv_wall` is the + // core's own 1_000_000 throughout, and the two paths are otherwise the same + // implementation. + for (stamp_at, clock, expected) in [ + ( + StampAt::Receive, + Clock::Wall, + TimestampFields { + recv_wall: Some(1_000_000), + ..TimestampFields::default() + }, + ), + ( + StampAt::Both, + Clock::Both, + TimestampFields { + recv_wall: Some(1_000_000), + recv_mono: Some(10_000), + send_wall: Some(1_200_000), + send_mono: Some(10_300), + ..TimestampFields::default() + }, + ), + ( + StampAt::Midpoint, + Clock::Both, + TimestampFields { + midpoint_wall: Some(1_100_000), + midpoint_mono: Some(10_150), + ..TimestampFields::default() + }, + ), + ] { + assert_eq!( + echo_timestamps(stamp_at, clock, None), + expected, + "{stamp_at:?} on {clock:?} without kernel metadata" + ); + } +} + +#[test] +fn an_implausible_kernel_reading_falls_back_to_the_servers_own_sample() { + // Case A of the wall-step analysis: the wall clock stepped *backwards* + // between the kernel's reading and the core's sample, so the kernel value + // is later than a sample that observed the same datagram. Case B: it + // stepped forwards so far that the reading lags beyond the bound. Neither + // is a reason to drop the datagram or to answer differently in any other + // respect. + let userspace_wall = received().wall_ns; + for kernel in [ + at_epoch_ns(userspace_wall + 1), + at_epoch_ns(userspace_wall - i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap() - 1), + ] { + assert_eq!( + echo_timestamps(StampAt::Both, Clock::Both, Some(kernel)), + TimestampFields { + recv_wall: Some(1_000_000), + recv_mono: Some(10_000), + send_wall: Some(1_200_000), + send_mono: Some(10_300), + ..TimestampFields::default() + }, + ); + } +} + +#[test] +fn a_backward_wall_step_after_receive_never_reports_receive_after_send() { + // The kernel's reading and the send sample no longer come from one paired + // source, so the ordering rule has to reach the reported arrival value on + // its own: an accepted kernel reading of 900_000, against a send sample the + // wall clock stepped back to 850_000, must still settle on the send + // reading. + let mut core = core_with_sources( + ServerConfig::default(), + ScriptedTokens::new([TOKEN_A]), + ScriptedClock::new([ + open_sample(), + sample(1_000_000, 10_000), + sample(850_000, 10_300), + ]), + ); + let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); + let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); + + let packet = core + .handle_received_datagram( + peer(), + &echo_request(token, 0, &negotiated, &[], None), + Some(at_epoch_ns(900_000)), + ) + .unwrap() + .expect("a backward wall step must not stop the echo being answered"); + let timestamps = expect_echo_reply(&packet, &negotiated, None).timestamps; + + assert_eq!( + timestamps, + TimestampFields { + recv_wall: Some(850_000), + recv_mono: Some(10_000), + send_wall: Some(850_000), + send_mono: Some(10_300), + ..TimestampFields::default() + }, + "the reported arrival wall value is held back to the send reading" + ); + assert!(timestamps.recv_wall <= timestamps.send_wall); + // The monotonic pair is ordered by its own source and owes nothing to what + // the wall clock did. + assert_eq!(timestamps.recv_mono, Some(10_000)); + assert!(timestamps.recv_mono <= timestamps.send_mono); +} + +#[test] +fn the_public_core_path_emits_exactly_the_userspace_receive_wall() { + // A direct `ServerCore` user has no transport metadata and must see the + // behavior it always did, from the same implementation the runtime path + // uses. + let mut core = core_with_sources( + ServerConfig::default(), + ScriptedTokens::new([TOKEN_A]), + ScriptedClock::new([open_sample(), received(), sent()]), + ); + let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); + let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None); + + let packet = core + .handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None)) + .unwrap() + .expect("the public path must answer an ordinary echo"); + + assert_eq!( + expect_echo_reply(&packet, &negotiated, None).timestamps, + TimestampFields { + recv_wall: Some(1_000_000), + recv_mono: Some(10_000), + send_wall: Some(1_200_000), + send_mono: Some(10_300), + ..TimestampFields::default() + }, + ); +} + +/// One echo's outcome, in the terms a lifecycle test cares about: whether it was +/// answered at all, what it counted, and how much session state the server still +/// holds afterwards. +#[derive(Debug, PartialEq, Eq)] +struct Outcome { + answered: bool, + closing: bool, + recv_count: Option, + recv_window: Option, + session_count: usize, + recv_wall: Option, +} + +fn outcome_of( + core: &ServerCore, + reply: Option, + params: &Params, + closing: bool, +) -> Outcome { + let Some(reply) = reply else { + return Outcome { + answered: false, + closing: false, + recv_count: None, + recv_window: None, + session_count: core.session_count(), + recv_wall: None, + }; + }; + let decoded = if closing { + expect_closing_echo_reply(&reply, params, None) + } else { + expect_echo_reply(&reply, params, None) + }; + Outcome { + answered: true, + closing, + recv_count: decoded.recv_count, + recv_window: decoded.recv_window, + session_count: core.session_count(), + recv_wall: decoded.timestamps.recv_wall, + } +} + +/// Sends one echo as it would arrive at `at_ns`, with the kernel having observed +/// it 1 µs earlier when `with_kernel_rx` is set. +fn echo_at_with_kernel_rx( + core: &mut ServerCore, + clock: &ManualClock, + at_ns: i64, + token: u64, + sequence: u32, + params: &Params, + with_kernel_rx: bool, +) -> Option { + if !with_kernel_rx { + return echo_at(core, clock, at_ns, token, sequence, params, None); + } + clock.set(at_ns); + core.handle_received_datagram( + peer(), + &echo_request(token, sequence, params, &[], None), + Some(at_epoch_ns(at_ns - 1_000)), + ) + .expect("a rejected or rate-limited echo is not an internal error") +} + +#[test] +fn kernel_metadata_changes_no_lifecycle_rate_or_deadline_decision() { + // Two identical runs over one scripted arrival schedule, one with a + // plausible kernel reading on every echo and one with none. The schedule + // crosses a rate-limit burst, a refill, an idle refresh that only a + // rate-limited request can have produced, and the maximum-duration + // boundary — and the two runs must agree on every one of those outcomes. + // + // The session reports its receive wall, so the one permitted difference is + // visible rather than merely assumed absent. + let params = Params { + interval_ns: 100 * MS, + ..echo_params(ReceivedStats::Both, StampAt::Receive, Clock::Wall, 0) + }; + let config = ServerConfig::default() + .with_min_send_interval(Duration::from_millis(100)) + .with_burst_allowance(2) + .with_idle_timeout(Duration::from_secs(2)) + .with_max_test_duration(Duration::from_secs(1)); + + let run = |with_kernel_rx: bool| { + let (mut core, clock, token, negotiated) = manual_core( + config.clone(), + ¶ms, + ScriptedTokens::new([TOKEN_A]), + None, + ); + let mut outcomes = Vec::new(); + let mut send = |core: &mut ServerCore, at_ns: i64, sequence: u32, closing: bool| { + let reply = echo_at_with_kernel_rx( + core, + &clock, + at_ns, + token, + sequence, + &negotiated, + with_kernel_rx, + ); + outcomes.push(outcome_of(core, reply, &negotiated, closing)); + }; + + // A burst of three against an allowance of two: the third is + // rate-limited, answers nothing and advances no statistic, but does + // refresh the idle deadline. + send(&mut core, 10 * MS, 0, false); + send(&mut core, 10 * MS, 1, false); + send(&mut core, 10 * MS, 2, false); + // Past the refill, and out of order, so the window transition is + // exercised too. + send(&mut core, 200 * MS, 4, false); + send(&mut core, 400 * MS, 3, false); + // The idle timeout is 2 s and the last activity was at 400 ms, so this + // request at 2.3 s finds the session alive only because activity — not + // the open — is what the deadline runs from. + send(&mut core, 2_300 * MS, 5, false); + // The maximum-duration deadline is the first served echo plus one + // second plus the two-second grace: 10 ms + 3 s. This crosses it and + // carries the close, releasing the session. + send(&mut core, 3_100 * MS, 6, true); + // And every later request is an unknown token. + send(&mut core, 3_200 * MS, 7, false); + assert_eq!(core.session_count(), 0); + outcomes + }; + + let without = run(false); + let with = run(true); + + assert_eq!( + without.len(), + 8, + "the schedule must actually have run to the end" + ); + for (index, (without, with)) in without.iter().zip(&with).enumerate() { + assert_eq!( + ( + with.answered, + with.closing, + with.recv_count, + with.recv_window, + with.session_count + ), + ( + without.answered, + without.closing, + without.recv_count, + without.recv_window, + without.session_count + ), + "echo {index}: kernel metadata may not change any same-host decision" + ); + } + + // The permitted difference, and proof the runs were not simply identical: + // every answered reply reported the kernel's reading rather than the + // server's own, which is 1 µs later. + let reported: Vec<_> = with + .iter() + .filter_map(|outcome| outcome.recv_wall) + .collect(); + let userspace: Vec<_> = without + .iter() + .filter_map(|outcome| outcome.recv_wall) + .collect(); + assert_eq!(reported.len(), 6, "six of the eight echoes were answered"); + assert_eq!( + reported, + userspace + .iter() + .map(|wall| wall - 1_000) + .collect::>(), + "each answered reply reported the kernel's arrival reading" + ); +} + +#[test] +fn an_open_or_close_ignores_kernel_metadata_entirely() { + // Neither emits a receive timestamp, and neither may take a different + // lifecycle decision for having been observed. The clock script is what + // pins that: an open consumes exactly its own lifecycle sample whatever the + // transport reported. + let clock = ScriptedClock::new([open_sample(), sample(1_000_000, 10_000)]); + let mut core = core_with_sources( + ServerConfig::default(), + ScriptedTokens::new([TOKEN_A]), + clock.clone(), + ); + let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0); + + let packet = core + .handle_received_datagram(peer(), &open_request(¶ms, None), Some(kernel_rx())) + .unwrap() + .expect("an open must be answered"); + let reply = expect_normal_open_reply(&packet, None); + assert_eq!( + clock.remaining(), + 1, + "an open consumes one lifecycle sample" + ); + assert_eq!(core.session_count(), 1); + + let closed: SocketAddr = peer(); + assert!(core + .handle_received_datagram(closed, &close_request(reply.token, None), Some(kernel_rx())) + .unwrap() + .is_none()); + assert_eq!(core.session_count(), 0, "the close released its session"); + assert_eq!( + clock.remaining(), + 0, + "and a close consumes one of its own, whatever the transport observed" + ); +} diff --git a/crates/irtt-server/src/tests/mod.rs b/crates/irtt-server/src/tests/mod.rs index 28067b6..4d002e5 100644 --- a/crates/irtt-server/src/tests/mod.rs +++ b/crates/irtt-server/src/tests/mod.rs @@ -16,6 +16,7 @@ mod close; mod echo; mod fill; mod hmac; +mod kernel_rx; mod lifecycle; mod negotiation; mod no_test;