nros_rmw/traits.rs
1//! Transport abstraction traits.
2//!
3//! Defines the backend-agnostic interface that transport implementations
4//! (zenoh-pico, XRCE-DDS) must satisfy. The core trait hierarchy is:
5//!
6//! - [`Session`] — connection lifecycle and handle creation
7//! - [`Publisher`] / [`Subscription`] — pub/sub data transport
8//! - [`ServiceTrait`] / [`ClientTrait`] — request/reply
9//! - [`Rmw`] — top-level factory that creates sessions
10
11use crate::service_inbox::ServiceInboxSpec;
12use nros_core::{Deserialize, RosMessage, RosService, Serialize};
13
14/// Topic information for pub/sub
15#[derive(Debug, Clone)]
16pub struct TopicInfo<'a> {
17 /// Topic name (e.g., "/chatter")
18 pub name: &'a str,
19 /// ROS type name (e.g., "std_msgs::msg::dds_::String_")
20 pub type_name: &'a str,
21 /// Type hash for compatibility checking
22 pub type_hash: &'a str,
23 /// Domain ID (default: 0)
24 pub domain_id: u32,
25 /// Node name for liveliness token generation.
26 /// `None` means no node association — no liveliness token will be declared.
27 pub node_name: Option<&'a str>,
28 /// Node namespace for liveliness token generation (default: "/").
29 /// In ROS 2, "/" is the root namespace and the standard default.
30 pub namespace: &'a str,
31 /// Phase 231 (RFC-0038) — receive-buffer size hint, bytes. The executor sets
32 /// this from the subscription's `RX_BUF`; a backend may use it to route the
33 /// subscription to a size-class receive buffer (zenoh-pico: small vs large).
34 /// `0` = unset (backend picks its default). Ignored by backends that don't
35 /// size-class their receive storage.
36 pub rx_buffer_hint: usize,
37 /// phase-279 (#145) — publisher-side "express" hint: this topic's samples
38 /// bypass transport tx batching (zenoh: the wire EXPRESS flag; a batching
39 /// zenoh-pico session sends them immediately instead of queueing them for
40 /// the next flush). For control-tier / latency-sensitive topics. Ignored by
41 /// subscriptions and by backends without a batching concept.
42 pub tx_express: bool,
43}
44
45impl<'a> TopicInfo<'a> {
46 /// Create new topic info
47 pub const fn new(name: &'a str, type_name: &'a str, type_hash: &'a str) -> Self {
48 Self {
49 name,
50 type_name,
51 type_hash,
52 domain_id: 0,
53 node_name: None,
54 namespace: "/",
55 rx_buffer_hint: 0,
56 tx_express: false,
57 }
58 }
59
60 /// Create topic info with custom domain ID
61 pub const fn with_domain(mut self, domain_id: u32) -> Self {
62 self.domain_id = domain_id;
63 self
64 }
65
66 /// Phase 231 (RFC-0038) — set the receive-buffer size hint (bytes) used by
67 /// size-classing backends (zenoh-pico) to route the subscription's receive
68 /// buffer to the small or large class.
69 pub const fn with_rx_buffer_hint(mut self, hint: usize) -> Self {
70 self.rx_buffer_hint = hint;
71 self
72 }
73
74 /// phase-279 (#145) — mark this publisher's samples express: they bypass
75 /// transport tx batching (sent immediately even when `ZPICO_TX_BATCH` is
76 /// on). For control-tier / latency-sensitive topics.
77 pub const fn with_tx_express(mut self, express: bool) -> Self {
78 self.tx_express = express;
79 self
80 }
81
82 /// Set the node name for liveliness token generation
83 pub const fn with_node_name(mut self, node_name: &'a str) -> Self {
84 self.node_name = Some(node_name);
85 self
86 }
87
88 /// Set the node namespace for liveliness token generation
89 pub const fn with_namespace(mut self, namespace: &'a str) -> Self {
90 self.namespace = namespace;
91 self
92 }
93}
94
95/// Service information for service client/server
96#[derive(Debug, Clone)]
97pub struct ServiceInfo<'a> {
98 /// Service name (e.g., "/add_two_ints")
99 pub name: &'a str,
100 /// ROS service type name (e.g., "example_interfaces::srv::dds_::AddTwoInts_")
101 pub type_name: &'a str,
102 /// Type hash for compatibility checking
103 pub type_hash: &'a str,
104 /// Domain ID (default: 0)
105 pub domain_id: u32,
106 /// Node name for liveliness token generation.
107 /// `None` means no node association — no liveliness token will be declared.
108 pub node_name: Option<&'a str>,
109 /// Node namespace for liveliness token generation (default: "/").
110 /// In ROS 2, "/" is the root namespace and the standard default.
111 pub namespace: &'a str,
112}
113
114/// Issue 1913 — the REP-2011 hash each of an action's five endpoints is keyed
115/// on. An Iron+ `rmw_zenoh_cpp` peer keys every endpoint on its OWN type: the
116/// `<Action>_SendGoal` / `<Action>_GetResult` services, the standard
117/// `action_msgs/srv/CancelGoal`, `<Action>_FeedbackMessage`, and the standard
118/// `action_msgs/msg/GoalStatusArray`. One hash for all five matched none of
119/// them, which is what every registration path carried until 1913.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct ActionEndpointHashes<'a> {
122 pub send_goal: &'a str,
123 pub cancel_goal: &'a str,
124 pub get_result: &'a str,
125 pub feedback: &'a str,
126 pub status: &'a str,
127}
128
129impl<'a> ActionEndpointHashes<'a> {
130 /// Every endpoint on one value — Humble's `TypeHashNotSupported`, or a
131 /// caller that has nothing better (it will not match an Iron+ peer).
132 pub const fn uniform(hash: &'a str) -> Self {
133 Self {
134 send_goal: hash,
135 cancel_goal: hash,
136 get_result: hash,
137 feedback: hash,
138 status: hash,
139 }
140 }
141}
142
143impl ActionEndpointHashes<'static> {
144 /// The five hashes a typed action carries.
145 pub const fn of<A: nros_core::RosAction>() -> Self {
146 Self {
147 send_goal: A::SEND_GOAL_SERVICE_HASH,
148 cancel_goal: A::CANCEL_GOAL_SERVICE_HASH,
149 get_result: A::GET_RESULT_SERVICE_HASH,
150 feedback: <A::FeedbackMessage as nros_core::RosMessage>::TYPE_HASH,
151 status: A::STATUS_MESSAGE_HASH,
152 }
153 }
154}
155
156/// Action information for action client/server
157///
158/// Actions in ROS 2 use 5 communication channels:
159/// - `send_goal` service: `<action_name>/_action/send_goal`
160/// - `cancel_goal` service: `<action_name>/_action/cancel_goal`
161/// - `get_result` service: `<action_name>/_action/get_result`
162/// - `feedback` topic: `<action_name>/_action/feedback`
163/// - `status` topic: `<action_name>/_action/status`
164#[derive(Debug, Clone)]
165pub struct ActionInfo<'a> {
166 /// Action name (e.g., "/fibonacci")
167 pub name: &'a str,
168 /// ROS action type name (e.g., "example_interfaces::action::dds_::Fibonacci_")
169 pub type_name: &'a str,
170 /// Type hash for compatibility checking
171 pub type_hash: &'a str,
172 /// Domain ID (default: 0)
173 pub domain_id: u32,
174}
175
176impl<'a> ActionInfo<'a> {
177 /// Create new action info
178 pub const fn new(name: &'a str, type_name: &'a str, type_hash: &'a str) -> Self {
179 Self {
180 name,
181 type_name,
182 type_hash,
183 domain_id: 0,
184 }
185 }
186
187 /// Create action info with custom domain ID
188 pub const fn with_domain(mut self, domain_id: u32) -> Self {
189 self.domain_id = domain_id;
190 self
191 }
192
193 /// Generate the send_goal service name
194 /// Returns: `<action>/_action/send_goal`
195 pub fn send_goal_key<const N: usize>(&self) -> heapless::String<N> {
196 self.sub_name::<N>("send_goal")
197 }
198
199 /// Generate the cancel_goal service name
200 /// Returns: `<action>/_action/cancel_goal`
201 pub fn cancel_goal_key<const N: usize>(&self) -> heapless::String<N> {
202 self.sub_name::<N>("cancel_goal")
203 }
204
205 /// Generate the get_result service name
206 /// Returns: `<action>/_action/get_result`
207 pub fn get_result_key<const N: usize>(&self) -> heapless::String<N> {
208 self.sub_name::<N>("get_result")
209 }
210
211 /// Generate the feedback topic name
212 /// Returns: `<action>/_action/feedback`
213 pub fn feedback_key<const N: usize>(&self) -> heapless::String<N> {
214 self.sub_name::<N>("feedback")
215 }
216
217 /// Generate the status topic name
218 /// Returns: `<action>/_action/status`
219 pub fn status_key<const N: usize>(&self) -> heapless::String<N> {
220 self.sub_name::<N>("status")
221 }
222
223 /// Generate a sub-entity name for an action component
224 /// Returns: `<action>/_action/<suffix>` (e.g., `fibonacci/_action/send_goal`)
225 ///
226 /// The caller is responsible for constructing the full key expression
227 /// by wrapping this name in a `ServiceInfo` or `TopicInfo` with the
228 /// correct sub-service/sub-topic type name.
229 fn sub_name<const N: usize>(&self, suffix: &str) -> heapless::String<N> {
230 let mut name = heapless::String::new();
231 let action_stripped = self.name.trim_matches('/');
232 let _ = core::fmt::write(
233 &mut name,
234 format_args!("/{}/_action/{}", action_stripped, suffix),
235 );
236 name
237 }
238}
239
240impl<'a> ServiceInfo<'a> {
241 /// Create new service info
242 pub const fn new(name: &'a str, type_name: &'a str, type_hash: &'a str) -> Self {
243 Self {
244 name,
245 type_name,
246 type_hash,
247 domain_id: 0,
248 node_name: None,
249 namespace: "/",
250 }
251 }
252
253 /// Create service info with custom domain ID
254 pub const fn with_domain(mut self, domain_id: u32) -> Self {
255 self.domain_id = domain_id;
256 self
257 }
258
259 /// Set the node name for liveliness token generation
260 pub const fn with_node_name(mut self, node_name: &'a str) -> Self {
261 self.node_name = Some(node_name);
262 self
263 }
264
265 /// Set the node namespace for liveliness token generation
266 pub const fn with_namespace(mut self, namespace: &'a str) -> Self {
267 self.namespace = namespace;
268 self
269 }
270}
271
272/// Transport error types.
273///
274/// No longer `Copy` — the `Backend` / `BackendDynamic` variants carry a
275/// string diagnostic, which can't be `Copy`. Rust callers that used to
276/// copy a `TransportError` value repeatedly now need `.clone()` or
277/// `ref` in match arms. C/C++ callers are unaffected — both map
278/// `TransportError` to integer codes (`rcl_ret_t` / `ErrorCode`)
279/// before crossing the FFI boundary.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum TransportError {
282 /// Failed to connect to transport
283 ConnectionFailed,
284 /// Connection was closed
285 Disconnected,
286 /// Failed to create publisher
287 PublisherCreationFailed,
288 /// Failed to create subscriber
289 SubscriberCreationFailed,
290 /// Failed to create service server
291 ServiceServerCreationFailed,
292 /// Failed to create service client
293 ServiceClientCreationFailed,
294 /// Failed to publish message
295 PublishFailed,
296 /// Failed to send service request
297 ServiceRequestFailed,
298 /// Failed to send service reply
299 ServiceReplyFailed,
300 /// Serialization error
301 SerializationError,
302 /// Deserialization error
303 DeserializationError,
304 /// Buffer too small
305 BufferTooSmall,
306 /// Incoming message exceeded the static buffer capacity
307 MessageTooLarge,
308 /// Timeout waiting for message
309 Timeout,
310 /// Invalid configuration
311 InvalidConfig,
312 /// Resource (slot, buffer, queue) momentarily unavailable. Retry.
313 /// Phase 99: returned by `try_loan` when arena slots are full and
314 /// by `try_borrow` when no message is ready (alternative to
315 /// `Ok(None)` for backends that prefer the error variant).
316 WouldBlock,
317 /// Requested allocation exceeds backend capacity. Phase 99:
318 /// `try_loan(len)` returns this when `len` > arena slot size.
319 TooLarge,
320 /// Failed to start background tasks
321 TaskStartFailed,
322 /// Failed to poll for incoming messages
323 PollFailed,
324 /// Failed to send keepalive
325 KeepaliveFailed,
326 /// Failed to send join message
327 JoinFailed,
328 /// Caller supplied a NULL pointer, an out-of-range value, or an
329 /// inconsistent argument combination. Phase 102.1.
330 InvalidArgument,
331 /// The backend does not implement this operation. Optional
332 /// callbacks return this; the runtime then falls back to a
333 /// default path. Phase 102.1.
334 Unsupported,
335 /// Memory allocation failed. Returned by backends on `std` /
336 /// `alloc`-equipped platforms when heap allocation fails.
337 /// Bare-metal backends generally do not produce this — they
338 /// preallocate at session-open time. Phase 102.1.
339 BadAlloc,
340 /// Publisher and subscriber QoS profiles do not match in a way
341 /// the backend cannot reconcile. Phase 102.1.
342 ///
343 /// **It NAMES the policy** — phase-417 G7. The payload is the single
344 /// [`QoSPolicyMask`] bit that was refused, so
345 /// `err.qos_policy_name()` (or `mask.policy_name()`) turns the refusal
346 /// into `"RELIABILITY"` without an allocator. Until this carried a
347 /// payload, [`QoSProfile::validate_against`] computed the offending bit
348 /// and threw it away, while `rmw_vtable.h`'s `supported_qos_policies`
349 /// block told the reader those creates fail "naming a policy". They did
350 /// not. Issue 1329's NONE-over-union choice rests on that sentence, so the
351 /// choice was right and its evidence was not yet true.
352 ///
353 /// [`QoSPolicyMask::NONE`] is the honest answer where the refusal crossed
354 /// a boundary that cannot carry one — a C backend returns
355 /// `NROS_RMW_RET_INCOMPATIBLE_QOS`, an `rmw_ret_t` with no room for a
356 /// mask — and `policy_name()` then answers `None` rather than naming a
357 /// policy nobody identified.
358 IncompatibleQos(QoSPolicyMask),
359 /// Topic, service, or action name failed validation. Phase 102.1.
360 TopicNameInvalid,
361 /// A request referenced a node that does not exist in this
362 /// session. Phase 102.1.
363 NodeNameNonExistent,
364 /// The backend does not support loaned messages on this entity,
365 /// or the loan slot is currently in use. Phase 102.1.
366 LoanNotSupported,
367 /// No data was available on a non-blocking receive. Distinct
368 /// from `Timeout`: fires immediately, not after a bounded wait.
369 /// Phase 102.1.
370 NoData,
371 /// Phase 115.A.2 — caller passed a versioned vtable struct
372 /// (e.g. `NrosTransportOps`) with an `abi_version` the runtime
373 /// doesn't know. Maps to `NROS_RMW_RET_INCOMPATIBLE_ABI` at
374 /// the C boundary.
375 IncompatibleAbi,
376 /// Backend-specific error with a `'static` diagnostic string.
377 ///
378 /// Useful for zenoh-pico / XRCE-DDS return codes that map to a
379 /// fixed set of known messages. `no_std`-compatible.
380 Backend(&'static str),
381 /// Backend-specific error with an owned diagnostic string.
382 ///
383 /// Available only with the `alloc` feature. Use this when the
384 /// diagnostic is formatted at runtime (e.g. from a C error code
385 /// plus a socket address).
386 #[cfg(feature = "alloc")]
387 BackendDynamic(alloc::string::String),
388}
389
390/// One line per variant, in the defining crate, so every consumer renders the
391/// same words (issue 0783: `NodeError` used to fall back to `{:?}` because
392/// this impl did not exist, and writing the prose there would have put a
393/// backend's wording in the wrong crate). `no_std`: no allocator, only
394/// `write_str` and the payloads the variants already carry.
395impl core::fmt::Display for TransportError {
396 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
397 let s = match self {
398 Self::ConnectionFailed => "failed to connect to the transport",
399 Self::Disconnected => "the transport connection was closed",
400 Self::PublisherCreationFailed => "failed to create a publisher",
401 Self::SubscriberCreationFailed => "failed to create a subscriber",
402 Self::ServiceServerCreationFailed => "failed to create a service server",
403 Self::ServiceClientCreationFailed => "failed to create a service client",
404 Self::PublishFailed => "failed to publish a message",
405 Self::ServiceRequestFailed => "failed to send a service request",
406 Self::ServiceReplyFailed => "failed to send a service reply",
407 Self::SerializationError => "serialization failed",
408 Self::DeserializationError => "deserialization failed",
409 Self::BufferTooSmall => "buffer too small",
410 Self::MessageTooLarge => "incoming message exceeds the static buffer",
411 Self::Timeout => "timed out",
412 Self::InvalidConfig => "invalid configuration",
413 Self::WouldBlock => "resource momentarily unavailable; retry",
414 Self::TooLarge => "requested size exceeds the backend's capacity",
415 Self::TaskStartFailed => "failed to start a background task",
416 Self::PollFailed => "failed to poll for incoming messages",
417 Self::KeepaliveFailed => "failed to send a keepalive",
418 Self::JoinFailed => "failed to send a join message",
419 Self::InvalidArgument => "invalid argument",
420 Self::Unsupported => "operation not supported by this backend",
421 Self::BadAlloc => "memory allocation failed",
422 Self::IncompatibleQos(policy) => {
423 return match policy.policy_name() {
424 Some(name) => write!(f, "incompatible QoS: {name}"),
425 None => f.write_str("incompatible QoS"),
426 };
427 }
428 Self::TopicNameInvalid => "invalid topic, service or action name",
429 Self::NodeNameNonExistent => "no such node in this session",
430 Self::LoanNotSupported => "message loans unavailable on this entity",
431 Self::NoData => "no data available",
432 Self::IncompatibleAbi => "incompatible ABI version",
433 Self::Backend(msg) => return write!(f, "backend error: {msg}"),
434 #[cfg(feature = "alloc")]
435 Self::BackendDynamic(msg) => return write!(f, "backend error: {msg}"),
436 };
437 f.write_str(s)
438 }
439}
440
441impl TransportError {
442 /// The QoS policy this error names, or `None`.
443 ///
444 /// phase-417 G7 — the one accessor the three surfaces share. `Some` only
445 /// for [`Self::IncompatibleQos`] carrying exactly one
446 /// [`QoSPolicyMask`] bit; `None` for every other error, and for a
447 /// refusal that crossed a boundary with no room for the mask.
448 ///
449 /// The string is borrowed from [`QoSPolicyMask::NAMED`], so this costs no
450 /// allocator and no formatter and is available on a bare-metal target.
451 pub fn qos_policy_name(&self) -> Option<&'static str> {
452 match self {
453 Self::IncompatibleQos(policy) => policy.policy_name(),
454 _ => None,
455 }
456 }
457}
458
459/// QoS history policy
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
461pub enum QoSHistoryPolicy {
462 /// **The caller did not state a history policy** — the backend picks.
463 /// Upstream's `RMW_QOS_POLICY_HISTORY_SYSTEM_DEFAULT`; lowers to
464 /// `NROS_RMW_HISTORY_SYSTEM_DEFAULT` (0) across the C ABI.
465 ///
466 /// See [`QoSSystemDefaults`] for how a backend resolves it. Listed
467 /// first to match rclrs's enumerator order.
468 SystemDefault,
469 /// Keep last N messages (where N is defined in QoSProfile)
470 #[default]
471 KeepLast,
472 /// Keep all messages (up to resource limits)
473 KeepAll,
474 /// **The backend could not determine this policy.** Upstream's
475 /// `RMW_QOS_POLICY_HISTORY_UNKNOWN`; lowers to `NROS_RMW_HISTORY_UNKNOWN`
476 /// (3) across the C ABI.
477 ///
478 /// An ABSENCE, not a value: it is what a `*_get_actual_qos` read-back
479 /// writes for a policy it looked at and cannot report, and it is never a
480 /// request an application makes. Distinct from `SystemDefault`, which is
481 /// "nobody asked, so the middleware chooses" — see
482 /// [`QoSProfile::QOS_PROFILE_UNKNOWN`].
483 Unknown,
484}
485
486/// QoS reliability policy
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
488pub enum QoSReliabilityPolicy {
489 /// **The caller did not state a reliability policy** — the backend picks.
490 /// Upstream's `RMW_QOS_POLICY_RELIABILITY_SYSTEM_DEFAULT`; lowers to
491 /// `NROS_RMW_RELIABILITY_SYSTEM_DEFAULT` (0) across the C ABI.
492 ///
493 /// See [`QoSSystemDefaults`].
494 SystemDefault,
495 /// Reliable delivery (retransmit if needed).
496 ///
497 /// Default — matches ROS 2 `rmw_qos_profile_default` and the
498 /// `QoSProfile::default()` / `QOS_PROFILE_DEFAULT` aggregates.
499 #[default]
500 Reliable,
501 /// Best-effort delivery (no retransmits)
502 BestEffort,
503 /// **The backend could not determine this policy.** Upstream's
504 /// `RMW_QOS_POLICY_RELIABILITY_UNKNOWN`; lowers to `NROS_RMW_RELIABILITY_UNKNOWN`
505 /// (3) across the C ABI.
506 ///
507 /// An ABSENCE, not a value: it is what a `*_get_actual_qos` read-back
508 /// writes for a policy it looked at and cannot report, and it is never a
509 /// request an application makes. Distinct from `SystemDefault`, which is
510 /// "nobody asked, so the middleware chooses" — see
511 /// [`QoSProfile::QOS_PROFILE_UNKNOWN`].
512 Unknown,
513}
514
515/// QoS durability policy
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
517pub enum QoSDurabilityPolicy {
518 /// **The caller did not state a durability policy** — the backend picks.
519 /// Upstream's `RMW_QOS_POLICY_DURABILITY_SYSTEM_DEFAULT`; lowers to
520 /// `NROS_RMW_DURABILITY_SYSTEM_DEFAULT` (0) across the C ABI.
521 ///
522 /// See [`QoSSystemDefaults`].
523 SystemDefault,
524 /// Messages are discarded when subscriber disconnects
525 #[default]
526 Volatile,
527 /// Messages are persisted for late-joining subscribers
528 TransientLocal,
529 /// **The backend could not determine this policy.** Upstream's
530 /// `RMW_QOS_POLICY_DURABILITY_UNKNOWN`; lowers to `NROS_RMW_DURABILITY_UNKNOWN`
531 /// (3) across the C ABI.
532 ///
533 /// An ABSENCE, not a value: it is what a `*_get_actual_qos` read-back
534 /// writes for a policy it looked at and cannot report, and it is never a
535 /// request an application makes. Distinct from `SystemDefault`, which is
536 /// "nobody asked, so the middleware chooses" — see
537 /// [`QoSProfile::QOS_PROFILE_UNKNOWN`].
538 Unknown,
539}
540
541/// QoS liveliness policy. Matches DDS `LIVELINESS` semantics.
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
543#[repr(u8)]
544pub enum QoSLivelinessPolicy {
545 /// Discriminant 0 — the same value the C ABI spells
546 /// `NROS_RMW_LIVELINESS_SYSTEM_DEFAULT` and upstream spells
547 /// `RMW_QOS_POLICY_LIVELINESS_SYSTEM_DEFAULT`.
548 ///
549 /// phase-428 W10 — the NAME diverges from both and the VALUE does not, so
550 /// this is what an upstream-mirroring profile carries. Read it as "the
551 /// middleware's choice", not as "liveliness is off": a backend that sees 0
552 /// omits the DDS call entirely and inherits the DDS default, which is
553 /// AUTOMATIC. `None` is our spelling, kept because it is the C ABI
554 /// discriminant three enums already agree on; renaming it is a separate
555 /// question from getting the value right, and only the value crosses.
556 ///
557 /// No liveliness assertion or tracking of our own.
558 #[default]
559 None = 0,
560 /// Backend's keepalive task asserts liveliness automatically.
561 Automatic = 1,
562 /// Application calls `assert_liveliness()` at the node level.
563 ///
564 /// Phase 376 W5/B2 — this was 3 and `ManualByTopic` was 2, the opposite of
565 /// upstream. The discriminant IS the C ABI value (`liveliness_kind` is
566 /// written with `as u8`), and the cyclonedds backend turns it into a real
567 /// DDS liveliness kind that a ROS peer matches on, so the swap was visible
568 /// on the wire and nowhere else.
569 ManualByNode = 2,
570 /// Application calls `assert_liveliness()` per topic explicitly.
571 ManualByTopic = 3,
572 /// **The backend could not determine this policy.** Discriminant 4, the
573 /// value the C ABI spells `NROS_RMW_LIVELINESS_UNKNOWN`.
574 ///
575 /// An ABSENCE, not a value — see [`QoSProfile::QOS_PROFILE_UNKNOWN`]. A
576 /// `*_get_actual_qos` read-back writes it for a policy it cannot report;
577 /// an application never requests it.
578 Unknown = 4,
579}
580
581impl QoSLivelinessPolicy {
582 /// `true` for the two kinds under which the APPLICATION owns the
583 /// assertion — [`ManualByNode`](Self::ManualByNode) and
584 /// [`ManualByTopic`](Self::ManualByTopic).
585 ///
586 /// The ONE spelling of that test (the phase-467 RMW gap-closure design
587 /// study's Row 7). It decides what [`Publisher::assert_liveliness`] owes:
588 /// under a manual kind the caller asked for something a backend either
589 /// performs or cannot, so a backend that cannot must answer
590 /// `Unsupported`; under `Automatic` / `None` there is nothing to assert
591 /// and `Ok(())` is the whole truth. `Unknown` is an absence, never a
592 /// request, so it is not manual.
593 #[must_use]
594 pub const fn is_manual(self) -> bool {
595 matches!(self, Self::ManualByNode | Self::ManualByTopic)
596 }
597}
598
599/// Phase 211.H — which side of a topic a [`QoSOverride`] targets.
600/// Mirrors the `<role>` segment of a ROS 2
601/// `qos_overrides.<topic>.<role>.<policy>` launch parameter.
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603pub enum QoSOverrideRole {
604 /// `qos_overrides.<topic>.publisher.*`
605 Publisher,
606 /// `qos_overrides.<topic>.subscription.*`
607 Subscription,
608}
609
610/// Phase 211.H — a single policy value a [`QoSOverride`] sets. A typed enum
611/// (not a string) so the codegen that bakes these from the plan catches an
612/// unknown policy / mistyped value at generation time rather than silently
613/// no-op-ing at runtime.
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum QoSOverrideValue {
616 /// `.reliability` → Reliable / BestEffort.
617 Reliability(QoSReliabilityPolicy),
618 /// `.durability` → Volatile / TransientLocal.
619 Durability(QoSDurabilityPolicy),
620 /// `.history` → KeepLast / KeepAll.
621 History(QoSHistoryPolicy),
622 /// `.depth` → KeepLast depth.
623 Depth(u32),
624 /// Issue 0303 — `.deadline` → [`QoSProfile::deadline_ms`]. `0` /
625 /// [`DURATION_INFINITE_MS`] = no deadline check.
626 Deadline(u32),
627 /// Issue 0303 — `.lifespan` → [`QoSProfile::lifespan_ms`].
628 Lifespan(u32),
629 /// Issue 0303 — `.liveliness` → [`QoSProfile::liveliness_kind`].
630 Liveliness(QoSLivelinessPolicy),
631 /// Issue 0303 — `.liveliness_lease_duration` →
632 /// [`QoSProfile::liveliness_lease_ms`].
633 LivelinessLease(u32),
634}
635
636/// Issue 0303 — the wire form a baked QoS override travels in:
637/// `(topic, role, policy, value)`, all primitives.
638///
639/// One numbering for every language: `rclc_qos_override_t` (C),
640/// `nros_cpp_qos_override_t` (C++), `RuntimeCtx::qos_overrides` (the Rust
641/// entry bake) and `NodeRecord::qos_overrides` are all THIS tuple. Codes rather
642/// than [`QoSOverride`] because the table crosses the C ABI and rides
643/// `nros-platform`, which sits below this crate in the layer graph.
644///
645/// `topic` is `&'static` because every producer bakes a literal.
646pub type QoSOverrideCode = (&'static str, u8, u8, u32);
647
648/// `role` codes for [`QoSOverrideCode`].
649pub mod qos_override_role {
650 /// The override targets publishers on the topic.
651 pub const PUBLISHER: u8 = 0;
652 /// The override targets subscriptions on the topic.
653 pub const SUBSCRIPTION: u8 = 1;
654}
655
656/// `policy` codes for [`QoSOverrideCode`].
657///
658/// Append-only: these numbers are baked into shipped images and mirrored in
659/// two C headers. Never renumber — add.
660pub mod qos_override_policy {
661 /// value `0` = best_effort, `1` = reliable.
662 pub const RELIABILITY: u8 = 0;
663 /// value `0` = volatile, `1` = transient_local.
664 pub const DURABILITY: u8 = 1;
665 /// value `0` = keep_last, `1` = keep_all.
666 pub const HISTORY: u8 = 2;
667 /// value = the KeepLast depth.
668 pub const DEPTH: u8 = 3;
669 /// value = milliseconds (issue 0303).
670 pub const DEADLINE: u8 = 4;
671 /// value = milliseconds (issue 0303).
672 pub const LIFESPAN: u8 = 5;
673 /// value = [`super::QoSLivelinessPolicy`] discriminant (issue 0303).
674 pub const LIVELINESS: u8 = 6;
675 /// value = milliseconds (issue 0303).
676 pub const LIVELINESS_LEASE: u8 = 7;
677}
678
679/// Decode one [`QoSOverrideCode`] into a typed [`QoSOverride`].
680///
681/// `None` for an unrecognised role or policy code. THE one decoder: before
682/// issue 0303 this match existed four times (nros-node, nros-c, nros-cpp, and
683/// the executor's node record), each with a silent catch-all, so adding a
684/// policy meant finding all four — and the two FFI copies had already been
685/// forgotten once.
686pub fn decode_qos_override(code: &QoSOverrideCode) -> Option<QoSOverride> {
687 let (topic, role, policy, value) = *code;
688 decode_qos_override_parts(topic, role, policy, value)
689}
690
691/// [`decode_qos_override`] over loose parts — for the FFI paths, which read the
692/// fields out of a `#[repr(C)]` struct rather than a tuple.
693pub fn decode_qos_override_parts(
694 topic: &'static str,
695 role: u8,
696 policy: u8,
697 value: u32,
698) -> Option<QoSOverride> {
699 Some(QoSOverride {
700 topic,
701 role: decode_qos_override_role(role)?,
702 value: decode_qos_override_value(policy, value)?,
703 })
704}
705
706/// Decode a `role` code. `None` for an unrecognised one.
707pub fn decode_qos_override_role(role: u8) -> Option<QoSOverrideRole> {
708 match role {
709 qos_override_role::PUBLISHER => Some(QoSOverrideRole::Publisher),
710 qos_override_role::SUBSCRIPTION => Some(QoSOverrideRole::Subscription),
711 _ => None,
712 }
713}
714
715/// Decode a `(policy, value)` code pair. `None` for an unrecognised policy or
716/// an out-of-range enum value.
717///
718/// Split out from [`decode_qos_override`] for the FFI paths: they have already
719/// matched the topic against a `*const c_char`, so they need the VALUE without
720/// a `&'static str` to build a whole [`QoSOverride`] around.
721pub fn decode_qos_override_value(policy: u8, value: u32) -> Option<QoSOverrideValue> {
722 let out = match policy {
723 qos_override_policy::RELIABILITY => QoSOverrideValue::Reliability(if value == 0 {
724 QoSReliabilityPolicy::BestEffort
725 } else {
726 QoSReliabilityPolicy::Reliable
727 }),
728 qos_override_policy::DURABILITY => QoSOverrideValue::Durability(if value == 1 {
729 QoSDurabilityPolicy::TransientLocal
730 } else {
731 QoSDurabilityPolicy::Volatile
732 }),
733 qos_override_policy::HISTORY => QoSOverrideValue::History(if value == 1 {
734 QoSHistoryPolicy::KeepAll
735 } else {
736 QoSHistoryPolicy::KeepLast
737 }),
738 qos_override_policy::DEPTH => QoSOverrideValue::Depth(value),
739 qos_override_policy::DEADLINE => QoSOverrideValue::Deadline(value),
740 qos_override_policy::LIFESPAN => QoSOverrideValue::Lifespan(value),
741 // The encoder is `nros_orchestration_ir::qos_override`; both ends name
742 // the variant rather than its number, so W5/B2's renumbering moved them
743 // together instead of silently swapping two policies.
744 qos_override_policy::LIVELINESS => QoSOverrideValue::Liveliness(match value {
745 v if v == QoSLivelinessPolicy::None as u32 => QoSLivelinessPolicy::None,
746 v if v == QoSLivelinessPolicy::Automatic as u32 => QoSLivelinessPolicy::Automatic,
747 v if v == QoSLivelinessPolicy::ManualByNode as u32 => QoSLivelinessPolicy::ManualByNode,
748 v if v == QoSLivelinessPolicy::ManualByTopic as u32 => {
749 QoSLivelinessPolicy::ManualByTopic
750 }
751 _ => return None,
752 }),
753 qos_override_policy::LIVELINESS_LEASE => QoSOverrideValue::LivelinessLease(value),
754 _ => return None,
755 };
756 Some(out)
757}
758
759/// Phase 211.H — one per-topic QoS override, lowered from a ROS 2
760/// `qos_overrides.<topic>.<role>.<policy>` launch parameter by the planner and
761/// baked into a `&'static [QoSOverride]` table by the entry codegen. The node
762/// folds the matching entries into the entity's [`QoSProfile`] at
763/// `create_publisher` / `create_subscription` time (setup-time, single
764/// linear scan, no alloc), *before* the backend-compat `validate_against` —
765/// so an override the active RMW can't honour still errors loudly, never a
766/// silent downgrade.
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
768pub struct QoSOverride {
769 /// The resolved (remapped) topic name the override targets, e.g.
770 /// `"/chatter"`. Matched exactly against the entity's topic.
771 pub topic: &'static str,
772 /// Publisher or subscription side.
773 pub role: QoSOverrideRole,
774 /// The policy + value to set.
775 pub value: QoSOverrideValue,
776}
777
778/// Phase-301 (issue 0241) — explicit "infinite" spelling for the u32
779/// millisecond QoS duration fields (`deadline_ms`, `lifespan_ms`,
780/// `liveliness_lease_ms`). Semantically identical to `0` (unset /
781/// no-check) at every check site; exists so a caller can distinguish
782/// "default" from "deliberately infinite". Mirrors the C header's
783/// `NROS_RMW_DURATION_INFINITE_MS`.
784pub const DURATION_INFINITE_MS: u32 = u32::MAX;
785
786/// Phase-301 (issue 0241) — lower a [`core::time::Duration`] into a u32
787/// millisecond QoS field. Boundary contract:
788///
789/// - zero stays `0` (unset / no-check);
790/// - sub-millisecond remainders CEIL to the next ms (rounding down
791/// would silently turn a real deadline into "no deadline");
792/// - values at or past [`DURATION_INFINITE_MS`] ms are a create-time
793/// error, never a clamp (infinite is requested via the sentinel or
794/// `0`, not by a huge finite duration).
795pub fn duration_to_qos_ms(d: core::time::Duration) -> Result<u32, TransportError> {
796 if d.is_zero() {
797 return Ok(0);
798 }
799 let mut ms = d.as_millis();
800 if !d.subsec_nanos().is_multiple_of(1_000_000) {
801 ms += 1;
802 }
803 if ms >= DURATION_INFINITE_MS as u128 {
804 return Err(TransportError::InvalidArgument);
805 }
806 Ok(ms as u32)
807}
808
809/// Full DDS-shaped QoS profile. Matches the field set of upstream
810/// `rmw_qos_profile_t`.
811///
812/// Backends advertise per-policy support via
813/// [`Session::supported_qos_policies`]; entities created with a
814/// profile the active backend can't honour return
815/// [`TransportError::IncompatibleQos`] synchronously at create time
816/// — no silent downgrade.
817///
818/// Zero-valued time-window fields ("off") mean infinite — the policy
819/// is effectively disabled for the entity.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub struct QoSProfile {
822 /// History policy
823 pub history: QoSHistoryPolicy,
824 /// Reliability policy
825 pub reliability: QoSReliabilityPolicy,
826 /// Durability policy
827 pub durability: QoSDurabilityPolicy,
828 /// Liveliness policy
829 pub liveliness_kind: QoSLivelinessPolicy,
830 /// History depth (only used if history is KeepLast)
831 pub depth: u32,
832 /// Subscription max-inter-arrival / publisher offered-rate, ms.
833 /// `0` = infinite (no deadline check).
834 pub deadline_ms: u32,
835 /// Sample expiry, ms. Subscribers filter samples older than this.
836 /// `0` = infinite (no expiry).
837 pub lifespan_ms: u32,
838 /// Liveliness lease, ms. `0` = infinite.
839 pub liveliness_lease_ms: u32,
840 /// If `true`, topic-name encoding skips the `/rt/` ROS prefix.
841 pub avoid_ros_namespace_conventions: bool,
842 /// Phase 282 (#145) — publisher-side "express" hint: this publisher's
843 /// samples bypass transport tx batching (zenoh: the wire EXPRESS flag; a
844 /// batching zenoh-pico session sends them immediately instead of queueing
845 /// them for the next flush). A transport hint, not a DDS policy — no RxO
846 /// matching, no backend-compat validation; ignored by subscriptions and by
847 /// backends without a batching concept.
848 pub tx_express: bool,
849}
850
851impl Default for QoSProfile {
852 fn default() -> Self {
853 Self::QOS_PROFILE_DEFAULT
854 }
855}
856
857/// The depth sentinel — "the caller did not state a queue depth".
858///
859/// issue 0829. Upstream spells it `RMW_QOS_POLICY_DEPTH_SYSTEM_DEFAULT = 0`
860/// (`rmw/include/rmw/types.h`); depth is `size_t` there and `uint16_t` in our
861/// C ABI, but 0 is 0 in both. The value was already free on every backend:
862/// Cyclone REJECTS `KEEP_LAST` with depth 0 outright
863/// (`validate_history_qospolicy`, `ddsi_plist.c:2603-2604`), the XRCE client
864/// already reads it as "unstated" and drops the field from the wire
865/// (`create_entities_bin.c:148`), and both `read_entity_qos`
866/// (`nros-rmw-cyclonedds/src/qos.cpp:138`) and `report_qos_downgrade`
867/// (`nros-rmw-cffi/src/lib.rs:1903`) already treat a 0 read-back as "no
868/// answer" rather than as an answer.
869pub const DEPTH_SYSTEM_DEFAULT: u32 = 0;
870
871/// What ONE backend resolves the `SYSTEM_DEFAULT` sentinel to.
872///
873/// issue 0829. `rmw_qos_profile_system_default` is an absence, and the RMW
874/// fills it — which means the answer is per backend and there is no constant
875/// this crate could bake. Each backend declares its own `QoSSystemDefaults`
876/// and applies it with [`QoSProfile::resolve_system_default`] at its create
877/// entry, **before anything is derived from the QoS**.
878///
879/// That ordering is load-bearing on the zenoh path: the profile is serialised
880/// into the liveliness-token keyexpr that a ROS `rmw_zenoh_cpp` peer parses
881/// out of the graph (`nros-rmw-zenoh/src/keyexpr.rs`). Upstream resolves in
882/// `best_available_qos` before the entity and its token exist, so its tokens
883/// never carry a sentinel; ours must not either, or we advertise `0:0:0,0` to
884/// peers as though it were a policy.
885///
886/// The values a backend picks should mirror **the corresponding upstream
887/// RMW**, not the raw middleware default — interop with a ROS peer is the
888/// requirement, and the two differ. Leaving `dds_qset_reliability` unset gives
889/// Cyclone's own reader default of BEST_EFFORT (`ddsi_plist.c:3470`), where
890/// `rmw_cyclonedds_cpp` deliberately picks RELIABLE.
891#[derive(Debug, Clone, Copy, PartialEq, Eq)]
892pub struct QoSSystemDefaults {
893 /// Concrete reliability. Must not itself be `SystemDefault`.
894 pub reliability: QoSReliabilityPolicy,
895 /// Concrete durability. Must not itself be `SystemDefault`.
896 pub durability: QoSDurabilityPolicy,
897 /// Concrete history. Must not itself be `SystemDefault`.
898 pub history: QoSHistoryPolicy,
899 /// Concrete queue depth. `0` means the backend genuinely has no answer and
900 /// defers further down the stack — the XRCE case, where the client encodes
901 /// exactly that (`optional_history_depth = false`) and the Agent's DDS
902 /// layer resolves it.
903 pub depth: u32,
904}
905
906impl QoSProfile {
907 /// Phase 211.H — fold the plan's `qos_overrides` matching `topic` + `role`
908 /// into this profile, returning the overridden profile. Setup-time only
909 /// (called from `create_publisher`/`create_subscription`): a single linear
910 /// scan over the baked `&'static` table, no alloc, RT-safe. Later entries
911 /// win on a duplicate `(topic, role, policy)` (last-write), matching the
912 /// planner's sorted, de-conflicted emit. Non-matching entries are ignored,
913 /// so passing the whole node table to every entity is cheap + correct.
914 #[must_use]
915 pub fn apply_overrides(
916 mut self,
917 topic: &str,
918 role: QoSOverrideRole,
919 overrides: &[QoSOverride],
920 ) -> Self {
921 for ovr in overrides {
922 if ovr.topic == topic && ovr.role == role {
923 self.apply_override_value(ovr.value);
924 }
925 }
926 self
927 }
928
929 /// Issue 0303 — apply ONE decoded override value. The single place a
930 /// policy maps to the field it sets; `apply_overrides` and the FFI folds
931 /// both go through it, so a new policy cannot reach some paths only.
932 pub fn apply_override_value(&mut self, value: QoSOverrideValue) {
933 match value {
934 QoSOverrideValue::Reliability(r) => self.reliability = r,
935 QoSOverrideValue::Durability(d) => self.durability = d,
936 QoSOverrideValue::History(h) => self.history = h,
937 QoSOverrideValue::Depth(d) => self.depth = d,
938 QoSOverrideValue::Deadline(ms) => self.deadline_ms = ms,
939 QoSOverrideValue::Lifespan(ms) => self.lifespan_ms = ms,
940 QoSOverrideValue::Liveliness(k) => self.liveliness_kind = k,
941 QoSOverrideValue::LivelinessLease(ms) => self.liveliness_lease_ms = ms,
942 }
943 }
944
945 /// Issue 0303 — fold baked [`QoSOverrideCode`]s for one `(topic, role)`.
946 /// Unrecognised codes are skipped; the producers reject them at BAKE time
947 /// (`nros_orchestration_ir::qos_override`), so a code reaching here that
948 /// this build does not know is an older image running newer data, not a
949 /// user error to diagnose at runtime.
950 pub fn apply_override_codes(
951 self,
952 topic: &str,
953 role: QoSOverrideRole,
954 codes: &[QoSOverrideCode],
955 ) -> Self {
956 let mut qos = self;
957 for code in codes {
958 if let Some(ovr) = decode_qos_override(code) {
959 qos = qos.apply_overrides(topic, role, core::slice::from_ref(&ovr));
960 }
961 }
962 qos
963 }
964}
965
966/// The ONE authoring site for every named [`QoSProfile`] preset — phase-428 W10.
967///
968/// Upstream's profiles were hand-transcribed here and in three other places
969/// (`nros::qos`, `rclcpp/qos.hpp`, `rmw_entity.h`) with nothing binding them, and
970/// they drifted exactly as every other hand-mirror in this tree has: issue 0793
971/// caught `PARAMETERS` claiming TRANSIENT_LOCAL, fixed that one profile, and
972/// left `PARAMETER_EVENTS` sitting at `KeepAll, depth 0` under a comment saying
973/// *"matches rmw_qos_profile_parameter_events"* — where upstream is
974/// `KEEP_LAST, 1000`. A RELIABLE writer with KEEP_ALL **blocks** when history
975/// fills where KEEP_LAST(1000) overwrites, so that was an inverting difference,
976/// not a weaker one, and the test beneath it asserted the wrong value.
977///
978/// Three properties are what make this a table rather than ten `const`s:
979///
980/// 1. **Every field is stated.** The `Self::build(...)` helper this replaces
981/// supplied `liveliness_kind`, the three duration windows and both flags
982/// silently, so five of ten fields per profile were invisible at the
983/// authoring site and no reader could compare a row against upstream.
984/// 2. **Every row declares its PROVENANCE** — the upstream symbol it mirrors,
985/// or `nros: <why>` for a preset we invented. A profile cannot be added
986/// without answering "whose constant is this?".
987/// 3. **The field ORDER is upstream's** (`rmw_qos_profile_t` in
988/// `rmw/include/rmw/types.h`: history, depth, reliability, durability,
989/// deadline, lifespan, liveliness, lease, avoid-ns), so a row reads against
990/// the header positionally.
991///
992/// Expansion is exactly the `pub const` items that were here before — no table
993/// is materialised, nothing is `static`, and the crate stays `no_std` and
994/// zero-cost on every target it reaches.
995///
996/// `scripts/check-qos-profile-ssot.py` parses this invocation and asserts every
997/// upstream-mirroring row field-by-field against
998/// `docs/reference/rmw-qos-profiles.txt`, which is itself extracted from a real
999/// ROS install. The other three sites bind to this block.
1000macro_rules! qos_profiles {
1001 ($(
1002 $(#[$meta:meta])*
1003 $name:ident : $provenance:literal = {
1004 history: $history:ident,
1005 depth: $depth:expr,
1006 reliability: $reliability:ident,
1007 durability: $durability:ident,
1008 deadline_ms: $deadline_ms:expr,
1009 lifespan_ms: $lifespan_ms:expr,
1010 liveliness_kind: $liveliness_kind:ident,
1011 liveliness_lease_ms: $liveliness_lease_ms:expr,
1012 avoid_ros_namespace_conventions: $avoid:expr,
1013 tx_express: $tx_express:expr,
1014 };
1015 )+) => {
1016 impl QoSProfile {
1017 $(
1018 $(#[$meta])*
1019 ///
1020 #[doc = concat!("Provenance: `", $provenance, "`.")]
1021 pub const $name: Self = Self {
1022 history: QoSHistoryPolicy::$history,
1023 depth: $depth,
1024 reliability: QoSReliabilityPolicy::$reliability,
1025 durability: QoSDurabilityPolicy::$durability,
1026 deadline_ms: $deadline_ms,
1027 lifespan_ms: $lifespan_ms,
1028 liveliness_kind: QoSLivelinessPolicy::$liveliness_kind,
1029 liveliness_lease_ms: $liveliness_lease_ms,
1030 avoid_ros_namespace_conventions: $avoid,
1031 tx_express: $tx_express,
1032 };
1033 )+
1034 }
1035 };
1036}
1037
1038// nros-qos-table: BEGIN
1039// The SSoT. Everything between this marker and `nros-qos-table: END` is parsed
1040// by `scripts/check-qos-profile-ssot.py`; do not add a `QoSProfile` preset
1041// anywhere else in this crate — the gate refuses one.
1042qos_profiles! {
1043 /// Best-effort QoS (for real-time).
1044 BEST_EFFORT: "nros: best-effort keep-last(1); a shorthand, not an upstream constant" = {
1045 history: KeepLast,
1046 depth: 1,
1047 reliability: BestEffort,
1048 durability: Volatile,
1049 deadline_ms: 0,
1050 lifespan_ms: 0,
1051 liveliness_kind: Automatic,
1052 liveliness_lease_ms: 0,
1053 avoid_ros_namespace_conventions: false,
1054 tx_express: false,
1055 };
1056
1057 /// Reliable QoS.
1058 RELIABLE: "nros: reliable keep-last(10); a shorthand, not an upstream constant" = {
1059 history: KeepLast,
1060 depth: 10,
1061 reliability: Reliable,
1062 durability: Volatile,
1063 deadline_ms: 0,
1064 lifespan_ms: 0,
1065 liveliness_kind: Automatic,
1066 liveliness_lease_ms: 0,
1067 avoid_ros_namespace_conventions: false,
1068 tx_express: false,
1069 };
1070
1071 /// `rmw_qos_profile_system_default` — **an absence, not a profile.**
1072 ///
1073 /// issue 0829. Every field is the sentinel: upstream's constant names no
1074 /// concrete policy at all, and the two reference RMWs fill the absence with
1075 /// different numbers — `rmw_cyclonedds_cpp`'s `create_readwrite_qos` folds
1076 /// `RMW_QOS_POLICY_DEPTH_SYSTEM_DEFAULT` to `KEEP_LAST(1)`, while
1077 /// `rmw_zenoh_cpp`'s `QoS::QoS()` fills it from
1078 /// `RMW_ZENOH_DEFAULT_HISTORY_DEPTH`, which is 42, over a comment stating
1079 /// the contract outright: *"If the depth field in the qos profile is set to
1080 /// 0, the RMW implementation has the liberty to assign a default depth."*
1081 ///
1082 /// So no baked number can be right. This carried a concrete
1083 /// `Reliable / Volatile / KeepLast(1)` until 2026-09-03, and
1084 /// `nros::qos::SYSTEM_DEFAULT` carried a concrete depth **10**, which is how
1085 /// one name shipped two queue depths. Both are gone; the backend resolves
1086 /// this at its create entry via [`QoSProfile::resolve_system_default`].
1087 ///
1088 /// `liveliness_kind` is [`QoSLivelinessPolicy::None`], which IS the
1089 /// sentinel on that policy — it lowers to
1090 /// `NROS_RMW_LIVELINESS_SYSTEM_DEFAULT` (0), the two having collapsed onto
1091 /// one value in phase-376 W5/B2. This is therefore the ONE preset that
1092 /// matches upstream on all nine fields.
1093 QOS_PROFILE_SYSTEM_DEFAULT: "rmw_qos_profile_system_default" = {
1094 history: SystemDefault,
1095 depth: DEPTH_SYSTEM_DEFAULT,
1096 reliability: SystemDefault,
1097 durability: SystemDefault,
1098 deadline_ms: 0,
1099 lifespan_ms: 0,
1100 liveliness_kind: None,
1101 liveliness_lease_ms: 0,
1102 avoid_ros_namespace_conventions: false,
1103 tx_express: false,
1104 };
1105
1106 /// `rmw_qos_profile_unknown` — **"the backend could not determine this"**,
1107 /// and distinct from [`QOS_PROFILE_SYSTEM_DEFAULT`](Self::QOS_PROFILE_SYSTEM_DEFAULT).
1108 ///
1109 /// `SYSTEM_DEFAULT` means "nobody asked, so the middleware chooses";
1110 /// this means "we looked and cannot say", which is what a
1111 /// `*_get_actual_qos` read-back owes its caller for a policy it cannot
1112 /// report. It is the starting value the read-back helper pre-loads, so a
1113 /// field the backend does not overwrite reads as an ABSENCE rather than as
1114 /// a confident grant of whatever was asked.
1115 ///
1116 /// **Never a request.** No `create_*` accepts it — a backend's
1117 /// `qos_admit` refuses `UNKNOWN` on every policy, because an application
1118 /// asking for "I cannot say" is a bug, not a preference.
1119 ///
1120 /// The C ABI twin is `NROS_RMW_QOS_PROFILE_UNKNOWN` (`rmw_entity.h`),
1121 /// which existed from phase-428 W10 with no Rust counterpart: three of the
1122 /// four policy enums had no `Unknown` variant to build it from. They do
1123 /// now.
1124 QOS_PROFILE_UNKNOWN: "rmw_qos_profile_unknown" = {
1125 history: Unknown,
1126 depth: 0,
1127 reliability: Unknown,
1128 durability: Unknown,
1129 deadline_ms: 0,
1130 lifespan_ms: 0,
1131 liveliness_kind: Unknown,
1132 liveliness_lease_ms: 0,
1133 avoid_ros_namespace_conventions: false,
1134 tx_express: false,
1135 };
1136
1137 /// Default QoS profile — reliable, volatile, keep-last(10).
1138 QOS_PROFILE_DEFAULT: "rmw_qos_profile_default" = {
1139 history: KeepLast,
1140 depth: 10,
1141 reliability: Reliable,
1142 durability: Volatile,
1143 deadline_ms: 0,
1144 lifespan_ms: 0,
1145 liveliness_kind: None,
1146 liveliness_lease_ms: 0,
1147 avoid_ros_namespace_conventions: false,
1148 tx_express: false,
1149 };
1150
1151 /// Sensor data QoS profile — best-effort, volatile, keep-last(5).
1152 QOS_PROFILE_SENSOR_DATA: "rmw_qos_profile_sensor_data" = {
1153 history: KeepLast,
1154 depth: 5,
1155 reliability: BestEffort,
1156 durability: Volatile,
1157 deadline_ms: 0,
1158 lifespan_ms: 0,
1159 liveliness_kind: None,
1160 liveliness_lease_ms: 0,
1161 avoid_ros_namespace_conventions: false,
1162 tx_express: false,
1163 };
1164
1165 /// Services default QoS profile — reliable, volatile, keep-last(10).
1166 QOS_PROFILE_SERVICES_DEFAULT: "rmw_qos_profile_services_default" = {
1167 history: KeepLast,
1168 depth: 10,
1169 reliability: Reliable,
1170 durability: Volatile,
1171 deadline_ms: 0,
1172 lifespan_ms: 0,
1173 liveliness_kind: None,
1174 liveliness_lease_ms: 0,
1175 avoid_ros_namespace_conventions: false,
1176 tx_express: false,
1177 };
1178
1179 /// Parameters QoS profile — KEEP_LAST(1000), RELIABLE, **VOLATILE**.
1180 ///
1181 /// issue 0793 — this said `TransientLocal` until 2026-08-25, disagreeing
1182 /// both with upstream (`/opt/ros/<distro>/include/rmw/rmw/qos_profiles.h`)
1183 /// and with our own second copy of the same profile, `nros::qos::PARAMETERS`,
1184 /// which was already correct. Two copies of one profile that disagree is the
1185 /// defect; the wrong one being the one named after the upstream constant is
1186 /// what made it hard to see.
1187 QOS_PROFILE_PARAMETERS: "rmw_qos_profile_parameters" = {
1188 history: KeepLast,
1189 depth: 1000,
1190 reliability: Reliable,
1191 durability: Volatile,
1192 deadline_ms: 0,
1193 lifespan_ms: 0,
1194 liveliness_kind: None,
1195 liveliness_lease_ms: 0,
1196 avoid_ros_namespace_conventions: false,
1197 tx_express: false,
1198 };
1199
1200 /// Parameter events QoS profile — KEEP_LAST(1000), RELIABLE, VOLATILE.
1201 ///
1202 /// phase-428 W10 — this was `KeepAll, depth 0` until 2026-09-05, under a
1203 /// comment claiming it matched `rmw_qos_profile_parameter_events`. Upstream
1204 /// is `KEEP_LAST, 1000` (identical to `rmw_qos_profile_parameters`), and
1205 /// `rclcpp::ParameterEventsQoS` — which our own C++ surface mirrors — was
1206 /// already correct, so the two languages shipped different profiles under
1207 /// one name.
1208 ///
1209 /// The difference INVERTS behaviour rather than weakening it: both DDS
1210 /// backends map KEEP_ALL faithfully, and a RELIABLE writer with KEEP_ALL
1211 /// **blocks** once history fills, where KEEP_LAST(1000) overwrites. A
1212 /// parameter server whose events nobody is reading would stall the node
1213 /// instead of dropping the oldest event.
1214 QOS_PROFILE_PARAMETER_EVENTS: "rmw_qos_profile_parameter_events" = {
1215 history: KeepLast,
1216 depth: 1000,
1217 reliability: Reliable,
1218 durability: Volatile,
1219 deadline_ms: 0,
1220 lifespan_ms: 0,
1221 liveliness_kind: None,
1222 liveliness_lease_ms: 0,
1223 avoid_ros_namespace_conventions: false,
1224 tx_express: false,
1225 };
1226
1227 /// Action status default QoS profile — reliable, transient-local,
1228 /// keep-last(1). From `rcl_action`, not `rmw`.
1229 QOS_PROFILE_ACTION_STATUS_DEFAULT: "rcl_action_qos_profile_status_default" = {
1230 history: KeepLast,
1231 depth: 1,
1232 reliability: Reliable,
1233 durability: TransientLocal,
1234 deadline_ms: 0,
1235 lifespan_ms: 0,
1236 liveliness_kind: None,
1237 liveliness_lease_ms: 0,
1238 avoid_ros_namespace_conventions: false,
1239 tx_express: false,
1240 };
1241
1242 /// Clock QoS profile — sensor-data shaped with depth 1.
1243 // phase-428 W4 — the provenance string said "/clock has no rmw constant",
1244 // which is true and was read as "no upstream at all". `rclrs::QOS_PROFILE_CLOCK`
1245 // EXISTS under this exact name (`rclrs/src/qos.rs:598`, it is rclcpp's
1246 // `ClockQoS`), and Rust follows rclrs. So this row mirrors something after
1247 // all, and the gate — which only compares upstream-MIRRORING rows — could
1248 // not see it.
1249 //
1250 // That is why it kept `Automatic` when W10 moved the other five to the
1251 // sentinel: F2's fourth instance, sitting inside the fix for F2's third.
1252 // The one row the instrument does not reach is the one that kept the
1253 // defect.
1254 QOS_PROFILE_CLOCK: "nros: mirrors rclrs::QOS_PROFILE_CLOCK; no rmw constant exists, so the gate cannot bind it — phase-428 W4" = {
1255 history: KeepLast,
1256 depth: 1,
1257 reliability: BestEffort,
1258 durability: Volatile,
1259 deadline_ms: 0,
1260 lifespan_ms: 0,
1261 liveliness_kind: None,
1262 liveliness_lease_ms: 0,
1263 avoid_ros_namespace_conventions: false,
1264 tx_express: false,
1265 };
1266
1267 /// PX4 companion QoS profile (Phase 233 / RFC-0039 Track B). Matches the
1268 /// QoS PX4's `uxrce_dds_client` uses on `/fmu/out/*` and `/fmu/in/*` —
1269 /// `BEST_EFFORT` + `VOLATILE` + `KEEP_LAST(1)`. A nano-ros node talking to
1270 /// the same `MicroXRCEAgent` must use this (a reliable or
1271 /// `TRANSIENT_LOCAL` reader will not match PX4's volatile best-effort
1272 /// writers). Verified against real PX4 SITL (`nros-px4-sitl-test`):
1273 /// `TRANSIENT_LOCAL` durability silently fails to match `/fmu/out/*`.
1274 /// Adjust depth via `.keep_last(n)` for higher-rate streams.
1275 QOS_PROFILE_PX4: "nros: PX4 uxrce_dds_client companion QoS; no upstream constant" = {
1276 history: KeepLast,
1277 depth: 1,
1278 reliability: BestEffort,
1279 durability: Volatile,
1280 deadline_ms: 0,
1281 lifespan_ms: 0,
1282 liveliness_kind: Automatic,
1283 liveliness_lease_ms: 0,
1284 avoid_ros_namespace_conventions: false,
1285 tx_express: false,
1286 };
1287}
1288// nros-qos-table: END
1289
1290// Where we knowingly differ from upstream, and where we knowingly have no
1291// counterpart. phase-428 W10.
1292//
1293// These lines are DATA, not prose: `check-qos-profile-ssot.py` requires a
1294// deviation to name the field, BOTH values and a tracking reference, and it
1295// re-measures `ours=` and `upstream=` against the table and the extracted
1296// record on every run. So a deviation cannot survive either side changing —
1297// which is the failure mode the phase-428 sweep found in `ARG_DEVIATIONS`,
1298// where 42 values were reason strings against a membership test and nothing
1299// checked that the reason was still true.
1300//
1301// The liveliness deviation is CLOSED (issue 1329, 2026-09-12) and is
1302// recorded here because the reason it survived two phases is worth keeping.
1303//
1304// Every application-facing preset used to state AUTOMATIC where upstream
1305// states the SYSTEM_DEFAULT sentinel. W10 moved this table and
1306// `rmw_entity.h` onto the sentinel and could not move the remaining three —
1307// `nros-c`'s `NROS_QOS_*`, `rclc_c_qos_default()` and `nros::QoS`'s
1308// constructor — because `liveliness_kind` crosses the C ABI as its
1309// discriminant, so flipping one surface alone makes a C caller's default and
1310// a Rust caller's default differ in a field cyclonedds puts on the wire. All
1311// three moved together instead.
1312//
1313// MEASURED consequences of the move, 2026-09-05 and re-read 2026-09-12:
1314// - cyclonedds: wire-identical. `nros_rmw_cyclonedds::make_dds_qos`
1315// (`qos.cpp`; the name here read `nros_rmw_cyclonedds_qos_apply` until
1316// phase-428 W9 went looking for it — no such symbol has ever existed)
1317// calls `dds_qset_liveliness` only when the kind is
1318// NOT the sentinel, and Cyclone's own reader/writer default IS
1319// `DDS_LIVELINESS_AUTOMATIC` — so both spellings put AUTOMATIC on the
1320// wire.
1321// - zenoh: invisible. `QosKeyExpr::to_qos_string` (`keyexpr.rs`)
1322// emits `"{rel}:{dur}:{hist},{depth}:,:,:,,"` (defaults elided, issue
1323// 1495) — the liveliness and lease
1324// positions are EMPTY for every profile, so the value never reaches a
1325// peer's graph parse.
1326// - xrce: invisible. `uxrQoS_t` has no liveliness field at all.
1327// The one live consequence was `required_policies()`, which set
1328// `LIVELINESS_AUTOMATIC` for presets upstream leaves unset — an over-demand,
1329// so it could only turn an acceptance into a rejection. It flipped no
1330// verdict while every `supported_qos_policies()` in the tree advertised the
1331// bit, and issue 1329 is exactly the change that stops them doing so: XRCE
1332// honours no liveliness and now says so, which would have refused every C
1333// application on that backend over a policy nobody asked for. The fix is to
1334// stop asking, not to let a backend claim what it does not implement.
1335//
1336// The `BEST_EFFORT` / `RELIABLE` / `QOS_PROFILE_PX4` shorthands still state
1337// AUTOMATIC. They mirror no upstream constant — their doc strings say so —
1338// so they are choices rather than deviations, and a caller picking one is
1339// asking for automatic liveliness on purpose.
1340//
1341// `rmw_qos_profile_unknown` HAS a counterpart now — `QOS_PROFILE_UNKNOWN` in the
1342// table below, added by this phase. The note that used to stand here declared it
1343// absent because three of our four policy enums had no `Unknown` variant to
1344// build it from; they have one now, which is what made the read-back able to
1345// say "I cannot report this policy" instead of echoing the request back as a
1346// grant.
1347
1348impl QoSProfile {
1349 /// Create new QoS settings with defaults (matches `QOS_PROFILE_DEFAULT`:
1350 /// Reliable, Volatile, KeepLast(10)).
1351 pub const fn new() -> Self {
1352 Self::QOS_PROFILE_DEFAULT
1353 }
1354
1355 // --- Static constructor methods (matching rclrs API) ---
1356
1357 /// Get the default QoS profile for ordinary topics
1358 pub const fn topics_default() -> Self {
1359 Self::QOS_PROFILE_DEFAULT
1360 }
1361
1362 /// The PX4 companion QoS profile ([`QOS_PROFILE_PX4`](Self::QOS_PROFILE_PX4))
1363 /// — use for `/fmu/out/*` subscriptions and `/fmu/in/*` publications against
1364 /// a `MicroXRCEAgent`.
1365 pub const fn px4() -> Self {
1366 Self::QOS_PROFILE_PX4
1367 }
1368
1369 /// Get the default QoS profile for sensor data topics
1370 pub const fn sensor_data_default() -> Self {
1371 Self::QOS_PROFILE_SENSOR_DATA
1372 }
1373
1374 /// Get the default QoS profile for services
1375 pub const fn services_default() -> Self {
1376 Self::QOS_PROFILE_SERVICES_DEFAULT
1377 }
1378
1379 /// The parameter services' profile — rclrs's
1380 /// `QoSProfile::parameter_services_default()`.
1381 ///
1382 /// `rmw_qos_profile_parameters`: KEEP_LAST(**1000**), RELIABLE, VOLATILE.
1383 /// The depth is the point — 1000 against the generic services profile's
1384 /// 10 — and it is what a tool setting many parameters at once depends on.
1385 ///
1386 /// **RENAMED in phase-417 G6, and the old spelling is GONE.** It was
1387 /// `parameters_default`, an ours-only word for a name rclrs has; the
1388 /// ledger carried the pair as `rust:QoSProfile::parameters_default`
1389 /// (`rename` — ours is the one that should change) and
1390 /// `rust:QoSProfile::parameter_services_default` (`gap`). Adding the
1391 /// upstream name beside ours would have been a FOURTH spelling of one
1392 /// preset (`QOS_PROFILE_PARAMETERS` and `QosPresets::PARAMETERS` are the
1393 /// other two) and would have left the `rename` row open for good. No
1394 /// deprecated forwarder: a stale call should fail on the IDENTIFIER rather
1395 /// than on a deprecation, which is stage 6 step B's rule and what the
1396 /// sibling `parameter_events_default` — already at rclrs's spelling —
1397 /// makes the pair consistent with.
1398 pub const fn parameter_services_default() -> Self {
1399 Self::QOS_PROFILE_PARAMETERS
1400 }
1401
1402 /// Get the default QoS profile for parameter events
1403 pub const fn parameter_events_default() -> Self {
1404 Self::QOS_PROFILE_PARAMETER_EVENTS
1405 }
1406
1407 /// Get the system default QoS profile
1408 pub const fn system_default() -> Self {
1409 Self::QOS_PROFILE_SYSTEM_DEFAULT
1410 }
1411
1412 /// Get the default QoS profile for action status topics
1413 pub const fn action_status_default() -> Self {
1414 Self::QOS_PROFILE_ACTION_STATUS_DEFAULT
1415 }
1416
1417 /// Get the default QoS profile for clock topics
1418 pub const fn clock_default() -> Self {
1419 Self::QOS_PROFILE_CLOCK
1420 }
1421
1422 // --- Builder methods ---
1423
1424 /// Set history to keep last N messages
1425 pub const fn keep_last(mut self, depth: u32) -> Self {
1426 self.history = QoSHistoryPolicy::KeepLast;
1427 self.depth = depth;
1428 self
1429 }
1430
1431 /// Set history to keep all messages
1432 pub const fn keep_all(mut self) -> Self {
1433 self.history = QoSHistoryPolicy::KeepAll;
1434 self
1435 }
1436
1437 /// Set reliability to reliable
1438 pub const fn reliable(mut self) -> Self {
1439 self.reliability = QoSReliabilityPolicy::Reliable;
1440 self
1441 }
1442
1443 /// Set reliability to best-effort
1444 pub const fn best_effort(mut self) -> Self {
1445 self.reliability = QoSReliabilityPolicy::BestEffort;
1446 self
1447 }
1448
1449 /// Set durability to volatile
1450 pub const fn volatile(mut self) -> Self {
1451 self.durability = QoSDurabilityPolicy::Volatile;
1452 self
1453 }
1454
1455 /// Set durability to transient local
1456 pub const fn transient_local(mut self) -> Self {
1457 self.durability = QoSDurabilityPolicy::TransientLocal;
1458 self
1459 }
1460
1461 /// Set reliability policy explicitly
1462 pub const fn reliability(mut self, policy: QoSReliabilityPolicy) -> Self {
1463 self.reliability = policy;
1464 self
1465 }
1466
1467 /// Set durability policy explicitly
1468 pub const fn durability(mut self, policy: QoSDurabilityPolicy) -> Self {
1469 self.durability = policy;
1470 self
1471 }
1472
1473 /// Set history policy explicitly
1474 pub const fn history(mut self, policy: QoSHistoryPolicy) -> Self {
1475 self.history = policy;
1476 self
1477 }
1478
1479 /// Set history depth explicitly
1480 pub const fn depth(mut self, depth: u32) -> Self {
1481 self.depth = depth;
1482 self
1483 }
1484
1485 /// Phase 282 (#145) — mark this publisher's samples "express": they
1486 /// bypass transport tx batching (sent immediately even when the batching
1487 /// knob is on). A transport hint for control-tier / latency-sensitive
1488 /// topics; ignored on subscriptions and by backends without batching.
1489 pub const fn tx_express(mut self, express: bool) -> Self {
1490 self.tx_express = express;
1491 self
1492 }
1493
1494 /// Get history depth (for backwards compatibility)
1495 pub const fn history_depth(&self) -> u8 {
1496 if self.depth > 255 {
1497 255
1498 } else {
1499 self.depth as u8
1500 }
1501 }
1502}
1503
1504/// Transport session configuration
1505#[derive(Debug, Clone)]
1506pub struct TransportConfig<'a> {
1507 /// Peer locator (e.g., "tcp/192.168.1.1:7447" or "serial//dev/ttyUSB0#baudrate=115200")
1508 pub locator: Option<&'a str>,
1509 /// Session mode: client, peer, or router
1510 pub mode: SessionMode,
1511 /// Additional transport properties (key-value pairs)
1512 ///
1513 /// These are passed through to the underlying transport backend.
1514 /// For zenoh-pico, recognized keys include:
1515 /// - `"multicast_scouting"` - Enable/disable multicast scouting (`"true"` or `"false"`)
1516 /// - `"scouting_timeout_ms"` - Scouting timeout in milliseconds
1517 /// - `"multicast_locator"` - Multicast group address
1518 /// - `"listen"` - Listen endpoint (e.g., `"tcp/0.0.0.0:0"`)
1519 /// - `"add_timestamp"` - Add timestamps to messages (`"true"` or `"false"`)
1520 pub properties: &'a [(&'a str, &'a str)],
1521 /// Node name for ROS 2 graph discovery liveliness token.
1522 ///
1523 /// Empty string (`""`) means no node-liveliness token is declared (preserves
1524 /// the pre-#104 behaviour). Non-empty causes the session to declare a
1525 /// `@ros2_lv/<domain>/<zid>/0/0/NN/%/<ns>/<node>` token on open.
1526 pub node_name: &'a str,
1527 /// Node namespace for the liveliness token (e.g., `""` or `"/ns1"`).
1528 ///
1529 /// Empty string is treated as root `"/"` by the keyexpr builder.
1530 pub namespace: &'a str,
1531 /// ROS 2 domain ID used in the liveliness token key expression.
1532 pub domain_id: u32,
1533}
1534
1535impl Default for TransportConfig<'_> {
1536 fn default() -> Self {
1537 Self {
1538 locator: None,
1539 mode: SessionMode::Client,
1540 properties: &[],
1541 node_name: "",
1542 namespace: "",
1543 domain_id: 0,
1544 }
1545 }
1546}
1547
1548/// Middleware-agnostic session configuration.
1549///
1550/// `RmwConfig` provides a uniform interface that any RMW backend can
1551/// interpret. Backends map the universal fields to their own connection
1552/// parameters and interpret `properties` for anything backend-specific.
1553///
1554/// # Examples
1555///
1556/// ```
1557/// use nros_rmw::{RmwConfig, SessionMode};
1558///
1559/// let config = RmwConfig {
1560/// locator: "tcp/192.168.1.1:7447",
1561/// mode: SessionMode::Client,
1562/// domain_id: 0,
1563/// node_name: "talker",
1564/// namespace: "",
1565/// properties: &[],
1566/// };
1567/// ```
1568#[derive(Debug, Clone, Copy)]
1569pub struct RmwConfig<'a> {
1570 /// Middleware-specific connection string.
1571 ///
1572 /// - zenoh: `"tcp/192.168.1.1:7447"` or `"udp/224.0.0.224:7447"`
1573 /// - XRCE-DDS: `"udp/192.168.1.1:2019"`
1574 pub locator: &'a str,
1575 /// Session mode (zenoh: client/peer; XRCE-DDS: always client)
1576 pub mode: SessionMode,
1577 /// ROS 2 domain ID (maps to DDS domain or zenoh key prefix)
1578 pub domain_id: u32,
1579 /// Node name (e.g., `"talker"`)
1580 pub node_name: &'a str,
1581 /// Node namespace (e.g., `""` or `"/ns1"`)
1582 pub namespace: &'a str,
1583 /// Backend-specific key/value properties.
1584 ///
1585 /// Uniform escape hatch for backend-specific tuning that doesn't fit
1586 /// the universal fields above. Each backend documents the keys it
1587 /// understands; unknown keys are ignored. Passing `&[]` is always
1588 /// valid.
1589 ///
1590 /// Examples:
1591 /// - zenoh: `"tls.root_ca"`, `"scouting.multicast.enabled"`
1592 /// - XRCE-DDS: `"agent_port"`, `"client_key"`
1593 pub properties: &'a [(&'a str, &'a str)],
1594}
1595
1596impl Default for RmwConfig<'_> {
1597 fn default() -> Self {
1598 Self {
1599 locator: "tcp/127.0.0.1:7447",
1600 mode: SessionMode::Client,
1601 domain_id: 0,
1602 node_name: "node",
1603 namespace: "",
1604 properties: &[],
1605 }
1606 }
1607}
1608
1609/// Locator transport protocol
1610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1611pub enum LocatorProtocol {
1612 /// TCP transport (e.g., "tcp/127.0.0.1:7447")
1613 Tcp,
1614 /// UDP transport (e.g., "udp/192.168.1.50:2019" — common for XRCE-DDS)
1615 Udp,
1616 /// Serial/UART transport (e.g., "serial//dev/ttyUSB0#baudrate=115200")
1617 Serial,
1618 /// Unknown protocol
1619 Unknown,
1620}
1621
1622/// Parse the protocol from a locator string
1623pub fn locator_protocol(locator: &str) -> LocatorProtocol {
1624 if locator.starts_with("tcp/") {
1625 LocatorProtocol::Tcp
1626 } else if locator.starts_with("udp/") {
1627 LocatorProtocol::Udp
1628 } else if locator.starts_with("serial/") {
1629 LocatorProtocol::Serial
1630 } else {
1631 LocatorProtocol::Unknown
1632 }
1633}
1634
1635/// Validate a locator string format.
1636///
1637/// Returns `Ok(())` if the locator is well-formed, or an error message describing
1638/// the problem. This provides early feedback before zenoh-pico or XRCE-DDS rejects
1639/// a bad locator.
1640///
1641/// Supported formats:
1642/// - TCP: `tcp/<host>:<port>` (e.g., `tcp/127.0.0.1:7447`)
1643/// - UDP: `udp/<host>:<port>` (e.g., `udp/192.168.1.50:2019`)
1644/// - Serial: `serial/<device>#baudrate=<rate>` (e.g., `serial//dev/ttyUSB0#baudrate=115200`)
1645pub fn validate_locator(locator: &str) -> Result<(), &'static str> {
1646 match locator_protocol(locator) {
1647 LocatorProtocol::Tcp => {
1648 let rest = &locator[4..]; // skip "tcp/"
1649 if !rest.contains(':') {
1650 return Err("TCP locator must contain host:port (e.g., tcp/127.0.0.1:7447)");
1651 }
1652 Ok(())
1653 }
1654 LocatorProtocol::Udp => {
1655 let rest = &locator[4..]; // skip "udp/"
1656 if !rest.contains(':') {
1657 return Err("UDP locator must contain host:port (e.g., udp/192.168.1.50:2019)");
1658 }
1659 Ok(())
1660 }
1661 LocatorProtocol::Serial => {
1662 let rest = &locator[7..]; // skip "serial/"
1663 if rest.is_empty() {
1664 return Err(
1665 "serial locator must specify device (e.g., serial//dev/ttyUSB0#baudrate=115200)",
1666 );
1667 }
1668 if !rest.contains("#baudrate=") {
1669 return Err(
1670 "serial locator must include #baudrate=RATE (e.g., serial//dev/ttyUSB0#baudrate=115200)",
1671 );
1672 }
1673 // Validate baudrate is numeric
1674 if let Some(baud_str) = rest.split("#baudrate=").nth(1) {
1675 let baud_str = baud_str.split('#').next().unwrap_or(baud_str);
1676 if baud_str.parse::<u32>().is_err() {
1677 return Err("serial baudrate must be a number");
1678 }
1679 }
1680 Ok(())
1681 }
1682 LocatorProtocol::Unknown => {
1683 Err("unknown locator protocol (expected tcp/, udp/, or serial/)")
1684 }
1685 }
1686}
1687
1688/// Session mode
1689#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1690pub enum SessionMode {
1691 /// Connect as client to a router
1692 #[default]
1693 Client,
1694 /// Connect as peer for peer-to-peer communication
1695 Peer,
1696}
1697
1698/// Transport session trait — the per-process anchor an RMW backend
1699/// gives to the executor.
1700///
1701/// # Threading
1702///
1703/// `&mut self` on every method means the executor serialises all
1704/// session calls onto a single thread. A backend may rely on this
1705/// — no internal locking is required for `create_*` / `close` /
1706/// `drive_io`. **Publisher / subscriber / service handles created
1707/// from the session, however, are typically used from worker
1708/// threads** and must carry their own synchronisation (see the
1709/// [`Publisher`] / [`Subscription`] trait docs).
1710///
1711/// # Calling pattern
1712///
1713/// 1. Open the session (backend-specific factory; not on this trait).
1714/// 2. `create_*` for every entity at startup. Creating entities mid-
1715/// flight after `drive_io` has run is allowed but not common.
1716/// 3. The executor calls `drive_io` periodically. Worker threads
1717/// publish / receive in parallel.
1718/// 4. `close` once at shutdown. Entities must be dropped first.
1719pub trait Session {
1720 /// RFC-0088 — the serialization format this backend speaks, as ROS 2's
1721 /// `rmw_get_serialization_format()` reports it ("One middleware can only
1722 /// have one encoding").
1723 ///
1724 /// Defaulted to CDR because every backend in tree except uORB speaks it,
1725 /// and a backend that speaks something else says so by overriding these
1726 /// two. They travel together: `SERIALIZATION_FORMAT` is the identity that
1727 /// crosses images (bridge config, tooling, the vtable slot) and
1728 /// `SERIALIZATION_FORMAT_ID` is the image-local discriminant used for the
1729 /// one-byte comparison a bridge makes at construction.
1730 const SERIALIZATION_FORMAT: &'static str =
1731 nros_serdes::format::SerializationFormatId::Cdr.as_str();
1732
1733 /// Image-local discriminant for [`Self::SERIALIZATION_FORMAT`]. Never
1734 /// persisted, never compared across images — see `nros_serdes::format`.
1735 const SERIALIZATION_FORMAT_ID: nros_serdes::format::SerializationFormatId =
1736 nros_serdes::format::SerializationFormatId::Cdr;
1737
1738 /// RFC-0088 — this session's serialization format, as ROS 2's
1739 /// `rmw_get_serialization_format()` reports it.
1740 ///
1741 /// **Per session, not per process.** ROS 2's function takes no handle
1742 /// because one process links one middleware; an `Executor::open_multi`
1743 /// image links two, so the answer must be asked of the session.
1744 ///
1745 /// **`None` means the backend has NOT SAID**, and that is not a synonym
1746 /// for `"cdr"`. The seam's contract is stated once, for the
1747 /// `get_serialization_format` slot in
1748 /// `packages/core/nros-rmw-abi/include/nros/rmw_vtable.h`: "NULL slot: the
1749 /// runtime answers NULL — it does NOT guess `cdr`." This method is the
1750 /// Rust face of that slot and carries the same absence. A `&'static str`
1751 /// could not: the only value it had to hand back was the literal `"cdr"`,
1752 /// indistinguishable at every call site from a backend that declared CDR.
1753 ///
1754 /// The default answers `Some(Self::SERIALIZATION_FORMAT)`, which is right
1755 /// for any backend whose format is a compile-time fact — it HAS said, in
1756 /// the const. A session that dispatches to a backend chosen at run time —
1757 /// the C-ABI adapter, whose vtable carries the answer — overrides this to
1758 /// ask the backend, and hands on the `None` a NULL slot gives it.
1759 fn serialization_format(&self) -> Option<&'static str> {
1760 Some(Self::SERIALIZATION_FORMAT)
1761 }
1762
1763 /// Error type for this session
1764 type Error;
1765 /// Publisher handle type
1766 type PublisherHandle;
1767 /// Subscription handle type
1768 type SubscriptionHandle;
1769 /// Service server handle type
1770 type ServiceHandle;
1771 /// Service client handle type
1772 type ClientHandle;
1773
1774 /// Create a publisher bound to this session.
1775 ///
1776 /// May allocate transport resources (zenoh declarations, DDS
1777 /// writers). Returns a handle that outlives the call but not the
1778 /// session — drop the handle before `close()`.
1779 fn create_publisher(
1780 &mut self,
1781 topic: &TopicInfo,
1782 qos: QoSProfile,
1783 ) -> Result<Self::PublisherHandle, Self::Error>;
1784
1785 /// Create a subscriber bound to this session.
1786 ///
1787 /// Subscribers may start receiving immediately after creation if
1788 /// the transport supports late-joining publishers. Late messages
1789 /// are buffered up to the QoS depth.
1790 fn create_subscription(
1791 &mut self,
1792 topic: &TopicInfo,
1793 qos: QoSProfile,
1794 ) -> Result<Self::SubscriptionHandle, Self::Error>;
1795
1796 /// Create a service server bound to this session. Replies are
1797 /// matched to requests by the sequence number returned from
1798 /// [`ServiceTrait::take_request`].
1799 ///
1800 /// `qos` is applied to both the request and reply endpoints (a
1801 /// service is two DDS topics; rmw uses one profile for both). The
1802 /// default is [`QoSProfile::services_default`]
1803 /// (RELIABLE+VOLATILE+KEEP_LAST(10)).
1804 ///
1805 /// The server receives through the backend's own inbox. A builtin family
1806 /// that sizes its own ring calls [`create_service_with_inbox`] instead.
1807 ///
1808 /// [`create_service_with_inbox`]: Session::create_service_with_inbox
1809 fn create_service(
1810 &mut self,
1811 service: &ServiceInfo,
1812 qos: QoSProfile,
1813 ) -> Result<Self::ServiceHandle, Self::Error>;
1814
1815 /// phase-461 W2 -- does this backend accept a caller-owned request ring
1816 /// ([`ServiceInboxSpec::Caller`])?
1817 ///
1818 /// A `const`, so the family that would bring one picks at COMPILE time
1819 /// and a `Caller` spec never degrades silently into the backend's own
1820 /// geometry (RFC-0052: a knob that is dropped is a knob that lies). The
1821 /// caller reads this and passes [`ServiceInboxSpec::Backend`] where it is
1822 /// `false`, which is how a family refuses at BUILD time rather than
1823 /// discovering at boot that its ring went nowhere -- `nros-node`'s
1824 /// `PARAM_INBOX` is the worked example.
1825 ///
1826 /// `false` is the right default and every backend in tree states it
1827 /// explicitly with its own reason, because "this backend has no caller
1828 /// ring" and "nobody thought about it" are different facts.
1829 ///
1830 /// [`create_service_with_inbox`]: Session::create_service_with_inbox
1831 const SUPPORTS_CALLER_INBOX: bool = false;
1832
1833 /// Create a service server that receives through `inbox`.
1834 ///
1835 /// phase-461 W2 / issue 1352 -- the ROS parameter services are six
1836 /// queryables per node and the REP-2002 lifecycle services five, and on a
1837 /// board they are most of the image's queryables. Their requests are
1838 /// bounded by the contract's declared parameters and their clients are
1839 /// sequential, so they want one small slot where a user service wants four
1840 /// large ones. The family that knows that bound brings the ring; the
1841 /// backend keeps the per-queryable header.
1842 ///
1843 /// The default body is [`Self::SUPPORTS_CALLER_INBOX`] `= false` spelled
1844 /// out: it serves [`ServiceInboxSpec::Backend`] and nothing else. It can
1845 /// only be reached with a `Caller` spec by a caller that ignored the
1846 /// const, so the `debug_assert` names that -- the REFUSAL itself is the
1847 /// caller's, at compile time, because a runtime error here would be a
1848 /// boot failure where a build failure was available (`nros-node`'s
1849 /// `PARAM_INBOX` is the worked example).
1850 ///
1851 /// A backend that overrides this must also state the const `true`.
1852 fn create_service_with_inbox(
1853 &mut self,
1854 service: &ServiceInfo,
1855 qos: QoSProfile,
1856 inbox: ServiceInboxSpec,
1857 ) -> Result<Self::ServiceHandle, Self::Error> {
1858 debug_assert!(
1859 inbox.caller().is_none(),
1860 "a caller-owned service inbox reached a backend whose \
1861 SUPPORTS_CALLER_INBOX is false; the caller must read the const"
1862 );
1863 self.create_service(service, qos)
1864 }
1865
1866 /// Create a service client bound to this session.
1867 ///
1868 /// `qos` is applied to both the request and reply endpoints (a
1869 /// service is two DDS topics; rmw uses one profile for both). The
1870 /// default is [`QoSProfile::services_default`]
1871 /// (RELIABLE+VOLATILE+KEEP_LAST(10)).
1872 fn create_client(
1873 &mut self,
1874 service: &ServiceInfo,
1875 qos: QoSProfile,
1876 ) -> Result<Self::ClientHandle, Self::Error>;
1877
1878 /// Close the session, releasing transport resources. All entity
1879 /// handles created from this session must already be dropped.
1880 fn close(&mut self) -> Result<(), Self::Error>;
1881
1882 /// Drive transport I/O (poll network, dispatch callbacks).
1883 ///
1884 /// Both zenoh-pico and XRCE-DDS are pull-based: they require the
1885 /// application to periodically call this method to read from the
1886 /// network socket and dispatch incoming messages to subscriber
1887 /// buffers.
1888 ///
1889 /// `timeout_ms` is the maximum time to wait for data (0 = non-blocking;
1890 /// negative values mean "block indefinitely" — see Phase 84.D7 for the
1891 /// planned migration to `core::time::Duration`).
1892 ///
1893 /// **Required**. There is no default body — both shipped backends
1894 /// (zenoh and XRCE) must drive I/O, and a silent no-op default was a
1895 /// trap for third-party implementers. If your backend genuinely
1896 /// receives data via OS callbacks (push-based) and has nothing to do
1897 /// here, return `Ok(())` explicitly.
1898 fn drive_io(&mut self, timeout_ms: i32) -> Result<(), Self::Error>;
1899
1900 /// Phase 109 — report which QoS policies the active backend
1901 /// honours. The runtime validates requested QoS against this mask
1902 /// at entity-create time and returns
1903 /// [`TransportError::IncompatibleQos`] if the requested profile
1904 /// includes a policy the backend can't enforce. **No silent
1905 /// downgrade.**
1906 ///
1907 /// # The mask is DERIVED, not authored — phase-428 W9
1908 ///
1909 /// Every bit an implementation sets must be backed by a
1910 /// `nros-qos-honours:` claim sited on the code that honours the policy,
1911 /// and `check-qos-mask-derivation` re-measures those claims on every
1912 /// push. Deleting the code deletes the claim, which is the property this
1913 /// item exists to buy: before W9 this was a hand-written constant, so a
1914 /// backend could advertise a policy it had never implemented and nothing
1915 /// noticed. Three did — `LIVELINESS_MANUAL_BY_NODE` was advertised by
1916 /// every backend in the tree and implemented by none.
1917 ///
1918 /// "Honours" means the backend reads the field and either APPLIES it or
1919 /// REFUSES a value it cannot serve. Passing the field on to someone else
1920 /// (a discovery keyexpr, a lowered C struct) is not honouring it, and the
1921 /// gate does not count it: see `nros-qos-discovery-only`.
1922 ///
1923 /// # The default is EMPTY, deliberately
1924 ///
1925 /// It returned [`QoSPolicyMask::CORE`] until phase-428 W9 — four policies
1926 /// granted to any implementation that had written no code at all, which
1927 /// is precisely the lie above with the trait as its author. An
1928 /// implementation now states what it honours or is told it honours
1929 /// nothing; the failure is loud (`IncompatibleQos` at create) rather than
1930 /// a silent downgrade at runtime, which is the direction this trait has
1931 /// chosen everywhere else.
1932 ///
1933 /// # A C backend answers through the vtable — issue 1329
1934 ///
1935 /// `CffiSession` is the one implementation that cannot answer from
1936 /// compile-time knowledge: which backend registered is a run-time fact. It
1937 /// returned the UNION of what any C backend it routes to honours, which
1938 /// over-claims for each of them; it now asks the backend through
1939 /// `nros_rmw_vtable_t::supported_qos_policies` and returns what it said. A
1940 /// NULL slot there carries the same meaning as this default — the backend
1941 /// has not said, so it honours nothing — deliberately, so the ABI and the
1942 /// trait give one answer to one question.
1943 fn supported_qos_policies(&self) -> QoSPolicyMask {
1944 QoSPolicyMask::NONE
1945 }
1946
1947 /// Phase 110.0 — backend's next internal-event deadline in
1948 /// milliseconds from now (lease keepalive, heartbeat, reader
1949 /// ACK-NACK timeout, etc.).
1950 ///
1951 /// The executor caps its `drive_io` timeout against
1952 /// `min(user_timeout, timer_deadline, this)` so quiet links don't
1953 /// wake early, see no user-visible work, and round-trip back into
1954 /// `drive_io`. Returns `None` when the backend has no internal
1955 /// deadlines or chooses not to expose them.
1956 ///
1957 /// Default `None` keeps existing backends working unchanged; opt-in
1958 /// per backend.
1959 fn next_deadline_ms(&self) -> Option<u32> {
1960 None
1961 }
1962
1963 /// Phase 124.B.1 — install (or clear, when `cb.is_none()`) the
1964 /// executor wake callback. The runtime calls this once per
1965 /// session after `open` with `cb` pointing at a runtime-owned
1966 /// function and `ctx` pointing at the executor's wake state.
1967 /// The backend stores `(cb, ctx)` in its per-session state and
1968 /// calls `cb(ctx)` whenever its transport notification path
1969 /// fires (datagram arrival, condvar wake, etc.) — the runtime
1970 /// cb does flag-write + condvar-signal atomically, so a
1971 /// `spin_once` blocked on the wake condvar resumes immediately
1972 /// instead of waiting for the next poll iteration.
1973 ///
1974 /// # Safety
1975 ///
1976 /// When `cb` is `Some`, `ctx` must remain valid until the callback is
1977 /// cleared or the session is closed. The backend may invoke `cb(ctx)` from
1978 /// its transport notification path.
1979 ///
1980 /// Default body: ignore the call. Poll-only backends (XRCE,
1981 /// bare-metal) leave the default in place; the executor still
1982 /// drains them on its deadline-bound cv-wait boundary.
1983 unsafe fn set_wake_callback(
1984 &mut self,
1985 cb: Option<unsafe extern "C" fn(ctx: *mut core::ffi::c_void)>,
1986 ctx: *mut core::ffi::c_void,
1987 ) {
1988 let _ = (cb, ctx);
1989 }
1990
1991 /// Phase 130.4 — does this backend actually honour
1992 /// [`set_wake_callback`]?
1993 ///
1994 /// `true` means the backend installs the callback and will
1995 /// fire it from its async notify path (worker thread, ISR,
1996 /// signalfd, …). `false` (the default) means
1997 /// `set_wake_callback` was a no-op — the executor must drive
1998 /// I/O for the caller's full timeout because no async wake
1999 /// will pre-empt it.
2000 ///
2001 /// The executor uses this to choose between the wake-primitive
2002 /// wait (`NodeWake::wait_ms` / `std::Condvar::wait_timeout_while`)
2003 /// and a direct `drive_io(timeout_ms)`. Poll-only backends
2004 /// (XRCE-DDS-Client, bare-metal smoltcp) return `false`;
2005 /// event-driven backends (zenoh-pico with an RX task that
2006 /// invokes the callback on packet arrival) return `true`.
2007 ///
2008 /// [`set_wake_callback`]: Self::set_wake_callback
2009 fn supports_wake_callback(&self) -> bool {
2010 false
2011 }
2012
2013 /// Phase 124.F.1 — session-level connectivity probe.
2014 ///
2015 /// Sends a wire-level round-trip probe and waits up to
2016 /// `timeout_ms`. `Ok(())` on reply, `Err(TransportError::Timeout)`
2017 /// on no reply, `Err(TransportError::Unsupported)` when the
2018 /// backend can't probe (DDS without participant introspection).
2019 /// Lesson from micro-ROS's `rmw_uros_ping_agent`.
2020 ///
2021 /// Default body: `Err(Unsupported)`. Backends with a native
2022 /// ping API (XRCE: `uxr_ping_agent_session`; zenoh, which has no
2023 /// round-trip ping, sends one `zpico_send_keep_alive` frame) opt in by
2024 /// overriding.
2025 fn ping_session(&mut self, timeout_ms: i32) -> Result<(), Self::Error>
2026 where
2027 Self::Error: From<TransportError>,
2028 {
2029 let _ = timeout_ms;
2030 Err(TransportError::Unsupported.into())
2031 }
2032
2033 /// phase-381 W3 — enumerate the nodes this session can see.
2034 ///
2035 /// A VISITOR, not a returned collection, because upstream's
2036 /// `rcutils_string_array_t` allocates two levels deep and there is no
2037 /// allocator at this seam. A caller-provided buffer is worse than it looks:
2038 /// the graph has no bound the CALLER can know. So the backend streams from
2039 /// state it already holds, peak extra RAM is one entry, and a caller with a
2040 /// bound stops early by returning `false`.
2041 ///
2042 /// `namespace` and `name` are ROS names. `enclave` is `None` where the
2043 /// backend does not track one — which is what lets this one method answer
2044 /// both `rmw_get_node_names` and `rmw_get_node_names_with_enclaves`.
2045 ///
2046 /// Every string is BORROWED for the duration of the call.
2047 ///
2048 /// **Must not block on the wire, and takes no timeout.** It reports what has
2049 /// ALREADY arrived, so the first call after startup legitimately returns a
2050 /// partial graph — a backend feeds its view from `drive_io`. Letting this
2051 /// block was considered and rejected: it would stall the executor's only
2052 /// thread inside an introspection call, on a runtime whose premise is that
2053 /// there is no other thread to do the work.
2054 ///
2055 /// Default body: `Err(Unsupported)` — a backend with no graph (XRCE) says
2056 /// so, and the runtime can tell that from an empty graph.
2057 fn get_node_names(
2058 &mut self,
2059 visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
2060 ) -> Result<(), Self::Error>
2061 where
2062 Self::Error: From<TransportError>,
2063 {
2064 let _ = visit;
2065 Err(TransportError::Unsupported.into())
2066 }
2067
2068 /// phase-381 W3 — how many publishers this session can see on `topic_name`.
2069 ///
2070 /// `topic_name` is a ROS name (`/chatter`); the backend mangles as needed.
2071 /// Same warm-up caveat as [`Self::get_node_names`]: a count reflects what
2072 /// has already been discovered, so it can be low right after startup and is
2073 /// never a proof of absence.
2074 ///
2075 /// Default body: `Err(Unsupported)` — distinct from `Ok(0)`, which claims
2076 /// there are none.
2077 fn count_publishers(&mut self, topic_name: &str) -> Result<usize, Self::Error>
2078 where
2079 Self::Error: From<TransportError>,
2080 {
2081 let _ = topic_name;
2082 Err(TransportError::Unsupported.into())
2083 }
2084
2085 /// phase-381 W3 — how many subscribers this session can see on `topic_name`.
2086 /// See [`Self::count_publishers`] for the caveats.
2087 fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, Self::Error>
2088 where
2089 Self::Error: From<TransportError>,
2090 {
2091 let _ = topic_name;
2092 Err(TransportError::Unsupported.into())
2093 }
2094
2095 /// phase-381 W3 — every topic, with the types published or subscribed on it.
2096 ///
2097 /// A visitor for the same reason as [`Self::get_node_names`], and one call
2098 /// per distinct NAME: the contract hands over a name and the types on it,
2099 /// so a topic carrying two types is one visit with two entries, not two
2100 /// visits.
2101 ///
2102 /// `types_count` may legitimately be 0 on a partially discovered graph —
2103 /// reporting the name without a type beats dropping it.
2104 ///
2105 /// Default body: `Err(Unsupported)`.
2106 fn get_topic_names_and_types(
2107 &mut self,
2108 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2109 ) -> Result<(), Self::Error>
2110 where
2111 Self::Error: From<TransportError>,
2112 {
2113 let _ = visit;
2114 Err(TransportError::Unsupported.into())
2115 }
2116
2117 /// phase-381 W3 — every service, with its types. As
2118 /// [`Self::get_topic_names_and_types`], over servers and clients.
2119 fn get_service_names_and_types(
2120 &mut self,
2121 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2122 ) -> Result<(), Self::Error>
2123 where
2124 Self::Error: From<TransportError>,
2125 {
2126 let _ = visit;
2127 Err(TransportError::Unsupported.into())
2128 }
2129
2130 /// phase-381 W3 — what ONE named node publishes / subscribes / serves /
2131 /// calls.
2132 ///
2133 /// `node_name` and `node_namespace` are ROS names; a node the graph has not
2134 /// discovered yields no visits, which is NOT an error — see
2135 /// [`Self::get_node_names`] for why an empty answer is "not seen yet".
2136 ///
2137 /// `kind` selects which of the four upstream `*_by_node` questions this
2138 /// answers. One method rather than four because the four differ ONLY by
2139 /// which entity kind they keep, and four trait methods would be four copies
2140 /// of one filter.
2141 ///
2142 /// The trait keeps the ABI's `subscriber` vocabulary because
2143 /// `rmw_get_subscriber_names_and_types_by_node` is what upstream rmw calls
2144 /// it and RFC-0054 makes the C headers the SSoT. The USER-facing spelling
2145 /// is per language and settled at that layer: rcl says `subscriber`, rclcpp
2146 /// and rclrs say `subscription`.
2147 ///
2148 /// Default body: `Err(Unsupported)`.
2149 fn get_names_and_types_by_node(
2150 &mut self,
2151 kind: GraphEntityKind,
2152 node_name: &str,
2153 node_namespace: &str,
2154 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2155 ) -> Result<(), Self::Error>
2156 where
2157 Self::Error: From<TransportError>,
2158 {
2159 let _ = (kind, node_name, node_namespace, visit);
2160 Err(TransportError::Unsupported.into())
2161 }
2162
2163 /// phase-381 W3 — the endpoints on one topic, with the QoS each GRANTED.
2164 ///
2165 /// `publishers` selects `rmw_get_publishers_info_by_topic` (`true`) or
2166 /// `rmw_get_subscriptions_info_by_topic` (`false`).
2167 ///
2168 /// The granted profile is the whole reason a consumer asks: "why is nothing
2169 /// arriving" is usually a QoS incompatibility, and the REQUESTED profile
2170 /// cannot answer it. A backend that cannot read a remote's granted QoS says
2171 /// so per field rather than echoing the request back — see
2172 /// `rmw_topic_endpoint_info_t`.
2173 ///
2174 /// Default body: `Err(Unsupported)`.
2175 fn get_endpoint_info_by_topic(
2176 &mut self,
2177 publishers: bool,
2178 topic_name: &str,
2179 visit: &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool,
2180 ) -> Result<(), Self::Error>
2181 where
2182 Self::Error: From<TransportError>,
2183 {
2184 let _ = (publishers, topic_name, visit);
2185 Err(TransportError::Unsupported.into())
2186 }
2187
2188 /// phase-467 Row 8 — install (or clear, when `callback` is `None`) the
2189 /// callback the backend fires when the ROS GRAPH changes.
2190 ///
2191 /// The Rust twin of `rmw_vtable_t::node_get_graph_guard_condition`, which
2192 /// is upstream's `rmw_node_get_graph_guard_condition` under the
2193 /// `set_wake_callback` shape: upstream RETURNS a guard condition the caller
2194 /// adds to a wait set, we have no wait set, and a guard condition here is
2195 /// an executor concept the backend never hands out. So the edge is PUSHED,
2196 /// not polled — and the ABI slot has said so since phase-376 W4, while
2197 /// this is the trait twin it never had.
2198 ///
2199 /// The callback is an EDGE and carries no payload. `count` is how many
2200 /// changes the backend coalesced, never a description of WHAT changed:
2201 /// delivering that would mean buffering the graph, which is the cache a
2202 /// small target cannot afford.
2203 ///
2204 /// **A backend must not fire it for a no-op.** The contract is "the graph
2205 /// this session can SEE differs from what it saw", which both real
2206 /// backends already computed and threw away — zenoh in
2207 /// `zpico_graph_set_apply`'s return value, Cyclone in
2208 /// `on_data_available`'s first parameter.
2209 ///
2210 /// # Safety
2211 ///
2212 /// When `callback` is `Some`, `user_data` must remain valid until the
2213 /// callback is cleared or the session is closed; the backend may invoke it
2214 /// from a transport or worker thread. **The installer owes the clear** —
2215 /// see `Executor::clear_graph_change_signal`, which is issue 1385's lesson
2216 /// applied before the hazard rather than after it.
2217 ///
2218 /// Default body: `Err(Unsupported)` — a backend with no graph (XRCE, uORB)
2219 /// says "cannot tell you" rather than accepting a callback it will never
2220 /// fire. That answer is also the capability PROBE the runtime uses: a
2221 /// `None` clear on a graphless backend returns `Unsupported`, so nothing
2222 /// else has to be asked and no handle is consumed before the answer.
2223 unsafe fn set_graph_change_callback(
2224 &mut self,
2225 callback: Option<unsafe extern "C" fn(user_data: *const core::ffi::c_void, count: usize)>,
2226 user_data: *const core::ffi::c_void,
2227 ) -> Result<(), Self::Error>
2228 where
2229 Self::Error: From<TransportError>,
2230 {
2231 let _ = (callback, user_data);
2232 Err(TransportError::Unsupported.into())
2233 }
2234}
2235
2236/// Which entity kind a `*_by_node` graph query keeps — phase-381 W3.
2237///
2238/// Named for upstream rmw's four `*_by_node` slots. `Subscriber` carries rmw's
2239/// word, not rclcpp's `subscription`; the user-facing spelling is chosen per
2240/// language one layer up.
2241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2242pub enum GraphEntityKind {
2243 Publisher,
2244 Subscriber,
2245 Service,
2246 Client,
2247}
2248
2249/// One discovered endpoint on a topic — phase-381 W3, the Rust view of
2250/// `rmw_topic_endpoint_info_t`.
2251///
2252/// Every string BORROWS from the backend's own state for the duration of the
2253/// visit. A caller that needs one afterwards copies it; that is what lets the
2254/// graph stream without an allocator.
2255#[derive(Debug, Clone)]
2256pub struct GraphEndpointInfo<'a> {
2257 /// Node that owns the endpoint.
2258 pub node_name: &'a str,
2259 /// That node's namespace.
2260 pub node_namespace: &'a str,
2261 /// Fully-qualified type on the wire, e.g. `"std_msgs/msg/Int32"`.
2262 pub topic_type: &'a str,
2263 /// `true` for a publisher, `false` for a subscription.
2264 pub is_publisher: bool,
2265 /// The endpoint's 24-byte identity; all-zero when the backend has none.
2266 pub endpoint_gid: [u8; 24],
2267}
2268
2269// No `qos` field, deliberately. The C seam carries one and fills it with the
2270// ABI's `*_UNKNOWN` sentinels, which is the contract for a policy a backend
2271// cannot determine (see `publisher_get_actual_qos`: write UNKNOWN and return
2272// OK; `UNSUPPORTED` means no read-back AT ALL).
2273//
2274// No backend can fill it today. zenoh's liveliness token carries the DECLARING
2275// side's own profile, not a negotiated grant, and this seam promises the
2276// granted one — reporting the declaration would be the plausible wrong answer
2277// the slot exists to avoid, since "why is nothing arriving" is usually a QoS
2278// mismatch and the requested profile cannot show it. Cyclone can read a real
2279// grant and will need this; adding a field to a Rust struct then is additive
2280// and not an ABI change, which is why carrying an always-`None` field now
2281// buys nothing.
2282
2283/// Bitmask of QoS policies a backend can honour. See
2284/// [`Session::supported_qos_policies`].
2285///
2286/// One bit per value `QoSProfile::required_policies` can request, and that
2287/// function is the definition of what each bit MEANS — `check-qos-mask-
2288/// derivation` reads the bit->field mapping out of its body rather than
2289/// restating it, so a policy that gains a bit here and no arm there is a gate
2290/// failure instead of a bit nothing can ever ask for.
2291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2292pub struct QoSPolicyMask(pub u32);
2293
2294impl QoSPolicyMask {
2295 pub const RELIABILITY: Self = Self(1 << 0);
2296 pub const DURABILITY_VOLATILE: Self = Self(1 << 1);
2297 pub const DURABILITY_TRANSIENT_LOCAL: Self = Self(1 << 2);
2298 pub const HISTORY: Self = Self(1 << 3);
2299 pub const DEPTH: Self = Self(1 << 4);
2300 pub const DEADLINE: Self = Self(1 << 5);
2301 pub const LIFESPAN: Self = Self(1 << 6);
2302 pub const LIVELINESS_AUTOMATIC: Self = Self(1 << 7);
2303 pub const LIVELINESS_MANUAL_BY_TOPIC: Self = Self(1 << 8);
2304 pub const LIVELINESS_MANUAL_BY_NODE: Self = Self(1 << 9);
2305 pub const LIVELINESS_LEASE: Self = Self(1 << 10);
2306 pub const AVOID_ROS_NAMESPACE_CONVENTIONS: Self = Self(1 << 11);
2307
2308 /// Honours nothing. The default answer for an implementation that has not
2309 /// said otherwise — phase-428 W9, see [`Session::supported_qos_policies`].
2310 pub const NONE: Self = Self(0);
2311
2312 /// The four policies every `QoSProfile` states a value for.
2313 ///
2314 /// NOT "policies every nano-ros backend implements", which is what this
2315 /// said until phase-428 W9 measured it: the zenoh shim honoured exactly
2316 /// none of the four, serialising all four into a discovery keyexpr and
2317 /// enforcing a build-time ring depth. The name describes the SHAPE of a
2318 /// profile — a caller who states nothing else still states these — and a
2319 /// backend still has to earn each bit.
2320 pub const CORE: Self =
2321 Self(Self::RELIABILITY.0 | Self::DURABILITY_VOLATILE.0 | Self::HISTORY.0 | Self::DEPTH.0);
2322
2323 /// Every policy bit paired with the name it PRINTS — the vocabulary the
2324 /// three languages name a refused policy in (phase-417 G7, ledger row
2325 /// `cpp:qos_policy_kind_to_cstr`).
2326 ///
2327 /// The name of a bit is the CONSTANT'S OWN IDENTIFIER, never an
2328 /// independently chosen word. That is what keeps this from becoming the
2329 /// parallel table this campaign has paid for twice — the RMW parity map's
2330 /// 28 stale slots and the layout gate's three authored type names — and it
2331 /// is what `check-qos-mask-derivation`'s R8 measures: the entries here must
2332 /// be exactly the `pub const … = Self(1 << n)` bits above, each paired with
2333 /// its own spelling. A bit added above and not here is a gate failure, not
2334 /// a policy that silently prints as nothing.
2335 ///
2336 /// Uppercase because that is upstream's convention for the same strings:
2337 /// `rclcpp::qos_policy_kind_to_cstr` returns `"DURABILITY"`, `"DEADLINE"`,
2338 /// `"LIVELINESS"` and the rest.
2339 ///
2340 /// `NONE` and `CORE` are deliberately absent: they are not policies, they
2341 /// are the empty mask and a named union, and neither can be the answer to
2342 /// "which policy was refused".
2343 ///
2344 /// The name is stored as a [`core::ffi::CStr`] and not as a `&str`,
2345 /// because the C entry point (`rclc_qos_policy_kind_to_cstr`) needs the
2346 /// trailing NUL and a `&str` does not carry one. Storing the `&str` form
2347 /// and NUL-terminating it in `nros-c` was the first shape here, and it put
2348 /// the twelve identifiers in a second file — which is the drift this row
2349 /// exists to avoid. One table, two readings: `policy_name` slices the NUL
2350 /// off for Rust, the C entry point hands the pointer straight over.
2351 pub const NAMED: &'static [(Self, &'static core::ffi::CStr)] = &[
2352 (Self::RELIABILITY, c"RELIABILITY"),
2353 (Self::DURABILITY_VOLATILE, c"DURABILITY_VOLATILE"),
2354 (
2355 Self::DURABILITY_TRANSIENT_LOCAL,
2356 c"DURABILITY_TRANSIENT_LOCAL",
2357 ),
2358 (Self::HISTORY, c"HISTORY"),
2359 (Self::DEPTH, c"DEPTH"),
2360 (Self::DEADLINE, c"DEADLINE"),
2361 (Self::LIFESPAN, c"LIFESPAN"),
2362 (Self::LIVELINESS_AUTOMATIC, c"LIVELINESS_AUTOMATIC"),
2363 (
2364 Self::LIVELINESS_MANUAL_BY_TOPIC,
2365 c"LIVELINESS_MANUAL_BY_TOPIC",
2366 ),
2367 (
2368 Self::LIVELINESS_MANUAL_BY_NODE,
2369 c"LIVELINESS_MANUAL_BY_NODE",
2370 ),
2371 (Self::LIVELINESS_LEASE, c"LIVELINESS_LEASE"),
2372 (
2373 Self::AVOID_ROS_NAMESPACE_CONVENTIONS,
2374 c"AVOID_ROS_NAMESPACE_CONVENTIONS",
2375 ),
2376 ];
2377
2378 /// The name of the ONE policy this mask is, as a C string.
2379 ///
2380 /// `None` for the empty mask and for a mask carrying more than one bit:
2381 /// both are real answers a diagnostic must be able to give. "The backend
2382 /// did not say which" is not a policy name, and printing a neighbouring
2383 /// one would be the plausible wrong answer this whole family exists to
2384 /// refuse.
2385 ///
2386 /// Borrowed from a table in flash: no allocator, no formatting, reaches a
2387 /// bare-metal target, and these are the exact bytes the C and C++ entry
2388 /// points hand back.
2389 pub const fn policy_name_cstr(self) -> Option<&'static core::ffi::CStr> {
2390 let mut i = 0;
2391 while i < Self::NAMED.len() {
2392 if Self::NAMED[i].0.0 == self.0 {
2393 return Some(Self::NAMED[i].1);
2394 }
2395 i += 1;
2396 }
2397 None
2398 }
2399
2400 /// [`Self::policy_name_cstr`] as a Rust string — the same bytes without
2401 /// the terminator.
2402 pub fn policy_name(self) -> Option<&'static str> {
2403 // Every entry is an ASCII literal, so this cannot fail; `ok()` rather
2404 // than `expect()` because a panic in a diagnostic path is worse than
2405 // the diagnostic being absent.
2406 self.policy_name_cstr().and_then(|c| c.to_str().ok())
2407 }
2408
2409 /// The policies in `self` that `supported` does not carry.
2410 pub const fn missing_from(self, supported: Self) -> Self {
2411 Self(self.0 & !supported.0)
2412 }
2413
2414 /// The lowest-numbered policy bit set here, or [`Self::NONE`].
2415 ///
2416 /// Which single policy a multi-policy refusal REPORTS. Lowest bit rather
2417 /// than "any": a stable choice means the same profile against the same
2418 /// backend names the same policy on every run and in every language, so a
2419 /// test can assert it and two surfaces cannot disagree about which one to
2420 /// mention first.
2421 pub const fn lowest_policy(self) -> Self {
2422 Self(self.0 & self.0.wrapping_neg())
2423 }
2424
2425 /// `true` if `self` contains every policy in `other`.
2426 pub const fn contains(self, other: Self) -> bool {
2427 self.0 & other.0 == other.0
2428 }
2429
2430 /// Bitwise OR of two masks.
2431 pub const fn union(self, other: Self) -> Self {
2432 Self(self.0 | other.0)
2433 }
2434}
2435
2436impl core::ops::BitOr for QoSPolicyMask {
2437 type Output = Self;
2438 fn bitor(self, rhs: Self) -> Self {
2439 self.union(rhs)
2440 }
2441}
2442
2443impl core::ops::BitOrAssign for QoSPolicyMask {
2444 fn bitor_assign(&mut self, rhs: Self) {
2445 self.0 |= rhs.0;
2446 }
2447}
2448
2449impl QoSProfile {
2450 /// Does any policy in this profile carry its `Unknown` sentinel?
2451 ///
2452 /// True only for a profile that came out of a `*_get_actual_qos`
2453 /// read-back on a backend that could not report every policy — never for
2454 /// one an application built. [`validate_against`](Self::validate_against)
2455 /// refuses such a profile at create time, so "I cannot say" cannot be
2456 /// laundered into a request.
2457 pub const fn has_unknown(&self) -> bool {
2458 matches!(self.reliability, QoSReliabilityPolicy::Unknown)
2459 || matches!(self.durability, QoSDurabilityPolicy::Unknown)
2460 || matches!(self.history, QoSHistoryPolicy::Unknown)
2461 || matches!(self.liveliness_kind, QoSLivelinessPolicy::Unknown)
2462 }
2463
2464 /// Compute the set of QoS policies actually requested by this profile.
2465 ///
2466 /// Zero-valued time fields and `LivelinessKind::None` count as "not
2467 /// requesting" the corresponding policy — the cheap default.
2468 ///
2469 /// issue 0829 — the four `CORE` bits (reliability, durability, history,
2470 /// depth) used to be added UNCONDITIONALLY, on the reasoning that every
2471 /// nano-ros backend honours them. That is true and still not the right
2472 /// test: this function answers *what did the caller ASK FOR*, and a
2473 /// `SYSTEM_DEFAULT` policy asks for nothing. Starting from `CORE` made
2474 /// `QOS_PROFILE_SYSTEM_DEFAULT` demand all four, so a backend that could
2475 /// not honour one would reject the profile with `IncompatibleQos` — for
2476 /// requesting nothing. The pattern was already here for the extended
2477 /// policies three lines down (a zero `deadline_ms` declines its bit); it
2478 /// just never reached the four CORE ones.
2479 ///
2480 /// This RELAXES the mask, so it can only turn a rejection into an
2481 /// acceptance, never the reverse.
2482 ///
2483 /// It used to say it flipped no verdict "because every
2484 /// `supported_qos_policies` impl in the tree returns at least `CORE`".
2485 /// That stopped being true in phase-428 W9: the trait default is
2486 /// [`QoSPolicyMask::NONE`] now, so an implementation that claims nothing
2487 /// is refused on the first CORE bit a caller states — which is the whole
2488 /// point, and which makes THIS function's sentinel handling the thing
2489 /// standing between `QOS_PROFILE_SYSTEM_DEFAULT` and a hard refusal.
2490 pub fn required_policies(&self) -> QoSPolicyMask {
2491 let mut mask = QoSPolicyMask(0);
2492 if self.reliability != QoSReliabilityPolicy::SystemDefault {
2493 mask |= QoSPolicyMask::RELIABILITY;
2494 }
2495 if self.history != QoSHistoryPolicy::SystemDefault {
2496 mask |= QoSPolicyMask::HISTORY;
2497 }
2498 // Depth's sentinel is a VALUE, not a variant: `DEPTH_SYSTEM_DEFAULT`
2499 // is 0, the same 0 that a `KEEP_ALL` profile carries to mean "depth is
2500 // not used here" (`QoSProfile::keep_all()` leaves whatever depth the
2501 // profile had, and a caller building one from scratch leaves it 0).
2502 // Both mean the caller did not ask for a depth, so both decline the
2503 // bit. No NAMED preset is `KEEP_ALL` any more — phase-428 W10;
2504 // `QOS_PROFILE_PARAMETER_EVENTS` used to be the exemplar here and was
2505 // the defect, not the example.
2506 if self.depth != DEPTH_SYSTEM_DEFAULT {
2507 mask |= QoSPolicyMask::DEPTH;
2508 }
2509 match self.durability {
2510 QoSDurabilityPolicy::SystemDefault => {}
2511 QoSDurabilityPolicy::Volatile => mask |= QoSPolicyMask::DURABILITY_VOLATILE,
2512 QoSDurabilityPolicy::TransientLocal => {
2513 mask |= QoSPolicyMask::DURABILITY_TRANSIENT_LOCAL
2514 }
2515 // `Unknown` is a READ-BACK sentinel, never a request, so there is
2516 // no capability bit to demand for it — there is nothing a backend
2517 // could support. Refusing it belongs one level up, in
2518 // `validate_against`, where it is refused for every policy at once
2519 // rather than smuggled in as an unsatisfiable mask bit.
2520 QoSDurabilityPolicy::Unknown => {}
2521 }
2522 // Phase-301 (issue 0241): DURATION_INFINITE_MS reads the same as 0
2523 // (infinite = no check) at every duration check site.
2524 if self.deadline_ms != 0 && self.deadline_ms != DURATION_INFINITE_MS {
2525 mask |= QoSPolicyMask::DEADLINE;
2526 }
2527 if self.lifespan_ms != 0 && self.lifespan_ms != DURATION_INFINITE_MS {
2528 mask |= QoSPolicyMask::LIFESPAN;
2529 }
2530 match self.liveliness_kind {
2531 QoSLivelinessPolicy::None => {}
2532 QoSLivelinessPolicy::Automatic => mask |= QoSPolicyMask::LIVELINESS_AUTOMATIC,
2533 QoSLivelinessPolicy::ManualByTopic => mask |= QoSPolicyMask::LIVELINESS_MANUAL_BY_TOPIC,
2534 QoSLivelinessPolicy::ManualByNode => mask |= QoSPolicyMask::LIVELINESS_MANUAL_BY_NODE,
2535 // See the durability arm.
2536 QoSLivelinessPolicy::Unknown => {}
2537 }
2538 if self.liveliness_lease_ms != 0 && self.liveliness_lease_ms != DURATION_INFINITE_MS {
2539 mask |= QoSPolicyMask::LIVELINESS_LEASE;
2540 }
2541 if self.avoid_ros_namespace_conventions {
2542 mask |= QoSPolicyMask::AVOID_ROS_NAMESPACE_CONVENTIONS;
2543 }
2544 mask
2545 }
2546
2547 /// Returns `Err(TransportError::IncompatibleQos(policy))` if any policy
2548 /// this profile requires is missing from the backend's `supported` mask.
2549 /// Used at entity-create time to enforce the **no silent
2550 /// degradation** contract.
2551 ///
2552 /// **The error NAMES the policy** (phase-417 G7). This computed the
2553 /// offending bit and discarded it until then, returning a bare
2554 /// `IncompatibleQos` — so every one of the six `create_*` paths in
2555 /// `nros-node` refused with the one fact the caller needed missing, while
2556 /// `rmw_vtable.h` told the reader the opposite. Which bit is reported when
2557 /// several are missing is [`QoSPolicyMask::lowest_policy`]'s choice, and it
2558 /// is stable so a test can assert it.
2559 pub fn validate_against(&self, supported: QoSPolicyMask) -> Result<(), TransportError> {
2560 // issue 1437 — `UNKNOWN` is what a `*_get_actual_qos` read-back writes
2561 // for a policy it cannot report. It is an ABSENCE, so it is meaningless
2562 // as a REQUEST, and the way it reaches a create call is a caller
2563 // feeding a read-back profile straight back in — the exact round trip
2564 // that would otherwise turn "I cannot say" into a silently different
2565 // entity. Refused here, once, for all four policies.
2566 //
2567 // The refusal names NO policy, and that is not laziness: the mask's
2568 // bits are CAPABILITIES a backend declares, and no backend could ever
2569 // declare one that makes "I cannot say" servable — `required_policies`
2570 // has no bit to set, so there is none to report. `policy_name` already
2571 // answers `None` for the empty mask, which the diagnostic renders as
2572 // the honest "the profile is not a request", rather than naming
2573 // `DURABILITY_VOLATILE` for a durability that is not volatile.
2574 if self.has_unknown() {
2575 return Err(TransportError::IncompatibleQos(QoSPolicyMask::NONE));
2576 }
2577 let missing = self.required_policies().missing_from(supported);
2578 if missing.0 == 0 {
2579 Ok(())
2580 } else {
2581 Err(TransportError::IncompatibleQos(missing.lowest_policy()))
2582 }
2583 }
2584
2585 /// Replace every `SYSTEM_DEFAULT` field with the backend's own answer.
2586 ///
2587 /// issue 0829 — a backend calls this at its create entry, **before**
2588 /// anything is derived from the profile (see [`QoSSystemDefaults`] for why
2589 /// the ordering matters on the zenoh path). Fields the caller DID state
2590 /// are left exactly as they are: this resolves an absence, it never
2591 /// overrides a request.
2592 ///
2593 /// Idempotent, and safe to call on a fully concrete profile — a profile
2594 /// with no sentinel in it is returned unchanged.
2595 #[must_use]
2596 pub const fn resolve_system_default(mut self, defaults: &QoSSystemDefaults) -> Self {
2597 if matches!(self.reliability, QoSReliabilityPolicy::SystemDefault) {
2598 self.reliability = defaults.reliability;
2599 }
2600 if matches!(self.durability, QoSDurabilityPolicy::SystemDefault) {
2601 self.durability = defaults.durability;
2602 }
2603 if matches!(self.history, QoSHistoryPolicy::SystemDefault) {
2604 self.history = defaults.history;
2605 }
2606 if self.depth == DEPTH_SYSTEM_DEFAULT {
2607 self.depth = defaults.depth;
2608 }
2609 self
2610 }
2611
2612 /// `true` if any field is still the `SYSTEM_DEFAULT` sentinel.
2613 ///
2614 /// Depth is deliberately NOT part of this test: a `KEEP_ALL` profile
2615 /// legitimately carries depth 0 forever (any profile a caller builds with
2616 /// [`QoSProfile::keep_all`] and never gave a depth to — no NAMED preset is
2617 /// `KEEP_ALL` since phase-428 W10), and a backend that resolves the
2618 /// sentinel depth to 0 — XRCE does, on purpose — leaves a resolved profile
2619 /// reading 0 here. This asks about the
2620 /// three POLICY fields, where the sentinel is a distinct variant and so
2621 /// cannot be confused with a stated value.
2622 #[must_use]
2623 pub const fn has_unresolved_system_default(&self) -> bool {
2624 matches!(self.reliability, QoSReliabilityPolicy::SystemDefault)
2625 || matches!(self.durability, QoSDurabilityPolicy::SystemDefault)
2626 || matches!(self.history, QoSHistoryPolicy::SystemDefault)
2627 }
2628}
2629
2630/// Publisher trait for sending messages.
2631///
2632/// The name is rclrs's, the role is the backend seam: rclrs's `Publisher<T>` is a
2633/// typed, shared handle (`Arc<PublisherState<T>>`); this trait is what an RMW
2634/// backend implements, raw bytes in. The typed handle a node hands an application
2635/// is one type with no `Arc` (`nros::EmbeddedPublisher<M>`). Loaning exists:
2636/// `EmbeddedRawPublisher::try_loan` / `loan`, zero-copy through `SlotLending`
2637/// when the `lending` feature is on and the backend supports it.
2638///
2639/// # Threading
2640///
2641/// `&self` on `publish_raw` — implementors must allow concurrent
2642/// publishes from multiple threads. Internal locking (or lock-free
2643/// queues) is the backend's responsibility.
2644///
2645/// # Buffer ownership
2646///
2647/// `data` in `publish_raw` is borrowed for the duration of the call.
2648/// The backend must either send it inline or copy into its own
2649/// buffer before returning — the slice is invalid after the call.
2650///
2651/// # Blocking
2652///
2653/// `publish_raw` is expected to be non-blocking on best-effort QoS
2654/// and bounded-blocking on reliable QoS (waiting for outbound queue
2655/// space). Backends should *not* block waiting for ack from a
2656/// matched subscriber.
2657pub trait Publisher {
2658 /// Error type for publish operations
2659 type Error;
2660
2661 /// Publish a CDR-serialised message.
2662 ///
2663 /// Returns once the message has been handed to the transport
2664 /// (queued or fired-and-forgotten depending on QoS). Does **not**
2665 /// wait for delivery.
2666 fn publish_raw(&self, data: &[u8]) -> Result<(), Self::Error>;
2667
2668 /// Phase 128.F.4 — publish with an opaque attachment block.
2669 ///
2670 /// `attachment` rides alongside the payload at the wire layer.
2671 /// Receivers can read it back via
2672 /// [`Subscription::take_serialized_with_attachment`].
2673 ///
2674 /// Primary use case: cross-RMW bridges stamp a `bridge_origin`
2675 /// tag (the source backend's RMW name) so a paired return
2676 /// bridge can deterministically drop echoed frames.
2677 ///
2678 /// Default body delegates to [`publish_raw`](Self::publish_raw)
2679 /// and discards the attachment — backends that do not natively
2680 /// carry attachments (XRCE today, DDS without a user-data hook)
2681 /// see no change. Backends with native attachment support
2682 /// (zenoh-pico's `z_publisher_put_options::attachment`,
2683 /// Cyclone DDS user-data) override to write the bytes onto the
2684 /// wire.
2685 fn publish_raw_with_attachment(
2686 &self,
2687 data: &[u8],
2688 _attachment: &[u8],
2689 ) -> Result<(), Self::Error> {
2690 self.publish_raw(data)
2691 }
2692
2693 /// Phase 124.E.1 — streamed publish.
2694 ///
2695 /// `size_cb` reports the total payload length once; `chunk_cb`
2696 /// fills the slot in chunks. What it saves is the CALLER-side
2697 /// buffer holding a whole serialised message — which is STACK,
2698 /// not `.bss`: `EmbeddedPublisher::publish` serialises into a
2699 /// per-CALL array of `DEFAULT_TX_BUF`, and this trait's own
2700 /// fallback below stages on the caller's stack too. There is no
2701 /// per-publisher staging buffer and none of it is in `.bss`;
2702 /// issue 0782 corrected that fiction on the C slot and left this
2703 /// copy of it standing (phase-428 W12's F2, one layer over).
2704 ///
2705 /// Default body — the **staging-buffer fallback** (124.E.2).
2706 /// Asks `size_cb` for the total length, fills a stack-allocated
2707 /// `[u8; STAGE_CAP]` via `chunk_cb`, then forwards to
2708 /// `publish_raw`. `STAGE_CAP` is the 4096 constant declared in
2709 /// the body, not a knob: this said `NROS_MAX_STREAM_CHUNK` until
2710 /// 2026-09-12 and no such setting has ever existed in any form,
2711 /// so a reader looking for it found nothing and had no way to
2712 /// learn the limit. Returns `Err(BufferTooSmall)` if the total
2713 /// exceeds that cap so the caller can drop back to a regular
2714 /// `publish_raw` with a heap-sized buffer.
2715 ///
2716 /// Concrete backends opt in by overriding to stream straight
2717 /// into the network buffer (zenoh: write into the zenoh-pico
2718 /// outbound buffer; XRCE: micro-CDR streaming APIs).
2719 ///
2720 /// # Safety
2721 ///
2722 /// The caller must ensure `user_ctx` is valid for every invocation of
2723 /// `size_cb` and `chunk_cb` during this call, and that both callbacks obey
2724 /// their out-pointer contracts.
2725 ///
2726 /// `size_cb` and `chunk_cb` may be called from the same thread
2727 /// that called `publish_streamed`. Backends MUST NOT defer the
2728 /// calls past the function return; the caller's `user_ctx`
2729 /// pointer is only guaranteed valid for the duration of the
2730 /// call.
2731 unsafe fn publish_streamed(
2732 &self,
2733 size_cb: unsafe extern "C" fn(out_total_len: *mut usize, user_ctx: *mut core::ffi::c_void),
2734 chunk_cb: unsafe extern "C" fn(
2735 out_buf: *mut u8,
2736 cap: usize,
2737 out_written: *mut usize,
2738 user_ctx: *mut core::ffi::c_void,
2739 ),
2740 user_ctx: *mut core::ffi::c_void,
2741 ) -> Result<(), Self::Error>
2742 where
2743 Self::Error: From<TransportError>,
2744 {
2745 /// Default staging-buffer cap. Stack-allocated, so embedded
2746 /// callers don't pay for it unless they actually invoke this
2747 /// fallback. 4 KiB matches typical RTPS frag-size +
2748 /// micro-XRCE message ceilings.
2749 const STAGE_CAP: usize = 4096;
2750
2751 let mut total = 0usize;
2752 // SAFETY: caller's contract on `size_cb` matches our trait
2753 // doc — fire once with a writable `*mut usize` slot.
2754 unsafe { size_cb(&mut total as *mut usize, user_ctx) };
2755 if total > STAGE_CAP {
2756 return Err(TransportError::BufferTooSmall.into());
2757 }
2758 let mut stage = [0u8; STAGE_CAP];
2759 let mut written_so_far = 0usize;
2760 while written_so_far < total {
2761 let mut chunk_written = 0usize;
2762 let remaining = total - written_so_far;
2763 // SAFETY: `chunk_cb` writes ≤ `cap` bytes to
2764 // `out_buf` and reports the count via `out_written`.
2765 unsafe {
2766 chunk_cb(
2767 stage.as_mut_ptr().add(written_so_far),
2768 remaining,
2769 &mut chunk_written as *mut usize,
2770 user_ctx,
2771 );
2772 }
2773 if chunk_written == 0 {
2774 // Caller signalled EOF early — treat the partial
2775 // write as a malformed sequence; reporting it as
2776 // BufferTooSmall keeps the surface tight without
2777 // adding a new variant.
2778 return Err(TransportError::BufferTooSmall.into());
2779 }
2780 written_so_far += chunk_written;
2781 }
2782 self.publish_raw(&stage[..total])
2783 }
2784
2785 /// Publish a typed message (serializes automatically)
2786 fn publish<M: RosMessage>(&self, msg: &M, buf: &mut [u8]) -> Result<(), Self::Error> {
2787 use nros_core::CdrWriter;
2788
2789 let mut writer = CdrWriter::new_with_header(buf).map_err(|_| self.buffer_error())?;
2790 msg.serialize(&mut writer)
2791 .map_err(|_| self.serialization_error())?;
2792 let len = writer.position();
2793 self.publish_raw(&buf[..len])
2794 }
2795
2796 /// Return a buffer-too-small error (implementation specific)
2797 fn buffer_error(&self) -> Self::Error;
2798
2799 /// Return a serialization error (implementation specific)
2800 fn serialization_error(&self) -> Self::Error;
2801
2802 /// Phase 108 — `true` if the backend can generate this event for
2803 /// this publisher. Default returns `false`; backends override per
2804 /// supported event kind.
2805 ///
2806 /// Only [`EventKind::LivelinessLost`](crate::event::EventKind::LivelinessLost) and
2807 /// [`EventKind::OfferedDeadlineMissed`](crate::event::EventKind::OfferedDeadlineMissed) are publisher-side events;
2808 /// other kinds always return `false` here.
2809 fn supports_event(&self, _kind: crate::event::EventKind) -> bool {
2810 false
2811 }
2812
2813 /// Phase 108 — register a callback fired when the named status
2814 /// event occurs. `deadline_ms` applies to
2815 /// [`EventKind::OfferedDeadlineMissed`](crate::event::EventKind::OfferedDeadlineMissed) only; ignored otherwise.
2816 /// Default impl returns the backend's "unsupported"-shaped error.
2817 ///
2818 /// # Safety
2819 ///
2820 /// `cb` and `user_ctx` must remain valid for the entity's
2821 /// lifetime. Caller (typically `nros-node`'s typed wrapper) is
2822 /// responsible for keeping the closure / context arena alive.
2823 unsafe fn register_event_callback(
2824 &mut self,
2825 _kind: crate::event::EventKind,
2826 _deadline_ms: u32,
2827 _cb: crate::event::EventCallback,
2828 _user_ctx: *mut core::ffi::c_void,
2829 ) -> Result<(), Self::Error> {
2830 Err(self.unsupported_event_error())
2831 }
2832
2833 /// Phase 108 — backend's error variant for "this event kind is
2834 /// not supported." Default impl reuses `serialization_error()`
2835 /// since most backends share an `Unsupported` variant; backends
2836 /// override if they have a distinct `Unsupported` mapping.
2837 fn unsupported_event_error(&self) -> Self::Error {
2838 self.serialization_error()
2839 }
2840
2841 /// Phase 109 — assert this publisher's liveliness manually.
2842 /// Required for publishers configured with
2843 /// [`QoSLivelinessPolicy::ManualByTopic`] / [`ManualByNode`](QoSLivelinessPolicy::ManualByNode);
2844 /// nothing to do for the other kinds.
2845 ///
2846 /// **`Ok(())` MEANS THE ASSERTION REACHED THE WIRE, or that there was
2847 /// nothing to assert** (the phase-467 RMW gap-closure design study's
2848 /// Row 7). Under a kind for which [`QoSLivelinessPolicy::is_manual`]
2849 /// holds, a backend that cannot renew a lease a PEER can observe must
2850 /// answer `Unsupported` — an `Ok(())` from a call that sent nothing is
2851 /// the silently-wrong shape RFC-0089 Part I refuses, and it is what the
2852 /// zenoh shim answered until this study. A backend is free to keep a
2853 /// LOCAL record of the call (zenoh drives its own `LivelinessLost`
2854 /// watchdog off one) and still report `Unsupported`; the return value is
2855 /// about what a peer can see, not about what we noted down.
2856 ///
2857 /// The default body is `Ok(())`, which is correct only for a publisher
2858 /// whose kind is not manual — a backend that can be created with a manual
2859 /// kind must override.
2860 fn assert_liveliness(&self) -> Result<(), Self::Error> {
2861 Ok(())
2862 }
2863
2864 /// This publisher's own global identifier — upstream
2865 /// `rmw_get_gid_for_publisher`, the vtable's `get_gid_for_publisher`.
2866 ///
2867 /// The phase-467 RMW gap-closure design study's Q1. **Not the same
2868 /// question as [`MessageInfo::publisher_gid`](nros_core::MessageInfo::publisher_gid)**,
2869 /// which is the identity of whoever sent a message we RECEIVED. Since
2870 /// that study's Q1(a) the two are one TYPE — both
2871 /// [`PUBLISHER_GID_SIZE`](nros_core::PUBLISHER_GID_SIZE) wide, nothing
2872 /// truncated, no mapping — and since issue 1495 they are one VALUE on
2873 /// zenoh and Cyclone: a take reports the gid this publisher's own
2874 /// `get_gid` reports, so the two compare. (XRCE and uORB answer neither.)
2875 ///
2876 /// **How many of the 24 bytes MEAN anything is a backend property.** A
2877 /// backend whose identity is narrower zero-extends through
2878 /// [`pad_publisher_gid`](nros_core::pad_publisher_gid) rather than
2879 /// leaving the tail undefined, so two gids naming one entity compare
2880 /// equal. Gids from DIFFERENT backends are never comparable.
2881 ///
2882 /// The default is `Err(Unsupported)`, never a fabricated or all-zero id:
2883 /// an all-zero gid is what an unwritten buffer holds, so handing one back
2884 /// would make "this backend has no identity for this publisher"
2885 /// indistinguishable from an answer. That is the same NULL-slot
2886 /// discipline the ABI header states for `get_serialization_format`.
2887 fn get_gid(&self) -> Result<[u8; nros_core::PUBLISHER_GID_SIZE], Self::Error>
2888 where
2889 Self::Error: From<TransportError>,
2890 {
2891 Err(TransportError::Unsupported.into())
2892 }
2893
2894 /// How many subscriptions are MATCHED to this publisher — upstream
2895 /// `rmw_publisher_count_matched_subscriptions`, the vtable's
2896 /// `publisher_count_matched_subscriptions`. Issue 1643.
2897 ///
2898 /// Matched means QoS-compatible with THIS publisher, which is upstream's
2899 /// question and a stronger one than "how many subscriptions are on the
2900 /// topic" (the graph's `count_subscribers`): an incompatible peer is on
2901 /// the topic and not matched.
2902 ///
2903 /// The default is `Err(Unsupported)`, never `Ok(0)` — `0` means "nobody
2904 /// is listening", and a backend that cannot tell must not say that. A
2905 /// caller with a weaker fallback (the node API's topic-wide graph count)
2906 /// takes it on `Unsupported`.
2907 fn count_matched_subscriptions(&self) -> Result<usize, Self::Error>
2908 where
2909 Self::Error: From<TransportError>,
2910 {
2911 Err(TransportError::Unsupported.into())
2912 }
2913
2914 /// The profile this entity is ACTUALLY running — what the backend GRANTED,
2915 /// not what the call site requested.
2916 ///
2917 /// Upstream's `rmw_publisher_get_actual_qos`. The two differ whenever a
2918 /// backend serves something other than what it was asked for, and the
2919 /// difference is exactly what answers "why is nothing arriving": a
2920 /// RELIABLE reader does not match a BEST_EFFORT writer, and a clamped
2921 /// history depth drops samples the caller believed were kept.
2922 ///
2923 /// **Per-field, and an unreportable field is an ABSENCE.** A backend that
2924 /// determines four policies and not the fifth returns the four and leaves
2925 /// the fifth at its `Unknown` variant. Never echo the request into a field
2926 /// you did not read back — that is the inverse of this method's meaning,
2927 /// and it is what the six C-ABI slots did until this phase.
2928 ///
2929 /// The default is [`QoSProfile::QOS_PROFILE_UNKNOWN`] — every field an
2930 /// absence — which is the honest answer for a backend with no read-back at
2931 /// all (XRCE has none; zenoh-pico's QoS is per-message flags). It is NOT
2932 /// the request, and it is NOT `QOS_PROFILE_DEFAULT`.
2933 fn actual_qos(&self) -> QoSProfile {
2934 QoSProfile::QOS_PROFILE_UNKNOWN
2935 }
2936}
2937
2938/// Subscription trait for receiving messages.
2939///
2940/// # Threading
2941///
2942/// `&mut self` on `take_serialized` — the executor takes exclusive
2943/// ownership of the subscriber for the duration of a receive. A
2944/// backend that wants to allow concurrent receives must split into
2945/// per-thread sub-handles internally.
2946///
2947/// # Buffer ownership
2948///
2949/// `buf` is caller-owned. The implementation copies the next ready
2950/// message into `buf` and returns the byte count. The caller may
2951/// re-use or drop `buf` immediately after the call.
2952///
2953/// # Blocking
2954///
2955/// `take_serialized` is **non-blocking**: returns `Ok(None)` (or
2956/// equivalent for backends that map empty into a zero-length read)
2957/// when no message is ready. Use [`Session::drive_io`] to wait for
2958/// data; never sleep inside `take_serialized`.
2959pub trait Subscription {
2960 /// Error type for receive operations
2961 type Error;
2962
2963 /// Check if data is available without consuming it.
2964 ///
2965 /// Non-destructive — does not advance the receive cursor.
2966 /// Conservative default returns `true` (always assume data may
2967 /// be available); backends should override with a real check
2968 /// to avoid spurious receive attempts.
2969 fn has_data(&self) -> bool {
2970 true
2971 }
2972
2973 /// Try to receive one message into `buf`.
2974 ///
2975 /// Non-blocking. On success returns `Ok(Some(len))` where `len`
2976 /// is the byte count written into `buf[..len]`. Returns
2977 /// `Ok(None)` if no message is ready. If `buf` is too small the
2978 /// backend may either truncate (and document it) or return an
2979 /// error (preferred).
2980 fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, Self::Error>;
2981
2982 /// Issue 1612 — how many bytes the sample the most recent take REFUSED
2983 /// as too big for the caller's buffer needed.
2984 ///
2985 /// Read it right after a take returns `BufferTooSmall`; a later take may
2986 /// overwrite it. `None` means the backend does not know the size (the
2987 /// default, and the honest answer for one that never measured it), never
2988 /// "zero bytes" — a zero-byte sample fits every buffer, so no refusal can
2989 /// need zero.
2990 ///
2991 /// A query beside the error rather than a payload on
2992 /// `TransportError::BufferTooSmall`: that variant is the same word a dozen
2993 /// unrelated paths return (arena exhaustion, a short reply buffer), so
2994 /// giving it a field would make every one of them invent a size. Only a
2995 /// take ever has one, and only a take is asked.
2996 fn refused_sample_len(&self) -> Option<usize> {
2997 None
2998 }
2999
3000 /// Phase 128.F.4 — receive with attachment bytes alongside the
3001 /// payload.
3002 ///
3003 /// On success returns `Ok(Some((payload_len, attachment_len)))`
3004 /// with the payload written into `buf[..payload_len]` and the
3005 /// attachment (if any) written into
3006 /// `att_buf[..attachment_len]`. `attachment_len == 0` means the
3007 /// incoming sample carried no attachment.
3008 ///
3009 /// Default body falls back to [`take_serialized`](Self::take_serialized)
3010 /// and reports a 0-length attachment. Backends with native
3011 /// attachment support override to populate `att_buf`. Cross-RMW
3012 /// bridges use the attachment to read the `bridge_origin` tag
3013 /// stamped by the sending side.
3014 fn take_serialized_with_attachment(
3015 &mut self,
3016 buf: &mut [u8],
3017 _att_buf: &mut [u8],
3018 ) -> Result<Option<(usize, usize)>, Self::Error> {
3019 match self.take_serialized(buf)? {
3020 Some(len) => Ok(Some((len, 0))),
3021 None => Ok(None),
3022 }
3023 }
3024
3025 /// Phase 124.D.1 — burst-take.
3026 ///
3027 /// Drain up to `max_msgs` queued samples into the contiguous
3028 /// `buf` block in one call, with the i-th sample at
3029 /// `buf[i * per_msg_cap .. i * per_msg_cap + out_lens[i]]`.
3030 /// Returns the number of messages actually delivered. Partial
3031 /// drains MUST report the count, not error out.
3032 ///
3033 /// Default body loop-drives `take_serialized` so callers can
3034 /// commit to the batched API regardless of backend support.
3035 /// Concrete backends opt in by overriding with a native batch
3036 /// take (zenoh queue drain, `dds_take(max_samples)`).
3037 fn take_sequence(
3038 &mut self,
3039 buf: &mut [u8],
3040 per_msg_cap: usize,
3041 max_msgs: usize,
3042 out_lens: &mut [usize],
3043 ) -> Result<usize, Self::Error> {
3044 if per_msg_cap == 0 || max_msgs == 0 {
3045 return Ok(0);
3046 }
3047 let limit = max_msgs.min(out_lens.len());
3048 let mut count = 0;
3049 for i in 0..limit {
3050 let slot = &mut buf[i * per_msg_cap..(i + 1) * per_msg_cap];
3051 // Issue 0971 — NOT `take_serialized(slot)?`. The `?` propagates the
3052 // error and DISCARDS `count`, which is precisely what the doc
3053 // comment above forbids ("Partial drains MUST report the count, not
3054 // error out") and what `c3af8c1d1` removed from the two concrete
3055 // implementations. A backend that does not override this body got
3056 // the original defect back through the default.
3057 //
3058 // The shape is the one that fix established: a drain that has
3059 // already taken messages reports the COUNT, and the error is
3060 // delivered by the NEXT call — the caller sees every message it was
3061 // handed, then the reason the drain stopped. With nothing taken
3062 // there is no count to protect, so the error goes out immediately.
3063 //
3064 // Parking is per-implementation state, which a default body has no
3065 // place to keep. So it does the half it can do correctly: it
3066 // returns the partial count and lets the error surface on the
3067 // caller's next `take_serialized`, which is where an unconsumed
3068 // backend error still sits.
3069 match self.take_serialized(slot) {
3070 Ok(Some(len)) => {
3071 out_lens[i] = len;
3072 count += 1;
3073 }
3074 Ok(None) => break,
3075 Err(e) => {
3076 if count == 0 {
3077 return Err(e);
3078 }
3079 break;
3080 }
3081 }
3082 }
3083 Ok(count)
3084 }
3085
3086 /// Try to receive a typed message (non-blocking)
3087 fn take<M: RosMessage>(&mut self, buf: &mut [u8]) -> Result<Option<M>, Self::Error> {
3088 use nros_core::CdrReader;
3089
3090 match self.take_serialized(buf)? {
3091 Some(len) => {
3092 let mut reader = CdrReader::new_with_header(&buf[..len])
3093 .map_err(|_| self.deserialization_error())?;
3094 let msg = M::deserialize(&mut reader).map_err(|_| self.deserialization_error())?;
3095 Ok(Some(msg))
3096 }
3097 None => Ok(None),
3098 }
3099 }
3100
3101 /// Process the received message in-place without copying.
3102 ///
3103 /// Calls `f` with a reference to the raw CDR bytes in the subscriber's
3104 /// internal receive buffer, avoiding a copy into a caller-provided buffer.
3105 /// While `f` executes the buffer is exclusively borrowed — any messages
3106 /// arriving from the transport during that time are dropped to prevent
3107 /// data races.
3108 ///
3109 /// Returns `Ok(true)` if a message was available and `f` was called,
3110 /// `Ok(false)` if no message was available.
3111 ///
3112 /// **Default body**: returns `Err(MessageTooLarge)` — the old default
3113 /// silently truncated anything larger than 1 KB into a stack buffer,
3114 /// which broke large messages with no diagnostic. Backends must
3115 /// override this with a real zero-copy path if they advertise support
3116 /// for `process_raw_in_place`; callers that hit the default should
3117 /// use `take_serialized` with a caller-sized buffer instead.
3118 fn process_raw_in_place(&mut self, f: impl FnOnce(&[u8])) -> Result<bool, Self::Error>
3119 where
3120 Self::Error: From<TransportError>,
3121 {
3122 let _ = f;
3123 Err(TransportError::MessageTooLarge.into())
3124 }
3125
3126 /// Whether this backend implements the in-place dispatch methods
3127 /// ([`process_raw_in_place`](Subscription::process_raw_in_place) /
3128 /// [`process_raw_in_place_with_info`](Subscription::process_raw_in_place_with_info))
3129 /// with a real zero-copy borrow.
3130 ///
3131 /// The executor consults this at subscription registration to choose the
3132 /// **in-place** arena dispatch (borrow + deserialize from the backend slot, no
3133 /// arena buffer) over the **buffered** dispatch (copy into an arena buffer
3134 /// first). Backends that leave the in-place methods at their unsupported
3135 /// default return `false` (the default) and keep the buffered path. (RFC-0038,
3136 /// Phase 231 Wave 0.2.)
3137 fn supports_process_in_place(&self) -> bool {
3138 false
3139 }
3140
3141 /// In-place processing variant that also surfaces publisher metadata.
3142 ///
3143 /// Same borrow contract as
3144 /// [`process_raw_in_place`](Subscription::process_raw_in_place): `f` receives
3145 /// the raw CDR bytes plus the parsed [`MessageInfo`](nros_core::MessageInfo)
3146 /// — the co-located attachment (publisher GID / sequence / source timestamp),
3147 /// or `None` when no attachment was present — for the duration of the call;
3148 /// the slot is released after `f` returns. `Ok(true)` = a message was
3149 /// available and `f` was called; `Ok(false)` = none ready.
3150 ///
3151 /// **Default body**: returns the unsupported error (mirrors
3152 /// `process_raw_in_place`). Backends that advertise in-place support override
3153 /// this with a real zero-copy path; callers that hit the default should use
3154 /// the buffered [`take_serialized_with_info`](Subscription::take_serialized_with_info)
3155 /// path instead. (RFC-0038, Phase 231 Wave 0.1.)
3156 fn process_raw_in_place_with_info(
3157 &mut self,
3158 f: impl FnOnce(&[u8], Option<nros_core::MessageInfo>),
3159 ) -> Result<bool, Self::Error>
3160 where
3161 Self::Error: From<TransportError>,
3162 {
3163 let _ = f;
3164 Err(TransportError::MessageTooLarge.into())
3165 }
3166
3167 /// Try to receive raw data along with publisher metadata.
3168 ///
3169 /// When available, [`MessageInfo`](nros_core::MessageInfo) contains
3170 /// the publisher's GID (Global Identifier) and source timestamp,
3171 /// extracted from a transport-level attachment on the incoming message.
3172 ///
3173 /// Returns `Ok(Some((len, info)))` if data is available, where:
3174 /// - `len` is the number of bytes written to the buffer
3175 /// - `info` is the parsed publisher metadata (if attachment was present)
3176 ///
3177 /// Default: delegates to [`take_serialized`](Subscription::take_serialized) with no info.
3178 fn take_serialized_with_info(
3179 &mut self,
3180 buf: &mut [u8],
3181 ) -> Result<Option<(usize, Option<nros_core::MessageInfo>)>, Self::Error> {
3182 self.take_serialized(buf)
3183 .map(|opt| opt.map(|len| (len, None)))
3184 }
3185
3186 /// Try to receive raw data with E2E safety validation (CRC + sequence tracking).
3187 ///
3188 /// Returns `Ok(Some((len, status)))` if data is available, where:
3189 /// - `len` is the number of bytes written to the buffer
3190 /// - `status` is the integrity validation result
3191 ///
3192 /// Default: delegates to `take_serialized` with no CRC info.
3193 #[cfg(feature = "safety-e2e")]
3194 fn take_validated(
3195 &mut self,
3196 buf: &mut [u8],
3197 ) -> Result<Option<(usize, crate::IntegrityStatus)>, Self::Error> {
3198 self.take_serialized(buf).map(|opt| {
3199 opt.map(|len| {
3200 (
3201 len,
3202 crate::IntegrityStatus {
3203 gap: 0,
3204 duplicate: false,
3205 crc_valid: None,
3206 },
3207 )
3208 })
3209 })
3210 }
3211
3212 /// Register an async waker to be notified when data arrives.
3213 ///
3214 /// Called from `Future::poll()` implementations to store the waker.
3215 /// The transport backend calls `waker.wake()` from its receive callback
3216 /// when new data is available, enabling event-driven async without
3217 /// busy-polling.
3218 ///
3219 /// Default: no-op (backends that don't support waking simply ignore this).
3220 fn register_waker(&self, _waker: &core::task::Waker) {}
3221
3222 /// Return a deserialization error (implementation specific)
3223 fn deserialization_error(&self) -> Self::Error;
3224
3225 /// Phase 108 — `true` if the backend can generate this event for
3226 /// this subscriber. Default returns `false`; backends override per
3227 /// supported event kind.
3228 ///
3229 /// Subscription-side event kinds:
3230 /// [`EventKind::LivelinessChanged`](crate::event::EventKind::LivelinessChanged),
3231 /// [`EventKind::RequestedDeadlineMissed`](crate::event::EventKind::RequestedDeadlineMissed),
3232 /// [`EventKind::MessageLost`](crate::event::EventKind::MessageLost).
3233 /// Publisher kinds always return `false` here.
3234 fn supports_event(&self, _kind: crate::event::EventKind) -> bool {
3235 false
3236 }
3237
3238 /// Phase 108 — register a callback fired when the named status
3239 /// event occurs. `deadline_ms` applies to
3240 /// [`EventKind::RequestedDeadlineMissed`](crate::event::EventKind::RequestedDeadlineMissed) only; ignored otherwise.
3241 /// Default impl returns the backend's "unsupported"-shaped error.
3242 ///
3243 /// # Safety
3244 ///
3245 /// `cb` and `user_ctx` must remain valid for the entity's
3246 /// lifetime. Caller (typically `nros-node`'s typed wrapper) is
3247 /// responsible for keeping the closure / context arena alive.
3248 unsafe fn register_event_callback(
3249 &mut self,
3250 _kind: crate::event::EventKind,
3251 _deadline_ms: u32,
3252 _cb: crate::event::EventCallback,
3253 _user_ctx: *mut core::ffi::c_void,
3254 ) -> Result<(), Self::Error> {
3255 Err(self.unsupported_event_error())
3256 }
3257
3258 /// Phase 108 — backend's error variant for "this event kind is
3259 /// not supported." Default reuses `deserialization_error()` for
3260 /// backends that don't have a distinct `Unsupported` mapping.
3261 fn unsupported_event_error(&self) -> Self::Error {
3262 self.deserialization_error()
3263 }
3264 /// The profile this entity is ACTUALLY running — what the backend GRANTED,
3265 /// not what the call site requested.
3266 ///
3267 /// Upstream's `rmw_subscription_get_actual_qos`. The two differ whenever a
3268 /// backend serves something other than what it was asked for, and the
3269 /// difference is exactly what answers "why is nothing arriving": a
3270 /// RELIABLE reader does not match a BEST_EFFORT writer, and a clamped
3271 /// history depth drops samples the caller believed were kept.
3272 ///
3273 /// **Per-field, and an unreportable field is an ABSENCE.** A backend that
3274 /// determines four policies and not the fifth returns the four and leaves
3275 /// the fifth at its `Unknown` variant. Never echo the request into a field
3276 /// you did not read back — that is the inverse of this method's meaning,
3277 /// and it is what the six C-ABI slots did until this phase.
3278 ///
3279 /// The default is [`QoSProfile::QOS_PROFILE_UNKNOWN`] — every field an
3280 /// absence — which is the honest answer for a backend with no read-back at
3281 /// all (XRCE has none; zenoh-pico's QoS is per-message flags). It is NOT
3282 /// the request, and it is NOT `QOS_PROFILE_DEFAULT`.
3283 fn actual_qos(&self) -> QoSProfile {
3284 QoSProfile::QOS_PROFILE_UNKNOWN
3285 }
3286
3287 /// How many publishers are MATCHED to this subscription — upstream
3288 /// `rmw_subscription_count_matched_publishers`, the vtable's
3289 /// `subscription_count_matched_publishers`. Issue 1643.
3290 ///
3291 /// The subscription half of
3292 /// [`Publisher::count_matched_subscriptions`], which states the contract:
3293 /// matched, not merely discovered; `Err(Unsupported)` by default, never a
3294 /// fabricated `0`.
3295 fn count_matched_publishers(&self) -> Result<usize, Self::Error>
3296 where
3297 Self::Error: From<TransportError>,
3298 {
3299 Err(TransportError::Unsupported.into())
3300 }
3301}
3302
3303/// Service request from a client
3304pub struct ServiceRequest<'a> {
3305 /// Raw request data (CDR encoded)
3306 pub data: &'a [u8],
3307 /// Sequence number for request/response matching
3308 pub sequence_number: i64,
3309}
3310
3311// ============================================================================
3312// Phase 99 — zero-copy raw API: SlotLending / SlotBorrowing
3313// ============================================================================
3314//
3315// Backends that can lend a slot directly into their outbound buffer
3316// (zenoh-pico w/ unstable-zenoh-api, XRCE-DDS via uxr_prepare_output_stream,
3317// full DDS w/ SHM transport) implement these traits. Backends that cannot
3318// (uORB, default zenoh-pico) do NOT impl them — `EmbeddedRawPublisher` then
3319// falls back to its per-publisher arena and memcpys at commit time. Both
3320// paths land at `Publisher::publish_raw` for the actual wire write; only
3321// the user-side copy is eliminated when lending is available.
3322//
3323// Selection is **compile-time** via the `lending` Cargo feature. Each
3324// backend crate forwards its own `lending` feature to `nros-rmw/lending`
3325// when it can satisfy the trait. nros-node's `rmw-lending` aggregates.
3326// User opting `nros/rmw-lending` w/ a non-lending backend (e.g. uORB)
3327// gets a clear compile error from the unsatisfied trait bound on the
3328// concrete `RmwPublisher`.
3329
3330/// Backend can lend a writable slot into its outbound buffer.
3331///
3332/// The returned slot's lifetime is tied to `&self`; user fills it in
3333/// place, then calls [`commit_slot`](Self::commit_slot) to publish.
3334/// Dropping the slot without commit is a no-op (slot returned to free
3335/// pool); concurrent loan attempts that would exceed backend capacity
3336/// return [`TransportError::WouldBlock`] — never block.
3337#[cfg(feature = "lending")]
3338pub trait SlotLending: Publisher {
3339 /// Backend-owned writable slot. Holds a `&'a mut [u8]` and any
3340 /// state needed for commit_slot.
3341 type Slot<'a>: AsMut<[u8]> + 'a
3342 where
3343 Self: 'a;
3344
3345 /// Reserve a writable slot of `len` bytes from the backend's
3346 /// outbound buffer. Returns `Ok(None)` if the backend has no slot
3347 /// available (full); never blocks.
3348 fn try_lend_slot(&self, len: usize) -> Result<Option<Self::Slot<'_>>, Self::Error>;
3349
3350 /// Commit a previously-lent slot. Consumes the slot and triggers
3351 /// the actual wire write. Returns `Err` on backend send failure;
3352 /// the slot's bytes are lost in that case (caller must re-lend +
3353 /// re-fill to retry).
3354 fn commit_slot(&self, slot: Self::Slot<'_>) -> Result<(), Self::Error>;
3355}
3356
3357/// Backend can lend a read-only view into its receive buffer.
3358///
3359/// The returned view's lifetime is tied to `&mut self` (subscriber-
3360/// exclusive); dropping the view releases any backend lock and lets
3361/// the next message advance into the buffer.
3362#[cfg(feature = "lending")]
3363pub trait SlotBorrowing: Subscription {
3364 /// Backend-owned read-only view. Holds a `&'a [u8]` and any state
3365 /// needed to release the borrow on Drop.
3366 type View<'a>: AsRef<[u8]> + 'a
3367 where
3368 Self: 'a;
3369
3370 /// Try to borrow the next available message in place. Returns
3371 /// `Ok(None)` if no message is ready; never blocks.
3372 fn try_borrow(&mut self) -> Result<Option<Self::View<'_>>, Self::Error>;
3373}
3374
3375/// Issues 1612 / 1632 — the size a refused take needed, as a drop log prints
3376/// it: the number, or `?` when unknown. No allocator.
3377///
3378/// [`RefusedLen::for_error`] is the one rule for WHICH refusal may carry a
3379/// size: only `BufferTooSmall`, the caller's-buffer refusal a take measures.
3380/// `MessageTooLarge` is the backend's own staging buffer refusing before any
3381/// take saw a length, so a size recorded by an earlier `BufferTooSmall` must
3382/// not be printed beside it. The C++ subscription drop log and `nros-node`'s
3383/// arena drop log both read it from here, so the two cannot disagree.
3384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3385pub struct RefusedLen(pub Option<usize>);
3386
3387impl RefusedLen {
3388 /// The size `recorded` (a `refused_*_len()` reading) for `err`, or
3389 /// unknown when `err` is not a refusal a take measured.
3390 pub fn for_error(err: &TransportError, recorded: Option<usize>) -> Self {
3391 Self(match err {
3392 TransportError::BufferTooSmall => recorded,
3393 _ => None,
3394 })
3395 }
3396}
3397
3398impl core::fmt::Display for RefusedLen {
3399 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3400 match self.0 {
3401 Some(n) => write!(f, "{n}"),
3402 None => f.write_str("?"),
3403 }
3404 }
3405}
3406
3407/// Service server trait for handling requests.
3408///
3409/// # Threading
3410///
3411/// `&mut self` on `take_request` and `send_response` — the executor
3412/// owns the server while a request is being handled. Handler bodies
3413/// run synchronously on the executor thread; long handlers should
3414/// dispatch work to a worker queue and reply later via the recorded
3415/// `sequence_number`.
3416///
3417/// # Calling pattern
3418///
3419/// 1. Executor calls `take_request(buf)`.
3420/// 2. If `Some(req)` returned, decode, run handler, encode reply.
3421/// 3. `send_response(req.sequence_number, &reply_buf)`.
3422///
3423/// `sequence_number` is the canonical request → reply correlation
3424/// token; backends derive it from the wire-level metadata (zenoh
3425/// query id, DDS sample identity).
3426pub trait ServiceTrait {
3427 /// Error type for service operations
3428 type Error;
3429
3430 /// Check if a request is available without consuming it.
3431 ///
3432 /// Non-destructive. Default returns `true` (always assume one
3433 /// may be available); backends should override with a real
3434 /// check.
3435 fn has_request(&self) -> bool {
3436 true
3437 }
3438
3439 /// Phase 122.3.c.6.e — register a `Waker` for event-driven
3440 /// service servers. Mirrors the matching method on
3441 /// `SubscriberTrait` / `ClientTrait`. Backends that
3442 /// surface incoming-request notifications wake `waker` when
3443 /// `has_request()` flips true. Default: no-op (backends without
3444 /// wake support ignore — caller falls back to polling).
3445 fn register_waker(&self, _waker: &core::task::Waker) {}
3446
3447 /// Try to receive a service request into `buf` (non-blocking).
3448 ///
3449 /// On success returns a `ServiceRequest` that borrows from
3450 /// `buf`. The borrow is released when the returned struct is
3451 /// dropped — typically before `send_response` is called, since
3452 /// `send_response` takes `&mut self`.
3453 fn take_request<'a>(
3454 &mut self,
3455 buf: &'a mut [u8],
3456 ) -> Result<Option<ServiceRequest<'a>>, Self::Error>;
3457
3458 /// Issue 1632 — how many bytes the request the most recent
3459 /// [`take_request`](Self::take_request) REFUSED as too big for the
3460 /// caller's buffer needed.
3461 ///
3462 /// [`Subscription::refused_sample_len`]'s contract, for the service
3463 /// server: read it right after a take returns `BufferTooSmall`; `None`
3464 /// means the backend does not know (the default), never "zero bytes".
3465 fn refused_request_len(&self) -> Option<usize> {
3466 None
3467 }
3468
3469 /// Send a reply for the given sequence number. Non-blocking
3470 /// from the application's perspective; the backend may queue
3471 /// the reply for transport-level transmission.
3472 fn send_response(&mut self, sequence_number: i64, data: &[u8]) -> Result<(), Self::Error>;
3473
3474 /// Handle a service request with typed messages
3475 fn handle_request<S: RosService>(
3476 &mut self,
3477 req_buf: &mut [u8],
3478 reply_buf: &mut [u8],
3479 handler: impl FnOnce(&S::Request) -> S::Reply,
3480 ) -> Result<bool, Self::Error>
3481 where
3482 Self::Error: From<TransportError>,
3483 {
3484 use nros_core::{CdrReader, CdrWriter};
3485
3486 // First, try to receive a request and extract necessary data.
3487 // Capture the data slice's offset within `req_buf` so we can
3488 // re-borrow it after the `ServiceRequest` (which holds a
3489 // borrow into `req_buf`) is dropped. Some backends prepend a
3490 // header (DDS: 8-byte sequence number) and place the CDR
3491 // payload at a non-zero offset in the buffer; others (zenoh)
3492 // put it at offset 0. Reading from offset 0 unconditionally
3493 // would feed the prefix bytes to the CDR deserializer and
3494 // silently corrupt the request.
3495 let buf_start = req_buf.as_ptr() as usize;
3496 let (data_offset, data_len, sequence_number) = match self.take_request(req_buf)? {
3497 Some(request) => {
3498 let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
3499 (offset, request.data.len(), request.sequence_number)
3500 }
3501 None => return Ok(false),
3502 };
3503
3504 // Deserialize request from the captured offset.
3505 let mut reader = CdrReader::new_with_header(&req_buf[data_offset..data_offset + data_len])
3506 .map_err(|_| TransportError::DeserializationError)?;
3507 let req = S::Request::deserialize(&mut reader)
3508 .map_err(|_| TransportError::DeserializationError)?;
3509
3510 // Call handler
3511 let reply = handler(&req);
3512
3513 // Serialize reply
3514 let mut writer =
3515 CdrWriter::new_with_header(reply_buf).map_err(|_| TransportError::BufferTooSmall)?;
3516 reply
3517 .serialize(&mut writer)
3518 .map_err(|_| TransportError::SerializationError)?;
3519 let len = writer.position();
3520
3521 // Send reply (now we can borrow self mutably again)
3522 self.send_response(sequence_number, &reply_buf[..len])?;
3523 Ok(true)
3524 }
3525
3526 /// Handle a service request where the handler returns `Box<S::Reply>`
3527 ///
3528 /// Identical to `handle_request` but the handler returns a heap-allocated reply.
3529 /// This is needed for services with large response types (e.g., parameter services
3530 /// where `Vec<ParameterValue, 64>` is ~1MB+) that would overflow the stack.
3531 #[cfg(feature = "alloc")]
3532 fn handle_request_boxed<S: RosService>(
3533 &mut self,
3534 req_buf: &mut [u8],
3535 reply_buf: &mut [u8],
3536 handler: impl FnOnce(&S::Request) -> alloc::boxed::Box<S::Reply>,
3537 ) -> Result<bool, Self::Error>
3538 where
3539 Self::Error: From<TransportError>,
3540 {
3541 use nros_core::{CdrReader, CdrWriter};
3542
3543 let buf_start = req_buf.as_ptr() as usize;
3544 let (data_offset, data_len, sequence_number) = match self.take_request(req_buf)? {
3545 Some(request) => {
3546 let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
3547 (offset, request.data.len(), request.sequence_number)
3548 }
3549 None => return Ok(false),
3550 };
3551
3552 let mut reader = CdrReader::new_with_header(&req_buf[data_offset..data_offset + data_len])
3553 .map_err(|_| TransportError::DeserializationError)?;
3554 let req = S::Request::deserialize(&mut reader)
3555 .map_err(|_| TransportError::DeserializationError)?;
3556
3557 let reply = handler(&req);
3558
3559 let mut writer =
3560 CdrWriter::new_with_header(reply_buf).map_err(|_| TransportError::BufferTooSmall)?;
3561 reply
3562 .serialize(&mut writer)
3563 .map_err(|_| TransportError::SerializationError)?;
3564 let len = writer.position();
3565
3566 self.send_response(sequence_number, &reply_buf[..len])?;
3567 Ok(true)
3568 }
3569
3570 /// Handle one request by STREAMING: the handler reads fields off the wire
3571 /// and writes the reply's fields straight back, so neither the request nor
3572 /// the reply is ever materialised as a value.
3573 ///
3574 /// phase-382 W1'. `handle_request_boxed` above boxes the REPLY because the
3575 /// parameter responses are enormous — `GetParametersResponse` measures
3576 /// 1,176,072 bytes. It does not box the REQUEST, which is deserialized by
3577 /// value into a stack local one line above the handler, and
3578 /// `SetParametersRequest` measures **1,192,968 bytes**. So every
3579 /// `ros2 param set` against a node put a 1.19 MB local on the calling
3580 /// task's stack — larger than the reply the boxing exists for, on every
3581 /// platform, with `param-services` live on Zephyr.
3582 ///
3583 /// Streaming removes both, and removes the `alloc` requirement with them:
3584 /// the whole value is never needed, because serialisation happens on the
3585 /// line after construction. Three things make it safe rather than clever:
3586 ///
3587 /// * **No `rcl_interfaces` message uses a DHEADER** — every `Serialize` impl
3588 /// is plain sequential CDR, so hand-written field writes are byte-identical
3589 /// to the generated ones. (If a future message gains XCDR2 extensibility
3590 /// this stops being true for THAT message; see RFC-0055.)
3591 /// * `req_buf` and `reply_buf` are disjoint, so a handler can hold the
3592 /// reader and the writer at once.
3593 /// * `CdrReader::read_string` borrows out of `req_buf` rather than copying,
3594 /// so a handler can look a name up without a buffer of its own.
3595 ///
3596 /// The cost is that the hand-written writes can drift from the generated
3597 /// `Serialize`. Guard it with a round-trip test that deserialises the
3598 /// streamed bytes back into the generated type — the by-value handler makes
3599 /// a good test-only oracle.
3600 ///
3601 /// No `alloc`, deliberately: this is the seam that lets `param-services` and
3602 /// `lifecycle-services` build without it.
3603 fn handle_request_raw(
3604 &mut self,
3605 req_buf: &mut [u8],
3606 reply_buf: &mut [u8],
3607 handler: impl FnOnce(
3608 &mut nros_core::CdrReader<'_>,
3609 &mut nros_core::CdrWriter<'_>,
3610 ) -> Result<(), TransportError>,
3611 ) -> Result<bool, Self::Error>
3612 where
3613 Self::Error: From<TransportError>,
3614 {
3615 use nros_core::{CdrReader, CdrWriter};
3616
3617 let buf_start = req_buf.as_ptr() as usize;
3618 let (data_offset, data_len, sequence_number) = match self.take_request(req_buf)? {
3619 Some(request) => {
3620 let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
3621 (offset, request.data.len(), request.sequence_number)
3622 }
3623 None => return Ok(false),
3624 };
3625
3626 // Split the borrow so the reader (over `req_buf`) and the writer (over
3627 // `reply_buf`) can be live at the same time. They are separate
3628 // parameters, so this is disjoint by construction.
3629 let mut reader = CdrReader::new_with_header(&req_buf[data_offset..data_offset + data_len])
3630 .map_err(|_| TransportError::DeserializationError)?;
3631 let mut writer =
3632 CdrWriter::new_with_header(reply_buf).map_err(|_| TransportError::BufferTooSmall)?;
3633
3634 handler(&mut reader, &mut writer)?;
3635 let len = writer.position();
3636
3637 self.send_response(sequence_number, &reply_buf[..len])?;
3638 Ok(true)
3639 }
3640 /// The profile this entity is ACTUALLY running — what the backend GRANTED,
3641 /// not what the call site requested.
3642 ///
3643 /// Upstream's `rmw_service_request_subscription_get_actual_qos`. The two differ whenever a
3644 /// backend serves something other than what it was asked for, and the
3645 /// difference is exactly what answers "why is nothing arriving": a
3646 /// RELIABLE reader does not match a BEST_EFFORT writer, and a clamped
3647 /// history depth drops samples the caller believed were kept.
3648 ///
3649 /// **Per-field, and an unreportable field is an ABSENCE.** A backend that
3650 /// determines four policies and not the fifth returns the four and leaves
3651 /// the fifth at its `Unknown` variant. Never echo the request into a field
3652 /// you did not read back — that is the inverse of this method's meaning,
3653 /// and it is what the six C-ABI slots did until this phase.
3654 ///
3655 /// The default is [`QoSProfile::QOS_PROFILE_UNKNOWN`] — every field an
3656 /// absence — which is the honest answer for a backend with no read-back at
3657 /// all (XRCE has none; zenoh-pico's QoS is per-message flags). It is NOT
3658 /// the request, and it is NOT `QOS_PROFILE_DEFAULT`.
3659 fn request_subscription_actual_qos(&self) -> QoSProfile {
3660 QoSProfile::QOS_PROFILE_UNKNOWN
3661 }
3662
3663 /// The profile this entity is ACTUALLY running — what the backend GRANTED,
3664 /// not what the call site requested.
3665 ///
3666 /// Upstream's `rmw_service_response_publisher_get_actual_qos`. The two differ whenever a
3667 /// backend serves something other than what it was asked for, and the
3668 /// difference is exactly what answers "why is nothing arriving": a
3669 /// RELIABLE reader does not match a BEST_EFFORT writer, and a clamped
3670 /// history depth drops samples the caller believed were kept.
3671 ///
3672 /// **Per-field, and an unreportable field is an ABSENCE.** A backend that
3673 /// determines four policies and not the fifth returns the four and leaves
3674 /// the fifth at its `Unknown` variant. Never echo the request into a field
3675 /// you did not read back — that is the inverse of this method's meaning,
3676 /// and it is what the six C-ABI slots did until this phase.
3677 ///
3678 /// The default is [`QoSProfile::QOS_PROFILE_UNKNOWN`] — every field an
3679 /// absence — which is the honest answer for a backend with no read-back at
3680 /// all (XRCE has none; zenoh-pico's QoS is per-message flags). It is NOT
3681 /// the request, and it is NOT `QOS_PROFILE_DEFAULT`.
3682 fn response_publisher_actual_qos(&self) -> QoSProfile {
3683 QoSProfile::QOS_PROFILE_UNKNOWN
3684 }
3685}
3686
3687/// Service client trait for sending requests.
3688///
3689/// # Threading
3690///
3691/// `&mut self` on every method — the client is single-owner. For
3692/// fan-out request patterns, create one client per worker thread.
3693///
3694/// # Calling pattern
3695///
3696/// All in-tree backends route blocking waits through the executor:
3697///
3698/// 1. `send_request_raw(buf)` — non-blocking; returns once the
3699/// request is queued for transmission.
3700/// 2. The executor's `drive_io` runs.
3701/// 3. `take_response_raw(buf)` — non-blocking; returns
3702/// `Ok(Some(len))` when the reply is back.
3703///
3704/// Phase-301 (issue 0240): the deprecated blocking `call_raw` path is
3705/// DELETED — `send_request_raw` + `take_response_raw` is the one
3706/// request/reply path, and both are required for service-capable
3707/// backends.
3708pub trait ClientTrait {
3709 /// Error type for service operations
3710 type Error;
3711
3712 /// Send a service request without waiting for a reply (non-blocking).
3713 ///
3714 /// Returns the SEQUENCE ID the backend assigned. The caller must
3715 /// subsequently poll [`take_response_raw`](Self::take_response_raw) and
3716 /// match that id against the one the reply carries.
3717 ///
3718 /// Issue 0778 — this returned `()` until 2026-08-25, and every backend
3719 /// computed an id and discarded it. With nothing to correlate by, a client
3720 /// with two calls outstanding could not tell the replies apart, so each
3721 /// backend picked a policy: cyclonedds abandoned the older request, zenoh
3722 /// took the first reply. Both are wrong for `send_goal` and
3723 /// `SetParameters`, which travel this path and are not idempotent.
3724 fn send_request_raw(&mut self, request: &[u8]) -> Result<i64, Self::Error>;
3725
3726 /// Poll for a reply (non-blocking).
3727 ///
3728 /// Returns `Ok(Some((len, sequence_id)))` when a reply has arrived,
3729 /// `Ok(None)` if not yet available, or `Err` on failure. The
3730 /// `sequence_id` is the one [`send_request_raw`](Self::send_request_raw)
3731 /// returned for the request this answers.
3732 ///
3733 /// It used to say "a reply to the MOST RECENTLY sent request", which was
3734 /// the single-outstanding-call assumption written into the contract.
3735 fn take_response_raw(
3736 &mut self,
3737 reply_buf: &mut [u8],
3738 ) -> Result<Option<(usize, i64)>, Self::Error>;
3739
3740 /// Issue 1632 — how many bytes the reply the most recent
3741 /// [`take_response_raw`](Self::take_response_raw) REFUSED as too big for
3742 /// the caller's buffer needed. [`Subscription::refused_sample_len`]'s
3743 /// contract, for the client: `None` is "the backend does not know".
3744 fn refused_response_len(&self) -> Option<usize> {
3745 None
3746 }
3747
3748 /// Send a typed service request without waiting for a reply (non-blocking).
3749 ///
3750 /// Serializes the request into `req_buf` and calls [`send_request_raw`](Self::send_request_raw).
3751 fn send_request<S: RosService>(
3752 &mut self,
3753 request: &S::Request,
3754 req_buf: &mut [u8],
3755 ) -> Result<(), Self::Error>
3756 where
3757 Self::Error: From<TransportError>,
3758 {
3759 use nros_core::CdrWriter;
3760
3761 let mut writer =
3762 CdrWriter::new_with_header(req_buf).map_err(|_| TransportError::BufferTooSmall)?;
3763 request
3764 .serialize(&mut writer)
3765 .map_err(|_| TransportError::SerializationError)?;
3766 let req_len = writer.position();
3767
3768 self.send_request_raw(&req_buf[..req_len]).map(|_seq| ())
3769 }
3770
3771 /// Poll for a typed reply to the most recently sent request (non-blocking).
3772 ///
3773 /// Calls [`take_response_raw`](Self::take_response_raw) and deserializes if available.
3774 fn take_response<S: RosService>(
3775 &mut self,
3776 reply_buf: &mut [u8],
3777 ) -> Result<Option<S::Reply>, Self::Error>
3778 where
3779 Self::Error: From<TransportError>,
3780 {
3781 use nros_core::CdrReader;
3782
3783 match self.take_response_raw(reply_buf)? {
3784 Some((len, _seq)) => {
3785 let mut reader = CdrReader::new_with_header(&reply_buf[..len])
3786 .map_err(|_| TransportError::DeserializationError)?;
3787 let reply = S::Reply::deserialize(&mut reader)
3788 .map_err(|_| TransportError::DeserializationError)?;
3789 Ok(Some(reply))
3790 }
3791 None => Ok(None),
3792 }
3793 }
3794
3795 /// Register an async waker to be notified when a reply arrives.
3796 ///
3797 /// Called from `Future::poll()` implementations to store the waker.
3798 /// The transport backend calls `waker.wake()` from its reply callback
3799 /// when a response is available, enabling event-driven async without
3800 /// busy-polling.
3801 ///
3802 /// Default: no-op (backends that don't support waking simply ignore this).
3803 fn register_waker(&self, _waker: &core::task::Waker) {}
3804
3805 // phase-428 W13 — the `start_server_discovery` / `poll_server_discovery`
3806 // pair was DELETED here. It existed because the only zenoh backend could
3807 // answer "is a server up?" only by issuing a `z_liveliness_get`, which is
3808 // start-then-poll, so the synchronous `service_is_ready` below had nothing
3809 // to return but a latch (issue 1087). The pair had no vtable slot, so every
3810 // C-ABI backend inherited its default and `wait_for_service` answered
3811 // without asking. The zenoh backend now keeps a matched-server SET fed by
3812 // its liveliness subscriber — the state upstream's DDS discovery cache
3813 // keeps — and `service_is_ready` reads it synchronously; the wait loops
3814 // spin on that one query, and nothing needs the pair.
3815
3816 /// Whether a matching service server is currently discoverable.
3817 ///
3818 /// Mirrors `rclcpp::ClientBase::service_is_ready` — the NAME is upstream's.
3819 /// The SHAPE is `rcl`'s: `rcl_service_server_is_available(node, client,
3820 /// bool *is_available)` returns `RCL_RET_OK` "if the check was made
3821 /// successfully (regardless of the service readiness)", i.e. the return
3822 /// code says whether the CHECK worked and the out-param carries the ANSWER.
3823 /// rclcpp collapses that to a bare `bool` and moves the error to
3824 /// exceptions; RFC-0018 forbids exceptions, so `Result<bool, _>` is how the
3825 /// same contract is expressed here (phase-379 W6, RFC-0036).
3826 ///
3827 /// Returns `Ok(true)` if at least one matching server is CURRENTLY
3828 /// discoverable, `Ok(false)` if none is, or `Err(_)` if the backend
3829 /// cannot answer (e.g. XRCE — micro-XRCE-DDS-Client has no participant
3830 /// enumeration).
3831 ///
3832 /// CURRENT, not latched: upstream `rmw.h` says the outcome reflects a
3833 /// change in either direction, and `rclcpp::ClientBase::service_is_ready`
3834 /// calls `rcl_service_server_is_available` on every invocation. A backend
3835 /// answers from the discovery state it maintains — the DDS built-in
3836 /// topics, or the zenoh matched-server set fed by liveliness PUT/DELETE
3837 /// (phase-428 W13) — so a server that goes away reads unavailable again.
3838 /// Every wait loop in the tree (`wait_for_service`,
3839 /// `wait_for_action_server`, their C and C++ bindings) is a spin on this
3840 /// one method; there is no separate asynchronous probe (issue 1087).
3841 ///
3842 /// Those are three answers, not two, and keeping them apart is the whole
3843 /// point: `is_server_ready` used to sit beside this method collapsing
3844 /// "don't know" and "no server" into one `false`, its trait default was
3845 /// `true`, and only zenoh overrode it — so on every other image
3846 /// `wait_for_service` returned success without probing anything. Issue
3847 /// 1008 deleted it rather than fixing it, because a caller cannot recover
3848 /// from an answer that does not distinguish the two.
3849 ///
3850 /// User-facing surface: `Client<S>::server_available()` in Rust,
3851 /// `rclc_client_server_available()` in C/C++. Clients use this
3852 /// to gate the first request so a startup-ordering race
3853 /// (client opens before server's discovery announcement lands)
3854 /// doesn't surface as a request-side timeout.
3855 ///
3856 /// Default impl: `Err(TransportError::Unsupported)` — backends
3857 /// that support graph introspection (zenoh queryable interest,
3858 /// DDS built-in topic readers) opt in by overriding.
3859 fn service_is_ready(&self) -> Result<bool, Self::Error>
3860 where
3861 Self::Error: From<TransportError>,
3862 {
3863 Err(TransportError::Unsupported.into())
3864 }
3865 /// The profile this entity is ACTUALLY running — what the backend GRANTED,
3866 /// not what the call site requested.
3867 ///
3868 /// Upstream's `rmw_client_request_publisher_get_actual_qos`. The two differ whenever a
3869 /// backend serves something other than what it was asked for, and the
3870 /// difference is exactly what answers "why is nothing arriving": a
3871 /// RELIABLE reader does not match a BEST_EFFORT writer, and a clamped
3872 /// history depth drops samples the caller believed were kept.
3873 ///
3874 /// **Per-field, and an unreportable field is an ABSENCE.** A backend that
3875 /// determines four policies and not the fifth returns the four and leaves
3876 /// the fifth at its `Unknown` variant. Never echo the request into a field
3877 /// you did not read back — that is the inverse of this method's meaning,
3878 /// and it is what the six C-ABI slots did until this phase.
3879 ///
3880 /// The default is [`QoSProfile::QOS_PROFILE_UNKNOWN`] — every field an
3881 /// absence — which is the honest answer for a backend with no read-back at
3882 /// all (XRCE has none; zenoh-pico's QoS is per-message flags). It is NOT
3883 /// the request, and it is NOT `QOS_PROFILE_DEFAULT`.
3884 fn request_publisher_actual_qos(&self) -> QoSProfile {
3885 QoSProfile::QOS_PROFILE_UNKNOWN
3886 }
3887
3888 /// The profile this entity is ACTUALLY running — what the backend GRANTED,
3889 /// not what the call site requested.
3890 ///
3891 /// Upstream's `rmw_client_response_subscription_get_actual_qos`. The two differ whenever a
3892 /// backend serves something other than what it was asked for, and the
3893 /// difference is exactly what answers "why is nothing arriving": a
3894 /// RELIABLE reader does not match a BEST_EFFORT writer, and a clamped
3895 /// history depth drops samples the caller believed were kept.
3896 ///
3897 /// **Per-field, and an unreportable field is an ABSENCE.** A backend that
3898 /// determines four policies and not the fifth returns the four and leaves
3899 /// the fifth at its `Unknown` variant. Never echo the request into a field
3900 /// you did not read back — that is the inverse of this method's meaning,
3901 /// and it is what the six C-ABI slots did until this phase.
3902 ///
3903 /// The default is [`QoSProfile::QOS_PROFILE_UNKNOWN`] — every field an
3904 /// absence — which is the honest answer for a backend with no read-back at
3905 /// all (XRCE has none; zenoh-pico's QoS is per-message flags). It is NOT
3906 /// the request, and it is NOT `QOS_PROFILE_DEFAULT`.
3907 fn response_subscription_actual_qos(&self) -> QoSProfile {
3908 QoSProfile::QOS_PROFILE_UNKNOWN
3909 }
3910}
3911
3912/// Transport backend trait (legacy).
3913///
3914/// Use [`Rmw`] for new code. This trait is retained for backward compatibility
3915/// with existing code that uses [`TransportConfig`] directly.
3916pub trait Transport {
3917 /// Error type for this transport
3918 type Error;
3919 /// Session type for this transport
3920 type Session: Session;
3921
3922 /// Open a new session with the given configuration
3923 fn open(config: &TransportConfig) -> Result<Self::Session, Self::Error>;
3924}
3925
3926/// Factory trait for compile-time middleware selection.
3927///
3928/// Embedded crates select a backend via feature flag:
3929/// ```rust,ignore
3930/// #[cfg(feature = "rmw-cffi")]
3931/// type DefaultRmw = nros_rmw_cffi::CffiRmw;
3932/// ```
3933///
3934/// Each backend provides its own `Rmw` implementation that bridges
3935/// from the middleware-agnostic [`RmwConfig`] to backend-specific
3936/// initialization.
3937///
3938/// Phase 84.E2: `open` consumes `self`. Backends carry their own
3939/// configuration (agent addresses, serial ports, TLS CA slots)
3940/// inside the factory value and hand that over to the session at
3941/// `open` time. All in-repo backends also implement
3942/// [`Default`]; most callers spell this as
3943/// `BackendRmw::default().open(&config)`.
3944pub trait Rmw {
3945 /// Session type returned by [`open`](Rmw::open)
3946 type Session: Session;
3947 /// Error type for session creation
3948 type Error: core::fmt::Debug;
3949
3950 /// Open a new middleware session with the given configuration.
3951 ///
3952 /// The backend maps [`RmwConfig`] fields to its own connection
3953 /// parameters (e.g., zenoh locator and session mode, XRCE-DDS
3954 /// agent address). Any backend-specific pre-open state stored
3955 /// on `self` (e.g. configured agent IP / port) is moved into the
3956 /// returned `Session`.
3957 fn open(self, config: &RmwConfig) -> Result<Self::Session, Self::Error>;
3958}
3959
3960#[cfg(test)]
3961mod tests {
3962 use super::*;
3963
3964 /// issue 0783 -- the variants with a payload render it; the rest render
3965 /// their fixed line. `heapless`, so the check runs without `alloc`.
3966 #[test]
3967 fn transport_error_display_renders_payloads() {
3968 use core::fmt::Write;
3969 fn show(e: &TransportError) -> heapless::String<64> {
3970 let mut s = heapless::String::new();
3971 write!(s, "{e}").unwrap();
3972 s
3973 }
3974 assert_eq!(show(&TransportError::Timeout).as_str(), "timed out");
3975 assert_eq!(
3976 show(&TransportError::IncompatibleQos(QoSPolicyMask::RELIABILITY)).as_str(),
3977 "incompatible QoS: RELIABILITY"
3978 );
3979 assert_eq!(
3980 show(&TransportError::IncompatibleQos(QoSPolicyMask::NONE)).as_str(),
3981 "incompatible QoS"
3982 );
3983 assert_eq!(
3984 show(&TransportError::Backend("z_open refused")).as_str(),
3985 "backend error: z_open refused"
3986 );
3987 }
3988
3989 /// Issue 0971 — the DEFAULT `take_sequence` body must report a partial
3990 /// count rather than discard it, which is what its own doc comment says and
3991 /// what `?` did not do.
3992 ///
3993 /// This is the default's negative control: a backend that takes two
3994 /// messages and then errors must hand the caller those two. Before the fix
3995 /// the `?` threw them away and the caller could not tell two messages had
3996 /// been consumed — they were gone from the queue and absent from the
3997 /// result.
3998 mod take_sequence_default {
3999 use super::*;
4000
4001 #[derive(Debug, PartialEq)]
4002 struct Boom;
4003
4004 /// Yields `n` one-byte messages, then errors forever.
4005 struct ThenErrors {
4006 left: usize,
4007 }
4008
4009 impl Subscription for ThenErrors {
4010 type Error = Boom;
4011
4012 fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, Self::Error> {
4013 if self.left == 0 {
4014 return Err(Boom);
4015 }
4016 self.left -= 1;
4017 buf[0] = 0xAB;
4018 Ok(Some(1))
4019 }
4020
4021 fn deserialization_error(&self) -> Self::Error {
4022 Boom
4023 }
4024 }
4025
4026 #[test]
4027 fn a_partial_drain_reports_its_count_instead_of_the_error() {
4028 let mut sub = ThenErrors { left: 2 };
4029 let mut buf = [0u8; 4 * 8];
4030 let mut lens = [0usize; 4];
4031
4032 // Two messages are available, the third call errors.
4033 let got = sub.take_sequence(&mut buf, 8, 4, &mut lens);
4034
4035 assert_eq!(
4036 got,
4037 Ok(2),
4038 "a drain that took messages must report them; `?` discarded the \
4039 count and the caller lost two consumed messages"
4040 );
4041 assert_eq!(&lens[..2], &[1, 1]);
4042 }
4043
4044 #[test]
4045 fn an_error_with_nothing_taken_is_returned_immediately() {
4046 let mut sub = ThenErrors { left: 0 };
4047 let mut buf = [0u8; 4 * 8];
4048 let mut lens = [0usize; 4];
4049
4050 // No count to protect, so the caller should see the error itself
4051 // rather than an ambiguous `Ok(0)`.
4052 assert_eq!(sub.take_sequence(&mut buf, 8, 4, &mut lens), Err(Boom));
4053 }
4054 }
4055
4056 #[test]
4057 fn test_topic_info() {
4058 let topic = TopicInfo::new("/chatter", "std_msgs::msg::dds_::String_", "abc123");
4059 assert_eq!(topic.name, "/chatter");
4060 assert_eq!(topic.domain_id, 0);
4061 }
4062
4063 #[test]
4064 fn qos_apply_overrides_matches_topic_and_role() {
4065 // Default is Reliable / Volatile / KeepLast(10).
4066 static OVERRIDES: &[QoSOverride] = &[
4067 QoSOverride {
4068 topic: "/chatter",
4069 role: QoSOverrideRole::Publisher,
4070 value: QoSOverrideValue::Reliability(QoSReliabilityPolicy::BestEffort),
4071 },
4072 QoSOverride {
4073 topic: "/chatter",
4074 role: QoSOverrideRole::Publisher,
4075 value: QoSOverrideValue::Depth(5),
4076 },
4077 QoSOverride {
4078 topic: "/scan",
4079 role: QoSOverrideRole::Subscription,
4080 value: QoSOverrideValue::Durability(QoSDurabilityPolicy::TransientLocal),
4081 },
4082 ];
4083
4084 // Matching topic + publisher role → reliability + depth applied.
4085 let pub_qos = QoSProfile::default().apply_overrides(
4086 "/chatter",
4087 QoSOverrideRole::Publisher,
4088 OVERRIDES,
4089 );
4090 assert_eq!(pub_qos.reliability, QoSReliabilityPolicy::BestEffort);
4091 assert_eq!(pub_qos.depth, 5);
4092 assert_eq!(pub_qos.durability, QoSDurabilityPolicy::Volatile); // untouched
4093
4094 // Same topic but subscription role → publisher overrides DON'T apply;
4095 // the /scan override is for a different topic → also no change.
4096 let sub_qos = QoSProfile::default().apply_overrides(
4097 "/chatter",
4098 QoSOverrideRole::Subscription,
4099 OVERRIDES,
4100 );
4101 assert_eq!(sub_qos, QoSProfile::default());
4102
4103 // The /scan subscription override applies only to /scan+subscription.
4104 let scan_qos = QoSProfile::default().apply_overrides(
4105 "/scan",
4106 QoSOverrideRole::Subscription,
4107 OVERRIDES,
4108 );
4109 assert_eq!(scan_qos.durability, QoSDurabilityPolicy::TransientLocal);
4110
4111 // Empty table → identity (the zero-override fast path).
4112 assert_eq!(
4113 QoSProfile::default().apply_overrides("/x", QoSOverrideRole::Publisher, &[]),
4114 QoSProfile::default()
4115 );
4116 }
4117
4118 #[test]
4119 fn test_qos_defaults() {
4120 let qos = QoSProfile::default();
4121 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4122 }
4123
4124 #[test]
4125 fn test_action_info() {
4126 let action = ActionInfo::new(
4127 "/fibonacci",
4128 "example_interfaces::action::dds_::Fibonacci_",
4129 "abc123",
4130 );
4131 assert_eq!(action.name, "/fibonacci");
4132 assert_eq!(action.domain_id, 0);
4133 }
4134
4135 #[test]
4136 fn test_action_info_with_domain() {
4137 let action = ActionInfo::new(
4138 "/fibonacci",
4139 "example_interfaces::action::dds_::Fibonacci_",
4140 "abc123",
4141 )
4142 .with_domain(42);
4143 assert_eq!(action.domain_id, 42);
4144 }
4145
4146 #[test]
4147 fn test_action_send_goal_key() {
4148 let action = ActionInfo::new(
4149 "/fibonacci",
4150 "example_interfaces::action::dds_::Fibonacci_",
4151 "abc123",
4152 )
4153 .with_domain(0);
4154
4155 let key: heapless::String<256> = action.send_goal_key();
4156 // ActionInfo returns the sub-entity name with leading slash for ROS 2 compatibility
4157 assert_eq!(key.as_str(), "/fibonacci/_action/send_goal");
4158 }
4159
4160 #[test]
4161 fn test_action_feedback_key() {
4162 let action = ActionInfo::new(
4163 "/fibonacci",
4164 "example_interfaces::action::dds_::Fibonacci_",
4165 "abc123",
4166 )
4167 .with_domain(0);
4168
4169 let key: heapless::String<256> = action.feedback_key();
4170 assert_eq!(key.as_str(), "/fibonacci/_action/feedback");
4171 }
4172
4173 #[test]
4174 fn test_action_all_sub_names() {
4175 let action = ActionInfo::new(
4176 "/fibonacci",
4177 "example_interfaces::action::dds_::Fibonacci_",
4178 "abc123",
4179 )
4180 .with_domain(0);
4181
4182 let cancel: heapless::String<256> = action.cancel_goal_key();
4183 assert_eq!(cancel.as_str(), "/fibonacci/_action/cancel_goal");
4184
4185 let result: heapless::String<256> = action.get_result_key();
4186 assert_eq!(result.as_str(), "/fibonacci/_action/get_result");
4187
4188 let status: heapless::String<256> = action.status_key();
4189 assert_eq!(status.as_str(), "/fibonacci/_action/status");
4190 }
4191
4192 // --- QoS Profile Tests ---
4193
4194 #[test]
4195 fn test_qos_profile_sensor_data() {
4196 let qos = QoSProfile::QOS_PROFILE_SENSOR_DATA;
4197 assert_eq!(qos.reliability, QoSReliabilityPolicy::BestEffort);
4198 assert_eq!(qos.durability, QoSDurabilityPolicy::Volatile);
4199 assert_eq!(qos.history, QoSHistoryPolicy::KeepLast);
4200 assert_eq!(qos.depth, 5);
4201 }
4202
4203 #[test]
4204 fn test_qos_profile_default() {
4205 let qos = QoSProfile::QOS_PROFILE_DEFAULT;
4206 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4207 assert_eq!(qos.durability, QoSDurabilityPolicy::Volatile);
4208 assert_eq!(qos.depth, 10);
4209 }
4210
4211 #[test]
4212 fn test_qos_profile_services_default() {
4213 let qos = QoSProfile::QOS_PROFILE_SERVICES_DEFAULT;
4214 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4215 assert_eq!(qos.durability, QoSDurabilityPolicy::Volatile);
4216 }
4217
4218 /// issue 0829 — `SYSTEM_DEFAULT` asks for NOTHING, so it demands no policy.
4219 ///
4220 /// `required_policies` started from `QoSPolicyMask::CORE` unconditionally,
4221 /// which made the sentinel profile demand reliability, durability, history
4222 /// AND depth — so a backend that could not honour one would answer
4223 /// `IncompatibleQos` to a profile that requested none of them.
4224 #[test]
4225 fn system_default_profile_requires_no_policy() {
4226 let required = QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.required_policies();
4227 assert_eq!(required.0, 0, "SYSTEM_DEFAULT demanded {required:?}");
4228 // Therefore it is admissible against a backend that advertises nothing.
4229 assert!(
4230 QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
4231 .validate_against(QoSPolicyMask(0))
4232 .is_ok()
4233 );
4234 }
4235
4236 /// phase-428 W9 — the roadmap predicted that deriving zenoh's mask would
4237 /// drop `DEPTH` and that "nothing that works today breaks". The second
4238 /// half is FALSE, and this is the measurement that says so.
4239 ///
4240 /// `QOS_PROFILE_DEFAULT` states `depth: 10`, so it REQUIRES `DEPTH`; a
4241 /// mask without that bit refuses it, and that profile is what every
4242 /// `create_publisher` / `create_subscription` with no explicit QoS passes
4243 /// (`nros-node/src/executor/node.rs:238,372`). Withdrawing the bit would
4244 /// therefore refuse every default entity on the backend, not just the ones
4245 /// asking for more depth than the ring holds.
4246 ///
4247 /// So the zenoh shim EARNS the bit instead of withdrawing it (phase-428
4248 /// W9: `shim/qos.rs` reads the depth, grants what the ring can hold, and
4249 /// publishes the GRANTED value to the graph). Which is the honest reading
4250 /// of the mask anyway: the bit says "I will not silently ignore this
4251 /// policy", and a depth lie lives in the VALUE, not in the bit.
4252 #[test]
4253 fn withdrawing_depth_would_refuse_the_default_profile() {
4254 let without_depth = QoSPolicyMask(!QoSPolicyMask::DEPTH.0);
4255 assert!(
4256 QoSProfile::QOS_PROFILE_DEFAULT
4257 .validate_against(without_depth)
4258 .is_err(),
4259 "QOS_PROFILE_DEFAULT states depth 10; a mask without DEPTH must refuse it"
4260 );
4261 // And the same for the two other presets a default caller reaches.
4262 for qos in [
4263 QoSProfile::QOS_PROFILE_SERVICES_DEFAULT,
4264 QoSProfile::QOS_PROFILE_SENSOR_DATA,
4265 ] {
4266 assert!(qos.validate_against(without_depth).is_err());
4267 }
4268 // The sentinel profile is the one that survives, because it states no
4269 // depth at all.
4270 assert!(
4271 QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
4272 .validate_against(without_depth)
4273 .is_ok()
4274 );
4275 }
4276
4277 /// phase-428 W9 — the trait default advertises NOTHING, so an
4278 /// implementation that has written no QoS code refuses any stated policy.
4279 ///
4280 /// The default was `CORE` until W9: four policies granted to a backend
4281 /// that had implemented none of them, by the trait itself.
4282 #[test]
4283 fn the_empty_mask_refuses_every_stated_policy_and_nothing_else() {
4284 assert_eq!(QoSPolicyMask::NONE.0, 0);
4285 assert!(
4286 QoSProfile::QOS_PROFILE_DEFAULT
4287 .validate_against(QoSPolicyMask::NONE)
4288 .is_err()
4289 );
4290 assert!(
4291 QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
4292 .validate_against(QoSPolicyMask::NONE)
4293 .is_ok(),
4294 "an absence demands nothing, so it is admissible anywhere"
4295 );
4296 }
4297
4298 // ------------------------------------------------------------------
4299 // phase-417 G7 — the refusal NAMES the policy, and the vocabulary it
4300 // names it in is `QoSPolicyMask`'s own constants.
4301 // ------------------------------------------------------------------
4302
4303 /// The table is the twelve bits and nothing else, and a name is never a
4304 /// second authoring of the constant it belongs to.
4305 ///
4306 /// The buildless half of this lives in `check-qos-mask-derivation` (R8),
4307 /// which reads the `pub const` block and compares it against `NAMED`
4308 /// entry for entry. This is the half a `no_std` unit run can assert: every
4309 /// entry is one bit, the bits are distinct, and every name is distinct.
4310 /// A bit added above with no entry here would leave a policy that refuses
4311 /// a create and prints nothing.
4312 #[test]
4313 fn every_named_policy_is_one_distinct_bit_with_one_distinct_name() {
4314 assert_eq!(QoSPolicyMask::NAMED.len(), 12);
4315 let mut seen_bits = 0u32;
4316 for (bit, cstr) in QoSPolicyMask::NAMED {
4317 let name = cstr.to_str().expect("every entry is an ASCII literal");
4318 assert_eq!(
4319 bit.0.count_ones(),
4320 1,
4321 "{name} is not a single policy bit ({:#x})",
4322 bit.0
4323 );
4324 assert_eq!(seen_bits & bit.0, 0, "{name} repeats a bit already named");
4325 seen_bits |= bit.0;
4326 assert_eq!(
4327 bit.policy_name(),
4328 Some(name),
4329 "{name} does not name itself back"
4330 );
4331 // The C entry points hand this pointer straight over, so the NUL
4332 // is load-bearing and the byte before it must be the last of the
4333 // name — a `&str` view that silently dropped a byte would be a
4334 // truncated C string.
4335 assert_eq!(cstr.to_bytes().len(), name.len());
4336 }
4337 // The twelve bits are contiguous from 0, which is what
4338 // `rmw_entity.h`'s `NROS_RMW_QOS_POLICY_*` mirrors.
4339 assert_eq!(seen_bits, (1 << 12) - 1);
4340 }
4341
4342 /// `None` is a real answer in both directions — neither the empty mask nor
4343 /// a union is a policy, so neither may borrow a neighbour's name.
4344 #[test]
4345 fn neither_the_empty_mask_nor_a_union_names_a_policy() {
4346 assert_eq!(QoSPolicyMask::NONE.policy_name(), None);
4347 assert_eq!(QoSPolicyMask::CORE.policy_name(), None);
4348 assert_eq!(
4349 (QoSPolicyMask::DEADLINE | QoSPolicyMask::LIFESPAN).policy_name(),
4350 None
4351 );
4352 // And an error that is not a QoS refusal names no policy either.
4353 assert_eq!(TransportError::Timeout.qos_policy_name(), None);
4354 assert_eq!(
4355 TransportError::IncompatibleQos(QoSPolicyMask::NONE).qos_policy_name(),
4356 None,
4357 "a refusal relayed across `rmw_ret_t` identified no policy"
4358 );
4359 }
4360
4361 /// Several missing policies still produce ONE name, the same one every
4362 /// time — otherwise two surfaces could report different policies for the
4363 /// same refusal and a test could not assert either.
4364 #[test]
4365 fn a_refusal_missing_several_policies_names_the_lowest_one() {
4366 // DEFAULT states reliability, durability(volatile), history and depth.
4367 let qos = QoSProfile::QOS_PROFILE_DEFAULT;
4368 assert_eq!(
4369 qos.validate_against(QoSPolicyMask::NONE),
4370 Err(TransportError::IncompatibleQos(QoSPolicyMask::RELIABILITY)),
4371 "RELIABILITY is bit 0, so it is the reported one"
4372 );
4373 // Grant reliability and the next-lowest missing policy is reported.
4374 assert_eq!(
4375 qos.validate_against(QoSPolicyMask::RELIABILITY)
4376 .unwrap_err()
4377 .qos_policy_name(),
4378 Some("DURABILITY_VOLATILE")
4379 );
4380 // Grant everything the profile states and it is admitted.
4381 assert!(qos.validate_against(qos.required_policies()).is_ok());
4382 }
4383
4384 /// The policy a caller stated is the policy the refusal names — not a
4385 /// policy the profile never asked for. `DEADLINE` is the interesting one
4386 /// because it is stated by a VALUE rather than by a variant.
4387 #[test]
4388 fn the_refusal_names_the_policy_the_caller_actually_stated() {
4389 let qos = QoSProfile {
4390 deadline_ms: 250,
4391 ..QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
4392 };
4393 assert_eq!(qos.required_policies(), QoSPolicyMask::DEADLINE);
4394 assert_eq!(
4395 qos.validate_against(QoSPolicyMask::NONE)
4396 .unwrap_err()
4397 .qos_policy_name(),
4398 Some("DEADLINE")
4399 );
4400 }
4401
4402 /// The relaxation is per FIELD, not all-or-nothing: a profile that states
4403 /// SOME policies still demands exactly those.
4404 #[test]
4405 fn a_partly_stated_profile_demands_only_what_it_states() {
4406 let qos = QoSProfile {
4407 reliability: QoSReliabilityPolicy::BestEffort,
4408 ..QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
4409 };
4410 let required = qos.required_policies();
4411 assert!(required.contains(QoSPolicyMask::RELIABILITY));
4412 assert!(!required.contains(QoSPolicyMask::HISTORY));
4413 assert!(!required.contains(QoSPolicyMask::DEPTH));
4414 assert!(!required.contains(QoSPolicyMask::DURABILITY_VOLATILE));
4415 assert!(!required.contains(QoSPolicyMask::DURABILITY_TRANSIENT_LOCAL));
4416 }
4417
4418 /// A fully concrete profile is unaffected by the relaxation — this is the
4419 /// guard on "do not silently stop demanding what the caller asked for".
4420 #[test]
4421 fn concrete_profiles_still_demand_their_core_policies() {
4422 let required = QoSProfile::QOS_PROFILE_DEFAULT.required_policies();
4423 assert!(required.contains(QoSPolicyMask::RELIABILITY));
4424 assert!(required.contains(QoSPolicyMask::DURABILITY_VOLATILE));
4425 assert!(required.contains(QoSPolicyMask::HISTORY));
4426 assert!(required.contains(QoSPolicyMask::DEPTH));
4427 // And a backend missing one still rejects it.
4428 let missing = QoSPolicyMask(required.0 & !QoSPolicyMask::DEPTH.0);
4429 // phase-417 G7 — and it says WHICH policy. `DEPTH` is the only bit
4430 // dropped, so there is exactly one right answer here.
4431 assert_eq!(
4432 QoSProfile::QOS_PROFILE_DEFAULT.validate_against(missing),
4433 Err(TransportError::IncompatibleQos(QoSPolicyMask::DEPTH))
4434 );
4435 }
4436
4437 /// issue 0829 — resolution replaces ONLY the sentinel fields, and the
4438 /// answer is the backend's, not a constant this crate bakes.
4439 #[test]
4440 fn resolve_system_default_fills_absences_and_touches_nothing_else() {
4441 // Two backends, two different answers to the SAME sentinel — the whole
4442 // reason no concrete `QOS_PROFILE_SYSTEM_DEFAULT` could be right.
4443 const CYCLONE: QoSSystemDefaults = QoSSystemDefaults {
4444 reliability: QoSReliabilityPolicy::Reliable,
4445 durability: QoSDurabilityPolicy::Volatile,
4446 history: QoSHistoryPolicy::KeepLast,
4447 depth: 1,
4448 };
4449 const ZENOH: QoSSystemDefaults = QoSSystemDefaults {
4450 depth: 4,
4451 ..CYCLONE
4452 };
4453
4454 let a = QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.resolve_system_default(&CYCLONE);
4455 let b = QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.resolve_system_default(&ZENOH);
4456 assert_eq!(a.reliability, QoSReliabilityPolicy::Reliable);
4457 assert_eq!(a.history, QoSHistoryPolicy::KeepLast);
4458 assert_eq!(a.durability, QoSDurabilityPolicy::Volatile);
4459 assert_eq!(a.depth, 1);
4460 assert_eq!(b.depth, 4);
4461 assert!(!a.has_unresolved_system_default());
4462 assert!(!b.has_unresolved_system_default());
4463
4464 // A STATED policy is never overridden — resolution fills an absence.
4465 let stated = QoSProfile {
4466 reliability: QoSReliabilityPolicy::BestEffort,
4467 history: QoSHistoryPolicy::KeepAll,
4468 durability: QoSDurabilityPolicy::TransientLocal,
4469 depth: 7,
4470 ..QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
4471 };
4472 let resolved = stated.resolve_system_default(&CYCLONE);
4473 assert_eq!(resolved, stated, "resolution overrode a stated policy");
4474
4475 // Idempotent.
4476 assert_eq!(a.resolve_system_default(&ZENOH), a);
4477 }
4478
4479 #[test]
4480 fn services_default_validates_and_rejects_missing_policy() {
4481 // Phase 193.5 — the service-create path (node `create_service*_sized` +
4482 // the typed-arena `register_service*_on`) runs `validate_against` on the
4483 // caller's profile, exactly like pub/sub. A backend advertising the
4484 // profile's required policies admits it; dropping any required bit (here
4485 // RELIABILITY) rejects it with `IncompatibleQos` — no silent downgrade.
4486 let qos = QoSProfile::services_default();
4487 let required = qos.required_policies();
4488 assert!(qos.validate_against(required).is_ok());
4489 assert!(qos.validate_against(QoSPolicyMask(u32::MAX)).is_ok());
4490 let missing = QoSPolicyMask(required.0 & !QoSPolicyMask::RELIABILITY.0);
4491 // phase-417 G7 — RELIABILITY is the bit dropped and RELIABILITY is
4492 // what the refusal has to name.
4493 assert_eq!(
4494 qos.validate_against(missing),
4495 Err(TransportError::IncompatibleQos(QoSPolicyMask::RELIABILITY))
4496 );
4497 assert_eq!(
4498 qos.validate_against(missing).unwrap_err().qos_policy_name(),
4499 Some("RELIABILITY")
4500 );
4501 }
4502
4503 /// issue 0793 — this asserted `TransientLocal`, which PINNED THE DEFECT:
4504 /// upstream `rmw_qos_profile_parameters` is KEEP_LAST(1000) + RELIABLE +
4505 /// **VOLATILE** (`/opt/ros/<distro>/include/rmw/rmw/qos_profiles.h`), and
4506 /// our own second copy of the profile, `nros::qos::PARAMETERS`, was already
4507 /// correct. The test agreed with the wrong copy, so the disagreement between
4508 /// the two survived every run.
4509 #[test]
4510 fn test_qos_profile_parameters() {
4511 let qos = QoSProfile::QOS_PROFILE_PARAMETERS;
4512 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4513 assert_eq!(
4514 qos.durability,
4515 QoSDurabilityPolicy::Volatile,
4516 "rmw_qos_profile_parameters is VOLATILE; transient-local would make \
4517 every parameter server retain for late joiners, which ROS 2 does not do"
4518 );
4519 assert_eq!(qos.depth, 1000);
4520 }
4521
4522 #[test]
4523 fn test_qos_profile_clock() {
4524 let qos = QoSProfile::QOS_PROFILE_CLOCK;
4525 assert_eq!(qos.reliability, QoSReliabilityPolicy::BestEffort);
4526 assert_eq!(qos.depth, 1);
4527 }
4528
4529 /// phase-428 W10 — this asserted `KeepAll`, which PINNED THE DEFECT, in
4530 /// exactly the shape issue 0793 had already been caught in one profile
4531 /// over: upstream `rmw_qos_profile_parameter_events` is KEEP_LAST(1000) +
4532 /// RELIABLE + VOLATILE, byte-identical to `rmw_qos_profile_parameters`, and
4533 /// our C++ surface (`rclcpp::ParameterEventsQoS`) was already correct — so
4534 /// the two languages shipped different profiles under one name while this
4535 /// test reported green.
4536 ///
4537 /// The old value inverted behaviour rather than weakening it: a RELIABLE
4538 /// writer with KEEP_ALL blocks once history fills, where KEEP_LAST(1000)
4539 /// overwrites.
4540 #[test]
4541 fn test_qos_profile_parameter_events() {
4542 let qos = QoSProfile::QOS_PROFILE_PARAMETER_EVENTS;
4543 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4544 assert_eq!(qos.durability, QoSDurabilityPolicy::Volatile);
4545 assert_eq!(
4546 qos.history,
4547 QoSHistoryPolicy::KeepLast,
4548 "rmw_qos_profile_parameter_events is KEEP_LAST; KEEP_ALL makes a \
4549 reliable parameter-event writer BLOCK where upstream overwrites"
4550 );
4551 assert_eq!(qos.depth, 1000);
4552
4553 // Upstream defines the two constants with identical field values.
4554 assert_eq!(qos, QoSProfile::QOS_PROFILE_PARAMETERS);
4555 }
4556
4557 /// phase-428 W10 — the class behind the `PARAMETER_EVENTS` defect, not just
4558 /// the instance. `KEEP_ALL` under RELIABLE is a blocking configuration, and
4559 /// no upstream `rmw_qos_profile_*` nor `rcl_action_qos_profile_*` uses it;
4560 /// every one of them is `KEEP_LAST` or the SYSTEM_DEFAULT sentinel. A NAMED
4561 /// preset that is `KeepAll` is therefore a transcription error by
4562 /// construction — a caller who genuinely wants it says
4563 /// [`QoSProfile::keep_all`].
4564 #[test]
4565 fn no_named_preset_is_keep_all() {
4566 for (name, qos) in [
4567 ("BEST_EFFORT", QoSProfile::BEST_EFFORT),
4568 ("RELIABLE", QoSProfile::RELIABLE),
4569 ("SYSTEM_DEFAULT", QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT),
4570 ("DEFAULT", QoSProfile::QOS_PROFILE_DEFAULT),
4571 ("SENSOR_DATA", QoSProfile::QOS_PROFILE_SENSOR_DATA),
4572 ("SERVICES_DEFAULT", QoSProfile::QOS_PROFILE_SERVICES_DEFAULT),
4573 ("PARAMETERS", QoSProfile::QOS_PROFILE_PARAMETERS),
4574 ("PARAMETER_EVENTS", QoSProfile::QOS_PROFILE_PARAMETER_EVENTS),
4575 (
4576 "ACTION_STATUS_DEFAULT",
4577 QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT,
4578 ),
4579 ("CLOCK", QoSProfile::QOS_PROFILE_CLOCK),
4580 ("PX4", QoSProfile::QOS_PROFILE_PX4),
4581 ] {
4582 assert_ne!(
4583 qos.history,
4584 QoSHistoryPolicy::KeepAll,
4585 "preset {name} is KEEP_ALL; no upstream profile is"
4586 );
4587 }
4588 }
4589
4590 #[test]
4591 fn test_qos_profile_action_status() {
4592 let qos = QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT;
4593 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4594 assert_eq!(qos.durability, QoSDurabilityPolicy::TransientLocal);
4595 assert_eq!(qos.depth, 1);
4596 }
4597
4598 #[test]
4599 fn test_qos_static_constructors() {
4600 assert_eq!(
4601 QoSProfile::topics_default(),
4602 QoSProfile::QOS_PROFILE_DEFAULT
4603 );
4604 assert_eq!(
4605 QoSProfile::sensor_data_default(),
4606 QoSProfile::QOS_PROFILE_SENSOR_DATA
4607 );
4608 assert_eq!(
4609 QoSProfile::services_default(),
4610 QoSProfile::QOS_PROFILE_SERVICES_DEFAULT
4611 );
4612 assert_eq!(
4613 QoSProfile::parameter_services_default(),
4614 QoSProfile::QOS_PROFILE_PARAMETERS
4615 );
4616 assert_eq!(
4617 QoSProfile::action_status_default(),
4618 QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT
4619 );
4620 }
4621
4622 #[test]
4623 fn test_qos_builder_explicit_setters() {
4624 let qos = QoSProfile::new()
4625 .reliability(QoSReliabilityPolicy::Reliable)
4626 .durability(QoSDurabilityPolicy::TransientLocal)
4627 .history(QoSHistoryPolicy::KeepAll)
4628 .depth(100);
4629
4630 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4631 assert_eq!(qos.durability, QoSDurabilityPolicy::TransientLocal);
4632 assert_eq!(qos.history, QoSHistoryPolicy::KeepAll);
4633 assert_eq!(qos.depth, 100);
4634 }
4635
4636 #[test]
4637 fn test_qos_builder_chaining() {
4638 // Test that builder methods can be chained in any order
4639 let qos = QoSProfile::sensor_data_default()
4640 .reliable()
4641 .transient_local()
4642 .keep_last(20);
4643
4644 assert_eq!(qos.reliability, QoSReliabilityPolicy::Reliable);
4645 assert_eq!(qos.durability, QoSDurabilityPolicy::TransientLocal);
4646 assert_eq!(qos.history, QoSHistoryPolicy::KeepLast);
4647 assert_eq!(qos.depth, 20);
4648 }
4649
4650 #[test]
4651 fn test_qos_eq_impl() {
4652 // Verify that PartialEq works correctly via derive on QoSProfile
4653 let qos1 = QoSProfile::QOS_PROFILE_DEFAULT;
4654 let qos2 = QoSProfile::topics_default();
4655 // Both should have same values - verify field by field
4656 assert_eq!(qos1.reliability, qos2.reliability);
4657 assert_eq!(qos1.durability, qos2.durability);
4658 assert_eq!(qos1.history, qos2.history);
4659 assert_eq!(qos1.depth, qos2.depth);
4660 }
4661
4662 // --- Locator validation tests ---
4663
4664 #[test]
4665 fn test_locator_protocol_tcp() {
4666 assert_eq!(locator_protocol("tcp/127.0.0.1:7447"), LocatorProtocol::Tcp);
4667 }
4668
4669 #[test]
4670 fn test_locator_protocol_serial() {
4671 assert_eq!(
4672 locator_protocol("serial//dev/ttyUSB0#baudrate=115200"),
4673 LocatorProtocol::Serial
4674 );
4675 }
4676
4677 #[test]
4678 fn test_locator_protocol_unknown() {
4679 assert_eq!(locator_protocol(""), LocatorProtocol::Unknown);
4680 assert_eq!(locator_protocol("http://foo"), LocatorProtocol::Unknown);
4681 assert_eq!(locator_protocol("tls/host:port"), LocatorProtocol::Unknown);
4682 }
4683
4684 #[test]
4685 fn test_locator_protocol_udp() {
4686 assert_eq!(locator_protocol("udp/127.0.0.1:7447"), LocatorProtocol::Udp);
4687 assert_eq!(
4688 locator_protocol("udp/192.168.1.50:2019"),
4689 LocatorProtocol::Udp
4690 );
4691 }
4692
4693 #[test]
4694 fn test_validate_tcp_locator_ok() {
4695 assert!(validate_locator("tcp/127.0.0.1:7447").is_ok());
4696 assert!(validate_locator("tcp/192.168.1.1:7447").is_ok());
4697 }
4698
4699 #[test]
4700 fn test_validate_tcp_locator_missing_port() {
4701 assert!(validate_locator("tcp/127.0.0.1").is_err());
4702 }
4703
4704 #[test]
4705 fn test_validate_serial_locator_ok() {
4706 assert!(validate_locator("serial//dev/ttyUSB0#baudrate=115200").is_ok());
4707 assert!(validate_locator("serial//dev/ttyACM0#baudrate=9600").is_ok());
4708 assert!(validate_locator("serial/uart1#baudrate=921600").is_ok());
4709 }
4710
4711 #[test]
4712 fn test_validate_serial_locator_empty_device() {
4713 assert!(validate_locator("serial/").is_err());
4714 }
4715
4716 #[test]
4717 fn test_validate_serial_locator_missing_baudrate() {
4718 assert!(validate_locator("serial//dev/ttyUSB0").is_err());
4719 }
4720
4721 #[test]
4722 fn test_validate_serial_locator_invalid_baudrate() {
4723 assert!(validate_locator("serial//dev/ttyUSB0#baudrate=abc").is_err());
4724 }
4725
4726 #[test]
4727 fn test_validate_unknown_protocol() {
4728 assert!(validate_locator("http://foo").is_err());
4729 assert!(validate_locator("tls/host:port").is_err());
4730 }
4731
4732 #[test]
4733 fn test_validate_udp_locator_ok() {
4734 assert!(validate_locator("udp/127.0.0.1:7447").is_ok());
4735 assert!(validate_locator("udp/192.168.1.50:2019").is_ok());
4736 }
4737
4738 #[test]
4739 fn test_validate_udp_locator_missing_port() {
4740 assert!(validate_locator("udp/127.0.0.1").is_err());
4741 }
4742
4743 // Phase 233.2 — the PX4 companion QoS profile must be BEST_EFFORT +
4744 // TRANSIENT_LOCAL + KEEP_LAST so it matches PX4's uxrce_dds_client endpoints.
4745 #[test]
4746 fn px4_qos_profile_matches_uxrce_dds_client() {
4747 let q = QoSProfile::px4();
4748 assert_eq!(q.reliability, QoSReliabilityPolicy::BestEffort);
4749 // VOLATILE — PX4's /fmu/out writers are volatile; a TRANSIENT_LOCAL
4750 // reader silently fails to match (verified against real PX4 SITL).
4751 assert_eq!(q.durability, QoSDurabilityPolicy::Volatile);
4752 assert_eq!(q.history, QoSHistoryPolicy::KeepLast);
4753 assert_eq!(q, QoSProfile::QOS_PROFILE_PX4);
4754 // Depth is tunable via the builder without losing the PX4 policies.
4755 let deep = QoSProfile::px4().keep_last(5);
4756 assert_eq!(deep.depth, 5);
4757 assert_eq!(deep.reliability, QoSReliabilityPolicy::BestEffort);
4758 assert_eq!(deep.durability, QoSDurabilityPolicy::Volatile);
4759 }
4760
4761 // --- RmwConfig Tests ---
4762
4763 #[test]
4764 fn test_rmw_config_default() {
4765 let config = RmwConfig::default();
4766 assert_eq!(config.locator, "tcp/127.0.0.1:7447");
4767 assert_eq!(config.mode, SessionMode::Client);
4768 assert_eq!(config.domain_id, 0);
4769 assert_eq!(config.node_name, "node");
4770 assert_eq!(config.namespace, "");
4771 }
4772
4773 #[test]
4774 fn test_rmw_config_custom() {
4775 let config = RmwConfig {
4776 locator: "tcp/192.168.1.1:7447",
4777 mode: SessionMode::Peer,
4778 domain_id: 42,
4779 node_name: "talker",
4780 namespace: "/ns1",
4781 properties: &[("agent_port", "2019")],
4782 };
4783 assert_eq!(config.locator, "tcp/192.168.1.1:7447");
4784 assert_eq!(config.mode, SessionMode::Peer);
4785 assert_eq!(config.domain_id, 42);
4786 assert_eq!(config.node_name, "talker");
4787 assert_eq!(config.namespace, "/ns1");
4788 assert_eq!(config.properties.len(), 1);
4789 assert_eq!(config.properties[0].0, "agent_port");
4790 }
4791
4792 #[test]
4793 fn test_rmw_config_is_copy() {
4794 let config = RmwConfig::default();
4795 let config2 = config; // Copy
4796 assert_eq!(config.locator, config2.locator);
4797 assert_eq!(config.domain_id, config2.domain_id);
4798 }
4799
4800 #[test]
4801 fn test_rmw_config_clone() {
4802 let config = RmwConfig::default();
4803 let cloned = RmwConfig { ..config };
4804 assert_eq!(cloned.locator, config.locator);
4805 assert_eq!(cloned.node_name, config.node_name);
4806 }
4807}
4808
4809#[cfg(test)]
4810mod rx_buffer_hint_tests {
4811 use super::*;
4812
4813 /// issue 0896 / phase-402 — the hint must SURVIVE being set on `TopicInfo`.
4814 ///
4815 /// This is the seam every path funnels through: the C register writes it
4816 /// here, `nros-rmw-cffi` reads `topic.rx_buffer_hint` back out when
4817 /// building `rmw_subscription_options_t`, and a size-classing backend
4818 /// routes on it. A default that silently stayed 0 is the original defect,
4819 /// so the round trip is worth an assertion rather than an assumption.
4820 #[test]
4821 fn a_hint_set_on_topic_info_is_readable_back() {
4822 let t = TopicInfo::new("/chatter", "std_msgs/msg/Int32", "").with_rx_buffer_hint(4096);
4823 assert_eq!(t.rx_buffer_hint, 4096);
4824 }
4825
4826 /// Zero is "no opinion", not "zero bytes". The C options struct uses 0 as
4827 /// its unset sentinel and the executor only calls the setter when the
4828 /// caller stated something, so the default must stay 0 for the
4829 /// backend-default path to remain reachable.
4830 #[test]
4831 fn the_default_hint_is_zero_meaning_no_opinion() {
4832 let t = TopicInfo::new("/chatter", "std_msgs/msg/Int32", "");
4833 assert_eq!(t.rx_buffer_hint, 0);
4834 }
4835
4836 /// The builder must not disturb the rest of the descriptor — it is applied
4837 /// AFTER domain/namespace/node in the C path, so a setter that reset a
4838 /// field would drop identity that discovery depends on.
4839 #[test]
4840 fn setting_the_hint_preserves_the_rest_of_the_descriptor() {
4841 let make = || {
4842 TopicInfo::new("/chatter", "std_msgs/msg/Int32", "hash")
4843 .with_domain(7)
4844 .with_namespace("/ns")
4845 };
4846 let base = make();
4847 let hinted = make().with_rx_buffer_hint(1234);
4848 assert_eq!(hinted.name, base.name);
4849 assert_eq!(hinted.type_name, base.type_name);
4850 assert_eq!(hinted.type_hash, base.type_hash);
4851 assert_eq!(hinted.domain_id, base.domain_id);
4852 assert_eq!(hinted.namespace, base.namespace);
4853 assert_eq!(hinted.rx_buffer_hint, 1234);
4854 }
4855}
4856
4857#[cfg(test)]
4858mod granted_qos_tests {
4859 use super::*;
4860
4861 /// A backend with no read-back answers an ABSENCE, and the trait default
4862 /// is where that is decided for every backend that does not override it.
4863 ///
4864 /// The tempting defaults are both wrong and both silent: the REQUEST
4865 /// ("you got what you asked for", which is the claim this accessor exists
4866 /// to stop being made for free) and `QOS_PROFILE_DEFAULT` ("RELIABLE
4867 /// KEEP_LAST(10)", a profile nobody chose).
4868 struct Mute;
4869
4870 impl Publisher for Mute {
4871 type Error = TransportError;
4872 fn publish_raw(&self, _data: &[u8]) -> Result<(), TransportError> {
4873 Ok(())
4874 }
4875 fn buffer_error(&self) -> TransportError {
4876 TransportError::BufferTooSmall
4877 }
4878 fn serialization_error(&self) -> TransportError {
4879 TransportError::SerializationError
4880 }
4881 }
4882
4883 #[test]
4884 fn the_default_read_back_is_an_absence_not_the_request() {
4885 let answered = Publisher::actual_qos(&Mute);
4886 assert_eq!(answered, QoSProfile::QOS_PROFILE_UNKNOWN);
4887 assert!(answered.has_unknown());
4888 assert_ne!(
4889 answered,
4890 QoSProfile::QOS_PROFILE_DEFAULT,
4891 "a backend that cannot say must not answer a profile nobody chose"
4892 );
4893 }
4894
4895 /// The phase-467 RMW gap-closure design study's Q1 — the trait default is
4896 /// a REFUSAL, never a gid.
4897 ///
4898 /// `Mute` overrides nothing, so it stands for every backend that has not
4899 /// implemented the accessor. The tempting default is `Ok([0u8; 24])`: it
4900 /// compiles, it needs no error plumbing, and it is indistinguishable from
4901 /// a real gid a backend zero-padded — which is the RFC-0089 Part I shape
4902 /// this method exists on the loud side of.
4903 #[test]
4904 fn the_default_gid_is_a_refusal_not_zeros() {
4905 let answered = Publisher::get_gid(&Mute);
4906 assert_eq!(answered, Err(TransportError::Unsupported));
4907 assert_ne!(answered, Ok([0u8; nros_core::PUBLISHER_GID_SIZE]));
4908 }
4909
4910 /// Row 7 of the same study — the ONE spelling of "the application owns
4911 /// the assertion", which two backends had written out by hand.
4912 ///
4913 /// `Unknown` is deliberately not manual: it is what a `*_get_actual_qos`
4914 /// read-back writes for a policy the backend could not report, and an
4915 /// absence is not a request. Reading it as manual would make a backend
4916 /// with no read-back refuse an assertion nobody asked for.
4917 #[test]
4918 fn only_the_two_manual_kinds_are_manual() {
4919 assert!(QoSLivelinessPolicy::ManualByNode.is_manual());
4920 assert!(QoSLivelinessPolicy::ManualByTopic.is_manual());
4921 assert!(!QoSLivelinessPolicy::Automatic.is_manual());
4922 assert!(!QoSLivelinessPolicy::None.is_manual());
4923 assert!(!QoSLivelinessPolicy::Unknown.is_manual());
4924 }
4925
4926 /// `UNKNOWN` and `SYSTEM_DEFAULT` are DIFFERENT absences and the ABI keeps
4927 /// them apart: "nobody asked" is resolvable by the backend, "I cannot say"
4928 /// is not. A profile that confused them would let a read-back round-trip
4929 /// into a create call and silently pick policies.
4930 #[test]
4931 fn unknown_is_not_the_system_default_sentinel() {
4932 assert_ne!(
4933 QoSProfile::QOS_PROFILE_UNKNOWN,
4934 QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT
4935 );
4936 assert!(QoSProfile::QOS_PROFILE_UNKNOWN.has_unknown());
4937 assert!(!QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.has_unknown());
4938 }
4939
4940 /// The round trip is REFUSED. Feeding a read-back straight back into a
4941 /// create is the one way `UNKNOWN` reaches a request, and it must fail
4942 /// loudly rather than resolve to whatever the backend picks.
4943 #[test]
4944 fn a_read_back_profile_cannot_be_used_as_a_request() {
4945 // A mask that supports everything, so the refusal cannot be an
4946 // accident of a narrow backend.
4947 let everything = QoSPolicyMask(u32::MAX);
4948 assert_eq!(
4949 QoSProfile::QOS_PROFILE_UNKNOWN.validate_against(everything),
4950 Err(TransportError::IncompatibleQos(QoSPolicyMask::NONE)),
4951 "UNKNOWN is an absence; nothing can grant it, so no policy is named"
4952 );
4953 // And exactly one unknown policy is enough — a partial read-back is
4954 // the common case, not the exotic one.
4955 let mut partial = QoSProfile::QOS_PROFILE_DEFAULT;
4956 partial.durability = QoSDurabilityPolicy::Unknown;
4957 assert_eq!(
4958 partial.validate_against(everything),
4959 Err(TransportError::IncompatibleQos(QoSPolicyMask::NONE))
4960 );
4961 // The same profile without the sentinel is admitted, so the refusal
4962 // above is about `UNKNOWN` and not about the mask.
4963 assert_eq!(
4964 QoSProfile::QOS_PROFILE_DEFAULT.validate_against(everything),
4965 Ok(())
4966 );
4967 }
4968
4969 /// `required_policies` cannot express `UNKNOWN`, which is why the refusal
4970 /// lives one level up. Asserted so that a future attempt to smuggle it in
4971 /// as an unsatisfiable mask bit has to change a test that says why not.
4972 #[test]
4973 fn unknown_demands_no_capability_bit() {
4974 let mut p = QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT;
4975 p.durability = QoSDurabilityPolicy::Unknown;
4976 p.liveliness_kind = QoSLivelinessPolicy::Unknown;
4977 assert_eq!(p.required_policies().0, QoSPolicyMask(0).0);
4978 }
4979}