Skip to main content

nros_serdes/
size.rs

1//! Phase 380 W1 — a message's serialized size, computed instead of guessed.
2//!
3//! `NROS_SUBSCRIPTION_BUFFER_SIZE` defaults to 1024 bytes and is a GUESS.
4//! Nothing checks it against the messages an image actually subscribes to, so a
5//! sample that does not fit is dropped AFTER the transport ACKed it, and
6//! `report_dropped_take` can only say "raise the knob" because the runtime does
7//! not know what value would have worked. On a target that knob is static RAM
8//! nobody can spare. Issue 0776 is the gap; this module is the calculator.
9//!
10//! # Why this is not a vtable slot
11//!
12//! Settled in phase-376 W4: nothing about a size bound varies by backend, and
13//! upstream proves it by not varying at all — both `librmw_cyclonedds_cpp.so`
14//! and `librmw_fastrtps_cpp.so` answer `rmw_get_serialized_message_size` with
15//! `RMW_RET_UNSUPPORTED`, and nothing in a Humble install calls it. Upstream can
16//! afford that because its serialized buffer RESIZES; the bound is a hint that
17//! saves a realloc. Ours cannot resize, so the same number is load-bearing here.
18//!
19//! # Thread the offset — do not sum the maxima
20//!
21//! Padding is a function of WHERE a field starts, so a calculation that sums
22//! per-field maxima is wrong the moment a variable-length field shifts what
23//! follows. [`size_bound`] therefore takes `current_alignment` in and returns
24//! the size from there, which is also what makes nested structs compose with no
25//! special case — the same signature rosidl's generated Fast-RTPS support uses
26//! (`max_serialized_size_T(full_bounded, is_plain, current_alignment)`).
27//!
28//! # Agreement with the writer is the only thing that matters
29//!
30//! Every rule below was read out of `CdrWriter`, not out of the CDR spec:
31//! `align()` caps alignment at 4 under XCDR2 and honours 8 under XCDR1;
32//! `begin_dheader()` aligns to 4 and reserves 4 bytes for EVERY struct under
33//! XCDR2 (a generated `serialize` opens with it, so nested structs get one too);
34//! `write_string` writes `len + 1` and then the NUL. A bound that merely looks
35//! right is worthless — see `size_tests.rs`, which checks it against the bytes
36//! the writer actually produced.
37
38use core::ops::ControlFlow;
39
40use crate::{
41    cdr::EncodingVersion,
42    schema::{Field, FieldType},
43};
44
45/// What one walk of a schema can say about size.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct SizeBound {
48    /// Bytes, given the starting offset the walk was handed.
49    pub bytes: usize,
50    /// False once an unbounded `String` / `WString` / `Sequence` is reached —
51    /// `bytes` is then a FLOOR, not a bound, and callers must not size a buffer
52    /// from it.
53    pub bounded: bool,
54    /// No variable-length member anywhere, so the layout is fixed: `bytes` is
55    /// EXACT rather than an upper bound, and the type is loan-eligible
56    /// (phase-380 W5 wires this to `borrow_loaned_message` /
57    /// `subscription_supports_in_place` rather than letting a second notion of
58    /// "fixed layout" grow).
59    pub plain: bool,
60}
61
62/// The encapsulation header the payload crossing our vtable carries: 2 bytes of
63/// representation id + 2 of options.
64///
65/// TOP LEVEL ONLY, not per nested struct — which is why the Cyclone backend
66/// computes `total = paylen + 4`. [`size_bound`] deliberately does not include
67/// it; [`max_serialized_size`] does.
68pub const ENCAPSULATION_HEADER_BYTES: usize = 4;
69
70/// Padding needed to reach `alignment` from `offset`, under `version`.
71///
72/// Mirrors `CdrWriter::align` exactly, including the XCDR2 cap at 4 — a message
73/// containing an `int64` therefore has TWO different bounds, and a single
74/// constant would be silently wrong for one encoding.
75const fn pad_to(offset: usize, alignment: usize, version: EncodingVersion) -> usize {
76    let alignment = match version {
77        EncodingVersion::Xcdr2 => {
78            if alignment > 4 {
79                4
80            } else {
81                alignment
82            }
83        }
84        EncodingVersion::Xcdr1 => alignment,
85    };
86    if alignment == 0 {
87        return 0;
88    }
89    (alignment - (offset % alignment)) % alignment
90}
91
92/// Size of one field, starting at `offset`. Returns the new offset plus the two
93/// flags, folded by the caller.
94const fn field_bound(
95    ty: &FieldType,
96    version: EncodingVersion,
97    offset: usize,
98) -> (usize, bool, bool) {
99    match ty {
100        FieldType::Bool | FieldType::Uint8 | FieldType::Int8 => (offset + 1, true, true),
101        FieldType::Uint16 | FieldType::Int16 => {
102            let o = offset + pad_to(offset, 2, version);
103            (o + 2, true, true)
104        }
105        FieldType::Uint32 | FieldType::Int32 | FieldType::Float32 => {
106            let o = offset + pad_to(offset, 4, version);
107            (o + 4, true, true)
108        }
109        FieldType::Uint64 | FieldType::Int64 | FieldType::Float64 => {
110            let o = offset + pad_to(offset, 8, version);
111            (o + 8, true, true)
112        }
113        // `write_string` writes `len + 1` as the u32 prefix and then the NUL,
114        // so a bound of `n` payload bytes costs `4 + n + 1`.
115        FieldType::BoundedString(n) => {
116            let o = offset + pad_to(offset, 4, version);
117            (o + 4 + *n + 1, true, false)
118        }
119        FieldType::BoundedWString(n) => {
120            let o = offset + pad_to(offset, 4, version);
121            (o + 4 + 2 * *n, true, false)
122        }
123        // No bound exists. Account the length prefix so `bytes` is an honest
124        // floor, and clear `bounded` so nobody sizes a buffer from it.
125        FieldType::String | FieldType::WString | FieldType::Sequence(_) => {
126            let o = offset + pad_to(offset, 4, version);
127            (o + 4, false, false)
128        }
129        FieldType::Array(n, inner) => {
130            let mut o = offset;
131            let mut bounded = true;
132            let mut plain = true;
133            let mut i = 0;
134            while i < *n {
135                let (next, b, p) = field_bound(inner, version, o);
136                o = next;
137                bounded &= b;
138                plain &= p;
139                i += 1;
140            }
141            // A fixed array of plain elements is itself plain; of anything else,
142            // not — the element count is fixed but each element's size is not.
143            (o, bounded, plain)
144        }
145        FieldType::BoundedSequence(n, inner) => {
146            let mut o = offset + pad_to(offset, 4, version);
147            o += 4;
148            let mut bounded = true;
149            let mut i = 0;
150            while i < *n {
151                let (next, b, _) = field_bound(inner, version, o);
152                o = next;
153                bounded &= b;
154                i += 1;
155            }
156            // Never plain: the wire length varies with the actual element count
157            // even though its maximum is known.
158            (o, bounded, false)
159        }
160        FieldType::Nested(nested) => {
161            let inner = size_bound(nested.fields, version, offset);
162            (inner.bytes, inner.bounded, inner.plain)
163        }
164    }
165}
166
167/// Walk `fields` from `current_alignment`, returning the size bound of the
168/// struct they describe.
169///
170/// The returned `bytes` is an ABSOLUTE offset — the position after the last
171/// field, measured from the same origin `current_alignment` was measured from —
172/// so a nested struct composes by being handed the parent's current offset.
173/// Subtract the starting offset if a length is what you want.
174///
175/// Excludes the encapsulation header; see [`max_serialized_size`].
176pub const fn size_bound(
177    fields: &'static [Field],
178    version: EncodingVersion,
179    current_alignment: usize,
180) -> SizeBound {
181    let mut offset = current_alignment;
182    let mut bounded = true;
183    let mut plain = true;
184
185    // XCDR2 delimits EVERY appendable struct, nested ones included: a generated
186    // `serialize` opens with `begin_dheader()`, which aligns to 4 and reserves
187    // 4. Under XCDR1 the call is a no-op. Missing this UNDER-reports, which is
188    // the dangerous direction — an under-reported bound sizes a buffer too
189    // small and reintroduces the very drop this exists to stop.
190    if matches!(version, EncodingVersion::Xcdr2) {
191        offset += pad_to(offset, 4, version);
192        offset += 4;
193        // A DHEADER does not make a struct non-plain: its width is fixed.
194    }
195
196    let mut i = 0;
197    while i < fields.len() {
198        let (next, b, p) = field_bound(&fields[i].ty, version, offset);
199        offset = next;
200        bounded &= b;
201        plain &= p;
202        i += 1;
203    }
204
205    SizeBound {
206        bytes: offset,
207        bounded,
208        plain,
209    }
210}
211
212/// How deep a field path [`first_unbounded`] will name before it stops.
213///
214/// ROS message nesting is shallow in practice (`PoseStamped.header.stamp.sec`
215/// is three), and this walk runs on `no_std` with no allocator, so the path is
216/// a fixed array rather than a `Vec`. A deeper type still reports — the path is
217/// simply truncated, and [`UnboundedField::truncated`] says so, because a path
218/// that silently stops short would point at the wrong member.
219pub const MAX_FIELD_PATH_DEPTH: usize = 8;
220
221/// Which unbounded member kind was reached.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum UnboundedKind {
224    /// `string` with no IDL bound.
225    String,
226    /// `wstring` with no IDL bound.
227    WString,
228    /// `sequence<T>` with no IDL bound.
229    Sequence,
230}
231
232impl UnboundedKind {
233    /// The IDL spelling, for a diagnostic.
234    pub const fn as_str(self) -> &'static str {
235        match self {
236            UnboundedKind::String => "string",
237            UnboundedKind::WString => "wstring",
238            UnboundedKind::Sequence => "sequence<T>",
239        }
240    }
241}
242
243/// The first member that makes a type unbounded, named.
244///
245/// [`max_serialized_size`] answers `None`, which is honest and useless on its
246/// own: a user told their type has no bound cannot act without knowing WHICH
247/// member costs it, and the offender is routinely three structs down in a type
248/// they did not write. This is that answer.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct UnboundedField {
251    /// Field names from the root outward; only `depth` entries are meaningful.
252    pub path: [&'static str; MAX_FIELD_PATH_DEPTH],
253    /// How many entries of `path` are set.
254    pub depth: usize,
255    /// True when nesting exceeded [`MAX_FIELD_PATH_DEPTH`] and `path` names a
256    /// prefix rather than the whole route.
257    pub truncated: bool,
258    /// What kind of member it is.
259    pub kind: UnboundedKind,
260}
261
262impl core::fmt::Display for UnboundedField {
263    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
264        for (i, seg) in self.path.iter().take(self.depth).enumerate() {
265            if i > 0 {
266                f.write_str(".")?;
267            }
268            f.write_str(seg)?;
269        }
270        if self.truncated {
271            f.write_str(".…")?;
272        }
273        write!(f, " ({})", self.kind.as_str())
274    }
275}
276
277/// Report EVERY member that makes `fields` unbounded, in declaration order.
278///
279/// [`first_unbounded`] answers "which member costs the bound" one member at a
280/// time, and that is the wrong shape for the caller that actually asks.
281/// phase-403 W0 made an unbounded type a BUILD ERROR, and a stock ROS type is
282/// routinely unbounded in several places at once — `nav_msgs/Odometry` has
283/// `header.frame_id` and `child_frame_id`, and `sensor_msgs/PointCloud2` has
284/// four. Naming only the first turns "bound your types" into cap, rebuild,
285/// discover the next one, repeat, once per member, with a whole codegen run
286/// between each step. One build should name everything that needs a bound.
287///
288/// THE walk. [`first_unbounded`] is expressed on top of this rather than beside
289/// it: two walks of one schema is the shape the sizes-header mirror defect keeps
290/// taking (issues 0088 -> 0268), and "the first thing this reports" is exactly
291/// what "the first unbounded member" means, so there is nothing left for a
292/// second implementation to say.
293///
294/// `visit` returns [`ControlFlow`] so a caller that wants only the first can
295/// stop the walk rather than let it enumerate a whole type and discard all but
296/// one answer. The return value is `Break` iff the visitor broke.
297///
298/// `&mut dyn FnMut` rather than `impl FnMut`: the walk recurses, and a recursive
299/// generic function cannot name the closure type it would instantiate itself
300/// with. `no_std` and allocation-free either way — nothing is collected here,
301/// which is what lets an all-members form exist at all on a target with no
302/// allocator. The COLLECTING is the caller's, and only codegen (`std`) does it.
303pub fn visit_unbounded(
304    fields: &'static [Field],
305    visit: &mut dyn FnMut(UnboundedField) -> ControlFlow<()>,
306) -> ControlFlow<()> {
307    fn walk(
308        fields: &'static [Field],
309        prefix: &mut [&'static str; MAX_FIELD_PATH_DEPTH],
310        depth: usize,
311        visit: &mut dyn FnMut(UnboundedField) -> ControlFlow<()>,
312    ) -> ControlFlow<()> {
313        for field in fields {
314            let truncated = depth >= MAX_FIELD_PATH_DEPTH;
315            if !truncated {
316                prefix[depth] = field.name;
317            }
318            let here = |kind: UnboundedKind| UnboundedField {
319                path: *prefix,
320                depth: if truncated {
321                    MAX_FIELD_PATH_DEPTH
322                } else {
323                    depth + 1
324                },
325                truncated,
326                kind,
327            };
328            match &field.ty {
329                FieldType::String => visit(here(UnboundedKind::String))?,
330                FieldType::WString => visit(here(UnboundedKind::WString))?,
331                FieldType::Sequence(_) => visit(here(UnboundedKind::Sequence))?,
332                // A fixed array or a bounded sequence is bounded only if its
333                // ELEMENT is, and the element can itself be an unbounded
334                // string — `string[4]` has no bound. The element carries no
335                // name of its own, so it reports at the field's own path, and
336                // ONCE: several offenders inside one unnamed element would all
337                // print the same path, which reads as a repeated line rather
338                // than as more information.
339                FieldType::Array(_, inner) | FieldType::BoundedSequence(_, inner) => {
340                    if let Some(kind) = element_unbounded(inner) {
341                        visit(here(kind))?;
342                    }
343                }
344                FieldType::Nested(nested) => {
345                    if truncated {
346                        // Cannot record another segment; report the deepest
347                        // path we can name rather than descending silently.
348                        walk(nested.fields, prefix, depth, &mut |mut u| {
349                            u.truncated = true;
350                            visit(u)
351                        })?;
352                    } else {
353                        walk(nested.fields, prefix, depth + 1, visit)?;
354                    }
355                }
356                _ => {}
357            }
358        }
359        ControlFlow::Continue(())
360    }
361
362    /// An element type is not a field, so it has no name — report only its kind.
363    fn element_unbounded(ty: &'static FieldType) -> Option<UnboundedKind> {
364        match ty {
365            FieldType::String => Some(UnboundedKind::String),
366            FieldType::WString => Some(UnboundedKind::WString),
367            FieldType::Sequence(_) => Some(UnboundedKind::Sequence),
368            FieldType::Array(_, inner) | FieldType::BoundedSequence(_, inner) => {
369                element_unbounded(inner)
370            }
371            FieldType::Nested(nested) => {
372                // A nested struct inside an array: any unbounded member counts.
373                first_unbounded(nested.fields).map(|u| u.kind)
374            }
375            _ => None,
376        }
377    }
378
379    let mut prefix = [""; MAX_FIELD_PATH_DEPTH];
380    walk(fields, &mut prefix, 0, visit)
381}
382
383/// Find the first member that makes `fields` unbounded, if any.
384///
385/// Returns `None` exactly when [`size_bound`] reports `bounded` — the two walk
386/// the same schema by the same rules, so a type that has a bound has no
387/// offender to name and vice versa. That agreement is asserted by test, not
388/// assumed: two walks of one schema is the shape the sizes-header mirror defect
389/// keeps taking (issues 0088 -> 0268), so the second one exists only because it
390/// answers a question the first cannot (WHICH member) and must be checked
391/// against it.
392///
393/// The walk is [`visit_unbounded`]; this is its "stop at the first one" caller.
394/// Reach for [`visit_unbounded`] when the diagnostic should name every member a
395/// user has to fix, which is what codegen wants now that an unbounded type is a
396/// build error (phase-403 W0).
397///
398/// Not `const`: it recurses through `&'static NestedType`, and the array
399/// bookkeeping is not worth expressing in a const walk when every caller is a
400/// diagnostic path.
401pub fn first_unbounded(fields: &'static [Field]) -> Option<UnboundedField> {
402    let mut found = None;
403    let _ = visit_unbounded(fields, &mut |u| {
404        found = Some(u);
405        ControlFlow::Break(())
406    });
407    found
408}
409
410/// The whole payload a publisher hands the transport: encapsulation header plus
411/// the struct's body, starting from offset 0.
412///
413/// `None` when the type is unbounded — the honest answer, and the one that keeps
414/// a caller from sizing a buffer off a floor. Reach for `serialized_size(&self)`
415/// (phase-380 W3) when an unbounded type still needs a number for THIS message.
416pub const fn max_serialized_size(
417    fields: &'static [Field],
418    version: EncodingVersion,
419) -> Option<usize> {
420    // The body's offsets are measured from after the encapsulation header —
421    // that is where `CdrWriter`'s `origin` sits — so the walk starts at 0 and
422    // the header is added once, at the top.
423    let bound = size_bound(fields, version, 0);
424    if bound.bounded {
425        Some(ENCAPSULATION_HEADER_BYTES + bound.bytes)
426    } else {
427        None
428    }
429}
430
431/// Phase 380 W3 — the EXACT serialized size of THIS message.
432///
433/// Two questions get asked about size and they are not the same one:
434///
435/// | question | asked by | this module |
436/// | --- | --- | --- |
437/// | how large can this TYPE ever be? | build-time buffer sizing | [`max_serialized_size`] |
438/// | how large is THIS message? | a publisher before publishing; a drop report | `serialized_size` |
439///
440/// The second is the only honest answer for an unbounded type, where the first
441/// is `None` — a `String` field has no maximum, but the string in hand has a
442/// length. It is what lets a drop report name the number that would have worked
443/// instead of saying "raise the knob".
444///
445/// Exact by construction: it runs the REAL writer with its stores disabled
446/// (`CdrWriter::measuring`), so the count comes from the same code that emits
447/// the bytes. A second walk of the schema could not see the actual string
448/// lengths and sequence counts, and would be a second implementation to keep in
449/// step besides.
450///
451/// Includes the encapsulation header, matching [`max_serialized_size`], so the
452/// two are directly comparable — which is the whole point at a call site
453/// deciding whether a message fits.
454pub fn serialized_size<T: crate::traits::Serialize>(
455    value: &T,
456    version: EncodingVersion,
457) -> Result<usize, crate::error::SerError> {
458    let mut w = crate::cdr::CdrWriter::measuring(&mut [], version);
459    value.serialize(&mut w)?;
460    Ok(ENCAPSULATION_HEADER_BYTES + w.position())
461}
462
463/// Phase 380 W4 — does a receive buffer of `rx_buf` bytes fit every message of
464/// this type?
465///
466/// `true` when the type is bounded and the bound fits. **`false` when the type
467/// is UNBOUNDED**, deliberately: no finite buffer fits a `String`, so the
468/// honest answer to "is this guaranteed to fit" is no. A caller that wants
469/// "fits unless proven otherwise" is asking a different question and should
470/// look at [`serialized_size`] per message.
471///
472/// Const, so a call site can put it in a `const { assert!(...) }` and turn a
473/// runtime drop into a build error:
474///
475/// ```ignore
476/// const { assert!(buffer_fits::<Odometry>(RX_BUF, EncodingVersion::Xcdr1)) };
477/// ```
478///
479/// # Why this is not simply a bound on `Subscription`
480///
481/// `Subscription<M, RX_BUF>` bounds `M: RosMessage`, which is a DIFFERENT trait
482/// from [`crate::schema::Message`] — and measured 2026-08-26, hand-written
483/// `RosMessage` types with no schema DO exist (`nros-core/src/service.rs`, the
484/// component-runtime tests). Tightening that bound would break them, so the
485/// assertion is opt-in at sites where the schema is known rather than universal
486/// at a site where it is not. See the phase doc for what closing that gap needs.
487pub const fn buffer_fits<M: crate::schema::Message>(
488    rx_buf: usize,
489    version: EncodingVersion,
490) -> bool {
491    let bound = match version {
492        EncodingVersion::Xcdr1 => M::MAX_SERIALIZED_SIZE_XCDR1,
493        EncodingVersion::Xcdr2 => M::MAX_SERIALIZED_SIZE_XCDR2,
494    };
495    match bound {
496        Some(n) => rx_buf >= n,
497        None => false,
498    }
499}
500
501/// Phase 392 W3a — the type's own receive-buffer bound, or `None` when it has
502/// none.
503///
504/// The VALUE behind [`bound_fits`]'s predicate. A subscription that knows its
505/// type's bound can be routed to a size class instead of forcing the GLOBAL
506/// buffer knob up: `ZPICO_SUBSCRIBER_BUFFER_SIZE` multiplies across
507/// `MAX_SUBSCRIBERS x RING_DEPTH`, so raising it from 1024 to 4096 for one
508/// 4 KiB topic costs 98,304 bytes, while the large class that topic belongs in
509/// is already reserved and empty.
510///
511/// Takes the larger of the two encodings, for the same reason `bound_fits`
512/// does: the peer picks the encoding at runtime, so sizing from XCDR1 alone is
513/// a trap.
514///
515/// `None` means "no bound EXISTS", never "unknown" — phase 380 is explicit that
516/// a buffer must not be sized from a fallback. A caller routing by size class
517/// must treat `None` as "keep the default", not as "assume small".
518pub const fn max_serialized_bound<M: crate::schema::Message>() -> Option<usize> {
519    match (M::MAX_SERIALIZED_SIZE_XCDR1, M::MAX_SERIALIZED_SIZE_XCDR2) {
520        (Some(x1), Some(x2)) => Some(if x1 > x2 { x1 } else { x2 }),
521        // One encoding unbounded makes the type unbounded on the wire, because
522        // the peer chooses. Not `Some(the_other)`.
523        _ => None,
524    }
525}
526
527/// Phase 380 W4 — the BUILD-ASSERTION predicate: `false` only when the type is
528/// provably too large for `rx_buf`.
529///
530/// Distinct from [`buffer_fits`], and the difference is the whole reason both
531/// exist:
532///
533/// * `buffer_fits` answers "is this GUARANTEED to fit", so an unbounded type is
534///   `false` — no finite buffer fits a `String`.
535/// * this answers "can we PROVE it will not fit", so an unbounded type is
536///   `true` — there is nothing to check, and failing the build for every
537///   `std_msgs/String` subscription would be absurd.
538///
539/// Asserting `buffer_fits` at a subscription site was my first attempt and
540/// would have refused the most common message in ROS. Kept as two named
541/// functions rather than one with a flag, because the wrong one is silently
542/// plausible at either call site.
543///
544/// Checks BOTH encodings and takes the larger: the encoding is a runtime
545/// property of the peer, so a buffer sized from XCDR1 alone is a trap — XCDR2
546/// adds a DHEADER per struct and is the bigger of the two for nested types.
547pub const fn bound_fits<M: crate::schema::Message>(rx_buf: usize) -> bool {
548    let x1 = match M::MAX_SERIALIZED_SIZE_XCDR1 {
549        Some(n) => n,
550        None => return true, // unbounded: nothing to prove
551    };
552    let x2 = match M::MAX_SERIALIZED_SIZE_XCDR2 {
553        Some(n) => n,
554        None => return true,
555    };
556    let largest = if x1 > x2 { x1 } else { x2 };
557    rx_buf >= largest
558}
559
560/// Phase 380 W5 — is this type loan-eligible?
561///
562/// A loan hands out a pointer into the transport's own memory, which is sound
563/// only when the layout is fixed: no length prefix to chase, no variable
564/// member. That is exactly [`SizeBound::plain`], so the answer falls out of W1
565/// rather than becoming a second notion of "fixed layout" maintained by hand —
566/// which is the drift `borrow_loaned_message` and
567/// `subscription_supports_in_place` would otherwise each grow their own version
568/// of.
569pub const fn is_loan_eligible<M: crate::schema::Message>() -> bool {
570    M::IS_PLAIN
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::{
577        cdr::CdrWriter,
578        schema::{Field, NestedType},
579    };
580
581    const fn f(name: &'static str, ty: FieldType) -> Field {
582        Field {
583            name,
584            ty,
585            offset: 0,
586        }
587    }
588
589    // ========================================================================
590    // issue 0896 layer 0 — naming the member that costs the bound
591    // ========================================================================
592
593    /// The two walks must agree. `first_unbounded` is a SECOND walk of the same
594    /// schema, which is the shape the sizes-header mirror defect keeps taking
595    /// (0088 -> 0268), so it earns its existence only by answering a question
596    /// `size_bound` cannot — and only while it never disagrees about the
597    /// question they share.
598    fn agree(fields: &'static [Field]) {
599        for version in [EncodingVersion::Xcdr1, EncodingVersion::Xcdr2] {
600            let bounded = size_bound(fields, version, 0).bounded;
601            let named = first_unbounded(fields);
602            assert_eq!(
603                bounded,
604                named.is_none(),
605                "size_bound says bounded={bounded} but first_unbounded says \
606                 {named:?} ({version:?})"
607            );
608        }
609    }
610
611    #[test]
612    fn a_bounded_type_has_no_offender_to_name() {
613        static FIELDS: &[Field] = &[
614            f("x", FieldType::Uint32),
615            f("s", FieldType::BoundedString(8)),
616        ];
617        agree(FIELDS);
618    }
619
620    #[test]
621    fn an_unbounded_string_is_named() {
622        static FIELDS: &[Field] = &[f("flag", FieldType::Bool), f("label", FieldType::String)];
623        agree(FIELDS);
624        let u = first_unbounded(FIELDS).unwrap();
625        assert_eq!(u.kind, UnboundedKind::String);
626        assert_eq!(&u.path[..u.depth], &["label"]);
627    }
628
629    /// The offender is usually not at the top level — this is the whole reason
630    /// the path exists rather than a bare field name.
631    #[test]
632    fn a_nested_offender_reports_its_full_path() {
633        static INNER: &[Field] = &[f("sec", FieldType::Int32), f("frame_id", FieldType::String)];
634        static INNER_TY: NestedType = NestedType {
635            type_name: "std_msgs/msg/Header",
636            fields: INNER,
637        };
638        static FIELDS: &[Field] = &[f("header", FieldType::Nested(&INNER_TY))];
639        agree(FIELDS);
640        let u = first_unbounded(FIELDS).unwrap();
641        assert_eq!(&u.path[..u.depth], &["header", "frame_id"]);
642    }
643
644    /// `string[4]` is a FIXED array of an UNBOUNDED element: the count is
645    /// known and the bound is not. Reported at the field's own path, because
646    /// an element has no name of its own.
647    #[test]
648    fn a_fixed_array_of_unbounded_elements_is_unbounded() {
649        static ELEM: FieldType = FieldType::String;
650        static FIELDS: &[Field] = &[f("names", FieldType::Array(4, &ELEM))];
651        agree(FIELDS);
652        let u = first_unbounded(FIELDS).unwrap();
653        assert_eq!(&u.path[..u.depth], &["names"]);
654        assert_eq!(u.kind, UnboundedKind::String);
655    }
656
657    /// A bounded sequence of bounded elements IS bounded — the easy way to get
658    /// this wrong is to treat any sequence as unbounded.
659    #[test]
660    fn a_bounded_sequence_of_bounded_elements_is_bounded() {
661        static ELEM: FieldType = FieldType::Uint16;
662        static FIELDS: &[Field] = &[f("ranges", FieldType::BoundedSequence(16, &ELEM))];
663        agree(FIELDS);
664        assert!(first_unbounded(FIELDS).is_none());
665    }
666
667    #[test]
668    fn the_first_offender_wins_so_the_message_names_one_thing() {
669        static FIELDS: &[Field] = &[
670            f("a", FieldType::String),
671            f("b", FieldType::Sequence(&FieldType::Uint8)),
672        ];
673        let u = first_unbounded(FIELDS).unwrap();
674        assert_eq!(&u.path[..u.depth], &["a"]);
675    }
676
677    /// A stack-only `core::fmt::Write` sink.
678    ///
679    /// `alloc::format!` would be shorter and would test the wrong build:
680    /// `Display` here exists FOR the `no_std` diagnostic path, so the test runs
681    /// where that path runs. Silently drops overflow — the assertion below
682    /// catches a truncated result either way.
683    struct Buf {
684        bytes: [u8; 128],
685        len: usize,
686    }
687
688    impl core::fmt::Write for Buf {
689        fn write_str(&mut self, s: &str) -> core::fmt::Result {
690            for b in s.as_bytes() {
691                if self.len < self.bytes.len() {
692                    self.bytes[self.len] = *b;
693                    self.len += 1;
694                }
695            }
696            Ok(())
697        }
698    }
699
700    #[test]
701    fn the_display_form_reads_as_a_path_and_a_kind() {
702        use core::fmt::Write;
703        static INNER: &[Field] = &[f("frame_id", FieldType::String)];
704        static INNER_TY: NestedType = NestedType {
705            type_name: "std_msgs/msg/Header",
706            fields: INNER,
707        };
708        static FIELDS: &[Field] = &[f("header", FieldType::Nested(&INNER_TY))];
709        let u = first_unbounded(FIELDS).unwrap();
710
711        let mut buf = Buf {
712            bytes: [0; 128],
713            len: 0,
714        };
715        write!(buf, "{u}").unwrap();
716        assert_eq!(
717            core::str::from_utf8(&buf.bytes[..buf.len]).unwrap(),
718            "header.frame_id (string)"
719        );
720    }
721
722    // ========================================================================
723    // phase-403 W0 — naming EVERY member, not the first
724    // ========================================================================
725
726    /// Collect a whole type's offenders. `heapless` rather than `Vec` because
727    /// this crate is `no_std`; that is also the point of the visitor shape.
728    fn all(fields: &'static [Field]) -> heapless::Vec<UnboundedField, 16> {
729        let mut out = heapless::Vec::new();
730        let _ = visit_unbounded(fields, &mut |u| {
731            let _ = out.push(u);
732            ControlFlow::Continue(())
733        });
734        out
735    }
736
737    fn names(fields: &'static [Field]) -> heapless::Vec<&'static str, 16> {
738        let mut out = heapless::Vec::new();
739        for u in all(fields) {
740            let _ = out.push(u.path[u.depth - 1]);
741        }
742        out
743    }
744
745    /// An unbounded type is a build error now, and a stock ROS type is unbounded
746    /// in several places at once, so one build has to name all of them —
747    /// otherwise bounding a package is one cap and one full codegen run per
748    /// member.
749    #[test]
750    fn every_unbounded_member_is_visited_in_declaration_order() {
751        static FIELDS: &[Field] = &[
752            f("a", FieldType::String),
753            f("keep", FieldType::Int32),
754            f("b", FieldType::Sequence(&FieldType::Int64)),
755            f("c", FieldType::WString),
756        ];
757        assert_eq!(names(FIELDS).as_slice(), &["a", "b", "c"]);
758    }
759
760    /// Nested members are visited too, and a bounded sibling does not stop the
761    /// walk from continuing past the struct that contained one.
762    #[test]
763    fn the_walk_continues_past_a_nested_offender_to_its_siblings() {
764        static INNER: &[Field] = &[f("frame_id", FieldType::String)];
765        static INNER_TY: NestedType = NestedType {
766            type_name: "std_msgs/msg/Header",
767            fields: INNER,
768        };
769        static FIELDS: &[Field] = &[
770            f("header", FieldType::Nested(&INNER_TY)),
771            f("child_frame_id", FieldType::String),
772        ];
773        assert_eq!(names(FIELDS).as_slice(), &["frame_id", "child_frame_id"]);
774        // The path, not just the leaf name, so a diagnostic can be acted on.
775        assert_eq!(all(FIELDS)[0].path[0], "header");
776        assert_eq!(all(FIELDS)[0].depth, 2);
777    }
778
779    /// `first_unbounded` is now expressed on top of this walk, so it must still
780    /// answer exactly what the walk reports first — and must still stop, rather
781    /// than enumerating a type and discarding all but one answer.
782    #[test]
783    fn the_first_offender_is_the_first_one_visited() {
784        static INNER: &[Field] = &[f("frame_id", FieldType::String)];
785        static INNER_TY: NestedType = NestedType {
786            type_name: "std_msgs/msg/Header",
787            fields: INNER,
788        };
789        static FLAT: &[Field] = &[f("a", FieldType::String), f("b", FieldType::String)];
790        static NESTED: &[Field] = &[
791            f("header", FieldType::Nested(&INNER_TY)),
792            f("tail", FieldType::String),
793        ];
794        static CLEAN: &[Field] = &[f("only", FieldType::Int32)];
795        for fields in [FLAT, NESTED, CLEAN] {
796            assert_eq!(first_unbounded(fields), all(fields).first().copied());
797        }
798    }
799
800    /// A visitor that breaks stops the walk where it broke — which is the
801    /// mechanism `first_unbounded` uses, asserted rather than assumed.
802    #[test]
803    fn a_visitor_that_breaks_stops_the_walk() {
804        static FIELDS: &[Field] = &[
805            f("a", FieldType::String),
806            f("b", FieldType::String),
807            f("c", FieldType::String),
808        ];
809        let mut seen = 0usize;
810        let flow = visit_unbounded(FIELDS, &mut |_| {
811            seen += 1;
812            if seen == 2 {
813                ControlFlow::Break(())
814            } else {
815                ControlFlow::Continue(())
816            }
817        });
818        assert_eq!(seen, 2);
819        assert_eq!(flow, ControlFlow::Break(()));
820    }
821
822    /// A bounded type has nothing to report, from either form.
823    #[test]
824    fn a_bounded_type_visits_nothing() {
825        static FIELDS: &[Field] = &[
826            f("a", FieldType::BoundedString(8)),
827            f("b", FieldType::Int32),
828        ];
829        assert!(all(FIELDS).is_empty());
830        assert!(first_unbounded(FIELDS).is_none());
831    }
832
833    /// Serialize a MAXIMAL instance of a schema with the real `CdrWriter`, and
834    /// return the byte length the writer produced (header included).
835    ///
836    /// This is the whole point of the test module: a bound that is merely
837    /// self-consistent proves nothing. Every assertion below compares
838    /// [`max_serialized_size`] against bytes this function actually wrote.
839    fn write_maximal(fields: &[Field], version: EncodingVersion, buf: &mut [u8]) -> usize {
840        let mut w = match version {
841            EncodingVersion::Xcdr1 => CdrWriter::new_with_header(buf).unwrap(),
842            EncodingVersion::Xcdr2 => CdrWriter::new_with_header_xcdr2(buf).unwrap(),
843        };
844        let dh = w.begin_dheader().unwrap();
845        write_fields(&mut w, fields);
846        w.end_dheader(dh).unwrap();
847        w.position()
848    }
849
850    /// Phase 392 W3a — `max_serialized_bound` is what routes a subscription to a
851    /// size class, so the two ways it can be wrong both cost real RAM or real
852    /// truncation: reporting the smaller encoding under-sizes the block the peer
853    /// may actually fill, and inventing a number for an unbounded type sizes a
854    /// buffer from a fallback, which phase 380 forbids in as many words.
855    struct BoundedMsg;
856    impl crate::schema::Message for BoundedMsg {
857        const TYPE_NAME: &'static str = "test/msg/BoundedMsg";
858        const FIELDS: &'static [Field] = &[
859            f("a", FieldType::Uint8),
860            f("b", FieldType::Uint64),
861            f("c", FieldType::Uint8),
862        ];
863    }
864
865    struct UnboundedMsg;
866    impl crate::schema::Message for UnboundedMsg {
867        const TYPE_NAME: &'static str = "test/msg/UnboundedMsg";
868        const FIELDS: &'static [Field] = &[f("s", FieldType::String)];
869    }
870
871    #[test]
872    fn max_serialized_bound_takes_the_larger_encoding() {
873        let x1 = <BoundedMsg as crate::schema::Message>::MAX_SERIALIZED_SIZE_XCDR1
874            .expect("bounded in xcdr1");
875        let x2 = <BoundedMsg as crate::schema::Message>::MAX_SERIALIZED_SIZE_XCDR2
876            .expect("bounded in xcdr2");
877        let got = max_serialized_bound::<BoundedMsg>().expect("bounded type has a bound");
878        assert_eq!(
879            got,
880            x1.max(x2),
881            "must take the LARGER encoding — the peer picks it at runtime, so \
882             sizing from one alone is the trap phase 380 documents"
883        );
884        assert!(
885            bound_fits::<BoundedMsg>(got),
886            "the value must satisfy the predicate it is derived from"
887        );
888        assert!(
889            !bound_fits::<BoundedMsg>(got - 1),
890            "one byte under the bound must NOT fit, or the value is not tight"
891        );
892    }
893
894    #[test]
895    fn max_serialized_bound_is_none_for_an_unbounded_type() {
896        assert_eq!(
897            max_serialized_bound::<UnboundedMsg>(),
898            None,
899            "an unbounded type has NO bound; returning a number here would let a \
900             caller size a buffer from a fallback"
901        );
902        assert!(
903            bound_fits::<UnboundedMsg>(1),
904            "unbounded still passes the BUILD assertion — nothing is provable"
905        );
906    }
907
908    fn write_fields(w: &mut CdrWriter<'_>, fields: &[Field]) {
909        for field in fields {
910            write_one(w, &field.ty);
911        }
912    }
913
914    fn write_one(w: &mut CdrWriter<'_>, ty: &FieldType) {
915        match ty {
916            FieldType::Bool => w.write_bool(true).unwrap(),
917            FieldType::Uint8 => w.write_u8(0xAB).unwrap(),
918            FieldType::Int8 => w.write_i8(-1).unwrap(),
919            FieldType::Uint16 => w.write_u16(0xBEEF).unwrap(),
920            FieldType::Int16 => w.write_i16(-2).unwrap(),
921            FieldType::Uint32 => w.write_u32(0xDEADBEEF).unwrap(),
922            FieldType::Int32 => w.write_i32(-3).unwrap(),
923            FieldType::Float32 => w.write_f32(1.5).unwrap(),
924            FieldType::Uint64 => w.write_u64(u64::MAX).unwrap(),
925            FieldType::Int64 => w.write_i64(-4).unwrap(),
926            FieldType::Float64 => w.write_f64(2.5).unwrap(),
927            // Maximal = exactly `n` payload bytes, which is what the bound
928            // claims room for.
929            FieldType::BoundedString(n) => {
930                let s = "x".repeat(*n);
931                w.write_string(&s).unwrap()
932            }
933            FieldType::String => w.write_string("").unwrap(),
934            FieldType::Array(n, inner) => {
935                for _ in 0..*n {
936                    write_one(w, inner);
937                }
938            }
939            FieldType::BoundedSequence(n, inner) => {
940                w.write_u32(*n as u32).unwrap();
941                for _ in 0..*n {
942                    write_one(w, inner);
943                }
944            }
945            FieldType::Nested(nested) => {
946                let dh = w.begin_dheader().unwrap();
947                write_fields(w, nested.fields);
948                w.end_dheader(dh).unwrap();
949            }
950            other => panic!("test writer has no maximal value for {other:?}"),
951        }
952    }
953
954    /// The core property, in both encodings: the computed bound is an upper
955    /// bound on what the writer produces, and an EXACT one when `plain`.
956    fn assert_agrees(fields: &'static [Field], version: EncodingVersion) {
957        let mut buf = [0u8; 4096];
958        let actual = write_maximal(fields, version, &mut buf);
959        let bound = max_serialized_size(fields, version).expect("these fixtures are all bounded");
960        assert!(
961            actual <= bound,
962            "{version:?}: writer produced {actual} bytes, bound claimed {bound} — \
963             an UNDER-reported bound sizes a buffer too small, which is the drop \
964             this module exists to stop"
965        );
966        if size_bound(fields, version, 0).plain {
967            assert_eq!(
968                actual, bound,
969                "{version:?}: a plain type's bound must be EXACT, not merely an \
970                 upper bound"
971            );
972        }
973    }
974
975    fn both(fields: &'static [Field]) {
976        assert_agrees(fields, EncodingVersion::Xcdr1);
977        assert_agrees(fields, EncodingVersion::Xcdr2);
978    }
979
980    // `builtin_interfaces/Time` — the plain case, and the one whose bound must
981    // be exact.
982    static TIME: &[Field] = &[f("sec", FieldType::Int32), f("nanosec", FieldType::Uint32)];
983
984    #[test]
985    fn plain_struct_bound_is_exact() {
986        both(TIME);
987        assert!(size_bound(TIME, EncodingVersion::Xcdr1, 0).plain);
988    }
989
990    /// The defect issue 0776 calls out first: a message containing an `int64`
991    /// has TWO different bounds, because `CdrWriter::align` honours 8 under
992    /// XCDR1 and caps at 4 under XCDR2. A single constant is silently wrong for
993    /// one of them.
994    #[test]
995    fn eight_byte_alignment_differs_by_encoding() {
996        // Two `int64`s, deliberately: with only ONE the two encodings come out
997        // EQUAL, and that is a coincidence rather than a missing cap. For
998        // `[u8, i64]` XCDR1 pads 7 before the i64 while XCDR2 caps alignment at
999        // 4 and pads 3 — saving exactly the 4 bytes its DHEADER costs, so both
1000        // totals are 20. A test built on that single field would have "passed"
1001        // while asserting nothing, and would keep passing if the cap were
1002        // deleted. Repeating the pattern breaks the tie: the padding saving
1003        // scales with the number of 8-byte members, the DHEADER does not.
1004        static S: &[Field] = &[
1005            f("flag", FieldType::Uint8),
1006            f("big", FieldType::Int64),
1007            f("flag2", FieldType::Uint8),
1008            f("big2", FieldType::Int64),
1009        ];
1010        both(S);
1011        let x1 = max_serialized_size(S, EncodingVersion::Xcdr1).unwrap();
1012        let x2 = max_serialized_size(S, EncodingVersion::Xcdr2).unwrap();
1013        assert_ne!(
1014            x1, x2,
1015            "8-byte primitives must pad differently under the two encodings; if \
1016             these agree the alignment cap is not being applied"
1017        );
1018        assert!(
1019            x1 > x2,
1020            "XCDR1 aligns 8-byte primitives to 8 and so must be the larger of \
1021             the two here ({x1} vs {x2})"
1022        );
1023    }
1024
1025    /// The coincidence above, pinned so nobody "simplifies" the test back into
1026    /// it: for this one layout the encodings genuinely agree, and a bound that
1027    /// reported one number for both would look correct here and nowhere else.
1028    #[test]
1029    fn a_single_int64_makes_the_two_encodings_agree_by_coincidence() {
1030        static S: &[Field] = &[f("flag", FieldType::Uint8), f("big", FieldType::Int64)];
1031        both(S);
1032        assert_eq!(
1033            max_serialized_size(S, EncodingVersion::Xcdr1),
1034            max_serialized_size(S, EncodingVersion::Xcdr2),
1035            "XCDR2's DHEADER (+4) exactly offsets the padding its alignment cap \
1036             saves (-4) for this shape"
1037        );
1038    }
1039
1040    /// Padding depends on WHERE a field starts, so summing per-field maxima is
1041    /// wrong. Same fields, different order, different size.
1042    #[test]
1043    fn offset_is_threaded_not_summed() {
1044        static A: &[Field] = &[f("a", FieldType::Uint8), f("b", FieldType::Uint32)];
1045        static B: &[Field] = &[f("b", FieldType::Uint32), f("a", FieldType::Uint8)];
1046        both(A);
1047        both(B);
1048        assert_ne!(
1049            max_serialized_size(A, EncodingVersion::Xcdr1),
1050            max_serialized_size(B, EncodingVersion::Xcdr1),
1051            "u8,u32 pads and u32,u8 does not — a sum of maxima cannot tell them apart"
1052        );
1053    }
1054
1055    #[test]
1056    fn bounded_string_and_sequence_are_bounded_but_not_plain() {
1057        static S: &[Field] = &[
1058            f("name", FieldType::BoundedString(8)),
1059            f("vals", FieldType::BoundedSequence(3, &FieldType::Uint32)),
1060        ];
1061        both(S);
1062        let b = size_bound(S, EncodingVersion::Xcdr1, 0);
1063        assert!(b.bounded);
1064        assert!(!b.plain, "variable-length members are not loan-eligible");
1065    }
1066
1067    #[test]
1068    fn nested_struct_composes_at_the_parent_offset() {
1069        static NESTED: NestedType = NestedType {
1070            type_name: "builtin_interfaces/msg/Time",
1071            fields: TIME,
1072        };
1073        static S: &[Field] = &[
1074            f("flag", FieldType::Uint8),
1075            f("stamp", FieldType::Nested(&NESTED)),
1076        ];
1077        both(S);
1078    }
1079
1080    /// An unbounded member makes the type unbounded, and `max_serialized_size`
1081    /// must answer `None` rather than a floor someone could size a buffer from.
1082    #[test]
1083    fn unbounded_member_yields_none() {
1084        static S: &[Field] = &[
1085            f(
1086                "stamp",
1087                FieldType::Nested(&NestedType {
1088                    type_name: "builtin_interfaces/msg/Time",
1089                    fields: TIME,
1090                }),
1091            ),
1092            f("frame_id", FieldType::String),
1093        ];
1094        assert_eq!(max_serialized_size(S, EncodingVersion::Xcdr1), None);
1095        assert!(!size_bound(S, EncodingVersion::Xcdr1, 0).bounded);
1096        assert!(!size_bound(S, EncodingVersion::Xcdr1, 0).plain);
1097    }
1098
1099    /// XCDR2 delimits every struct, nested ones included. Missing the nested
1100    /// DHEADER under-reports — the dangerous direction.
1101    #[test]
1102    fn xcdr2_counts_a_dheader_per_struct() {
1103        static INNER: NestedType = NestedType {
1104            type_name: "t/Inner",
1105            fields: TIME,
1106        };
1107        static FLAT: &[Field] = &[f("sec", FieldType::Int32), f("nanosec", FieldType::Uint32)];
1108        static WRAPPED: &[Field] = &[f("inner", FieldType::Nested(&INNER))];
1109        both(WRAPPED);
1110        let flat = max_serialized_size(FLAT, EncodingVersion::Xcdr2).unwrap();
1111        let wrapped = max_serialized_size(WRAPPED, EncodingVersion::Xcdr2).unwrap();
1112        assert_eq!(
1113            wrapped,
1114            flat + 4,
1115            "the nested struct's own DHEADER must be counted under XCDR2"
1116        );
1117        // ...and must NOT be under XCDR1, where begin_dheader is a no-op.
1118        assert_eq!(
1119            max_serialized_size(WRAPPED, EncodingVersion::Xcdr1).unwrap(),
1120            max_serialized_size(FLAT, EncodingVersion::Xcdr1).unwrap(),
1121            "XCDR1 has no DHEADER; wrapping must cost nothing"
1122        );
1123    }
1124}