nros_node/declared_qos.rs
1//! phase-454 W10 — the DECLARED QoS history depth, and what Rust does with it.
2//!
3//! # What this is
4//!
5//! The QoS history DEPTH of a subscription multiplies the executor arena:
6//! `sizeof(entry) + (depth + 1) * bound + (depth + 1) * 8`. A build that sizes
7//! from a declared depth and an image that registers a different one are an
8//! image sized for one number and running another — so the two have to be held
9//! to one. C++ holds them with a `static_assert` inside `NROS_SUBSCRIBE`, and C
10//! with a `_Static_assert` inside `NROS_ASSERT_DECLARED_DEPTH`.
11//!
12//! # Why Rust's half is at REGISTRATION and not at compile time
13//!
14//! Both C surfaces get a compile-time answer because a preprocessor macro sees
15//! the call site's TOPIC as a token. Rust has neither of the two things that
16//! would make the same answer possible:
17//!
18//! * **No macro seam at the subscribe site.** `nros::main!` emits the boot
19//! scaffold and knows nothing about subscriptions; there is no
20//! `nros::subscribe!`. Every call site is an ordinary method call.
21//! * **The topic is a runtime `&str`**, not a const generic. A `const`
22//! assertion would need it in the type system.
23//!
24//! And the descriptor road differs too: a RUST component is registered as an
25//! empty cmake INTERFACE library (`NanoRosNodeRegister.cmake`), compiled by the
26//! workspace runtime crate rather than by that target, so there is no C
27//! preprocessor in the lane and no include path to put a header on. That is why
28//! `_nros_declared_qos_arm()` returns early for `RUST` — the header mechanism
29//! is not available to it, not merely unimplemented.
30//!
31//! # TWO facts arrive, on two roads
32//!
33//! The declared depths reach `nros-node/build.rs` from whichever carrier the
34//! road has, and it writes both into [`crate::config::DECLARED_QOS_ROWS`]:
35//!
36//! * the **cmake road** (native C/C++, Zephyr west, NuttX) sets
37//! `NROS_ENTITY_DECLARED_DEPTHS` (`type|topic=depth`), written by the entity
38//! inventory from the resolved SystemModel;
39//! * the **single-package cargo leaf road** — the one phase-454 W12 taught to
40//! read a `system.contract.yaml` — sets no such variable and names a SIZING
41//! DESCRIPTOR instead, whose `[[endpoint]]` rows carry the same fact
42//! (RFC-0100 D4). W10 shipped reading only the first, so on exactly the road
43//! that had a contract this table was `None` and nothing could be compared.
44//!
45//! # And two things are done with them, because the surfaces differ
46//!
47//! * [`check`](crate::declared_qos::check) REFUSES a disagreement, with
48//! [`NodeError::DeclaredDepthMismatch`] — the registration fails, the
49//! subscription never exists, and the entry's `?` carries it out. Reached
50//! from the C and C++ FFI seams, which took the declared depth at their own
51//! call site already.
52//! * [`honour`](crate::declared_qos::honour) is what a RUST registration calls: it TAKES a declared depth
53//! shallower than the ask (the C++ three-argument `NROS_SUBSCRIBE`'s answer,
54//! delivered one layer down because Rust has no call-site seam) and refuses
55//! a declaration DEEPER than the ask. Its doc comment has the whole rule and
56//! why the asymmetry is real.
57//!
58//! # ABSENCE IS NOT ZERO
59//!
60//! A `(type, topic)` with no row is "nobody declared this endpoint". Nothing is
61//! checked for it, nothing is taken, and nothing is defaulted. An image with no
62//! contract sidecar has `DECLARED_QOS_ROWS == None` and behaves exactly as it
63//! did before — measured, not asserted: on `examples/linux/rust/listener` with
64//! the sidecar removed, `.bss + .data` and every attributed symbol are
65//! identical to `origin/main`'s, and the image carries no `declared_qos` symbol
66//! at all.
67//!
68//! # issue 1256 -- RELIABILITY and DURABILITY too
69//!
70//! A contract can state three things about a subscription and this module now
71//! reads all three. The two policies are not a sizing fact the way depth is;
72//! they are an INTEROP one -- an incompatible-QoS match never delivers -- and
73//! until this they were declared, delivered to the build and never compared
74//! with the code. They arrive on the sizing DESCRIPTOR only (no env knob is
75//! added for them: re-carrying a fact the descriptor already states is the
76//! 0460/0491 shape), and [`check`](crate::declared_qos::check) / [`honour`](crate::declared_qos::honour) treat them by the same rules as
77//! the depth, with the order of the two values standing in for "deeper".
78//!
79//! # issue 1608 -- PUBLISHERS too, with the policy direction REVERSED
80//!
81//! A contract can state the same three things about a publisher, and
82//! `transient_local` is the one QoS fact that is publisher-side by nature: it is
83//! what a zenoh image spends a queryable slot on. The descriptor's publisher
84//! rows reach [`DECLARED_PUBLISHER_QOS_ROWS`](crate::declared_qos::DECLARED_PUBLISHER_QOS_ROWS)
85//! (a table of its own: a publisher and a subscription on one `(type, topic)`
86//! are two endpoints), and every publisher registration -- the Rust seams and
87//! the C/C++ FFI seams alike -- goes through
88//! [`honour_publisher`](crate::declared_qos::honour_publisher).
89//!
90//! Its rule is NOT the subscription's copied across. Reliability and durability
91//! are matched request-vs-offered, so the tolerant direction flips: a reader may
92//! ask for less than a writer offers, a writer may offer more than a reader asks.
93//! A publisher asking LESS than its declaration is therefore RAISED to it (the
94//! build reserved it, and every reader that matched the lower offer still
95//! matches); one asking MORE is REFUSED (lowering it could break a match, and
96//! keeping it rides storage the build did not count). The depth rule is shared.
97//! Measured on `examples/linux/rust/talker` with a contract declaring
98//! `/chatter` `transient_local`, depth 1: the default publisher registers
99//! transient_local, depth 1, and reports both takes; declaring `best_effort`
100//! instead refuses the registration and the image exits naming the topic.
101
102use crate::executor::NodeError;
103use nros_rmw::{QoSDurabilityPolicy, QoSProfile, QoSReliabilityPolicy};
104
105/// One subscription this image's contract declares something about.
106///
107/// Each column is `None` where nobody stated it -- a row exists when ANY of the
108/// three is stated, so any one of them may be silent. `type_name` appears once
109/// in the ROS spelling and once DDS-mangled, as two rows, because which one a
110/// generated message class carries is a property of the codegen that produced
111/// it.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct DeclaredEndpoint {
114 pub type_name: &'static str,
115 pub topic: &'static str,
116 /// The declared KEEP_LAST depth.
117 pub depth: Option<u32>,
118 /// `Reliable` / `BestEffort` -- never `SystemDefault`, which says "the
119 /// middleware chooses" and so cannot be disagreed with.
120 pub reliability: Option<QoSReliabilityPolicy>,
121 /// `Volatile` / `TransientLocal`, on the same rule.
122 pub durability: Option<QoSDurabilityPolicy>,
123}
124
125/// Every subscription this image's contract declares something about, or
126/// `None` when it declares nothing.
127pub use crate::config::DECLARED_QOS_ROWS;
128
129/// issue 1608 -- every PUBLISHER this image's contract declares something
130/// about, or `None`. Same row shape as [`DECLARED_QOS_ROWS`]; read by
131/// [`honour_publisher`].
132pub use crate::config::DECLARED_PUBLISHER_QOS_ROWS;
133
134/// Do two topic spellings name the same endpoint?
135///
136/// One leading `/` is ignored on each side: a contract states `/chatter` and a
137/// call site may write either `"/chatter"` or the relative `"chatter"` (
138/// `examples/workspaces/launch/src/listener_pkg` writes the second). Nothing
139/// deeper is normalised — a namespace is a different endpoint, and guessing at
140/// one here would make the check match rows nobody meant.
141fn topic_eq(a: &str, b: &str) -> bool {
142 let a = a.strip_prefix('/').unwrap_or(a);
143 let b = b.strip_prefix('/').unwrap_or(b);
144 a == b
145}
146
147/// The declared row for `(type_name, topic)` in `rows`, or `None`.
148///
149/// Split out so the lookup is testable against a table this build did not
150/// generate: in-tree, `DECLARED_QOS_ROWS` is `None` (no contract reaches a
151/// unit-test build), so a test that could only call the public form would
152/// assert nothing while passing.
153fn row_in<'r>(
154 rows: &'r [DeclaredEndpoint],
155 type_name: &str,
156 topic: &str,
157) -> Option<&'r DeclaredEndpoint> {
158 rows.iter()
159 .find(|r| r.type_name == type_name && topic_eq(r.topic, topic))
160}
161
162/// The declared depth for `(type_name, topic)` in `rows`, or `None`.
163fn depth_in(rows: &[DeclaredEndpoint], type_name: &str, topic: &str) -> Option<u32> {
164 row_in(rows, type_name, topic).and_then(|r| r.depth)
165}
166
167/// The declared depth for `(type_name, topic)`, or `None` when nobody declared
168/// that endpoint's depth.
169///
170/// Linear over a handful of rows, on a path that runs once per subscription at
171/// registration. `None` when this image declares nothing at all.
172pub fn declared_depth(type_name: &str, topic: &str) -> Option<u32> {
173 depth_in(DECLARED_QOS_ROWS?, type_name, topic)
174}
175
176/// issue 1256 -- the declared reliability for `(type_name, topic)`, or `None`.
177pub fn declared_reliability(type_name: &str, topic: &str) -> Option<QoSReliabilityPolicy> {
178 row_in(DECLARED_QOS_ROWS?, type_name, topic).and_then(|r| r.reliability)
179}
180
181/// issue 1256 -- the declared durability for `(type_name, topic)`, or `None`.
182pub fn declared_durability(type_name: &str, topic: &str) -> Option<QoSDurabilityPolicy> {
183 row_in(DECLARED_QOS_ROWS?, type_name, topic).and_then(|r| r.durability)
184}
185
186/// The contract's own spelling of a reliability, for a log line.
187fn reliability_spelling(r: QoSReliabilityPolicy) -> &'static str {
188 match r {
189 QoSReliabilityPolicy::Reliable => "reliable",
190 QoSReliabilityPolicy::BestEffort => "best_effort",
191 QoSReliabilityPolicy::SystemDefault => "system_default",
192 QoSReliabilityPolicy::Unknown => "unknown",
193 }
194}
195
196/// The contract's own spelling of a durability, for a log line.
197fn durability_spelling(d: QoSDurabilityPolicy) -> &'static str {
198 match d {
199 QoSDurabilityPolicy::Volatile => "volatile",
200 QoSDurabilityPolicy::TransientLocal => "transient_local",
201 QoSDurabilityPolicy::SystemDefault => "system_default",
202 QoSDurabilityPolicy::Unknown => "unknown",
203 }
204}
205
206/// How much a reliability ASKS FOR: the order [`honour`] reads "stronger" in.
207/// `None` is "states no opinion" -- `SystemDefault` hands the choice to the
208/// middleware and `Unknown` is a read-back sentinel nobody requests.
209fn reliability_rank(r: QoSReliabilityPolicy) -> Option<u8> {
210 match r {
211 QoSReliabilityPolicy::BestEffort => Some(0),
212 QoSReliabilityPolicy::Reliable => Some(1),
213 QoSReliabilityPolicy::SystemDefault | QoSReliabilityPolicy::Unknown => None,
214 }
215}
216
217/// How much a durability ASKS FOR. See [`reliability_rank`].
218fn durability_rank(d: QoSDurabilityPolicy) -> Option<u8> {
219 match d {
220 QoSDurabilityPolicy::Volatile => Some(0),
221 QoSDurabilityPolicy::TransientLocal => Some(1),
222 QoSDurabilityPolicy::SystemDefault | QoSDurabilityPolicy::Unknown => None,
223 }
224}
225
226/// Refuse a subscription whose QoS disagrees with what the contract declared
227/// for its topic -- its depth, and (issue 1256) its reliability and durability.
228///
229/// `Ok(())` when every declared column agrees AND when nothing was declared --
230/// an image that has not opted in is not an image in error. On a disagreement
231/// the TOPIC and BOTH values go to the log first (a `NodeError` is a unit
232/// variant and can carry none of the three), then the registration is
233/// refused: [`NodeError::DeclaredDepthMismatch`] for the depth,
234/// [`NodeError::DeclaredQosMismatch`] for a policy.
235///
236/// Reached from the **C and C++ FFI registration seams**, which have already
237/// taken the declared QoS at their own call site (`NROS_SUBSCRIBE`'s
238/// three-argument form → `qos_from_declared`; C's `_Static_assert`), so a
239/// disagreement arriving here is a real one. The Rust seams call [`honour`]
240/// instead — see its doc comment for why the two surfaces differ.
241///
242/// # Keep the message inside ONE log line
243///
244/// `nros_log`'s formatting buffer is 256 bytes by default (`NROS_LOG_BUFFER_SIZE`)
245/// and OVERFLOW TRUNCATES with a single `…`. This message used to run past 450
246/// characters, so on a real image the half a reader needs — which file to edit —
247/// was the half that got cut, and the diagnostic ended mid-sentence. The
248/// numbers and the topic come first for the same reason: a long type name may
249/// still push the tail off, and the tail is the least load-bearing part.
250pub fn check(type_name: &str, topic: &str, qos: &QoSProfile) -> Result<(), NodeError> {
251 match DECLARED_QOS_ROWS {
252 Some(rows) => check_in(rows, type_name, topic, qos),
253 None => Ok(()),
254 }
255}
256
257/// [`check`] against a table this build did not generate. See [`row_in`].
258fn check_in(
259 rows: &[DeclaredEndpoint],
260 type_name: &str,
261 topic: &str,
262 qos: &QoSProfile,
263) -> Result<(), NodeError> {
264 let Some(row) = row_in(rows, type_name, topic) else {
265 return Ok(());
266 };
267 if let Some(declared) = row.depth
268 && declared != qos.depth
269 {
270 nros_log::log_error!(
271 nros_log::get_logger("nros_node"),
272 "declared depth: `{}` registers KEEP_LAST({}) but this image's contract \
273 declares {}, and the arena was sized from {}. Fix the contract row or the \
274 QoS at the call site (type `{}`).",
275 topic,
276 qos.depth,
277 declared,
278 declared,
279 type_name
280 );
281 return Err(NodeError::DeclaredDepthMismatch);
282 }
283 if let Some(declared) = row.reliability
284 && declared != qos.reliability
285 {
286 nros_log::log_error!(
287 nros_log::get_logger("nros_node"),
288 "declared QoS: `{}` registers {} but this image's contract declares reliability \
289 {}; an incompatible match never delivers. Fix the contract row or the call site.",
290 topic,
291 reliability_spelling(qos.reliability),
292 reliability_spelling(declared)
293 );
294 return Err(NodeError::DeclaredQosMismatch);
295 }
296 if let Some(declared) = row.durability
297 && declared != qos.durability
298 {
299 nros_log::log_error!(
300 nros_log::get_logger("nros_node"),
301 "declared QoS: `{}` registers {} but this image's contract declares durability \
302 {}; an incompatible match never delivers. Fix the contract row or the call site.",
303 topic,
304 durability_spelling(qos.durability),
305 durability_spelling(declared)
306 );
307 return Err(NodeError::DeclaredQosMismatch);
308 }
309 Ok(())
310}
311
312/// The QoS a Rust subscription actually registers with, once the contract has
313/// had its say — phase-454 W13, the Rust half of W10, widened to the two
314/// policies by issue 1256.
315///
316/// # Why Rust TAKES the declared value where C and C++ ASSERT it
317///
318/// C++ settles this at the call site, by arity: `NROS_SUBSCRIBE(Msg, m, topic)`
319/// states no QoS, so `qos_from_declared` BUILDS one from the declaration
320/// ("nothing to assert — there is only ever one statement"), and the
321/// four-argument form, where the call site did state one, `static_assert`s that
322/// the two agree. C does the same with `NROS_ASSERT_DECLARED_*`.
323///
324/// Rust has neither half of that seam: there is no `nros::subscribe!`, and the
325/// topic is a runtime `&str`, so nothing at the call site can be told apart
326/// from anything else at compile time — and by the time a QoS reaches a
327/// registration, "the caller wrote this" and "a constructor defaulted it" are
328/// the same `QoSProfile`. `Node::create_subscription` hands
329/// `QoSProfile::default()` — `rmw_qos_profile_default`, KEEP_LAST(10),
330/// RELIABLE, VOLATILE — to the same `_with_qos` entry point a caller with an
331/// opinion reaches, and the declarative road folds both into one
332/// `EntityMetadata::qos` field long before this point.
333///
334/// So the distinction C++ makes is not available here, and the two candidate
335/// rules that remain each fail on their own:
336///
337/// * **Check only.** Every `QoSProfile::default()` call site in the tree would
338/// be refused the moment its endpoint is declared at anything but the
339/// default — which is the entire point of declaring one. A declaration would
340/// become a way to stop an image booting.
341/// * **Take, always.** A caller who narrowed a queue on purpose
342/// (`create_subscription_viewable` requires KEEP_LAST(1)) or chose
343/// `best_effort` for a sensor would be silently widened by a contract that
344/// over-declared.
345///
346/// # The rule, and why the asymmetry is real
347///
348/// **The contract states what the BUILD RESERVED.** W12 made that literal for
349/// the depth: the executor arena, the zenoh receive ring and both payload pools
350/// on a declared image are all sized from the declared depth. The policies are
351/// the same kind of statement: XRCE drops both reliable stream buffers to the
352/// protocol floor when every endpoint declares `best_effort` (RFC-0100 D3), and
353/// a `transient_local` endpoint is what the build counts a late-joiner slot
354/// for. So for each column, ordered by how much it ASKS FOR (deeper; reliable
355/// over best_effort; transient_local over volatile):
356///
357/// * `asked > declared` — the registration wants more than was bought, and
358/// asking for it would ride storage the build did not reserve. **TAKEN**, and
359/// reported. For the two policies it is also the direction RxO tolerates: a
360/// `best_effort` or `volatile` reader still matches a `reliable` or
361/// `transient_local` writer.
362/// * `asked < declared` — taking would WIDEN what the code narrowed, and the
363/// image paid for something nothing wants. Two statements about one fact
364/// disagreeing, with no default that explains either (RFC-0100 D8).
365/// **REFUSED**, naming both.
366/// * equal, or nothing declared — returned unchanged. An image with no contract
367/// is byte-identical to every build before this wave.
368/// * a policy asked as `SystemDefault` — the code stated no opinion, so the
369/// declaration is TAKEN, silently: there is nothing to report a divergence
370/// from.
371///
372/// # And it says so
373///
374/// A take is REPORTED, once per endpoint that diverges — the C++ surface's
375/// value is visible in the source at the call site and Rust's is not, so a
376/// registration that quietly stopped being what `lib.rs` reads would be the lie
377/// this whole model exists to remove. Every take here narrows what the code
378/// asked for (a shallower queue, no retransmission, no late-joiner history),
379/// any of which can drop a sample, so each is `WARN`.
380///
381/// Called instead of [`check`] at every Rust subscription registration. The C
382/// and C++ FFI seams keep [`check`] — they have already taken the declared QoS
383/// at their own call site, so a disagreement reaching THEM is a real refusal
384/// and not a default nobody wrote.
385///
386/// # `#[inline]` is load-bearing, and it was MEASURED
387///
388/// An image with no contract must be byte-identical to every build before this
389/// wave, and "the table is empty so the work folds away" is a claim about the
390/// optimiser, not a fact. It was FALSE as first written: with the table
391/// threaded in as an `unwrap_or(&[])` argument, `honour` stayed out of line —
392/// a real `T nros_node::declared_qos::honour` symbol, +144 bytes of text and
393/// +8 of data on the no-contract listener against `origin/main`, because a
394/// 40-byte `QoSProfile` moving in and out by value is past LLVM's inlining
395/// budget. Matching the `Option` HERE, behind `#[inline]`, is what lets a
396/// `None` table fold the call to `Ok(qos)` at every one of the fourteen
397/// registration sites and leave `honour_in` unreferenced. Verified by
398/// rebuilding both trees and comparing the linked binary byte for byte.
399#[inline]
400pub fn honour(type_name: &str, topic: &str, qos: QoSProfile) -> Result<QoSProfile, NodeError> {
401 let Some(rows) = DECLARED_QOS_ROWS else {
402 return Ok(qos);
403 };
404 honour_in(rows, type_name, topic, qos)
405}
406
407/// [`honour`] against a table this build did not generate.
408///
409/// Split out for the reason [`row_in`] is: in-tree `DECLARED_QOS_ROWS` is
410/// `None`, so a test that could only call the public form would exercise the
411/// "nothing declared" arm and assert nothing about the take or the refusal
412/// while reading as coverage of both.
413fn honour_in(
414 rows: &[DeclaredEndpoint],
415 type_name: &str,
416 topic: &str,
417 qos: QoSProfile,
418) -> Result<QoSProfile, NodeError> {
419 honour_role_in(rows, type_name, topic, qos, Role::Subscription)
420}
421
422/// Which side of a match an endpoint is on -- issue 1608.
423///
424/// It decides ONE thing: which direction of a POLICY disagreement
425/// [`honour_role_in`] may take. Depth is not a matched policy and is treated
426/// the same on both sides. Reliability and durability are request-vs-offered
427/// (RxO) matched, and the tolerant direction is OPPOSITE for the two roles: a
428/// reader may ask for LESS than a writer offers, a writer may offer MORE than a
429/// reader asks.
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431enum Role {
432 Subscription,
433 Publisher,
434}
435
436impl Role {
437 fn noun(self) -> &'static str {
438 match self {
439 Role::Subscription => "subscription",
440 Role::Publisher => "publisher",
441 }
442 }
443
444 /// Why a [`PolicyVerdict::Refuse`] refuses, in the log line's words.
445 fn refusal_reason(self) -> &'static str {
446 match self {
447 Role::Subscription => "widen what the code narrowed",
448 Role::Publisher => {
449 "weaken the offer below the call site's, and a reader asking for it stops matching"
450 }
451 }
452 }
453}
454
455/// What [`honour_role_in`] does with one POLICY column that disagrees.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457enum PolicyVerdict {
458 /// Take the declaration and report the divergence.
459 Take,
460 /// Take the declaration silently: the call site stated no opinion.
461 TakeSilently,
462 /// Refuse the registration.
463 Refuse,
464}
465
466/// THE rule for a policy disagreement, for both roles, in rank space (`None`
467/// is "no opinion" -- see [`reliability_rank`]).
468///
469/// * **Subscription** -- refuse when the code asked for LESS than was declared
470/// (taking would widen what the code narrowed); otherwise take, which lowers
471/// the request into what the build reserved and is the direction RxO
472/// tolerates for a reader.
473/// * **Publisher** -- refuse when the code asked for MORE than was declared.
474/// Taking would WEAKEN the offer below what the code asked, so a reader that
475/// requests what the code offered stops matching -- an incompatible-QoS pair
476/// never delivers -- while keeping the ask rides storage the build did not
477/// reserve: a `transient_local` writer is a zenoh queryable slot plus its
478/// retained samples, and a `reliable` one is XRCE's reliable stream history,
479/// both counted from the declaration. Otherwise take: RAISING a writer's
480/// offer keeps every reader that matched the lower one, and it is what the
481/// build reserved.
482///
483/// So the two roles share the asymmetry's REASON -- never ride storage the
484/// build did not reserve, never silently break a match -- and get opposite
485/// directions from it, because RxO is directional.
486fn policy_verdict(role: Role, asked: Option<u8>, declared: Option<u8>) -> PolicyVerdict {
487 let (Some(asked), Some(declared)) = (asked, declared) else {
488 return PolicyVerdict::TakeSilently;
489 };
490 let refuse = match role {
491 Role::Subscription => asked < declared,
492 Role::Publisher => asked > declared,
493 };
494 if refuse {
495 PolicyVerdict::Refuse
496 } else {
497 PolicyVerdict::Take
498 }
499}
500
501/// [`honour`] / [`honour_publisher`] for either [`Role`].
502fn honour_role_in(
503 rows: &[DeclaredEndpoint],
504 type_name: &str,
505 topic: &str,
506 qos: QoSProfile,
507 role: Role,
508) -> Result<QoSProfile, NodeError> {
509 let Some(row) = row_in(rows, type_name, topic) else {
510 return Ok(qos);
511 };
512 let mut granted = qos;
513 // The DEPTH rule is the same for both roles: a subscription's depth
514 // multiplies the executor arena, a publisher's is its history cache (the
515 // samples a transient_local writer retains for late joiners), and in both
516 // the build reserved the DECLARED number.
517 if let Some(declared) = row.depth
518 && declared != qos.depth
519 {
520 if declared > qos.depth {
521 nros_log::log_error!(
522 nros_log::get_logger("nros_node"),
523 "declared depth: {} `{}` registers KEEP_LAST({}) and the contract declares {} \
524 -- DEEPER. Taking it would widen a queue the code narrowed; fix the contract \
525 row or the call site (type `{}`).",
526 role.noun(),
527 topic,
528 qos.depth,
529 declared,
530 type_name
531 );
532 return Err(NodeError::DeclaredDepthMismatch);
533 }
534 granted.depth = declared;
535 nros_log::log_warn!(
536 nros_log::get_logger("nros_node"),
537 "declared depth: taking KEEP_LAST({}) on {} `{}`; the call site asked {} and the \
538 build reserved {}. Raise the contract row to keep more (type `{}`).",
539 declared,
540 role.noun(),
541 topic,
542 qos.depth,
543 declared,
544 type_name
545 );
546 }
547 if let Some(declared) = row.reliability
548 && declared != qos.reliability
549 {
550 match policy_verdict(
551 role,
552 reliability_rank(qos.reliability),
553 reliability_rank(declared),
554 ) {
555 PolicyVerdict::Refuse => {
556 nros_log::log_error!(
557 nros_log::get_logger("nros_node"),
558 "declared QoS: {} `{}` registers {} and the contract declares reliability \
559 {}; taking it would {}. Fix the contract row or the call site.",
560 role.noun(),
561 topic,
562 reliability_spelling(qos.reliability),
563 reliability_spelling(declared),
564 role.refusal_reason()
565 );
566 return Err(NodeError::DeclaredQosMismatch);
567 }
568 PolicyVerdict::Take => {
569 nros_log::log_warn!(
570 nros_log::get_logger("nros_node"),
571 "declared QoS: taking reliability {} on {} `{}`; the call site asked {}. \
572 Change the contract row to keep the call site's.",
573 reliability_spelling(declared),
574 role.noun(),
575 topic,
576 reliability_spelling(qos.reliability)
577 );
578 }
579 // The call site stated no opinion: nothing diverged, so nothing is
580 // reported.
581 PolicyVerdict::TakeSilently => {}
582 }
583 granted.reliability = declared;
584 }
585 if let Some(declared) = row.durability
586 && declared != qos.durability
587 {
588 match policy_verdict(
589 role,
590 durability_rank(qos.durability),
591 durability_rank(declared),
592 ) {
593 PolicyVerdict::Refuse => {
594 nros_log::log_error!(
595 nros_log::get_logger("nros_node"),
596 "declared QoS: {} `{}` registers {} and the contract declares durability \
597 {}; taking it would {}. Fix the contract row or the call site.",
598 role.noun(),
599 topic,
600 durability_spelling(qos.durability),
601 durability_spelling(declared),
602 role.refusal_reason()
603 );
604 return Err(NodeError::DeclaredQosMismatch);
605 }
606 PolicyVerdict::Take => {
607 nros_log::log_warn!(
608 nros_log::get_logger("nros_node"),
609 "declared QoS: taking durability {} on {} `{}`; the call site asked {}. \
610 Change the contract row to keep the call site's.",
611 durability_spelling(declared),
612 role.noun(),
613 topic,
614 durability_spelling(qos.durability)
615 );
616 }
617 PolicyVerdict::TakeSilently => {}
618 }
619 granted.durability = declared;
620 }
621 Ok(granted)
622}
623
624/// issue 1608 -- the PUBLISHER counterpart of [`honour`]: the QoS a publisher
625/// registers with once the contract has had its say.
626///
627/// The rule is `policy_verdict`'s, with the POLICY direction reversed from a
628/// subscription's because RxO is directional: a publisher that asked for less
629/// than the contract declares is RAISED to the declaration (the build reserved
630/// it, and every reader that matched the lower offer still matches), and one
631/// that asked for more is REFUSED (lowering it could break a match, keeping it
632/// rides storage nobody reserved). The depth rule is the subscription's.
633///
634/// Called at every publisher registration -- the Rust seams AND the C/C++ FFI
635/// seams. Unlike a subscription, a publisher has no call-site seam in C or C++
636/// that already took the declaration (`NROS_SUBSCRIBE`'s three-argument form
637/// has no publisher counterpart, and the compile-time table carries no
638/// publisher rows), so the FFI seams honour too rather than strict-checking: a
639/// strict check there would refuse every `create_publisher_in<M>(topic)` whose
640/// default profile the contract refines.
641///
642/// `#[inline]` for [`honour`]'s measured reason: a `None` table must fold the
643/// call to `Ok(qos)` so an image with no contract is byte-identical.
644#[inline]
645pub fn honour_publisher(
646 type_name: &str,
647 topic: &str,
648 qos: QoSProfile,
649) -> Result<QoSProfile, NodeError> {
650 let Some(rows) = DECLARED_PUBLISHER_QOS_ROWS else {
651 return Ok(qos);
652 };
653 honour_role_in(rows, type_name, topic, qos, Role::Publisher)
654}
655
656/// issue 1608 -- the declared row for a PUBLISHER on `(type_name, topic)`, or
657/// `None`. A table of its own because a publisher and a subscription on one
658/// `(type, topic)` are two endpoints with two declarations.
659pub fn declared_publisher(type_name: &str, topic: &str) -> Option<DeclaredEndpoint> {
660 row_in(DECLARED_PUBLISHER_QOS_ROWS?, type_name, topic).copied()
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 /// A row with only a depth stated -- the shape every road has carried
668 /// since phase-454 W10.
669 const fn depth_row(type_name: &'static str, topic: &'static str, d: u32) -> DeclaredEndpoint {
670 DeclaredEndpoint {
671 type_name,
672 topic,
673 depth: Some(d),
674 reliability: None,
675 durability: None,
676 }
677 }
678
679 /// The shape `build.rs` writes: the ROS spelling and the DDS-mangled one,
680 /// for each declared endpoint.
681 const ROWS: &[DeclaredEndpoint] = &[
682 depth_row("std_msgs/msg/Int32", "/chatter", 1),
683 depth_row("std_msgs::msg::dds_::Int32_", "/chatter", 1),
684 ];
685
686 /// issue 1256 -- a subscription that declares BOTH policies and no depth,
687 /// which is a row the depth-only table could not carry at all.
688 const POLICY_ROWS: &[DeclaredEndpoint] = &[DeclaredEndpoint {
689 type_name: "std_msgs/msg/Int32",
690 topic: "/sensor",
691 depth: None,
692 reliability: Some(QoSReliabilityPolicy::BestEffort),
693 durability: Some(QoSDurabilityPolicy::Volatile),
694 }];
695
696 /// ...and one that declares the STRONG value of each.
697 const STRONG_ROWS: &[DeclaredEndpoint] = &[DeclaredEndpoint {
698 type_name: "std_msgs/msg/Int32",
699 topic: "/latched",
700 depth: None,
701 reliability: Some(QoSReliabilityPolicy::Reliable),
702 durability: Some(QoSDurabilityPolicy::TransientLocal),
703 }];
704
705 #[test]
706 fn either_type_spelling_finds_the_row() {
707 assert_eq!(depth_in(ROWS, "std_msgs/msg/Int32", "/chatter"), Some(1));
708 assert_eq!(
709 depth_in(ROWS, "std_msgs::msg::dds_::Int32_", "/chatter"),
710 Some(1),
711 "a generated Rust message class reports the DDS-mangled TYPE_NAME; a table that \
712 carried only the ROS spelling would match nothing and check nothing"
713 );
714 }
715
716 #[test]
717 fn a_relative_topic_matches_the_declared_absolute_one() {
718 assert_eq!(depth_in(ROWS, "std_msgs/msg/Int32", "chatter"), Some(1));
719 }
720
721 #[test]
722 fn absence_is_not_a_depth() {
723 assert_eq!(
724 depth_in(ROWS, "std_msgs/msg/Int32", "/undeclared"),
725 None,
726 "an endpoint nobody declared has no depth -- not 0, and not the ROS default of 10"
727 );
728 assert_eq!(
729 depth_in(ROWS, "std_msgs/msg/Bool", "/chatter"),
730 None,
731 "the table is keyed on the PAIR: a declared topic under another type is not a hit, \
732 or one declaration would size every type carried on that topic"
733 );
734 assert_eq!(depth_in(&[], "std_msgs/msg/Int32", "/chatter"), None);
735 assert_eq!(
736 depth_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor"),
737 None,
738 "a row that declares only policies has declared no depth"
739 );
740 }
741
742 #[test]
743 fn a_namespace_is_a_different_endpoint() {
744 assert_eq!(
745 depth_in(ROWS, "std_msgs/msg/Int32", "/ns/chatter"),
746 None,
747 "only ONE leading slash is ignored; matching a namespaced topic against a bare \
748 declaration would check a row nobody meant"
749 );
750 }
751
752 /// phase-454 W13 — the TAKE. An unstated call site asks the ROS default 10; a
753 /// contract that declares 1 is the size the build reserved, so the
754 /// registration comes back at 1 and every other policy is untouched.
755 ///
756 /// Untouched matters as much as the depth: `honour` rebuilds the profile a
757 /// backend is handed, so a reliability or durability quietly reset here
758 /// would change the wire behaviour of every declared endpoint.
759 #[test]
760 fn an_endpoint_asking_deeper_than_its_declaration_registers_at_the_declared_depth() {
761 let asked = QoSProfile::QOS_PROFILE_DEFAULT;
762 assert_eq!(
763 asked.depth, 10,
764 "the ROS default an unstated call site gets"
765 );
766 let granted = honour_in(ROWS, "std_msgs/msg/Int32", "/chatter", asked)
767 .expect("taking the declared depth is not an error");
768 assert_eq!(
769 granted.depth, 1,
770 "the contract declares KEEP_LAST(1) and the build reserved for it; a registration \
771 that kept its 10 would claim a receive region the arena never bought"
772 );
773 assert_eq!(granted.reliability, asked.reliability);
774 assert_eq!(granted.durability, asked.durability);
775 assert_eq!(granted.history, asked.history);
776 assert_eq!(granted.deadline_ms, asked.deadline_ms);
777 assert_eq!(granted.lifespan_ms, asked.lifespan_ms);
778 }
779
780 /// The DDS-mangled spelling is the one a generated Rust message class
781 /// carries, so it is the one a typed call site actually hands `honour` —
782 /// a take that only matched the ROS spelling would fire for nothing.
783 #[test]
784 fn the_take_matches_the_spelling_a_typed_call_site_carries() {
785 let granted = honour_in(
786 ROWS,
787 "std_msgs::msg::dds_::Int32_",
788 "/chatter",
789 QoSProfile::QOS_PROFILE_DEFAULT,
790 )
791 .expect("admissible");
792 assert_eq!(granted.depth, 1);
793 }
794
795 /// phase-454 W13 — the REFUSAL, and the reason the rule is asymmetric.
796 ///
797 /// A registration asking for LESS than the declaration fits inside what the
798 /// build reserved, so nothing overruns — but taking would WIDEN a queue the
799 /// code narrowed on purpose (`create_subscription_viewable` requires
800 /// KEEP_LAST(1)), and the image has paid for slots nothing wants. Two
801 /// statements about one fact disagreeing, with no default that explains
802 /// either: RFC-0100 D8.
803 #[test]
804 fn a_declaration_deeper_than_the_registration_is_refused_not_taken() {
805 const DEEP: &[DeclaredEndpoint] = &[depth_row("std_msgs/msg/Int32", "/chatter", 20)];
806 let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
807 asked.depth = 10;
808 assert_eq!(
809 honour_in(DEEP, "std_msgs/msg/Int32", "/chatter", asked),
810 Err(NodeError::DeclaredDepthMismatch),
811 "THE control for the other direction: declared 20, registered 10. If this returns \
812 Ok the rule has collapsed into an unconditional take, and a contract can silently \
813 deepen any queue in the image."
814 );
815 }
816
817 /// Equal is not a divergence, and an undeclared endpoint is not one either
818 /// — the two arms that must stay silent, or every image in the tree gains
819 /// a report it cannot act on.
820 #[test]
821 fn agreement_and_absence_both_pass_the_profile_through_unchanged() {
822 let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
823 asked.depth = 1;
824 assert_eq!(
825 honour_in(ROWS, "std_msgs/msg/Int32", "/chatter", asked),
826 Ok(asked)
827 );
828 let deep = QoSProfile::QOS_PROFILE_DEFAULT;
829 assert_eq!(
830 honour_in(ROWS, "std_msgs/msg/Int32", "/undeclared", deep),
831 Ok(deep),
832 "absence is not zero and it is not ten either -- an endpoint nobody declared keeps \
833 exactly the profile the call site built"
834 );
835 assert_eq!(
836 honour_in(&[], "std_msgs/msg/Int32", "/chatter", deep),
837 Ok(deep)
838 );
839 }
840
841 /// issue 1256 — the policy TAKE. The default profile asks RELIABLE and
842 /// VOLATILE; a contract declaring `best_effort` is what the build reserved
843 /// (no reliable stream history on XRCE), so an unstated call site comes back
844 /// best-effort. Durability agrees, so it is untouched, and so is the depth
845 /// the row does not state.
846 #[test]
847 fn a_declared_best_effort_is_taken_by_a_call_site_that_asked_reliable() {
848 let asked = QoSProfile::QOS_PROFILE_DEFAULT;
849 assert_eq!(asked.reliability, QoSReliabilityPolicy::Reliable);
850 let granted = honour_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor", asked)
851 .expect("a weaker declared reliability is taken, not refused");
852 assert_eq!(granted.reliability, QoSReliabilityPolicy::BestEffort);
853 assert_eq!(granted.durability, QoSDurabilityPolicy::Volatile);
854 assert_eq!(
855 granted.depth, asked.depth,
856 "the row states no depth, so the depth the call site asked stands"
857 );
858 }
859
860 /// issue 1256 — the policy REFUSAL, the control for the take above. A call
861 /// site that asked `best_effort` against a contract that declares
862 /// `reliable` narrowed on purpose; taking would widen it.
863 #[test]
864 fn a_declared_reliable_is_refused_to_a_call_site_that_asked_best_effort() {
865 let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
866 asked.reliability = QoSReliabilityPolicy::BestEffort;
867 assert_eq!(
868 honour_in(STRONG_ROWS, "std_msgs/msg/Int32", "/latched", asked),
869 Err(NodeError::DeclaredQosMismatch),
870 "declared reliable, asked best_effort. If this returns Ok the policy rule has \
871 collapsed into an unconditional take."
872 );
873 // And durability on its own: reliability agrees, durability is weaker.
874 let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
875 asked.durability = QoSDurabilityPolicy::Volatile;
876 assert_eq!(
877 honour_in(STRONG_ROWS, "std_msgs/msg/Int32", "/latched", asked),
878 Err(NodeError::DeclaredQosMismatch),
879 "declared transient_local, asked volatile"
880 );
881 }
882
883 /// issue 1256 — a call site that stated NO opinion takes the declaration.
884 #[test]
885 fn a_system_default_policy_takes_the_declaration() {
886 let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
887 asked.reliability = QoSReliabilityPolicy::SystemDefault;
888 asked.durability = QoSDurabilityPolicy::SystemDefault;
889 let granted = honour_in(STRONG_ROWS, "std_msgs/msg/Int32", "/latched", asked)
890 .expect("no opinion is not a disagreement");
891 assert_eq!(granted.reliability, QoSReliabilityPolicy::Reliable);
892 assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
893 }
894
895 /// issue 1256 — the FFI seams' strict check covers the policies too. C and
896 /// C++ already took the declaration at their call site, so ANY disagreement
897 /// reaching here is real, in either direction.
898 #[test]
899 fn the_strict_check_refuses_a_policy_disagreement_in_either_direction() {
900 let agrees = {
901 let mut q = QoSProfile::QOS_PROFILE_DEFAULT;
902 q.reliability = QoSReliabilityPolicy::BestEffort;
903 q
904 };
905 assert_eq!(
906 check_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor", &agrees),
907 Ok(())
908 );
909 assert_eq!(
910 check_in(
911 POLICY_ROWS,
912 "std_msgs/msg/Int32",
913 "/sensor",
914 &QoSProfile::QOS_PROFILE_DEFAULT
915 ),
916 Err(NodeError::DeclaredQosMismatch),
917 "declared best_effort, registered reliable"
918 );
919 let mut tl = agrees;
920 tl.durability = QoSDurabilityPolicy::TransientLocal;
921 assert_eq!(
922 check_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor", &tl),
923 Err(NodeError::DeclaredQosMismatch),
924 "declared volatile, registered transient_local"
925 );
926 assert_eq!(
927 check_in(
928 ROWS,
929 "std_msgs/msg/Int32",
930 "/chatter",
931 &QoSProfile::QOS_PROFILE_DEFAULT
932 ),
933 Err(NodeError::DeclaredDepthMismatch),
934 "and the depth is still refused as a DEPTH"
935 );
936 }
937
938 /// issue 1608 -- a PUBLISHER row declaring the STRONG value of each
939 /// policy: a latched topic, which is what a zenoh image spends a queryable
940 /// slot on.
941 const PUB_LATCHED: &[DeclaredEndpoint] = &[DeclaredEndpoint {
942 type_name: "std_msgs/msg/Int32",
943 topic: "/latched",
944 depth: Some(1),
945 reliability: Some(QoSReliabilityPolicy::Reliable),
946 durability: Some(QoSDurabilityPolicy::TransientLocal),
947 }];
948
949 /// ...and one declaring the WEAK value of each: a sensor stream.
950 const PUB_SENSOR: &[DeclaredEndpoint] = &[DeclaredEndpoint {
951 type_name: "std_msgs/msg/Int32",
952 topic: "/sensor",
953 depth: None,
954 reliability: Some(QoSReliabilityPolicy::BestEffort),
955 durability: Some(QoSDurabilityPolicy::Volatile),
956 }];
957
958 fn honour_pub(
959 rows: &[DeclaredEndpoint],
960 topic: &str,
961 qos: QoSProfile,
962 ) -> Result<QoSProfile, NodeError> {
963 honour_role_in(rows, "std_msgs/msg/Int32", topic, qos, Role::Publisher)
964 }
965
966 /// issue 1608 -- the publisher TAKE. A default-profile publisher (VOLATILE,
967 /// KEEP_LAST(10)) on a topic the contract declares `transient_local` with
968 /// depth 1 registers as transient_local, depth 1: the offer is RAISED to
969 /// what the build reserved (the queryable slot and one retained sample),
970 /// and every reader that matched a volatile writer still matches.
971 #[test]
972 fn a_publisher_is_raised_to_a_stronger_declared_policy() {
973 let asked = QoSProfile::QOS_PROFILE_DEFAULT;
974 assert_eq!(asked.durability, QoSDurabilityPolicy::Volatile);
975 let granted = honour_pub(PUB_LATCHED, "/latched", asked)
976 .expect("a publisher asking LESS than its declaration is raised, not refused");
977 assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
978 assert_eq!(granted.reliability, QoSReliabilityPolicy::Reliable);
979 assert_eq!(granted.depth, 1, "the depth rule is the subscription's");
980 let mut be = asked;
981 be.reliability = QoSReliabilityPolicy::BestEffort;
982 assert_eq!(
983 honour_pub(PUB_LATCHED, "/latched", be).map(|q| q.reliability),
984 Ok(QoSReliabilityPolicy::Reliable),
985 "a best_effort writer is raised to the declared reliable"
986 );
987 }
988
989 /// issue 1608 -- the publisher REFUSAL, and THE control for the direction.
990 ///
991 /// A publisher asking for MORE than the contract declares is refused: taking
992 /// the declaration would WEAKEN its offer (a reader requesting what the code
993 /// offered stops matching) and keeping the ask rides storage the build did
994 /// not reserve. This is the OPPOSITE direction from a subscription -- the
995 /// last assertion runs the same row and profile through the subscription
996 /// rule and gets a take, so a rule copied across without reversing fails
997 /// here rather than in an image.
998 #[test]
999 fn a_publisher_asking_more_than_its_declaration_is_refused() {
1000 let asked = QoSProfile::QOS_PROFILE_DEFAULT;
1001 assert_eq!(asked.reliability, QoSReliabilityPolicy::Reliable);
1002 assert_eq!(
1003 honour_pub(PUB_SENSOR, "/sensor", asked),
1004 Err(NodeError::DeclaredQosMismatch),
1005 "declared best_effort, asked reliable: lowering the offer could break a match"
1006 );
1007 let mut tl = asked;
1008 tl.reliability = QoSReliabilityPolicy::BestEffort;
1009 tl.durability = QoSDurabilityPolicy::TransientLocal;
1010 assert_eq!(
1011 honour_pub(PUB_SENSOR, "/sensor", tl),
1012 Err(NodeError::DeclaredQosMismatch),
1013 "declared volatile, asked transient_local: the build counted no queryable slot"
1014 );
1015 let granted = honour_in(PUB_SENSOR, "std_msgs/msg/Int32", "/sensor", asked)
1016 .expect("the SUBSCRIPTION rule takes the same disagreement");
1017 assert_eq!(granted.reliability, QoSReliabilityPolicy::BestEffort);
1018 }
1019
1020 /// issue 1608 -- agreement, no opinion, and absence stay silent and
1021 /// unchanged for a publisher too.
1022 #[test]
1023 fn a_publisher_that_agrees_or_has_no_opinion_or_no_row_passes() {
1024 let mut agrees = QoSProfile::QOS_PROFILE_DEFAULT;
1025 agrees.reliability = QoSReliabilityPolicy::BestEffort;
1026 assert_eq!(honour_pub(PUB_SENSOR, "/sensor", agrees), Ok(agrees));
1027 let mut none = QoSProfile::QOS_PROFILE_DEFAULT;
1028 none.reliability = QoSReliabilityPolicy::SystemDefault;
1029 none.durability = QoSDurabilityPolicy::SystemDefault;
1030 let granted = honour_pub(PUB_LATCHED, "/latched", none).expect("no opinion");
1031 assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
1032 let deep = QoSProfile::QOS_PROFILE_DEFAULT;
1033 assert_eq!(honour_pub(PUB_LATCHED, "/undeclared", deep), Ok(deep));
1034 assert_eq!(
1035 honour_pub(PUB_LATCHED, "/latched", {
1036 let mut q = deep;
1037 q.durability = QoSDurabilityPolicy::TransientLocal;
1038 q.depth = 0;
1039 q
1040 }),
1041 Err(NodeError::DeclaredDepthMismatch),
1042 "a declaration DEEPER than the ask is refused for a publisher as for a subscription"
1043 );
1044 }
1045
1046 /// The public entry point over THIS build's table. In-tree that table is
1047 /// `None` (no contract reaches a unit-test build), so this asserts the
1048 /// no-declaration behaviour rather than a lookup -- and says so, because a
1049 /// reader would otherwise take it for coverage of the lookup above.
1050 #[cfg(not(nros_declared_qos_table))]
1051 #[test]
1052 fn an_image_that_declares_nothing_checks_nothing() {
1053 assert!(
1054 DECLARED_QOS_ROWS.is_none(),
1055 "`nros_declared_qos_table` is unset, so the build wrote no table. If this fires, \
1056 the cfg and the const have come apart and the declared-image test below is \
1057 being skipped on a build that HAS a table."
1058 );
1059 assert_eq!(declared_depth("std_msgs/msg/Int32", "/chatter"), None);
1060 assert_eq!(
1061 check(
1062 "std_msgs/msg/Int32",
1063 "/chatter",
1064 &QoSProfile::QOS_PROFILE_DEFAULT
1065 ),
1066 Ok(())
1067 );
1068 // phase-454 W13 — and the take is inert too, which is acceptance 4: an
1069 // image with no contract registers exactly the profile it built.
1070 let asked = QoSProfile::QOS_PROFILE_DEFAULT;
1071 assert_eq!(honour("std_msgs/msg/Int32", "/chatter", asked), Ok(asked));
1072 // issue 1608 -- and so is the publisher take.
1073 assert!(DECLARED_PUBLISHER_QOS_ROWS.is_none());
1074 assert_eq!(
1075 honour_publisher("std_msgs/msg/Int32", "/chatter", asked),
1076 Ok(asked)
1077 );
1078 }
1079
1080 /// THE NEGATIVE CONTROL, over the build's OWN table.
1081 ///
1082 /// Everything above tests `depth_in` against a table this module wrote,
1083 /// which proves the search and nothing about DELIVERY: a carrier cmake
1084 /// never forwards, a `build.rs` parse that drops every row, and a table
1085 /// keyed on a spelling no call site writes all look exactly like "no
1086 /// mismatch here". Six mechanisms in this campaign have been correct and
1087 /// unreachable for precisely that reason.
1088 ///
1089 /// So this module is compiled only where `build.rs` wrote rows, and
1090 /// `just check declared-qos-registration` builds this crate with
1091 /// `NROS_ENTITY_DECLARED_DEPTHS` set to make that happen. It also asserts
1092 /// that the test does NOT exist without the variable -- otherwise a cfg
1093 /// that was always on would make the lane pass while proving nothing.
1094 #[cfg(nros_declared_qos_table)]
1095 mod declared_image {
1096 use super::*;
1097
1098 #[test]
1099 fn refuses_a_disagreeing_depth_and_accepts_an_agreeing_one() {
1100 let rows = DECLARED_QOS_ROWS.expect("the cfg says this build wrote a table");
1101 assert!(
1102 !rows.is_empty(),
1103 "an empty table with the cfg set is a parse that dropped every row -- which \
1104 would disable the check while reading as if it were on"
1105 );
1106 assert_eq!(
1107 declared_depth("std_msgs/msg/Int32", "/chatter"),
1108 Some(1),
1109 "the lane forwards `std_msgs/msg/Int32|/chatter=1`; if this is None the \
1110 carrier, the parse or the key spelling is wrong, and every registration in \
1111 a real image would go unchecked"
1112 );
1113 assert_eq!(
1114 declared_depth("std_msgs::msg::dds_::Int32_", "/chatter"),
1115 Some(1),
1116 "build.rs must emit the DDS-mangled spelling too -- it is what a generated \
1117 Rust message class reports as TYPE_NAME, and the only spelling a typed call \
1118 site ever hands `check`"
1119 );
1120 let mut one = QoSProfile::QOS_PROFILE_DEFAULT;
1121 one.depth = 1;
1122 assert_eq!(check("std_msgs/msg/Int32", "/chatter", &one), Ok(()));
1123 assert_eq!(
1124 check(
1125 "std_msgs::msg::dds_::Int32_",
1126 "/chatter",
1127 &QoSProfile::QOS_PROFILE_DEFAULT
1128 ),
1129 Err(NodeError::DeclaredDepthMismatch),
1130 "THE control: declared 1, registered 10. If this passes, the registration \
1131 check does nothing on a real image and every lane above it stays green."
1132 );
1133 assert_eq!(
1134 check(
1135 "std_msgs/msg/Int32",
1136 "/undeclared",
1137 &QoSProfile::QOS_PROFILE_DEFAULT
1138 ),
1139 Ok(()),
1140 "and an endpoint nobody declared is still not an error"
1141 );
1142 }
1143
1144 /// phase-454 W13 — the TAKE, over the build's own table.
1145 ///
1146 /// The sibling above proves the ROWS arrived. This proves the thing a
1147 /// Rust image actually depends on: that an unstated call site's
1148 /// `QoSProfile::default()` comes back at the DECLARED depth. A table
1149 /// that is delivered and never consulted by the registration would pass
1150 /// every assertion above it, which is the shape this campaign keeps
1151 /// finding.
1152 ///
1153 /// `just check declared-qos-registration` runs this against BOTH
1154 /// carriers — the cmake road's `NROS_ENTITY_DECLARED_DEPTHS` and the
1155 /// cargo leaf road's sizing descriptor — because a carrier that reaches
1156 /// one road and not the other is exactly how W10 shipped with its
1157 /// registration check unable to compare anything on the road W12 taught
1158 /// to read a contract.
1159 #[test]
1160 fn an_unstated_call_site_registers_at_the_declared_depth() {
1161 let asked = QoSProfile::QOS_PROFILE_DEFAULT;
1162 assert_eq!(
1163 asked.depth, 10,
1164 "the profile `Node::create_subscription` hands a registration when the call \
1165 site states none"
1166 );
1167 let granted = honour("std_msgs::msg::dds_::Int32_", "/chatter", asked)
1168 .expect("the declared depth is shallower than the ask, so it is TAKEN");
1169 assert_eq!(
1170 granted.depth, 1,
1171 "this build's table declares KEEP_LAST(1) for `/chatter`; if this is 10 the \
1172 registration ignored the declaration and the image runs a depth the build did \
1173 not reserve for"
1174 );
1175 }
1176
1177 /// issue 1256 — the two POLICIES, over the build's own table.
1178 ///
1179 /// Compiled only where the build's table states a policy, which is the
1180 /// DESCRIPTOR carrier (`tests/fixtures/declared-qos-descriptor.toml`
1181 /// declares `/status` best-effort and volatile): the env carrier
1182 /// `NROS_ENTITY_DECLARED_DEPTHS` is depth-only by design, and no knob is
1183 /// added for the policies. `just check declared-qos-registration`
1184 /// therefore expects THREE tests on the descriptor step and two on the
1185 /// env step -- so a descriptor whose policies stopped reaching the table
1186 /// fails that lane instead of passing two of three.
1187 #[cfg(nros_declared_qos_policy_table)]
1188 #[test]
1189 fn a_declared_policy_reaches_the_registration_from_the_descriptor() {
1190 assert_eq!(
1191 declared_reliability("std_msgs::msg::dds_::Int32_", "/status"),
1192 Some(QoSReliabilityPolicy::BestEffort),
1193 "the descriptor states `reliability = \"best_effort\"` for `/status`; if this \
1194 is None the policies are not reaching DECLARED_QOS_ROWS and no Rust image \
1195 compares them with its code"
1196 );
1197 assert_eq!(
1198 declared_durability("std_msgs/msg/Int32", "/status"),
1199 Some(QoSDurabilityPolicy::Volatile)
1200 );
1201 let granted = honour(
1202 "std_msgs::msg::dds_::Int32_",
1203 "/status",
1204 QoSProfile::QOS_PROFILE_DEFAULT,
1205 )
1206 .expect("a declared best_effort is TAKEN by a call site that asked reliable");
1207 assert_eq!(granted.reliability, QoSReliabilityPolicy::BestEffort);
1208 assert_eq!(
1209 check(
1210 "std_msgs/msg/Int32",
1211 "/status",
1212 &QoSProfile::QOS_PROFILE_DEFAULT
1213 ),
1214 Err(NodeError::DeclaredQosMismatch),
1215 "and the FFI seams' strict check refuses the same disagreement"
1216 );
1217 }
1218
1219 /// issue 1608 -- a declared PUBLISHER, over the build's own table.
1220 ///
1221 /// Compiled only where the descriptor delivered a publisher row
1222 /// (`tests/fixtures/declared-qos-descriptor.toml` declares `/latched`
1223 /// transient_local, depth 1). `just check declared-qos-registration`
1224 /// expects FOUR tests on the descriptor step -- so a descriptor whose
1225 /// publisher rows stopped reaching `DECLARED_PUBLISHER_QOS_ROWS` fails
1226 /// the lane instead of passing three of four.
1227 #[cfg(nros_declared_publisher_qos_table)]
1228 #[test]
1229 fn a_declared_publisher_reaches_the_registration_from_the_descriptor() {
1230 let row = declared_publisher("std_msgs::msg::dds_::Int32_", "/latched").expect(
1231 "the descriptor declares a publisher on `/latched`; if this is None its \
1232 publisher rows are not reaching DECLARED_PUBLISHER_QOS_ROWS",
1233 );
1234 assert_eq!(row.durability, Some(QoSDurabilityPolicy::TransientLocal));
1235 assert!(
1236 declared_publisher("std_msgs/msg/Int32", "/chatter").is_none(),
1237 "`/chatter` is declared for a SUBSCRIPTION only; a publisher table that \
1238 carried it would hold every publisher to a reader's declaration"
1239 );
1240 let granted = honour_publisher(
1241 "std_msgs::msg::dds_::Int32_",
1242 "/latched",
1243 QoSProfile::QOS_PROFILE_DEFAULT,
1244 )
1245 .expect("a default publisher is raised to the declared transient_local");
1246 assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
1247 assert_eq!(granted.depth, 1);
1248 let mut be = QoSProfile::QOS_PROFILE_DEFAULT;
1249 be.durability = QoSDurabilityPolicy::TransientLocal;
1250 assert_eq!(
1251 honour_publisher("std_msgs/msg/Int32", "/plain", be),
1252 Err(NodeError::DeclaredQosMismatch),
1253 "the descriptor declares `/plain` volatile; a transient_local publisher there \
1254 asks for a queryable slot the build never counted"
1255 );
1256 }
1257 }
1258}