fix(server): use kernel receive time for wall timestamps #102

Merged
Lochnair merged 1 commits from fix/server-kernel-rx-wall into main 2026-08-15 07:10:52 +00:00
Lochnair commented 2026-08-15 06:47:27 +00:00 (Migrated from github.com)

Summary

  • Linux server replies now prefer the captured SCM_TIMESTAMPNS receive wall
    time for the Receive/Both wall timestamp field when it is plausible for the
    datagram it describes;
  • the userspace receive wall remains the fallback, and is what every other case
    emits;
  • monotonic receive timing remains userspace and continues to own lifecycle and
    same-host duration semantics;
  • midpoint deliberately remains based on the paired userspace samples.

Core boundary

ServerCore::handle_datagram(peer, packet) is unchanged — same signature, same
behavior, no transport metadata. It samples every instant it reports, exactly as
before.

The Tokio runtime uses a crate-private handle_received_datagram(peer, packet, kernel_rx_timestamp), which passes the observation explicitly with the datagram
it describes. Both entry points run one private implementation, so there is no
duplicated request-processing logic and no second public API.

No mutable "next receive timestamp" side channel, no pending-metadata field, no
thread-local or global state, and no ClockSource wrapper was introduced.
ReplySource stays transport-side and never enters the core.

ClockSample is unchanged: still one paired userspace wall+monotonic instant.
The preferred receive wall value is carried beside it on the classified echo
(EchoRequest::receive_wall_ns), never inside it.

Measurement semantics

  • Upstream OWD: improved server receive endpoint on Linux — the reported
    arrival becomes server_kernel_receive_wall instead of
    server_userspace_receive_wall, removing socket-queue and Tokio wakeup latency
    from the apparent upstream path. It does not remove client send syscall or qdisc
    latency, network-stack TX delay, or clock synchronization error.
  • Monotonic server processing: unchanged (userspace_send_mono - userspace_receive_mono).
  • Lifecycle, rate and deadlines: unchanged — always received_at.mono_ns.
  • Midpoint: unchanged — the per-domain mean of the paired userspace receive
    and send samples.
  • Downstream OWD server endpoint: unchanged userspace send wall.
  • Clock::Wall + StampAt::Both: the wall processing interval now includes
    the kernel-to-userspace receive delay when the kernel reading is selected, so a
    client may see a larger server_processing and a correspondingly smaller
    adjusted RTT. This is intended, and arguably the more complete server residency
    interval. Clock::Both still yields the unchanged monotonic interval; no attempt
    is made to force the two to agree.

Under Clock::Both with StampAt::Both, recv_wall and recv_mono may name
different physical instants. That is a deliberate wire policy — each field is
honest about its own domain and nothing computes across the two — and it is
documented as such rather than modelled as one sample.

Validation

A kernel reading is used only when it:

  • converts into the wire's signed-nanosecond vocabulary (checked, not saturating,
    so a clamped value cannot masquerade as a real instant);
  • is not later than the core's own paired receive wall sample;
  • lags that sample by at most MAX_KERNEL_RX_LAG (1 s, inclusive) — a sanity
    guard against realtime clock discontinuities, not an expected wakeup latency.

Anything else falls back to the userspace receive wall. A failed check never
rejects the datagram, never emits an error reply, and never moves session state.

The reported arrival wall value is held back to the send reading in its own
right, so recv_wall <= send_wall still holds when the wall clock steps backwards
after receive — the kernel reading and the send sample are no longer from one
paired source, so ClockSample::not_after alone is not sufficient.

Compatibility

  • non-Linux: kernel_rx_timestamp is always None, so behavior is exactly as
    before;
  • Linux without a usable kernel timestamp: exactly as before;
  • public ServerCore callers: exactly as before;
  • no public API break, no protocol negotiation change, no wire layout change.

Scope

Explicitly not in this PR:

  • kernel monotonic estimation or any reconstructed ClockSample;
  • server TX timestamping;
  • client TX timestamping (SO_TIMESTAMPING, MSG_ERRQUEUE, send correlation);
  • midpoint kernel substitution;
  • protocol negotiation changes;
  • any irtt-client, irtt-stats or irtt-cli production change;
  • any Cargo.toml/Cargo.lock change.

No socket capture work was revisited: #101's transport tests already prove the
timestamp is captured, and nothing here recertifies SCM_TIMESTAMPNS.

Tests

