Skip to main content

snap_tun/
udp_batch.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Helpers for batched UDP receive and transmit operations used by SNAP tunnel I/O.
16//!
17//! `UdpBatchReceiver` batches receive-side work with a fixed compile-time batch size,
18//! while `UdpBatchSender` batches same-sized datagrams to the same destination so the
19//! underlying socket can take advantage of UDP segmentation offload when available.
20//!
21//! Both helpers are intended to be created once per socket and reused. They keep
22//! batch-sized scratch state alive so repeated calls can reuse packet buffers and
23//! socket state instead of rebuilding that state for every receive or flush cycle.
24
25use std::{
26    collections::VecDeque,
27    io,
28    io::IoSliceMut,
29    net::SocketAddr,
30    time::{Duration, Instant},
31};
32
33use ana_gotatun::packet::{Packet, PacketBufPool};
34use quinn_udp::{RecvMeta, Transmit, UdpSockRef, UdpSocketState};
35use tokio::{io::Interest, net::UdpSocket};
36
37const MAX_BATCH_SIZE: usize = 64;
38
39/// Maximum payload of a single UDP datagram, i.e. 64 KiB minus the IPv4 and UDP headers.
40///
41/// The kernel rejects larger sends with `EMSGSIZE`, so this also bounds how many equally
42/// sized datagrams may be coalesced into one segmentation-offloaded transmit. IPv6 without
43/// extension headers would allow 20 bytes more, which this bound conservatively gives up.
44const MAX_UDP_PAYLOAD_SIZE: usize = u16::MAX as usize - 20 - 8;
45
46/// How often a discarded datagram is reported at warning level.
47///
48/// A peer whose datagrams the socket keeps refusing would otherwise produce one warning per
49/// datagram. Matches what `quinn-udp` does for its own send errors.
50const DISCARD_LOG_INTERVAL: Duration = Duration::from_secs(60);
51
52/// The socket was not writable, so the queued datagrams were left in place.
53///
54/// This is the only way [`UdpBatchSender::try_flush_best_effort`] can fail. Datagrams the
55/// socket refuses are discarded by the flush itself, so back pressure is all that remains for
56/// the caller to handle: retry the flush once the socket signals writability again.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct SocketNotWritable;
59
60impl std::fmt::Display for SocketNotWritable {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str("socket not writable, queued datagrams were left in place")
63    }
64}
65
66impl std::error::Error for SocketNotWritable {}
67
68/// Errors returned while receiving and processing a UDP batch.
69pub enum RecvBatchError<E> {
70    /// The socket operation itself failed.
71    Io(io::Error),
72    /// The caller-provided packet handler failed.
73    Handler(E),
74}
75
76/// Errors returned while queueing packets for batched transmission.
77#[derive(Debug)]
78pub enum QueuePacketError {
79    /// The sender queue is full and cannot accept another packet right now.
80    Full {
81        /// The unsent packet.
82        packet: Packet,
83        /// The original target address of the unsent packet.
84        target: SocketAddr,
85    },
86    /// The packet is larger than the configured sender scratch budget.
87    PacketTooLarge {
88        /// The oversized packet.
89        packet: Packet,
90        /// The original target address of the oversized packet.
91        target: SocketAddr,
92        /// The packet length in bytes.
93        packet_len: usize,
94        /// The configured maximum packet size.
95        max_packet_size: usize,
96    },
97}
98
99/// UdpBatchReceiver wraps a standard UDP socket and provides batched receive operations.
100///
101/// It receives up to `BATCH_SIZE` UDP datagrams in one socket read cycle and is
102/// intended to be reused for as long as that socket is active. Reusing it keeps
103/// the receive slots checked out from the pool so repeated receive calls can stay
104/// on the fast path.
105///
106/// `BUFFER_SIZE` controls the size of packet buffers drawn from the provided pool.
107/// `BATCH_SIZE * BUFFER_SIZE` bytes of memory will be reserved for the receive buffer.
108pub struct UdpBatchReceiver<const BATCH_SIZE: usize, const BUFFER_SIZE: usize = 4096> {
109    state: UdpSocketState,
110    recv_meta: [RecvMeta; BATCH_SIZE],
111    recv_slots: [Packet; BATCH_SIZE],
112}
113
114impl<const BATCH_SIZE: usize, const BUFFER_SIZE: usize> UdpBatchReceiver<BATCH_SIZE, BUFFER_SIZE> {
115    /// Creates a receiver configured for a fixed compile-time batch size.
116    ///
117    /// The receiver keeps `BATCH_SIZE` packet buffers checked out from `pool` until
118    /// it is dropped, so callers should typically create one receiver per socket and
119    /// reuse it across receive calls.
120    pub fn new(socket: &UdpSocket, pool: &PacketBufPool<BUFFER_SIZE>) -> io::Result<Self> {
121        assert!(
122            BATCH_SIZE > 0,
123            "UdpBatchReceiver BATCH_SIZE must be greater than zero"
124        );
125        assert!(
126            BATCH_SIZE <= MAX_BATCH_SIZE,
127            "UdpBatchReceiver BATCH_SIZE must not exceed MAX_BATCH_SIZE"
128        );
129        let state = UdpSocketState::new(UdpSockRef::from(socket))?;
130        let recv_slots = std::array::from_fn(|_| pool.get());
131        Ok(Self {
132            state,
133            recv_meta: std::array::from_fn(|_| RecvMeta::default()),
134            recv_slots,
135        })
136    }
137
138    /// Receives a batch of packets and invokes `handler` for each decoded datagram.
139    pub async fn recv_batch<E, F>(
140        &mut self,
141        socket: &UdpSocket,
142        pool: &PacketBufPool<BUFFER_SIZE>,
143        mut handler: F,
144    ) -> Result<(), RecvBatchError<E>>
145    where
146        F: FnMut(Packet, SocketAddr) -> Result<(), E>,
147    {
148        let received = loop {
149            socket.readable().await.map_err(RecvBatchError::Io)?;
150            match socket.try_io(Interest::READABLE, || self.try_recv(socket)) {
151                Ok(count) => break count,
152                Err(err) if err.kind() == io::ErrorKind::WouldBlock => continue,
153                Err(err) => return Err(RecvBatchError::Io(err)),
154            }
155        };
156
157        for index in 0..received {
158            self.handle_received(index, pool, &mut handler)
159                .map_err(RecvBatchError::Handler)?;
160        }
161
162        Ok(())
163    }
164
165    fn handle_received<E, F>(
166        &mut self,
167        index: usize,
168        pool: &PacketBufPool<BUFFER_SIZE>,
169        handler: &mut F,
170    ) -> Result<(), E>
171    where
172        F: FnMut(Packet, SocketAddr) -> Result<(), E>,
173    {
174        // `quinn_udp` can report one large receive buffer together with a stride
175        // when the kernel coalesced multiple datagrams. Split that back into
176        // logical packets here so downstream code keeps its usual one-packet-at-a-time view.
177        let meta = self.recv_meta[index];
178        if meta.len == 0 {
179            return Ok(());
180        }
181        let stride = if meta.stride == 0 {
182            meta.len
183        } else {
184            meta.stride
185        };
186        if stride >= meta.len {
187            // Hand ownership of the filled slot to the caller and immediately put a
188            // fresh buffer back into the slot so the next batch can reuse the same layout.
189            let mut packet = std::mem::replace(&mut self.recv_slots[index], pool.get());
190            packet.truncate(meta.len);
191            handler(packet, meta.addr)?;
192            return Ok(());
193        }
194
195        // Keep the receive slots permanently populated and carve a coalesced buffer
196        // into individually owned segments only when the kernel told us multiple
197        // datagrams were packed into one receive slot.
198        let packet = std::mem::replace(&mut self.recv_slots[index], pool.get());
199        for chunk in packet[..meta.len].chunks(stride) {
200            let mut segment = pool.get();
201            segment[..chunk.len()].copy_from_slice(chunk);
202            segment.truncate(chunk.len());
203            handler(segment, meta.addr)?;
204        }
205        Ok(())
206    }
207
208    fn try_recv(&mut self, socket: &UdpSocket) -> io::Result<usize> {
209        // Keep the receive slots alive across calls and hand them directly to the
210        // socket so a steady-state receive loop does not need to re-acquire buffers
211        // from the pool on every readiness notification.
212        let mut bufs_uninit: [std::mem::MaybeUninit<IoSliceMut<'_>>; BATCH_SIZE] =
213            std::array::from_fn(|_| std::mem::MaybeUninit::uninit());
214        for (index, packet) in self.recv_slots.iter_mut().enumerate() {
215            bufs_uninit[index].write(IoSliceMut::new(packet.as_mut()));
216        }
217        // SAFETY: Every element of `bufs_uninit` was written in the loop above, so
218        // all `BATCH_SIZE` slots are fully initialised. `MaybeUninit<T>` is guaranteed
219        // to have the same size and alignment as `T`, so reinterpreting the
220        // pointer as `*mut IoSliceMut<'_>` is sound. The resulting slice covers
221        // exactly the `BATCH_SIZE` elements that were initialised, and the backing
222        // array lives for the duration of this function.
223        let bufs = unsafe {
224            std::slice::from_raw_parts_mut(
225                bufs_uninit.as_mut_ptr() as *mut IoSliceMut<'_>,
226                BATCH_SIZE,
227            )
228        };
229        self.state
230            .recv(UdpSockRef::from(socket), bufs, &mut self.recv_meta)
231    }
232}
233
234/// Queues up to `BATCH_SIZE` packets for batched UDP transmission.
235///
236/// The sender is intended to be reused for the lifetime of a socket. It keeps a
237/// reusable scratch buffer and a small transmit queue so successive flushes do not
238/// need to rebuild that state from scratch.
239///
240/// `MAX_PACKET_SIZE` determines the capacity reserved for the transmit scratch buffer.
241pub struct UdpBatchSender<const BATCH_SIZE: usize, const MAX_PACKET_SIZE: usize = 4096> {
242    state: UdpSocketState,
243    queued_packets: VecDeque<(SocketAddr, Packet)>,
244    scratch: Vec<u8>,
245    /// Datagrams discarded since [`UdpBatchSender::take_discarded_datagrams`] was last called.
246    discarded_datagrams: u64,
247    /// When a discarded datagram was last reported at warning level.
248    last_discard_log: Option<Instant>,
249    /// Datagrams discarded since the last warning, reported with the next one.
250    discards_since_log: u64,
251}
252
253impl<const BATCH_SIZE: usize, const MAX_PACKET_SIZE: usize>
254    UdpBatchSender<BATCH_SIZE, MAX_PACKET_SIZE>
255{
256    /// Creates a sender configured for a fixed compile-time batch size.
257    ///
258    /// Callers should generally create one sender per socket and reuse it across
259    /// queue/flush cycles so the queue and scratch storage stay hot.
260    pub fn new(socket: &UdpSocket) -> io::Result<Self> {
261        assert!(
262            BATCH_SIZE > 0,
263            "UdpBatchSender BATCH_SIZE must be greater than zero"
264        );
265        assert!(
266            BATCH_SIZE <= MAX_BATCH_SIZE,
267            "UdpBatchSender BATCH_SIZE must not exceed MAX_BATCH_SIZE"
268        );
269        Ok(Self {
270            state: UdpSocketState::new(UdpSockRef::from(socket))?,
271            queued_packets: VecDeque::with_capacity(BATCH_SIZE),
272            scratch: Vec::with_capacity(MAX_PACKET_SIZE * BATCH_SIZE),
273            discarded_datagrams: 0,
274            last_discard_log: None,
275            discards_since_log: 0,
276        })
277    }
278
279    /// Returns whether no packets are currently queued for transmission.
280    pub fn is_empty(&self) -> bool {
281        self.queued_packets.is_empty()
282    }
283
284    /// Returns whether the sender queue has reached its configured capacity.
285    pub fn is_full(&self) -> bool {
286        self.queued_packets.len() == BATCH_SIZE
287    }
288
289    /// Queues one packet for transmission to `target`.
290    ///
291    /// Returns an error when the sender queue is full or when `packet` exceeds
292    /// `MAX_PACKET_SIZE`, which would otherwise force the scratch buffer to grow.
293    ///
294    /// Accepting a packet here does not guarantee that it can be delivered: datagrams above
295    /// the egress path MTU are rejected by the kernel and are discarded when flushing.
296    pub fn try_queue_packet(
297        &mut self,
298        packet: Packet,
299        target: SocketAddr,
300    ) -> Result<(), QueuePacketError> {
301        let packet_len = packet.len();
302        if packet.len() > MAX_PACKET_SIZE {
303            return Err(QueuePacketError::PacketTooLarge {
304                packet,
305                target,
306                packet_len,
307                max_packet_size: MAX_PACKET_SIZE,
308            });
309        }
310        if self.is_full() {
311            return Err(QueuePacketError::Full { packet, target });
312        }
313        self.queued_packets.push_back((target, packet));
314        Ok(())
315    }
316
317    /// Attempts to flush queued packets without waiting for the socket to become writable.
318    ///
319    /// A datagram the socket refuses is discarded rather than retried forever: it would
320    /// otherwise stay at the head of the queue and block every packet behind it for good.
321    /// This applies to a datagram that can never be sent, such as one above the egress MTU
322    /// which fails with `EMSGSIZE` because `quinn-udp` sets the don't-fragment bit, but also
323    /// costs a datagram on transient conditions like a full qdisc reporting `ENOBUFS`.
324    ///
325    /// Discards are counted in [`Self::take_discarded_datagrams`] and reported at warning
326    /// level at most once per minute, so callers that want to observe every discard should
327    /// consume that counter rather than rely on the log.
328    ///
329    /// Fails only with [`SocketNotWritable`], which leaves the queue intact.
330    pub fn try_flush_best_effort(&mut self, socket: &UdpSocket) -> Result<(), SocketNotWritable> {
331        // Cleared once a coalesced transmit failed so the remaining datagrams of this flush
332        // are sent one by one. That tells a datagram which is undeliverable on its own apart
333        // from a transmit that only failed because it was coalesced with others.
334        let mut coalesce = true;
335
336        while !self.is_empty() {
337            let (target, segment_size, segments) = self.fill_scratch_from_front(coalesce);
338            let result = socket.try_io(Interest::WRITABLE, || {
339                let transmit = Transmit {
340                    destination: target,
341                    ecn: None,
342                    contents: &self.scratch,
343                    segment_size: (segments > 1).then_some(segment_size),
344                    src_ip: None,
345                };
346                self.state.try_send(UdpSockRef::from(socket), &transmit)
347            });
348
349            match result {
350                Ok(()) => self.drop_prefix(segments),
351                Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
352                    return Err(SocketNotWritable);
353                }
354                Err(err) if segments > 1 => {
355                    tracing::debug!(
356                        ?target,
357                        segments,
358                        segment_size,
359                        err = ?err,
360                        "coalesced transmit failed, retrying datagrams individually"
361                    );
362                    coalesce = false;
363                }
364                Err(err) => {
365                    self.drop_prefix(segments);
366                    self.discarded_datagrams += 1;
367                    self.log_discard(target, segment_size, &err);
368                }
369            }
370        }
371        Ok(())
372    }
373
374    /// Flushes queued packets, waiting asynchronously until the socket becomes writable.
375    ///
376    /// Refused datagrams are discarded as described on [`Self::try_flush_best_effort`], so the
377    /// only error this returns comes from waiting for writability.
378    pub async fn flush(&mut self, socket: &UdpSocket) -> io::Result<()> {
379        while !self.is_empty() {
380            socket.writable().await?;
381            if self.try_flush_best_effort(socket).is_ok() {
382                break;
383            }
384        }
385        Ok(())
386    }
387
388    /// Returns how many datagrams were discarded since this was last called, and resets the
389    /// count.
390    ///
391    /// Discarding keeps the queue draining but loses the datagram, so callers should export
392    /// this as a metric: a rising count means outbound traffic is being dropped.
393    pub fn take_discarded_datagrams(&mut self) -> u64 {
394        std::mem::take(&mut self.discarded_datagrams)
395    }
396
397    /// Reports a discarded datagram, at warning level at most once per
398    /// [`DISCARD_LOG_INTERVAL`] so that a peer producing undeliverable datagrams at line rate
399    /// cannot flood the log.
400    fn log_discard(&mut self, target: SocketAddr, datagram_len: usize, err: &io::Error) {
401        let now = Instant::now();
402        let due = self
403            .last_discard_log
404            .is_none_or(|last| now.duration_since(last) >= DISCARD_LOG_INTERVAL);
405
406        if !due {
407            self.discards_since_log += 1;
408            tracing::debug!(?target, datagram_len, err = ?err, "discarding refused datagram");
409            return;
410        }
411
412        tracing::warn!(
413            ?target,
414            datagram_len,
415            err = ?err,
416            suppressed_discards = self.discards_since_log,
417            "discarding refused datagram"
418        );
419        self.last_discard_log = Some(now);
420        self.discards_since_log = 0;
421    }
422
423    fn drop_prefix(&mut self, count: usize) {
424        self.queued_packets.drain(..count);
425    }
426
427    /// Copies the datagrams at the front of the queue into the scratch buffer.
428    ///
429    /// Returns their common destination, their segment size and how many of them were
430    /// copied. The datagrams stay queued until the transmit is known to have left the queue.
431    fn fill_scratch_from_front(&mut self, coalesce: bool) -> (SocketAddr, usize, usize) {
432        self.scratch.clear();
433        let (target, first_packet) = self
434            .queued_packets
435            .front()
436            .expect("filling the scratch buffer requires a non-empty queue");
437        let target = *target;
438        let segment_size = first_packet.len();
439        let mut segments = 0;
440        // A coalesced transmit is one datagram towards the kernel, so it must respect the
441        // maximum UDP payload size on top of the offload and batch limits.
442        //
443        // Zero-length datagrams are excluded from coalescing entirely: the kernel reads a
444        // segment size of zero as "no segmentation" and delivers a single datagram for the
445        // whole group, which would retire every queue entry while sending only one of them.
446        let max_segments = if coalesce && segment_size > 0 {
447            self.state
448                .max_gso_segments()
449                .min(BATCH_SIZE)
450                .min(MAX_UDP_PAYLOAD_SIZE / segment_size)
451                .max(1)
452        } else {
453            1
454        };
455
456        // Only coalesce the segments at the front with matching destination and
457        // segment size so queue order stays intact and we can drop exactly the
458        // packets that were handed to the kernel.
459        for (queued_target, packet) in self.queued_packets.iter().take(max_segments) {
460            if *queued_target != target || packet.len() != segment_size {
461                break;
462            }
463            self.scratch.extend_from_slice(&packet[..]);
464            segments += 1;
465        }
466
467        (target, segment_size, segments)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use std::{net::SocketAddr, time::Duration};
474
475    use ana_gotatun::packet::PacketBufPool;
476    use tokio::net::UdpSocket;
477
478    use super::{MAX_BATCH_SIZE, MAX_UDP_PAYLOAD_SIZE, UdpBatchReceiver, UdpBatchSender};
479
480    const TEST_PACKET_SIZE: usize = 128;
481
482    fn packet_pool() -> PacketBufPool<TEST_PACKET_SIZE> {
483        PacketBufPool::new(MAX_BATCH_SIZE)
484    }
485
486    async fn bound_socket() -> UdpSocket {
487        UdpSocket::bind("127.0.0.1:0").await.unwrap()
488    }
489
490    fn packet_from_bytes(
491        pool: &PacketBufPool<TEST_PACKET_SIZE>,
492        bytes: &[u8],
493    ) -> ana_gotatun::packet::Packet {
494        let mut packet = pool.get();
495        packet[..bytes.len()].copy_from_slice(bytes);
496        packet.truncate(bytes.len());
497        packet
498    }
499
500    #[tokio::test]
501    async fn flushes_partially_full_sender_batch() {
502        let sender_socket = bound_socket().await;
503        let receiver_socket = bound_socket().await;
504        let pool = packet_pool();
505        let mut sender =
506            UdpBatchSender::<MAX_BATCH_SIZE, TEST_PACKET_SIZE>::new(&sender_socket).unwrap();
507
508        sender
509            .try_queue_packet(
510                packet_from_bytes(&pool, b"one"),
511                receiver_socket.local_addr().unwrap(),
512            )
513            .unwrap();
514        sender
515            .try_queue_packet(
516                packet_from_bytes(&pool, b"two"),
517                receiver_socket.local_addr().unwrap(),
518            )
519            .unwrap();
520
521        sender.flush(&sender_socket).await.unwrap();
522
523        let mut buf = [0u8; TEST_PACKET_SIZE];
524        let (n1, _) = receiver_socket.recv_from(&mut buf).await.unwrap();
525        let first = buf[..n1].to_vec();
526        let (n2, _) = receiver_socket.recv_from(&mut buf).await.unwrap();
527        let second = buf[..n2].to_vec();
528
529        assert!(sender.is_empty());
530        assert_eq!(vec![first, second], vec![b"one".to_vec(), b"two".to_vec()]);
531    }
532
533    #[tokio::test]
534    async fn flushes_sender_batch_with_mixed_targets() {
535        let sender_socket = bound_socket().await;
536        let first_target = bound_socket().await;
537        let second_target = bound_socket().await;
538        let pool = packet_pool();
539        let mut sender =
540            UdpBatchSender::<MAX_BATCH_SIZE, TEST_PACKET_SIZE>::new(&sender_socket).unwrap();
541
542        sender
543            .try_queue_packet(
544                packet_from_bytes(&pool, b"alpha"),
545                first_target.local_addr().unwrap(),
546            )
547            .unwrap();
548        sender
549            .try_queue_packet(
550                packet_from_bytes(&pool, b"beta"),
551                second_target.local_addr().unwrap(),
552            )
553            .unwrap();
554        sender
555            .try_queue_packet(
556                packet_from_bytes(&pool, b"gamma"),
557                first_target.local_addr().unwrap(),
558            )
559            .unwrap();
560
561        sender.flush(&sender_socket).await.unwrap();
562
563        let mut buf = [0u8; TEST_PACKET_SIZE];
564        let (n_first_a, _) = first_target.recv_from(&mut buf).await.unwrap();
565        let first_a = buf[..n_first_a].to_vec();
566        let (n_second, _) = second_target.recv_from(&mut buf).await.unwrap();
567        let second = buf[..n_second].to_vec();
568        let (n_first_b, _) = first_target.recv_from(&mut buf).await.unwrap();
569        let first_b = buf[..n_first_b].to_vec();
570
571        assert_eq!(first_a, b"alpha".to_vec());
572        assert_eq!(second, b"beta".to_vec());
573        assert_eq!(first_b, b"gamma".to_vec());
574    }
575
576    /// A datagram above the maximum UDP payload size can never be sent. It must not keep the
577    /// packets queued behind it from being flushed.
578    #[tokio::test]
579    async fn flush_discards_undeliverable_datagram_and_drains_the_rest() {
580        const OVERSIZED_PACKET_SIZE: usize = MAX_UDP_PAYLOAD_SIZE + 1;
581
582        let sender_socket = bound_socket().await;
583        let receiver_socket = bound_socket().await;
584        let target = receiver_socket.local_addr().unwrap();
585        let pool = PacketBufPool::<OVERSIZED_PACKET_SIZE>::new(2);
586        let mut sender =
587            UdpBatchSender::<MAX_BATCH_SIZE, OVERSIZED_PACKET_SIZE>::new(&sender_socket).unwrap();
588
589        let mut oversized = pool.get();
590        oversized.truncate(OVERSIZED_PACKET_SIZE);
591        sender.try_queue_packet(oversized, target).unwrap();
592
593        let mut deliverable = pool.get();
594        deliverable[..5].copy_from_slice(b"after");
595        deliverable.truncate(5);
596        sender.try_queue_packet(deliverable, target).unwrap();
597
598        sender.flush(&sender_socket).await.unwrap();
599
600        let mut buf = [0u8; 64];
601        let (len, _) = receiver_socket.recv_from(&mut buf).await.unwrap();
602
603        assert!(sender.is_empty(), "the queue must not retain the discard");
604        assert_eq!(&buf[..len], b"after");
605        assert_eq!(
606            sender.take_discarded_datagrams(),
607            1,
608            "the discard must be accountable, the log is only sampled"
609        );
610        assert_eq!(
611            sender.take_discarded_datagrams(),
612            0,
613            "taking the count must reset it"
614        );
615    }
616
617    /// Coalescing must stay within the maximum UDP payload size, which the kernel would
618    /// otherwise reject with `EMSGSIZE`.
619    #[tokio::test]
620    async fn coalescing_respects_the_maximum_udp_payload_size() {
621        const LARGE_PACKET_SIZE: usize = 2048;
622
623        let sender_socket = bound_socket().await;
624        let target = bound_socket().await.local_addr().unwrap();
625        let pool = PacketBufPool::<LARGE_PACKET_SIZE>::new(MAX_BATCH_SIZE);
626        let mut sender =
627            UdpBatchSender::<MAX_BATCH_SIZE, LARGE_PACKET_SIZE>::new(&sender_socket).unwrap();
628
629        for _ in 0..MAX_BATCH_SIZE {
630            let mut packet = pool.get();
631            packet.truncate(LARGE_PACKET_SIZE);
632            sender.try_queue_packet(packet, target).unwrap();
633        }
634
635        let (_, segment_size, segments) = sender.fill_scratch_from_front(true);
636
637        assert_eq!(segment_size, LARGE_PACKET_SIZE);
638        assert!(segments >= 1);
639        assert!(
640            segments * segment_size <= MAX_UDP_PAYLOAD_SIZE,
641            "coalesced {segments} segments of {segment_size} bytes exceed the maximum UDP payload"
642        );
643    }
644
645    /// Zero-length datagrams are legal and must each reach the peer.
646    ///
647    /// They must not be coalesced: a segment size of zero reads as "no segmentation" to the
648    /// kernel, which accepts the transmit and delivers a single datagram for the whole group.
649    /// The queue entries would all be retired, losing every datagram but the first without
650    /// any error to account for.
651    #[tokio::test]
652    async fn flush_delivers_every_zero_length_datagram() {
653        const PACKETS: usize = 3;
654
655        let sender_socket = bound_socket().await;
656        let receiver_socket = bound_socket().await;
657        let target = receiver_socket.local_addr().unwrap();
658        let pool = packet_pool();
659        let mut sender =
660            UdpBatchSender::<MAX_BATCH_SIZE, TEST_PACKET_SIZE>::new(&sender_socket).unwrap();
661
662        for _ in 0..PACKETS {
663            sender
664                .try_queue_packet(packet_from_bytes(&pool, b""), target)
665                .unwrap();
666        }
667
668        let (_, segment_size, segments) = sender.fill_scratch_from_front(true);
669        assert_eq!(segment_size, 0);
670        assert_eq!(segments, 1, "zero-length datagrams must be sent one by one");
671
672        sender.flush(&sender_socket).await.unwrap();
673
674        assert!(sender.is_empty());
675        assert_eq!(sender.take_discarded_datagrams(), 0);
676
677        let mut buf = [0u8; TEST_PACKET_SIZE];
678        for index in 0..PACKETS {
679            let received =
680                tokio::time::timeout(Duration::from_secs(5), receiver_socket.recv_from(&mut buf))
681                    .await
682                    .unwrap_or_else(|_| panic!("zero-length datagram {index} never arrived"))
683                    .unwrap();
684
685            assert_eq!(received.0, 0);
686        }
687    }
688
689    /// All queued datagrams must reach the peer even when their total exceeds what fits into a
690    /// single coalesced transmit.
691    #[tokio::test]
692    async fn flush_delivers_more_datagrams_than_one_transmit_can_carry() {
693        const LARGE_PACKET_SIZE: usize = 2048;
694        const PACKETS: usize = MAX_BATCH_SIZE;
695
696        let sender_socket = bound_socket().await;
697        let receiver_socket = bound_socket().await;
698        let target = receiver_socket.local_addr().unwrap();
699        let pool = PacketBufPool::<LARGE_PACKET_SIZE>::new(PACKETS);
700        let mut sender =
701            UdpBatchSender::<MAX_BATCH_SIZE, LARGE_PACKET_SIZE>::new(&sender_socket).unwrap();
702
703        for index in 0..PACKETS {
704            let mut packet = pool.get();
705            packet.truncate(LARGE_PACKET_SIZE);
706            packet[0] = index as u8;
707            sender.try_queue_packet(packet, target).unwrap();
708        }
709
710        // Receive concurrently with the flush: the datagrams together exceed the receive
711        // buffer, and a lost one has to fail the test rather than block it forever.
712        let receive = tokio::spawn(async move {
713            let mut buf = [0u8; LARGE_PACKET_SIZE];
714            let mut received = Vec::with_capacity(PACKETS);
715            for _ in 0..PACKETS {
716                let (len, _) = receiver_socket.recv_from(&mut buf).await.unwrap();
717                assert_eq!(len, LARGE_PACKET_SIZE);
718                received.push(buf[0]);
719            }
720            received
721        });
722
723        sender.flush(&sender_socket).await.unwrap();
724
725        let received = tokio::time::timeout(Duration::from_secs(10), receive)
726            .await
727            .expect("all datagrams should arrive")
728            .unwrap();
729
730        assert!(sender.is_empty());
731        assert_eq!(
732            received,
733            (0..PACKETS).map(|index| index as u8).collect::<Vec<_>>(),
734            "datagrams arrived out of order or were lost"
735        );
736    }
737
738    #[tokio::test]
739    async fn receive_with_stride_smaller_than_length_splits_segments() {
740        let socket = bound_socket().await;
741        let pool = packet_pool();
742        let mut receiver =
743            UdpBatchReceiver::<MAX_BATCH_SIZE, TEST_PACKET_SIZE>::new(&socket, &pool).unwrap();
744        let source = "127.0.0.1:30000".parse::<SocketAddr>().unwrap();
745
746        receiver.recv_meta[0].addr = source;
747        receiver.recv_meta[0].len = 10;
748        receiver.recv_meta[0].stride = 4;
749        receiver.recv_slots[0][..10].copy_from_slice(b"abcdefghij");
750
751        let mut seen = Vec::new();
752        receiver
753            .handle_received(0, &pool, &mut |packet, addr| {
754                seen.push((packet[..].to_vec(), addr));
755                Ok::<(), ()>(())
756            })
757            .unwrap();
758
759        assert_eq!(
760            seen,
761            vec![
762                (b"abcd".to_vec(), source),
763                (b"efgh".to_vec(), source),
764                (b"ij".to_vec(), source),
765            ]
766        );
767    }
768
769    #[tokio::test]
770    async fn receive_with_stride_at_least_length_uses_single_packet() {
771        let socket = bound_socket().await;
772        let pool = packet_pool();
773        let mut receiver =
774            UdpBatchReceiver::<MAX_BATCH_SIZE, TEST_PACKET_SIZE>::new(&socket, &pool).unwrap();
775        let source = "127.0.0.1:30001".parse::<SocketAddr>().unwrap();
776
777        receiver.recv_meta[0].addr = source;
778        receiver.recv_meta[0].len = 5;
779        receiver.recv_meta[0].stride = 5;
780        receiver.recv_slots[0][..5].copy_from_slice(b"hello");
781
782        let mut seen = Vec::new();
783        receiver
784            .handle_received(0, &pool, &mut |packet, addr| {
785                seen.push((packet[..].to_vec(), addr));
786                Ok::<(), ()>(())
787            })
788            .unwrap();
789
790        assert_eq!(seen, vec![(b"hello".to_vec(), source)]);
791    }
792
793    #[test]
794    fn refuses_to_grow_beyond_batch_capacity() {
795        let runtime = tokio::runtime::Runtime::new().unwrap();
796        runtime.block_on(async {
797            let socket = bound_socket().await;
798            let pool = packet_pool();
799            let mut sender =
800                UdpBatchSender::<MAX_BATCH_SIZE, TEST_PACKET_SIZE>::new(&socket).unwrap();
801
802            for _ in 0..MAX_BATCH_SIZE {
803                sender
804                    .try_queue_packet(packet_from_bytes(&pool, b"x"), socket.local_addr().unwrap())
805                    .unwrap();
806            }
807
808            assert!(
809                sender
810                    .try_queue_packet(
811                        packet_from_bytes(&pool, b"overflow"),
812                        socket.local_addr().unwrap()
813                    )
814                    .is_err()
815            );
816        });
817    }
818}