75 KiB
IRTT-RS Implementation Plan
1. Inputs Reviewed
RUST_DESIGN.md
Contribution: Defines the full Rust architecture: four-crate workspace (irtt-proto, irtt-client, irtt-stats, irtt-cli), the Client / ManagedClient split, event model, CLI output formats, dependency choices, and milestone sequence.
Sufficiency: Sufficient for implementation planning. The design is thorough and internally consistent. A few minor amendments are noted in Section 18.
Inconsistencies found:
- None that would block implementation. The design is well-aligned with the protocol spec and black-box findings.
IRTT_CLIENT_PROTOCOL_SPEC.md
Contribution: Normative protocol specification. Defines wire format, field ordering, parameter serialization (varint/zigzag), session lifecycle, timing semantics, measurement formulas, validation rules, and error handling. Includes two packet test vectors with HMAC.
Sufficiency: Sufficient for implementing a conforming client. All resolved open questions have clear answers. Three open questions remain (19.3, 19.6, 19.12) — none block initial implementation.
Inconsistencies found:
- The spec's packet count formula was corrected to
ceil(d/i)— the spec itself already reflects this correction. - M8 implementation exposes
--dscpas a DSCP codepoint in the range 0..=63, encodes that codepoint in protocol parameters, and shifts it left two bits only when applying the local socket TOS/Traffic Class byte. - Test vector sizes corrected to 92 bytes — the spec already reflects this.
- The flags byte in test vector 18.2 is
0x08(HMAC only, no Reply flag). The spec explicitly notes this is for HMAC/layout verification only and that real packets require Reply flag0x02. This is clear and not a problem.
BLACKBOX_VERIFICATION_REPORT.md
Contribution: Independent black-box verification of protocol behavior against irtt 0.9.1. Resolves 7 open questions, partially resolves 2, confirms spec corrections, and provides packet captures as ground truth.
Sufficiency: Sufficient. The remaining unresolved items (19.3, 19.6, 19.12) are edge cases with clear defensive strategies.
Inconsistencies found:
- None with the spec (corrections already applied).
CLEANROOM_NOTES.md
Contribution: Documents the clean-room boundary, source materials inspected by the specification agent, post-drafting audit, and second-pass scrub.
Sufficiency: Sufficient. Clear separation between contaminated (spec-writing) and clean (implementation) sides.
Inconsistencies found:
- None.
Clean Packet Test Vectors (docs/test-vectors.md)
Contribution: 12 packet test vectors from actual network captures against irtt 0.9.1. Covers: open request/reply (with and without HMAC), echo request/reply (with and without HMAC, various stats/timestamp configs), close request (with and without HMAC), no-test mode request/reply, minimal echo packet (16 bytes), and midpoint timestamp echo reply.
Sufficiency: Excellent. Combined with the two HMAC test vectors in the protocol spec (Section 18.1, 18.2), this provides 14 total test vectors covering every packet type and major field combination. Sufficient for thorough protocol validation.
Inconsistencies found:
- None. All vectors are consistent with the protocol spec and black-box report.
2. Executive Summary
- Crate structure: Four crates in a Cargo workspace —
irtt-proto(wire protocol),irtt-client(socket + session + events),irtt-stats(event aggregation),irtt-cli(user-facing binary). - Manual vs managed client:
Clientis the caller-paced, no-thread, no-stats core.ManagedClientis a threaded convenience runner built on the same primitives. Both live inirtt-client. - Event-first design:
irtt-clientemitsClientEventvalues. It never aggregates statistics. Consumers decide what to do with events. - Stats separation:
irtt-statsconsumesClientEventand provides cumulative, rolling, and summary statistics. It is an optional dependency ofirtt-cli. - CLI output philosophy: Line-oriented, awk-friendly, delimiter-configurable output. Human, machine, simple, rtt-us, and optional JSONL formats. Default builds include stats; minimal/router builds can omit them.
- Interop testing strategy: default
cargo testruns self-contained tests. Backend-neutral smoke tests useIRTT_TEST_BACKEND: unset orfakeuses the in-process test backend,realruns an optionalirtt serverfromIRTT_BINorPATH.tsharkis diagnostic-only, never a CI dependency. - Clean-room compliance: Implementation works only from the four clean artifacts. No upstream source, tests, or contaminated notes.
- Router friendliness: No mandatory async runtime. No busy-spin by default. Feature-gated optional capabilities. Minimal binary possible with
--no-default-features. - First-class consumers:
sqm-autorate-rust(manualClientAPI),cake-autorate(CLI machine output), general users (CLI with stats). - Staged implementation: 10 milestones, each independently testable, building from protocol layer up.
3. Architecture Confirmation
The intended architecture from RUST_DESIGN.md is confirmed with no structural changes needed.
irtt-proto
Public responsibility:
- Binary wire format encoding and decoding
- Packet constants (magic, flags, protocol version)
- Varint/zigzag encoding and decoding
- Parameter model and serialization
- Packet layout calculation (minimum packet size for given parameters)
- Open request/reply encoding and decoding
- Echo request encoding, echo reply decoding
- Close request encoding
- HMAC-MD5 calculation and verification
Forbidden responsibility:
- Sockets, threads, channels, timers
- Statistics aggregation
- Session runtime control
- Event fanout
- ECN/DSCP/TOS socket metadata (these are IP-layer, not IRTT payload)
Main public types:
Magic,Flags,ProtocolVersionParams,ParamTag,ReceivedStats,StampAt,Clock,ServerFillOpenRequest,OpenReplyEchoRequest,EchoReplyCloseRequestPacketLayoutHmacKey, HMAC computation functionsProtoError
Internal modules:
varint— uvarint/zigzag encode/decodeparams— parameter serialization/deserializationheader— magic/flags/field parsinghmac— HMAC-MD5 wrapperlayout— packet size calculation
Feature flags:
serde— derive Serialize/Deserialize on public types
Dependencies:
thiserror,hmac,md-5,subtle,fastrandserde(optional)
irtt-client
Public responsibility:
- Connected UDP socket setup and configuration via
socket2 - Session negotiation (open/close lifecycle)
- Caller-paced manual client API (
Client) - Optional managed threaded runner (
ManagedClient) - Probe sending and reply receiving
- Packet validation
- Timestamp capture
- RTT sample construction
- Duplicate/late/loss detection
- Public event types (
ClientEvent) - Event fanout for managed mode
Forbidden responsibility:
- Aggregate statistics (min/max/mean/median/stddev/variance)
- Rolling windows
- Human-readable reports
- CLI formatting
- Mandatory full sample history
Main public types:
ClientConfig,SocketConfigClient,OpenOutcomeManagedClient,ManagedClientSessionEventHub,EventSubscription,SubscriberConfig,SubscriberOverflowClientEvent,RttSample,ServerTimingReceivedStatsSample,PacketMeta,LossKindClientWarning,SessionOutcome,SessionEndReasonNegotiatedParams,NegotiationPolicy,ValidationPolicyRunMode,LossDetection,TimerModeReconnectPolicy,CancellationTokenClientErrorClientTimestamp
Internal modules:
socket—ClientSocketwrappersession— session state machineprobe— pending probe trackingvalidate— packet validation logictiming— timestamp capture and RTT computationmanaged— managed runner threads and event hub
Feature flags:
serde— derive Serialize/Deserialize on public event typestracing— structured loggingancillary— platform control-message APIs for ECN/DSCP/kernel RX timestamps
Dependencies:
irtt-proto,thiserror,socket2,flume,fastrandserde,tracing(optional)rustixor similar (optional, for ancillary)
irtt-stats
Public responsibility:
- Consume
ClientEventvalues - Cumulative statistics
- Rolling time-window statistics
- Rolling sample-count statistics
- Finite-run summaries
- RTT/IPDV/OWD/server-processing/loss statistics
Forbidden responsibility:
- Sockets, packet encoding/decoding
- Session negotiation
- Thread/runtime ownership
- CLI parsing
Main public types:
StatsCollector(orStatsEngine)CumulativeStatsRollingStatsFiniteSummaryRttStats,IpdvStats,LossStatsStatsConfig
Internal modules:
running— online mean/variance computationrolling— windowed statisticssummary— finite-run summary buildermedian— median computation strategy
Feature flags:
serde— derive Serialize/Deserialize on stats types
Dependencies:
irtt-client(forClientEventtypes)thiserrorserde(optional)
irtt-cli
Public responsibility:
- User-facing executable
- Command-line argument parsing
- Output formatting (human, machine, simple, rtt-us, jsonl)
- Stats display (when feature-enabled)
- Signal handling (Ctrl-C)
- Exit codes
Forbidden responsibility:
- Protocol reimplementation
- Session logic reimplementation
- Statistics reimplementation
Main public types:
- (binary crate — minimal public API)
- Internal:
CliConfig,OutputFormatter,MachineFormatter,SimpleFormatter,RttUsFormatter,HumanFormatter
Internal modules:
args— clap argument parsingformat— output formattingrun— test execution orchestrationsignal— Ctrl-C handling
Feature flags:
default = ["stats"]stats = ["dep:irtt-stats"]json = ["dep:serde_json", "irtt-client/serde", "irtt-stats?/serde"]tracing = ["irtt-client/tracing"]ancillary = ["irtt-client/ancillary"]
Dependencies:
irtt-client,irtt-stats(optional),clap,thiserror,ctrlcserde_json(optional)
4. Protocol Implementation Plan
Constants
MAGIC: [0x14, 0xA7, 0x5B]
PROTOCOL_VERSION: 1
FLAG_OPEN: 0x01
FLAG_REPLY: 0x02
FLAG_CLOSE: 0x04
FLAG_HMAC: 0x08
RESERVED_FLAGS_MASK: 0xF0
HMAC_SIZE: 16
TOKEN_SIZE: 8
SEQ_SIZE: 4
RECV_COUNT_SIZE: 4
RECV_WINDOW_SIZE: 8
TIMESTAMP_SIZE: 8
Flags
Implement Flags as a newtype over u8 with named bit accessors:
is_open(),is_reply(),is_close(),is_hmac()has_reserved_bits()— returns true if bits 4–7 are set- Builder pattern for constructing outgoing flags
Field Layout
Implement PacketLayout that computes field offsets and total minimum size given:
- Whether HMAC is present
- Whether this is an open request (no token), echo (token + seq + optional fields), or close (token only)
ReceivedStatsmodeStampAtmodeClockmode
The layout must match the field ordering verified in BLACKBOX_VERIFICATION_REPORT Section 19.11:
- HMAC (if flag set)
- Connection Token (except open request from client)
- Sequence Number (echo only)
- Received Count (if count or both)
- Received Window (if window or both)
- Receive Wall (if receive or both, and wall or both clock)
- Receive Mono (if receive or both, and mono or both clock)
- Midpoint Wall (if midpoint, and wall or both clock) — mutually exclusive with recv/send
- Midpoint Mono (if midpoint, and mono or both clock)
- Send Wall (if send or both, and wall or both clock)
- Send Mono (if send or both, and mono or both clock)
Endianness
All multi-byte integers are little-endian. Use u32::from_le_bytes(), u64::from_le_bytes(), i64::from_le_bytes() for reading. Use .to_le_bytes() for writing.
Varint / Zigzag
Implement two functions:
encode_uvarint(value: u64, buf: &mut [u8]) -> usize— standard unsigned LEB128decode_uvarint(buf: &[u8]) -> Result<(u64, usize), ProtoError>— returns (value, bytes_consumed)
And for signed:
encode_varint(value: i64, buf: &mut [u8]) -> usize— zigzag then LEB128decode_varint(buf: &[u8]) -> Result<(i64, usize), ProtoError>— LEB128 then unzigzag
Zigzag transform:
- Encode:
((v << 1) ^ (v >> 63)) as u64 - Decode:
((uv >> 1) as i64) ^ -((uv & 1) as i64)
Parameter Encoding/Decoding
Parameters are tag-value pairs:
- Tag: uvarint
- Value: varint (signed zigzag), except for string-typed values
- String values: uvarint length prefix + raw UTF-8 bytes
Define Params struct:
protocol_version: i64
duration_ns: i64
interval_ns: i64
length: i64
received_stats: ReceivedStats
stamp_at: StampAt
clock: Clock
dscp: i64
server_fill: Option<String>
Serialize: iterate fields, skip zero values (except protocol_version which is always 1), write tag + value.
Deserialize: read tag-value pairs in a loop, match known tags, silently ignore unknown tags.
Verified encoding examples from BLACKBOX_VERIFICATION_REPORT:
- ProtocolVersion=1: tag
01, value zigzag(1)=2 →02 - Duration=3s: tag
02, value zigzag(3000000000)=6000000000 →80 f8 82 ad 16 - DSCP=46: tag
08, value zigzag(46)=92 →5c; the socket TOS/Traffic Class byte derived from this codepoint is46 << 2 == 184.
Open Request/Reply
Open Request encoding:
- Write magic (3 bytes)
- Write flags (1 byte): Open | optional HMAC | optional Close (no-test)
- If HMAC: write 16 zero bytes (placeholder)
- No connection token
- Write serialized params as payload
- If HMAC: compute HMAC-MD5 over entire buffer with HMAC field zeroed, write result into HMAC field
Open Reply decoding:
- Validate magic
- Read flags — must have Open | Reply. Check for Close flag and reserved bits.
- If HMAC flag: extract HMAC, verify
- Read connection token (8 bytes)
- Parse params from remaining payload
- Determine outcome: normal open, no-test ack, or rejection
Echo Request/Reply
Echo Request encoding:
- Write magic + flags (0x00 or 0x08 for HMAC)
- If HMAC: write 16 zero bytes
- Write connection token (8 bytes)
- Write sequence number (4 bytes LE)
- Write zeroed received stats fields (if negotiated)
- Write zeroed timestamp fields (if negotiated)
- Write payload to reach negotiated length
- If HMAC: compute and write
Echo Reply decoding:
- Validate magic
- Read flags — must have Reply. Check HMAC, Close, reserved bits.
- If HMAC: extract and verify
- Read connection token
- Read sequence number
- Read received count (if negotiated)
- Read received window (if negotiated)
- Read timestamp fields (per StampAt + Clock)
- Remaining bytes are payload (ignore for measurement purposes)
Close Request
Encoding:
- Write magic + flags (Close | optional HMAC)
- If HMAC: write 16 zero bytes
- Write connection token
- If HMAC: compute and write
Total: 12 bytes without HMAC, 28 bytes with HMAC.
HMAC-MD5 Handling
- Use
hmacandmd-5crates - Compute over entire packet buffer with HMAC field (bytes 4–19) zeroed
- Use
subtle::ConstantTimeEqfor verification - HMAC key is raw bytes (from user-provided string, typically UTF-8)
Packet Vector Tests
Implement tests for:
- Test vector 18.1 (echo request with HMAC, 92 bytes) — verify HMAC, field layout, field values
- Test vector 18.2 (echo reply with HMAC, 92 bytes) — verify HMAC, field layout, field values
- Varint encoding/decoding round-trips for all verified values from BLACKBOX_VERIFICATION_REPORT
- Parameter encoding/decoding round-trips
- Layout calculation for all 6 verified field combinations from Section 19.11
- Flags construction and parsing
- Open request encoding (verify no token field present)
- Close request encoding (verify 12/28 byte sizes)
5. Client Manual API Plan
ClientConfig
server_addr: String // hostname:port or IP:port
duration: Option<Duration> // None for continuous
interval: Duration // default 1s
length: u32 // 0 = minimum
received_stats: ReceivedStats // default Both
stamp_at: StampAt // default Both
clock: Clock // default Both
dscp: u8 // default 0
hmac_key: Option<Vec<u8>>
server_fill: Option<String>
open_timeouts: Vec<Duration> // default [1s, 2s, 4s, 8s]
run_mode: RunMode
negotiation_policy: NegotiationPolicy
validation_policy: ValidationPolicy
loss_detection: LossDetection
timer_mode: TimerMode
socket_config: SocketConfig
SocketConfig
bind_addr: Option<SocketAddr>
ttl: Option<u32>
ipv4_only: bool
ipv6_only: bool
recv_timeout: Option<Duration> // for blocking recv in manual mode
ClientSocket
Internal struct wrapping std::net::UdpSocket (created via socket2::Socket).
Responsibilities:
- Create socket (IPv4 or IPv6 based on resolved address)
- Bind to local address (or ephemeral)
- Set DSCP/TOS via
socket2(set_tosfor IPv4,set_traffic_class_v6for IPv6) from the negotiated DSCP codepoint shifted into the TOS/Traffic Class byte; applied only for echo phase per Finding C - Set TTL/hop limit if configured
- Connect to resolved server address
send(&self, buf: &[u8]) -> io::Result<usize>recv(&self, buf: &mut [u8]) -> io::Result<usize>set_read_timeout(&self, timeout: Option<Duration>)try_clone(&self) -> io::Result<ClientSocket>— for managed mode
MVP PacketMeta returns all None fields. Future ancillary feature fills them.
SessionState
Internal struct tracking:
connection_token: u64negotiated_params: NegotiatedParamsnext_seq: u32(wire sequence)logical_seq: u64packets_sent: u64replies_received: u64duplicates: u64late_count: u64malformed: u64max_received_seq: Option<u32>(for late detection)pending_probes: BoundedPendingMap(seq → PendingProbe)start_time: Option<Instant>end_time: Option<Instant>(computed from start + duration)phase: SessionPhase(Opening, Active, Draining, Closing, Completed)
PendingProbe
logical_seq: u64
wire_seq: u32
sent_at_mono: Instant
sent_at_wall: Option<SystemTime>
timeout_at: Instant
BoundedPendingMap
A bounded map from u32 (wire seq) to PendingProbe. Capacity limited to prevent unbounded growth in continuous mode. Oldest entries evicted when capacity reached (these become loss events).
Client::connect
- Resolve server address (DNS lookup if hostname)
- Create
ClientSocket(socket2 → std) - Bind local address
- Connect UDP socket
- Return
ClientinOpeningphase
Client::open
Returns Result<OpenOutcome, ClientError> (not Vec<ClientEvent>).
pub enum OpenOutcome {
Started {
session_id: SessionId,
remote: SocketAddr,
negotiated: NegotiatedParams,
event: ClientEvent,
},
NoTestCompleted {
remote: SocketAddr,
event: ClientEvent,
},
}
Steps:
- Encode open request with proposed params
- Send open request
- Set read timeout to first timeout value
- Loop: recv → validate magic/flags/HMAC → parse open reply
- On timeout: retransmit with next timeout value
- On all timeouts exhausted: return
ClientError::OpenTimeout - On valid reply:
- If Close flag and client didn't request close: return
ClientError::ServerRejected - If Close flag and client requested close (no-test): return
OpenOutcome::NoTestCompleted - Extract connection token, negotiated params
- Apply negotiation policy (reject or accept restrictions)
- Compute minimum packet length from negotiated params
- Transition to Active phase
- Set DSCP on socket (only now, for echo phase)
- Return
OpenOutcome::Started
- If Close flag and client didn't request close: return
Client::send_probe
- Assert Active phase
- Check if duration exceeded (if finite mode) → if so, transition to Draining
- Encode echo request with current sequence number, connection token
- Capture send timestamp (Instant + SystemTime) immediately before send call
- Call
socket.send() - Insert PendingProbe into pending map
- Increment sequence counters
- Return any events (e.g., eviction-triggered loss events from bounded map)
Client::recv_once
- Attempt one
socket.recv()(may block up to read timeout, or nonblocking) - Capture receive timestamp immediately after recv returns
- Validate packet (magic, flags, reserved bits, HMAC, connection token, sequence)
- Match to pending probe
- If duplicate: emit
DuplicateReply, do not update original - If late (seq < max_received_seq): emit
LateReply - Compute
RttSample(raw, server_processing, adjusted) - Compute
OneWayDelaySampleif wall timestamps available - Extract
ReceivedStatsSampleif negotiated - Emit
EchoReplyevent - Return
Ok(events)— empty Vec on timeout/would-block
Client::recv_available
Loop calling recv_once up to a budget limit (max packets or max duration). Collect all events.
Client::poll_timeouts
- Check all pending probes against loss detection policy
- For probes exceeding timeout: emit
EchoLossevent, remove from pending map - Use received window from most recent reply to classify loss direction where possible
- Return collected events
Client::close
- Reset DSCP to 0 on socket (per Finding C)
- Encode and send close request
- Transition to Completed phase
- Return
SessionEndedevent with outcome
No-Test Mode
RunMode::NoTest: Client::open sets both Open and Close flags. On valid reply with Close flag and zero token, immediately returns SessionEnded. No echo phase.
Continuous Mode
RunMode::Continuous: No duration limit. send_probe never transitions to Draining on its own. Caller is responsible for stopping (by calling close or dropping). Bounded pending map prevents unbounded memory growth.
Finite Duration/Count Modes
RunMode::Duration(d): send_probe checks Instant::now() >= start + d before sending. send_probe returns a "test complete" indication when done.
RunMode::Count(n): send_probe checks logical_seq >= n before sending.
Negotiation Policy
Strict(default): reject if any param was changed by serverLoose: accept server-returned params, emit warnings if changed
Validation Policy
Strict: abort session on any malformed packetDropMalformed: silently drop malformed packets, continueDropUnrelatedAbortMalformedSession: drop unrelated garbage, abort on malformed session packets
Loss Detection Policy
Disabled: never emit loss events from timeoutTimeout(Duration): emit loss if no reply within fixed durationMultipleOfInterval(u32): emit loss if no reply within N × interval
Cancellation for Manual Mode
No signal handling in Client. Caller controls lifecycle by:
- Not calling
send_probeafter deciding to stop - Calling
closefor graceful shutdown - Dropping
Clientfor ungraceful shutdown
6. Managed Runner Plan
Overview
ManagedClient wraps the same socket and session primitives as Client, but runs them on dedicated threads with event fanout.
Thread Roles
Coordinator thread:
- Owns session state (equivalent of
SessionState) - Receives compact
SentRecordfrom sender via internalflumechannel - Receives compact
RecvRecordfrom receiver via internalflumechannel - Correlates sent/received records
- Computes RTT samples
- Manages pending probe map
- Handles loss timeouts
- Detects duplicates and late replies
- Emits
ClientEventthroughEventHub - Handles graceful shutdown and close packet
Sender/pacer thread:
- Owns a cloned socket handle (write half)
- Maintains absolute send schedule:
scheduled_time(seq) = session_start + seq * interval - Prepares echo request packets
- Sends packets at scheduled times
- Captures send timestamps immediately before send call
- Sends compact
SentRecord { wire_seq, logical_seq, sent_at_mono, sent_at_wall }to coordinator - Must NOT perform: event fanout, stats, subscriber delivery, blocking coordination
Receiver thread:
- Owns a cloned socket handle (read half)
- Blocks on
socket.recv() - Captures receive timestamp immediately after recv returns, before parsing
- Parses/validates reply minimally
- Sends compact
RecvRecord { wire_seq, received_at_mono, received_at_wall, server_timestamps, received_stats, packet_meta, raw_packet_info }to coordinator - Must NOT perform: RTT computation, event fanout, stats
Internal Channels
- Sender → Coordinator:
flume::bounded<SentRecord>(capacity)— capacity sized to avoid dropped sends (e.g., 256) - Receiver → Coordinator:
flume::bounded<RecvRecord>(capacity)— similar - Control → All:
CancellationToken(Arc) checked by all threads
Internal channels must never silently drop records. If sender or receiver channel is full, this indicates coordinator is stuck — treat as fatal.
Event Hub / Fanout
EventHub {
subscribers: Vec<SubscriberSlot>
}
SubscriberSlot {
sender: flume::Sender<ClientEvent>
config: SubscriberConfig
drops: AtomicU64
}
When coordinator emits an event:
- Clone event for each subscriber
- Attempt send according to subscriber's overflow policy:
Block: blocking send (only if subscriber is trusted to keep up)DropNewest: try_send, increment drops on failureDisconnect: try_send, on failure remove subscriber
DropOldest is deferred from MVP. It is useful for SQM/live-control freshness, but flume does not provide it natively and it is not worth blocking the managed runner. A future implementation may use a drain-one-and-retry workaround or a ring-buffer channel.
Critical invariant: Public subscriber backpressure must NOT delay the sender/pacer thread. The sender never touches the event hub. Only the coordinator touches the event hub, and the coordinator is not on the timing-critical path.
Subscriber Configuration
SubscriberConfig {
capacity: usize // channel buffer size
overflow: SubscriberOverflow
event_filter: EventFilter // optional: subscribe only to specific event types
}
Preset profiles:
SubscriberConfig::stats(): Block, large capacity, all eventsSubscriberConfig::live(): DropNewest, small capacity (e.g., 4), EchoReply/EchoLoss only (future: DropOldest when available)SubscriberConfig::cli(): DropNewest, moderate capacity (e.g., 1024), all eventsSubscriberConfig::debug(): DropNewest, small capacity, all events
Shutdown / Stop / Join
session.stop(): setsCancellationToken, non-blocking- Sender thread: detects cancellation, exits loop
- Receiver thread: detects cancellation (via recv timeout + check), exits
- Coordinator: detects cancellation, drains remaining records, sends close packet, emits
SessionEnded, exits session.join(self) -> Result<SessionOutcome, ClientError>: joins all threads, returns outcome
Thread Lifecycle
ManagedClient::start(config):- Create socket, perform open handshake (on calling thread)
- On success: clone socket handles
- Spawn sender, receiver, coordinator threads
- Return
ManagedClientSession(owns join handles + event hub + cancellation token)
- Subscribers can be added before or after start (before start is preferred; adding after requires the hub to be shared)
- On thread panic: coordinator detects via channel disconnect, emits FatalError, shuts down remaining threads
Timer Strategy
Sender uses absolute scheduling. For TimerMode::Normal:
- Compute
wake_time = session_start + seq * interval - Sleep until
wake_timeusingstd::thread::sleeporstd::thread::park_timeout - After waking, immediately send
For TimerMode::Precise { spin_threshold }:
- Sleep until
wake_time - spin_threshold - Busy-spin for remaining time
- Only for users who explicitly opt in (not default, not router-safe)
Reuse of Manual/Session Primitives
The managed runner reuses:
irtt-protofor all encoding/decodingClientSocketfor socket operationsPacketLayoutfor buffer sizing- Validation logic from the session module
- RTT/timestamp computation logic
It does NOT reuse Client directly (the managed runner has different ownership semantics), but the logic is shared at the module level.
7. Event Model Plan
ClientEvent
Do not define or emit EchoSent in MVP. It is debug/instrumentation noise that can be added later behind a tracing or debug event option.
pub enum ClientEvent {
SessionStarted {
session_id: SessionId,
remote: SocketAddr,
negotiated: NegotiatedParams,
},
EchoReply {
session_id: SessionId,
seq: u64,
wire_seq: u32,
remote: SocketAddr,
sent_at: ClientTimestamp,
received_at: ClientTimestamp,
rtt: RttSample,
one_way: Option<OneWayDelaySample>,
server_timing: Option<ServerTiming>,
received_stats: Option<ReceivedStatsSample>,
packet_meta: PacketMeta,
},
EchoLoss {
session_id: SessionId,
seq: u64,
wire_seq: u32,
kind: LossKind,
},
DuplicateReply {
session_id: SessionId,
seq: u64,
wire_seq: u32,
},
LateReply {
session_id: SessionId,
seq: u64,
wire_seq: u32,
rtt: Option<RttSample>,
},
Warning {
session_id: Option<SessionId>,
warning: ClientWarning,
},
SessionEnded {
session_id: SessionId,
reason: SessionEndReason,
},
FatalError {
session_id: Option<SessionId>,
error: ClientError,
},
}
Change from initial design: FatalError uses ClientError directly instead of a separate ClientErrorKind enum.
RttSample
pub struct RttSample {
pub raw: Duration,
pub adjusted: Option<Duration>,
pub server_processing: Option<Duration>,
}
impl RttSample {
pub fn effective(&self) -> Duration {
self.adjusted.unwrap_or(self.raw)
}
}
Rules:
raw = client_receive_mono - client_send_monoserver_processing = server_send_mono - server_receive_mono(prefer mono, fallback wall)adjusted = raw - server_processingONLY IFserver_processing <= raw- If
server_processing > raw:adjusted = None, emitClientWarning::ServerProcessingExceedsRawRtt
ClientTimestamp
pub struct ClientTimestamp {
pub mono: Instant,
pub wall: SystemTime,
}
Both captured together, as close to the syscall as possible.
PacketMeta
pub struct PacketMeta {
pub traffic_class: Option<u8>,
pub dscp: Option<Dscp>,
pub ecn: Option<Ecn>,
pub kernel_rx_timestamp: Option<SystemTime>,
}
All None in MVP. Populated by ancillary feature.
ReceivedStatsSample
pub struct ReceivedStatsSample {
pub count: Option<u32>,
pub window: Option<u64>,
}
ServerTiming
pub struct ServerTiming {
pub receive_wall: Option<i64>, // ns since epoch
pub receive_mono: Option<i64>, // ns duration
pub send_wall: Option<i64>,
pub send_mono: Option<i64>,
pub midpoint_wall: Option<i64>,
pub midpoint_mono: Option<i64>,
}
OneWayDelaySample
pub struct OneWayDelaySample {
pub send_delay: Duration, // server_best_receive_wall - client_send_wall
pub receive_delay: Duration, // client_receive_wall - server_best_send_wall
}
Only computable with wall clock timestamps and assumes synchronized clocks.
LossKind
pub enum LossKind {
Unknown,
Upstream,
Downstream,
}
Classified using received window when available.
ClientWarning
pub enum ClientWarning {
ServerProcessingExceedsRawRtt {
seq: u64,
raw: Duration,
server_processing: Duration,
},
SubscriberDroppedEvents {
subscriber_id: SubscriberId,
count: u64,
},
ServerRestrictedParams {
original: Params,
negotiated: Params,
},
}
SessionOutcome
pub struct SessionOutcome {
pub end_reason: SessionEndReason,
pub packets_sent: u64,
pub replies_received: u64,
pub duplicates: u64,
pub late: u64,
pub malformed: u64,
}
SessionEndReason
pub enum SessionEndReason {
TestComplete,
Cancelled,
ServerClosed,
Error(ClientError),
}
Sequence Number Modeling
pub struct ProbeSeq {
pub logical: u64,
pub wire: u32,
}
Wire sequence is the 32-bit value on the wire. Logical sequence is a monotonic u64 that survives wire wraps in very long continuous sessions. For practical purposes in protocol v1, wire = (logical as u32).
8. Socket and Networking Plan
socket2 Usage
Use socket2::Socket for creation and configuration:
- Create
Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)) - Domain:
Domain::IPV4orDomain::IPV6based on resolved address - Bind:
socket.bind(&local_addr.into()) - Configure options (TOS/TC, TTL, etc.)
- Connect:
socket.connect(&server_addr.into()) - Convert:
std::net::UdpSocket::from(socket)
Bind/Connect Flow
- Resolve server address (may involve DNS)
- Determine IP version from resolved address
- Create socket2 socket for that IP version
- Bind to configured local address or
0.0.0.0:0/[::]:0 - Set socket options
- Connect to server address
- Convert to std
UdpSocket
IPv4/IPv6 Handling
- If user specifies
-4or-6, use only that version - If user specifies an IP literal, use matching version
- If user specifies a hostname, resolve and prefer based on what succeeds (implementation-defined preference)
- Do NOT create dual-stack sockets; create one socket for one IP version per session
Connected UDP Semantics
The socket is connected via connect(). This means:
send()instead ofsend_to()— no per-packet address overheadrecv()instead ofrecv_from()— kernel filters by connected address- ICMP errors may be delivered (port unreachable on localhost)
DSCP / Traffic Class Setting
- IPv4:
socket.set_tos(dscp_byte)via socket2 - IPv6:
socket2does not directly exposeIPV6_TCLASS; may need rawsetsockoptvialibcorsocket2'sset_traffic_class_v6(check availability) - Applied only before echo phase (after open, before first send_probe)
- Reset to 0 before close packet (per Finding C)
- Set from the negotiated DSCP codepoint (0..=63), shifted left two bits for the local TOS/Traffic Class byte with ECN bits cleared
TTL / Hop Limit
Optional. If configured:
- IPv4:
socket.set_ttl(ttl)via socket2 - IPv6:
socket.set_unicast_hops_v6(hops)via socket2
Socket Cloning for Managed Mode
socket.try_clone() returns a new handle to the same underlying socket. Sender and receiver threads each get a clone. This is safe for connected UDP: one thread sends, one thread receives.
Nonblocking / Read-Timeout Behavior
- Manual mode: caller sets read timeout via
ClientAPI or uses nonblocking mode - Managed mode receiver: uses blocking recv with a moderate timeout (e.g., 100ms) to periodically check cancellation token
- Managed mode sender: uses
thread::sleep/thread::park_timeoutfor timing
Future Ancillary Feature
Feature-gated (ancillary):
- Use
recvmsgsyscall to receive control messages - Extract
IP_TOS/IPV6_TCLASSfrom ancillary data → populatePacketMeta - Extract
SO_TIMESTAMPNS/SCM_TIMESTAMPNS→ populate kernel RX timestamp - Platform-specific: Linux via
rustixorlibc, macOS may differ - NOT MVP — defer entirely
MVP vs Future
| Feature | MVP | Future |
|---|---|---|
| socket2 create/bind/connect | Yes | - |
| IPv4 TOS setting | Yes | - |
| IPv6 Traffic Class setting | Yes (best effort) | - |
| Connected UDP send/recv | Yes | - |
| Socket cloning | Yes | - |
| Read timeout | Yes | - |
| Incoming TC/DSCP/ECN | No (all None) | ancillary feature |
| Kernel RX timestamp | No (None) | ancillary feature |
| DF bit | No | Maybe |
9. Statistics Plan
Architecture
StatsCollector consumes ClientEvent values and maintains running statistics. It does not own threads or channels — it is a passive processor.
pub struct StatsCollector {
config: StatsConfig,
cumulative: CumulativeStats,
rolling_count: Option<RollingCountWindow>,
rolling_time: Option<RollingTimeWindow>,
}
impl StatsCollector {
pub fn new(config: StatsConfig) -> Self;
pub fn process(&mut self, event: &ClientEvent);
pub fn cumulative(&self) -> &CumulativeStats;
pub fn rolling_count(&self) -> Option<&RollingCountWindow>;
pub fn rolling_time(&self) -> Option<&RollingTimeWindow>;
pub fn summary(&self) -> FiniteSummary;
}
Cumulative Stats
Maintained incrementally as events arrive:
packets_sent: u64replies_received: u64duplicates: u64late: u64lost: u64rtt_stats: RunningStats(min/max/mean/variance online)raw_rtt_stats: RunningStatsadjusted_rtt_stats: RunningStats(only from samples where adjusted is Some)ipdv_stats: RunningStatsserver_processing_stats: RunningStatssend_delay_stats: Option<RunningStats>receive_delay_stats: Option<RunningStats>upstream_lost: u64downstream_lost: u64
Rolling Count Window
A fixed-size ring buffer of the last N samples. Recomputes stats over the window on demand.
Rolling Time Window
A time-bounded deque of samples. Evicts samples older than the window duration. Recomputes stats over remaining samples.
Finite Summary
Built from cumulative stats at session end:
- All cumulative fields
- Median RTT (requires storing all RTT values — only for finite mode)
- Standard deviation / variance (from online algorithm)
RTT Stats Breakdown
- Effective RTT stats: uses
rtt.effective()for each sample - Raw RTT stats: uses
rtt.raw - Adjusted RTT stats: uses
rtt.adjustedwhen Some; skip when None
IPDV Stats
IPDV computed as rtt_effective[N] - rtt_effective[N-1] for successive received replies. Only computable when both N and N-1 have replies.
Server Processing Stats
From rtt.server_processing when Some.
Loss/Duplicate/Late Stats
Simple counters. Loss percentage = lost / (sent). Direction breakdown using LossKind.
Median / Stddev / Variance Strategy
- Online mean/variance: Welford's algorithm (standard approach, no attribution needed)
- Median: Requires all values. For finite mode: store all effective RTT values in a Vec, sort at summary time. For continuous/rolling mode: median is either not available or approximate.
- Stddev:
sqrt(variance)from online computation
Handling Edge Cases
- Missing adjusted RTT: Skip in adjusted stats; effective falls back to raw, so effective stats always have data.
- Server processing > raw RTT: Warning already emitted by client. Stats treats adjusted as None for that sample. Effective = raw.
- Loss direction unknown: Counted in total loss and unknown-direction loss.
- Duplicate replies: Counted but not included in RTT stats (per spec: duplicates MUST NOT update original data).
- Late replies: Counted.
LateReplyevent may include RTT if it can be computed — if so, include in late-RTT stats but NOT in primary RTT stats. - Events missing due to subscriber drops: Stats will be inaccurate.
StatsCollectorshould track amissed_eventscounter if the subscriber reports drops. Stats output should note that results are approximate.
10. CLI Plan
Command Structure
irtt-cli [OPTIONS] <server>
irtt-cli --no-test <server>
irtt-cli --continuous <server>
Single binary, single command (no subcommands in MVP). Options control mode and output.
Run Modes
- Finite test (default):
-d 60s -i 1s— run for duration, print summary - No-test / check:
-nor--no-test— open, negotiate, close immediately - Continuous:
--continuousor-C— run until Ctrl-C - Count:
-c 100— send exactly N probes
Output Formats
- human (
--format=human): Human-readable per-line output + summary - machine (
--format=machine): Tab-delimited, all fields, stable format - simple (
--format=simple):timestamp\tseq\tresult - rtt-us (
--format=rtt-us): One value per line: RTT in microseconds or "loss" - jsonl (
--format=jsonl, requiresjsonfeature): One JSON object per line
Default: human for finite tests, machine for continuous
Machine Format Examples
reply 1714320000.123456 1.2.3.4:2112 1 42 8341 8420 79 - - - ok
loss 1714320000.323456 1.2.3.4:2112 1 43 unknown
duplicate 1714320000.423456 1.2.3.4:2112 1 42
late 1714320000.523456 1.2.3.4:2112 1 40 8500
Simple Format Examples
1714320000.123456 42 8341
1714320000.323456 43 loss
RTT-US Format Examples
8341
loss
8120
Awk-Friendly Usage
# Extract RTT values only (rtt-us format)
irtt-cli --format=rtt-us server | grep -v loss
# Average RTT from machine format
irtt-cli --format=machine -d 10s server | awk -F'\t' '/^reply/ { sum+=$6; n++ } END { print sum/n }'
# Filter high-latency replies
irtt-cli --format=simple server | awk -F'\t' '$3 > 10000 { print }'
Delimiter Support
--delimiter=<char>: tab (default), comma, semicolon, pipe, space
Timestamp Support
--timestamp-format=<fmt>: unix (default: 1714320000.123456), iso8601, none
Header Support
--header: Print column header as first line (off by default)
Stats Integration
Default build (stats feature):
- Finite test: print per-probe lines during test, print summary at end
- Continuous: print per-probe lines, optional periodic summary lines
Minimal build (no stats feature):
- Per-probe lines only
- No summary
- Smaller binary
Ctrl-C Handling
Use ctrlc crate:
- Register handler that sets a shared
AtomicBool - On Ctrl-C: call
session.stop() - Wait for
session.join() - Print partial summary if stats available
- Exit with appropriate code
Exit Codes
- 0: Success
- 1: Error (open timeout, server rejection, etc.)
- 2: Partial results (cancelled mid-test)
11. Feature Flags
irtt-proto
| Feature | Default | MVP | Description |
|---|---|---|---|
serde |
No | Deferred | Serialize/Deserialize on protocol types |
irtt-client
| Feature | Default | MVP | Description |
|---|---|---|---|
serde |
No | Deferred | Serialize/Deserialize on event types |
tracing |
No | Deferred | Structured logging |
ancillary |
No | Deferred | Platform control-message APIs |
irtt-stats
| Feature | Default | MVP | Description |
|---|---|---|---|
serde |
No | Deferred | Serialize/Deserialize on stats types |
irtt-cli
| Feature | Default | MVP | Description |
|---|---|---|---|
stats |
Yes | Milestone 7–8 | Include irtt-stats |
json |
No | Deferred | JSONL output |
tracing |
No | Deferred | Pass-through to irtt-client |
ancillary |
No | Deferred | Pass-through to irtt-client |
MVP Features
Milestone 1–5: No optional features needed. Core functionality only.
Milestone 6–8: stats feature implemented and default-enabled.
Post-MVP: serde, json, tracing, ancillary.
Optional real-backend smoke tests are selected by environment variables, not by feature flags.
12. Error Model
irtt-proto::ProtoError
#[derive(Debug, thiserror::Error)]
pub enum ProtoError {
#[error("invalid magic bytes")]
InvalidMagic,
#[error("reserved flag bits set: {0:#04x}")]
ReservedFlagBits(u8),
#[error("buffer too short: need {need}, have {have}")]
BufferTooShort { need: usize, have: usize },
#[error("varint overflow")]
VarintOverflow,
#[error("varint truncated")]
VarintTruncated,
#[error("unknown received stats value: {0}")]
UnknownReceivedStats(i64),
#[error("unknown stamp_at value: {0}")]
UnknownStampAt(i64),
#[error("unknown clock value: {0}")]
UnknownClock(i64),
#[error("invalid HMAC")]
InvalidHmac,
#[error("midpoint and receive/send timestamps mutually exclusive")]
TimestampConflict,
#[error("server fill too long: {0} bytes")]
ServerFillTooLong(usize),
#[error("invalid parameter tag: {0}")]
InvalidParamTag(u64),
}
irtt-client::ClientError
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
// Connection errors
#[error("DNS resolution failed: {0}")]
ResolutionFailed(String),
#[error("socket creation failed: {0}")]
SocketCreation(#[source] io::Error),
#[error("socket bind failed: {0}")]
SocketBind(#[source] io::Error),
#[error("socket connect failed: {0}")]
SocketConnect(#[source] io::Error),
#[error("DSCP/TOS not supported: {0}")]
DscpNotSupported(#[source] io::Error),
// Open phase errors
#[error("open timeout: no reply after {attempts} attempts")]
OpenTimeout { attempts: usize },
#[error("server rejected session")]
ServerRejected,
#[error("protocol version mismatch: expected {expected}, got {got}")]
ProtocolVersionMismatch { expected: i64, got: i64 },
#[error("server restricted parameters")]
ServerRestriction { original: Params, negotiated: Params },
// Protocol errors
#[error("protocol error: {0}")]
Protocol(#[from] ProtoError),
// Runtime errors
#[error("send failed: {0}")]
SendFailed(#[source] io::Error),
#[error("receive failed: {0}")]
RecvFailed(#[source] io::Error),
#[error("malformed packet from active session")]
MalformedSessionPacket,
#[error("unexpected sequence number: {0}")]
UnexpectedSequence(u32),
#[error("short reply: expected {expected}, got {got}")]
ShortReply { expected: usize, got: usize },
#[error("stamp_at mismatch in reply")]
StampAtMismatch,
#[error("clock mismatch in reply")]
ClockMismatch,
// Thread errors (managed mode)
#[error("worker thread panicked")]
ThreadPanic,
#[error("internal channel disconnected")]
ChannelDisconnected,
// Partial outcome
#[error("session ended with error: {0}")]
SessionError(Box<ClientError>),
}
ClientError is used directly in ClientEvent::FatalError and SessionEndReason::Error. No separate ClientErrorKind is needed — ClientError should derive Clone where possible, or the event variants should use Arc<ClientError> for cheap cloning if io::Error prevents Clone.
13. Testing Plan
Pure Protocol Unit Tests (irtt-proto)
- Varint encode/decode: round-trip for 0, 1, -1, 127, 128, -128, i64::MAX, i64::MIN, all verified values from black-box report
- Uvarint encode/decode: round-trip for 0, 1, 127, 128, 16383, u64::MAX
- Parameter encoding: encode known params, compare bytes to black-box captures
- Parameter decoding: decode known bytes, compare to expected params
- Parameter round-trip: encode → decode → compare
- Flags construction and parsing
- Layout calculation: verify minimum sizes for all 6 verified configurations (16, 28, 32, 44, 60, 76 bytes)
- Open request encoding: verify no token field, correct magic/flags
- Close request encoding: verify 12 bytes (no HMAC), 28 bytes (with HMAC)
- HMAC calculation: verify test vector 18.1 (echo request)
- HMAC verification: verify test vector 18.2 (echo reply)
- Field ordering: encode echo request with various field configs, verify byte offsets
- Zigzag edge cases: 0, -1, 1, i64::MAX, i64::MIN
Packet Vector Tests
From docs/test-vectors.md (12 vectors) and protocol spec Section 18 (2 vectors):
- Vector 1: Open request (no HMAC, 24 bytes) — verify magic, flags, param encoding
- Vector 2: Open reply (no HMAC, 32 bytes) — verify token extraction, param decoding
- Vector 3: Echo request (no HMAC, 60 bytes) — verify field layout, zeroed placeholders
- Vector 4: Echo reply (no HMAC, 60 bytes) — verify all field extraction (seq, count, window, timestamps)
- Vector 5: Close request (no HMAC, 12 bytes) — verify minimal close format
- Vector 6: HMAC open request (40 bytes) — verify HMAC computation
- Vector 7: HMAC echo request (76 bytes) — verify HMAC with full header
- Vector 8: HMAC close request (28 bytes) — verify HMAC close format
- Vector 9: No-test open+close request (25 bytes) — verify Open|Close flags, param encoding
- Vector 10: No-test open+close reply (33 bytes) — verify zero token, Open|Reply|Close flags
- Vector 11: Minimal echo packet (16 bytes) — verify absolute minimum size
- Vector 12: Midpoint timestamp echo reply (44 bytes) — verify midpoint field layout
- Spec 18.1: Echo request with HMAC (92 bytes, pattern fill) — verify HMAC, full field layout
- Spec 18.2: Echo reply with HMAC (92 bytes, pattern fill) — verify HMAC, all field values
Client State Tests (irtt-client)
- Session phase transitions: Opening → Active → Draining → Closing → Completed
- Pending probe map: insert, match reply, detect duplicate, detect late, eviction at capacity
- Loss detection: timeout-based loss, multiple-of-interval loss
- Sequence numbering: wire/logical mapping, wrap behavior
- Negotiation policy: accept restrictions, reject restrictions, require-within bounds
- RTT computation: raw, server_processing, adjusted, effective, warning on processing > raw
- Loss direction classification from received window
- Run mode: count completion, duration completion, continuous no-stop
Socket Tests
- (Minimal: socket creation is hard to unit test without a real network stack)
- Verify DSCP setter works on available platform (integration-level)
- Verify socket clone produces working handle
Managed Runner Tests
- Start/stop lifecycle
- Event delivery to subscriber
- Multiple subscribers receive all events
- Subscriber overflow policies (DropNewest, DropOldest)
- Cancellation token stops all threads
- Thread panic is detected and reported
Stats Tests (irtt-stats)
- Process sequence of EchoReply events, verify cumulative min/max/mean
- Process with losses, verify loss counts and percentages
- Process with duplicates, verify duplicate counting (not included in RTT stats)
- Rolling count window: verify correct window behavior
- Rolling time window: verify eviction
- IPDV computation from successive replies
- Edge case: server_processing > raw (adjusted=None, effective=raw)
- Edge case: all packets lost (no RTT stats)
- Edge case: single packet (no IPDV)
- Median computation for finite summary
CLI Formatter Tests
- Machine format: verify tab-delimited output for EchoReply, EchoLoss, DuplicateReply, LateReply
- Simple format: verify timestamp + seq + result
- RTT-us format: verify single value or "loss"
- Delimiter customization
- Timestamp formatting
- Header line generation
- Edge cases: missing fields (no server timestamps → "-")
Optional Real-Backend Smoke Tests
Backend-neutral smoke tests run against the in-process fake backend by default.
Set IRTT_TEST_BACKEND=real to run them against a real irtt server; set
IRTT_BIN to choose a specific binary, otherwise irtt is resolved from
PATH.
- Basic connectivity: open, send probes, receive replies, close
- No-test mode: open+close with zero token
- Parameter negotiation (strict and loose)
- HMAC authentication (correct key)
- HMAC mismatch (expect timeout)
- DSCP setting
- All timestamp modes (none, send, receive, both, midpoint)
- All clock modes (wall, mono, both)
- All received stats modes (none, count, window, both)
- Large packet (1472 bytes)
- Server fill
- IPv6 (if available)
- Finite duration count verification (ceil(d/i))
- Continuous mode start/stop
Black-Box Regression Tests
- Run backend-neutral smoke tests with
IRTT_TEST_BACKEND=realand compare packet counts, RTT ranges, error behavior to known-good baseline - Verify close packet is sent at end of session
Tests Intentionally Deferred
- Ancillary metadata tests (requires ancillary feature)
- ECN-specific tests (requires server --ecn)
- Server close during test (hard to trigger)
- Kernel RX timestamp tests (requires ancillary feature)
- Tokio/async integration tests (not planned for MVP)
- Performance/benchmark tests (post-MVP)
Test Execution Requirements
cargo test: Must pass withoutirttin PATH. Pure unit tests only.IRTT_TEST_BACKEND=real RUST_TEST_THREADS=1 cargo test -p irtt-client: optional real-backend smoke; requiresirttinPATHorIRTT_BIN.tshark: Diagnostic/manual only. Never a CI or test dependency.
14. Milestone Plan
Milestone 0 — Workspace Skeleton
Goal: Create the Cargo workspace with all four crates, license files, and documentation.
Files/modules:
Cargo.toml(workspace)crates/irtt-proto/Cargo.toml,crates/irtt-proto/src/lib.rscrates/irtt-client/Cargo.toml,crates/irtt-client/src/lib.rscrates/irtt-stats/Cargo.toml,crates/irtt-stats/src/lib.rscrates/irtt-cli/Cargo.toml,crates/irtt-cli/src/main.rsLICENSE-APACHE,LICENSE-MIT
Tasks:
- Create workspace
Cargo.tomlwith members - Create each crate with minimal
Cargo.tomland empty lib/main - Add license files
- Add
.gitignore - Verify
cargo check --workspacepasses - Verify
cargo test --workspacepasses (trivially)
Tests: cargo check --workspace
Success criteria: Workspace compiles. All four crates exist. No upstream source present.
Risks: None.
Milestone 1 — irtt-proto
Goal: Complete wire protocol implementation with all packet types and passing test vectors.
Files/modules:
crates/irtt-proto/src/lib.rscrates/irtt-proto/src/varint.rscrates/irtt-proto/src/params.rscrates/irtt-proto/src/header.rscrates/irtt-proto/src/layout.rscrates/irtt-proto/src/hmac.rscrates/irtt-proto/src/error.rscrates/irtt-proto/src/open.rscrates/irtt-proto/src/echo.rscrates/irtt-proto/src/close.rs
Tasks:
- Implement varint/zigzag encode/decode
- Implement parameter model and serialization
- Implement flags and magic constants
- Implement packet layout calculation
- Implement open request encoding
- Implement open reply decoding
- Implement echo request encoding
- Implement echo reply decoding
- Implement close request encoding
- Implement HMAC-MD5 calculation and verification
- Write all unit tests
- Verify test vectors 18.1 and 18.2
Tests:
- All varint round-trips
- All parameter encode/decode tests
- Layout calculations for all 6 verified configurations
- All 14 packet test vectors (12 from
docs/test-vectors.md+ 2 from spec Section 18) - HMAC verification for vectors 6, 7, 8, spec 18.1, spec 18.2
- Edge cases (empty params, unknown tags, max values)
Success criteria: All proto tests pass. All 14 test vectors verified. cargo test -p irtt-proto green.
Risks: Varint encoding mismatch (mitigated by verified test values).
Milestone 2 — Manual Client No-Test / Open
Goal: Client can connect to an irtt server, perform open handshake, and run no-test mode.
Files/modules:
crates/irtt-client/src/lib.rscrates/irtt-client/src/socket.rscrates/irtt-client/src/config.rscrates/irtt-client/src/session.rscrates/irtt-client/src/client.rscrates/irtt-client/src/event.rscrates/irtt-client/src/error.rscrates/irtt-client/src/timing.rs
Tasks:
- Implement
ClientSocketwith socket2 - Implement
ClientConfigandSocketConfig - Implement DNS resolution
- Implement
Client::connect - Implement open request sending with retransmission
- Implement open reply parsing and validation
- Implement negotiation policy
- Implement no-test mode (Open+Close flags)
- Implement
Client::close - Define
ClientEventenum (full definition, even if only SessionStarted/SessionEnded used here) - Define error types
Tests:
- Unit: config validation, negotiation policy logic
- Interop (gated): no-test mode against real server
- Interop (gated): basic open/close cycle
Success criteria: Client::connect + Client::open + no-test mode works against irtt server. Interop test passes.
Risks: Socket option availability on target platforms. IPv6 TOS/TC setting may need platform-specific handling.
Milestone 3 — Manual Finite Probe Events
Goal: Client can send probes, receive replies, and emit events for a finite test.
Files/modules:
crates/irtt-client/src/probe.rs(pending probe map)crates/irtt-client/src/validate.rs- Updates to
client.rs,session.rs,event.rs
Tasks:
- Implement
Client::send_probe - Implement
Client::recv_once - Implement
Client::recv_available - Implement
Client::poll_timeouts - Implement pending probe map (bounded)
- Implement reply matching and validation
- Implement RTT sample computation
- Implement duplicate detection
- Implement late detection
- Implement loss timeout detection
- Implement
RunMode::DurationandRunMode::Countcompletion logic - Implement
OneWayDelaySamplecomputation - Implement
ReceivedStatsSampleextraction - Implement
ServerTimingextraction - Implement
ClientWarning::ServerProcessingExceedsRawRtt
Tests:
- Unit: RTT computation (raw, adjusted, effective)
- Unit: pending probe map operations
- Unit: duplicate/late detection
- Unit: loss timeout detection
- Unit: received window → loss direction classification
- Interop (gated): finite 5s test, verify packet counts, RTT values present
- Interop (gated): verify
ceil(d/i)packet count formula
Success criteria: Manual client can run a complete finite test and produce correct events. Interop tests pass.
Risks: Timing accuracy. Potential edge cases in recv_once blocking behavior.
Milestone 4 — Minimal CLI Stream Mode
Goal: Working CLI binary that can run a test and print results in machine/simple/rtt-us format.
Files/modules:
crates/irtt-cli/src/main.rscrates/irtt-cli/src/args.rscrates/irtt-cli/src/format.rscrates/irtt-cli/src/run.rscrates/irtt-cli/src/signal.rs
Tasks:
- Implement CLI argument parsing with clap
- Implement machine output formatter
- Implement simple output formatter
- Implement rtt-us output formatter
- Implement basic run loop (using manual Client initially, or managed if M5 is done)
- Implement Ctrl-C handling
- Implement delimiter support
- Implement timestamp formatting
- Implement header option
Tests:
- Unit: formatter tests for each format
- Unit: delimiter substitution
- Unit: timestamp formatting
- Integration: run CLI against irtt server, verify output format
- Verify awk-friendly examples from design doc work
Success criteria: irtt-cli <server> runs a test and prints machine-format output. Ctrl-C works.
Risks: None significant. Straightforward formatting work.
Milestone 5 — Managed Runner
Goal: ManagedClient with coordinator/sender/receiver threads and event fanout.
Files/modules:
crates/irtt-client/src/managed/mod.rscrates/irtt-client/src/managed/coordinator.rscrates/irtt-client/src/managed/sender.rscrates/irtt-client/src/managed/receiver.rscrates/irtt-client/src/managed/hub.rscrates/irtt-client/src/managed/cancellation.rs
Tasks:
- Implement
CancellationToken - Implement
EventHubwith subscriber management - Implement
SubscriberConfigand overflow policies - Implement sender/pacer thread with absolute scheduling
- Implement receiver thread
- Implement coordinator thread (correlate, compute RTT, emit events)
- Implement
ManagedClient::start(open on calling thread, then spawn) - Implement
ManagedClientSession::subscribe - Implement
ManagedClientSession::stopandjoin - Implement continuous mode
- Implement thread panic detection
- Implement graceful shutdown (close packet)
Tests:
- Unit: EventHub fanout to multiple subscribers
- Unit: SubscriberOverflow policies
- Unit: CancellationToken
- Integration: ManagedClient finite test, verify events arrive at subscriber
- Integration: ManagedClient continuous mode, stop, verify clean shutdown
- Interop (gated): managed client against real server
Success criteria: ManagedClient runs tests, delivers events to subscribers, shuts down cleanly.
Risks: Thread coordination complexity. Potential deadlocks if channels are misused. Careful testing required.
Milestone 6 — irtt-stats
Goal: Statistics crate that consumes events and produces cumulative/rolling/summary stats.
Files/modules:
crates/irtt-stats/src/lib.rscrates/irtt-stats/src/collector.rscrates/irtt-stats/src/running.rscrates/irtt-stats/src/rolling.rscrates/irtt-stats/src/summary.rscrates/irtt-stats/src/median.rs
Tasks:
- Implement
RunningStats(online mean/variance) - Implement
StatsCollectorevent processing - Implement cumulative stats
- Implement rolling count window
- Implement rolling time window
- Implement IPDV computation
- Implement loss/duplicate/late counting
- Implement finite summary with median
- Handle edge cases (missing adjusted, processing > raw, all lost)
Tests:
- Unit: RunningStats with known sequences
- Unit: process events, verify min/max/mean/variance
- Unit: rolling window behavior
- Unit: IPDV from successive RTTs
- Unit: median computation
- Unit: edge cases (empty, single, all lost)
Success criteria: StatsCollector produces correct statistics from event streams.
Risks: Floating-point precision. Median storage for long tests (acceptable for finite mode).
Milestone 7 — Full/Default CLI Stats Integration
Goal: Default CLI build includes stats. Finite tests show summary. Minimal build still works.
Files/modules:
- Updates to
crates/irtt-cli/src/run.rs crates/irtt-cli/src/format.rs(summary formatting)crates/irtt-cli/Cargo.toml(feature flags)
Tasks:
- Wire StatsCollector into CLI run loop as event subscriber
- Implement human summary formatting
- Implement stats display for finite tests
- Optional periodic stats in continuous mode
- Verify
--no-default-featuresbuild works without stats - Implement human output format (with summary)
Tests:
- Integration: finite test with summary output
- Integration: minimal build compiles and runs without stats
- Unit: human summary formatting
Success criteria: cargo build -p irtt-cli includes stats. cargo build -p irtt-cli --no-default-features works without.
Risks: None significant.
Milestone 8 — Compatibility Features
Status: Complete as of M8 cleanup.
Goal: Full protocol compatibility: HMAC, all timestamp modes, all received stats modes, server fill, DSCP, TTL/hop limit, and large packets.
Files/modules:
- Updates across irtt-client and irtt-cli
Tasks:
- HMAC backend-neutral smoke testing (correct key, wrong key, no key)
- All StampAt modes (none, send, receive, both, midpoint)
- All Clock modes (wall, mono, both)
- All ReceivedStats modes (none, count, window, both)
- Server fill
- DSCP / Traffic Class setting
- TTL / hop limit
- Large packet sizes
- Backend-neutral fake/real smoke coverage
Tests:
- Self-contained: timestamp, clock, received-stats, HMAC, server-fill, DSCP, TTL/hop-limit, and large-packet behavior
- Optional real backend: basic open/echo/close, HMAC, received-stats,
timestamp, clock, TTL config smoke, and large-packet smoke via
IRTT_TEST_BACKEND=real - DSCP/TTL socket-option unit tests verify local socket configuration only; real-backend smoke does not claim packet marking observation
Success criteria: M8 compatibility behavior is covered by self-contained
tests, and backend-neutral smoke tests can be explicitly run against a real
irtt server.
Risks: Platform-specific socket option issues (IPv6 TC, DF bit).
Milestone 9 — Advanced Networking
Goal: Ancillary receive metadata for ECN/DSCP/kernel RX timestamps.
Files/modules:
crates/irtt-client/src/ancillary.rs(feature-gated)- Platform-specific code
Tasks:
- Implement recvmsg wrapper (Linux)
- Extract IP_TOS / IPV6_TCLASS from ancillary data
- Extract kernel RX timestamp
- Populate PacketMeta
- macOS support (if feasible)
- Test with
irtt server --ecn
Tests:
- Integration: verify ancillary data extraction on Linux
- Interop: verify ECN behavior with server --ecn
- Unit: ancillary data parsing
Success criteria: PacketMeta populated with real values on supported platforms.
Risks: High platform variability. macOS ancillary API differences. ECN behavior not fully verified.
15. Open Questions
Q1: Server Close During Active Test (Section 19.3)
Why it matters: If the server can set Close flag on echo replies mid-test, the client must detect and handle this gracefully.
Blocks implementation: No. Defensive handling is straightforward.
Suggested behavior: Check for Close flag in every echo reply. If set, process the reply normally, emit SessionEnded with reason ServerClosed. This is safe regardless of whether the behavior actually occurs.
Black-box test suggestion: Run a very long test exceeding the server's max duration. If the server closes the session, observe the packet.
Q2: Send Timestamp Capture Timing (Section 19.6)
Why it matters: Affects RTT accuracy.
Blocks implementation: No. This is an implementation choice.
Recommended behavior: Capture Instant::now() + SystemTime::now() immediately before send(). This ensures the measured RTT includes local send/enqueue overhead, which is more conservative for latency-control consumers like sqm-autorate-rust. The receive timestamp remains captured immediately after recv() returns.
Q3: RTT When Server Processing > Raw RTT (Section 19.12)
Why it matters: Negative adjusted RTT is nonsensical.
Blocks implementation: No. The Rust design already specifies the correct handling.
Recommended behavior: Set adjusted = None, emit ClientWarning::ServerProcessingExceedsRawRtt. effective() falls back to raw. This is already in the design.
Q4: Upstream Server --ecn
Why it matters: Required for full ECN compatibility testing.
Blocks implementation: No. ECN is a deferred ancillary feature (Milestone 9).
Suggested black-box test: Run irtt server --ecn and capture packets with tshark. Observe whether the server sets ECN bits in IP header. Test with --dscp values that include ECN bits.
Q5: Continuous Session Duration Semantics
Why it matters: If server negotiates a finite duration for a continuous client, the client must handle session expiry.
Blocks implementation: No. ReconnectPolicy covers this.
Recommended behavior: Default ReconnectPolicy::Never for library. CLI continuous mode uses ReconnectPolicy::OnServerClose { delay: 1s }. If the server closes, reconnect after delay.
Q6: Exact Feature-Gating of Stats/JSON/Ancillary
Why it matters: Build size and dependency management.
Blocks implementation: No. Feature flags are well-defined in the design.
Recommended behavior: Follow the design document exactly. stats is default on for irtt-cli. All others are opt-in.
Q7: DropOldest Subscriber Overflow with Flume — RESOLVED
Decision: Deferred from MVP. Initial overflow policies are DropNewest, Block, and Disconnect. DropOldest may be added post-MVP using a drain-one-and-retry approach or a ring-buffer channel.
16. Risk Register
| Risk | Impact | Likelihood | Mitigation | Owner/Artifact |
|---|---|---|---|---|
| Clean-room contamination | Critical — legal/licensing | Low | Implementation agent reads only clean artifacts. No upstream source in repo. Documented boundary in CLEANROOM_NOTES.md | Process / all crates |
| Protocol wire format mismatch | High — interop failure | Low | Test vectors verified. Black-box captures available. Interop tests in CI. | irtt-proto |
| Varint/zigzag encoding mismatch | High — open/negotiation fails | Very Low | Independently verified with multiple value samples. Standard LEB128/zigzag. | irtt-proto |
| HMAC computation mismatch | High — authentication fails | Very Low | Two test vectors verified. Four packet types verified against captures. | irtt-proto |
| Timing accuracy insufficient | Medium — RTT measurements off | Low | Use monotonic clock. Absolute scheduling. Timestamp close to syscall. TimerMode::Precise available as opt-in. | irtt-client |
| Router resource exhaustion | Medium — OOM on OpenWrt | Low | Bounded pending map. No mandatory sample retention. Feature-gated stats. Minimal build path. | irtt-client, irtt-cli |
| Event subscriber backpressure delays sender | High — timing disruption | Low | Sender thread never touches event hub. Only coordinator fans out. Coordinator is not timing-critical. | irtt-client (managed) |
| Stats/event loss from subscriber drops | Medium — inaccurate stats | Medium | Document limitation. Track drop count. Stats output marks approximate results. | irtt-stats |
| Managed runner deadlock | High — hang | Low | Careful channel sizing. CancellationToken as escape hatch. Thread panic detection. Thorough testing. | irtt-client (managed) |
| ECN/ancillary portability | Medium — feature unavailable | Medium | Feature-gated. Deferred to Milestone 9. Platform-specific code isolated. Graceful fallback to None. | irtt-client (ancillary) |
| CLI format instability | Medium — downstream breakage | Low | Machine format fields are positional and documented. New fields added at end only. | irtt-cli |
| IPv6 Traffic Class socket option | Low — IPv6 DSCP may not work | Medium | Use socket2 if available. Fall back to libc setsockopt. Test on target platforms. | irtt-client |
| Server behavior divergence across versions | Low — untested server behavior | Low | Target irtt 0.9.1 only. Black-box tests as regression suite. | all |
| flume DropOldest not native | Low — subscriber policy gap | Medium | Deferred from MVP. DropNewest/Block/Disconnect available. | irtt-client (managed) |
17. Implementation Agent Instructions
What to implement first
- Milestone 0: Workspace skeleton. Get
cargo check --workspacepassing with empty crates. - Milestone 1:
irtt-proto. All wire format logic. This is pure, testable, and has no external dependencies beyond crypto crates. Verify test vectors before moving on. - Milestone 2: Manual
Clientopen/close/no-test. First real interop with an irtt server. - Milestone 3: Manual
Clientprobe sending/receiving. Full event model.
What NOT to implement first
- Do NOT implement
ManagedClientbefore the manualClientworks (milestones 2–3 come before 5). - Do NOT implement
irtt-statsbefore the client produces correct events (milestone 6 comes after 5). - Do NOT implement ancillary networking (Milestone 9) during initial implementation.
- Do NOT implement serde/json/tracing features during initial milestones.
- Do NOT implement human output format before machine format works.
- Do NOT implement
ReconnectPolicylogic before basic continuous mode works.
What files to read
docs/IRTT_CLIENT_PROTOCOL_SPEC.md— normative protocol referencedocs/BLACKBOX_VERIFICATION_REPORT.md— verified protocol details and packet capturesdocs/RUST_DESIGN.md— architecture and product goalsdocs/test-vectors.md— 12 packet test vectors from real capturesdocs/CLEANROOM_NOTES.md— clean-room boundary (read once for awareness)IMPLEMENTATION_PLAN.md(this file) — detailed implementation guidance
What files NOT to read
- Any upstream IRTT source code (Go files, Go tests, Go modules)
- Any files in
captures/directory (these are diagnostic artifacts from the spec agent, not implementation inputs — use the verified findings in the black-box report instead) - Any contaminated notes or transcripts
- Any GPL-licensed material
Clean-room boundaries to maintain
- Never search for, open, or read upstream IRTT source files
- Never search online for IRTT implementation details
- Never ask for contaminated notes or transcripts
- Never copy or adapt upstream type names, function names, or module layout
- Design all internal names independently based on Rust conventions
- Use only the clean protocol spec and black-box report for protocol truth
- When uncertain about protocol behavior, mark as open question and suggest a black-box test — do NOT guess from upstream patterns
How to report uncertainties
- If a protocol detail is unclear: add a
// OPEN: <description>comment and note it in test output - If a behavior differs from spec expectation during interop testing: document the discrepancy, do not silently adapt
- If a platform-specific feature is unavailable: use
cfgattributes, provide graceful fallback, and document
How to structure commits/milestones
- One commit per logical unit of work (e.g., "implement varint encoding" or "add open request encoding")
- Tag milestone completion with a clear commit message (e.g., "milestone 1: irtt-proto complete")
- At every commit, ensure:
cargo fmt --check --workspacecargo clippy --workspace --all-targetscargo test --workspace
- At compatibility milestone boundaries, run backend-neutral smoke tests with
IRTT_TEST_BACKEND=realwhen a realirttbinary is available
First work order
The first implementation agent task should be:
Implement Milestone 0 and Milestone 1 only. Create the Cargo workspace and complete irtt-proto. Do not implement sockets, client runtime, stats, CLI behavior, managed runner, serde, tracing, ancillary networking, or interop tests yet.
Success means:
cargo check --workspacepassescargo test -p irtt-protopasses- All 14 packet vectors pass
- No upstream source or tests are present
18. Design Brief Amendments
The following amendments to RUST_DESIGN.md have been incorporated into this plan. They are listed here for traceability.
Incorporated: Client::open return type
Changed from Result<Vec<ClientEvent>, ClientError> to Result<OpenOutcome, ClientError>. See Section 5.
Incorporated: recv_once return type
Changed from Result<Option<Vec<ClientEvent>>, ClientError> to Result<Vec<ClientEvent>, ClientError> with empty Vec meaning "no data available." See Section 5.
Incorporated: EchoSent event omitted
EchoSent is not defined in MVP. Can be added later behind a debug/tracing option.
Incorporated: DropOldest deferred
DropOldest subscriber overflow is deferred from MVP. Initial overflow policies: DropNewest, Block, Disconnect. See Section 6.
Incorporated: NegotiationPolicy simplified
Changed from three-variant (AcceptServerRestrictions/RequireExact/RequireWithin) to two-variant (Strict/Loose). See Section 5.
Incorporated: ClientErrorKind removed
FatalError and SessionEndReason::Error use ClientError directly. No separate ClientErrorKind enum.
Remaining: irtt-stats dependency on irtt-client
Keep as-is. The dependency is on the types, not the runtime. Rust's dead-code elimination handles this. If binary size becomes an issue, extract types later.
Remaining: Clock value 0
The spec defines Clock enum values as 1=Wall, 2=Monotonic, 3=Both. Value 0 is not defined. If StampAt is None, ignore Clock value. If StampAt is non-None and Clock is 0, treat as an error or default to Wall. Document the decision.
19. Final Recommendation
Proceed.
The clean artifacts are thorough and internally consistent. The protocol spec has been verified against a real irtt 0.9.1 server. The Rust design is well-structured and addresses all major architectural concerns. The three remaining open questions (server close mid-test, timestamp capture timing, processing > raw RTT) do not block implementation — defensive handling is specified for all three.
No major blockers exist. The recommended minor amendments (Section 18) are quality-of-life improvements, not structural issues.
First milestone to implement: Milestone 0 (workspace skeleton), immediately followed by Milestone 1 (irtt-proto). The protocol crate is pure, heavily testable, and establishes the foundation for everything else.