New deterministic core tests in crates/irtt-server/src/tests/kernel_rx.rs, all
with fabricated values and no sleeps or tolerances:

  • the acceptance rule: no observation, plausible earlier reading, equal to the
    userspace sample, later than it, lag exactly at the bound, lag past the bound,
    and a pre-epoch reading judged by the same rule against a realistic host clock;
  • unrepresentable readings in both directions, judged against userspace samples at
    0, i64::MAX and i64::MIN — fallback, no panic;
  • the full StampAt x Clock matrix with a plausible reading, asserted as whole
    TimestampFields structs so a field appearing where it should not is as loud as
    a wrong instant: Receive+Wall, Receive+Both, Both+Both, Both+Wall,
    Both+Monotonic, Receive+Monotonic, Send+Wall, Send+Both, None,
    and all three Midpoint clocks;
  • the same matrix without metadata, proving the pre-existing emission;
  • an implausible reading (both step directions) falling back while the reply is
    otherwise unchanged;
  • a backward wall step after receive: accepted kernel reading 900_000, send
    sample stepped back to 850_000, asserting recv_wall <= send_wall and that the
    monotonic pair is untouched;
  • the public handle_datagram path emitting exactly the userspace receive wall;
  • a lifecycle non-regression running one arrival schedule twice — with and
    without a kernel reading on every echo — across a rate-limit burst, a refill, an
    out-of-order window transition, an idle refresh only a rate-limited request can
    have produced, and the maximum-duration close, asserting identical answered/
    closing/count/window/session-count outcomes and that the only difference is the
    reported recv_wall;
  • open and close ignoring the observation, pinned by the clock script.

Also added: a wall_ns_of conversion test in clock.rs covering epoch, positive,
pre-epoch and out-of-field values.

Verification

All run and passing:

  • cargo test -p irtt-server --all-features --locked (148 core tests)
  • cargo test -p irtt-client --all-features --locked
  • cargo fmt --all -- --check
  • cargo check --workspace --all-targets --all-features --locked
  • cargo test --workspace --all-targets --all-features --locked
  • cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked
  • cargo check -p irtt-cli --no-default-features --features {server,client,tui} --all-targets --locked
  • git diff --check
  • cross-target cargo check -p irtt-server --all-features --all-targets for
    x86_64-unknown-linux-gnu, aarch64-unknown-linux-musl, x86_64-unknown-freebsd
    and x86_64-pc-windows-gnu

Documentation

crates/irtt-server/AGENTS.md replaces the "Nothing consumes it yet" rule with
the two-entry-point boundary, the acceptance policy, what the observation may and
may not change, and the intended Clock::Wall processing-interval consequence.
clock.rs's module doc no longer claims callers never pass timestamps into the
core, and states that ClockSample remains a paired userspace instant.

