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