Merge pull request #102 from Lochnair/fix/server-kernel-rx-wall
fix(server): use kernel receive time for wall timestamps
This commit was merged in pull request #102.
This commit is contained in:
6 files changed
+985
-29
No files matched your search
@@ -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
|
on an explicit listener, or one that describes no representable instant, all
|
||||||
mean the datagram is served with `kernel_rx_timestamp: None`. Only the
|
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.
|
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
|
- **Do not map wall to monotonic.** It is a realtime reading with no monotonic
|
||||||
counterpart, and `ClockSample` is a paired userspace instant. Synthesizing the
|
counterpart, and `ClockSample` is a paired userspace instant. Synthesizing the
|
||||||
missing half would invent precision the kernel never reported.
|
missing half would invent precision the kernel never reported.
|
||||||
- **Do not judge plausibility here.** Conversion is structural only. Whether a
|
- **Do not judge plausibility in the socket layer.** Conversion there is
|
||||||
representable reading is close enough to the server's own sample to measure
|
structural only. Whether a representable reading is close enough to the
|
||||||
against belongs with whoever measures.
|
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
|
- Linux only, and receive only. No macOS, FreeBSD or Windows implementation, and
|
||||||
no transmit timestamping, without a reason and agreement.
|
no transmit timestamping, without a reason and agreement.
|
||||||
- Control-buffer capacity is derived from the cmsg payload types and asserted at
|
- 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.
|
clock source and is stable for its life.
|
||||||
- A reply's receive instant is held back to its send instant where the wall
|
- 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
|
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
|
pre-step wall value across packets would keep reporting a time the host has
|
||||||
corrected away, which is the smoothing the specification forbids, and
|
corrected away, which is the smoothing the specification forbids, and
|
||||||
anchoring the wall clock to the monotonic origin would drift and never pick up
|
anchoring the wall clock to the monotonic origin would drift and never pick up
|
||||||
|
|||||||
@@ -8,9 +8,18 @@
|
|||||||
//!
|
//!
|
||||||
//! This is the clock counterpart of [`TokenSource`](crate::token::TokenSource)
|
//! This is the clock counterpart of [`TokenSource`](crate::token::TokenSource)
|
||||||
//! and nothing more. It is **not** a runtime abstraction, not a transport
|
//! 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
|
//! boundary and not a pluggable product API: it is crate-private and the public
|
||||||
//! constructor still takes only a [`ServerConfig`](crate::ServerConfig), and
|
//! constructor still takes only a [`ServerConfig`](crate::ServerConfig).
|
||||||
//! callers never pass timestamps into the core.
|
//!
|
||||||
|
//! 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::{
|
use std::{
|
||||||
fmt,
|
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<i64> {
|
||||||
|
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.
|
/// A duration as nanoseconds, saturating rather than panicking.
|
||||||
///
|
///
|
||||||
/// This is the crate's one [`Duration`] → wire-nanoseconds conversion, used for
|
/// 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::from_nanos(1_500)), 1_500);
|
||||||
assert_eq!(saturating_ns(Duration::MAX), i64::MAX);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+179
-10
@@ -1,4 +1,8 @@
|
|||||||
use std::{collections::HashMap, net::SocketAddr};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
net::SocketAddr,
|
||||||
|
time::{Duration, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
use irtt_proto::{
|
use irtt_proto::{
|
||||||
decode_request, echo_packet_len, encode_echo_reply, encode_open_reply, verify_packet_hmac,
|
decode_request, echo_packet_len, encode_echo_reply, encode_open_reply, verify_packet_hmac,
|
||||||
@@ -7,7 +11,7 @@ use irtt_proto::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
clock::{saturating_ns, ClockSample, ClockSource, SystemClock},
|
clock::{saturating_ns, wall_ns_of, ClockSample, ClockSource, SystemClock},
|
||||||
config::ServerConfig,
|
config::ServerConfig,
|
||||||
error::ServerError,
|
error::ServerError,
|
||||||
fill::{echo_payload_len, negotiate_server_fill},
|
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
|
/// data or consulting the session table, and no reply behavior may reveal
|
||||||
/// which stage rejected it.
|
/// 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
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`ServerError`] only for an internal failure — the random source
|
/// Returns [`ServerError`] only for an internal failure — the random source
|
||||||
@@ -164,6 +174,49 @@ impl ServerCore {
|
|||||||
&mut self,
|
&mut self,
|
||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
packet: &[u8],
|
packet: &[u8],
|
||||||
|
) -> Result<Option<OutboundDatagram>, 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<SystemTime>,
|
||||||
|
) -> Result<Option<OutboundDatagram>, 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<SystemTime>,
|
||||||
) -> Result<Option<OutboundDatagram>, ServerError> {
|
) -> Result<Option<OutboundDatagram>, ServerError> {
|
||||||
let Ok(request) = decode_request(packet) else {
|
let Ok(request) = decode_request(packet) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -176,7 +229,7 @@ impl ServerCore {
|
|||||||
// protocol state, and it is a strictly better trade than looking a
|
// protocol state, and it is a strictly better trade than looking a
|
||||||
// session up before authentication to discover whether it even wanted
|
// session up before authentication to discover whether it even wanted
|
||||||
// timestamps.
|
// 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
|
// The HMAC flag must agree with this server's configuration in both
|
||||||
// directions of mismatch, and the MAC itself is verified separately.
|
// 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
|
/// Pairs a structurally decoded request with the clock sample an echo
|
||||||
/// needs, leaving every other kind untouched.
|
/// 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>(
|
fn classify<'a>(
|
||||||
&mut self,
|
&mut self,
|
||||||
kind: &DecodedRequestKind<'a>,
|
kind: &DecodedRequestKind<'a>,
|
||||||
datagram_len: usize,
|
datagram_len: usize,
|
||||||
|
kernel_rx_timestamp: Option<SystemTime>,
|
||||||
) -> ClassifiedKind<'a> {
|
) -> ClassifiedKind<'a> {
|
||||||
match *kind {
|
match *kind {
|
||||||
DecodedRequestKind::Open { no_test, params } => {
|
DecodedRequestKind::Open { no_test, params } => {
|
||||||
@@ -262,12 +322,16 @@ impl ServerCore {
|
|||||||
// read no further than its length.
|
// read no further than its length.
|
||||||
DecodedRequestKind::Echo {
|
DecodedRequestKind::Echo {
|
||||||
token, sequence, ..
|
token, sequence, ..
|
||||||
} => ClassifiedKind::Echo(EchoRequest {
|
} => {
|
||||||
|
let received_at = self.clock.sample();
|
||||||
|
ClassifiedKind::Echo(EchoRequest {
|
||||||
token,
|
token,
|
||||||
sequence,
|
sequence,
|
||||||
datagram_len,
|
datagram_len,
|
||||||
received_at: self.clock.sample(),
|
received_at,
|
||||||
}),
|
receive_wall_ns: preferred_receive_wall_ns(kernel_rx_timestamp, received_at),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +441,12 @@ impl ServerCore {
|
|||||||
sequence: request.sequence,
|
sequence: request.sequence,
|
||||||
recv_count: params.received_stats.has_count().then_some(next.count),
|
recv_count: params.received_stats.has_count().then_some(next.count),
|
||||||
recv_window: params.received_stats.has_window().then_some(next.window),
|
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
|
// The session's negotiated fill, generated fresh for this reply and
|
||||||
// sized from the layout rather than from `params.length`. A `none`
|
// sized from the layout rather than from `params.length`. A `none`
|
||||||
// session contributes no bytes and the encoder's zero-fill stands,
|
// 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
|
/// fields is carried forward. `datagram_len` is the length the datagram
|
||||||
/// actually arrived with, which the configured maximum judges; it is not
|
/// actually arrived with, which the configured maximum judges; it is not
|
||||||
/// required to match the negotiated length, and it never sizes the reply.
|
/// 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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct EchoRequest {
|
struct EchoRequest {
|
||||||
token: u64,
|
token: u64,
|
||||||
sequence: u32,
|
sequence: u32,
|
||||||
datagram_len: usize,
|
datagram_len: usize,
|
||||||
received_at: ClockSample,
|
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<SystemTime>,
|
||||||
|
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.
|
/// 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
|
/// receive instant must not be later than its send instant and the wall clock
|
||||||
/// can be stepped backwards between the two readings. See
|
/// can be stepped backwards between the two readings. See
|
||||||
/// [`ClockSample::not_after`]; the correction is confined to this one reply.
|
/// [`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);
|
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| {
|
let per_clock = |sample: ClockSample| {
|
||||||
(
|
(
|
||||||
params.clock.has_wall().then_some(sample.wall_ns),
|
params.clock.has_wall().then_some(sample.wall_ns),
|
||||||
params.clock.has_mono().then_some(sample.mono_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 absent = (None, None);
|
||||||
let (recv, midpoint, send) = match params.stamp_at {
|
let (recv, midpoint, send) = match params.stamp_at {
|
||||||
StampAt::None => (absent, absent, absent),
|
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::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),
|
StampAt::Midpoint => (absent, per_clock(received.midpoint(sent)), absent),
|
||||||
};
|
};
|
||||||
TimestampFields {
|
TimestampFields {
|
||||||
|
|||||||
@@ -229,14 +229,21 @@ impl Server {
|
|||||||
len,
|
len,
|
||||||
peer,
|
peer,
|
||||||
reply_source,
|
reply_source,
|
||||||
// Observed by the transport and deliberately unused.
|
// Handed to the core with the datagram it describes, as
|
||||||
// The core samples its own receive instant, and this
|
// an explicit per-datagram input. The core samples its
|
||||||
// is where a kernel-observed arrival time stops until a
|
// own receive instant regardless and decides whether
|
||||||
// follow-up change decides how it should enter the core
|
// this one is usable; nothing here judges it, and there
|
||||||
// — feeding it in here would change protocol timing.
|
// is no "next receive" state between the two.
|
||||||
kernel_rx_timestamp: _kernel_rx_timestamp,
|
kernel_rx_timestamp,
|
||||||
} = received;
|
} = 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()) {
|
if !self.prepare_reply_traffic_class(reply.traffic_class()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SystemTime>,
|
||||||
|
) -> 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<u32>,
|
||||||
|
recv_window: Option<u64>,
|
||||||
|
session_count: usize,
|
||||||
|
recv_wall: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outcome_of(
|
||||||
|
core: &ServerCore,
|
||||||
|
reply: Option<OutboundDatagram>,
|
||||||
|
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<OutboundDatagram> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ mod close;
|
|||||||
mod echo;
|
mod echo;
|
||||||
mod fill;
|
mod fill;
|
||||||
mod hmac;
|
mod hmac;
|
||||||
|
mod kernel_rx;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod negotiation;
|
mod negotiation;
|
||||||
mod no_test;
|
mod no_test;
|
||||||
|
|||||||
Reference in new issue
Block a user