Skip to main content

irtt_client/
client.rs

1use std::{
2    io,
3    net::{SocketAddr, UdpSocket},
4    time::{Duration, Instant},
5};
6
7#[cfg(test)]
8use std::cell::Cell;
9
10#[cfg(test)]
11use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
12
13use crate::{
14    config::{ClientConfig, RecvBudget},
15    error::ClientError,
16    event::{ClientEvent, OpenOutcome},
17    receive::{drain_tx_timestamps, recv_datagram, try_enable_tx_timestamping, ReceivedDatagram},
18    session::machine::{
19        recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, ProbeSent,
20        SessionMachine, MAX_OPEN_PACKET_SIZE,
21    },
22    socket::{connect_udp_socket, resolve_remote, validate_open_timeouts},
23    socket_options::{apply_traffic_class_to_socket, clear_dscp_on_socket},
24    timing::ClientTimestamp,
25};
26
27pub(crate) mod schedule;
28
29use schedule::{instant_abs_diff, ProbeSchedule};
30
31#[derive(Debug, Clone, Copy)]
32enum ProbeScheduleMode {
33    CallerPaced,
34    #[cfg(test)]
35    Managed {
36        scheduled_at: Instant,
37    },
38}
39
40#[derive(Debug)]
41struct PreparedClientOpen {
42    machine: PreparedOpenAcceptance,
43    schedule: Option<ProbeSchedule>,
44    recv_buffer_len: Option<usize>,
45    negotiated_traffic_class: Option<u8>,
46    previous_traffic_class: Option<u8>,
47    post_open_recv_timeout: Option<Duration>,
48}
49
50#[derive(Debug)]
51struct PreparedClientOpenFailure {
52    primary: ClientError,
53    machine: PreparedOpenAcceptance,
54}
55
56#[cfg(test)]
57#[derive(Debug, Default)]
58struct ClientTestHooks {
59    fail_open_dscp: Cell<bool>,
60    fail_open_timeout_restore: Cell<bool>,
61    fail_close_event_reserve: Cell<bool>,
62    fail_close_dscp_clear: Cell<bool>,
63    fail_close_send: Cell<bool>,
64    close_reported_len: Cell<Option<usize>>,
65    close_send_attempts: Cell<usize>,
66    close_sent_at: Cell<Option<ClientTimestamp>>,
67    fail_cleanup_send: Cell<bool>,
68    recv_buffer_len_override: Cell<Option<usize>>,
69    fail_dscp_restore: Cell<bool>,
70    last_restored_read_timeout: Cell<Option<Duration>>,
71    prepared_active_session_before_dscp: Cell<bool>,
72    /// Number of subsequent receive attempts that should report
73    /// `io::ErrorKind::Interrupted` before a real receive is attempted.
74    /// Decremented on every injected receive; `0` means inject nothing.
75    inject_recv_interrupted: Cell<usize>,
76}
77
78#[cfg(test)]
79#[derive(Debug, Clone, Copy)]
80pub(crate) struct ProbeSendTimestamps {
81    pub(crate) permission_at: Instant,
82    /// Private pre-send anchor: timeout deadline / kernel TX lower bound.
83    pub(crate) send_anchor: ClientTimestamp,
84    pub(crate) send_call_start: Instant,
85    pub(crate) send_finished_at: Instant,
86    /// Public post-send measurement: RTT / OWD fallback / timer_error.
87    pub(crate) sent_at: ClientTimestamp,
88}
89
90#[cfg(test)]
91use crate::{
92    session::machine::{
93        compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
94        unix_epoch_ns_i64, update_highest_received,
95    },
96    session::negotiate_params,
97    NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
98};
99
100/// Low-level synchronous IRTT client.
101///
102/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
103/// session, send probes, receive replies, poll timeouts, and close. Callers
104/// that do not need to own this loop can use the unified managed API in
105/// [`crate::managed`] when the `tokio` feature is enabled.
106///
107/// # Example
108///
109/// `Client` is the runtime-free adapter. The caller drives receives and timeout
110/// polling between sends; see the standalone example for a complete loop.
111/// This example type-checks without contacting a server:
112///
113/// ```no_run
114/// use irtt_client::{Client, ClientConfig};
115///
116/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
117/// let mut client = Client::connect(ClientConfig::default())?;
118/// let outcome = client.open()?;
119/// println!("opened: {outcome:?}");
120/// let sent = client.send_probe()?;
121/// println!("sent: {sent:?}");
122/// let received = client.recv_once()?;
123/// println!("received: {received:?}");
124/// let timed_out = client.poll_timeouts()?;
125/// println!("timeouts: {timed_out:?}");
126/// let closed = client.close()?;
127/// println!("closed: {closed:?}");
128/// # Ok(())
129/// # }
130/// ```
131#[derive(Debug)]
132pub struct Client {
133    runtime: SessionMachine,
134    schedule: Option<ProbeSchedule>,
135    socket: UdpSocket,
136    remote: SocketAddr,
137    recv_buffer: Vec<u8>,
138    applied_traffic_class: Option<u8>,
139    /// Whether the socket's `SO_TIMESTAMPING` flags carry `TX_SOFTWARE` +
140    /// `OPT_ID` + `OPT_TSONLY`. Always `false` off Linux, without the
141    /// `ancillary` feature, or before the best-effort post-Open upgrade;
142    /// tracked here (adapter-owned capability, not protocol state) so
143    /// unnecessary error-queue drains are skipped once it is known there is
144    /// nothing to drain.
145    tx_timestamping_enabled: bool,
146    #[cfg(test)]
147    test_hooks: ClientTestHooks,
148    #[cfg(test)]
149    probe_reported_len: Option<usize>,
150    #[cfg(test)]
151    probe_send_error: bool,
152    #[cfg(test)]
153    probe_send_timestamps: Option<ProbeSendTimestamps>,
154}
155
156impl Client {
157    /// Resolve the configured server and create a connected UDP socket.
158    ///
159    /// This validates local configuration and prepares the open request, but it
160    /// does not contact the server. Call [`open`](Self::open) to perform the
161    /// IRTT open exchange.
162    pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
163        validate_open_timeouts(&config.open_timeouts)?;
164        let remote = resolve_remote(&config)?;
165        let runtime = SessionMachine::new(config.clone(), remote)?;
166        let socket = connect_udp_socket(&config.socket_config, remote)?;
167
168        Ok(Self {
169            runtime,
170            schedule: None,
171            socket,
172            remote,
173            recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
174            applied_traffic_class: None,
175            tx_timestamping_enabled: false,
176            #[cfg(test)]
177            test_hooks: ClientTestHooks::default(),
178            #[cfg(test)]
179            probe_reported_len: None,
180            #[cfg(test)]
181            probe_send_error: false,
182            #[cfg(test)]
183            probe_send_timestamps: None,
184        })
185    }
186
187    /// Perform the IRTT open exchange.
188    ///
189    /// On success, returns the negotiated open outcome and transitions the
190    /// client into either an open probe session or completed no-test state.
191    /// Open attempts use [`ClientConfig::open_timeouts`]. Malformed or
192    /// unrelated datagrams are ignored until the current attempt's absolute
193    /// deadline, so one attempt may consume several datagrams without
194    /// retransmitting. Silence or ignored traffic eventually produces
195    /// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or
196    /// incompatible Open reply from the connected peer is terminal; when HMAC
197    /// is configured, that recognition additionally requires authentication.
198    ///
199    /// When a trusted reply allocates a token but later negotiation or socket
200    /// preparation fails, the client sends a best-effort cleanup close and
201    /// preserves the original failure. Failed opening never leaves the session
202    /// machine open.
203    pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
204        let result = self.open_transaction();
205        if result.is_err() {
206            let _ =
207                self.restore_open_read_timeout(self.runtime.config().socket_config.recv_timeout);
208        }
209        result
210    }
211
212    /// Send a close request and emit a [`ClientEvent::SessionClosed`] event.
213    ///
214    /// The close event means the client has sent its close packet and stopped
215    /// tracking the session locally; it is not a server acknowledgement. If the
216    /// send fails, the negotiated DSCP is restored best-effort and the session
217    /// and probe schedule remain open.
218    pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
219        let prepared = self.runtime.prepare_close()?;
220        let mut events = Vec::new();
221        #[cfg(test)]
222        if self.test_hooks.fail_close_event_reserve.replace(false) {
223            events
224                .try_reserve(usize::MAX)
225                .map_err(|source| ClientError::AllocationFailed {
226                    operation: "close event result",
227                    source,
228                })?;
229        }
230        events
231            .try_reserve(1)
232            .map_err(|source| ClientError::AllocationFailed {
233                operation: "close event result",
234                source,
235            })?;
236        let previous_traffic_class = self.applied_traffic_class;
237        let expected_bytes = prepared.bytes.len();
238
239        self.clear_close_dscp()?;
240        let bytes = match self.send_close_datagram(prepared.bytes) {
241            Ok(bytes) => bytes,
242            Err(err) => {
243                self.restore_dscp_best_effort(previous_traffic_class);
244                return Err(ClientError::Socket(err));
245            }
246        };
247        #[cfg(not(test))]
248        let sent_at = ClientTimestamp::now();
249        #[cfg(test)]
250        let sent_at = self
251            .test_hooks
252            .close_sent_at
253            .take()
254            .unwrap_or_else(ClientTimestamp::now);
255        let event = self.runtime.commit_local_close(prepared.commit, sent_at);
256        self.schedule = None;
257        self.applied_traffic_class = None;
258
259        validate_datagram_length(expected_bytes, bytes)?;
260        events.push(event);
261        Ok(events)
262    }
263
264    /// Return the monotonic deadline for the next probe send, if another probe
265    /// is scheduled.
266    pub fn next_send_deadline(&self) -> Option<Instant> {
267        if !self.runtime.is_open() {
268            return None;
269        }
270        self.schedule.as_ref()?.next_send_deadline()
271    }
272
273    /// Return the local timeout used to classify pending probes as lost.
274    pub fn probe_timeout(&self) -> Duration {
275        self.runtime.probe_timeout()
276    }
277
278    /// Send one echo probe if the negotiated run is still active.
279    ///
280    /// Returns an `EchoSent` event when a probe is sent. Returns an empty event
281    /// list when the run duration has elapsed and no further probe should be
282    /// sent.
283    pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
284        self.send_probe_transaction(ProbeScheduleMode::CallerPaced)
285    }
286
287    /// Receive and classify at most one datagram from the socket.
288    ///
289    /// Returns an empty event list when the socket read would block or times
290    /// out. Malformed or unrelated datagrams are reported as warning events.
291    pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
292        self.socket
293            .set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
294        self.recv_once_inner()
295    }
296
297    /// Receive one datagram into `self.recv_buffer`. In test builds, a
298    /// pending `inject_recv_interrupted` count reports
299    /// `io::ErrorKind::Interrupted` instead of performing a real receive,
300    /// deterministically exercising interrupted-syscall retry behavior.
301    fn recv_datagram_once(&mut self) -> Result<ReceivedDatagram, io::Error> {
302        #[cfg(test)]
303        {
304            let remaining = self.test_hooks.inject_recv_interrupted.get();
305            if remaining > 0 {
306                self.test_hooks.inject_recv_interrupted.set(remaining - 1);
307                return Err(io::Error::from(io::ErrorKind::Interrupted));
308            }
309        }
310        recv_datagram(&self.socket, &mut self.recv_buffer)
311    }
312
313    /// Same as [`Self::recv_datagram_once`], receiving into a caller-supplied
314    /// buffer instead of `self.recv_buffer` (used before the negotiated
315    /// receive buffer size is known, i.e. during Open).
316    fn recv_datagram_once_into(&self, buf: &mut [u8]) -> Result<ReceivedDatagram, io::Error> {
317        #[cfg(test)]
318        {
319            let remaining = self.test_hooks.inject_recv_interrupted.get();
320            if remaining > 0 {
321                self.test_hooks.inject_recv_interrupted.set(remaining - 1);
322                return Err(io::Error::from(io::ErrorKind::Interrupted));
323            }
324        }
325        recv_datagram(&self.socket, buf)
326    }
327
328    fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
329        let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
330            return Ok(vec![]);
331        };
332
333        // One final bounded drain before processing the reply, so a TX
334        // timestamp that raced the reply is still associated with its probe
335        // before that probe is looked up and possibly removed below.
336        self.drain_tx_timestamps()?;
337
338        let events = self.runtime.process_received_echo_packet(
339            &self.recv_buffer[..datagram.len],
340            datagram.received_at,
341            datagram.meta,
342        )?;
343        if self.runtime.is_peer_closed() {
344            self.schedule = None;
345            if self.clear_close_dscp().is_ok() {
346                self.applied_traffic_class = None;
347            }
348        }
349        Ok(events)
350    }
351
352    /// Receive one datagram, transparently retrying an interrupted syscall
353    /// (`io::ErrorKind::Interrupted`, e.g. `EINTR`) without treating it as a
354    /// genuine socket error, timeout, or empty receive.
355    ///
356    /// One logical receive keeps one timeout budget: when a receive timeout
357    /// is configured, an absolute deadline is computed once here and each
358    /// retry after an interruption restores the socket's remaining time
359    /// against that same deadline, so repeated interruptions cannot extend
360    /// this receive past its configured timeout. An unconfigured timeout
361    /// (`None`) keeps blocking indefinitely after an interruption, matching
362    /// its no-timeout contract. The socket's read timeout is restored to the
363    /// caller-configured value before returning, so a later call in the same
364    /// `recv_available` budget is unaffected by an earlier interruption here.
365    fn recv_datagram_retrying_interrupted(
366        &mut self,
367    ) -> Result<Option<ReceivedDatagram>, ClientError> {
368        let configured_timeout = self.runtime.config().socket_config.recv_timeout;
369        let deadline = match configured_timeout {
370            Some(timeout) => Some(
371                Instant::now()
372                    .checked_add(timeout)
373                    .ok_or(ClientError::DurationOverflow)?,
374            ),
375            None => None,
376        };
377        let mut timeout_narrowed = false;
378
379        let result = loop {
380            match self.recv_datagram_once() {
381                Ok(datagram) => break Ok(Some(datagram)),
382                Err(err)
383                    if matches!(
384                        err.kind(),
385                        io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
386                    ) =>
387                {
388                    break Ok(None);
389                }
390                Err(err) if err.kind() == io::ErrorKind::Interrupted => {
391                    if let Some(deadline) = deadline {
392                        let Some(remaining) = deadline.checked_duration_since(Instant::now())
393                        else {
394                            break Ok(None);
395                        };
396                        if remaining.is_zero() {
397                            break Ok(None);
398                        }
399                        if let Err(err) = self.socket.set_read_timeout(Some(remaining)) {
400                            break Err(ClientError::Socket(err));
401                        }
402                        timeout_narrowed = true;
403                    }
404                }
405                Err(err) => break Err(ClientError::Socket(err)),
406            }
407        };
408
409        if timeout_narrowed {
410            if let Err(err) = self.socket.set_read_timeout(configured_timeout) {
411                return Err(ClientError::Socket(err));
412            }
413        }
414        result
415    }
416
417    /// Receive and classify datagrams until a receive produces no events or the
418    /// receive budget is exhausted.
419    pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
420        self.socket
421            .set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
422        let mut all_events = Vec::new();
423        for _ in 0..budget.max_packets {
424            let events = self.recv_once_inner()?;
425            if events.is_empty() {
426                break;
427            }
428            all_events.extend(events);
429            if self.is_peer_closed() {
430                break;
431            }
432        }
433        Ok(all_events)
434    }
435
436    /// Polls for probes that have timed out as of the current monotonic time.
437    pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
438        self.poll_timeouts_at(Instant::now())
439    }
440
441    /// Polls for probes that have timed out as of `now`.
442    ///
443    /// This is useful for callers that drive `Client` from their own event loop and
444    /// want timeout decisions to use the same sampled `Instant` as their scheduling
445    /// logic.
446    ///
447    /// `now` is monotonic time only; wall-clock time is not used for timeout expiry.
448    pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
449        self.drain_tx_timestamps()?;
450        self.runtime.poll_timeouts_at(now)
451    }
452
453    /// Return whether the current run has completed.
454    ///
455    /// A normal run is complete once no more probes will be sent and all
456    /// pending probes have either replied or timed out. No-test and closed
457    /// sessions are also considered complete.
458    pub fn is_run_complete(&self) -> bool {
459        self.runtime.is_terminal()
460            || self
461                .schedule
462                .as_ref()
463                .is_some_and(|schedule| schedule.is_finished() && self.runtime.pending_is_empty())
464    }
465
466    /// Return whether the session was closed by a peer close-flagged reply.
467    ///
468    /// Direct operations on a closed client still return
469    /// [`ClientError::AlreadyClosed`]. This method lets higher-level run loops
470    /// avoid treating a successfully observed peer close as a local cleanup
471    /// failure.
472    pub fn is_peer_closed(&self) -> bool {
473        self.runtime.is_peer_closed()
474    }
475
476    #[cfg(test)]
477    pub(crate) fn send_managed_probe(
478        &mut self,
479        scheduled_at: Instant,
480    ) -> Result<Vec<ClientEvent>, ClientError> {
481        self.send_probe_transaction(ProbeScheduleMode::Managed { scheduled_at })
482    }
483
484    fn send_probe_transaction(
485        &mut self,
486        mode: ProbeScheduleMode,
487    ) -> Result<Vec<ClientEvent>, ClientError> {
488        self.runtime.ensure_open()?;
489        #[cfg(test)]
490        let test_timestamps = self.probe_send_timestamps.take();
491        #[cfg(test)]
492        let fail_send = std::mem::take(&mut self.probe_send_error);
493        let remote = self.remote;
494        let (runtime, schedule, socket) = (&mut self.runtime, &mut self.schedule, &self.socket);
495        let schedule = schedule
496            .as_mut()
497            .expect("open sessions always have a probe schedule");
498        #[cfg(not(test))]
499        let permission_at = Instant::now();
500        #[cfg(test)]
501        let permission_at = test_timestamps
502            .map(|timestamps| timestamps.permission_at)
503            .unwrap_or_else(Instant::now);
504        if !schedule.permit_probe_at(permission_at) {
505            return Ok(Vec::new());
506        }
507
508        let Some(prepared) = runtime.prepare_probe()? else {
509            return Ok(Vec::new());
510        };
511        let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
512        let schedule_commit = match mode {
513            ProbeScheduleMode::CallerPaced => {
514                schedule.preflight_caller_commit(machine_preflight.next_packets_sent)?
515            }
516            #[cfg(test)]
517            ProbeScheduleMode::Managed { scheduled_at } => {
518                schedule.preflight_managed_commit(scheduled_at, permission_at)?
519            }
520        };
521        let mut events = Vec::new();
522        events
523            .try_reserve(1)
524            .map_err(|source| ClientError::AllocationFailed {
525                operation: "probe event result",
526                source,
527            })?;
528
529        let expected_bytes = prepared.bytes.len();
530        let scheduled_at = schedule_commit.scheduled_at;
531        #[cfg(test)]
532        let reported_bytes = self.probe_reported_len.take();
533        // All fallible probe-commit preflight (timeout deadline arithmetic,
534        // capacity/collision checks already done above) is finalized from
535        // this PRIVATE pre-send anchor, before the socket send. It is not
536        // the public measurement `sent_at` captured further below.
537        #[cfg(not(test))]
538        let send_anchor = ClientTimestamp::now();
539        #[cfg(test)]
540        let send_anchor = test_timestamps
541            .map(|timestamps| timestamps.send_anchor)
542            .unwrap_or_else(ClientTimestamp::now);
543        let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
544        #[cfg(not(test))]
545        let send_call_start = Instant::now();
546        #[cfg(test)]
547        let send_call_start = test_timestamps
548            .map(|timestamps| timestamps.send_call_start)
549            .unwrap_or_else(Instant::now);
550        #[cfg(test)]
551        if fail_send {
552            runtime.invalidate_kernel_tx_correlation();
553            return Err(ClientError::Socket(io::Error::other(
554                "injected probe send failure",
555            )));
556        }
557        let bytes = match socket.send(&prepared.bytes) {
558            Ok(bytes) => bytes,
559            Err(error) => {
560                runtime.invalidate_kernel_tx_correlation();
561                return Err(ClientError::Socket(error));
562            }
563        };
564        #[cfg(not(test))]
565        let send_finished_at = Instant::now();
566        #[cfg(test)]
567        let send_finished_at = test_timestamps
568            .map(|timestamps| timestamps.send_finished_at)
569            .unwrap_or_else(Instant::now);
570        // The public measurement timestamp: paired wall/monotonic sample
571        // captured immediately after the successful socket send completed.
572        // This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
573        // endpoint. The infallible machine/schedule commit below never fails.
574        #[cfg(not(test))]
575        let sent_at = ClientTimestamp::now();
576        #[cfg(test)]
577        let sent_at = test_timestamps
578            .map(|timestamps| timestamps.sent_at)
579            .unwrap_or_else(ClientTimestamp::now);
580        let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
581        schedule.commit(schedule_commit);
582
583        let send_call = send_finished_at.saturating_duration_since(send_call_start);
584        let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
585        #[cfg(test)]
586        let bytes = reported_bytes.unwrap_or(bytes);
587        validate_datagram_length(expected_bytes, bytes)?;
588        self.drain_tx_timestamps()?;
589
590        events.push(echo_sent_event(
591            remote,
592            sent,
593            send_call,
594            scheduled_at,
595            timer_error,
596        ));
597        Ok(events)
598    }
599
600    fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
601        let request = self.runtime.prepare_open_request()?;
602        let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
603        let attempt_count = self.runtime.config().open_timeouts.len();
604
605        for attempt in 0..attempt_count {
606            let timeout = self.runtime.config().open_timeouts[attempt];
607            let deadline = Instant::now()
608                .checked_add(timeout)
609                .ok_or(ClientError::DurationOverflow)?;
610            self.socket.set_read_timeout(Some(timeout))?;
611            self.socket.send(&request.bytes)?;
612
613            loop {
614                let now = Instant::now();
615                let Some(remaining) = deadline.checked_duration_since(now) else {
616                    break;
617                };
618                if remaining.is_zero() {
619                    break;
620                }
621                self.socket.set_read_timeout(Some(remaining))?;
622
623                let datagram = match self.recv_datagram_once_into(&mut buf) {
624                    Ok(datagram) => datagram,
625                    // WouldBlock/TimedOut mean the attempt's own deadline
626                    // elapsed; Interrupted (EINTR) means the syscall was
627                    // merely interrupted and carries no timing information of
628                    // its own. Both retry through the same loop, which
629                    // recomputes `remaining` and re-applies it as the read
630                    // timeout on every iteration — so an interruption resumes
631                    // within this same absolute deadline rather than
632                    // resetting or extending it.
633                    Err(err)
634                        if matches!(
635                            err.kind(),
636                            io::ErrorKind::WouldBlock
637                                | io::ErrorKind::TimedOut
638                                | io::ErrorKind::Interrupted
639                        ) =>
640                    {
641                        if Instant::now() >= deadline {
642                            break;
643                        }
644                        continue;
645                    }
646                    Err(err) => return Err(ClientError::Socket(err)),
647                };
648                if datagram.received_at.mono > deadline {
649                    break;
650                }
651
652                let reply = match self.runtime.inspect_open_datagram(&buf[..datagram.len])? {
653                    OpenDatagramDisposition::Ignore => continue,
654                    OpenDatagramDisposition::Trusted(reply) => reply,
655                };
656                let machine = match self
657                    .runtime
658                    .prepare_open_acceptance(reply, datagram.received_at)
659                {
660                    Ok(machine) => machine,
661                    Err(failure) => {
662                        self.send_cleanup_close_best_effort(failure.cleanup_close.as_deref());
663                        return Err(failure.primary);
664                    }
665                };
666                let prepared = match self.prepare_client_open(machine, datagram.received_at) {
667                    Ok(prepared) => prepared,
668                    Err(failure) => {
669                        self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
670                        return Err(failure.primary);
671                    }
672                };
673                return self.apply_prepared_open(prepared);
674            }
675        }
676
677        Err(ClientError::OpenTimeout)
678    }
679
680    fn prepare_client_open(
681        &self,
682        machine: PreparedOpenAcceptance,
683        opened_at: ClientTimestamp,
684    ) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
685        let Some(negotiated) = machine.normal_negotiated() else {
686            return Ok(PreparedClientOpen {
687                machine,
688                schedule: None,
689                recv_buffer_len: None,
690                negotiated_traffic_class: None,
691                previous_traffic_class: self.applied_traffic_class,
692                post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
693            });
694        };
695        let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
696            Ok(schedule) => schedule,
697            Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
698        };
699        let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
700            Ok(size) => size,
701            Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
702        };
703        #[cfg(test)]
704        let recv_buffer_len = self
705            .test_hooks
706            .recv_buffer_len_override
707            .get()
708            .unwrap_or(recv_buffer_len);
709        let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
710            Ok(traffic_class) => traffic_class,
711            Err(_) => {
712                return Err(Box::new(PreparedClientOpenFailure {
713                    primary: ClientError::InvalidConfig {
714                        reason: "negotiated dscp must be in range 0..=255".to_owned(),
715                    },
716                    machine,
717                }));
718            }
719        };
720
721        Ok(PreparedClientOpen {
722            machine,
723            schedule: Some(schedule),
724            recv_buffer_len: Some(recv_buffer_len),
725            negotiated_traffic_class: Some(negotiated_traffic_class),
726            previous_traffic_class: self.applied_traffic_class,
727            post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
728        })
729    }
730
731    fn apply_prepared_open(
732        &mut self,
733        prepared: PreparedClientOpen,
734    ) -> Result<OpenOutcome, ClientError> {
735        let PreparedClientOpen {
736            machine,
737            schedule,
738            recv_buffer_len,
739            negotiated_traffic_class,
740            previous_traffic_class,
741            post_open_recv_timeout,
742        } = prepared;
743
744        if let (Some(recv_buffer_len), Some(negotiated_traffic_class)) =
745            (recv_buffer_len, negotiated_traffic_class)
746        {
747            let previous_len = self.recv_buffer.len();
748            let additional = recv_buffer_len.saturating_sub(previous_len);
749            if let Err(source) = self.recv_buffer.try_reserve(additional) {
750                self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
751                return Err(ClientError::AllocationFailed {
752                    operation: "negotiated receive buffer",
753                    source,
754                });
755            }
756            self.recv_buffer.resize(recv_buffer_len, 0);
757
758            #[cfg(test)]
759            self.test_hooks
760                .prepared_active_session_before_dscp
761                .set(machine.has_prepared_active_session());
762            if let Err(primary) = self.apply_open_dscp(negotiated_traffic_class) {
763                self.recv_buffer.truncate(previous_len);
764                self.restore_dscp_best_effort(previous_traffic_class);
765                self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
766                return Err(primary);
767            }
768            if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
769                let primary = ClientError::ReadTimeoutRestore { source };
770                self.recv_buffer.truncate(previous_len);
771                self.restore_dscp_best_effort(previous_traffic_class);
772                self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
773                return Err(primary);
774            }
775
776            let outcome = self.runtime.commit_open(machine);
777            self.schedule = schedule;
778            self.applied_traffic_class = Some(negotiated_traffic_class);
779            self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
780            Ok(outcome)
781        } else {
782            debug_assert!(schedule.is_none());
783            if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
784                return Err(ClientError::ReadTimeoutRestore { source });
785            }
786            let outcome = self.runtime.commit_open(machine);
787            self.schedule = None;
788            self.applied_traffic_class = None;
789            self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
790            Ok(outcome)
791        }
792    }
793
794    /// Best-effort, bounded, nonblocking drain of the socket's
795    /// `MSG_ERRQUEUE`. A no-op unless [`Self::apply_prepared_open`]
796    /// successfully upgraded the socket to TX timestamping. Any TX
797    /// timestamp found is handed to the session machine to attach to its
798    /// matching probe; a genuine socket/network error found on the queue is
799    /// surfaced through the normal socket-error path.
800    fn drain_tx_timestamps(&mut self) -> Result<(), ClientError> {
801        if !self.tx_timestamping_enabled {
802            return Ok(());
803        }
804        let runtime = &mut self.runtime;
805        drain_tx_timestamps(&self.socket, |id, timestamp| {
806            runtime.record_kernel_tx_timestamp(id, timestamp);
807        })
808        .map_err(ClientError::Socket)
809    }
810
811    fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
812        #[cfg(test)]
813        if self.test_hooks.fail_dscp_restore.replace(false) {
814            return;
815        }
816        let _ = match previous_traffic_class {
817            Some(traffic_class) => {
818                apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
819            }
820            None => clear_dscp_on_socket(&self.socket, self.remote),
821        };
822    }
823
824    fn clear_close_dscp(&self) -> Result<(), ClientError> {
825        #[cfg(test)]
826        if self.test_hooks.fail_close_dscp_clear.replace(false) {
827            return Err(ClientError::SocketOption {
828                operation: "clear negotiated DSCP",
829                remote: self.remote,
830                source: io::Error::other("injected negotiated DSCP clear failure"),
831            });
832        }
833        clear_dscp_on_socket(&self.socket, self.remote)
834    }
835
836    fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
837        if let Some(packet) = packet {
838            #[cfg(test)]
839            if self.test_hooks.fail_cleanup_send.replace(false) {
840                return;
841            }
842            let _ = self.socket.send(packet);
843        }
844    }
845
846    fn apply_open_dscp(&self, traffic_class: u8) -> Result<(), ClientError> {
847        #[cfg(test)]
848        if self.test_hooks.fail_open_dscp.replace(false) {
849            return Err(ClientError::SocketOption {
850                operation: "set negotiated DSCP",
851                remote: self.remote,
852                source: io::Error::other("injected negotiated DSCP failure"),
853            });
854        }
855        apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
856    }
857
858    fn restore_open_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
859        #[cfg(test)]
860        self.test_hooks.last_restored_read_timeout.set(timeout);
861        #[cfg(test)]
862        if self.test_hooks.fail_open_timeout_restore.replace(false) {
863            return Err(io::Error::other(
864                "injected configured read timeout restoration failure",
865            ));
866        }
867        self.socket.set_read_timeout(timeout)
868    }
869
870    fn send_close_datagram(&self, packet: &[u8]) -> io::Result<usize> {
871        #[cfg(test)]
872        {
873            self.test_hooks
874                .close_send_attempts
875                .set(self.test_hooks.close_send_attempts.get() + 1);
876            if self.test_hooks.fail_close_send.replace(false) {
877                return Err(io::Error::other("injected close send failure"));
878            }
879        }
880        let bytes = self.socket.send(packet)?;
881        #[cfg(test)]
882        if let Some(reported) = self.test_hooks.close_reported_len.take() {
883            return Ok(reported);
884        }
885        Ok(bytes)
886    }
887}
888
889pub(crate) fn echo_sent_event(
890    remote: SocketAddr,
891    sent: ProbeSent,
892    send_call: Duration,
893    scheduled_at: Instant,
894    timer_error: Duration,
895) -> ClientEvent {
896    ClientEvent::EchoSent {
897        seq: sent.seq,
898        remote,
899        scheduled_at,
900        sent_at: sent.sent_at,
901        bytes: sent.bytes,
902        send_call,
903        timer_error,
904    }
905}
906
907pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
908    if actual == expected {
909        Ok(())
910    } else {
911        Err(ClientError::DatagramLengthMismatch { expected, actual })
912    }
913}
914
915#[cfg(test)]
916impl Client {
917    fn send_probe_at(
918        &mut self,
919        timestamps: ProbeSendTimestamps,
920    ) -> Result<Vec<ClientEvent>, ClientError> {
921        self.probe_send_timestamps = Some(timestamps);
922        self.send_probe()
923    }
924
925    fn send_managed_probe_at(
926        &mut self,
927        scheduled_at: Instant,
928        timestamps: ProbeSendTimestamps,
929    ) -> Result<Vec<ClientEvent>, ClientError> {
930        self.probe_send_timestamps = Some(timestamps);
931        self.send_managed_probe(scheduled_at)
932    }
933}
934
935#[cfg(test)]
936fn bounded_receive_timeout(
937    deadline: Option<Instant>,
938    configured_timeout: Option<Duration>,
939    max_wait: Duration,
940    now: Instant,
941) -> Option<Duration> {
942    if max_wait.is_zero() {
943        return None;
944    }
945    let mut timeout = configured_timeout.unwrap_or(max_wait).min(max_wait);
946    if let Some(deadline) = deadline {
947        let remaining = deadline.checked_duration_since(now)?;
948        if remaining.is_zero() {
949            return None;
950        }
951        timeout = timeout.min(remaining);
952    }
953    (!timeout.is_zero()).then_some(timeout)
954}
955
956#[cfg(test)]
957mod tests;