Skip to main content

zerodds_rtps/
message_builder.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! MessageBuilder — submessage aggregation into a UDP datagram.
4//!
5//! Analogous to Fast-DDS `RTPSMessageGroup` (research WP 1.4). The writer
6//! opens a builder per target locator set, appends several submessages,
7//! and finalizes into an [`OutboundDatagram`]. Aggregation saves the
8//! RTPS header + UDP overhead on SEDP announce-all rounds and small
9//! samples.
10//!
11//! # Flush rules
12//!
13//! 1. **Size trigger**: `try_add_submessage` rejects if the body
14//!    no longer fits the MTU. The caller must `finish()` + open a new builder.
15//! 2. **DATA_FRAG goes alone**: the caller should not bundle DATA_FRAG
16//!    submessages with others (a fragment is typically near the MTU).
17//! 3. **Piggyback HEARTBEAT at the end**: the caller appends the HB after all
18//!    DATAs, before `finish()`.
19//! 4. **No INFO_DST**: phase 1 builds one datagram per proxy — the GuidPrefix
20//!    is static. INFO_DST is added for multicast fan-out with mixed
21//!    targets in phase 2.
22//! 5. **No INFO_TS**: the writer has no source timestamps today.
23//!
24//! # Target locators
25//!
26//! A datagram goes to **all** `targets`. Typically: the unicast locators
27//! of the remote reader, or a multicast locator. The transport layer puts
28//! it on the wire once per locator.
29
30extern crate alloc;
31use alloc::rc::Rc;
32use alloc::vec::Vec;
33
34use crate::header::RtpsHeader;
35use crate::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
36use crate::wire_types::Locator;
37
38/// Default MTU for aggregation (Ethernet 1500 − 20 IP − 8 UDP).
39pub const DEFAULT_MTU: usize = 1472;
40
41/// A fully aggregated datagram with targets.
42///
43/// `targets` is shared as `Rc<Vec<Locator>>` to avoid allocation overhead
44/// in multi-reader tick loops — the same proxy
45/// locator set is reused across all submessages of a proxy.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct OutboundDatagram {
48    /// Wire bytes (RTPS header + N submessages).
49    pub bytes: Vec<u8>,
50    /// Target locators. The transport layer sends to all.
51    pub targets: Rc<Vec<Locator>>,
52}
53
54impl OutboundDatagram {
55    /// Opt-6 (Spec `zerodds-zero-copy-1.0` §9): converts the
56    /// internal Vec into a shared `Arc<[u8]>` representation
57    /// **without a copy** (boxed-slice conversion is O(1) if `bytes`
58    /// has no excess capacity; otherwise a single realloc).
59    ///
60    /// Useful for multi-reader hot paths: instead of passing `&dg.bytes` to
61    /// each reader send and implicitly letting it `Vec::clone`,
62    /// the caller takes the `Arc<[u8]>` out once and
63    /// clones only the refcount. Eliminates the per-reader Vec clone.
64    #[must_use]
65    pub fn into_shared_bytes(self) -> alloc::sync::Arc<[u8]> {
66        alloc::sync::Arc::from(self.bytes.into_boxed_slice())
67    }
68}
69
70/// Reason why [`MessageBuilder::try_add_submessage`] rejects.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum AddError {
73    /// The submessage no longer fits the MTU budget.
74    WouldExceedMtu {
75        /// Number of bytes that no longer fit (incl. submessage header).
76        needed: usize,
77        /// Remaining budget.
78        remaining: usize,
79    },
80    /// Submessage body > u16::MAX (wire field `octetsToNextHeader`).
81    BodyTooLarge,
82}
83
84/// Submessage aggregator.
85///
86/// Initialized on `open` with a pre-allocated byte list starting at the
87/// RTPS header. Submessages are appended via `try_add_submessage`;
88/// when full the caller must `finish()` + open a new builder.
89#[derive(Debug)]
90pub struct MessageBuilder {
91    bytes: Vec<u8>,
92    targets: Rc<Vec<Locator>>,
93    mtu: usize,
94    submsg_count: usize,
95}
96
97impl MessageBuilder {
98    /// Opens a new builder with the given RTPS header,
99    /// targets and MTU budget.
100    ///
101    /// Panics: if `mtu` is smaller than the RTPS header (20 bytes).
102    #[must_use]
103    pub fn open(header: RtpsHeader, targets: Rc<Vec<Locator>>, mtu: usize) -> Self {
104        assert!(
105            mtu >= 20,
106            "MTU must accommodate at least the 20-byte RTPS header"
107        );
108        let mut bytes = Vec::with_capacity(mtu);
109        bytes.extend_from_slice(&header.to_bytes());
110        Self {
111            bytes,
112            targets,
113            mtu,
114            submsg_count: 0,
115        }
116    }
117
118    /// Number of submessages inserted so far.
119    #[must_use]
120    pub fn submsg_count(&self) -> usize {
121        self.submsg_count
122    }
123
124    /// True if the builder contains only the RTPS header.
125    #[must_use]
126    pub fn is_empty(&self) -> bool {
127        self.submsg_count == 0
128    }
129
130    /// Current total byte count (header + already appended
131    /// submessages).
132    #[must_use]
133    pub fn len(&self) -> usize {
134        self.bytes.len()
135    }
136
137    /// Remaining budget in bytes.
138    #[must_use]
139    pub fn remaining(&self) -> usize {
140        self.mtu.saturating_sub(self.bytes.len())
141    }
142
143    /// Tries to append a submessage. Returns
144    /// [`AddError::WouldExceedMtu`] if it no longer fits —
145    /// then `finish()` + a new builder is due.
146    ///
147    /// `flags` contains only the **submessage-specific** flags
148    /// (F, L, Q, H, K, N etc.). The E bit (little-endian) is set by the
149    /// builder itself, consistently for the whole datagram.
150    ///
151    /// # Errors
152    /// - [`AddError::WouldExceedMtu`] on size overflow.
153    /// - [`AddError::BodyTooLarge`] if `body.len() > u16::MAX`.
154    pub fn try_add_submessage(
155        &mut self,
156        id: SubmessageId,
157        flags: u8,
158        body: &[u8],
159    ) -> Result<(), AddError> {
160        // RTPS 2.5 §8.3.4.1: every submessage starts on a 32-bit
161        // boundary. Since the submessage header measures 4 bytes, `body`
162        // itself must be a multiple of 4 bytes so that a *following*
163        // submessage starts aligned. The builder deliberately does NOT pad:
164        // a pad counted in `octetsToNextHeader` would bleed through as
165        // serializedData slack into the user payload (the receiver
166        // derives the payload length from `octetsToNextHeader`). Whoever
167        // co-frames multiple submessages checks the alignment themselves —
168        // see `ReliableWriter::write_with_heartbeat`: it appends a
169        // piggyback HEARTBEAT only to a DATA that already ends 4-aligned
170        // and otherwise puts it into its own datagram.
171        let body_len = u16::try_from(body.len()).map_err(|_| AddError::BodyTooLarge)?;
172        let needed = SubmessageHeader::WIRE_SIZE + body.len();
173        if self.bytes.len() + needed > self.mtu {
174            return Err(AddError::WouldExceedMtu {
175                needed,
176                remaining: self.remaining(),
177            });
178        }
179        let sh = SubmessageHeader {
180            submessage_id: id,
181            flags: flags | FLAG_E_LITTLE_ENDIAN,
182            octets_to_next_header: body_len,
183        };
184        self.bytes.extend_from_slice(&sh.to_bytes());
185        self.bytes.extend_from_slice(body);
186        self.submsg_count += 1;
187        Ok(())
188    }
189
190    /// Like [`Self::try_add_submessage`], but the body in two slices —
191    /// avoids an intermediate Vec when the caller already has `header_body`
192    /// and `payload_tail` separately. Exactly the hot-
193    /// path case for the DATA submessage: 20-byte fixed header (the caller
194    /// writes into a stack buffer) + N byte serializedData (borrowed from
195    /// the cache `Arc<[u8]>`). Without this variant the caller needs
196    /// `Vec::with_capacity(20+N) + extend(header) + extend(payload)` —
197    /// a heap alloc + copy of N bytes, which is fully eliminated here.
198    /// `octets_to_next_header = header_body.len() + payload_tail.len()`.
199    ///
200    /// # Errors
201    /// Same as [`Self::try_add_submessage`].
202    pub fn try_add_submessage_split(
203        &mut self,
204        id: SubmessageId,
205        flags: u8,
206        header_body: &[u8],
207        payload_tail: &[u8],
208    ) -> Result<(), AddError> {
209        let total_body = header_body.len() + payload_tail.len();
210        let body_len = u16::try_from(total_body).map_err(|_| AddError::BodyTooLarge)?;
211        let needed = SubmessageHeader::WIRE_SIZE + total_body;
212        if self.bytes.len() + needed > self.mtu {
213            return Err(AddError::WouldExceedMtu {
214                needed,
215                remaining: self.remaining(),
216            });
217        }
218        let sh = SubmessageHeader {
219            submessage_id: id,
220            flags: flags | FLAG_E_LITTLE_ENDIAN,
221            octets_to_next_header: body_len,
222        };
223        self.bytes.extend_from_slice(&sh.to_bytes());
224        self.bytes.extend_from_slice(header_body);
225        self.bytes.extend_from_slice(payload_tail);
226        self.submsg_count += 1;
227        Ok(())
228    }
229
230    /// Converts into a finished [`OutboundDatagram`].
231    ///
232    /// Returns `None` for an empty builder (only the RTPS header without
233    /// submessages) — this lets callers simply discard unused builders
234    /// without having to check `is_empty()`
235    /// first.
236    #[must_use]
237    pub fn finish(self) -> Option<OutboundDatagram> {
238        if self.submsg_count == 0 {
239            return None;
240        }
241        Some(OutboundDatagram {
242            bytes: self.bytes,
243            targets: self.targets,
244        })
245    }
246}
247
248#[cfg(test)]
249#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
250mod tests {
251    use super::*;
252    use crate::datagram::{ParsedSubmessage, decode_datagram};
253    use crate::submessages::{DataSubmessage, HeartbeatSubmessage};
254    use crate::wire_types::{EntityId, GuidPrefix, Locator, SequenceNumber, VendorId};
255
256    fn sample_header() -> RtpsHeader {
257        RtpsHeader::new(VendorId::ZERODDS, GuidPrefix::from_bytes([1; 12]))
258    }
259
260    fn sample_data(sn: i64, payload_len: usize) -> DataSubmessage {
261        DataSubmessage {
262            extra_flags: 0,
263            reader_id: EntityId::user_reader_with_key([0xA0, 0xB0, 0xC0]),
264            writer_id: EntityId::user_writer_with_key([0x10, 0x20, 0x30]),
265            writer_sn: SequenceNumber(sn),
266            inline_qos: None,
267            key_flag: false,
268            non_standard_flag: false,
269            serialized_payload: alloc::sync::Arc::from(alloc::vec![0xAB; payload_len]),
270        }
271    }
272
273    fn targets() -> Rc<Vec<Locator>> {
274        Rc::new(alloc::vec![Locator::udp_v4([127, 0, 0, 1], 7400)])
275    }
276
277    #[test]
278    fn fresh_builder_contains_only_rtps_header() {
279        let b = MessageBuilder::open(sample_header(), targets(), DEFAULT_MTU);
280        assert!(b.is_empty());
281        assert_eq!(b.len(), 20, "only RTPS header");
282        assert_eq!(b.submsg_count(), 0);
283        assert_eq!(b.remaining(), DEFAULT_MTU - 20);
284    }
285
286    /// Opt-6 — into_shared_bytes returns Arc<[u8]> with identical
287    /// wire content; an Arc::clone across multiple reader sends shares
288    /// the buffer without a Vec clone.
289    #[test]
290    fn into_shared_bytes_preserves_wire_content_and_shares_via_arc() {
291        let mut b = MessageBuilder::open(sample_header(), targets(), DEFAULT_MTU);
292        let (body, flags) = sample_data(7, 16).write_body(true);
293        b.try_add_submessage(SubmessageId::Data, flags, &body)
294            .unwrap();
295        let dg = b.finish().unwrap();
296        let bytes_copy = dg.bytes.clone();
297        let shared = dg.into_shared_bytes();
298        // Content identical.
299        assert_eq!(&shared[..], &bytes_copy[..]);
300        // Arc::clone shares the buffer (one refcount, no realloc).
301        let c1 = alloc::sync::Arc::clone(&shared);
302        let c2 = alloc::sync::Arc::clone(&shared);
303        assert_eq!(alloc::sync::Arc::strong_count(&shared), 3);
304        assert_eq!(&c1[..], &c2[..]);
305    }
306
307    #[test]
308    fn single_data_submessage_fits_and_decodes() {
309        let mut b = MessageBuilder::open(sample_header(), targets(), DEFAULT_MTU);
310        let (body, flags) = sample_data(1, 10).write_body(true);
311        b.try_add_submessage(SubmessageId::Data, flags, &body)
312            .unwrap();
313        let dg = b.finish().unwrap();
314        assert_eq!(dg.targets.len(), 1);
315        let parsed = decode_datagram(&dg.bytes).unwrap();
316        assert_eq!(parsed.submessages.len(), 1);
317        assert!(matches!(&parsed.submessages[0], ParsedSubmessage::Data(_)));
318    }
319
320    #[test]
321    fn four_small_datas_aggregate_into_one_datagram() {
322        let mut b = MessageBuilder::open(sample_header(), targets(), DEFAULT_MTU);
323        for sn in 1..=4i64 {
324            let (body, flags) = sample_data(sn, 10).write_body(true);
325            b.try_add_submessage(SubmessageId::Data, flags, &body)
326                .unwrap();
327        }
328        let dg = b.finish().unwrap();
329        let parsed = decode_datagram(&dg.bytes).unwrap();
330        // 4 DATA submessages in one datagram
331        let data_count = parsed
332            .submessages
333            .iter()
334            .filter(|s| matches!(s, ParsedSubmessage::Data(_)))
335            .count();
336        assert_eq!(data_count, 4);
337    }
338
339    #[test]
340    fn overflow_rejects_with_would_exceed_mtu() {
341        let mtu = 100; // very small
342        let mut b = MessageBuilder::open(sample_header(), targets(), mtu);
343        // 1 DATA with a 50-byte payload fits (20 hdr + 4 sub_hdr + 20 body + 50 payload = 94)
344        let (body, flags) = sample_data(1, 50).write_body(true);
345        b.try_add_submessage(SubmessageId::Data, flags, &body)
346            .unwrap();
347        // 2nd DATA would overflow
348        let (body2, flags2) = sample_data(2, 50).write_body(true);
349        let res = b.try_add_submessage(SubmessageId::Data, flags2, &body2);
350        assert!(matches!(res, Err(AddError::WouldExceedMtu { .. })));
351        assert_eq!(b.submsg_count(), 1, "first add must still be counted");
352    }
353
354    #[test]
355    fn overflow_allows_caller_to_open_new_builder() {
356        let mtu = 100;
357        let (body, flags) = sample_data(1, 50).write_body(true);
358        let mut out: Vec<OutboundDatagram> = Vec::new();
359        let mut b = MessageBuilder::open(sample_header(), targets(), mtu);
360
361        for sn in 1..=3i64 {
362            let (body_n, flags_n) = sample_data(sn, 50).write_body(true);
363            if b.try_add_submessage(SubmessageId::Data, flags_n, &body_n)
364                .is_err()
365            {
366                out.push(b.finish().unwrap());
367                b = MessageBuilder::open(sample_header(), targets(), mtu);
368                b.try_add_submessage(SubmessageId::Data, flags_n, &body_n)
369                    .unwrap();
370            }
371        }
372        if !b.is_empty() {
373            out.push(b.finish().unwrap());
374        }
375        let _ = flags;
376        let _ = body;
377        // 3 DATAs, 1 per datagram each (because MTU 100 fits only 1)
378        assert_eq!(out.len(), 3);
379    }
380
381    #[test]
382    fn finish_on_empty_builder_returns_none() {
383        let b = MessageBuilder::open(sample_header(), targets(), DEFAULT_MTU);
384        assert!(b.finish().is_none());
385    }
386
387    #[test]
388    fn piggyback_heartbeat_after_data_aggregates() {
389        let mut b = MessageBuilder::open(sample_header(), targets(), DEFAULT_MTU);
390        let (body, flags) = sample_data(1, 10).write_body(true);
391        b.try_add_submessage(SubmessageId::Data, flags, &body)
392            .unwrap();
393        let hb = HeartbeatSubmessage {
394            reader_id: EntityId::user_reader_with_key([0xA0, 0xB0, 0xC0]),
395            writer_id: EntityId::user_writer_with_key([0x10, 0x20, 0x30]),
396            first_sn: SequenceNumber(1),
397            last_sn: SequenceNumber(1),
398            count: 1,
399            final_flag: true,
400            liveliness_flag: false,
401            group_info: None,
402        };
403        let (hb_body, hb_flags) = hb.write_body(true);
404        b.try_add_submessage(SubmessageId::Heartbeat, hb_flags, &hb_body)
405            .unwrap();
406        let dg = b.finish().unwrap();
407        let parsed = decode_datagram(&dg.bytes).unwrap();
408        assert_eq!(parsed.submessages.len(), 2);
409        assert!(matches!(&parsed.submessages[0], ParsedSubmessage::Data(_)));
410        assert!(matches!(
411            &parsed.submessages[1],
412            ParsedSubmessage::Heartbeat(h) if h.final_flag
413        ));
414    }
415
416    #[test]
417    fn builder_propagates_little_endian_flag_e() {
418        // We write a DATA with body LE. The builder should
419        // automatically set the E bit in the submessage header.
420        let mut b = MessageBuilder::open(sample_header(), targets(), DEFAULT_MTU);
421        let (body, _flags_from_write) = sample_data(1, 10).write_body(true);
422        // The caller passes flags without the E bit; the builder must set it.
423        b.try_add_submessage(SubmessageId::Data, 0, &body).unwrap();
424        let dg = b.finish().unwrap();
425        // Submessage header byte 1 (flags) must have the E bit set
426        let sub_header_flags = dg.bytes[21]; // 20 bytes RTPS header + 1 byte id
427        assert_eq!(
428            sub_header_flags & FLAG_E_LITTLE_ENDIAN,
429            FLAG_E_LITTLE_ENDIAN
430        );
431    }
432
433    #[test]
434    #[should_panic(expected = "MTU must accommodate")]
435    fn open_panics_on_mtu_below_header() {
436        let _ = MessageBuilder::open(sample_header(), targets(), 10);
437    }
438
439    #[test]
440    fn body_too_large_rejected() {
441        let mut b = MessageBuilder::open(sample_header(), targets(), 100_000);
442        let oversize = alloc::vec![0u8; u16::MAX as usize + 1];
443        let res = b.try_add_submessage(SubmessageId::Data, 0, &oversize);
444        assert!(matches!(res, Err(AddError::BodyTooLarge)));
445    }
446}