Skip to main content

irtt_client/
async_client.rs

1use std::{
2    future::{poll_fn, Future},
3    io,
4    net::SocketAddr,
5    pin::Pin,
6    task::{Context, Poll},
7    time::{Duration, Instant},
8};
9
10#[cfg(test)]
11use std::{
12    cell::{Cell, RefCell},
13    collections::VecDeque,
14};
15
16use tokio::time;
17
18use crate::{
19    client::{
20        echo_sent_event,
21        schedule::{instant_abs_diff, ProbeSchedule},
22        validate_datagram_length,
23    },
24    config::ClientConfig,
25    error::ClientError,
26    event::{ClientEvent, OpenOutcome},
27    receive::{drain_tx_timestamps, try_enable_tx_timestamping, try_recv_tokio_datagram},
28    session::machine::{
29        recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
30        PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
31    },
32    socket::{connect_tokio_udp_socket, resolve_remote_tokio, validate_open_timeouts},
33    socket_options::{apply_traffic_class_to_tokio_socket, clear_dscp_on_tokio_socket},
34    timing::ClientTimestamp,
35};
36
37/// Maximum amount of immediately available opening work performed by one poll.
38///
39/// Opening may consume ignored UDP datagrams, roll over an expired attempt, or
40/// encounter readiness false positives without ever awaiting.  Bound that work
41/// so one managed target cannot retain the current-thread runtime.
42const OPEN_POLL_WORK_BUDGET: usize = 64;
43
44/// Maximum immediately available receive work performed by one
45/// [`AsyncClient::poll_recv`] poll.
46///
47/// A `WouldBlock` readiness false positive is naturally bounded: `try_io`
48/// clears the reactor's readiness on `WouldBlock`, so the next
49/// `poll_recv_ready` genuinely awaits a fresh edge. An `Interrupted`
50/// (`EINTR`) nonblocking `recvmsg` does not clear that readiness — the
51/// syscall was interrupted, not told there was nothing to read — so without
52/// an explicit bound a sustained signal delivery rate could otherwise retry
53/// in a tight loop on this executor thread. Bounding both cases the same way
54/// this crate already bounds open-attempt work keeps that impossible.
55const RECV_POLL_WORK_BUDGET: usize = 64;
56
57#[cfg(test)]
58use crate::client::ProbeSendTimestamps;
59
60#[derive(Debug)]
61struct PreparedAsyncOpen {
62    machine: PreparedOpenAcceptance,
63    schedule: Option<ProbeSchedule>,
64    recv_buffer_len: Option<usize>,
65    negotiated_traffic_class: Option<u8>,
66}
67
68#[derive(Debug)]
69struct PreparedAsyncOpenFailure {
70    primary: ClientError,
71    machine: PreparedOpenAcceptance,
72}
73
74#[derive(Debug)]
75enum AsyncOpenCleanup {
76    Acceptance {
77        primary: ClientError,
78        packet: Option<Box<[u8]>>,
79    },
80    Adapter(Box<PreparedAsyncOpenFailure>),
81}
82
83impl AsyncOpenCleanup {
84    fn packet(&self) -> Option<&[u8]> {
85        match self {
86            Self::Acceptance { packet, .. } => packet.as_deref(),
87            Self::Adapter(failure) => failure.machine.cleanup_close_packet(),
88        }
89    }
90
91    fn into_primary(self) -> ClientError {
92        match self {
93            Self::Acceptance { primary, .. } => primary,
94            Self::Adapter(failure) => failure.primary,
95        }
96    }
97}
98
99/// Owned state for one asynchronous open transaction.
100///
101/// This state deliberately contains no reference to [`AsyncClient`], allowing
102/// a future managed target to store the client and opening state side by side.
103#[derive(Debug)]
104pub(crate) struct AsyncOpenState {
105    attempt: usize,
106    deadline: Option<Instant>,
107    deadline_timer: Option<Pin<Box<time::Sleep>>>,
108    request_submitted: bool,
109    stop_after_current_attempt: bool,
110    stopped_after_current_attempt: bool,
111    buffer: [u8; MAX_OPEN_PACKET_SIZE],
112    cleanup: Option<AsyncOpenCleanup>,
113}
114
115impl AsyncOpenState {
116    pub(crate) fn new() -> Self {
117        Self {
118            attempt: 0,
119            deadline: None,
120            deadline_timer: None,
121            request_submitted: false,
122            stop_after_current_attempt: false,
123            stopped_after_current_attempt: false,
124            buffer: [0_u8; MAX_OPEN_PACKET_SIZE],
125            cleanup: None,
126        }
127    }
128
129    fn start_attempt(&mut self, timeout: Duration) -> Result<(), ClientError> {
130        let deadline = Instant::now()
131            .checked_add(timeout)
132            .ok_or(ClientError::DurationOverflow)?;
133        self.deadline = Some(deadline);
134        self.deadline_timer = Some(Box::pin(time::sleep_until(deadline.into())));
135        self.request_submitted = false;
136        Ok(())
137    }
138
139    fn finish_attempt(&mut self) {
140        self.attempt += 1;
141        self.deadline = None;
142        self.deadline_timer = None;
143        self.request_submitted = false;
144        self.stopped_after_current_attempt = self.stop_after_current_attempt;
145    }
146
147    pub(crate) fn has_in_flight_work(&self) -> bool {
148        self.request_submitted || self.cleanup.is_some()
149    }
150
151    pub(crate) fn request_stop_after_current_attempt(&mut self) {
152        self.stop_after_current_attempt = true;
153    }
154
155    pub(crate) fn stopped_after_current_attempt(&self) -> bool {
156        self.stopped_after_current_attempt
157    }
158
159    #[cfg(test)]
160    pub(crate) fn has_retained_cleanup(&self) -> bool {
161        self.cleanup.is_some()
162    }
163
164    fn deadline(&self) -> Instant {
165        self.deadline
166            .expect("an open attempt has an active deadline")
167    }
168
169    fn deadline_timer(&mut self) -> &mut Pin<Box<time::Sleep>> {
170        self.deadline_timer
171            .as_mut()
172            .expect("an open attempt has an active deadline timer")
173    }
174}
175
176#[cfg(test)]
177#[derive(Debug)]
178enum InjectedSend {
179    WouldBlock,
180    Error,
181    ReportedLength(usize),
182}
183
184#[cfg(test)]
185#[derive(Debug, Default)]
186struct AsyncClientTestHooks {
187    sends: RefCell<VecDeque<InjectedSend>>,
188    send_attempts: Cell<usize>,
189    receive_would_block: Cell<usize>,
190    /// Number of subsequent receive attempts (`poll_open` or `poll_recv`)
191    /// that should report `io::ErrorKind::Interrupted` instead of performing
192    /// a real nonblocking receive, deterministically exercising interrupted-
193    /// syscall retry behavior.
194    inject_recv_interrupted: Cell<usize>,
195    fail_open_dscp: Cell<bool>,
196    fail_cleanup_send: Cell<bool>,
197    fail_peer_close_dscp: Cell<bool>,
198    fail_dscp_restore: Cell<bool>,
199    pause_open_cleanup_before_send: Cell<bool>,
200    pause_open_before_writable: Cell<bool>,
201    pause_open_before_readable: Cell<bool>,
202    pause_probe_before_writable: Cell<bool>,
203    pause_close_before_writable: Cell<bool>,
204    pause_close_after_would_block: Cell<bool>,
205    probe_timestamps: RefCell<VecDeque<ProbeSendTimestamps>>,
206    close_sent_at: Cell<Option<ClientTimestamp>>,
207}
208
209#[cfg(test)]
210impl AsyncClientTestHooks {
211    fn try_send(&self, socket: &tokio::net::UdpSocket, packet: &[u8]) -> io::Result<usize> {
212        self.send_attempts.set(self.send_attempts.get() + 1);
213        if let Some(injected) = self.sends.borrow_mut().pop_front() {
214            return match injected {
215                InjectedSend::WouldBlock => Err(io::Error::from(io::ErrorKind::WouldBlock)),
216                InjectedSend::Error => Err(io::Error::other("injected send failure")),
217                InjectedSend::ReportedLength(bytes) => Ok(bytes),
218            };
219        }
220        socket.try_send(packet)
221    }
222
223    fn take_probe_timestamps(&self) -> Option<ProbeSendTimestamps> {
224        self.probe_timestamps.borrow_mut().pop_front()
225    }
226
227    /// Consumes one pending injected interruption, if any, reporting whether
228    /// the caller should report `io::ErrorKind::Interrupted` instead of
229    /// attempting a real receive.
230    fn take_injected_interrupted(&self) -> bool {
231        let remaining = self.inject_recv_interrupted.get();
232        if remaining == 0 {
233            return false;
234        }
235        self.inject_recv_interrupted.set(remaining - 1);
236        true
237    }
238}
239
240/// Low-level Tokio IRTT client for one connected UDP target.
241///
242/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
243/// methods are polled by the caller and require a current Tokio runtime with I/O
244/// and time enabled.
245///
246/// # Example
247///
248/// Use this low-level adapter when the application already owns Tokio and wants
249/// to drive the session lifecycle itself. This example type-checks without
250/// contacting a server.
251///
252/// ```no_run
253/// use irtt_client::{AsyncClient, ClientConfig};
254///
255/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
256/// let mut client = AsyncClient::connect(ClientConfig::default()).await?;
257/// let outcome = client.open().await?;
258/// println!("opened: {outcome:?}");
259/// let sent = client.send_probe().await?;
260/// println!("sent: {sent:?}");
261/// let received = client.recv().await?;
262/// println!("received: {received:?}");
263/// let timed_out = client.poll_timeouts()?;
264/// println!("timeouts: {timed_out:?}");
265/// let closed = client.close().await?;
266/// println!("closed: {closed:?}");
267/// # Ok(())
268/// # }
269/// ```
270#[derive(Debug)]
271pub struct AsyncClient {
272    socket: tokio::net::UdpSocket,
273    machine: SessionMachine,
274    schedule: Option<ProbeSchedule>,
275    remote: SocketAddr,
276    recv_buffer: Vec<u8>,
277    applied_traffic_class: Option<u8>,
278    prepared_open: Option<PreparedOpenRequest>,
279    prepared_probe: Option<PreparedProbe>,
280    /// See `Client::tx_timestamping_enabled` in `client.rs` for the
281    /// invariant this tracks; it is the same adapter-owned capability flag.
282    tx_timestamping_enabled: bool,
283    #[cfg(test)]
284    test_hooks: AsyncClientTestHooks,
285}
286
287impl AsyncClient {
288    /// Resolve the configured server and construct one connected Tokio UDP
289    /// socket.
290    ///
291    /// Polling this future without a current Tokio runtime returns
292    /// [`ClientError::NoTokioRuntime`]. A runtime without enabled I/O or time
293    /// drivers is outside this type's runtime contract.
294    pub async fn connect(config: ClientConfig) -> Result<Self, ClientError> {
295        tokio::runtime::Handle::try_current().map_err(|_| ClientError::NoTokioRuntime)?;
296        validate_open_timeouts(&config.open_timeouts)?;
297        let remote = resolve_remote_tokio(&config).await?;
298        let machine = SessionMachine::new(config.clone(), remote)?;
299        let prepared_open = machine.prepare_open_request()?;
300        let socket = connect_tokio_udp_socket(&config.socket_config, remote)?;
301
302        Ok(Self {
303            socket,
304            machine,
305            schedule: None,
306            remote,
307            recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
308            applied_traffic_class: None,
309            prepared_open: Some(prepared_open),
310            prepared_probe: None,
311            tx_timestamping_enabled: false,
312            #[cfg(test)]
313            test_hooks: AsyncClientTestHooks::default(),
314        })
315    }
316
317    /// Perform the IRTT open exchange.
318    ///
319    /// Each configured attempt uses one absolute deadline for one successful
320    /// request submission and all replies inspected during that attempt.
321    /// Malformed, unrelated, wrong-direction, and unauthenticated datagrams are
322    /// ignored without resending the request.
323    ///
324    /// Dropping this future before trusted acceptance leaves the local machine
325    /// connected with no committed schedule or negotiated adapter state. If it
326    /// is dropped during best-effort post-token cleanup, cleanup may remain
327    /// incomplete because this low-level client never detaches cleanup work.
328    pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
329        let mut state = AsyncOpenState::new();
330        poll_fn(|cx| self.poll_open(&mut state, cx)).await
331    }
332
333    /// Send one caller-paced probe with transactional protocol and schedule
334    /// commits.
335    ///
336    /// A prepared packet is retained across readiness false positives,
337    /// cancellation, and socket errors so a later call retries the same logical
338    /// probe without advancing state before kernel acceptance.
339    pub async fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
340        poll_fn(|cx| self.poll_send_probe(cx)).await
341    }
342
343    /// Await and classify one complete UDP datagram.
344    ///
345    /// State is validated before readiness is awaited. Readiness false positives
346    /// retry without changing protocol state. Authenticated peer-close events
347    /// remain authoritative even if best-effort DSCP cleanup fails.
348    pub async fn recv(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
349        poll_fn(|cx| self.poll_recv(cx)).await
350    }
351
352    /// Poll protocol timeouts using a newly captured monotonic timestamp.
353    pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
354        self.poll_timeouts_at(Instant::now())
355    }
356
357    /// Poll protocol timeouts using the caller's monotonic timestamp.
358    pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
359        self.drain_tx_timestamps()?;
360        self.machine.poll_timeouts_at(now)
361    }
362
363    pub(crate) fn poll_timeouts_bounded_at(
364        &mut self,
365        now: Instant,
366        limit: usize,
367    ) -> Result<TimeoutBatch, ClientError> {
368        self.drain_tx_timestamps()?;
369        self.machine.poll_timeouts_bounded_at(now, limit)
370    }
371
372    /// Send the retained close packet and commit local close exactly once.
373    ///
374    /// DSCP is cleared only after write readiness and immediately before the
375    /// nonblocking send attempt. A `WouldBlock` result restores it before the
376    /// next await, so dropping this future at a suspension point cannot leave
377    /// an otherwise-open session with cleared DSCP.
378    pub async fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
379        poll_fn(|cx| self.poll_close(cx)).await
380    }
381
382    pub(crate) fn poll_open(
383        &mut self,
384        state: &mut AsyncOpenState,
385        cx: &mut Context<'_>,
386    ) -> Poll<Result<OpenOutcome, ClientError>> {
387        if self.prepared_open.is_none() {
388            if let Err(error) = self.machine.prepare_open_request() {
389                return Poll::Ready(Err(error));
390            }
391            unreachable!("connected async clients retain an open request");
392        }
393
394        for _ in 0..OPEN_POLL_WORK_BUDGET {
395            if state.cleanup.is_some() {
396                return self.poll_open_cleanup(state, cx);
397            }
398
399            let attempt_count = self.machine.config().open_timeouts.len();
400            if state.attempt >= attempt_count {
401                return Poll::Ready(Err(ClientError::OpenTimeout));
402            }
403            if state.stop_after_current_attempt && state.deadline.is_none() {
404                state.stopped_after_current_attempt = true;
405                return Poll::Ready(Err(ClientError::OpenTimeout));
406            }
407            if state.deadline.is_none() {
408                let timeout = self.machine.config().open_timeouts[state.attempt];
409                if let Err(error) = state.start_attempt(timeout) {
410                    return Poll::Ready(Err(error));
411                }
412            }
413
414            if !state.request_submitted {
415                #[cfg(test)]
416                if self.test_hooks.pause_open_before_writable.replace(false) {
417                    return Poll::Pending;
418                }
419                match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
420                    Poll::Pending => return Poll::Pending,
421                    Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
422                    Poll::Ready(Ok(false)) => {
423                        state.finish_attempt();
424                        continue;
425                    }
426                    Poll::Ready(Ok(true)) => {}
427                }
428
429                let request = self
430                    .prepared_open
431                    .as_ref()
432                    .expect("connected clients retain their prepared open request");
433                #[cfg(test)]
434                let send_result = self.test_hooks.try_send(&self.socket, &request.bytes);
435                #[cfg(not(test))]
436                let send_result = self.socket.try_send(&request.bytes);
437                match send_result {
438                    Ok(bytes) => {
439                        state.request_submitted = true;
440                        if let Err(error) = validate_datagram_length(request.bytes.len(), bytes) {
441                            return Poll::Ready(Err(error));
442                        }
443                    }
444                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
445                    Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
446                }
447            }
448
449            #[cfg(test)]
450            if self.test_hooks.pause_open_before_readable.replace(false) {
451                return Poll::Pending;
452            }
453            match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
454                Poll::Pending => return Poll::Pending,
455                Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
456                Poll::Ready(Ok(false)) => {
457                    state.finish_attempt();
458                    if state.stopped_after_current_attempt() {
459                        return Poll::Ready(Err(ClientError::OpenTimeout));
460                    }
461                    continue;
462                }
463                Poll::Ready(Ok(true)) => {}
464            }
465
466            #[cfg(test)]
467            if self.test_hooks.take_injected_interrupted() {
468                continue;
469            }
470            let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
471                Ok(datagram) => datagram,
472                // WouldBlock is a readiness false positive; Interrupted
473                // (EINTR) is a transient nonblocking-recvmsg interruption.
474                // Both retry through the surrounding `OPEN_POLL_WORK_BUDGET`-
475                // bounded loop above, which re-checks readiness before the
476                // next attempt and yields back to the executor once that
477                // budget is exhausted.
478                Err(error)
479                    if matches!(
480                        error.kind(),
481                        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
482                    ) =>
483                {
484                    continue
485                }
486                Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
487            };
488            if datagram.received_at.mono > state.deadline() {
489                state.finish_attempt();
490                if state.stopped_after_current_attempt() {
491                    return Poll::Ready(Err(ClientError::OpenTimeout));
492                }
493                continue;
494            }
495
496            let reply = match self
497                .machine
498                .inspect_open_datagram(&state.buffer[..datagram.len])
499            {
500                Ok(OpenDatagramDisposition::Ignore) => continue,
501                Ok(OpenDatagramDisposition::Trusted(reply)) => reply,
502                Err(error) => return Poll::Ready(Err(error)),
503            };
504            let machine = match self
505                .machine
506                .prepare_open_acceptance(reply, datagram.received_at)
507            {
508                Ok(machine) => machine,
509                Err(failure) => {
510                    state.cleanup = Some(AsyncOpenCleanup::Acceptance {
511                        primary: failure.primary,
512                        packet: failure.cleanup_close,
513                    });
514                    continue;
515                }
516            };
517            let prepared = match self.prepare_async_open(machine, datagram.received_at) {
518                Ok(prepared) => prepared,
519                Err(failure) => {
520                    state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
521                    continue;
522                }
523            };
524            return Poll::Ready(Ok(self.commit_async_open(prepared)));
525        }
526
527        // We deliberately truncated known immediate opening work.  Arrange for
528        // another poll even if no new socket-readiness edge occurs.
529        cx.waker().wake_by_ref();
530        Poll::Pending
531    }
532
533    pub(crate) fn poll_send_probe(
534        &mut self,
535        cx: &mut Context<'_>,
536    ) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
537        if let Err(error) = self.machine.ensure_open() {
538            return Poll::Ready(Err(error));
539        }
540        if self.prepared_probe.is_none() {
541            match self.machine.prepare_probe() {
542                Ok(prepared) => self.prepared_probe = prepared,
543                Err(error) => return Poll::Ready(Err(error)),
544            }
545        }
546        if self.prepared_probe.is_none() {
547            return Poll::Ready(Ok(Vec::new()));
548        }
549
550        loop {
551            #[cfg(test)]
552            if self.test_hooks.pause_probe_before_writable.replace(false) {
553                return Poll::Pending;
554            }
555            match self.socket.poll_send_ready(cx) {
556                Poll::Pending => return Poll::Pending,
557                Poll::Ready(Ok(())) => {}
558                Poll::Ready(Err(error)) => {
559                    return Poll::Ready(Err(ClientError::Socket(error)));
560                }
561            }
562
563            #[cfg(test)]
564            let test_timestamps = self.test_hooks.take_probe_timestamps();
565            #[cfg(not(test))]
566            let permission_at = Instant::now();
567            #[cfg(test)]
568            let permission_at = test_timestamps
569                .map(|timestamps| timestamps.permission_at)
570                .unwrap_or_else(Instant::now);
571
572            let schedule = self
573                .schedule
574                .as_mut()
575                .expect("open sessions always have a probe schedule");
576            if !schedule.permit_probe_at(permission_at) {
577                self.prepared_probe = None;
578                return Poll::Ready(Ok(Vec::new()));
579            }
580            let prepared = self
581                .prepared_probe
582                .as_ref()
583                .expect("prepared probe was retained across readiness");
584            let machine_preflight = match self.machine.preflight_probe_commit(prepared) {
585                Ok(preflight) => preflight,
586                Err(error) => return Poll::Ready(Err(error)),
587            };
588            let schedule_commit =
589                match schedule.preflight_caller_commit(machine_preflight.next_packets_sent) {
590                    Ok(commit) => commit,
591                    Err(error) => return Poll::Ready(Err(error)),
592                };
593            let mut events = Vec::new();
594            if let Err(source) = events.try_reserve(1) {
595                return Poll::Ready(Err(ClientError::AllocationFailed {
596                    operation: "probe event result",
597                    source,
598                }));
599            }
600
601            let expected_bytes = prepared.bytes.len();
602            let scheduled_at = schedule_commit.scheduled_at;
603            // Private pre-send anchor: finalizes all fallible commit work
604            // (timeout deadline arithmetic, kernel TX lower bound) before
605            // the send attempt. A WouldBlock retry samples a fresh one on
606            // the next loop iteration; only the successful attempt's anchor
607            // is retained (via `machine_commit`/`commit_probe_sent` below).
608            #[cfg(not(test))]
609            let send_anchor = ClientTimestamp::now();
610            #[cfg(test)]
611            let send_anchor = test_timestamps
612                .map(|timestamps| timestamps.send_anchor)
613                .unwrap_or_else(ClientTimestamp::now);
614            let machine_commit = match self
615                .machine
616                .finalize_probe_commit(machine_preflight, send_anchor)
617            {
618                Ok(commit) => commit,
619                Err(error) => return Poll::Ready(Err(error)),
620            };
621            #[cfg(not(test))]
622            let send_call_start = Instant::now();
623            #[cfg(test)]
624            let send_call_start = test_timestamps
625                .map(|timestamps| timestamps.send_call_start)
626                .unwrap_or_else(Instant::now);
627            #[cfg(test)]
628            let send_result = self.test_hooks.try_send(&self.socket, &prepared.bytes);
629            #[cfg(not(test))]
630            let send_result = self.socket.try_send(&prepared.bytes);
631            let bytes = match send_result {
632                Ok(bytes) => bytes,
633                // No sent_at measurement, no machine commit, no schedule
634                // commit: the transaction is fully discarded and retried
635                // with a fresh send_anchor on the next loop iteration.
636                Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
637                    self.machine.invalidate_kernel_tx_correlation();
638                    continue;
639                }
640                Err(error) => {
641                    self.machine.invalidate_kernel_tx_correlation();
642                    return Poll::Ready(Err(ClientError::Socket(error)));
643                }
644            };
645
646            #[cfg(not(test))]
647            let send_finished_at = Instant::now();
648            #[cfg(test)]
649            let send_finished_at = test_timestamps
650                .map(|timestamps| timestamps.send_finished_at)
651                .unwrap_or_else(Instant::now);
652            // Public post-send measurement, captured only after the
653            // successful send above. The subsequent machine/schedule commit
654            // is infallible.
655            #[cfg(not(test))]
656            let sent_at = ClientTimestamp::now();
657            #[cfg(test)]
658            let sent_at = test_timestamps
659                .map(|timestamps| timestamps.sent_at)
660                .unwrap_or_else(ClientTimestamp::now);
661            let sent = self
662                .machine
663                .commit_probe_sent(machine_commit, sent_at, bytes);
664            schedule.commit(schedule_commit);
665            self.prepared_probe = None;
666
667            let send_call = send_finished_at.saturating_duration_since(send_call_start);
668            let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
669            if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
670                return Poll::Ready(Err(error));
671            }
672            if let Err(error) = self.drain_tx_timestamps() {
673                return Poll::Ready(Err(error));
674            }
675            events.push(echo_sent_event(
676                self.remote,
677                sent,
678                send_call,
679                scheduled_at,
680                timer_error,
681            ));
682            return Poll::Ready(Ok(events));
683        }
684    }
685
686    pub(crate) fn poll_recv(
687        &mut self,
688        cx: &mut Context<'_>,
689    ) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
690        if let Err(error) = self.machine.ensure_open() {
691            return Poll::Ready(Err(error));
692        }
693
694        for _ in 0..RECV_POLL_WORK_BUDGET {
695            match self.socket.poll_recv_ready(cx) {
696                Poll::Pending => return Poll::Pending,
697                Poll::Ready(Ok(())) => {}
698                Poll::Ready(Err(error)) => {
699                    return Poll::Ready(Err(ClientError::Socket(error)));
700                }
701            }
702            #[cfg(test)]
703            if self.test_hooks.receive_would_block.get() > 0 {
704                self.test_hooks
705                    .receive_would_block
706                    .set(self.test_hooks.receive_would_block.get() - 1);
707                continue;
708            }
709            #[cfg(test)]
710            if self.test_hooks.take_injected_interrupted() {
711                continue;
712            }
713            let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
714                Ok(datagram) => datagram,
715                // See `RECV_POLL_WORK_BUDGET`: both a readiness false
716                // positive and an interrupted nonblocking recvmsg retry
717                // within this bounded loop rather than failing the receive.
718                Err(error)
719                    if matches!(
720                        error.kind(),
721                        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
722                    ) =>
723                {
724                    continue
725                }
726                Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
727            };
728
729            // One final bounded drain before processing the reply, so a TX
730            // timestamp that raced the reply is still associated with its
731            // probe before that probe is looked up and possibly removed
732            // below.
733            if let Err(error) = self.drain_tx_timestamps() {
734                return Poll::Ready(Err(error));
735            }
736
737            let events = match self.machine.process_received_echo_packet(
738                &self.recv_buffer[..datagram.len],
739                datagram.received_at,
740                datagram.meta,
741            ) {
742                Ok(events) => events,
743                Err(error) => return Poll::Ready(Err(error)),
744            };
745            if self.machine.is_peer_closed() {
746                self.schedule = None;
747                self.prepared_probe = None;
748                if self.clear_peer_close_dscp().is_ok() {
749                    self.applied_traffic_class = None;
750                }
751            }
752            return Poll::Ready(Ok(events));
753        }
754
755        // Truncated known immediate receive work, exactly as `poll_open`
756        // does: arrange for another poll even if no new socket-readiness
757        // edge occurs, rather than spinning this executor thread further.
758        cx.waker().wake_by_ref();
759        Poll::Pending
760    }
761
762    pub(crate) fn poll_close(
763        &mut self,
764        cx: &mut Context<'_>,
765    ) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
766        loop {
767            let prepared = match self.machine.prepare_close() {
768                Ok(prepared) => prepared,
769                Err(error) => return Poll::Ready(Err(error)),
770            };
771            let mut events = Vec::new();
772            if let Err(source) = events.try_reserve(1) {
773                return Poll::Ready(Err(ClientError::AllocationFailed {
774                    operation: "close event result",
775                    source,
776                }));
777            }
778            let previous_traffic_class = self.applied_traffic_class;
779            let expected_bytes = prepared.bytes.len();
780
781            #[cfg(test)]
782            if self.test_hooks.pause_close_before_writable.replace(false) {
783                return Poll::Pending;
784            }
785            match self.socket.poll_send_ready(cx) {
786                Poll::Pending => return Poll::Pending,
787                Poll::Ready(Ok(())) => {}
788                Poll::Ready(Err(error)) => {
789                    return Poll::Ready(Err(ClientError::Socket(error)));
790                }
791            }
792            if let Err(error) = self.clear_close_dscp() {
793                return Poll::Ready(Err(error));
794            }
795            let mut rollback = DscpRollback::armed(
796                &self.socket,
797                self.remote,
798                previous_traffic_class,
799                #[cfg(test)]
800                &self.test_hooks.fail_dscp_restore,
801            );
802            #[cfg(test)]
803            let send_result = self.test_hooks.try_send(&self.socket, prepared.bytes);
804            #[cfg(not(test))]
805            let send_result = self.socket.try_send(prepared.bytes);
806            let bytes = match send_result {
807                Ok(bytes) => bytes,
808                Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
809                    if let Err(error) = rollback.restore() {
810                        return Poll::Ready(Err(error));
811                    }
812                    #[cfg(test)]
813                    if self.test_hooks.pause_close_after_would_block.replace(false) {
814                        return Poll::Pending;
815                    }
816                    continue;
817                }
818                Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
819            };
820
821            #[cfg(not(test))]
822            let close_sent_at = ClientTimestamp::now();
823            #[cfg(test)]
824            let close_sent_at = self
825                .test_hooks
826                .close_sent_at
827                .take()
828                .unwrap_or_else(ClientTimestamp::now);
829            let event = self
830                .machine
831                .commit_local_close(prepared.commit, close_sent_at);
832            rollback.disarm();
833            self.schedule = None;
834            self.prepared_probe = None;
835            self.applied_traffic_class = None;
836
837            if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
838                return Poll::Ready(Err(error));
839            }
840            events.push(event);
841            return Poll::Ready(Ok(events));
842        }
843    }
844
845    /// Return the next scheduled probe deadline.
846    pub fn next_send_deadline(&self) -> Option<Instant> {
847        if !self.machine.is_open() {
848            return None;
849        }
850        self.schedule.as_ref()?.next_send_deadline()
851    }
852
853    pub(crate) fn probe_interval(&self) -> Option<Duration> {
854        if !self.machine.is_open() {
855            return None;
856        }
857        self.schedule.as_ref().map(ProbeSchedule::interval)
858    }
859
860    /// Return the configured local probe timeout.
861    pub fn probe_timeout(&self) -> Duration {
862        self.machine.probe_timeout()
863    }
864
865    /// Return whether the current run has completed.
866    pub fn is_run_complete(&self) -> bool {
867        self.machine.is_terminal()
868            || self
869                .schedule
870                .as_ref()
871                .is_some_and(|schedule| schedule.is_finished() && self.machine.pending_is_empty())
872    }
873
874    /// Return whether an authenticated peer close ended the session.
875    pub fn is_peer_closed(&self) -> bool {
876        self.machine.is_peer_closed()
877    }
878
879    pub(crate) fn remote_addr(&self) -> SocketAddr {
880        self.remote
881    }
882
883    pub(crate) fn packets_sent(&self) -> u64 {
884        self.machine.packets_sent()
885    }
886
887    pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
888        self.machine.next_probe_timeout_deadline()
889    }
890
891    pub(crate) fn latest_probe_timeout_deadline(&self) -> Option<Instant> {
892        self.machine.latest_probe_timeout_deadline()
893    }
894
895    pub(crate) fn discard_prepared_probe(&mut self) {
896        self.prepared_probe = None;
897    }
898
899    #[cfg(test)]
900    pub(crate) fn retain_open_cleanup_once(&self) {
901        self.test_hooks.fail_open_dscp.set(true);
902        self.test_hooks.pause_open_cleanup_before_send.set(true);
903    }
904
905    #[cfg(test)]
906    pub(crate) fn poll_recv_ready_for_test(&self, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
907        self.socket.poll_recv_ready(cx)
908    }
909
910    #[cfg(test)]
911    pub(crate) fn remove_pending_for_test(
912        &mut self,
913        wire_seq: u32,
914    ) -> Option<crate::probe::PendingProbe> {
915        self.machine.remove_pending_for_test(wire_seq)
916    }
917
918    #[cfg(test)]
919    pub(crate) fn replace_pending_for_test(&mut self, probe: crate::probe::PendingProbe) {
920        self.machine.replace_pending_for_test(probe);
921    }
922
923    pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
924        let Some(schedule) = self.schedule.as_mut() else {
925            return Ok(());
926        };
927        let Some(deadline) = schedule.next_send_deadline() else {
928            return Ok(());
929        };
930        if deadline > now {
931            return Ok(());
932        }
933        let commit = schedule.preflight_managed_commit(deadline, now)?;
934        schedule.commit(commit);
935        Ok(())
936    }
937
938    fn prepare_async_open(
939        &mut self,
940        machine: PreparedOpenAcceptance,
941        opened_at: ClientTimestamp,
942    ) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
943        let Some(negotiated) = machine.normal_negotiated() else {
944            return Ok(PreparedAsyncOpen {
945                machine,
946                schedule: None,
947                recv_buffer_len: None,
948                negotiated_traffic_class: None,
949            });
950        };
951        let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
952            Ok(schedule) => schedule,
953            Err(primary) => {
954                return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
955            }
956        };
957        let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
958            Ok(size) => size,
959            Err(primary) => {
960                return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
961            }
962        };
963        let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
964            Ok(traffic_class) => traffic_class,
965            Err(_) => {
966                return Err(Box::new(PreparedAsyncOpenFailure {
967                    primary: ClientError::InvalidConfig {
968                        reason: "negotiated dscp must be in range 0..=255".to_owned(),
969                    },
970                    machine,
971                }));
972            }
973        };
974        let additional = recv_buffer_len.saturating_sub(self.recv_buffer.len());
975        if let Err(source) = self.recv_buffer.try_reserve(additional) {
976            return Err(Box::new(PreparedAsyncOpenFailure {
977                primary: ClientError::AllocationFailed {
978                    operation: "negotiated receive buffer",
979                    source,
980                },
981                machine,
982            }));
983        }
984        #[cfg(test)]
985        let dscp_result = if self.test_hooks.fail_open_dscp.replace(false) {
986            Err(ClientError::SocketOption {
987                operation: "set negotiated DSCP",
988                remote: self.remote,
989                source: io::Error::other("injected negotiated DSCP failure"),
990            })
991        } else {
992            apply_traffic_class_to_tokio_socket(&self.socket, self.remote, negotiated_traffic_class)
993        };
994        #[cfg(not(test))]
995        let dscp_result = apply_traffic_class_to_tokio_socket(
996            &self.socket,
997            self.remote,
998            negotiated_traffic_class,
999        );
1000        if let Err(primary) = dscp_result {
1001            self.restore_dscp_best_effort(self.applied_traffic_class);
1002            return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
1003        }
1004
1005        Ok(PreparedAsyncOpen {
1006            machine,
1007            schedule: Some(schedule),
1008            recv_buffer_len: Some(recv_buffer_len),
1009            negotiated_traffic_class: Some(negotiated_traffic_class),
1010        })
1011    }
1012
1013    fn commit_async_open(&mut self, prepared: PreparedAsyncOpen) -> OpenOutcome {
1014        if let Some(recv_buffer_len) = prepared.recv_buffer_len {
1015            self.recv_buffer.resize(recv_buffer_len, 0);
1016        }
1017        let outcome = self.machine.commit_open(prepared.machine);
1018        self.schedule = prepared.schedule;
1019        self.applied_traffic_class = prepared.negotiated_traffic_class;
1020        self.prepared_open = None;
1021        self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
1022        outcome
1023    }
1024
1025    /// See `Client::drain_tx_timestamps` in `client.rs`: same bounded,
1026    /// nonblocking, best-effort drain, adapted to the Tokio socket.
1027    fn drain_tx_timestamps(&mut self) -> Result<(), ClientError> {
1028        if !self.tx_timestamping_enabled {
1029            return Ok(());
1030        }
1031        let machine = &mut self.machine;
1032        drain_tx_timestamps(&self.socket, |id, timestamp| {
1033            machine.record_kernel_tx_timestamp(id, timestamp);
1034        })
1035        .map_err(ClientError::Socket)
1036    }
1037
1038    fn poll_open_cleanup(
1039        &self,
1040        state: &mut AsyncOpenState,
1041        cx: &mut Context<'_>,
1042    ) -> Poll<Result<OpenOutcome, ClientError>> {
1043        let Some(packet) = state
1044            .cleanup
1045            .as_ref()
1046            .expect("cleanup polling requires a retained primary error")
1047            .packet()
1048        else {
1049            return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
1050        };
1051
1052        #[cfg(test)]
1053        if self
1054            .test_hooks
1055            .pause_open_cleanup_before_send
1056            .replace(false)
1057        {
1058            return Poll::Pending;
1059        }
1060
1061        #[cfg(test)]
1062        if self.test_hooks.fail_cleanup_send.replace(false) {
1063            return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
1064        }
1065        #[cfg(test)]
1066        let send_result = self.test_hooks.try_send(&self.socket, packet);
1067        #[cfg(not(test))]
1068        let send_result = self.socket.try_send(packet);
1069        match send_result {
1070            Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
1071            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
1072            Err(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
1073        }
1074
1075        match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
1076            Poll::Pending => Poll::Pending,
1077            Poll::Ready(Ok(true)) => {
1078                // Retain cleanup state and resume the send in a fresh poll.  A
1079                // writable readiness false positive must not spin this future.
1080                cx.waker().wake_by_ref();
1081                Poll::Pending
1082            }
1083            Poll::Ready(Ok(false) | Err(_)) => {
1084                Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()))
1085            }
1086        }
1087    }
1088
1089    fn clear_peer_close_dscp(&self) -> Result<(), ClientError> {
1090        #[cfg(test)]
1091        if self.test_hooks.fail_peer_close_dscp.replace(false) {
1092            return Err(ClientError::SocketOption {
1093                operation: "clear DSCP before close",
1094                remote: self.remote,
1095                source: io::Error::other("injected peer-close DSCP cleanup failure"),
1096            });
1097        }
1098        clear_dscp_on_tokio_socket(&self.socket, self.remote)
1099    }
1100
1101    fn clear_close_dscp(&self) -> Result<(), ClientError> {
1102        clear_dscp_on_tokio_socket(&self.socket, self.remote)
1103    }
1104
1105    fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
1106        let _ = match previous_traffic_class {
1107            Some(traffic_class) => {
1108                apply_traffic_class_to_tokio_socket(&self.socket, self.remote, traffic_class)
1109            }
1110            None => clear_dscp_on_tokio_socket(&self.socket, self.remote),
1111        };
1112    }
1113}
1114
1115struct DscpRollback<'a> {
1116    socket: &'a tokio::net::UdpSocket,
1117    remote: SocketAddr,
1118    previous_traffic_class: Option<u8>,
1119    armed: bool,
1120    #[cfg(test)]
1121    fail_restore: &'a Cell<bool>,
1122}
1123
1124impl<'a> DscpRollback<'a> {
1125    fn armed(
1126        socket: &'a tokio::net::UdpSocket,
1127        remote: SocketAddr,
1128        previous_traffic_class: Option<u8>,
1129        #[cfg(test)] fail_restore: &'a Cell<bool>,
1130    ) -> Self {
1131        Self {
1132            socket,
1133            remote,
1134            previous_traffic_class,
1135            armed: true,
1136            #[cfg(test)]
1137            fail_restore,
1138        }
1139    }
1140
1141    fn restore(&mut self) -> Result<(), ClientError> {
1142        if !self.armed {
1143            return Ok(());
1144        }
1145        #[cfg(test)]
1146        if self.fail_restore.replace(false) {
1147            return Err(ClientError::SocketOption {
1148                operation: "restore negotiated DSCP",
1149                remote: self.remote,
1150                source: io::Error::other("injected DSCP restore failure"),
1151            });
1152        }
1153        match self.previous_traffic_class {
1154            Some(traffic_class) => {
1155                apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
1156            }
1157            None => clear_dscp_on_tokio_socket(self.socket, self.remote)?,
1158        }
1159        self.armed = false;
1160        Ok(())
1161    }
1162
1163    fn disarm(&mut self) {
1164        self.armed = false;
1165    }
1166}
1167
1168impl Drop for DscpRollback<'_> {
1169    fn drop(&mut self) {
1170        if self.armed {
1171            let _ = self.restore();
1172        }
1173    }
1174}
1175
1176fn poll_writable_until(
1177    socket: &tokio::net::UdpSocket,
1178    deadline: &mut Pin<Box<time::Sleep>>,
1179    cx: &mut Context<'_>,
1180) -> Poll<Result<bool, ClientError>> {
1181    match socket.poll_send_ready(cx) {
1182        Poll::Ready(Ok(())) => Poll::Ready(Ok(true)),
1183        Poll::Ready(Err(error)) => Poll::Ready(Err(ClientError::Socket(error))),
1184        Poll::Pending => match deadline.as_mut().poll(cx) {
1185            Poll::Ready(()) => Poll::Ready(Ok(false)),
1186            Poll::Pending => Poll::Pending,
1187        },
1188    }
1189}
1190
1191fn poll_readable_until(
1192    socket: &tokio::net::UdpSocket,
1193    deadline: &mut Pin<Box<time::Sleep>>,
1194    cx: &mut Context<'_>,
1195) -> Poll<Result<bool, ClientError>> {
1196    match socket.poll_recv_ready(cx) {
1197        Poll::Ready(Ok(())) => Poll::Ready(Ok(true)),
1198        Poll::Ready(Err(error)) => Poll::Ready(Err(ClientError::Socket(error))),
1199        Poll::Pending => match deadline.as_mut().poll(cx) {
1200            Poll::Ready(()) => Poll::Ready(Ok(false)),
1201            Poll::Pending => Poll::Pending,
1202        },
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests;