Skip to main content

irtt_client/
client.rs

1use std::{
2    io,
3    net::{SocketAddr, UdpSocket},
4    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
5};
6
7use irtt_proto::{
8    close::CloseRequest, decode_echo_reply, echo_packet_len, encode_close_request,
9    encode_echo_request, encode_open_request, flags, EchoReply, EchoRequest, OpenReply,
10    OpenRequest, Params, ServerFill, TimestampFields, PROTOCOL_VERSION,
11};
12
13use crate::{
14    config::{
15        ClientConfig, RecvBudget, RunMode, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES,
16        MAX_UDP_PAYLOAD_LENGTH,
17    },
18    error::ClientError,
19    event::{
20        ClientEvent, OneWayDelaySample, OpenOutcome, ReceivedStatsSample, RttSample, ServerTiming,
21        SignedDuration, WarningKind,
22    },
23    metadata::ReceiveMeta,
24    probe::{CompletedSet, PendingMap, PendingProbe, TimedOutMap},
25    receive::recv_datagram,
26    session::{negotiate_params, ActiveSession, ClientPhase, NegotiatedParams},
27    socket::{connect_udp_socket, resolve_remote, validate_open_timeouts},
28    socket_options::{apply_dscp_to_socket, clear_dscp_on_socket},
29    timing::ClientTimestamp,
30};
31
32const MAX_OPEN_PACKET_SIZE: usize = 512;
33const MIN_RECV_BUFFER_SIZE: usize = 2048;
34
35/// Low-level synchronous IRTT client.
36///
37/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
38/// session, send probes, receive replies, poll timeouts, and close. Callers
39/// that do not need to own this loop can use [`ManagedClient`](crate::ManagedClient)
40/// instead.
41#[derive(Debug)]
42pub struct Client {
43    config: ClientConfig,
44    socket: UdpSocket,
45    remote: SocketAddr,
46    requested: Params,
47    negotiated: Option<NegotiatedParams>,
48    phase: ClientPhase,
49    session: Option<ActiveSession>,
50    recv_buffer: Vec<u8>,
51}
52
53impl Client {
54    /// Resolve the configured server and create a connected UDP socket.
55    ///
56    /// This validates local configuration and prepares the open request, but it
57    /// does not contact the server. Call [`open`](Self::open) to perform the
58    /// IRTT open exchange.
59    pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
60        validate_open_timeouts(&config.open_timeouts)?;
61        if config.max_pending_probes == 0 {
62            return Err(ClientError::InvalidConfig {
63                reason: "max_pending_probes must be greater than zero".to_owned(),
64            });
65        }
66        if config.probe_timeout == Duration::ZERO {
67            return Err(ClientError::InvalidConfig {
68                reason: "probe_timeout must be greater than zero".to_owned(),
69            });
70        }
71        let remote = resolve_remote(&config)?;
72        let socket = connect_udp_socket(&config.socket_config, remote)?;
73        let requested = params_from_config(&config)?;
74
75        Ok(Self {
76            config,
77            socket,
78            remote,
79            requested,
80            negotiated: None,
81            phase: ClientPhase::Connected,
82            session: None,
83            recv_buffer: vec![0_u8; MIN_RECV_BUFFER_SIZE],
84        })
85    }
86
87    /// Perform the IRTT open exchange.
88    ///
89    /// On success, returns the negotiated open outcome and transitions the
90    /// client into either an open probe session or completed no-test state.
91    /// Open attempts use [`ClientConfig::open_timeouts`].
92    pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
93        match self.phase {
94            ClientPhase::Connected => {}
95            ClientPhase::Open { .. } => return Err(ClientError::AlreadyOpen),
96            ClientPhase::Closed => return Err(ClientError::AlreadyClosed),
97            ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
98        }
99
100        let outcome = (|| -> Result<OpenOutcome, ClientError> {
101            let request = OpenRequest {
102                params: self.requested.clone(),
103                close: self.config.run_mode == RunMode::NoTest,
104            };
105
106            let packet = encode_open_request(&request, self.config.hmac_key.as_deref())?;
107            let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
108
109            for timeout in &self.config.open_timeouts {
110                self.socket.set_read_timeout(Some(*timeout))?;
111                self.socket.send(&packet)?;
112
113                match self.socket.recv(&mut buf) {
114                    Ok(size) => {
115                        let reply = irtt_proto::decode_open_reply(
116                            &buf[..size],
117                            self.config.hmac_key.as_deref(),
118                        )?;
119
120                        return self.accept_open_reply(reply, ClientTimestamp::now());
121                    }
122                    Err(err)
123                        if matches!(
124                            err.kind(),
125                            io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
126                        ) => {}
127                    Err(err) => return Err(ClientError::Socket(err)),
128                }
129            }
130
131            Err(ClientError::OpenTimeout)
132        })();
133
134        let restore = self
135            .socket
136            .set_read_timeout(self.config.socket_config.recv_timeout);
137
138        match (outcome, restore) {
139            (Ok(outcome), Ok(())) => Ok(outcome),
140            (Ok(_), Err(source)) => Err(ClientError::ReadTimeoutRestore { source }),
141            (Err(err), Ok(())) => Err(err),
142            (Err(err), Err(_)) => Err(err),
143        }
144    }
145
146    /// Send a close request and emit a [`ClientEvent::SessionClosed`] event.
147    ///
148    /// The close event means the client has sent its close packet and stopped
149    /// tracking the session locally; it is not a server acknowledgement.
150    pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
151        let token = match self.phase {
152            ClientPhase::Open { token } => token,
153            ClientPhase::Closed => return Err(ClientError::AlreadyClosed),
154            ClientPhase::Connected | ClientPhase::NoTestCompleted => {
155                return Err(ClientError::NotOpen)
156            }
157        };
158
159        clear_dscp_on_socket(&self.socket, self.remote)?;
160        let packet =
161            encode_close_request(&CloseRequest { token }, self.config.hmac_key.as_deref())?;
162        self.socket.send(&packet)?;
163        self.phase = ClientPhase::Closed;
164        if let Some(session) = self.session.as_mut() {
165            session.timed_out.clear();
166        }
167        self.session = None;
168
169        Ok(vec![ClientEvent::SessionClosed {
170            remote: self.remote,
171            token,
172            at: ClientTimestamp::now(),
173        }])
174    }
175
176    /// Return the monotonic deadline for the next probe send, if another probe
177    /// is scheduled.
178    pub fn next_send_deadline(&self) -> Option<Instant> {
179        let session = self.session.as_ref()?;
180        if session.sending_done {
181            return None;
182        }
183        Some(session.next_send_at)
184    }
185
186    /// Return the local timeout used to classify pending probes as lost.
187    pub fn probe_timeout(&self) -> Duration {
188        self.config.probe_timeout
189    }
190
191    /// Send one echo probe if the negotiated run is still active.
192    ///
193    /// Returns an `EchoSent` event when a probe is sent. Returns an empty event
194    /// list when the run duration has elapsed and no further probe should be
195    /// sent.
196    pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
197        self.send_probe_inner(None)
198    }
199
200    /// Receive and classify at most one datagram from the socket.
201    ///
202    /// Returns an empty event list when the socket read would block or times
203    /// out. Malformed or unrelated datagrams are reported as warning events.
204    pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
205        self.recv_once_inner(None)
206    }
207
208    fn send_probe_inner(
209        &mut self,
210        override_ts: Option<ClientTimestamp>,
211    ) -> Result<Vec<ClientEvent>, ClientError> {
212        let token = match self.phase {
213            ClientPhase::Open { token } => token,
214            ClientPhase::Closed => return Err(ClientError::AlreadyClosed),
215            ClientPhase::Connected => return Err(ClientError::NotOpen),
216            ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
217        };
218
219        let session = self
220            .session
221            .as_mut()
222            .expect("session must exist when phase is Open");
223
224        if session.sending_done {
225            return Ok(vec![]);
226        }
227
228        let now = override_ts.unwrap_or_else(ClientTimestamp::now);
229
230        if let Some(end) = session.end_mono {
231            if now.mono >= end {
232                session.sending_done = true;
233                return Ok(vec![]);
234            }
235        }
236
237        session.pending.check_capacity()?;
238
239        let negotiated = self
240            .negotiated
241            .as_ref()
242            .expect("negotiated must exist when Open");
243
244        let wire_seq = session.next_wire_seq;
245        let scheduled_at = session.next_send_at;
246
247        let request = EchoRequest {
248            token,
249            sequence: wire_seq,
250            params: negotiated.params.clone(),
251            payload: vec![],
252        };
253        let packet = encode_echo_request(&request, self.config.hmac_key.as_deref())?;
254        let sent_at = override_ts.unwrap_or_else(ClientTimestamp::now);
255        let send_call_start = Instant::now();
256        let bytes = self.socket.send(&packet)?;
257        let send_call = send_call_start.elapsed();
258        let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
259
260        let session = self
261            .session
262            .as_mut()
263            .expect("session must exist when phase is Open");
264
265        let pending = PendingProbe {
266            wire_seq,
267            sent_at,
268            timeout_at: sent_at
269                .mono
270                .checked_add(self.config.probe_timeout)
271                .ok_or(ClientError::DurationOverflow)?,
272        };
273        session.pending.insert(pending)?;
274
275        session.next_wire_seq = session.next_wire_seq.wrapping_add(1);
276        session.packets_sent =
277            session
278                .packets_sent
279                .checked_add(1)
280                .ok_or(ClientError::CounterOverflow {
281                    counter: "packets_sent",
282                })?;
283
284        let negotiated = self
285            .negotiated
286            .as_ref()
287            .expect("negotiated must exist when Open");
288        let interval_ns = u64::try_from(negotiated.params.interval_ns)
289            .expect("validated positive negotiated interval");
290        let session = self
291            .session
292            .as_mut()
293            .expect("session must exist when phase is Open");
294        session.next_send_at =
295            next_probe_deadline(session.start_mono, interval_ns, session.packets_sent)?;
296
297        if let Some(end) = session.end_mono {
298            if session.next_send_at >= end {
299                session.sending_done = true;
300            }
301        }
302
303        Ok(vec![ClientEvent::EchoSent {
304            seq: wire_seq,
305            remote: self.remote,
306            scheduled_at,
307            sent_at,
308            bytes,
309            send_call,
310            timer_error,
311        }])
312    }
313
314    fn recv_once_inner(
315        &mut self,
316        override_ts: Option<ClientTimestamp>,
317    ) -> Result<Vec<ClientEvent>, ClientError> {
318        match self.phase {
319            ClientPhase::Open { .. } => {}
320            ClientPhase::Closed => return Err(ClientError::AlreadyClosed),
321            ClientPhase::Connected => return Err(ClientError::NotOpen),
322            ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
323        }
324
325        let datagram = match recv_datagram(&self.socket, &mut self.recv_buffer) {
326            Ok(datagram) => datagram,
327            Err(err)
328                if matches!(
329                    err.kind(),
330                    io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
331                ) =>
332            {
333                return Ok(vec![]);
334            }
335            Err(err) => return Err(ClientError::Socket(err)),
336        };
337
338        let now = override_ts.unwrap_or(datagram.received_at);
339        let packet_len = datagram.len;
340        let Some(reply) = self.decode_received_packet(&self.recv_buffer[..packet_len]) else {
341            return Ok(vec![ClientEvent::Warning {
342                kind: WarningKind::MalformedOrUnrelatedPacket,
343                message: "dropped malformed or unrelated packet".to_owned(),
344                at: now,
345            }]);
346        };
347        self.process_echo_reply(reply, packet_len, now, datagram.meta)
348    }
349
350    /// Receive and classify datagrams until a receive produces no events or the
351    /// receive budget is exhausted.
352    pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
353        let mut all_events = Vec::new();
354        for _ in 0..budget.max_packets {
355            let events = self.recv_once()?;
356            if events.is_empty() {
357                break;
358            }
359            all_events.extend(events);
360        }
361        Ok(all_events)
362    }
363
364    /// Polls for probes that have timed out as of the current monotonic time.
365    pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
366        self.poll_timeouts_at(Instant::now())
367    }
368
369    /// Polls for probes that have timed out as of `now`.
370    ///
371    /// This is useful for callers that drive `Client` from their own event loop and
372    /// want timeout decisions to use the same sampled `Instant` as their scheduling
373    /// logic.
374    ///
375    /// `now` is monotonic time only; wall-clock time is not used for timeout expiry.
376    pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
377        match self.phase {
378            ClientPhase::Open { .. } => {}
379            ClientPhase::Closed => return Err(ClientError::AlreadyClosed),
380            ClientPhase::Connected => return Err(ClientError::NotOpen),
381            ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
382        }
383
384        let session = self
385            .session
386            .as_mut()
387            .expect("session must exist when phase is Open");
388
389        let expired = session.pending.drain_expired(now);
390        let mut events = Vec::with_capacity(expired.len());
391        for probe in expired {
392            events.push(ClientEvent::EchoLoss {
393                seq: probe.wire_seq,
394                sent_at: probe.sent_at,
395                timeout_at: probe.timeout_at,
396            });
397            session.timed_out.insert(probe);
398        }
399
400        Ok(events)
401    }
402
403    /// Return whether the current run has completed.
404    ///
405    /// A normal run is complete once no more probes will be sent and all
406    /// pending probes have either replied or timed out. No-test and closed
407    /// sessions are also considered complete.
408    pub fn is_run_complete(&self) -> bool {
409        let Some(session) = self.session.as_ref() else {
410            return matches!(
411                self.phase,
412                ClientPhase::Closed | ClientPhase::NoTestCompleted
413            );
414        };
415        session.sending_done && session.pending.len() == 0
416    }
417
418    pub(crate) fn has_timed_out_metadata(&self) -> bool {
419        self.session
420            .as_ref()
421            .is_some_and(|session| session.timed_out.len() > 0)
422    }
423
424    pub(crate) fn packets_sent(&self) -> u64 {
425        self.session
426            .as_ref()
427            .map_or(0, |session| session.packets_sent)
428    }
429
430    fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
431        let negotiated = self
432            .negotiated
433            .as_ref()
434            .expect("negotiated must exist when Open");
435
436        decode_echo_reply(packet, &negotiated.params, self.config.hmac_key.as_deref()).ok()
437    }
438
439    fn process_echo_reply(
440        &mut self,
441        reply: EchoReply,
442        packet_len: usize,
443        now: ClientTimestamp,
444        meta: ReceiveMeta,
445    ) -> Result<Vec<ClientEvent>, ClientError> {
446        let token = match self.phase {
447            ClientPhase::Open { token } => token,
448            _ => unreachable!(),
449        };
450        if reply.token != token {
451            return Ok(vec![ClientEvent::Warning {
452                kind: WarningKind::WrongToken,
453                message: format!(
454                    "dropped reply with wrong token: expected {token:#x}, got {:#x}",
455                    reply.token
456                ),
457                at: now,
458            }]);
459        }
460
461        let session = self.session.as_mut().expect("session must exist when Open");
462
463        let wire_seq = reply.sequence;
464
465        if let Some(pending) = session.pending.remove(wire_seq) {
466            let rtt = compute_rtt(&pending.sent_at, &now, &reply.timestamps);
467            let server_timing = build_server_timing(&reply.timestamps);
468            let one_way = compute_one_way(&pending.sent_at, &now, &reply.timestamps);
469            let received_stats = build_received_stats(&reply);
470            let is_late = session
471                .highest_received_seq
472                .is_some_and(|h| sequence_is_before(wire_seq, h));
473            let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
474
475            update_highest_received(&mut session.highest_received_seq, wire_seq);
476            session.completed.insert(wire_seq);
477
478            let mut events = Vec::new();
479            if is_late {
480                events.push(ClientEvent::LateReply {
481                    seq: wire_seq,
482                    highest_seen,
483                    remote: self.remote,
484                    sent_at: Some(pending.sent_at),
485                    received_at: now,
486                    rtt: Some(rtt),
487                    server_timing,
488                    one_way,
489                    received_stats,
490                    bytes: packet_len,
491                    packet_meta: meta.into(),
492                });
493            } else {
494                events.push(ClientEvent::EchoReply {
495                    seq: wire_seq,
496                    remote: self.remote,
497                    sent_at: pending.sent_at,
498                    received_at: now,
499                    rtt,
500                    server_timing,
501                    one_way,
502                    received_stats,
503                    bytes: packet_len,
504                    packet_meta: meta.into(),
505                });
506            }
507            if flags::has(reply.flags, flags::FLAG_CLOSE) {
508                self.close_from_peer(token, now, &mut events)?;
509            }
510            Ok(events)
511        } else if session.completed.contains(wire_seq) {
512            update_highest_received(&mut session.highest_received_seq, wire_seq);
513            Ok(vec![ClientEvent::DuplicateReply {
514                seq: wire_seq,
515                remote: self.remote,
516                received_at: now,
517                bytes: packet_len,
518            }])
519        } else if let Some(timed_out) = session.timed_out.remove(wire_seq) {
520            let rtt = compute_rtt(&timed_out.sent_at, &now, &reply.timestamps);
521            let server_timing = build_server_timing(&reply.timestamps);
522            let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
523            let received_stats = build_received_stats(&reply);
524            let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
525            update_highest_received(&mut session.highest_received_seq, wire_seq);
526            session.completed.insert(wire_seq);
527
528            let mut events = vec![ClientEvent::LateReply {
529                seq: wire_seq,
530                highest_seen,
531                remote: self.remote,
532                sent_at: Some(timed_out.sent_at),
533                received_at: now,
534                rtt: Some(rtt),
535                server_timing,
536                one_way,
537                received_stats,
538                bytes: packet_len,
539                packet_meta: meta.into(),
540            }];
541            if flags::has(reply.flags, flags::FLAG_CLOSE) {
542                self.close_from_peer(token, now, &mut events)?;
543            }
544            Ok(events)
545        } else if session
546            .highest_received_seq
547            .is_some_and(|h| sequence_is_before(wire_seq, h))
548        {
549            Ok(vec![ClientEvent::LateReply {
550                seq: wire_seq,
551                highest_seen: session.highest_received_seq.unwrap(),
552                remote: self.remote,
553                sent_at: None,
554                received_at: now,
555                rtt: None,
556                server_timing: build_server_timing(&reply.timestamps),
557                one_way: None,
558                received_stats: build_received_stats(&reply),
559                bytes: packet_len,
560                packet_meta: meta.into(),
561            }])
562        } else {
563            Ok(vec![ClientEvent::Warning {
564                kind: WarningKind::UntrackedReply,
565                message: format!(
566                    "dropped reply with untracked seq {wire_seq} (no pending or completed entry)"
567                ),
568                at: now,
569            }])
570        }
571    }
572
573    fn close_from_peer(
574        &mut self,
575        token: u64,
576        now: ClientTimestamp,
577        events: &mut Vec<ClientEvent>,
578    ) -> Result<(), ClientError> {
579        clear_dscp_on_socket(&self.socket, self.remote)?;
580        self.phase = ClientPhase::Closed;
581        if let Some(session) = self.session.as_mut() {
582            session.timed_out.clear();
583        }
584        self.session = None;
585        events.push(ClientEvent::SessionClosed {
586            remote: self.remote,
587            token,
588            at: now,
589        });
590        Ok(())
591    }
592
593    fn accept_open_reply(
594        &mut self,
595        reply: OpenReply,
596        now: ClientTimestamp,
597    ) -> Result<OpenOutcome, ClientError> {
598        if reply.params.protocol_version != PROTOCOL_VERSION {
599            return Err(ClientError::ProtocolVersionMismatch {
600                requested: PROTOCOL_VERSION,
601                received: reply.params.protocol_version,
602            });
603        }
604
605        let reply_is_close = flags::has(reply.flags, flags::FLAG_CLOSE);
606        match self.config.run_mode {
607            RunMode::Normal if reply_is_close => Err(ClientError::ServerRejected),
608            RunMode::Normal if reply.token == 0 => Err(ClientError::ZeroToken),
609            RunMode::Normal => self.accept_normal_open(reply, now),
610            RunMode::NoTest if !reply_is_close => Err(ClientError::UnexpectedNoTestReply),
611            RunMode::NoTest if reply.token != 0 => {
612                Err(ClientError::NonZeroNoTestToken { token: reply.token })
613            }
614            RunMode::NoTest => self.accept_no_test_open(reply, now),
615        }
616    }
617
618    fn accept_normal_open(
619        &mut self,
620        reply: OpenReply,
621        now: ClientTimestamp,
622    ) -> Result<OpenOutcome, ClientError> {
623        let token = reply.token;
624        let negotiated = negotiate_params(
625            &self.requested,
626            reply.params,
627            self.config.negotiation_policy,
628        )?;
629        let recv_buffer_size = recv_buffer_size(self.config.hmac_key.is_some(), Some(&negotiated))?;
630        let negotiated_dscp =
631            u8::try_from(negotiated.params.dscp).map_err(|_| ClientError::InvalidConfig {
632                reason: "negotiated dscp must be in range 0..=63".to_owned(),
633            })?;
634        apply_dscp_to_socket(&self.socket, self.remote, negotiated_dscp)?;
635        self.negotiated = Some(negotiated.clone());
636        self.recv_buffer.resize(recv_buffer_size, 0);
637        self.phase = ClientPhase::Open { token };
638
639        let end_mono = if negotiated.params.duration_ns > 0 {
640            Some(negotiated_end_mono(
641                now.mono,
642                negotiated.params.duration_ns,
643            )?)
644        } else {
645            None
646        };
647
648        self.session = Some(ActiveSession {
649            next_wire_seq: 0,
650            highest_received_seq: None,
651            packets_sent: 0,
652            start_mono: now.mono,
653            end_mono,
654            next_send_at: now.mono,
655            pending: PendingMap::new(self.config.max_pending_probes),
656            timed_out: TimedOutMap::new(self.config.max_pending_probes),
657            completed: CompletedSet::new(self.config.max_pending_probes),
658            sending_done: false,
659        });
660
661        let event = ClientEvent::SessionStarted {
662            remote: self.remote,
663            token,
664            negotiated: negotiated.clone(),
665            at: now,
666        };
667
668        Ok(OpenOutcome::Started {
669            remote: self.remote,
670            token,
671            negotiated,
672            event,
673        })
674    }
675
676    fn accept_no_test_open(
677        &mut self,
678        reply: OpenReply,
679        now: ClientTimestamp,
680    ) -> Result<OpenOutcome, ClientError> {
681        let negotiated = negotiate_params(
682            &self.requested,
683            reply.params,
684            self.config.negotiation_policy,
685        )?;
686        self.negotiated = Some(negotiated.clone());
687        self.phase = ClientPhase::NoTestCompleted;
688        let event = ClientEvent::NoTestCompleted {
689            remote: self.remote,
690            negotiated: negotiated.clone(),
691            at: now,
692        };
693        Ok(OpenOutcome::NoTestCompleted {
694            remote: self.remote,
695            negotiated,
696            event,
697        })
698    }
699}
700
701#[cfg(test)]
702impl Client {
703    fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
704        self.send_probe_inner(Some(ts))
705    }
706}
707
708fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
709    *highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
710        if sequence_is_after(wire_seq, h) {
711            wire_seq
712        } else {
713            h
714        }
715    }));
716}
717
718fn next_probe_deadline(
719    start: Instant,
720    interval_ns: u64,
721    packets_sent: u64,
722) -> Result<Instant, ClientError> {
723    let offset_ns = interval_ns
724        .checked_mul(packets_sent)
725        .ok_or(ClientError::DurationOverflow)?;
726    start
727        .checked_add(Duration::from_nanos(offset_ns))
728        .ok_or(ClientError::DurationOverflow)
729}
730
731fn sequence_is_after(candidate: u32, current: u32) -> bool {
732    candidate != current && candidate.wrapping_sub(current) < (1 << 31)
733}
734
735fn sequence_is_before(candidate: u32, current: u32) -> bool {
736    current != candidate && current.wrapping_sub(candidate) < (1 << 31)
737}
738
739fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
740    left.checked_duration_since(right)
741        .or_else(|| right.checked_duration_since(left))
742        .unwrap_or(Duration::ZERO)
743}
744
745pub(crate) fn recv_buffer_size(
746    has_hmac: bool,
747    negotiated: Option<&NegotiatedParams>,
748) -> Result<usize, ClientError> {
749    Ok(match negotiated {
750        Some(negotiated) => echo_packet_len(has_hmac, &negotiated.params)?
751            .saturating_add(1)
752            .max(MIN_RECV_BUFFER_SIZE),
753        None => MIN_RECV_BUFFER_SIZE,
754    })
755}
756
757fn compute_rtt(
758    sent_at: &ClientTimestamp,
759    received_at: &ClientTimestamp,
760    ts: &TimestampFields,
761) -> RttSample {
762    let raw = received_at
763        .mono
764        .checked_duration_since(sent_at.mono)
765        .unwrap_or(Duration::ZERO);
766
767    let server_processing = compute_server_processing(ts);
768
769    let adjusted = server_processing
770        .map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
771    let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
772
773    RttSample {
774        raw,
775        adjusted,
776        effective,
777    }
778}
779
780fn duration_ns_i128(duration: Duration) -> i128 {
781    i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
782}
783
784fn compute_server_processing(ts: &TimestampFields) -> Option<Duration> {
785    if let (Some(recv_mono), Some(send_mono)) = (ts.recv_mono, ts.send_mono) {
786        let diff = send_mono.checked_sub(recv_mono)?;
787        return Some(Duration::from_nanos(u64::try_from(diff).ok()?));
788    }
789    if let (Some(recv_wall), Some(send_wall)) = (ts.recv_wall, ts.send_wall) {
790        let diff = send_wall.checked_sub(recv_wall)?;
791        return Some(Duration::from_nanos(u64::try_from(diff).ok()?));
792    }
793    None
794}
795
796fn build_server_timing(ts: &TimestampFields) -> Option<ServerTiming> {
797    if ts.recv_wall.is_none()
798        && ts.recv_mono.is_none()
799        && ts.send_wall.is_none()
800        && ts.send_mono.is_none()
801        && ts.midpoint_wall.is_none()
802        && ts.midpoint_mono.is_none()
803    {
804        return None;
805    }
806    Some(ServerTiming {
807        receive_wall_ns: ts.recv_wall,
808        receive_mono_ns: ts.recv_mono,
809        send_wall_ns: ts.send_wall,
810        send_mono_ns: ts.send_mono,
811        midpoint_wall_ns: ts.midpoint_wall,
812        midpoint_mono_ns: ts.midpoint_mono,
813        processing: compute_server_processing(ts),
814    })
815}
816
817fn compute_one_way(
818    sent_at: &ClientTimestamp,
819    received_at: &ClientTimestamp,
820    ts: &TimestampFields,
821) -> Option<OneWayDelaySample> {
822    let server_recv_wall = ts.recv_wall.or(ts.midpoint_wall);
823    let server_send_wall = ts.send_wall.or(ts.midpoint_wall);
824
825    let client_send_ns = unix_epoch_ns_i64(sent_at.wall);
826    let client_recv_ns = unix_epoch_ns_i64(received_at.wall);
827
828    let c2s = server_recv_wall
829        .zip(client_send_ns)
830        .and_then(|(srv, cli)| srv.checked_sub(cli))
831        .map(|d| SignedDuration::from_nanos(i128::from(d)));
832    let s2c = client_recv_ns
833        .zip(server_send_wall)
834        .and_then(|(cli, srv)| cli.checked_sub(srv))
835        .map(|d| SignedDuration::from_nanos(i128::from(d)));
836
837    if c2s.is_none() && s2c.is_none() {
838        return None;
839    }
840
841    Some(OneWayDelaySample {
842        client_to_server: c2s,
843        server_to_client: s2c,
844    })
845}
846
847fn unix_epoch_ns_i64(time: SystemTime) -> Option<i64> {
848    time.duration_since(UNIX_EPOCH)
849        .ok()
850        .and_then(|duration| i64::try_from(duration.as_nanos()).ok())
851}
852
853fn build_received_stats(reply: &EchoReply) -> Option<ReceivedStatsSample> {
854    if reply.recv_count.is_none() && reply.recv_window.is_none() {
855        return None;
856    }
857    Some(ReceivedStatsSample {
858        count: reply.recv_count,
859        window: reply.recv_window,
860    })
861}
862
863fn params_from_config(config: &ClientConfig) -> Result<Params, ClientError> {
864    validate_protocol_config(config)?;
865    Ok(Params {
866        protocol_version: PROTOCOL_VERSION,
867        duration_ns: match config.duration {
868            Some(duration) => config_duration_to_ns("duration", duration)?,
869            None => 0,
870        },
871        interval_ns: config_duration_to_ns("interval", config.interval)?,
872        length: i64::from(config.length),
873        received_stats: config.received_stats,
874        stamp_at: config.stamp_at,
875        clock: config.clock,
876        dscp: i64::from(config.dscp),
877        server_fill: config.server_fill.clone().map(|value| ServerFill { value }),
878    })
879}
880
881fn validate_protocol_config(config: &ClientConfig) -> Result<(), ClientError> {
882    if config.duration == Some(Duration::ZERO) {
883        return Err(ClientError::InvalidConfig {
884            reason: "duration must be greater than zero; use None for continuous mode".to_owned(),
885        });
886    }
887    if config.interval == Duration::ZERO {
888        return Err(ClientError::InvalidConfig {
889            reason: "interval must be greater than zero".to_owned(),
890        });
891    }
892    if config.dscp > MAX_DSCP_CODEPOINT {
893        return Err(ClientError::InvalidConfig {
894            reason: format!("dscp must be <= {MAX_DSCP_CODEPOINT}"),
895        });
896    }
897    if config.length > MAX_UDP_PAYLOAD_LENGTH {
898        return Err(ClientError::InvalidConfig {
899            reason: format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"),
900        });
901    }
902
903    if let Some(fill) = &config.server_fill {
904        let len = fill.len();
905        if len == 0 {
906            return Err(ClientError::InvalidConfig {
907                reason: "server_fill must not be empty".to_owned(),
908            });
909        }
910        if len > MAX_SERVER_FILL_BYTES {
911            return Err(ClientError::InvalidConfig {
912                reason: format!("server_fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"),
913            });
914        }
915    }
916
917    Ok(())
918}
919
920fn config_duration_to_ns(field: &str, duration: Duration) -> Result<i64, ClientError> {
921    i64::try_from(duration.as_nanos()).map_err(|_| ClientError::InvalidConfig {
922        reason: format!("{field} is too large to encode as nanoseconds"),
923    })
924}
925
926fn negotiated_end_mono(start: Instant, duration_ns: i64) -> Result<Instant, ClientError> {
927    debug_assert!(duration_ns > 0);
928    let duration_ns = u64::try_from(duration_ns).expect("validated positive negotiated duration");
929    start
930        .checked_add(Duration::from_nanos(duration_ns))
931        .ok_or_else(|| ClientError::NegotiationRejected {
932            reason: "duration is too large to schedule".to_owned(),
933        })
934}
935
936#[cfg(test)]
937mod tests;