## Summary - Linux server replies now prefer the captured `SCM_TIMESTAMPNS` receive wall time for the `Receive`/`Both` wall timestamp field when it is plausible for the datagram it describes; - the userspace receive wall remains the fallback, and is what every other case emits; - monotonic receive timing remains userspace and continues to own lifecycle and same-host duration semantics; - midpoint deliberately remains based on the paired userspace samples. ## Core boundary `ServerCore::handle_datagram(peer, packet)` is unchanged — same signature, same behavior, no transport metadata. It samples every instant it reports, exactly as before. The Tokio runtime uses a crate-private `handle_received_datagram(peer, packet, kernel_rx_timestamp)`, which passes the observation explicitly with the datagram it describes. Both entry points run one private implementation, so there is no duplicated request-processing logic and no second public API. No mutable "next receive timestamp" side channel, no pending-metadata field, no thread-local or global state, and no `ClockSource` wrapper was introduced. `ReplySource` stays transport-side and never enters the core. `ClockSample` is unchanged: still one paired userspace wall+monotonic instant. The preferred receive wall value is carried beside it on the classified echo (`EchoRequest::receive_wall_ns`), never inside it. ## Measurement semantics - **Upstream OWD**: improved server receive endpoint on Linux — the reported arrival becomes `server_kernel_receive_wall` instead of `server_userspace_receive_wall`, removing socket-queue and Tokio wakeup latency from the apparent upstream path. It does not remove client send syscall or qdisc latency, network-stack TX delay, or clock synchronization error. - **Monotonic server processing**: unchanged (`userspace_send_mono - userspace_receive_mono`). - **Lifecycle, rate and deadlines**: unchanged — always `received_at.mono_ns`. - **Midpoint**: unchanged — the per-domain mean of the paired userspace receive and send samples. - **Downstream OWD server endpoint**: unchanged userspace send wall. - **`Clock::Wall` + `StampAt::Both`**: the wall processing interval now includes the kernel-to-userspace receive delay when the kernel reading is selected, so a client may see a larger `server_processing` and a correspondingly smaller adjusted RTT. This is intended, and arguably the more complete server residency interval. `Clock::Both` still yields the unchanged monotonic interval; no attempt is made to force the two to agree. Under `Clock::Both` with `StampAt::Both`, `recv_wall` and `recv_mono` may name different physical instants. That is a deliberate wire policy — each field is honest about its own domain and nothing computes across the two — and it is documented as such rather than modelled as one sample. ## Validation A kernel reading is used only when it: - converts into the wire's signed-nanosecond vocabulary (checked, not saturating, so a clamped value cannot masquerade as a real instant); - is not later than the core's own paired receive wall sample; - lags that sample by at most `MAX_KERNEL_RX_LAG` (1 s, inclusive) — a sanity guard against realtime clock discontinuities, not an expected wakeup latency. Anything else falls back to the userspace receive wall. A failed check never rejects the datagram, never emits an error reply, and never moves session state. The reported arrival wall value is held back to the send reading in its own right, so `recv_wall <= send_wall` still holds when the wall clock steps backwards after receive — the kernel reading and the send sample are no longer from one paired source, so `ClockSample::not_after` alone is not sufficient. ## Compatibility - non-Linux: `kernel_rx_timestamp` is always `None`, so behavior is exactly as before; - Linux without a usable kernel timestamp: exactly as before; - public `ServerCore` callers: exactly as before; - no public API break, no protocol negotiation change, no wire layout change. ## Scope Explicitly not in this PR: - kernel monotonic estimation or any reconstructed `ClockSample`; - server TX timestamping; - client TX timestamping (`SO_TIMESTAMPING`, `MSG_ERRQUEUE`, send correlation); - midpoint kernel substitution; - protocol negotiation changes; - any `irtt-client`, `irtt-stats` or `irtt-cli` production change; - any `Cargo.toml`/`Cargo.lock` change. No socket capture work was revisited: #101's transport tests already prove the timestamp is captured, and nothing here recertifies `SCM_TIMESTAMPNS`. ## Tests New deterministic core tests in `crates/irtt-server/src/tests/kernel_rx.rs`, all with fabricated values and no sleeps or tolerances: - the acceptance rule: no observation, plausible earlier reading, equal to the userspace sample, later than it, lag exactly at the bound, lag past the bound, and a pre-epoch reading judged by the same rule against a realistic host clock; - unrepresentable readings in both directions, judged against userspace samples at `0`, `i64::MAX` and `i64::MIN` — fallback, no panic; - the full `StampAt` x `Clock` matrix with a plausible reading, asserted as whole `TimestampFields` structs so a field appearing where it should not is as loud as a wrong instant: `Receive`+`Wall`, `Receive`+`Both`, `Both`+`Both`, `Both`+`Wall`, `Both`+`Monotonic`, `Receive`+`Monotonic`, `Send`+`Wall`, `Send`+`Both`, `None`, and all three `Midpoint` clocks; - the same matrix without metadata, proving the pre-existing emission; - an implausible reading (both step directions) falling back while the reply is otherwise unchanged; - a backward wall step after receive: accepted kernel reading 900_000, send sample stepped back to 850_000, asserting `recv_wall <= send_wall` and that the monotonic pair is untouched; - the public `handle_datagram` path emitting exactly the userspace receive wall; - a lifecycle non-regression running one arrival schedule twice — with and without a kernel reading on every echo — across a rate-limit burst, a refill, an out-of-order window transition, an idle refresh only a rate-limited request can have produced, and the maximum-duration close, asserting identical answered/ closing/count/window/session-count outcomes and that the only difference is the reported `recv_wall`; - open and close ignoring the observation, pinned by the clock script. Also added: a `wall_ns_of` conversion test in `clock.rs` covering epoch, positive, pre-epoch and out-of-field values. ## Verification All run and passing: - `cargo test -p irtt-server --all-features --locked` (148 core tests) - `cargo test -p irtt-client --all-features --locked` - `cargo fmt --all -- --check` - `cargo check --workspace --all-targets --all-features --locked` - `cargo test --workspace --all-targets --all-features --locked` - `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` - `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked` - `cargo check -p irtt-cli --no-default-features --features {server,client,tui} --all-targets --locked` - `git diff --check` - cross-target `cargo check -p irtt-server --all-features --all-targets` for `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-musl`, `x86_64-unknown-freebsd` and `x86_64-pc-windows-gnu` ## Documentation `crates/irtt-server/AGENTS.md` replaces the "Nothing consumes it yet" rule with the two-entry-point boundary, the acceptance policy, what the observation may and may not change, and the intended `Clock::Wall` processing-interval consequence. `clock.rs`'s module doc no longer claims callers never pass timestamps into the core, and states that `ClockSample` remains a paired userspace instant.
Sign in to join this conversation.