From 19782dd3fdb9ec0d5f7daf77725631ffe9878dfe Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 18 Sep 2026 00:10:49 +0700 Subject: [PATCH 1/4] docs(irtt-stats): add runnable doctests for public API Add executable doctests that document the consumer-facing contract of irtt-stats: finite vs continuous retention, the exact-median guarantee, the retained-memory planning estimate, and count-/time-based rolling snapshots. Migrate the send-call exact-median test into the StatsConfig::finite() doc, which fully subsumes it. --- crates/irtt-stats/src/lib.rs | 246 +++++++++++++++++++++++++++++++ crates/irtt-stats/tests/stats.rs | 20 --- 2 files changed, 246 insertions(+), 20 deletions(-) diff --git a/crates/irtt-stats/src/lib.rs b/crates/irtt-stats/src/lib.rs index ee7e373..0d604b0 100644 --- a/crates/irtt-stats/src/lib.rs +++ b/crates/irtt-stats/src/lib.rs @@ -66,6 +66,44 @@ impl StatsConfig { /// them reports a median in the cumulative snapshot once it has samples, /// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent /// replies can still complete IPDV pairs. + /// + /// # Example + /// + /// ``` + /// use irtt_stats::{StatsCollector, StatsConfig}; + /// + /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + /// # use std::time::{Duration, Instant, SystemTime}; + /// # use irtt_client::{ClientEvent, ClientTimestamp}; + /// # + /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent { + /// # let sent_at = ClientTimestamp { + /// # mono: Instant::now() + Duration::from_millis(at_ms), + /// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms), + /// # }; + /// # ClientEvent::EchoSent { + /// # seq, + /// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112), + /// # scheduled_at: sent_at.mono, + /// # sent_at, + /// # bytes: 64, + /// # send_call: Duration::from_micros(send_call_us), + /// # timer_error: Duration::from_micros(2), + /// # } + /// # } + /// # + /// let mut collector = StatsCollector::new(StatsConfig::finite()); + /// for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() { + /// collector.process(&sent(seq as u32, seq as u64 * 10, send_call_us)); + /// } + /// + /// let snapshot = collector.snapshot(); + /// // Finite mode retains exact samples, so the send-call metric reports an + /// // exact median once it has samples: the middle of the sorted + /// // 10, 20, 30 µs values, not their mean. + /// assert_eq!(snapshot.send_call.count, 3); + /// assert_eq!(snapshot.send_call.median_ns, Some(20_000.0)); + /// ``` pub fn finite() -> Self { Self { samples: SampleMode::Exact, @@ -80,6 +118,44 @@ impl StatsConfig { /// Continuous mode uses running statistics, retains no exact samples so no /// timing metric reports a median, and bounds adjacent-sequence IPDV /// tracking for long-running sessions. + /// + /// # Example + /// + /// ``` + /// use irtt_stats::{StatsCollector, StatsConfig}; + /// + /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + /// # use std::time::{Duration, Instant, SystemTime}; + /// # use irtt_client::{ClientEvent, ClientTimestamp}; + /// # + /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent { + /// # let sent_at = ClientTimestamp { + /// # mono: Instant::now() + Duration::from_millis(at_ms), + /// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms), + /// # }; + /// # ClientEvent::EchoSent { + /// # seq, + /// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112), + /// # scheduled_at: sent_at.mono, + /// # sent_at, + /// # bytes: 64, + /// # send_call: Duration::from_micros(send_call_us), + /// # timer_error: Duration::from_micros(2), + /// # } + /// # } + /// # + /// let mut collector = StatsCollector::new(StatsConfig::continuous()); + /// for seq in 0..3 { + /// collector.process(&sent(seq, u64::from(seq) * 10, 10)); + /// } + /// + /// let snapshot = collector.snapshot(); + /// // Continuous mode still tracks running statistics — count, mean, min, + /// // and max — but retains no exact samples, so it reports no median. + /// assert_eq!(snapshot.send_call.count, 3); + /// assert!(snapshot.send_call.mean_ns > 0.0); + /// assert_eq!(snapshot.send_call.median_ns, None); + /// ``` pub fn continuous() -> Self { Self { samples: SampleMode::RunningOnly, @@ -135,6 +211,34 @@ impl StatsConfig { /// The result is deterministic and computed with saturating arithmetic, so /// an enormous `probe_count` saturates at [`u64::MAX`] rather than /// wrapping or panicking. + /// + /// # Example + /// + /// ``` + /// use irtt_stats::StatsConfig; + /// + /// // Finite retention grows with the probe count... + /// let finite = StatsConfig::finite(); + /// assert!( + /// finite.estimated_retained_bytes(1_000_000) + /// > finite.estimated_retained_bytes(1_000) + /// ); + /// + /// // ...while continuous retention is bounded, so a far longer run is no + /// // more expensive. + /// let continuous = StatsConfig::continuous(); + /// assert_eq!( + /// continuous.estimated_retained_bytes(1_000_000), + /// continuous.estimated_retained_bytes(10_000_000), + /// ); + /// assert!( + /// continuous.estimated_retained_bytes(1_000_000) + /// < finite.estimated_retained_bytes(1_000_000) + /// ); + /// + /// // Enormous probe counts saturate rather than wrapping or panicking. + /// assert_eq!(finite.estimated_retained_bytes(u64::MAX), u64::MAX); + /// ``` pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 { retention::estimated_retained_bytes(self, probe_count) } @@ -192,6 +296,60 @@ pub enum SampleMode { /// /// A collector maintains cumulative statistics and, when configured, rolling /// windows. Rolling snapshots are recomputed from retained normalized events. +/// +/// # Example +/// +/// Feed a short stream of `irtt-client` events to a finite-mode collector and +/// read back a cumulative snapshot: +/// +/// ``` +/// use irtt_stats::{StatsCollector, StatsConfig}; +/// +/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +/// # use std::time::{Duration, Instant, SystemTime}; +/// # use irtt_client::{ClientEvent, ClientTimestamp, RttSample, SignedDuration}; +/// # +/// # /// A synthetic reply; a real session delivers these as `ClientEvent`s. +/// # fn reply(seq: u32, rtt_ms: u64) -> ClientEvent { +/// # let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112); +/// # let sent_at = ClientTimestamp { +/// # mono: Instant::now() + Duration::from_millis(u64::from(seq) * 10), +/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(u64::from(seq) * 10), +/// # }; +/// # let raw = Duration::from_millis(rtt_ms); +/// # ClientEvent::EchoReply { +/// # seq, +/// # remote, +/// # sent_at, +/// # received_at: ClientTimestamp { +/// # mono: sent_at.mono + raw, +/// # wall: sent_at.wall + raw, +/// # }, +/// # rtt: RttSample { +/// # raw, +/// # adjusted: None, +/// # effective: SignedDuration::from_duration(raw), +/// # }, +/// # server_timing: None, +/// # one_way: None, +/// # received_stats: None, +/// # bytes: 64, +/// # packet_meta: irtt_client::PacketMeta::default(), +/// # } +/// # } +/// # +/// let mut collector = StatsCollector::new(StatsConfig::finite()); +/// for (seq, rtt_ms) in [10_u64, 30, 20].into_iter().enumerate() { +/// collector.process(&reply(seq as u32, rtt_ms)); +/// } +/// +/// let snapshot = collector.snapshot(); +/// assert_eq!(snapshot.events.echo_replies, 3); +/// assert_eq!(snapshot.rtt.primary.count, 3); +/// // Finite mode retains exact samples, so the round-trip metric reports an +/// // exact median: the middle of the sorted 10, 20, 30 ms samples. +/// assert_eq!(snapshot.rtt.primary.median_ns, Some(20_000_000.0)); +/// ``` pub struct StatsCollector { cumulative: CoreStats, rolling: RollingEvents, @@ -236,6 +394,53 @@ impl StatsCollector { /// running statistics only, whatever [`StatsConfig::samples`] is set to, /// so its timing metrics report no median. A rolling window is a bounded /// recent view rather than the run's retained history. + /// + /// # Example + /// + /// ``` + /// use irtt_stats::{StatsCollector, StatsConfig}; + /// + /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + /// # use std::time::{Duration, Instant, SystemTime}; + /// # use irtt_client::{ClientEvent, ClientTimestamp}; + /// # + /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent { + /// # let sent_at = ClientTimestamp { + /// # mono: Instant::now() + Duration::from_millis(at_ms), + /// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms), + /// # }; + /// # ClientEvent::EchoSent { + /// # seq, + /// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112), + /// # scheduled_at: sent_at.mono, + /// # sent_at, + /// # bytes: 64, + /// # send_call: Duration::from_micros(send_call_us), + /// # timer_error: Duration::from_micros(2), + /// # } + /// # } + /// # + /// let mut collector = StatsCollector::new(StatsConfig { + /// rolling_count: Some(2), + /// ..StatsConfig::finite() + /// }); + /// collector.process(&sent(0, 0, 10)); + /// collector.process(&sent(1, 10, 10)); + /// collector.process(&sent(2, 20, 10)); + /// + /// // The window keeps only the last two normalized events... + /// let rolling = collector.rolling_count().unwrap(); + /// assert_eq!(rolling.packets.packets_sent, 2); + /// // ...and is recomputed with running statistics, so it reports no median + /// // even though the collector retains exact samples for its cumulative + /// // snapshot. + /// assert_eq!(rolling.send_call.median_ns, None); + /// + /// // The cumulative snapshot still sees every event. + /// let cumulative = collector.snapshot(); + /// assert_eq!(cumulative.packets.packets_sent, 3); + /// assert_eq!(cumulative.send_call.median_ns, Some(10_000.0)); + /// ``` pub fn rolling_count(&self) -> Option { self.rolling.count_snapshot() } @@ -244,6 +449,47 @@ impl StatsCollector { /// /// Like [`StatsCollector::rolling_count`], this is recomputed with running /// statistics only and reports no medians. + /// + /// # Example + /// + /// ``` + /// use std::time::Duration; + /// + /// use irtt_stats::{StatsCollector, StatsConfig}; + /// + /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + /// # use std::time::Instant; + /// # use irtt_client::{ClientEvent, ClientTimestamp}; + /// # + /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent { + /// # let sent_at = ClientTimestamp { + /// # mono: Instant::now() + Duration::from_millis(at_ms), + /// # wall: std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms), + /// # }; + /// # ClientEvent::EchoSent { + /// # seq, + /// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112), + /// # scheduled_at: sent_at.mono, + /// # sent_at, + /// # bytes: 64, + /// # send_call: Duration::from_micros(send_call_us), + /// # timer_error: Duration::from_micros(2), + /// # } + /// # } + /// # + /// let mut collector = StatsCollector::new(StatsConfig { + /// rolling_time: Some(Duration::from_millis(15)), + /// ..StatsConfig::finite() + /// }); + /// collector.process(&sent(0, 0, 10)); + /// collector.process(&sent(1, 10, 10)); + /// collector.process(&sent(2, 30, 10)); + /// + /// // A 15 ms window anchored at the latest event (30 ms) retains only the + /// // events from the last 15 ms — here just the final send. + /// let rolling = collector.rolling_time().unwrap(); + /// assert_eq!(rolling.packets.packets_sent, 1); + /// ``` pub fn rolling_time(&self) -> Option { self.rolling.time_snapshot() } diff --git a/crates/irtt-stats/tests/stats.rs b/crates/irtt-stats/tests/stats.rs index 616c51b..01528c0 100644 --- a/crates/irtt-stats/tests/stats.rs +++ b/crates/irtt-stats/tests/stats.rs @@ -13,26 +13,6 @@ use irtt_stats::{ EventStatsUpdate, IpdvPairUpdate, LateReplyMode, SampleMode, StatsCollector, StatsConfig, }; -#[test] -fn exact_mode_reports_a_send_call_median() { - let mut collector = StatsCollector::new(StatsConfig::finite()); - for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() { - collector.process(&sent_with_timings( - seq as u32, - ts(seq as u64 * 10), - send_call_us, - 2, - )); - } - - let snapshot = collector.snapshot(); - assert_eq!(snapshot.send_call.count, 3); - assert_eq!(snapshot.send_call.min_ns, Some(10_000)); - assert_eq!(snapshot.send_call.max_ns, Some(30_000)); - // Sorted 10, 20, 30 us: the median is the middle sample, not the mean. - assert_eq!(snapshot.send_call.median_ns, Some(20_000.0)); -} - #[test] fn exact_mode_reports_a_timer_error_median() { let mut collector = StatsCollector::new(StatsConfig::finite()); From 328ff1790189b5eea6139e796cf01eba72d558f5 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 18 Sep 2026 09:13:41 +0700 Subject: [PATCH 2/4] docs(irtt-proto): add runnable doctests for public API Add executable doctests documenting the consumer-facing contract of irtt-proto: the directional request encode/decode/verify split, Params construction and round-trip, omitted-vs-explicit-zero presence, open and echo reply round trips, packet length calculation, and HMAC verification. No conventional tests are migrated: every existing irtt-proto test exercises wire vectors, flag matrices, malformed input, layout rules, HMAC error behavior, or encode/decode round trips, which the crate's AGENTS.md treats as durable compatibility coverage rather than documentation cleanup targets. --- crates/irtt-proto/src/echo.rs | 44 ++++++++++++++++++++++++++++++++ crates/irtt-proto/src/hmac.rs | 17 ++++++++++++ crates/irtt-proto/src/layout.rs | 14 ++++++++++ crates/irtt-proto/src/lib.rs | 33 ++++++++++++++++++++++++ crates/irtt-proto/src/open.rs | 40 +++++++++++++++++++++++++++++ crates/irtt-proto/src/params.rs | 37 +++++++++++++++++++++++++++ crates/irtt-proto/src/request.rs | 35 +++++++++++++++++++++++++ crates/irtt-proto/src/varint.rs | 16 ++++++++++++ 8 files changed, 236 insertions(+) diff --git a/crates/irtt-proto/src/echo.rs b/crates/irtt-proto/src/echo.rs index a17731d..f65ad3e 100644 --- a/crates/irtt-proto/src/echo.rs +++ b/crates/irtt-proto/src/echo.rs @@ -60,6 +60,50 @@ pub struct TimestampFields { pub send_mono: Option, } +/// Encodes an ECHO reply. +/// +/// `params` supplies the negotiated layout and length, exactly as on the +/// request side, and every optional field the layout requires must be present +/// in `reply`: a `Some` where the layout has no field, or a `None` where it has +/// one, is an error. `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` +/// and signs the finished datagram. +/// +/// # Errors +/// +/// Returns [`ProtoError::MissingFlag`] or [`ProtoError::UnexpectedFlag`] when +/// the reply does not carry exactly the packet-type flags this encoder +/// requires, [`ProtoError::MissingField`] or [`ProtoError::UnexpectedField`] +/// when `reply`'s optional fields do not match the negotiated layout, and +/// [`ProtoError::PayloadTooLarge`] when the payload does not fit the negotiated +/// length. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{ +/// decode_echo_reply, encode_echo_reply, EchoReply, Params, ReceivedStats, +/// TimestampFields, FLAG_REPLY, +/// }; +/// +/// // `received_stats` negotiates a receive counter; four payload bytes. +/// let params = Params { +/// length: 24, +/// received_stats: ReceivedStats::Count, +/// ..Params::default() +/// }; +/// let reply = EchoReply { +/// flags: FLAG_REPLY, +/// token: 0x1234_5678_9abc_def0, +/// sequence: 7, +/// recv_count: Some(3), +/// recv_window: None, +/// timestamps: TimestampFields::default(), +/// payload: vec![1, 2, 3, 4], +/// }; +/// +/// let packet = encode_echo_reply(&reply, ¶ms, None).unwrap(); +/// assert_eq!(decode_echo_reply(&packet, ¶ms, None).unwrap(), reply); +/// ``` pub fn encode_echo_reply( reply: &EchoReply, params: &Params, diff --git a/crates/irtt-proto/src/hmac.rs b/crates/irtt-proto/src/hmac.rs index ca82fb8..7baae5f 100644 --- a/crates/irtt-proto/src/hmac.rs +++ b/crates/irtt-proto/src/hmac.rs @@ -68,6 +68,23 @@ pub fn verify_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<()> /// [`ProtoError::InvalidHmacOffset`] when the field is truncated, and /// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never /// modified. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{encode_request, verify_packet_hmac, Params, RequestToEncode}; +/// +/// // Synthetic example key — use a real shared secret in production. +/// let key = b"example key"; +/// let params = Params::with_protocol_defaults(); +/// let packet = encode_request( +/// RequestToEncode::Open { params: ¶ms, no_test: false }, +/// Some(key), +/// ).unwrap(); +/// +/// verify_packet_hmac(key, &packet).unwrap(); +/// assert!(verify_packet_hmac(b"wrong key", &packet).is_err()); +/// ``` pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> { let envelope = envelope::decode_structural(packet)?; if !envelope.hmac_present { diff --git a/crates/irtt-proto/src/layout.rs b/crates/irtt-proto/src/layout.rs index 7c486a0..bf1749f 100644 --- a/crates/irtt-proto/src/layout.rs +++ b/crates/irtt-proto/src/layout.rs @@ -135,6 +135,20 @@ pub fn echo_header_len(hmac: bool, params: &Params) -> usize { /// A ceiling on what a negotiated length may legitimately *be* — an MTU, a /// resource bound, a maximum packet size — is deliberately not here. That is /// server and runtime policy, and it belongs where the negotiation happens. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{echo_packet_len, Params}; +/// +/// // The default layout's mandatory field block is 16 bytes (header, token, +/// // sequence), so a smaller requested length is floored up to it. +/// assert_eq!(echo_packet_len(false, &Params::default()).unwrap(), 16); +/// assert_eq!( +/// echo_packet_len(false, &Params { length: 40, ..Params::default() }).unwrap(), +/// 40, +/// ); +/// ``` pub fn echo_packet_len(hmac: bool, params: &Params) -> Result { let header_len = echo_header_len(hmac, params); let requested = if params.length <= 0 { diff --git a/crates/irtt-proto/src/lib.rs b/crates/irtt-proto/src/lib.rs index 3a8e7f1..d100953 100644 --- a/crates/irtt-proto/src/lib.rs +++ b/crates/irtt-proto/src/lib.rs @@ -42,6 +42,39 @@ //! which reply kind to expect, and the two need genuinely different semantic //! context. //! +//! # Example +//! +//! The normal path is directional: a sender encodes with [`encode_request`], a +//! receiver classifies with [`decode_request`], and authentication is a +//! separate step once the applicable key is known. +//! +//! ``` +//! use irtt_proto::{ +//! decode_request, encode_request, verify_packet_hmac, DecodedRequestKind, Params, +//! RequestToEncode, +//! }; +//! +//! // Sender side: encode an authenticated open request. +//! let params = Params::with_protocol_defaults(); +//! let key = b"example key"; +//! let packet = encode_request( +//! RequestToEncode::Open { params: ¶ms, no_test: false }, +//! Some(key), +//! ).unwrap(); +//! +//! // Receiver side: classify structurally, before any key is known. +//! let request = decode_request(&packet).unwrap(); +//! assert!(request.hmac_present); +//! let DecodedRequestKind::Open { no_test, params: encoded } = request.kind else { +//! panic!("expected an open request"); +//! }; +//! assert!(!no_test); +//! assert_eq!(Params::decode(encoded).unwrap(), params); +//! +//! // Authentication is a separate step, once the applicable key is known. +//! verify_packet_hmac(key, &packet).unwrap(); +//! ``` +//! #![forbid(unsafe_code)] pub mod echo; diff --git a/crates/irtt-proto/src/open.rs b/crates/irtt-proto/src/open.rs index 84e4791..7ed768f 100644 --- a/crates/irtt-proto/src/open.rs +++ b/crates/irtt-proto/src/open.rs @@ -13,6 +13,34 @@ pub struct OpenReply { pub params: Params, } +/// Encodes an open reply. +/// +/// `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` and signs the +/// finished datagram, while `None` produces an unauthenticated reply and clears +/// any caller-supplied `FLAG_HMAC`. A zero token is rejected unless the reply +/// also carries `FLAG_CLOSE` (a rejected open). +/// +/// # Errors +/// +/// Returns [`ProtoError::MissingFlag`] when the reply lacks `FLAG_OPEN` or +/// `FLAG_REPLY`, and [`ProtoError::ZeroToken`] for a zero token without +/// `FLAG_CLOSE`. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{ +/// decode_open_reply, encode_open_reply, OpenReply, Params, FLAG_OPEN, FLAG_REPLY, +/// }; +/// +/// let reply = OpenReply { +/// flags: FLAG_OPEN | FLAG_REPLY, +/// token: 0x1234_5678_9abc_def0, +/// params: Params::with_protocol_defaults(), +/// }; +/// let packet = encode_open_reply(&reply, None).unwrap(); +/// assert_eq!(decode_open_reply(&packet, None).unwrap(), reply); +/// ``` pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result> { let params = reply.params.encode(); let mut out = envelope::begin_checked( @@ -30,6 +58,18 @@ pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result) -> Result { let envelope = envelope::decode( packet, diff --git a/crates/irtt-proto/src/params.rs b/crates/irtt-proto/src/params.rs index ce74859..f579d41 100644 --- a/crates/irtt-proto/src/params.rs +++ b/crates/irtt-proto/src/params.rs @@ -16,6 +16,29 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32; /// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open /// request with an empty parameter payload means. It is not a set of sensible /// client settings; a client builds its request from its own configuration. +/// +/// # Example +/// +/// Construct a `Params`, encode it, and decode it back: +/// +/// ``` +/// use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt}; +/// +/// let params = Params { +/// protocol_version: 1, +/// duration_ns: 3_000_000_000, +/// interval_ns: 1_000_000_000, +/// length: 1472, +/// received_stats: ReceivedStats::Both, +/// stamp_at: StampAt::Both, +/// clock: Clock::Both, +/// server_fill: Some(ServerFill { value: "rand".to_owned() }), +/// ..Params::default() +/// }; +/// +/// let encoded = params.encode(); +/// assert_eq!(Params::decode(&encoded).unwrap(), params); +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Params { pub protocol_version: i64, @@ -88,6 +111,20 @@ impl Params { /// Presence means the tag appeared at least once. Repeated known tags keep /// last-value-wins, and unknown tags remain ignored and untracked. /// + /// # Example + /// + /// ``` + /// use irtt_proto::Params; + /// + /// // Wire bytes: tag 2 (duration) carrying an explicit zero. Interval + /// // (tag 3) is absent, so both fields decode to zero but only one is + /// // reported present. + /// let decoded = Params::decode_with_presence(&[0x02, 0x00]).unwrap(); + /// assert_eq!(decoded.params.duration_ns, 0); + /// assert!(decoded.presence.duration_ns); + /// assert!(!decoded.presence.interval_ns); + /// ``` + /// /// [`decode`]: Params::decode pub fn decode_with_presence(input: &[u8]) -> Result { let mut params = Self::default(); diff --git a/crates/irtt-proto/src/request.rs b/crates/irtt-proto/src/request.rs index 834e37c..5e4a61a 100644 --- a/crates/irtt-proto/src/request.rs +++ b/crates/irtt-proto/src/request.rs @@ -88,6 +88,26 @@ pub enum DecodedRequestKind<'a> { /// the negotiated length is positive and wider than `usize`. A *negative* /// negotiated length is not an error: see [`echo_packet_len`], which floors it /// at the mandatory field block. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{encode_request, Params, RequestToEncode}; +/// +/// // An unauthenticated ECHO probe. `params` supplies the negotiated length, +/// // which reserves four payload bytes beyond the 16-byte echo header. +/// let params = Params { length: 20, ..Params::default() }; +/// let packet = encode_request( +/// RequestToEncode::Echo { +/// token: 0x1234_5678_9abc_def0, +/// sequence: 7, +/// params: ¶ms, +/// payload: &[1, 2, 3, 4], +/// }, +/// None, +/// ).unwrap(); +/// assert_eq!(packet.len(), 20); +/// ``` pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> Result> { match request { RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key), @@ -138,6 +158,21 @@ pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> /// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`], /// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for /// `FLAG_REPLY`. +/// +/// # Example +/// +/// ``` +/// use irtt_proto::{decode_request, encode_request, DecodedRequestKind, RequestToEncode}; +/// +/// let packet = encode_request( +/// RequestToEncode::Close { token: 0x1234_5678_9abc_def0 }, +/// None, +/// ).unwrap(); +/// +/// let request = decode_request(&packet).unwrap(); +/// assert!(!request.hmac_present); +/// assert_eq!(request.kind, DecodedRequestKind::Close { token: 0x1234_5678_9abc_def0 }); +/// ``` pub fn decode_request(packet: &[u8]) -> Result> { let envelope = envelope::decode_structural(packet)?; if has(envelope.flags, FLAG_REPLY) { diff --git a/crates/irtt-proto/src/varint.rs b/crates/irtt-proto/src/varint.rs index 4f27ef3..82bf6f2 100644 --- a/crates/irtt-proto/src/varint.rs +++ b/crates/irtt-proto/src/varint.rs @@ -1,3 +1,19 @@ +//! Variable-length integer encoding for IRTT wire values. +//! +//! Tags use an unsigned base-128 varint ([`encode_uvarint`]), and signed +//! integers (such as parameter values) use the same encoding after a zigzag +//! transform ([`encode_varint`]), which keeps small negative numbers compact. +//! +//! # Example +//! +//! ``` +//! use irtt_proto::varint::{decode_varint, encode_varint}; +//! +//! let mut out = Vec::new(); +//! encode_varint(1_000_000_000, &mut out); +//! assert_eq!(decode_varint(&out).unwrap(), (1_000_000_000, out.len())); +//! ``` + use crate::{ProtoError, Result}; pub fn encode_uvarint(mut value: u64, out: &mut Vec) { From d7d6e766823e7ea9b91ea8962a68d03897ba635e Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Thu, 24 Sep 2026 16:40:04 +0700 Subject: [PATCH 3/4] docs(server): add executable API examples --- crates/irtt-server/src/config.rs | 22 ++++++++++++++++++++++ crates/irtt-server/src/core.rs | 26 ++++++++++++++++++++++++++ crates/irtt-server/src/runtime.rs | 22 ++++++++++++++++++++++ crates/irtt-server/src/set.rs | 23 +++++++++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/crates/irtt-server/src/config.rs b/crates/irtt-server/src/config.rs index 583bed6..3be109a 100644 --- a/crates/irtt-server/src/config.rs +++ b/crates/irtt-server/src/config.rs @@ -98,6 +98,28 @@ pub enum TimestampAllowance { /// [`max_packet_length`](Self::max_packet_length) like every other reply, and /// `none` is zero-filled here rather than left as residue. An operator /// restriction can be added if a real use case turns up. +/// +/// # Example +/// +/// Configure a modest per-listener session budget and restrict timestamp +/// placement while leaving DSCP marking disabled: +/// +/// ``` +/// use std::time::Duration; +/// +/// use irtt_server::{ServerConfig, TimestampAllowance}; +/// +/// let config = ServerConfig::default() +/// .with_max_sessions(128) +/// .with_idle_timeout(Duration::from_secs(30)) +/// .with_timestamp_allowance(TimestampAllowance::Single) +/// .with_dscp_allowed(false); +/// +/// assert_eq!(config.max_sessions(), 128); +/// assert_eq!(config.idle_timeout(), Duration::from_secs(30)); +/// assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single); +/// assert!(!config.dscp_allowed()); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServerConfig { hmac_key: Option>, diff --git a/crates/irtt-server/src/core.rs b/crates/irtt-server/src/core.rs index 455c1c8..9517f86 100644 --- a/crates/irtt-server/src/core.rs +++ b/crates/irtt-server/src/core.rs @@ -47,6 +47,32 @@ use crate::{ /// a transport abstraction, and there is no blocking or alternate-runtime /// counterpart. /// +/// # Example +/// +/// The core can be driven directly with a normally encoded open request. It +/// returns a reply to send and records the new session without performing I/O: +/// +/// ``` +/// use std::net::{Ipv4Addr, SocketAddr}; +/// +/// use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode}; +/// use irtt_server::{ServerConfig, ServerCore}; +/// +/// let peer = SocketAddr::from((Ipv4Addr::LOCALHOST, 40_000)); +/// let mut core = ServerCore::new(ServerConfig::default()); +/// let params = Params::with_protocol_defaults(); +/// let request = encode_request( +/// RequestToEncode::Open { params: ¶ms, no_test: false }, +/// None, +/// ) +/// .unwrap(); +/// +/// let reply = core.handle_datagram(peer, &request).unwrap().unwrap(); +/// let open = decode_open_reply(reply.bytes(), None).unwrap(); +/// assert_ne!(open.token, 0); +/// assert_eq!(core.session_count(), 1); +/// ``` +/// /// # Scope /// /// The core implements open handling, session creation and negotiation, diff --git a/crates/irtt-server/src/runtime.rs b/crates/irtt-server/src/runtime.rs index a6de0b6..4c5d88d 100644 --- a/crates/irtt-server/src/runtime.rs +++ b/crates/irtt-server/src/runtime.rs @@ -53,6 +53,28 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(1); /// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than /// served by a listener whose replies a client on a second address would /// silently discard. Explicit-address listeners are unaffected everywhere. +/// +/// # Example +/// +/// Bind one listener, inspect the resolved endpoint, and let the caller decide +/// when serving ends. This example type-checks without opening a socket: +/// +/// ```no_run +/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration}; +/// +/// use irtt_server::{Server, ServerConfig}; +/// +/// # async fn serve() -> Result<(), Box> { +/// let mut server = Server::bind( +/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), +/// ServerConfig::default(), +/// ) +/// .await?; +/// println!("listening on {}", server.local_addr()?); +/// server.run(tokio::time::sleep(Duration::from_secs(60))).await?; +/// # Ok(()) +/// # } +/// ``` #[derive(Debug)] pub struct Server { socket: UdpSocket, diff --git a/crates/irtt-server/src/set.rs b/crates/irtt-server/src/set.rs index b3efc68..e16677b 100644 --- a/crates/irtt-server/src/set.rs +++ b/crates/irtt-server/src/set.rs @@ -57,6 +57,29 @@ use crate::{Server, ServerConfig, ServerRuntimeError}; /// whole set: a service configured for IPv4 and IPv6 must not quietly continue /// as IPv4 only. Every listener task is joined before `run` returns, so no task /// outlives it. +/// +/// # Example +/// +/// Bind independent listeners, inspect their resolved addresses, then serve +/// them under one caller-owned shutdown future. This example type-checks +/// without binding sockets: +/// +/// ```no_run +/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration}; +/// +/// use irtt_server::{ServerConfig, ServerSet}; +/// +/// # async fn serve() -> Result<(), Box> { +/// let addrs = [ +/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), +/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), +/// ]; +/// let set = ServerSet::bind(addrs, ServerConfig::default()).await?; +/// println!("listening on {:?}", set.local_addrs()); +/// set.run(tokio::time::sleep(Duration::from_secs(60))).await?; +/// # Ok(()) +/// # } +/// ``` #[derive(Debug)] pub struct ServerSet { servers: Vec, From 0d69255e9e03d6acf584e3f66e84064898fd7483 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Thu, 24 Sep 2026 16:51:03 +0700 Subject: [PATCH 4/4] docs(client): add executable API examples --- crates/irtt-client/src/async_client.rs | 25 ++++++++++++++ crates/irtt-client/src/client.rs | 25 ++++++++++++++ crates/irtt-client/src/config.rs | 48 ++++++++++++++++++++++++++ crates/irtt-client/src/event.rs | 13 +++++++ crates/irtt-client/src/managed/task.rs | 36 +++++++++++++++++++ 5 files changed, 147 insertions(+) diff --git a/crates/irtt-client/src/async_client.rs b/crates/irtt-client/src/async_client.rs index c531c47..37c9cc2 100644 --- a/crates/irtt-client/src/async_client.rs +++ b/crates/irtt-client/src/async_client.rs @@ -242,6 +242,31 @@ impl AsyncClientTestHooks { /// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async /// methods are polled by the caller and require a current Tokio runtime with I/O /// and time enabled. +/// +/// # Example +/// +/// Use this low-level adapter when the application already owns Tokio and wants +/// to drive the session lifecycle itself. This example type-checks without +/// contacting a server. +/// +/// ```no_run +/// use irtt_client::{AsyncClient, ClientConfig}; +/// +/// # async fn run() -> Result<(), Box> { +/// let mut client = AsyncClient::connect(ClientConfig::default()).await?; +/// let outcome = client.open().await?; +/// println!("opened: {outcome:?}"); +/// let sent = client.send_probe().await?; +/// println!("sent: {sent:?}"); +/// let received = client.recv().await?; +/// println!("received: {received:?}"); +/// let timed_out = client.poll_timeouts()?; +/// println!("timeouts: {timed_out:?}"); +/// let closed = client.close().await?; +/// println!("closed: {closed:?}"); +/// # Ok(()) +/// # } +/// ``` #[derive(Debug)] pub struct AsyncClient { socket: tokio::net::UdpSocket, diff --git a/crates/irtt-client/src/client.rs b/crates/irtt-client/src/client.rs index 2d18a5d..f5586cf 100644 --- a/crates/irtt-client/src/client.rs +++ b/crates/irtt-client/src/client.rs @@ -103,6 +103,31 @@ use crate::{ /// session, send probes, receive replies, poll timeouts, and close. Callers /// that do not need to own this loop can use the unified managed API in /// [`crate::managed`] when the `tokio` feature is enabled. +/// +/// # Example +/// +/// `Client` is the runtime-free adapter. The caller drives receives and timeout +/// polling between sends; see the standalone example for a complete loop. +/// This example type-checks without contacting a server: +/// +/// ```no_run +/// use irtt_client::{Client, ClientConfig}; +/// +/// # fn run() -> Result<(), Box> { +/// let mut client = Client::connect(ClientConfig::default())?; +/// let outcome = client.open()?; +/// println!("opened: {outcome:?}"); +/// let sent = client.send_probe()?; +/// println!("sent: {sent:?}"); +/// let received = client.recv_once()?; +/// println!("received: {received:?}"); +/// let timed_out = client.poll_timeouts()?; +/// println!("timeouts: {timed_out:?}"); +/// let closed = client.close()?; +/// println!("closed: {closed:?}"); +/// # Ok(()) +/// # } +/// ``` #[derive(Debug)] pub struct Client { runtime: SessionMachine, diff --git a/crates/irtt-client/src/config.rs b/crates/irtt-client/src/config.rs index ca4505d..4f3a002 100644 --- a/crates/irtt-client/src/config.rs +++ b/crates/irtt-client/src/config.rs @@ -46,6 +46,31 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096; /// request and the local client behavior used to drive the UDP socket. Values /// that are negotiated by the server are available after opening the session /// through [`NegotiatedParams`](crate::NegotiatedParams). +/// +/// # Example +/// +/// Configure a finite run with larger probes and accept documented server +/// restrictions. Use `duration: None` for continuous mode, or +/// [`RunMode::NoTest`] for negotiation without probes. +/// +/// ``` +/// use std::time::Duration; +/// +/// use irtt_client::{ClientConfig, NegotiationPolicy}; +/// +/// let config = ClientConfig { +/// server_addr: "example.net:2112".into(), +/// duration: Some(Duration::from_secs(10)), +/// interval: Duration::from_millis(250), +/// length: 1200, +/// negotiation_policy: NegotiationPolicy::Loose, +/// ..ClientConfig::default() +/// }; +/// +/// assert_eq!(config.duration, Some(Duration::from_secs(10))); +/// assert_eq!(config.interval, Duration::from_millis(250)); +/// assert_eq!(config.length, 1200); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientConfig { /// Remote server name or address. @@ -176,6 +201,29 @@ impl Default for ClientConfig { /// These settings affect how the client binds, resolves, and receives from the /// socket. They do not change the IRTT protocol parameters negotiated with the /// server. +/// +/// # Example +/// +/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL: +/// +/// ``` +/// use std::net::{Ipv4Addr, SocketAddr}; +/// +/// use irtt_client::{ClientConfig, SocketConfig}; +/// +/// let config = ClientConfig { +/// socket_config: SocketConfig { +/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))), +/// ttl: Some(32), +/// ipv4_only: true, +/// ..SocketConfig::default() +/// }, +/// ..ClientConfig::default() +/// }; +/// +/// assert_eq!(config.socket_config.ttl, Some(32)); +/// assert!(config.socket_config.ipv4_only); +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct SocketConfig { /// Local address to bind before connecting the UDP socket. diff --git a/crates/irtt-client/src/event.rs b/crates/irtt-client/src/event.rs index 911fba9..fbe113b 100644 --- a/crates/irtt-client/src/event.rs +++ b/crates/irtt-client/src/event.rs @@ -260,6 +260,19 @@ pub struct RttSample { /// processing time or comparing wall-clock timestamps from different hosts. It /// should not be used to represent raw elapsed wall-clock or monotonic time, /// which is non-negative and represented by [`Duration`]. +/// +/// # Example +/// +/// Signed timing values retain a negative result instead of clamping clock +/// skew or server processing adjustments to zero: +/// +/// ``` +/// use irtt_client::SignedDuration; +/// +/// let adjusted_rtt = SignedDuration::from_nanos(-250_000); +/// assert!(adjusted_rtt.is_negative()); +/// assert_eq!(adjusted_rtt.as_micros(), -250); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct SignedDuration { ns: i128, diff --git a/crates/irtt-client/src/managed/task.rs b/crates/irtt-client/src/managed/task.rs index 15bb2e9..39c4691 100644 --- a/crates/irtt-client/src/managed/task.rs +++ b/crates/irtt-client/src/managed/task.rs @@ -44,6 +44,42 @@ const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET; const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1; /// Entry point for constructing a unified Tokio managed task. +/// +/// # Example +/// +/// Construct the task and its separate control handle, subscribe before +/// starting the task, then run it under the caller's Tokio runtime. A reachable +/// server is required at runtime, so this example only type-checks. +/// +/// ```no_run +/// use irtt_client::{ClientConfig, ClientEvent}; +/// use irtt_client::managed::{ +/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig, +/// }; +/// +/// # async fn run() -> Result<(), Box> { +/// let config = ManagedClientConfig { +/// client: ClientConfig { +/// duration: Some(std::time::Duration::from_secs(10)), +/// ..ClientConfig::default() +/// }, +/// ..ManagedClientConfig::default() +/// }; +/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")]; +/// let (task, handle) = ManagedClient::task(config, targets)?; +/// let mut events = handle.subscribe()?; +/// let driver = tokio::spawn(task); +/// +/// while let Ok(event) = events.recv().await { +/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event { +/// println!("received a probe reply"); +/// } +/// } +/// let outcome = driver.await?; +/// println!("run ended: {:?}", outcome.end_reason); +/// # Ok(()) +/// # } +/// ``` #[derive(Debug, Default)] pub struct ManagedClient;