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}