nros_rmw_cffi/lib.rs
1//! C function table adapter for nros RMW backends.
2//!
3//! This crate provides a vtable-based bridge so that backends written in C,
4//! C++, Zig, Ada, or any language with a C-compatible ABI can implement the
5//! nros `Session` / `Publisher` / `Subscription` / service traits without
6//! writing Rust code.
7//!
8//! # Usage (C backend implementor)
9//!
10//! 1. Include `<nros/rmw_vtable.h>`
11//! 2. Implement all function pointers in `nros_rmw_vtable_t`
12//! 3. Call `nros_rmw_cffi_register_named("<name>", &my_vtable)` before creating sessions
13//!
14//! # Usage (Rust consumer)
15//!
16//! Enable the `rmw-cffi` feature on `nros` and use `Executor<CffiSession>`.
17
18#![no_std]
19
20#[cfg(feature = "alloc")]
21extern crate alloc;
22
23#[cfg(feature = "std")]
24extern crate std;
25
26use core::{
27 cell::{Cell, UnsafeCell},
28 ffi::c_void,
29 sync::atomic::Ordering,
30};
31
32// RFC-0054 (phase-299 W1.3) — committed bindgen output from
33// `packages/core/nros-rmw-abi/include/nros/*.h`. This module is the ONLY
34// definition of the RMW ABI types (`nros_rmw_*_t`); the `NrosRmw*` names
35// below are compat aliases over the generated items.
36// (bindgen emits the vtable fn-pointer types inline, which trips
37// clippy::type_complexity under `-D warnings`; allowed here rather than
38// editing the generated file.)
39#[allow(clippy::type_complexity)]
40pub mod generated;
41pub use generated::*;
42
43use nros_rmw::{
44 ClientTrait, GraphEndpointInfo, GraphEntityKind, MessageInfo, Publisher, QoSDurabilityPolicy,
45 QoSHistoryPolicy, QoSLivelinessPolicy, QoSProfile, QoSReliabilityPolicy, ServiceInfo,
46 ServiceRequest, ServiceTrait, Session, TopicInfo, TransportError,
47};
48
49// Phase 115.L.0 — generic Rust→C-vtable adapter. Lives behind the
50// `alloc` feature because each entity handle is boxed for stable
51// address mgmt; every nros backend already requires alloc.
52#[cfg(feature = "alloc")]
53pub mod rust_adapter;
54
55#[cfg(feature = "alloc")]
56pub use rust_adapter::{RustBackend, RustBackendAdapter};
57
58// Phase 249 P4b.1 — `.init_array` ctor self-registration
59// (`nros_rmw_register_backend!` macro lives here).
60pub mod section;
61
62// ============================================================================
63// Phase 102.1 / RFC-0054 — `rmw_ret_t` named return codes
64// ============================================================================
65//
66// The constants live in `generated` (from `<nros/rmw_ret.h>`); only the
67// compat alias and the re-typed `OK` shadow live here.
68
69/// Compat alias for the generated `rmw_ret_t` typedef.
70/// Zero on success; negative on error.
71pub type NrosRmwRet = rmw_ret_t;
72
73// Anchor every C-stub-transport symbol so they survive
74// `--gc-sections` when integration tests link against
75// `libnros_rmw_cffi`. Only compiled when the c-stub-test feature
76// is on; otherwise no C anchor + no toolchain dep.
77#[cfg(feature = "c-stub-test")]
78unsafe extern "C" {
79 fn nros_c_stub_make_ops(out: *mut core::ffi::c_void);
80 fn nros_c_stub_reset_counters();
81 fn nros_c_stub_get_open_calls() -> u32;
82 fn nros_c_stub_get_close_calls() -> u32;
83 fn nros_c_stub_get_write_calls() -> u32;
84 fn nros_c_stub_get_read_calls() -> u32;
85}
86#[cfg(feature = "c-stub-test")]
87#[doc(hidden)]
88pub fn _c_stub_transport_vtable_anchor() -> [*const core::ffi::c_void; 6] {
89 [
90 nros_c_stub_make_ops as *const _,
91 nros_c_stub_reset_counters as *const _,
92 nros_c_stub_get_open_calls as *const _,
93 nros_c_stub_get_close_calls as *const _,
94 nros_c_stub_get_write_calls as *const _,
95 nros_c_stub_get_read_calls as *const _,
96 ]
97}
98/// Map a `TransportError` to the corresponding `rmw_ret_t` code.
99///
100/// By-reference because `TransportError` carries a `String` on its
101/// dynamic-diagnostic variant and is not `Copy`. The string itself is
102/// dropped at the boundary — embedded RMW callers cannot afford a
103/// thread-local error buffer.
104pub fn ret_from_error(err: &TransportError) -> NrosRmwRet {
105 match err {
106 TransportError::Timeout => NROS_RMW_RET_TIMEOUT,
107 TransportError::WouldBlock => NROS_RMW_RET_WOULD_BLOCK,
108 TransportError::TooLarge => NROS_RMW_RET_MESSAGE_TOO_LARGE,
109 TransportError::BufferTooSmall => NROS_RMW_RET_BUFFER_TOO_SMALL,
110 TransportError::MessageTooLarge => NROS_RMW_RET_MESSAGE_TOO_LARGE,
111 TransportError::InvalidArgument => NROS_RMW_RET_INVALID_ARGUMENT,
112 // Issue 0468 — InvalidConfig used to borrow INVALID_ARGUMENT, so a
113 // capacity the BUILD cannot honour arrived looking like a caller
114 // passing something wrong. It has its own code now.
115 TransportError::InvalidConfig => NROS_RMW_RET_INVALID_CONFIG,
116 TransportError::Unsupported => NROS_RMW_RET_UNSUPPORTED,
117 TransportError::BadAlloc => NROS_RMW_RET_BAD_ALLOC,
118 TransportError::IncompatibleQos(_) => NROS_RMW_RET_INCOMPATIBLE_QOS,
119 TransportError::TopicNameInvalid => NROS_RMW_RET_TOPIC_NAME_INVALID,
120 TransportError::NodeNameNonExistent => NROS_RMW_RET_NODE_NAME_NON_EXISTENT,
121 TransportError::LoanNotSupported => NROS_RMW_RET_LOAN_NOT_SUPPORTED,
122 TransportError::NoData => NROS_RMW_RET_NO_DATA,
123 TransportError::IncompatibleAbi => NROS_RMW_RET_INCOMPATIBLE_ABI,
124 // Phase 155.B.3 — distinguish wire-level connection failure
125 // from generic backend error so the FreeRTOS / RV64 C+C++
126 // `init -> -X` logs identify the actual class. zenoh-pico's
127 // `ZpicoError::Session` (zpico_open returned -3) and
128 // `ZpicoError::Generic` (zpico_init returned -1) both flow
129 // through `ZpicoError → ConnectionFailed`; the cmake-built
130 // FreeRTOS C/C++ tests will now surface NOT_FOUND (the
131 // user-side mapping in `rclc_support_init`) instead of the
132 // generic RCL_RET_ERROR catch-all.
133 TransportError::ConnectionFailed | TransportError::Disconnected => {
134 NROS_RMW_RET_CONNECTION_FAILED
135 }
136 // Everything else collapses to NROS_RMW_RET_ERROR. Backends
137 // that want fine-grained reporting should adopt the named
138 // variants above (Phase 102.2 sweep).
139 _ => NROS_RMW_RET_ERROR,
140 }
141}
142
143/// Map a `rmw_ret_t` returned by a C-side vtable function back to
144/// a `TransportError` for the Rust caller. Inverse of `ret_from_error`
145/// — used when `nros-rmw-cffi`'s `CffiSession` etc. receive a code
146/// from the registered C backend.
147///
148/// `NROS_RMW_RET_OK` is mapped to `TransportError::Backend("ok")` as a
149/// programming-error sentinel; callers should branch on the success
150/// path before calling this. Unknown negative codes collapse to the
151/// generic `TransportError::Backend("unknown rmw_ret_t")` so a future
152/// constant added to the C header degrades gracefully on the Rust side.
153pub fn error_from_ret(ret: NrosRmwRet) -> TransportError {
154 match ret {
155 NROS_RMW_RET_OK => {
156 TransportError::Backend("ok (logic error: positive ret_t at error site)")
157 }
158 NROS_RMW_RET_ERROR => TransportError::Backend("rmw_ret error"),
159 NROS_RMW_RET_TIMEOUT => TransportError::Timeout,
160 NROS_RMW_RET_BAD_ALLOC => TransportError::BadAlloc,
161 NROS_RMW_RET_INVALID_ARGUMENT => TransportError::InvalidArgument,
162 NROS_RMW_RET_INVALID_CONFIG => TransportError::InvalidConfig,
163 NROS_RMW_RET_UNSUPPORTED => TransportError::Unsupported,
164 // phase-417 G7 — `NONE`, and that is the honest answer rather than a
165 // default: `rmw_ret_t` is one integer, so a C backend's refusal
166 // crosses this boundary with no policy attached. `policy_name()`
167 // then reports `None` and the diagnostic says the backend named no
168 // policy, instead of naming one nobody identified.
169 NROS_RMW_RET_INCOMPATIBLE_QOS => {
170 TransportError::IncompatibleQos(nros_rmw::QoSPolicyMask::NONE)
171 }
172 NROS_RMW_RET_TOPIC_NAME_INVALID => TransportError::TopicNameInvalid,
173 NROS_RMW_RET_NODE_NAME_NON_EXISTENT => TransportError::NodeNameNonExistent,
174 NROS_RMW_RET_LOAN_NOT_SUPPORTED => TransportError::LoanNotSupported,
175 NROS_RMW_RET_NO_DATA => TransportError::NoData,
176 NROS_RMW_RET_WOULD_BLOCK => TransportError::WouldBlock,
177 NROS_RMW_RET_BUFFER_TOO_SMALL => TransportError::BufferTooSmall,
178 NROS_RMW_RET_MESSAGE_TOO_LARGE => TransportError::MessageTooLarge,
179 NROS_RMW_RET_INCOMPATIBLE_ABI => TransportError::IncompatibleAbi,
180 // Phase 155.B.3 — inverse of `ret_from_error`'s
181 // `ConnectionFailed | Disconnected → CONNECTION_FAILED`
182 // mapping. Decodes the new vtable-level code back to the
183 // `TransportError::ConnectionFailed` variant; downstream
184 // `transport_error_to_ret` in nros-c surfaces it as
185 // `RCLC_RET_NOT_FOUND` (-4) to the user.
186 NROS_RMW_RET_CONNECTION_FAILED => TransportError::ConnectionFailed,
187 _ => TransportError::Backend("unknown rmw_ret_t"),
188 }
189}
190
191// ============================================================================
192// Phase 102.3 / RFC-0054 — typed entity structs (defined in `generated`)
193// ============================================================================
194//
195// The `nros_rmw_*_t` structs live in `generated` (from
196// `<nros/rmw_entity.h>`); the `NrosRmw*` names are compat aliases.
197
198// The trait-level infinite spelling and the header's sentinel are the
199// same value by contract; a header edit that drifts one fails here.
200const _: () = assert!(nros_rmw::DURATION_INFINITE_MS as i64 == NROS_RMW_DURATION_INFINITE_MS);
201
202// The trait's gid width and the ABI header's are the same number by contract,
203// which is the whole point of the phase-467 RMW gap-closure design study's
204// Q1(a) — `Publisher::get_gid` hands its bytes straight into `rmw_gid_t::data`
205// and back, so a header edit that drifts one fails here rather than
206// truncating an identifier at the seam.
207const _: () = assert!(nros_rmw::PUBLISHER_GID_SIZE == RMW_GID_STORAGE_SIZE as usize);
208
209/// Compat alias for the generated `rmw_qos_profile_t`.
210pub type NrosRmwQos = rmw_qos_profile_t;
211/// Compat alias for the generated `rmw_gid_t`.
212pub type NrosRmwGid = rmw_gid_t;
213/// Compat alias for the generated `rmw_session_t`.
214pub type NrosRmwSession = rmw_session_t;
215/// issue 0808 — session creation options; NULL means every default.
216pub type NrosRmwSessionOptions = rmw_session_options_t;
217/// Phase 376 W5/B1 — a graph node. The first argument of the four `create_*`
218/// slots, in place of the fabricated per-call session view they used to take.
219pub type NrosRmwNode = rmw_node_t;
220/// Compat alias for the generated `rmw_publisher_t`.
221pub type NrosRmwPublisher = rmw_publisher_t;
222/// Compat alias for the generated `rmw_subscription_t`.
223pub type NrosRmwSubscription = rmw_subscription_t;
224
225/// A vtable with every slot NULL — the base for struct-update syntax.
226///
227/// Phase 376 W4 — a vtable literal must name EVERY field, and this crate's
228/// tests build 26 of them. Adding a slot therefore meant adding one `None,`
229/// line to each, twenty-six times: tedious, and a place to miss one. The
230/// compiler catches a miss, but only after the diff has grown by the slot count
231/// times twenty-six. With this, a test names the slots it actually scripts and
232/// ends with `..EMPTY_VTABLE`, so a new slot costs one line in the header and
233/// nothing here.
234///
235/// Deliberately a `const` and not a `Default` impl: `Default` would make an
236/// all-NULL vtable constructible by accident, and an all-NULL vtable is exactly
237/// what `nros_rmw_cffi_register_named` must REFUSE (issue 0349). A const used
238/// explicitly as a literal's base cannot be reached that way.
239pub const EMPTY_VTABLE: NrosRmwVtable = NrosRmwVtable {
240 create_session: None,
241 destroy_session: None,
242 drive_io: None,
243 create_publisher: None,
244 destroy_publisher: None,
245 publish: None,
246 create_subscription: None,
247 destroy_subscription: None,
248 take: None,
249 has_data: None,
250 create_service: None,
251 destroy_service: None,
252 take_request: None,
253 has_request: None,
254 send_response: None,
255 create_client: None,
256 destroy_client: None,
257 send_request: None,
258 take_response: None,
259 subscription_event_init: None,
260 subscription_take_event: None,
261 publisher_take_event: None,
262 publisher_event_init: None,
263 publisher_assert_liveliness: None,
264 next_deadline_ms: None,
265 set_wake_callback: None,
266 borrow_loaned_message: None,
267 publish_loaned_message: None,
268 return_loaned_message_from_publisher: None,
269 take_loaned_message: None,
270 return_loaned_message_from_subscription: None,
271 service_server_is_available: None,
272 take_sequence: None,
273 publish_streamed: None,
274 ping_session: None,
275 subscription_supports_in_place: None,
276 process_raw_in_place: None,
277 get_implementation_identifier: None,
278 get_serialization_format: None,
279 feature_supported: None,
280 get_gid_for_publisher: None,
281 publisher_count_matched_subscriptions: None,
282 subscription_count_matched_publishers: None,
283 publisher_get_actual_qos: None,
284 subscription_get_actual_qos: None,
285 client_request_publisher_get_actual_qos: None,
286 client_response_subscription_get_actual_qos: None,
287 service_request_subscription_get_actual_qos: None,
288 service_response_publisher_get_actual_qos: None,
289 publisher_wait_for_all_acked: None,
290 take_with_info: None,
291 take_loaned_message_with_info: None,
292 get_node_names: None,
293 get_topic_names_and_types: None,
294 get_service_names_and_types: None,
295 get_publisher_names_and_types_by_node: None,
296 get_subscriber_names_and_types_by_node: None,
297 get_service_names_and_types_by_node: None,
298 get_client_names_and_types_by_node: None,
299 get_publishers_info_by_topic: None,
300 get_subscriptions_info_by_topic: None,
301 count_publishers: None,
302 count_subscribers: None,
303 node_get_graph_guard_condition: None,
304 create_node: None,
305 destroy_node: None,
306 set_log_severity: None,
307 required_rx_bytes: None,
308 supported_qos_policies: None,
309};
310
311/// Compat alias for the generated `rmw_service_t`.
312pub type NrosRmwService = rmw_service_t;
313/// Compat alias for the generated `rmw_client_t`.
314pub type NrosRmwClient = rmw_client_t;
315/// Compat alias for the generated `nros_rmw_vtable_t`.
316pub type NrosRmwVtable = nros_rmw_vtable_t;
317
318// The generated struct intentionally derives only Copy/Clone/Debug;
319// consumers (and the hand-written predecessor) compare QoS profiles.
320impl PartialEq for rmw_qos_profile_t {
321 fn eq(&self, other: &Self) -> bool {
322 self.reliability == other.reliability
323 && self.durability == other.durability
324 && self.history == other.history
325 && self.liveliness_kind == other.liveliness_kind
326 && self.depth == other.depth
327 && self.deadline_ms == other.deadline_ms
328 && self.lifespan_ms == other.lifespan_ms
329 && self.liveliness_lease_ms == other.liveliness_lease_ms
330 && self.avoid_ros_namespace_conventions == other.avoid_ros_namespace_conventions
331 }
332}
333impl Eq for rmw_qos_profile_t {}
334
335// The QoS profile constants below are `#define` struct-literal macros in the
336// C header; bindgen does not translate function-like/struct-literal macros,
337// so the Rust-side literals stay here (built from the generated types).
338//
339// Phase 376 W5/B2 — these used BARE INTEGERS with the policy name in a
340// trailing comment (`reliability: 1, // RELIABLE`). When the values took
341// upstream's numbering, every one of them kept compiling and started meaning a
342// different policy: `history: 0` went from KEEP_LAST to SYSTEM_DEFAULT and
343// `reliability: 0` from BEST_EFFORT to SYSTEM_DEFAULT. A comment is not a
344// binding. They name the generated constant now, so the next renumbering is a
345// compile error or nothing at all.
346//
347// issue 1256 / phase-454 W1 — and the DEPTHS now name the SSoT for the same
348// reason the policies name the generated enums. They were bare integers
349// (`depth: 10`, `depth: 5`, `depth: 1000`) with the upstream profile in a doc
350// comment above, which is the shape the paragraph above is about, one field
351// over. `QoSProfile::QOS_PROFILE_*` is the tree's one statement of what
352// upstream's profiles are (`nros-rmw/src/traits.rs`, gated field-by-field
353// against `docs/reference/rmw-qos-profiles.txt`), so these read it.
354
355/// The SSoT's depth, narrowed to the width the C ABI carries.
356///
357/// `as u16` would be a silent truncation, and `PARAMETERS` sits at 1000 — one
358/// decimal place from a value this ABI cannot hold. Const-evaluated, so a table
359/// depth past the ABI's reach is a COMPILE error at the profile that states it
360/// rather than a subscription silently created at `depth % 65536`. The same
361/// refusal `NrosRmwQos::try_from` makes at runtime (issue 0241), made where the
362/// value is a constant and the check therefore costs nothing.
363const fn abi_depth(depth: u32) -> u16 {
364 assert!(
365 depth <= u16::MAX as u32,
366 "a QoS profile states a history depth the RMW C ABI's u16 cannot carry",
367 );
368 depth as u16
369}
370
371/// Standard `rmw_qos_profile_default`-equivalent.
372pub const NROS_RMW_QOS_PROFILE_DEFAULT: NrosRmwQos = NrosRmwQos {
373 reliability: generated::NROS_RMW_RELIABILITY_RELIABLE as u8,
374 durability: generated::NROS_RMW_DURABILITY_VOLATILE as u8,
375 history: generated::NROS_RMW_HISTORY_KEEP_LAST as u8,
376 // SYSTEM_DEFAULT, not AUTOMATIC: upstream's `rmw_qos_profile_default`
377 // and `_sensor_data` both leave liveliness at the sentinel. Issue 0829
378 // corrected this on SYSTEM_DEFAULT and left the siblings; phase-428 W10.
379 liveliness_kind: rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_SYSTEM_DEFAULT as u8,
380 depth: abi_depth(nros_rmw::QoSProfile::QOS_PROFILE_DEFAULT.depth),
381 _reserved0: 0,
382 deadline_ms: 0,
383 lifespan_ms: 0,
384 liveliness_lease_ms: 0,
385 avoid_ros_namespace_conventions: 0,
386 _reserved1: [0; 3],
387};
388
389/// Standard `rmw_qos_profile_sensor_data`-equivalent.
390pub const NROS_RMW_QOS_PROFILE_SENSOR_DATA: NrosRmwQos = NrosRmwQos {
391 reliability: generated::NROS_RMW_RELIABILITY_BEST_EFFORT as u8,
392 durability: generated::NROS_RMW_DURABILITY_VOLATILE as u8,
393 history: generated::NROS_RMW_HISTORY_KEEP_LAST as u8,
394 // SYSTEM_DEFAULT, not AUTOMATIC: upstream's `rmw_qos_profile_default`
395 // and `_sensor_data` both leave liveliness at the sentinel. Issue 0829
396 // corrected this on SYSTEM_DEFAULT and left the siblings; phase-428 W10.
397 liveliness_kind: rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_SYSTEM_DEFAULT as u8,
398 depth: abi_depth(nros_rmw::QoSProfile::QOS_PROFILE_SENSOR_DATA.depth),
399 _reserved0: 0,
400 deadline_ms: 0,
401 lifespan_ms: 0,
402 liveliness_lease_ms: 0,
403 avoid_ros_namespace_conventions: 0,
404 _reserved1: [0; 3],
405};
406
407/// Standard `rmw_qos_profile_services_default`-equivalent.
408///
409/// Stated independently rather than aliased to `_DEFAULT`. Upstream states it
410/// as its own initialiser, and an alias makes a future divergence
411/// unrepresentable -- the two profiles happen to agree today, which is not the
412/// same as being the same profile.
413pub const NROS_RMW_QOS_PROFILE_SERVICES_DEFAULT: NrosRmwQos = NrosRmwQos {
414 ..NROS_RMW_QOS_PROFILE_DEFAULT
415};
416
417/// Standard `rmw_qos_profile_parameters`-equivalent.
418pub const NROS_RMW_QOS_PROFILE_PARAMETERS: NrosRmwQos = NrosRmwQos {
419 depth: abi_depth(nros_rmw::QoSProfile::QOS_PROFILE_PARAMETERS.depth),
420 ..NROS_RMW_QOS_PROFILE_DEFAULT
421};
422
423/// Standard `rmw_qos_profile_system_default`-equivalent — **all sentinel**.
424///
425/// issue 0829. This aliased `_DEFAULT` until 2026-09-03, which said the
426/// constant carried no meaning of its own: `SYSTEM_DEFAULT` became a
427/// byte-for-byte synonym for a concrete reliable / volatile / keep-last(10)
428/// profile. Upstream's `rmw_qos_profile_system_default` names no concrete
429/// policy at all, and the RMW resolves the absence — differently per backend
430/// (`rmw_cyclonedds_cpp` → depth 1, `rmw_zenoh_cpp` → depth 42), which is why
431/// no baked number here could be right.
432///
433/// Every field is now zero, so this is also what a `memset` of the struct
434/// gives: the ABI stops having two answers for the same bytes. Mirrors
435/// `NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT` in `nros/rmw_entity.h`.
436pub const NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT: NrosRmwQos = NrosRmwQos {
437 reliability: generated::NROS_RMW_RELIABILITY_SYSTEM_DEFAULT as u8,
438 durability: generated::NROS_RMW_DURABILITY_SYSTEM_DEFAULT as u8,
439 history: generated::NROS_RMW_HISTORY_SYSTEM_DEFAULT as u8,
440 liveliness_kind: generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_SYSTEM_DEFAULT as u8,
441 depth: abi_depth(nros_rmw::QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT.depth),
442 _reserved0: 0,
443 deadline_ms: 0,
444 lifespan_ms: 0,
445 liveliness_lease_ms: 0,
446 avoid_ros_namespace_conventions: 0,
447 _reserved1: [0; 3],
448};
449
450// Phase-301 (issue 0241) — the QoS lowering is FALLIBLE: a depth the
451// C ABI's u16 cannot represent is a create-time error, never a silent
452// saturate. The duration fields are u32 ms on both sides (0 = unset,
453// `NROS_RMW_DURATION_INFINITE_MS` = explicit infinite) and pass
454// through unchanged; finer-grained callers lower via
455// `nros_rmw::duration_to_qos_ms` (sub-ms CEILs to 1 ms, past-u32
456// errors).
457impl TryFrom<QoSProfile> for NrosRmwQos {
458 type Error = TransportError;
459
460 fn try_from(qos: QoSProfile) -> Result<Self, TransportError> {
461 if qos.depth > u16::MAX as u32 {
462 return Err(TransportError::InvalidArgument);
463 }
464 Ok(Self {
465 // issue 0829 — the sentinel LOWERS; it is not resolved here.
466 // `NrosRmwQos` is what a C backend receives, and resolving the
467 // absence is that backend's job (cyclone answers depth 1, xrce
468 // leaves it for the Agent). Folding it to a concrete value on this
469 // side would hand every C backend the same answer and make the
470 // per-backend resolution unreachable.
471 reliability: match qos.reliability {
472 QoSReliabilityPolicy::SystemDefault => {
473 generated::NROS_RMW_RELIABILITY_SYSTEM_DEFAULT as u8
474 }
475 QoSReliabilityPolicy::BestEffort => {
476 generated::NROS_RMW_RELIABILITY_BEST_EFFORT as u8
477 }
478 QoSReliabilityPolicy::Reliable => generated::NROS_RMW_RELIABILITY_RELIABLE as u8,
479 // A read-back sentinel crossing back DOWN. `validate_against`
480 // refuses it as a request, so the only profile that reaches
481 // here carrying it is one the runtime built for a read-back
482 // OUT-parameter — which is exactly where it has to be legal.
483 QoSReliabilityPolicy::Unknown => generated::NROS_RMW_RELIABILITY_UNKNOWN as u8,
484 },
485 durability: match qos.durability {
486 QoSDurabilityPolicy::SystemDefault => {
487 generated::NROS_RMW_DURABILITY_SYSTEM_DEFAULT as u8
488 }
489 QoSDurabilityPolicy::Volatile => generated::NROS_RMW_DURABILITY_VOLATILE as u8,
490 QoSDurabilityPolicy::TransientLocal => {
491 generated::NROS_RMW_DURABILITY_TRANSIENT_LOCAL as u8
492 }
493 QoSDurabilityPolicy::Unknown => generated::NROS_RMW_DURABILITY_UNKNOWN as u8,
494 },
495 history: match qos.history {
496 QoSHistoryPolicy::SystemDefault => generated::NROS_RMW_HISTORY_SYSTEM_DEFAULT as u8,
497 QoSHistoryPolicy::KeepLast => generated::NROS_RMW_HISTORY_KEEP_LAST as u8,
498 QoSHistoryPolicy::KeepAll => generated::NROS_RMW_HISTORY_KEEP_ALL as u8,
499 QoSHistoryPolicy::Unknown => generated::NROS_RMW_HISTORY_UNKNOWN as u8,
500 },
501 liveliness_kind: qos.liveliness_kind as u8,
502 depth: qos.depth as u16,
503 _reserved0: 0,
504 deadline_ms: qos.deadline_ms,
505 lifespan_ms: qos.lifespan_ms,
506 liveliness_lease_ms: qos.liveliness_lease_ms,
507 avoid_ros_namespace_conventions: qos.avoid_ros_namespace_conventions as u8,
508 _reserved1: [0; 3],
509 })
510 }
511}
512
513/// The inverse of [`TryFrom<QoSProfile> for NrosRmwQos`] — what a C backend
514/// ANSWERED, back in the vocabulary a Rust caller reasons in.
515///
516/// Infallible on purpose, and the reason is the `_ =>` arms: this reads a
517/// value a BACKEND wrote, and a backend that writes a discriminant this ABI
518/// does not define has told us nothing about that policy. `Unknown` is exactly
519/// that statement, so an unrecognised value degrades to "I cannot say" rather
520/// than to a confident wrong policy or to an error that would discard the four
521/// fields that WERE readable. `depth` widens (`u16` -> `u32`) and cannot lose.
522pub fn qos_from_c(q: &NrosRmwQos) -> QoSProfile {
523 QoSProfile {
524 reliability: match q.reliability as i32 {
525 NROS_RMW_RELIABILITY_SYSTEM_DEFAULT => QoSReliabilityPolicy::SystemDefault,
526 NROS_RMW_RELIABILITY_RELIABLE => QoSReliabilityPolicy::Reliable,
527 NROS_RMW_RELIABILITY_BEST_EFFORT => QoSReliabilityPolicy::BestEffort,
528 _ => QoSReliabilityPolicy::Unknown,
529 },
530 durability: match q.durability as i32 {
531 generated::NROS_RMW_DURABILITY_SYSTEM_DEFAULT => QoSDurabilityPolicy::SystemDefault,
532 generated::NROS_RMW_DURABILITY_VOLATILE => QoSDurabilityPolicy::Volatile,
533 generated::NROS_RMW_DURABILITY_TRANSIENT_LOCAL => QoSDurabilityPolicy::TransientLocal,
534 _ => QoSDurabilityPolicy::Unknown,
535 },
536 history: match q.history as i32 {
537 generated::NROS_RMW_HISTORY_SYSTEM_DEFAULT => QoSHistoryPolicy::SystemDefault,
538 generated::NROS_RMW_HISTORY_KEEP_LAST => QoSHistoryPolicy::KeepLast,
539 generated::NROS_RMW_HISTORY_KEEP_ALL => QoSHistoryPolicy::KeepAll,
540 _ => QoSHistoryPolicy::Unknown,
541 },
542 liveliness_kind: match u32::from(q.liveliness_kind) {
543 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_SYSTEM_DEFAULT => {
544 QoSLivelinessPolicy::None
545 }
546 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_AUTOMATIC => {
547 QoSLivelinessPolicy::Automatic
548 }
549 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_MANUAL_BY_NODE => {
550 QoSLivelinessPolicy::ManualByNode
551 }
552 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_MANUAL_BY_TOPIC => {
553 QoSLivelinessPolicy::ManualByTopic
554 }
555 _ => QoSLivelinessPolicy::Unknown,
556 },
557 depth: u32::from(q.depth),
558 deadline_ms: q.deadline_ms,
559 lifespan_ms: q.lifespan_ms,
560 liveliness_lease_ms: q.liveliness_lease_ms,
561 avoid_ros_namespace_conventions: q.avoid_ros_namespace_conventions != 0,
562 // Not a DDS policy and not on the C QoS struct — a publisher-side
563 // transport hint (issue 0145). Nothing negotiates it, so a read-back
564 // has nothing to say about it and `false` is the struct's own default,
565 // not an answer.
566 tx_express: false,
567 }
568}
569
570// ============================================================================
571// Phase 108 / RFC-0054 — status-event types (defined in `generated`)
572// ============================================================================
573//
574// `rmw_event_type_t` is a module-consts alias (bindgen
575// `--default-enum-style=moduleconsts`), not a Rust enum, so the retired
576// `From` impls between it and `nros_rmw::EventKind` become plain functions.
577
578/// Compat alias for the generated `rmw_event_type_t::Type`
579/// (C-`unsigned`-sized event-kind discriminant).
580pub type NrosRmwEventKind = rmw_event_type_t::Type;
581/// Compat alias for the generated `rmw_liveliness_changed_status_t`.
582pub type NrosRmwLivelinessChangedStatus = rmw_liveliness_changed_status_t;
583/// Compat alias for the generated `rmw_count_status_t`.
584pub type NrosRmwCountStatus = rmw_count_status_t;
585/// Compat alias for the generated `rmw_event_payload_t` union.
586pub type NrosRmwEventPayload = rmw_event_payload_t;
587/// Compat alias for the generated `rmw_status_event_callback_t`
588/// (nullable — `Option`-wrapped fn pointer, per C ABI).
589pub type NrosRmwEventCallback = rmw_status_event_callback_t;
590
591/// Convert a trait-level [`nros_rmw::EventKind`] to the C ABI discriminant.
592/// Replaces the retired `From<nros_rmw::EventKind> for NrosRmwEventKind`.
593pub fn event_kind_to_c(k: nros_rmw::EventKind) -> NrosRmwEventKind {
594 use nros_rmw::EventKind as K;
595 use rmw_event_type_t as C;
596 match k {
597 K::LivelinessChanged => C::NROS_RMW_EVENT_LIVELINESS_CHANGED,
598 K::RequestedDeadlineMissed => C::NROS_RMW_EVENT_REQUESTED_DEADLINE_MISSED,
599 K::MessageLost => C::NROS_RMW_EVENT_MESSAGE_LOST,
600 K::LivelinessLost => C::NROS_RMW_EVENT_LIVELINESS_LOST,
601 K::OfferedDeadlineMissed => C::NROS_RMW_EVENT_OFFERED_DEADLINE_MISSED,
602 // unreachable for now (#[non_exhaustive])
603 _ => C::NROS_RMW_EVENT_MESSAGE_LOST,
604 }
605}
606
607/// Convert a C ABI event-kind discriminant to the trait-level
608/// [`nros_rmw::EventKind`]. Replaces the retired
609/// `From<NrosRmwEventKind> for nros_rmw::EventKind`. Unknown values map to
610/// `MessageLost`, mirroring the forward direction's fallback.
611pub fn event_kind_from_c(k: NrosRmwEventKind) -> nros_rmw::EventKind {
612 use nros_rmw::EventKind as K;
613 use rmw_event_type_t as C;
614 match k {
615 C::NROS_RMW_EVENT_LIVELINESS_CHANGED => K::LivelinessChanged,
616 C::NROS_RMW_EVENT_REQUESTED_DEADLINE_MISSED => K::RequestedDeadlineMissed,
617 C::NROS_RMW_EVENT_MESSAGE_LOST => K::MessageLost,
618 C::NROS_RMW_EVENT_LIVELINESS_LOST => K::LivelinessLost,
619 C::NROS_RMW_EVENT_OFFERED_DEADLINE_MISSED => K::OfferedDeadlineMissed,
620 _ => K::MessageLost,
621 }
622}
623
624// ============================================================================
625// Issue 1164 — the runtime is the caller of `*_take_event`
626// ============================================================================
627//
628// The status-event surface has two halves in the C ABI and a backend fills
629// exactly one of them (`rmw_vtable.h`, "how the three upstream parts map
630// here"):
631//
632// * `*_event_init` — the backend has a safe context and will CALL you.
633// `rust_adapter` fills this for every Rust backend, so zenoh arrives here.
634// * `*_take_event` — the backend has NOWHERE safe to deliver from, so it
635// buffers nothing and lets the caller drain its counters. cyclonedds fills
636// this one, and `subscriber.cpp` says what it expects of us in as many
637// words: "A caller polls them the way it already polls `has_data`."
638//
639// Nothing polled them. Both cyclonedds slots were implemented, read real
640// `dds_get_*_status` counters, and had no caller anywhere outside the
641// backend's own C++ test — so a Cyclone application could not observe a status
642// event through either half (issue 1164).
643//
644// This is that caller. It deliberately does NOT add a second user-facing
645// shape: `Subscription::register_event_callback` stays the one way to ask for
646// an event, and when the backend leaves `*_event_init` NULL the registration
647// is recorded HERE and satisfied by polling `*_take_event` from the entity's
648// ordinary data path. That is exactly how zenoh already behaves one layer
649// down — its shim fires registered callbacks from `has_data` / `try_recv_raw`
650// and from `publish_raw` — so an application sees the same API and the same
651// delivery points on either backend.
652//
653// **Deadline caveat.** `deadline_ms` is not forwarded on the poll path: there
654// is no slot to forward it to. A backend reached this way derives the deadline
655// from the entity's QoS (`qos.deadline_ms` at create time), so
656// `on_requested_deadline_missed(d)` observes the QoS deadline, not `d`. Ask
657// for the deadline in the QoS profile when the backend is a polled one.
658
659/// One status-event callback the runtime holds on the entity's behalf,
660/// because the backend filled `*_take_event` instead of `*_event_init`.
661///
662/// `cb` is the same nullable C callback `*_event_init` would have received;
663/// `user_ctx` is owned by whoever registered (`nros-node` keeps the boxed
664/// closure alive for the entity's lifetime).
665#[derive(Clone, Copy)]
666struct PolledEventReg {
667 cb: NrosRmwEventCallback,
668 user_ctx: *mut c_void,
669}
670
671/// Subscription-side event kinds, in registry-slot order.
672const SUB_EVENT_KINDS: [nros_rmw::EventKind; 3] = [
673 nros_rmw::EventKind::LivelinessChanged,
674 nros_rmw::EventKind::RequestedDeadlineMissed,
675 nros_rmw::EventKind::MessageLost,
676];
677
678/// Publisher-side event kinds, in registry-slot order.
679const PUB_EVENT_KINDS: [nros_rmw::EventKind; 2] = [
680 nros_rmw::EventKind::LivelinessLost,
681 nros_rmw::EventKind::OfferedDeadlineMissed,
682];
683
684/// Registry slot for a subscription-side kind. `None` for a publisher-side
685/// kind, which is a caller error rather than an unsupported capability — the
686/// backends answer it `INVALID_ARGUMENT` for the same reason.
687fn sub_event_slot(kind: nros_rmw::EventKind) -> Option<usize> {
688 SUB_EVENT_KINDS.iter().position(|k| *k == kind)
689}
690
691/// Registry slot for a publisher-side kind. See [`sub_event_slot`].
692fn pub_event_slot(kind: nros_rmw::EventKind) -> Option<usize> {
693 PUB_EVENT_KINDS.iter().position(|k| *k == kind)
694}
695
696/// A payload union with no member selected yet. The backend writes one of the
697/// two members before setting `taken`; we never read it unless it did.
698fn empty_event_payload() -> NrosRmwEventPayload {
699 NrosRmwEventPayload {
700 count: NrosRmwCountStatus {
701 total_count: 0,
702 total_count_change: 0,
703 },
704 }
705}
706
707// ============================================================================
708// Registration
709// ============================================================================
710//
711// Phase 104.B.2 — named registry replaces the singleton vtable.
712// Backends register under a stable identifier (`"zenoh"`, `"dds"`,
713// `"xrce"`, future `"uorb"`, `"cyclonedds"`); consumers look up
714// vtables by name via `nros_rmw_cffi_lookup`. Multiple backends can
715// coexist in the same process (bridge nodes).
716//
717// Capacity comes from the `NROS_RMW_MAX_BACKENDS` build-time env
718// var (default 8). See `build.rs`.
719//
720// Implementation: a fixed-size `[BackendSlot; MAX_BACKENDS]`
721// guarded by an atomic length counter. No alloc; `no_std`
722// compatible. Slot scan is O(N) for lookup but N is tiny (8 by
723// default). Each slot owns its name buffer; `name_ptr` returned
724// to consumers points into the slot and stays valid for the
725// program's lifetime.
726
727/// Compile-time max number of concurrently registered backends.
728/// Set via `NROS_RMW_MAX_BACKENDS` env var at build time
729/// (`build.rs`). Default 8.
730pub const MAX_BACKENDS: usize = parse_max_backends(env!("NROS_RMW_MAX_BACKENDS"));
731
732/// Issue 1419 — how many of [`MAX_BACKENDS`] the image DECLARED: its own
733/// backends, before the recorder's slot (`recorder-slot`) is added beside
734/// them. Equal to `MAX_BACKENDS` in an image that links no recorder.
735pub const DECLARED_BACKENDS: usize = parse_max_backends(env!("NROS_RMW_DECLARED_BACKENDS"));
736
737/// Issue 1419 — whether this registry reserves a slot for the recording
738/// backend, i.e. whether `nros-rmw-metadata` is linked.
739pub const RECORDER_SLOT: bool = cfg!(feature = "recorder-slot");
740
741const fn parse_max_backends(s: &str) -> usize {
742 parse_env_usize(s, "NROS_RMW_MAX_BACKENDS must be a decimal integer")
743}
744
745/// Const decimal parser for build.rs-emitted envs (`MAX_BACKENDS`,
746/// `NROS_RMW_SUBSCRIBER_SLOTS`).
747pub(crate) const fn parse_env_usize(s: &str, msg: &str) -> usize {
748 let bytes = s.as_bytes();
749 let mut i = 0usize;
750 let mut acc: usize = 0;
751 while i < bytes.len() {
752 let d = bytes[i];
753 if !d.is_ascii_digit() {
754 let _ = msg;
755 panic!("nros-rmw-cffi: build-time env must be a decimal integer");
756 }
757 acc = acc * 10 + (d - b'0') as usize;
758 i += 1;
759 }
760 acc
761}
762
763/// Maximum length of a backend name. Names are short ASCII
764/// identifiers (`"zenoh"`, `"cyclonedds"`); 32 bytes is generous.
765const BACKEND_NAME_MAX: usize = 32;
766
767#[repr(C)]
768struct BackendSlot {
769 /// Null-terminated UTF-8 backend name. Zero-initialized when
770 /// unused (`name[0] == 0`).
771 name: [u8; BACKEND_NAME_MAX],
772 vtable: *const NrosRmwVtable,
773}
774
775impl BackendSlot {
776 const fn empty() -> Self {
777 Self {
778 name: [0u8; BACKEND_NAME_MAX],
779 vtable: core::ptr::null(),
780 }
781 }
782
783 #[inline]
784 fn is_empty(&self) -> bool {
785 self.name[0] == 0
786 }
787
788 #[inline]
789 fn name_matches(&self, candidate: &[u8]) -> bool {
790 if self.is_empty() {
791 return false;
792 }
793 // Compare up to the first NUL or candidate length.
794 let mut i = 0usize;
795 while i < self.name.len() && i < candidate.len() {
796 if self.name[i] == 0 {
797 return false; // slot name shorter than candidate
798 }
799 if self.name[i] != candidate[i] {
800 return false;
801 }
802 i += 1;
803 }
804 // candidate fully consumed; slot must be NUL at i (same length)
805 i == candidate.len() && (i == self.name.len() || self.name[i] == 0)
806 }
807}
808
809// SAFETY: `BackendSlot::vtable` is a `*const` pointer used in a
810// `'static` context; once written it's never freed and the registry
811// is guarded by an atomic length counter for publication. Marker
812// trait implementations are required so the static array is
813// `Sync` across threads.
814unsafe impl Sync for BackendSlot {}
815
816/// Fixed-size registry. `slots[0..len]` are live; `slots[len..]`
817/// are zero-initialized. `len` is the publication fence.
818///
819/// `slots` lives in an `UnsafeCell` because we mutate through
820/// `&'static REGISTRY`. Safety invariants:
821/// * Slot writes happen only inside `nros_rmw_cffi_register_named`,
822/// which is documented "call before `Executor::open`" — backend
823/// ctors fire pre-main, manual calls precede session creation.
824/// * Slot reads via `nros_rmw_cffi_lookup` and `get_vtable` happen
825/// after `Executor::open`, well after registration completes.
826/// * The atomic `len` provides the release-acquire fence so any
827/// reader that sees `len = N` also sees the populated slot
828/// contents for indices `< N`.
829#[doc(hidden)]
830pub struct Registry {
831 slots: core::cell::UnsafeCell<[BackendSlot; MAX_BACKENDS]>,
832 len: portable_atomic::AtomicUsize,
833}
834
835impl Registry {
836 #[doc(hidden)]
837 pub const fn new() -> Self {
838 let slots = {
839 #[allow(clippy::declare_interior_mutable_const)]
840 const E: BackendSlot = BackendSlot::empty();
841 [E; MAX_BACKENDS]
842 };
843 Self {
844 slots: core::cell::UnsafeCell::new(slots),
845 len: portable_atomic::AtomicUsize::new(0),
846 }
847 }
848
849 /// Borrow slot `i` immutably. Caller must guarantee
850 /// `i < self.len.load(Acquire)`.
851 #[inline]
852 unsafe fn slot(&self, i: usize) -> &BackendSlot {
853 // SAFETY: registry protocol guarantees slot stability once
854 // published via the atomic len fence.
855 unsafe { &(*self.slots.get())[i] }
856 }
857
858 /// Borrow slot `i` mutably. Caller must guarantee exclusive
859 /// access — either pre-publication (idx > current `len`) or
860 /// during an idempotent overwrite of an already-registered name.
861 #[inline]
862 #[allow(clippy::mut_from_ref)]
863 unsafe fn slot_mut(&self, i: usize) -> &mut BackendSlot {
864 // SAFETY: see Registry doc — writer-side discipline.
865 unsafe { &mut (*self.slots.get())[i] }
866 }
867}
868
869// SAFETY: see `Registry` doc-comment on the mutation protocol.
870unsafe impl Sync for Registry {}
871
872// Phase 241.D3-rev — `REGISTRY` is DEFINED once in this rlib (plain
873// `#[no_mangle]`). The single-runtime model puts exactly one Rust staticlib in any
874// link (the umbrella `nros-c` / `nros-cpp` bundles the backend as an rlib), so the
875// cffi rlib appears once and one strong definition is correct everywhere: pure-Rust
876// firmware, the NuttX build-std ELF, and the umbrella C/C++ staticlib alike. This
877// supersedes the slice-4 `external-registry`/provider split, which existed only
878// because the C/C++ link used to carry multiple Rust staticlibs.
879#[unsafe(no_mangle)]
880static REGISTRY: Registry = Registry::new();
881
882/// The single process-wide backend registry.
883#[inline]
884fn registry() -> &'static Registry {
885 ®ISTRY
886}
887
888// ============================================================================
889// Rust-adapter MessageInfo side channel
890// ============================================================================
891//
892// The stable C subscriber ABI returns only a `(payload, len)` pair from
893// `take_serialized`. Rust backends can produce `MessageInfo`, so the generic
894// Rust->C adapter stores that metadata keyed by the backend handle pointer
895// immediately before returning the payload length. The Rust CFFI subscriber
896// consumes it after the vtable call. Pure C/C++ backends never write this table
897// and keep the documented `None` metadata behavior.
898
899/// Issue 0271 — build-time configurable via `NROS_RMW_MESSAGE_INFO_SLOTS`
900/// (default 64). Under-sizing costs metadata, not correctness: a subscriber
901/// that finds no free slot reads back `None` for `MessageInfo`, which is the
902/// documented behaviour for backends that never populate the table at all.
903const MESSAGE_INFO_SLOTS: usize = crate::parse_env_usize(
904 env!("NROS_RMW_MESSAGE_INFO_SLOTS"),
905 "NROS_RMW_MESSAGE_INFO_SLOTS must be a decimal integer",
906);
907
908struct MessageInfoSlot {
909 key: portable_atomic::AtomicUsize,
910 valid: portable_atomic::AtomicBool,
911 info: UnsafeCell<MessageInfo>,
912 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
913 validate_requested: portable_atomic::AtomicBool,
914 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
915 integrity_valid: portable_atomic::AtomicBool,
916 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
917 integrity: UnsafeCell<nros_rmw::IntegrityStatus>,
918}
919
920impl MessageInfoSlot {
921 const fn empty() -> Self {
922 Self {
923 key: portable_atomic::AtomicUsize::new(0),
924 valid: portable_atomic::AtomicBool::new(false),
925 info: UnsafeCell::new(MessageInfo::new()),
926 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
927 validate_requested: portable_atomic::AtomicBool::new(false),
928 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
929 integrity_valid: portable_atomic::AtomicBool::new(false),
930 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
931 integrity: UnsafeCell::new(nros_rmw::IntegrityStatus {
932 gap: 0,
933 duplicate: false,
934 crc_valid: None,
935 }),
936 }
937 }
938}
939
940// SAFETY: each slot is published by `key` and `valid` atomics. Writers store
941// `info` before setting `valid = true` with Release ordering; readers take
942// `valid` with AcqRel before copying the `MessageInfo`.
943unsafe impl Sync for MessageInfoSlot {}
944
945// issue 0739 — deliberately NOT annotated with a `// nros-pool:` formula.
946// `MessageInfoSlot`'s width depends on cfg (`alloc` + `safety-e2e` add three
947// more fields), so any constant here would be right for one build and wrong for
948// the rest. Issue 0271 measured 3,584 bytes at 64 slots in ITS configuration;
949// stating that as the cost would be the fabrication the inventory exists to
950// avoid. The knob still appears in the table with its default — the table says
951// "no byte figure", which is true, rather than implying it is free.
952static MESSAGE_INFO_TABLE: [MessageInfoSlot; MESSAGE_INFO_SLOTS] = {
953 #[allow(clippy::declare_interior_mutable_const)]
954 const E: MessageInfoSlot = MessageInfoSlot::empty();
955 [E; MESSAGE_INFO_SLOTS]
956};
957
958fn lookup_message_info_slot(key: usize) -> Option<&'static MessageInfoSlot> {
959 if key == 0 {
960 return None;
961 }
962 MESSAGE_INFO_TABLE
963 .iter()
964 .find(|slot| slot.key.load(Ordering::Acquire) == key)
965}
966
967#[cfg(feature = "alloc")]
968fn get_or_insert_message_info_slot(key: usize) -> Option<&'static MessageInfoSlot> {
969 if key == 0 {
970 return None;
971 }
972 for slot in &MESSAGE_INFO_TABLE {
973 let current = slot.key.load(Ordering::Acquire);
974 if current == key {
975 return Some(slot);
976 }
977 if current == 0
978 && slot
979 .key
980 .compare_exchange(0, key, Ordering::AcqRel, Ordering::Acquire)
981 .is_ok()
982 {
983 return Some(slot);
984 }
985 }
986 None
987}
988
989#[cfg(feature = "alloc")]
990pub(crate) fn store_cffi_message_info(key: usize, info: Option<MessageInfo>) {
991 let Some(slot) = get_or_insert_message_info_slot(key) else {
992 return;
993 };
994 match info {
995 Some(info) => {
996 // SAFETY: this slot is keyed to one subscriber backend handle. The
997 // executor owns each subscriber mutably while receiving, so writes
998 // for the same key are serialized.
999 unsafe {
1000 *slot.info.get() = info;
1001 }
1002 slot.valid.store(true, Ordering::Release);
1003 }
1004 None => slot.valid.store(false, Ordering::Release),
1005 }
1006}
1007
1008fn take_cffi_message_info(key: usize) -> Option<MessageInfo> {
1009 let slot = lookup_message_info_slot(key)?;
1010 if !slot.valid.swap(false, Ordering::AcqRel) {
1011 return None;
1012 }
1013 // SAFETY: `valid.swap(false)` gives this reader exclusive consumption of the
1014 // last stored `MessageInfo` for this key.
1015 Some(unsafe { *slot.info.get() })
1016}
1017
1018#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1019fn request_cffi_integrity_status(key: usize) {
1020 let Some(slot) = get_or_insert_message_info_slot(key) else {
1021 return;
1022 };
1023 slot.integrity_valid.store(false, Ordering::Release);
1024 slot.validate_requested.store(true, Ordering::Release);
1025}
1026
1027#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1028pub(crate) fn take_cffi_integrity_request(key: usize) -> bool {
1029 lookup_message_info_slot(key)
1030 .map(|slot| slot.validate_requested.swap(false, Ordering::AcqRel))
1031 .unwrap_or(false)
1032}
1033
1034#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1035pub(crate) fn store_cffi_integrity_status(key: usize, status: nros_rmw::IntegrityStatus) {
1036 let Some(slot) = get_or_insert_message_info_slot(key) else {
1037 return;
1038 };
1039 // SAFETY: integrity status follows the same per-subscriber handoff as
1040 // `info`; the CFFI subscriber owns receive calls mutably for this key.
1041 unsafe {
1042 *slot.integrity.get() = status;
1043 }
1044 slot.integrity_valid.store(true, Ordering::Release);
1045}
1046
1047#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1048fn take_cffi_integrity_status(key: usize) -> Option<nros_rmw::IntegrityStatus> {
1049 let slot = lookup_message_info_slot(key)?;
1050 if !slot.integrity_valid.swap(false, Ordering::AcqRel) {
1051 return None;
1052 }
1053 Some(unsafe { *slot.integrity.get() })
1054}
1055
1056fn clear_cffi_message_info(key: usize) {
1057 let Some(slot) = lookup_message_info_slot(key) else {
1058 return;
1059 };
1060 slot.valid.store(false, Ordering::Release);
1061 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1062 {
1063 slot.validate_requested.store(false, Ordering::Release);
1064 slot.integrity_valid.store(false, Ordering::Release);
1065 }
1066 slot.key.store(0, Ordering::Release);
1067}
1068
1069/// Issue 0332 — a vtable slot the runtime `.expect()`s on the hot path is
1070/// mandatory: a `None` there is a panic mid-spin, on a no_std target, the worst
1071/// place to discover an incomplete backend. Returns the name of the first
1072/// missing slot so registration can reject such a vtable loudly and early.
1073///
1074/// Issue 0349 — the list is CORE TRANSPORT only. It originally also required
1075/// `publisher_event_init`, `subscription_event_init` and
1076/// `publisher_assert_liveliness` (then `register_*_event` / `assert_publisher_liveliness`),
1077/// which refused the **xrce backend outright**:
1078/// its vtable NULLs all three deliberately, alongside ~14 other optional
1079/// capability slots this list correctly never required, so
1080/// `nros_rmw_xrce_register()` returned INVALID_ARGUMENT and xrce could not
1081/// register at all.
1082///
1083/// Those three are QoS-event and liveliness CAPABILITIES, not transport — and
1084/// the slots are `Option<fn>` precisely because C nullability encodes "not
1085/// provided" (RFC-0054). Requiring a slot whose type says it is optional was
1086/// the contradiction. `assert_publisher_liveliness`' own dispatch site had
1087/// documented "NULL function pointer = backend doesn't support manual
1088/// liveliness" the whole time, while the code `.expect()`ed it.
1089///
1090/// The three now report `TransportError::Unsupported` when used and absent.
1091/// That is the refinement this function's doc used to defer — the difference
1092/// between an optional slot and a missing required one is that the optional one
1093/// has a typed error at the point of use, which is exactly what makes dropping
1094/// it from this list safe.
1095fn first_missing_vtable_slot(v: &NrosRmwVtable) -> Option<&'static str> {
1096 macro_rules! require {
1097 ($($slot:ident),+ $(,)?) => {
1098 $( if v.$slot.is_none() { return Some(stringify!($slot)); } )+
1099 };
1100 }
1101 require!(
1102 create_session,
1103 destroy_session,
1104 create_publisher,
1105 destroy_publisher,
1106 create_subscription,
1107 destroy_subscription,
1108 publish,
1109 drive_io,
1110 has_data,
1111 take,
1112 create_service,
1113 destroy_service,
1114 create_client,
1115 destroy_client,
1116 send_response,
1117 has_request,
1118 take_request,
1119 );
1120 // NOT required (issue 0349) — optional capabilities with a typed
1121 // `Unsupported` error at the point of use, exactly like the ~14 other
1122 // nullable slots (`borrow_loaned_message`, `take_loaned_message`, `next_deadline_ms`,
1123 // `service_server_is_available`, …) this list has always allowed to be NULL:
1124 // publisher_event_init, subscription_event_init,
1125 // assert_publisher_liveliness
1126 None
1127}
1128
1129/// Register a backend under a stable name. Multiple backends can
1130/// coexist; consumers select via [`nros_rmw_cffi_lookup`] or the
1131/// higher-level `Executor::node_builder(...).rmw(...)` path.
1132///
1133/// Names must be UTF-8, NUL-terminated, ≤ 31 bytes (excluding NUL).
1134/// Reserved names today: `"zenoh"`, `"dds"`, `"xrce"`,
1135/// `"cyclonedds"`, future `"uorb"`. (`"default"` was the implicit name of
1136/// the unnamed single-argument form, deleted by phase-482 W6.)
1137///
1138/// Returns:
1139/// * `NROS_RMW_RET_OK` on success.
1140/// * `NROS_RMW_RET_INVALID_ARGUMENT` if `name` / `vtable` is
1141/// NULL, the name is empty, or exceeds 31 bytes.
1142/// * `NROS_RMW_RET_ERROR` if the registry is full
1143/// (`MAX_BACKENDS` reached without a matching entry).
1144///
1145/// Duplicate registration of the same name overwrites the
1146/// previous vtable (idempotent for ctor-fires-twice cases).
1147///
1148/// # Safety
1149///
1150/// * `name` must be a valid NUL-terminated UTF-8 string.
1151/// * `vtable` must remain valid for the program's lifetime.
1152#[unsafe(no_mangle)]
1153pub unsafe extern "C" fn nros_rmw_cffi_register_named(
1154 name: *const core::ffi::c_char,
1155 vtable: *const NrosRmwVtable,
1156) -> NrosRmwRet {
1157 if name.is_null() || vtable.is_null() {
1158 return NROS_RMW_RET_INVALID_ARGUMENT;
1159 }
1160
1161 // Issue 0332 — reject an incomplete vtable at registration rather than
1162 // panicking mid-spin. SAFETY: `vtable` is non-null (checked) and the caller
1163 // guarantees it is valid for the program's lifetime (see `# Safety`).
1164 if let Some(missing) = first_missing_vtable_slot(unsafe { &*vtable }) {
1165 let _ = missing; // named for debuggers; INVALID_ARGUMENT is the ABI signal
1166 return NROS_RMW_RET_INVALID_ARGUMENT;
1167 }
1168
1169 let name_u8 = name.cast::<u8>();
1170
1171 // Length-check the input. We scan up to BACKEND_NAME_MAX + 1
1172 // bytes; anything longer is rejected.
1173 let mut len = 0usize;
1174 while len < BACKEND_NAME_MAX {
1175 let b = unsafe { *name_u8.add(len) };
1176 if b == 0 {
1177 break;
1178 }
1179 len += 1;
1180 }
1181 if len == 0 {
1182 return NROS_RMW_RET_INVALID_ARGUMENT;
1183 }
1184 // Must have found a NUL within BACKEND_NAME_MAX.
1185 if unsafe { *name_u8.add(len) } != 0 {
1186 return NROS_RMW_RET_INVALID_ARGUMENT;
1187 }
1188
1189 let name_bytes = unsafe { core::slice::from_raw_parts(name_u8, len) };
1190
1191 // First pass: look for existing entry with same name → overwrite.
1192 let current_len = registry().len.load(Ordering::Acquire);
1193 for i in 0..current_len {
1194 // SAFETY: i < current_len, indices in bounds.
1195 let slot = unsafe { registry().slot(i) };
1196 if slot.name_matches(name_bytes) {
1197 // SAFETY: writer-side idempotent overwrite. The slot is
1198 // already published; concurrent readers will see either
1199 // the old or new vtable consistently, both valid.
1200 unsafe {
1201 let slot_mut = registry().slot_mut(i);
1202 slot_mut.vtable = vtable;
1203 }
1204 core::sync::atomic::fence(Ordering::Release);
1205 return NROS_RMW_RET_OK;
1206 }
1207 }
1208
1209 // No existing entry; append. Reserve a slot via atomic increment.
1210 let idx = registry().len.fetch_add(1, Ordering::AcqRel);
1211 if idx >= MAX_BACKENDS {
1212 // Roll back the increment so subsequent registers don't see a
1213 // stale `len > MAX_BACKENDS`. (Race window negligible — once
1214 // we hit capacity, no further append succeeds.)
1215 registry().len.store(MAX_BACKENDS, Ordering::Release);
1216 return NROS_RMW_RET_ERROR;
1217 }
1218
1219 // SAFETY: idx < MAX_BACKENDS, mutating an as-yet-unpublished slot.
1220 unsafe {
1221 let slot = registry().slot_mut(idx);
1222 slot.name[..len].copy_from_slice(name_bytes);
1223 slot.name[len] = 0;
1224 slot.vtable = vtable;
1225 }
1226 // Release-fence so concurrent lookups see both the name and the
1227 // vtable consistently with the updated `len`.
1228 core::sync::atomic::fence(Ordering::Release);
1229 NROS_RMW_RET_OK
1230}
1231
1232/// Look up a backend's vtable by name. Returns NULL if no backend
1233/// is registered under `name`.
1234///
1235/// # Safety
1236///
1237/// * `name` must be a valid NUL-terminated UTF-8 string.
1238#[unsafe(no_mangle)]
1239pub unsafe extern "C" fn nros_rmw_cffi_lookup(
1240 name: *const core::ffi::c_char,
1241) -> *const NrosRmwVtable {
1242 if name.is_null() {
1243 return core::ptr::null();
1244 }
1245 let name_u8 = name.cast::<u8>();
1246 let mut len = 0usize;
1247 while len < BACKEND_NAME_MAX {
1248 if unsafe { *name_u8.add(len) } == 0 {
1249 break;
1250 }
1251 len += 1;
1252 }
1253 if len == 0 || len == BACKEND_NAME_MAX {
1254 return core::ptr::null();
1255 }
1256 let name_bytes = unsafe { core::slice::from_raw_parts(name_u8, len) };
1257
1258 let current_len = registry().len.load(Ordering::Acquire);
1259 for i in 0..current_len {
1260 // SAFETY: i < current_len, indices in bounds; publication
1261 // fence via the atomic-len Acquire load.
1262 let slot = unsafe { registry().slot(i) };
1263 if slot.name_matches(name_bytes) {
1264 return slot.vtable;
1265 }
1266 }
1267 core::ptr::null()
1268}
1269
1270/// Diagnostic helper — fills `buf` with pointers to up to `cap`
1271/// registered backend names. Returns the number of names available
1272/// (may exceed `cap`). Pointer-valid for the program's lifetime.
1273///
1274/// # Safety
1275///
1276/// * `buf` must either be NULL (when `cap == 0`) or point at writable
1277/// memory of at least `cap * sizeof(*const c_char)` bytes.
1278#[unsafe(no_mangle)]
1279pub unsafe extern "C" fn nros_rmw_cffi_registered_names(
1280 buf: *mut *const core::ffi::c_char,
1281 cap: usize,
1282) -> usize {
1283 let n = registry().len.load(Ordering::Acquire);
1284 if !buf.is_null() && cap > 0 {
1285 let limit = n.min(cap);
1286 for i in 0..limit {
1287 // SAFETY: i < limit <= cap, buf capacity guaranteed by caller.
1288 let slot = unsafe { registry().slot(i) };
1289 unsafe {
1290 buf.add(i)
1291 .write(slot.name.as_ptr() as *const core::ffi::c_char)
1292 };
1293 }
1294 }
1295 n
1296}
1297
1298/// Phase 104.A — registry-presence probe. Returns `true` iff at
1299/// least one backend is registered. Used by `Executor::open` to
1300/// detect "user forgot to register a backend before opening the
1301/// session" and fail with a meaningful error.
1302#[inline]
1303pub fn backend_registered() -> bool {
1304 registry().len.load(Ordering::Acquire) > 0
1305}
1306
1307/// Phase 104.B — internal access to the registry for the Rust-side
1308/// adapter. `nros-node`'s `register_active_backend` removal already
1309/// switched to `backend_registered()` for the presence check; this
1310/// returns the vtable for any single-backend fast-path callers.
1311fn default_vtable() -> Option<&'static NrosRmwVtable> {
1312 let n = registry().len.load(Ordering::Acquire);
1313 if n == 0 {
1314 return None;
1315 }
1316 // SAFETY: index 0 < n, registry's len-Acquire fence orders the
1317 // slot read.
1318 let slot = unsafe { registry().slot(0) };
1319 if slot.vtable.is_null() {
1320 return None;
1321 }
1322 Some(unsafe { &*slot.vtable })
1323}
1324
1325/// Phase 128.A.3 — outcome of `resolve_backend`.
1326pub enum BackendResolution {
1327 /// Exactly one matching backend; use its vtable.
1328 Single(&'static NrosRmwVtable),
1329 /// No backend linked into the binary. Maps to
1330 /// [`NROS_RMW_RET_NO_BACKEND`].
1331 NoBackend,
1332 /// More than one backend linked and no selector given. Maps to
1333 /// [`NROS_RMW_RET_AMBIGUOUS_BACKEND`].
1334 Ambiguous,
1335 /// Selector did not match any registered backend. Maps to
1336 /// [`NROS_RMW_RET_UNKNOWN_BACKEND`].
1337 Unknown,
1338}
1339
1340/// Phase 128.A.3 — selection policy for the single-backend
1341/// `Executor::open` / `nros::init` path.
1342///
1343/// Algorithm:
1344///
1345/// 1. If `selector` is `Some(name)` (typically from `$NROS_RMW`),
1346/// look it up in the registry. Hit → [`BackendResolution::Single`];
1347/// miss → [`BackendResolution::Unknown`].
1348/// 2. Otherwise, if exactly one backend is registered, return it.
1349/// 3. Otherwise, if zero, [`BackendResolution::NoBackend`]; if more
1350/// than one, [`BackendResolution::Ambiguous`].
1351///
1352/// Callers convert the resolution to a [`NrosRmwRet`] via
1353/// [`backend_resolution_to_ret`].
1354///
1355/// Bridge consumers (`Executor::open_multi`) bypass this function and
1356/// call `nros_rmw_cffi_lookup` per spec instead.
1357pub fn resolve_backend(selector: Option<&[u8]>) -> BackendResolution {
1358 let n = registry().len.load(Ordering::Acquire);
1359 if let Some(name) = selector {
1360 let mut i = 0usize;
1361 while i < n {
1362 // SAFETY: i < n, registry len-Acquire fence orders the read.
1363 let slot = unsafe { registry().slot(i) };
1364 if slot.name_matches(name) {
1365 if slot.vtable.is_null() {
1366 return BackendResolution::Unknown;
1367 }
1368 return BackendResolution::Single(unsafe { &*slot.vtable });
1369 }
1370 i += 1;
1371 }
1372 return BackendResolution::Unknown;
1373 }
1374 match n {
1375 0 => BackendResolution::NoBackend,
1376 1 => default_vtable()
1377 .map(BackendResolution::Single)
1378 .unwrap_or(BackendResolution::NoBackend),
1379 _ => BackendResolution::Ambiguous,
1380 }
1381}
1382
1383/// Phase 128.A.3 — map a [`BackendResolution`] to its canonical
1384/// [`NrosRmwRet`]. [`BackendResolution::Single`] is *not* an error and
1385/// returns [`NROS_RMW_RET_OK`]; callers needing the vtable should
1386/// pattern-match on the resolution itself.
1387pub fn backend_resolution_to_ret(res: &BackendResolution) -> NrosRmwRet {
1388 match res {
1389 BackendResolution::Single(_) => NROS_RMW_RET_OK,
1390 BackendResolution::NoBackend => NROS_RMW_RET_NO_BACKEND,
1391 BackendResolution::Ambiguous => NROS_RMW_RET_AMBIGUOUS_BACKEND,
1392 BackendResolution::Unknown => NROS_RMW_RET_UNKNOWN_BACKEND,
1393 }
1394}
1395
1396// issue 0331 — `nros_rmw_cffi_set_custom_transport` takes the GENERATED
1397// `nros_transport_ops_t`, not the hand-written `nros_rmw::NrosTransportOps`.
1398//
1399// Under RFC-0054 the C header is the ABI SSoT and Rust consumes the committed
1400// bindgen output. The export used to take the hand-written Rust mirror while
1401// `rmw_transport.h` declared the generated type, so the two could drift and a
1402// C caller's struct layout was only accidentally correct.
1403//
1404// The two are still bridged by a `transmute_copy`, because
1405// `nros_rmw::set_custom_transport` takes the Rust type — but the bridge is
1406// guarded at COMPILE TIME here, so a drift that used to be silent is a build
1407// failure.
1408const _: () = {
1409 assert!(
1410 core::mem::size_of::<generated::nros_transport_ops_t>()
1411 == core::mem::size_of::<nros_rmw::NrosTransportOps>(),
1412 "nros_transport_ops_t and NrosTransportOps must have identical size \
1413 (RFC-0054: the header is the SSoT; regenerate with scripts/gen-abi-bindings.sh)"
1414 );
1415 assert!(
1416 core::mem::align_of::<generated::nros_transport_ops_t>()
1417 == core::mem::align_of::<nros_rmw::NrosTransportOps>(),
1418 "nros_transport_ops_t and NrosTransportOps must have identical alignment"
1419 );
1420};
1421
1422/// Phase 115.A.2 — C entry point for installing a custom transport.
1423///
1424/// Mirrors the Rust-side `nros_rmw::set_custom_transport(Some(...))`
1425/// (or `None` when `ops == NULL`) but returns the canonical
1426/// `rmw_ret_t` codes so non-Rust consumers don't have to
1427/// reach into nros-c's higher-level error enum.
1428///
1429/// The struct's contents are copied internally; the caller may
1430/// stack-allocate. Pass `NULL` to clear the slot.
1431///
1432/// # Safety
1433///
1434/// `ops` must either be `NULL` or point at a valid
1435/// `nros_transport_ops_t` whose four fn pointers stay live for the
1436/// lifetime of the registration (i.e. until a subsequent
1437/// `nros_rmw_cffi_set_custom_transport(NULL)` or a replacement
1438/// install).
1439#[unsafe(no_mangle)]
1440pub unsafe extern "C" fn nros_rmw_cffi_set_custom_transport(
1441 ops: *const generated::nros_transport_ops_t,
1442) -> NrosRmwRet {
1443 if ops.is_null() {
1444 // Clear: ignore any error (None is always accepted).
1445 let _ = unsafe { nros_rmw::set_custom_transport(None) };
1446 return NROS_RMW_RET_OK;
1447 }
1448 // SAFETY: caller guarantees `ops` is valid for one read.
1449 let src = unsafe { &*ops };
1450
1451 // issue 0331 — the generated type's fn-pointer slots are `Option<fn>`
1452 // (C nullability); `NrosTransportOps`' are plain `fn`. The two are
1453 // layout-identical via the null-pointer optimization, so a NULL slot
1454 // transmutes into a `fn` that is UB the moment the runtime calls it.
1455 // Taking the hand-written Rust type at this boundary made that
1456 // unrepresentable-looking but not unreachable — a C caller could always
1457 // pass NULL. Reject it here, before the copy.
1458 if src.open.is_none() || src.close.is_none() || src.write.is_none() || src.read.is_none() {
1459 return NROS_RMW_RET_INVALID_ARGUMENT;
1460 }
1461
1462 // SAFETY: layout equivalence of the two representations is asserted
1463 // above, and every fn slot is non-NULL per the check just made, so this
1464 // reinterpretation can neither silently mismatch nor produce a null `fn`.
1465 let copy: nros_rmw::NrosTransportOps = unsafe { core::mem::transmute_copy(src) };
1466 match unsafe { nros_rmw::set_custom_transport(Some(copy)) } {
1467 Ok(()) => NROS_RMW_RET_OK,
1468 Err(e) => ret_from_error(&e),
1469 }
1470}
1471
1472/// The UNNAMED open's answer to "which backend?" — issue 1050 defect (3).
1473///
1474/// This used to be `default_vtable()`, i.e. **registry slot 0**, described as a
1475/// single-backend fast path. It is not a fast path when two backends are
1476/// registered: it is a silent choice, and on a hosted target nobody makes it.
1477/// A Rust backend compiled into `libnros_cpp.a` registers from its
1478/// `.init_array` ctor, before `main`, and therefore before the image's own
1479/// generated `nros_app_register_backends()` runs — so slot 0 goes to whichever
1480/// backend the ARCHIVE happens to carry, not to the one the image declared. A
1481/// PX4 module declaring `BACKENDS uorb` opened zenoh that way.
1482///
1483/// `resolve_backend(None)` is the policy phase-128.A.3 already wrote for this
1484/// exact question, and `Executor::open_in` has consulted it since. It did not
1485/// reach here, so the tree carried TWO answers to one question and only one of
1486/// them refused an ambiguous registry — which is why the C++ surface was safe
1487/// and the C surface (`nros::internals::open_session` → [`CffiRmw::open`]) was
1488/// not. There is one answer now.
1489///
1490/// `InvalidConfig`, not `InvalidArgument`, for the ambiguous case: nothing has
1491/// been connected at this point, and reporting an unresolvable selection as a
1492/// transport failure is what sent issue 1050 looking for a missing router. The
1493/// empty-registry case keeps `InvalidArgument`, which is what it always
1494/// answered.
1495fn get_vtable() -> Result<&'static NrosRmwVtable, TransportError> {
1496 match resolve_backend(None) {
1497 BackendResolution::Single(vtable) => Ok(vtable),
1498 BackendResolution::Ambiguous => {
1499 nros_log::log_error!(
1500 nros_log::get_logger("nros_rmw_cffi"),
1501 "more than one RMW backend is registered and this open named \
1502 none; select one (hosted: $NROS_RMW=<name>; embedded: the \
1503 baked NROS_ENTRY_RMW rung), or open per-backend sessions"
1504 );
1505 Err(TransportError::InvalidConfig)
1506 }
1507 // `Unknown` is unreachable for a `None` selector; it collapses with
1508 // `NoBackend` rather than growing an arm that cannot be observed.
1509 BackendResolution::NoBackend | BackendResolution::Unknown => {
1510 Err(TransportError::InvalidArgument)
1511 }
1512 }
1513}
1514
1515// ============================================================================
1516// Helper: null-terminated string on the stack
1517// ============================================================================
1518
1519/// Write a Rust `&str` as a null-terminated byte sequence into a fixed buffer.
1520/// Returns a pointer to the buffer start (as C `char*`, matching the
1521/// generated ABI's string parameters).
1522fn to_c_str<const N: usize>(s: &str, buf: &mut [u8; N]) -> *const core::ffi::c_char {
1523 let len = s.len().min(N - 1);
1524 buf[..len].copy_from_slice(&s.as_bytes()[..len]);
1525 buf[len] = 0;
1526 buf.as_ptr().cast()
1527}
1528
1529/// Inverse of [`to_c_str`] — read a null-terminated byte buffer back
1530/// as a `&str`, stopping at the first NUL byte. Used by the
1531/// `topic_name()` / `type_name()` / `node_name()` accessors on the
1532/// `Cffi*` types so callers can introspect without round-tripping
1533/// through the vtable. Phase 102.5.
1534fn cstr_buf_to_str<const N: usize>(buf: &[u8; N]) -> &str {
1535 let len = buf.iter().position(|&b| b == 0).unwrap_or(N);
1536 // The buffers are written via `to_c_str` from a `&str`, so the
1537 // bytes between [..len] are guaranteed valid UTF-8. `from_utf8`
1538 // handles the (impossible) corruption case by returning empty.
1539 core::str::from_utf8(&buf[..len]).unwrap_or("")
1540}
1541
1542// ============================================================================
1543// Node table (phase-376 W5/B1)
1544// ============================================================================
1545//
1546// A SIDE table, not a field on `CffiSession`, for the same reason
1547// `MESSAGE_INFO_TABLE` above is one: the session's size is an ABI surface. It
1548// is what every C and C++ `_opaque` buffer is sized from, through a build-time
1549// probe that has to agree across every coordinate, and the guards that catch a
1550// disagreement are compile-time asserts in `nros-c` (issue 0472). Per-node
1551// bookkeeping is runtime state, not part of the session's shape — putting four
1552// name buffers inside the session grew `_opaque` by ~544 bytes in every C
1553// consumer and tripped those guards, which is the machinery working correctly
1554// and saying "this does not belong here".
1555
1556/// Distinct `(session, name, namespace)` triples the shim tracks. Raise with
1557/// `NROS_RMW_MAX_NODES`; the default mirrors the executor's `MAX_NODES`.
1558pub const MAX_NODES: usize = parse_env_usize(
1559 env!("NROS_RMW_MAX_NODES"),
1560 "NROS_RMW_MAX_NODES must be a decimal integer",
1561);
1562
1563struct NodeSlot {
1564 /// The owning session's `backend_data`, or 0 when free.
1565 session_key: portable_atomic::AtomicUsize,
1566 name: UnsafeCell<[u8; NODE_NAME_BUF_LEN]>,
1567 namespace_: UnsafeCell<[u8; NODE_NAME_BUF_LEN]>,
1568 backend_data: UnsafeCell<*mut c_void>,
1569}
1570
1571impl NodeSlot {
1572 const fn empty() -> Self {
1573 Self {
1574 session_key: portable_atomic::AtomicUsize::new(0),
1575 name: UnsafeCell::new([0u8; NODE_NAME_BUF_LEN]),
1576 namespace_: UnsafeCell::new([0u8; NODE_NAME_BUF_LEN]),
1577 backend_data: UnsafeCell::new(core::ptr::null_mut()),
1578 }
1579 }
1580}
1581
1582// SAFETY: a slot is claimed by a CAS on `session_key` from 0, and only the
1583// claiming thread writes its cells before any other reader can match it (a
1584// reader matches on `session_key` AND the names, which are written first).
1585// Entity creation is a setup-time operation on the executor thread.
1586unsafe impl Sync for NodeSlot {}
1587
1588static NODE_TABLE: [NodeSlot; MAX_NODES] = {
1589 #[allow(clippy::declare_interior_mutable_const)]
1590 const E: NodeSlot = NodeSlot::empty();
1591 [E; MAX_NODES]
1592};
1593
1594/// Release every node this session claimed. Called from `destroy_session`: a
1595/// static table that is never reclaimed would let a long-running image that
1596/// opens and closes sessions exhaust it.
1597/// Hand this session's node slots back, telling the backend first.
1598///
1599/// Issue 0800 — `create_node` was dispatched and `destroy_node` never was, so a
1600/// backend that allocated state in the create leaked it on every session close:
1601/// the shim forgot the node and the backend was never told it was gone. Nothing
1602/// caught it because `destroy_node` had no producer AND no consumer, which is
1603/// the state that reads as "optional slot" and is indistinguishable from
1604/// "nobody wired the other half".
1605///
1606/// Order matters: the nodes go before `destroy_session`, because a backend's
1607/// node state hangs off its session state.
1608fn release_session_nodes(
1609 session_key: usize,
1610 vtable: &'static NrosRmwVtable,
1611 session_view: &mut NrosRmwSession,
1612) {
1613 if session_key == 0 {
1614 return;
1615 }
1616 for slot in NODE_TABLE.iter() {
1617 if slot.session_key.load(Ordering::Acquire) != session_key {
1618 continue;
1619 }
1620 // SAFETY: written once by the claiming thread before the slot became
1621 // findable, and this session owns it.
1622 let backend_data = unsafe { *slot.backend_data.get() };
1623 if let Some(destroy) = vtable.destroy_node
1624 && !backend_data.is_null()
1625 {
1626 let mut view = node_view_of(slot, session_view);
1627 // SAFETY: `view` describes a node this session created through
1628 // `create_node`, and the session is still open.
1629 let ret = unsafe { destroy(&mut view) };
1630 if ret != NROS_RMW_RET_OK {
1631 nros_log::log_error!(
1632 nros_log::get_logger("nros_rmw_cffi"),
1633 "destroy_node failed with {}; the backend may still hold node state",
1634 ret
1635 );
1636 }
1637 }
1638 // SAFETY: same slot ownership as above; cleared before the slot is
1639 // published as free so a re-claim cannot see a stale pointer.
1640 unsafe { *slot.backend_data.get() = core::ptr::null_mut() };
1641 slot.session_key.store(0, Ordering::Release);
1642 }
1643}
1644
1645fn slot_str(cell: &UnsafeCell<[u8; NODE_NAME_BUF_LEN]>) -> &str {
1646 // SAFETY: cells are written once by the thread that claimed the slot,
1647 // before `session_key` makes it findable.
1648 cstr_buf_to_str(unsafe { &*cell.get() })
1649}
1650
1651fn find_node_slot(key: usize, name: &str, namespace: &str) -> Option<&'static NodeSlot> {
1652 NODE_TABLE.iter().find(|slot| {
1653 slot.session_key.load(Ordering::Acquire) == key
1654 && slot_str(&slot.name) == name
1655 && slot_str(&slot.namespace_) == namespace
1656 })
1657}
1658
1659fn claim_node_slot(key: usize, name: &str, namespace: &str) -> Option<&'static NodeSlot> {
1660 for slot in NODE_TABLE.iter() {
1661 if slot
1662 .session_key
1663 .compare_exchange(0, key, Ordering::AcqRel, Ordering::Acquire)
1664 .is_ok()
1665 {
1666 // SAFETY: the CAS above makes this thread the slot's owner, and a
1667 // reader cannot match it until the names are in place.
1668 unsafe {
1669 let _ = to_c_str(name, &mut *slot.name.get());
1670 let _ = to_c_str(namespace, &mut *slot.namespace_.get());
1671 *slot.backend_data.get() = core::ptr::null_mut();
1672 }
1673 return Some(slot);
1674 }
1675 }
1676 None
1677}
1678
1679fn node_view_of(slot: &'static NodeSlot, session_view: &mut NrosRmwSession) -> NrosRmwNode {
1680 NrosRmwNode {
1681 name: slot.name.get().cast(),
1682 namespace_: slot.namespace_.get().cast(),
1683 session: session_view as *mut NrosRmwSession,
1684 _reserved: [0u8; 8],
1685 // SAFETY: written once by the claiming thread before use.
1686 backend_data: unsafe { *slot.backend_data.get() },
1687 }
1688}
1689
1690// ============================================================================
1691// CffiSession
1692// ============================================================================
1693//
1694// Storage discipline:
1695// * Each Cffi* struct owns null-terminated name buffers as inline
1696// arrays. The C-side typed entity struct is rebuilt fresh on every
1697// FFI call via `make_*_view`, so move-invalidation of pointers
1698// into the buffer is impossible — the pointer always points to the
1699// *current* address of the buffer, computed at call time.
1700// * The backend writes `backend_data` (and `can_loan_messages` for
1701// pub/sub entities)
1702// into the FFI view; we copy the writes back into the Cffi*
1703// struct's fields after the call.
1704// * Strings ARE immutable for the entity's lifetime, so backends that
1705// stash the topic_name pointer for diagnostics see stable storage
1706// *as long as the Cffi* struct is not moved.* The Phase 102.4
1707// contract is "do not move a Cffi* struct after construction" —
1708// nano-ros embeds them inside the executor arena, which doesn't
1709// relocate.
1710
1711const NAME_BUF_LEN: usize = 256;
1712/// Node name / namespace storage. 64, not `NAME_BUF_LEN`: the executor bounds
1713/// both at `heapless::String<64>`, so a name that reached us through it cannot
1714/// be longer, and four nodes of two 256-byte buffers would put 2 KiB of mostly
1715/// zeroes in every session on an MCU.
1716const NODE_NAME_BUF_LEN: usize = 64;
1717/// Per-string bound for a marshalled session property. 256 so a TLS
1718/// certificate PATH fits; anything longer is refused rather than truncated.
1719const SESSION_PROPERTY_BUF_LEN: usize = 256;
1720/// `usize` spelling of the header's `RMW_SESSION_MAX_PROPERTIES` (RFC-0054:
1721/// the header is the SSoT; bindgen emits it as `i32`).
1722const MAX_SESSION_PROPERTIES: usize = generated::RMW_SESSION_MAX_PROPERTIES as usize;
1723
1724const HASH_BUF_LEN: usize = 128;
1725
1726/// Session backed by a C vtable.
1727pub struct CffiSession {
1728 vtable: &'static NrosRmwVtable,
1729 /// Borrowed-pointer storage for `node_name`. Outlives the session.
1730 node_name_buf: [u8; NAME_BUF_LEN],
1731 /// Borrowed-pointer storage for `namespace_`, filled at open from
1732 /// `RmwConfig::namespace` — issue 1444.
1733 ///
1734 /// It read "empty for now … reserved for future use" for as long as the
1735 /// field existed, and that comment WAS the bug: `rmw_vtable.h` promises a
1736 /// backend an `rmw_session_t` "with `node_name` / `namespace_` already
1737 /// filled", `Executor::open` had been filling `RmwConfig { namespace, .. }`
1738 /// all along, and only this seam dropped it — so every namespaced image
1739 /// advertised its SESSION node at the root while its per-node entities went
1740 /// where they belonged, and `ros2 node list` printed both without either
1741 /// looking wrong.
1742 ///
1743 /// EMPTY is passed through, not defaulted: the backend already reads an
1744 /// empty namespace as the root (`shim/session.rs`, "Treat empty namespace
1745 /// as root"), and a second default here would be a second place for the two
1746 /// to disagree.
1747 namespace_buf: [u8; NAME_BUF_LEN],
1748 /// Backend-private state, written by `vtable.create_session`.
1749 backend_data: *mut c_void,
1750 /// The domain this session was opened on. Kept because the entity paths
1751 /// need it and could not otherwise recover it: `create_session` consumed
1752 /// it and nothing stored it, so a caller with no support context silently
1753 /// fell back to domain 0 while the session itself was on another domain
1754 /// (issue 0801).
1755 domain_id: u32,
1756 /// What the REGISTERED backend said it honours, read once at open through
1757 /// `vtable.supported_qos_policies` (issue 1329).
1758 ///
1759 /// Cached rather than asked per create for two reasons, and the second is
1760 /// the load-bearing one: `Session::supported_qos_policies` takes `&self`
1761 /// while reaching the slot needs a `&mut` session view, and an answer that
1762 /// changed between two `create_publisher` calls would make one entity's
1763 /// refusal and another's acceptance depend on when they ran. The slot's
1764 /// contract is per session, so once per session is when to ask.
1765 qos_policies: nros_rmw::QoSPolicyMask,
1766}
1767
1768/// phase-381 W5/W6 — the C graph visitor, turned back into a Rust closure call.
1769///
1770/// `ctx` is a `*mut &mut dyn FnMut(...)`, which is how the borrowed closure
1771/// crosses the C boundary. The strings are BORROWED for this call only, which
1772/// is the contract on both sides.
1773///
1774/// A name that is not valid UTF-8 is SKIPPED rather than lossily converted: a
1775/// mangled node name is a different, plausible node.
1776///
1777/// # Safety
1778/// Called only by a backend slot this crate handed `ctx` to.
1779unsafe extern "C" fn node_visit_trampoline(
1780 ctx: *mut core::ffi::c_void,
1781 node_name: *const core::ffi::c_char,
1782 node_namespace: *const core::ffi::c_char,
1783 enclave: *const core::ffi::c_char,
1784) -> bool {
1785 if ctx.is_null() || node_name.is_null() || node_namespace.is_null() {
1786 return true; // skip this entry, keep enumerating
1787 }
1788 let cb = unsafe { &mut *(ctx as *mut &mut dyn FnMut(&str, &str, Option<&str>) -> bool) };
1789 let (Ok(name), Ok(ns)) = (
1790 unsafe { core::ffi::CStr::from_ptr(node_name) }.to_str(),
1791 unsafe { core::ffi::CStr::from_ptr(node_namespace) }.to_str(),
1792 ) else {
1793 return true;
1794 };
1795 let enc = if enclave.is_null() {
1796 None
1797 } else {
1798 unsafe { core::ffi::CStr::from_ptr(enclave) }.to_str().ok()
1799 };
1800 cb(name, ns, enc)
1801}
1802
1803/// Longest node name / namespace / topic this seam passes to a backend slot.
1804///
1805/// A longer one is REFUSED (`BufferTooSmall`), not truncated: a truncated node
1806/// name is a different, plausible node, and this seam's whole job is answering
1807/// "which node".
1808const GRAPH_NAME_CAP: usize = 256;
1809
1810/// phase-381 — the C endpoint-info visitor, back into a Rust closure call.
1811///
1812/// Mirrors [`names_and_types_visit_trampoline`]. `qos_profile` is deliberately
1813/// dropped: `GraphEndpointInfo` carries no `qos` field, because no backend can
1814/// fill it with a GRANTED profile today and reporting the requested one would
1815/// be the plausible wrong answer the slot exists to avoid.
1816///
1817/// # Safety
1818/// Called only by a backend slot this crate handed `ctx` to.
1819unsafe extern "C" fn endpoint_info_visit_trampoline(
1820 ctx: *mut core::ffi::c_void,
1821 info: *const generated::rmw_topic_endpoint_info_t,
1822) -> bool {
1823 if ctx.is_null() || info.is_null() {
1824 return true; // skip this entry, keep enumerating
1825 }
1826 let info = unsafe { &*info };
1827 let cb = unsafe { &mut *(ctx as *mut &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool) };
1828
1829 // Each borrowed string is skipped rather than lossily converted, for the
1830 // same reason as the names-and-types side.
1831 let cstr = |p: *const core::ffi::c_char| -> Option<&str> {
1832 if p.is_null() {
1833 None
1834 } else {
1835 unsafe { core::ffi::CStr::from_ptr(p) }.to_str().ok()
1836 }
1837 };
1838 let (Some(node_name), Some(node_namespace), Some(topic_type)) = (
1839 cstr(info.node_name),
1840 cstr(info.node_namespace),
1841 cstr(info.topic_type),
1842 ) else {
1843 return true;
1844 };
1845
1846 let view = GraphEndpointInfo {
1847 node_name,
1848 node_namespace,
1849 topic_type,
1850 is_publisher: info.endpoint_type == generated::rmw_endpoint_type_t::RMW_ENDPOINT_PUBLISHER,
1851 endpoint_gid: info.endpoint_gid.data,
1852 };
1853 cb(&view)
1854}
1855
1856/// phase-381 — the C names-and-types visitor, back into a Rust closure call.
1857///
1858/// # Safety
1859/// Called only by a backend slot this crate handed `ctx` to.
1860unsafe extern "C" fn names_and_types_visit_trampoline(
1861 ctx: *mut core::ffi::c_void,
1862 name: *const core::ffi::c_char,
1863 types: *const *const core::ffi::c_char,
1864 types_count: usize,
1865) -> bool {
1866 if ctx.is_null() || name.is_null() {
1867 return true; // skip this entry, keep enumerating
1868 }
1869 let cb = unsafe { &mut *(ctx as *mut &mut dyn FnMut(&str, &[&str]) -> bool) };
1870 let Ok(name) = (unsafe { core::ffi::CStr::from_ptr(name) }).to_str() else {
1871 return true;
1872 };
1873 // Bounded on the stack: the C side owns the array for this call only, and a
1874 // type that is not valid UTF-8 is SKIPPED rather than lossily converted — a
1875 // mangled type name is a different, plausible type.
1876 const TYPES_MAX: usize = 8;
1877 let mut buf: [&str; TYPES_MAX] = [""; TYPES_MAX];
1878 let mut n = 0usize;
1879 if !types.is_null() {
1880 for i in 0..types_count.min(TYPES_MAX) {
1881 let p = unsafe { *types.add(i) };
1882 if p.is_null() {
1883 continue;
1884 }
1885 if let Ok(t) = (unsafe { core::ffi::CStr::from_ptr(p) }).to_str() {
1886 buf[n] = t;
1887 n += 1;
1888 }
1889 }
1890 }
1891 cb(name, &buf[..n])
1892}
1893
1894/// Shared body for `count_publishers` / `count_subscribers`.
1895fn count_on_topic(
1896 view: &NrosRmwSession,
1897 f: unsafe extern "C" fn(
1898 *const NrosRmwSession,
1899 *const core::ffi::c_char,
1900 *mut usize,
1901 ) -> NrosRmwRet,
1902 topic_name: &str,
1903) -> Result<usize, TransportError> {
1904 // NUL-terminate on the stack; the C side borrows for the call only.
1905 const NAME_MAX: usize = 256;
1906 if topic_name.len() >= NAME_MAX {
1907 return Err(TransportError::InvalidArgument);
1908 }
1909 let mut buf = [0u8; NAME_MAX];
1910 buf[..topic_name.len()].copy_from_slice(topic_name.as_bytes());
1911 let mut out: usize = 0;
1912 let rc = unsafe {
1913 f(
1914 view as *const NrosRmwSession,
1915 buf.as_ptr() as *const core::ffi::c_char,
1916 &mut out,
1917 )
1918 };
1919 if rc == NROS_RMW_RET_OK {
1920 Ok(out)
1921 } else {
1922 Err(error_from_ret(rc))
1923 }
1924}
1925
1926impl CffiSession {
1927 /// Domain this session was opened on. Authoritative: it is the value the
1928 /// backend actually got, not a re-derivation that can disagree with it.
1929 pub fn domain_id(&self) -> u32 {
1930 self.domain_id
1931 }
1932
1933 /// RFC-0088 D4 / phase-421 W2 — the serialization format THIS session's
1934 /// backend speaks, as a NUL-terminated C string with `'static` lifetime,
1935 /// or NULL when the backend does not declare one.
1936 ///
1937 /// **Not** `<Self as Session>::SERIALIZATION_FORMAT`. That const is the
1938 /// trait default, `"cdr"`, and it is a lie for this type specifically:
1939 /// `CffiSession` is the one session that does not know its own backend at
1940 /// compile time — the vtable arrives at run time through
1941 /// `nros_rmw_cffi_register_named`, and an image may register several. The
1942 /// per-session answer therefore has to come from the vtable, which is the
1943 /// whole reason the slot got a body.
1944 ///
1945 /// A NULL slot answers NULL rather than `"cdr"`: a backend that declines
1946 /// to say what it speaks has not said `"cdr"`, and guessing on its behalf
1947 /// is the mistake `get_implementation_identifier`'s doc made for two
1948 /// phases (corrected phase-393 W2). Every in-tree backend fills the slot,
1949 /// so NULL means a foreign or pre-phase-421 vtable.
1950 pub fn serialization_format_cstr(&self) -> *const core::ffi::c_char {
1951 match self.vtable.get_serialization_format {
1952 // SAFETY: the slot is a non-NULL fn pointer a registered backend
1953 // installed; it takes no arguments and returns static storage.
1954 Some(f) => unsafe { f() },
1955 None => core::ptr::null(),
1956 }
1957 }
1958
1959 /// [`serialization_format_cstr`](Self::serialization_format_cstr) as a
1960 /// Rust string. `None` for a NULL slot or a non-UTF-8 answer.
1961 pub fn serialization_format(&self) -> Option<&'static str> {
1962 let ptr = self.serialization_format_cstr();
1963 if ptr.is_null() {
1964 return None;
1965 }
1966 // SAFETY: a backend's format name is a `'static` NUL-terminated string
1967 // literal — the slot's contract, and what every in-tree body returns.
1968 unsafe { core::ffi::CStr::from_ptr(ptr) }.to_str().ok()
1969 }
1970
1971 fn make_view(&mut self) -> NrosRmwSession {
1972 NrosRmwSession {
1973 node_name: self.node_name_buf.as_ptr().cast(),
1974 namespace_: self.namespace_buf.as_ptr().cast(),
1975 _reserved: [0u8; 8],
1976 backend_data: self.backend_data,
1977 }
1978 }
1979
1980 /// Phase 376 W5/B1 — find or create the node an entity belongs to, and
1981 /// return a view a `create_*` slot can take.
1982 ///
1983 /// This replaces `entity_view`, which fabricated a per-call
1984 /// `NrosRmwSession` whose `node_name` carried the entity's owning node.
1985 /// That worked and cost no ABI change, which is why phase-268 chose it —
1986 /// but it meant a backend learned about a node by reading a STRING off a
1987 /// session it was also using for transport state, and had to re-derive the
1988 /// set of nodes itself. zenoh does precisely that, linear-scanning declared
1989 /// liveliness tokens to answer "have I seen this node before".
1990 ///
1991 /// Now the runtime owns that question: `create_node` fires once per
1992 /// distinct `(name, namespace)`, which is only true because
1993 /// `Executor::create_node` deduplicates too (W5/B1.a).
1994 ///
1995 /// The returned `rmw_node_t` borrows `session_view` and the table's name
1996 /// cells, so the caller keeps `session_view` alive across the slot call —
1997 /// the same discipline `entity_view` documented, moved one level up.
1998 fn node_slot(
1999 &mut self,
2000 node_name: Option<&str>,
2001 namespace: &str,
2002 session_view: &mut NrosRmwSession,
2003 ) -> Result<NrosRmwNode, TransportError> {
2004 let key = self.backend_data as usize;
2005 if key == 0 {
2006 return Err(TransportError::InvalidArgument);
2007 }
2008
2009 // No node identity on the entity (direct-RMW / single-node path): fall
2010 // back to the session's own open-time name, which is what phase-268's
2011 // fallback did and keeps a one-node image working unchanged.
2012 let mut scratch = [0u8; NODE_NAME_BUF_LEN];
2013 let name: &str = match node_name {
2014 Some(n) if !n.is_empty() => n,
2015 _ => {
2016 let owned = self.node_name();
2017 let len = owned.len().min(NODE_NAME_BUF_LEN - 1);
2018 scratch[..len].copy_from_slice(&owned.as_bytes()[..len]);
2019 cstr_buf_to_str(&scratch)
2020 }
2021 };
2022
2023 // Truncating would MERGE two distinct nodes into one record and then
2024 // declare the wrong identity on the graph, so it is refused. The
2025 // executor bounds both at 64, so a name that arrived through it fits.
2026 if name.len() >= NODE_NAME_BUF_LEN || namespace.len() >= NODE_NAME_BUF_LEN {
2027 return Err(TransportError::InvalidArgument);
2028 }
2029
2030 if let Some(slot) = find_node_slot(key, name, namespace) {
2031 return Ok(node_view_of(slot, session_view));
2032 }
2033
2034 let slot = claim_node_slot(key, name, namespace).ok_or(TransportError::ConnectionFailed)?;
2035
2036 // A backend with no `create_node` keeps working: the node is then a
2037 // pure identity carrier with a NULL `backend_data`, which is what every
2038 // backend that has not implemented the slot already sees.
2039 if let Some(create) = self.vtable.create_node {
2040 let mut view = node_view_of(slot, session_view);
2041 let ret = unsafe {
2042 create(
2043 session_view as *mut NrosRmwSession,
2044 view.name,
2045 view.namespace_,
2046 &mut view,
2047 )
2048 };
2049 if ret != NROS_RMW_RET_OK {
2050 slot.session_key.store(0, Ordering::Release);
2051 return Err(error_from_ret(ret));
2052 }
2053 // SAFETY: this thread claimed the slot above and nothing else
2054 // writes it.
2055 unsafe { *slot.backend_data.get() = view.backend_data };
2056 }
2057 Ok(node_view_of(slot, session_view))
2058 }
2059
2060 /// Node name passed at session-open time.
2061 pub fn node_name(&self) -> &str {
2062 cstr_buf_to_str(&self.node_name_buf)
2063 }
2064
2065 /// Node namespace passed at session-open time — issue 1444.
2066 ///
2067 /// The sibling of [`node_name`](Self::node_name), and its absence was part
2068 /// of why the buffer stayed empty unnoticed: `node_name_buf` had a reader
2069 /// and `namespace_buf` had only the borrowed pointer a backend follows, so
2070 /// nothing in Rust could ask what the session's namespace was.
2071 ///
2072 /// `""` means the image declares none, which every backend reads as the
2073 /// root. It is NOT normalised here — see `namespace_buf`.
2074 pub fn namespace(&self) -> &str {
2075 cstr_buf_to_str(&self.namespace_buf)
2076 }
2077
2078 /// Open a new session via the **default** registered vtable
2079 /// (first entry in the registry — the RMW_IMPLEMENTATION-style
2080 /// fast path for single-backend builds).
2081 ///
2082 /// For explicit backend selection in multi-backend (bridge)
2083 /// binaries, use [`open_named`](Self::open_named).
2084 pub fn open(
2085 locator: &str,
2086 mode: u8,
2087 domain_id: u32,
2088 node_name: &str,
2089 namespace: &str,
2090 ) -> Result<Self, TransportError> {
2091 let vtable = get_vtable()?;
2092 Self::open_with_vtable(
2093 vtable,
2094 locator,
2095 mode,
2096 domain_id,
2097 node_name,
2098 namespace,
2099 core::ptr::null(),
2100 )
2101 }
2102
2103 /// phase-206 W3 — open a session carrying backend-specific configuration
2104 /// properties (`RmwConfig::properties` across the C seam).
2105 ///
2106 /// This is the rung that was missing. `RmwConfig` has carried
2107 /// `properties` since it existed and every Rust backend reads them, but
2108 /// nothing between the runtime and the vtable passed them on: this
2109 /// function's non-properties sibling handed `create_session` a NULL
2110 /// options pointer, and the Rust-backend adapter on the far side built
2111 /// `properties: &[]` unconditionally. So the only way to set a zenoh
2112 /// `listen` endpoint, a TLS certificate or a scouting timeout was to build
2113 /// an `RmwConfig` by hand in hosted Rust — a C or C++ image could state
2114 /// none of it, on any platform.
2115 ///
2116 /// Refused, never truncated: more than `RMW_SESSION_MAX_PROPERTIES`
2117 /// entries, an empty key or value, or either longer than
2118 /// `SESSION_PROPERTY_BUF_LEN`. All are `TransportError::InvalidArgument`.
2119 pub fn open_with_properties(
2120 locator: &str,
2121 mode: u8,
2122 domain_id: u32,
2123 node_name: &str,
2124 namespace: &str,
2125 properties: &[(&str, &str)],
2126 ) -> Result<Self, TransportError> {
2127 let vtable = get_vtable()?;
2128 if properties.is_empty() {
2129 return Self::open_with_vtable(
2130 vtable,
2131 locator,
2132 mode,
2133 domain_id,
2134 node_name,
2135 namespace,
2136 core::ptr::null(),
2137 );
2138 }
2139 Self::open_marshalling_properties(
2140 vtable, locator, mode, domain_id, node_name, namespace, properties,
2141 )
2142 }
2143
2144 /// The property-carrying half of [`open_with_properties`], kept in its own
2145 /// never-inlined frame so the marshalling buffers (up to
2146 /// `RMW_SESSION_MAX_PROPERTIES` × 2 × `SESSION_PROPERTY_BUF_LEN`) are only
2147 /// on the stack of a call that actually has properties to carry. An MCU
2148 /// image that configures nothing pays nothing.
2149 #[inline(never)]
2150 fn open_marshalling_properties(
2151 vtable: &'static NrosRmwVtable,
2152 locator: &str,
2153 mode: u8,
2154 domain_id: u32,
2155 node_name: &str,
2156 namespace: &str,
2157 properties: &[(&str, &str)],
2158 ) -> Result<Self, TransportError> {
2159 if properties.len() > MAX_SESSION_PROPERTIES {
2160 return Err(TransportError::InvalidArgument);
2161 }
2162 let mut key_bufs = [[0u8; SESSION_PROPERTY_BUF_LEN]; MAX_SESSION_PROPERTIES];
2163 let mut val_bufs = [[0u8; SESSION_PROPERTY_BUF_LEN]; MAX_SESSION_PROPERTIES];
2164 let mut entries = [rmw_session_property_t {
2165 key: core::ptr::null(),
2166 value: core::ptr::null(),
2167 }; MAX_SESSION_PROPERTIES];
2168 for (i, (key, value)) in properties.iter().enumerate() {
2169 // Empty or over-long is REFUSED. `to_c_str` truncates, which for a
2170 // name is a wrong-but-visible entity and for a TLS certificate
2171 // path is a session that fails somewhere unrelated.
2172 if key.is_empty()
2173 || value.is_empty()
2174 || key.len() >= SESSION_PROPERTY_BUF_LEN
2175 || value.len() >= SESSION_PROPERTY_BUF_LEN
2176 {
2177 return Err(TransportError::InvalidArgument);
2178 }
2179 key_bufs[i][..key.len()].copy_from_slice(key.as_bytes());
2180 val_bufs[i][..value.len()].copy_from_slice(value.as_bytes());
2181 entries[i] = rmw_session_property_t {
2182 key: key_bufs[i].as_ptr().cast(),
2183 value: val_bufs[i].as_ptr().cast(),
2184 };
2185 }
2186 let options = NrosRmwSessionOptions {
2187 localhost_only: 0,
2188 _reserved: [0u8; 7],
2189 enclave: core::ptr::null(),
2190 properties: entries.as_ptr(),
2191 property_count: properties.len(),
2192 };
2193 Self::open_with_vtable(
2194 vtable, locator, mode, domain_id, node_name, namespace, &options,
2195 )
2196 }
2197
2198 /// Phase 104.C.1 — open a new session against a named backend.
2199 /// Resolves `rmw_name` against the registry (Phase 104.B.2),
2200 /// returns `Err(TransportError::InvalidArgument)` if no backend
2201 /// is registered under that name.
2202 pub fn open_named(
2203 rmw_name: &str,
2204 locator: &str,
2205 mode: u8,
2206 domain_id: u32,
2207 node_name: &str,
2208 namespace: &str,
2209 ) -> Result<Self, TransportError> {
2210 // C-string-marshal `rmw_name` on the stack — registry lookup
2211 // expects NUL-terminated UTF-8.
2212 let mut name_buf = [0u8; BACKEND_NAME_MAX];
2213 if rmw_name.len() >= BACKEND_NAME_MAX {
2214 return Err(TransportError::InvalidArgument);
2215 }
2216 name_buf[..rmw_name.len()].copy_from_slice(rmw_name.as_bytes());
2217 // name_buf[rmw_name.len()] is already 0.
2218 let raw = unsafe { nros_rmw_cffi_lookup(name_buf.as_ptr() as *const _) };
2219 if raw.is_null() {
2220 return Err(TransportError::InvalidArgument);
2221 }
2222 // SAFETY: registry-issued pointer; valid for the program's lifetime.
2223 let vtable = unsafe { &*raw };
2224 Self::open_with_vtable(
2225 vtable,
2226 locator,
2227 mode,
2228 domain_id,
2229 node_name,
2230 namespace,
2231 core::ptr::null(),
2232 )
2233 }
2234
2235 /// [`open_named`](Self::open_named) carrying backend-specific
2236 /// configuration properties — see [`open_with_properties`](Self::open_with_properties).
2237 pub fn open_named_with_properties(
2238 rmw_name: &str,
2239 locator: &str,
2240 mode: u8,
2241 domain_id: u32,
2242 node_name: &str,
2243 namespace: &str,
2244 properties: &[(&str, &str)],
2245 ) -> Result<Self, TransportError> {
2246 let mut name_buf = [0u8; BACKEND_NAME_MAX];
2247 if rmw_name.len() >= BACKEND_NAME_MAX {
2248 return Err(TransportError::InvalidArgument);
2249 }
2250 name_buf[..rmw_name.len()].copy_from_slice(rmw_name.as_bytes());
2251 let raw = unsafe { nros_rmw_cffi_lookup(name_buf.as_ptr() as *const _) };
2252 if raw.is_null() {
2253 return Err(TransportError::InvalidArgument);
2254 }
2255 // SAFETY: registry-issued pointer; valid for the program's lifetime.
2256 let vtable = unsafe { &*raw };
2257 if properties.is_empty() {
2258 return Self::open_with_vtable(
2259 vtable,
2260 locator,
2261 mode,
2262 domain_id,
2263 node_name,
2264 namespace,
2265 core::ptr::null(),
2266 );
2267 }
2268 Self::open_marshalling_properties(
2269 vtable, locator, mode, domain_id, node_name, namespace, properties,
2270 )
2271 }
2272
2273 fn open_with_vtable(
2274 vtable: &'static NrosRmwVtable,
2275 locator: &str,
2276 mode: u8,
2277 domain_id: u32,
2278 node_name: &str,
2279 namespace: &str,
2280 options: *const NrosRmwSessionOptions,
2281 ) -> Result<Self, TransportError> {
2282 let mut loc_buf = [0u8; NAME_BUF_LEN];
2283 let loc_ptr = to_c_str(locator, &mut loc_buf);
2284
2285 let mut session = Self {
2286 vtable,
2287 node_name_buf: [0u8; NAME_BUF_LEN],
2288 namespace_buf: [0u8; NAME_BUF_LEN],
2289 backend_data: core::ptr::null_mut(),
2290 domain_id,
2291 qos_policies: nros_rmw::QoSPolicyMask::NONE,
2292 };
2293 let _ = to_c_str(node_name, &mut session.node_name_buf);
2294 // Issue 1444 — the other half of what `rmw_vtable.h` promises
2295 // `create_session`. Written BEFORE `view` borrows the buffer, and
2296 // passed through as given: an empty namespace is the root at the
2297 // backend, so normalising it here would be a second default.
2298 let _ = to_c_str(namespace, &mut session.namespace_buf);
2299
2300 let mut view = NrosRmwSession {
2301 node_name: session.node_name_buf.as_ptr().cast(),
2302 namespace_: session.namespace_buf.as_ptr().cast(),
2303 _reserved: [0u8; 8],
2304 backend_data: core::ptr::null_mut(),
2305 };
2306 let ret = unsafe {
2307 (vtable.create_session.expect("rmw vtable: create_session"))(
2308 loc_ptr,
2309 mode,
2310 domain_id,
2311 session.node_name_buf.as_ptr().cast(),
2312 // issue 0808 — NULL is "every default"; phase-206 W3 made this
2313 // a real argument. A caller reaches the backend's own
2314 // configuration through this struct rather than through a
2315 // locator it would have to parse
2316 // ([`open_with_properties`](Self::open_with_properties)).
2317 options,
2318 &mut view,
2319 )
2320 };
2321 // Phase 156.4 — diagnostic for bridge runtime
2322 // ConnectionFailed investigation. Logs the raw ret +
2323 // post-open backend_data state so callers see which of
2324 // the two failure paths fired. Gated on env var so
2325 // production traffic stays quiet.
2326 // issue 0589 — `nros_log`, never std stdio (fatal on Zephyr
2327 // native_sim). The env gate stays: it decides whether to FORMAT, which
2328 // is the cost worth avoiding on a hot open path; the level would only
2329 // decide whether to emit.
2330 #[cfg(feature = "std")]
2331 if std::env::var_os("NROS_RMW_TRACE_OPEN").is_some() {
2332 nros_log::log_info!(
2333 nros_log::get_logger("nros_rmw_cffi"),
2334 "open: locator={locator:?} mode={mode} ret={ret} backend_data={:p}",
2335 view.backend_data
2336 );
2337 }
2338 if ret != NROS_RMW_RET_OK {
2339 return Err(error_from_ret(ret));
2340 }
2341 if view.backend_data.is_null() {
2342 return Err(TransportError::ConnectionFailed);
2343 }
2344 session.backend_data = view.backend_data;
2345 session.qos_policies = read_supported_qos_policies(vtable, &mut view);
2346 Ok(session)
2347 }
2348}
2349
2350/// Ask the registered backend which QoS policies it honours (issue 1329).
2351///
2352/// A NULL slot, a non-OK return, or a mask carrying bits this build has no
2353/// name for all resolve to what the backend has actually established, which is
2354/// nothing above the bits it named. See the slot's header block for why the
2355/// absence is `NONE` and not the union it replaced.
2356fn read_supported_qos_policies(
2357 vtable: &'static NrosRmwVtable,
2358 view: &mut NrosRmwSession,
2359) -> nros_rmw::QoSPolicyMask {
2360 let Some(ask) = vtable.supported_qos_policies else {
2361 // Once per session, not per entity: the creates that follow each fail
2362 // with `IncompatibleQos` naming their own policy, and a line per
2363 // entity would bury the one line that says why.
2364 nros_log::log_warn!(
2365 nros_log::get_logger("nros_rmw_cffi"),
2366 "the registered backend fills no `supported_qos_policies` slot, so it has \
2367 declared no QoS policy it honours. Every entity stating a policy will be \
2368 refused with IncompatibleQos. Fill the slot in the backend's vtable."
2369 );
2370 return nros_rmw::QoSPolicyMask::NONE;
2371 };
2372 let mut mask: u32 = 0;
2373 // SAFETY: `view` describes the session just created, and the slot is one
2374 // the registered backend installed.
2375 let rc = unsafe { ask(view as *const NrosRmwSession, &mut mask) };
2376 if rc != NROS_RMW_RET_OK {
2377 nros_log::log_warn!(
2378 nros_log::get_logger("nros_rmw_cffi"),
2379 "the backend's `supported_qos_policies` returned {rc}; treating it as \
2380 'honours nothing'."
2381 );
2382 return nros_rmw::QoSPolicyMask::NONE;
2383 }
2384 // A bit outside the vocabulary this build knows is dropped rather than
2385 // trusted: it can only come from a backend built against a LATER header,
2386 // and admitting an unknown policy is the over-claim this slot exists to
2387 // end.
2388 nros_rmw::QoSPolicyMask(mask & QOS_POLICY_ALL)
2389}
2390
2391/// The C `NROS_RMW_QOS_POLICY_*` macros and `nros_rmw::QoSPolicyMask` are one
2392/// vocabulary written twice — the hand-mirror class (issue 0160), and this one
2393/// decides whether an application's entity is created at all. A backend
2394/// setting bit 7 and the runtime reading bit 7 as a different policy is a
2395/// wrong ACCEPTANCE, which is silent, so the agreement is asserted at compile
2396/// time here rather than trusted.
2397///
2398/// `check-qos-mask-derivation` checks the same pair without a build, for the
2399/// fast lane; this is the one that cannot be skipped by a target that does not
2400/// run gates.
2401const _: () = {
2402 use nros_rmw::QoSPolicyMask as M;
2403 assert!(NROS_RMW_QOS_POLICY_RELIABILITY as u32 == M::RELIABILITY.0);
2404 assert!(NROS_RMW_QOS_POLICY_DURABILITY_VOLATILE as u32 == M::DURABILITY_VOLATILE.0);
2405 assert!(
2406 NROS_RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL as u32 == M::DURABILITY_TRANSIENT_LOCAL.0
2407 );
2408 assert!(NROS_RMW_QOS_POLICY_HISTORY as u32 == M::HISTORY.0);
2409 assert!(NROS_RMW_QOS_POLICY_DEPTH as u32 == M::DEPTH.0);
2410 assert!(NROS_RMW_QOS_POLICY_DEADLINE as u32 == M::DEADLINE.0);
2411 assert!(NROS_RMW_QOS_POLICY_LIFESPAN as u32 == M::LIFESPAN.0);
2412 assert!(NROS_RMW_QOS_POLICY_LIVELINESS_AUTOMATIC as u32 == M::LIVELINESS_AUTOMATIC.0);
2413 assert!(
2414 NROS_RMW_QOS_POLICY_LIVELINESS_MANUAL_BY_TOPIC as u32 == M::LIVELINESS_MANUAL_BY_TOPIC.0
2415 );
2416 assert!(NROS_RMW_QOS_POLICY_LIVELINESS_MANUAL_BY_NODE as u32 == M::LIVELINESS_MANUAL_BY_NODE.0);
2417 assert!(NROS_RMW_QOS_POLICY_LIVELINESS_LEASE as u32 == M::LIVELINESS_LEASE.0);
2418 assert!(
2419 NROS_RMW_QOS_POLICY_AVOID_ROS_NAMESPACE_CONVENTIONS as u32
2420 == M::AVOID_ROS_NAMESPACE_CONVENTIONS.0
2421 );
2422 assert!(NROS_RMW_QOS_POLICY_NONE as u32 == M::NONE.0);
2423 assert!(NROS_RMW_QOS_POLICY_CORE as u32 == M::CORE.0);
2424};
2425
2426/// Every policy bit the C ABI names, as one mask.
2427///
2428/// Derived from the generated constants rather than written as a literal, so a
2429/// policy added to `rmw_entity.h` joins it with no edit here.
2430const QOS_POLICY_ALL: u32 = (NROS_RMW_QOS_POLICY_RELIABILITY
2431 | NROS_RMW_QOS_POLICY_DURABILITY_VOLATILE
2432 | NROS_RMW_QOS_POLICY_DURABILITY_TRANSIENT_LOCAL
2433 | NROS_RMW_QOS_POLICY_HISTORY
2434 | NROS_RMW_QOS_POLICY_DEPTH
2435 | NROS_RMW_QOS_POLICY_DEADLINE
2436 | NROS_RMW_QOS_POLICY_LIFESPAN
2437 | NROS_RMW_QOS_POLICY_LIVELINESS_AUTOMATIC
2438 | NROS_RMW_QOS_POLICY_LIVELINESS_MANUAL_BY_TOPIC
2439 | NROS_RMW_QOS_POLICY_LIVELINESS_MANUAL_BY_NODE
2440 | NROS_RMW_QOS_POLICY_LIVELINESS_LEASE
2441 | NROS_RMW_QOS_POLICY_AVOID_ROS_NAMESPACE_CONVENTIONS) as u32;
2442
2443/// Report a QoS DOWNGRADE — the granted profile differing from the requested
2444/// one — once per entity, at creation.
2445///
2446/// Issue 0823 gave the runtime the ability to READ the granted QoS. This is the
2447/// half that makes it a diagnostic. Silence from a QoS mismatch is
2448/// indistinguishable from a topic-name typo, a domain split (issue 0801) and a
2449/// discovery failure (issue 0803); all three were run to ground this month and
2450/// each cost hours precisely because nothing separated them. A line naming the
2451/// field that changed separates this one in a sentence.
2452///
2453/// Only a DIFFERENCE is reported. Equality is the common case and printing it
2454/// would bury the signal — the same reason `nros_platform_task_init` only warns
2455/// when the kernel disagreed with the priority it was asked for.
2456///
2457/// Best-effort by construction: a backend with no read-back slot reports
2458/// nothing, which is correct rather than missing (zenoh-pico's QoS is
2459/// per-message flags with no negotiation to read).
2460#[cfg(feature = "alloc")]
2461fn report_qos_downgrade(kind: &str, name: &str, requested: &NrosRmwQos, granted: &NrosRmwQos) {
2462 // The generated constants are `i32` (bindgen) while the struct field is
2463 // `u8`; compare in the constants' type rather than casting them down.
2464 fn reliability(v: u8) -> &'static str {
2465 match v as i32 {
2466 NROS_RMW_RELIABILITY_RELIABLE => "RELIABLE",
2467 NROS_RMW_RELIABILITY_BEST_EFFORT => "BEST_EFFORT",
2468 _ => "?",
2469 }
2470 }
2471 fn durability(v: u8) -> &'static str {
2472 match v as i32 {
2473 NROS_RMW_DURABILITY_TRANSIENT_LOCAL => "TRANSIENT_LOCAL",
2474 NROS_RMW_DURABILITY_VOLATILE => "VOLATILE",
2475 _ => "?",
2476 }
2477 }
2478 // An `UNKNOWN` field is the backend saying it could not determine the
2479 // policy. Comparing it against the request would report every unreportable
2480 // policy as a downgrade, which is the inverse of the old bug and just as
2481 // wrong: silence is not a grant, and neither is it a change.
2482 if requested.reliability != granted.reliability
2483 && !is_unknown(granted.reliability, NROS_RMW_RELIABILITY_UNKNOWN)
2484 {
2485 nros_log::log_warn!(
2486 nros_log::get_logger("nros_rmw_cffi"),
2487 "{kind} `{name}`: asked for reliability {} and the backend granted {}. A RELIABLE \
2488 reader does not match a BEST_EFFORT writer, so this is the usual reason a pair \
2489 goes silent.",
2490 reliability(requested.reliability),
2491 reliability(granted.reliability)
2492 );
2493 }
2494 if requested.durability != granted.durability
2495 && !is_unknown(granted.durability, NROS_RMW_DURABILITY_UNKNOWN)
2496 {
2497 nros_log::log_warn!(
2498 nros_log::get_logger("nros_rmw_cffi"),
2499 "{kind} `{name}`: asked for durability {} and the backend granted {}.",
2500 durability(requested.durability),
2501 durability(granted.durability)
2502 );
2503 }
2504 if requested.depth != granted.depth && granted.depth != 0 {
2505 nros_log::log_warn!(
2506 nros_log::get_logger("nros_rmw_cffi"),
2507 "{kind} `{name}`: asked for history depth {} and the backend granted {}.",
2508 requested.depth,
2509 granted.depth
2510 );
2511 }
2512}
2513
2514/// The C-ABI spelling of [`QoSProfile::QOS_PROFILE_UNKNOWN`] — every policy an
2515/// ABSENCE.
2516///
2517/// This is what a `*_get_actual_qos` out-struct is pre-loaded with, so a field
2518/// the backend does not overwrite reads as "I cannot report this" rather than
2519/// as a confident grant. Built from the Rust preset rather than restated, so
2520/// the two cannot drift.
2521fn qos_unknown_c() -> NrosRmwQos {
2522 // The conversion's only failure is `depth > u16::MAX`, and the preset's
2523 // depth is 0.
2524 NrosRmwQos::try_from(QoSProfile::QOS_PROFILE_UNKNOWN)
2525 .expect("QOS_PROFILE_UNKNOWN has depth 0 and therefore lowers")
2526}
2527
2528/// Is this policy value the C ABI's `*_UNKNOWN` sentinel?
2529///
2530/// Only `report_qos_downgrade` asks, and that is `alloc`-only (it logs), so
2531/// this carries the same gate rather than sitting dead in a `no_std` build.
2532#[cfg(feature = "alloc")]
2533fn is_unknown(field: u8, unknown: i32) -> bool {
2534 field as i32 == unknown
2535}
2536
2537/// Read the GRANTED QoS back off one entity.
2538///
2539/// The read-back is SIX slots — publisher, subscription, and the four
2540/// service/client directions — and every one of them is the same three steps:
2541/// pre-load the out-struct, call the slot, keep the answer.
2542/// `create_publisher` carried those three steps inline, and issue 1327 needed
2543/// four more copies of them — which is how the sizes-header mirror reached six
2544/// sites. One helper instead, generic over the entity type, since the slots
2545/// differ only in which handle they take.
2546///
2547/// **The pre-load is `UNKNOWN`, not the request.** It was the request until
2548/// this phase, and that inverted the slot's own contract: `rmw_vtable.h` says a
2549/// backend writes `*_UNKNOWN` for a policy it cannot determine, so a field
2550/// nobody overwrote read back as GRANTED — a caller comparing request against
2551/// answer saw agreement precisely where the backend had said nothing. Cyclone's
2552/// `qos_from_dds` overwrites exactly the fields whose `dds_qget_*` succeeds, so
2553/// the pre-load is the whole of the difference.
2554///
2555/// `None` means the backend has no read-back for this entity (NULL slot) or
2556/// declined this one (a non-OK return), which is a declared absence and not a
2557/// downgrade. Callers store [`qos_unknown_c`] for it — the same absence, spelled
2558/// per policy.
2559///
2560/// # Safety
2561/// `entity` must point at a live view of an entity this session created, and
2562/// `slot` must be a slot the registered vtable installed for that entity type.
2563unsafe fn read_granted_qos<E>(
2564 entity: *const E,
2565 slot: Option<unsafe extern "C" fn(*const E, *mut NrosRmwQos) -> NrosRmwRet>,
2566) -> Option<NrosRmwQos> {
2567 let read = slot?;
2568 let mut granted = qos_unknown_c();
2569 // SAFETY: the caller's contract, above.
2570 if unsafe { read(entity, &mut granted) } != NROS_RMW_RET_OK {
2571 return None;
2572 }
2573 Some(granted)
2574}
2575
2576/// [`read_granted_qos`] plus the diagnostic line, and the granted profile for
2577/// the caller to RETAIN.
2578///
2579/// Returns what the entity will actually run: the backend's answer where it
2580/// has one, and [`qos_unknown_c`] where it does not — never the request, which
2581/// is the value this accessor exists to be different from.
2582///
2583/// # Safety
2584/// As [`read_granted_qos`].
2585unsafe fn granted_qos_of<E>(
2586 kind: &str,
2587 name: &str,
2588 requested: &NrosRmwQos,
2589 entity: *const E,
2590 slot: Option<unsafe extern "C" fn(*const E, *mut NrosRmwQos) -> NrosRmwRet>,
2591) -> NrosRmwQos {
2592 // SAFETY: the caller's contract.
2593 let Some(granted) = (unsafe { read_granted_qos(entity, slot) }) else {
2594 return qos_unknown_c();
2595 };
2596 #[cfg(feature = "alloc")]
2597 report_qos_downgrade(kind, name, requested, &granted);
2598 #[cfg(not(feature = "alloc"))]
2599 {
2600 let _ = (kind, name, requested);
2601 }
2602 granted
2603}
2604
2605impl Session for CffiSession {
2606 /// The backend is chosen at run time here, so the trait's compile-time
2607 /// default would be a guess. Ask the vtable, and answer `None` when the
2608 /// slot is NULL: a backend that installed no body has not said what it
2609 /// speaks, which is the contract `rmw_vtable.h` states for the slot.
2610 ///
2611 /// A forwarder onto the inherent
2612 /// [`CffiSession::serialization_format`](CffiSession::serialization_format),
2613 /// which has always read the slot that way. Until phase-467 Q2 this impl
2614 /// `unwrap_or`-ed the trait constant instead, so the one session that
2615 /// cannot know its format at compile time was the one that guessed.
2616 fn serialization_format(&self) -> Option<&'static str> {
2617 CffiSession::serialization_format(self)
2618 }
2619
2620 /// phase-461 W2 / issue 1352 -- this session FORWARDS over a C vtable,
2621 /// and the vtable has no slot that carries a ring.
2622 ///
2623 /// `create_service` reaches the backend through
2624 /// `rmw_vtable.create_service` (`packages/core/nros-rmw-abi/include/nros/`),
2625 /// whose arguments are the node, the type support, the name, the domain
2626 /// and the QoS. A caller-owned inbox is four more words, so accepting one
2627 /// here means a second ABI slot and its bindgen regeneration -- not a
2628 /// default this session can answer `true` to on the backend's behalf. It
2629 /// stays `false` until that slot exists, which is the one thing standing
2630 /// between `nros-node`'s `PARAM_INBOX` and the zenoh ring that would hold
2631 /// it (the zenoh side has been caller-visible since W1).
2632 ///
2633 /// Deliberately NOT a runtime question of the vtable: the const is what
2634 /// lets the caller refuse at COMPILE time, and a session that answered
2635 /// from a vtable installed later could only refuse at boot.
2636 const SUPPORTS_CALLER_INBOX: bool = false;
2637
2638 type Error = TransportError;
2639 type PublisherHandle = CffiPublisher;
2640 type SubscriptionHandle = CffiSubscription;
2641 type ServiceHandle = CffiService;
2642 type ClientHandle = CffiClient;
2643
2644 fn create_publisher(
2645 &mut self,
2646 topic: &TopicInfo,
2647 qos: QoSProfile,
2648 ) -> Result<CffiPublisher, TransportError> {
2649 let mut hash_buf = [0u8; HASH_BUF_LEN];
2650 let hash_ptr = to_c_str(topic.type_hash, &mut hash_buf);
2651 let qos_struct = NrosRmwQos::try_from(qos)?;
2652 // phase-301 (issue 0240) — the express hint travels in the options
2653 // struct, not the QoS profile. Either surface wins: the QoS profile
2654 // field (language APIs) or `TopicInfo::with_tx_express` (direct RMW).
2655 let options = rmw_publisher_options_t {
2656 tx_express: (topic.tx_express || qos.tx_express) as u8,
2657 _reserved: [0u8; 7],
2658 };
2659
2660 let mut pub_state = CffiPublisher {
2661 vtable: self.vtable,
2662 topic_name_buf: [0u8; NAME_BUF_LEN],
2663 type_name_buf: [0u8; NAME_BUF_LEN],
2664 qos: qos_struct,
2665 // Overwritten with the backend's answer below, once the entity
2666 // exists. An absence until then, never the request.
2667 actual_qos: qos_unknown_c(),
2668 can_loan_messages: false,
2669 backend_data: core::ptr::null_mut(),
2670 polled_events: [const { Cell::new(None) }; PUB_EVENT_KINDS.len()],
2671 polling_events: Cell::new(false),
2672 };
2673 let topic_ptr = to_c_str(topic.name, &mut pub_state.topic_name_buf);
2674 let type_ptr = to_c_str(topic.type_name, &mut pub_state.type_name_buf);
2675
2676 let mut view = NrosRmwPublisher {
2677 topic_name: topic_ptr,
2678 type_name: type_ptr,
2679 qos: qos_struct,
2680 can_loan_messages: false,
2681 _reserved: [0u8; 7],
2682 backend_data: core::ptr::null_mut(),
2683 };
2684 // Phase 376 W5/B1 — the entity is created ON ITS NODE. `session_view`
2685 // must outlive the call: `node_view` points the node's `session` field
2686 // at it.
2687 let mut session_view = self.make_view();
2688 let node_view = self.node_slot(topic.node_name, topic.namespace, &mut session_view)?;
2689 let ret = unsafe {
2690 (self
2691 .vtable
2692 .create_publisher
2693 .expect("rmw vtable: create_publisher"))(
2694 &node_view,
2695 // phase-406 W1 — the identity as ONE argument, in upstream's
2696 // position. Built here on the stack: it borrows the same two
2697 // pointers the two loose arguments did, so nothing changed about
2698 // their lifetime, only about how many arguments carry them.
2699 &generated::rmw_message_type_support_t {
2700 type_name: type_ptr,
2701 type_hash: hash_ptr,
2702 },
2703 topic_ptr,
2704 topic.domain_id,
2705 &qos_struct,
2706 &options,
2707 &mut view,
2708 )
2709 };
2710 if ret != NROS_RMW_RET_OK {
2711 return Err(error_from_ret(ret));
2712 }
2713 if view.backend_data.is_null() {
2714 return Err(TransportError::PublisherCreationFailed);
2715 }
2716 pub_state.backend_data = view.backend_data;
2717 // issue 0814 — DERIVED from the vtable, never merely declared.
2718 //
2719 // The field is the same fact as `borrow_loaned_message` being
2720 // non-NULL — the header defines it as "the backend exposes the
2721 // loan_publish / commit_publish primitive" — and a fact with two
2722 // spellings drifts. It had drifted BOTH ways at once:
2723 //
2724 // * UNDER-claim. Nothing wrote `true` anywhere outside a test.
2725 // A C backend writes the field, but `RustBackendAdapter`'s
2726 // generic `create_publisher_trampoline` cannot: it is generic
2727 // over the Session and does not know which vtable it was
2728 // registered under, so it writes only `backend_data`
2729 // (`rust_adapter.rs:541-548`) and leaves the runtime's pre-zero
2730 // (`:1951`) standing. That is not an oversight to fix in the
2731 // trampoline; it is structural. So zenoh built with `lending` —
2732 // whose vtable DOES fill all three loan slots
2733 // (`nros-rmw-zenoh/src/lib.rs:427-432`) and whose loans work —
2734 // reported that it cannot loan.
2735 // * OVER-claim. A backend may write `true` with the slot NULL and
2736 // be believed; the test stub at `:4390` does exactly that.
2737 //
2738 // Neither was doing damage YET, because nothing branches on the
2739 // field (every read is a copy into the C view or an accessor) —
2740 // dispatch keys on the slot at `:2760`. The damage is scheduled:
2741 // the moment anyone implements the branch the header promises,
2742 // zenoh's working loan path silently drops to the copy fallback.
2743 //
2744 // A backend that must refuse a loan for a PARTICULAR entity has not
2745 // lost anything — it returns `NROS_RMW_RET_UNSUPPORTED` from
2746 // `borrow_loaned_message` and `try_lend_slot` surfaces that as an
2747 // error. The per-entity answer belongs on the call, which is
2748 // consulted; not on a flag, which is not.
2749 pub_state.can_loan_messages = pub_state.vtable.borrow_loaned_message.is_some();
2750 // phase-393 W1 — say so when the backend granted something else, and
2751 // RETAIN the answer: `actual_qos()` is the accessor upstream's
2752 // `rmw_publisher_get_actual_qos` is, and the read-back used to be
2753 // logged and dropped, so nobody could ask.
2754 {
2755 let mut v = pub_state.make_view();
2756 let slot = pub_state.vtable.publisher_get_actual_qos;
2757 // SAFETY: the entity was created above and `v` describes it.
2758 pub_state.actual_qos =
2759 unsafe { granted_qos_of("publisher", topic.name, &qos_struct, &v, slot) };
2760 let _ = &mut v;
2761 }
2762 Ok(pub_state)
2763 }
2764
2765 fn create_subscription(
2766 &mut self,
2767 topic: &TopicInfo,
2768 qos: QoSProfile,
2769 ) -> Result<CffiSubscription, TransportError> {
2770 let mut hash_buf = [0u8; HASH_BUF_LEN];
2771 let hash_ptr = to_c_str(topic.type_hash, &mut hash_buf);
2772 let qos_struct = NrosRmwQos::try_from(qos)?;
2773 // Phase 231 (RFC-0038) / phase-301 (issue 0240) — the receive-buffer
2774 // size hint travels in the options struct so a size-classing backend
2775 // can route its receive storage. A hint, not a policy: oversize
2776 // saturates.
2777 let options = rmw_subscription_options_t {
2778 rx_buffer_hint: topic.rx_buffer_hint.min(u32::MAX as usize) as u32,
2779 _reserved: [0u8; 4],
2780 };
2781
2782 let mut sub_state = CffiSubscription {
2783 vtable: self.vtable,
2784 topic_name_buf: [0u8; NAME_BUF_LEN],
2785 type_name_buf: [0u8; NAME_BUF_LEN],
2786 qos: qos_struct,
2787 actual_qos: qos_unknown_c(),
2788 can_loan_messages: false,
2789 backend_data: core::ptr::null_mut(),
2790 supports_in_place: false,
2791 pending_status: None,
2792 refused_len: None,
2793 polled_events: [const { Cell::new(None) }; SUB_EVENT_KINDS.len()],
2794 polling_events: Cell::new(false),
2795 };
2796 let topic_ptr = to_c_str(topic.name, &mut sub_state.topic_name_buf);
2797 let type_ptr = to_c_str(topic.type_name, &mut sub_state.type_name_buf);
2798
2799 let mut view = NrosRmwSubscription {
2800 topic_name: topic_ptr,
2801 type_name: type_ptr,
2802 qos: qos_struct,
2803 can_loan_messages: false,
2804 _reserved: [0u8; 7],
2805 backend_data: core::ptr::null_mut(),
2806 };
2807 // Phase 376 W5/B1 — the entity is created ON ITS NODE. `session_view`
2808 // must outlive the call: `node_view` points the node's `session` field
2809 // at it.
2810 let mut session_view = self.make_view();
2811 let node_view = self.node_slot(topic.node_name, topic.namespace, &mut session_view)?;
2812 let ret = unsafe {
2813 (self
2814 .vtable
2815 .create_subscription
2816 .expect("rmw vtable: create_subscription"))(
2817 &node_view,
2818 // phase-406 W1 — the identity as ONE argument, in upstream's
2819 // position. Built here on the stack: it borrows the same two
2820 // pointers the two loose arguments did, so nothing changed about
2821 // their lifetime, only about how many arguments carry them.
2822 &generated::rmw_message_type_support_t {
2823 type_name: type_ptr,
2824 type_hash: hash_ptr,
2825 },
2826 topic_ptr,
2827 topic.domain_id,
2828 &qos_struct,
2829 &options,
2830 &mut view,
2831 )
2832 };
2833 if ret != NROS_RMW_RET_OK {
2834 return Err(error_from_ret(ret));
2835 }
2836 if view.backend_data.is_null() {
2837 return Err(TransportError::SubscriberCreationFailed);
2838 }
2839 sub_state.backend_data = view.backend_data;
2840 // issue 0814 — the receive-side twin, derived from its own slot for
2841 // the same reason as the publisher above. `take_loaned_message` is
2842 // NULL in every backend we ship, so this is `false` everywhere today
2843 // — but now it is false BECAUSE no backend fills the slot, which is
2844 // checkable, rather than because nobody remembered to write it.
2845 sub_state.can_loan_messages = sub_state.vtable.take_loaned_message.is_some();
2846 // The SIXTH read-back, and the one that was never dispatched. Issue
2847 // 1327 wired the publisher and the four service/client directions;
2848 // `subscription_get_actual_qos` was filled by cyclonedds and uORB and
2849 // called by nobody, so a subscription — the entity whose reliability
2850 // downgrade is the classic "why is nothing arriving" — was the one
2851 // that could not answer.
2852 {
2853 let mut v = sub_state.make_view();
2854 let slot = sub_state.vtable.subscription_get_actual_qos;
2855 // SAFETY: the entity was created above and `v` describes it.
2856 sub_state.actual_qos =
2857 unsafe { granted_qos_of("subscription", topic.name, &qos_struct, &v, slot) };
2858 let _ = &mut v;
2859 }
2860 // Phase 231 (RFC-0038) — cache the in-place capability once.
2861 // The capability is the CONJUNCTION of the probe and the slot that
2862 // would serve it (issue 0781). The probe alone was the whole answer,
2863 // so a backend answering true with a NULL `process_raw_in_place` chose
2864 // the in-place dispatch path and then failed every take; and the slot
2865 // alone cannot answer, because `RustBackendAdapter::<R>::VTABLE` is a
2866 // `const` that installs `process_raw_in_place` for every `R` while the
2867 // Rust-side answer is a runtime method (zenoh true, metadata false
2868 // behind the same nullity). Neither mechanism subsumes the other.
2869 sub_state.supports_in_place = sub_state.vtable.process_raw_in_place.is_some()
2870 && match sub_state.vtable.subscription_supports_in_place {
2871 Some(f) => {
2872 let mut v = sub_state.make_view();
2873 // Phase 376 W3.d step A — a backend that FAILS the probe is not
2874 // one that supports in-place: both answer false, but only one of
2875 // them is an error, and the status now says which.
2876 let mut supports = false;
2877 let rc = unsafe { f(&mut v, &mut supports) };
2878 rc == NROS_RMW_RET_OK && supports
2879 }
2880 None => false,
2881 };
2882 Ok(sub_state)
2883 }
2884
2885 fn create_service(
2886 &mut self,
2887 service: &ServiceInfo,
2888 qos: QoSProfile,
2889 ) -> Result<CffiService, TransportError> {
2890 let qos_struct = NrosRmwQos::try_from(qos)?;
2891 let mut hash_buf = [0u8; HASH_BUF_LEN];
2892 let hash_ptr = to_c_str(service.type_hash, &mut hash_buf);
2893
2894 let mut srv_state = CffiService {
2895 vtable: self.vtable,
2896 service_name_buf: [0u8; NAME_BUF_LEN],
2897 type_name_buf: [0u8; NAME_BUF_LEN],
2898 backend_data: core::ptr::null_mut(),
2899 // Both overwritten with the backend's answers below, once the
2900 // entity exists. An absence until then, never the request.
2901 request_qos: qos_unknown_c(),
2902 response_qos: qos_unknown_c(),
2903 refused_len: None,
2904 };
2905 let svc_ptr = to_c_str(service.name, &mut srv_state.service_name_buf);
2906 let type_ptr = to_c_str(service.type_name, &mut srv_state.type_name_buf);
2907
2908 let mut view = NrosRmwService {
2909 service_name: svc_ptr,
2910 type_name: type_ptr,
2911 _reserved: [0u8; 8],
2912 backend_data: core::ptr::null_mut(),
2913 };
2914 // Phase 376 W5/B1 — see `create_publisher`.
2915 let mut session_view = self.make_view();
2916 let node_view = self.node_slot(service.node_name, service.namespace, &mut session_view)?;
2917 let ret = unsafe {
2918 (self
2919 .vtable
2920 .create_service
2921 .expect("rmw vtable: create_service"))(
2922 &node_view,
2923 // phase-406 W1 — see the publisher site. A SERVICE type support
2924 // is its own type: upstream keeps `rosidl_service_type_support_t`
2925 // distinct from the message form, and collapsing them here would
2926 // let a service type be passed where a message type is required
2927 // with no diagnostic.
2928 &generated::rmw_service_type_support_t {
2929 type_name: type_ptr,
2930 type_hash: hash_ptr,
2931 },
2932 svc_ptr,
2933 service.domain_id,
2934 &qos_struct,
2935 &mut view,
2936 )
2937 };
2938 if ret != NROS_RMW_RET_OK {
2939 return Err(error_from_ret(ret));
2940 }
2941 if view.backend_data.is_null() {
2942 return Err(TransportError::ServiceServerCreationFailed);
2943 }
2944 srv_state.backend_data = view.backend_data;
2945 // issue 1327 — the service half of the publisher site above.
2946 //
2947 // `create_service` takes ONE profile and builds TWO endpoints from it,
2948 // and `rmw_service_t` has no `qos` field, so the granted per-direction
2949 // profile is observable through these slots and nowhere else. Both are
2950 // read: the request endpoint and the response endpoint negotiate
2951 // against different peers and may be granted different things.
2952 {
2953 let mut v = srv_state.make_view();
2954 let req = srv_state.vtable.service_request_subscription_get_actual_qos;
2955 let resp = srv_state.vtable.service_response_publisher_get_actual_qos;
2956 // SAFETY: the entity was created above and `v` describes it.
2957 unsafe {
2958 srv_state.request_qos = granted_qos_of(
2959 "service request subscription",
2960 service.name,
2961 &qos_struct,
2962 &v,
2963 req,
2964 );
2965 srv_state.response_qos = granted_qos_of(
2966 "service response publisher",
2967 service.name,
2968 &qos_struct,
2969 &v,
2970 resp,
2971 );
2972 }
2973 let _ = &mut v;
2974 }
2975 Ok(srv_state)
2976 }
2977
2978 fn create_client(
2979 &mut self,
2980 service: &ServiceInfo,
2981 qos: QoSProfile,
2982 ) -> Result<CffiClient, TransportError> {
2983 let qos_struct = NrosRmwQos::try_from(qos)?;
2984 let mut hash_buf = [0u8; HASH_BUF_LEN];
2985 let hash_ptr = to_c_str(service.type_hash, &mut hash_buf);
2986
2987 let mut cli_state = CffiClient {
2988 vtable: self.vtable,
2989 service_name_buf: [0u8; NAME_BUF_LEN],
2990 type_name_buf: [0u8; NAME_BUF_LEN],
2991 backend_data: core::ptr::null_mut(),
2992 // Both overwritten with the backend's answers below, once the
2993 // entity exists. An absence until then, never the request.
2994 request_qos: qos_unknown_c(),
2995 response_qos: qos_unknown_c(),
2996 refused_len: None,
2997 };
2998 let svc_ptr = to_c_str(service.name, &mut cli_state.service_name_buf);
2999 let type_ptr = to_c_str(service.type_name, &mut cli_state.type_name_buf);
3000
3001 let mut view = NrosRmwClient {
3002 service_name: svc_ptr,
3003 type_name: type_ptr,
3004 _reserved: [0u8; 8],
3005 backend_data: core::ptr::null_mut(),
3006 };
3007 // Phase 376 W5/B1 — see `create_publisher`.
3008 let mut session_view = self.make_view();
3009 let node_view = self.node_slot(service.node_name, service.namespace, &mut session_view)?;
3010 let ret = unsafe {
3011 (self
3012 .vtable
3013 .create_client
3014 .expect("rmw vtable: create_client"))(
3015 &node_view,
3016 // phase-406 W1 — see the publisher site. A SERVICE type support
3017 // is its own type: upstream keeps `rosidl_service_type_support_t`
3018 // distinct from the message form, and collapsing them here would
3019 // let a service type be passed where a message type is required
3020 // with no diagnostic.
3021 &generated::rmw_service_type_support_t {
3022 type_name: type_ptr,
3023 type_hash: hash_ptr,
3024 },
3025 svc_ptr,
3026 service.domain_id,
3027 &qos_struct,
3028 &mut view,
3029 )
3030 };
3031 if ret != NROS_RMW_RET_OK {
3032 return Err(error_from_ret(ret));
3033 }
3034 if view.backend_data.is_null() {
3035 return Err(TransportError::ServiceClientCreationFailed);
3036 }
3037 cli_state.backend_data = view.backend_data;
3038 // issue 1327 — the client half; see `create_service` for why both
3039 // directions are read.
3040 {
3041 let mut v = cli_state.make_view();
3042 let req = cli_state.vtable.client_request_publisher_get_actual_qos;
3043 let resp = cli_state.vtable.client_response_subscription_get_actual_qos;
3044 // SAFETY: the entity was created above and `v` describes it.
3045 unsafe {
3046 cli_state.request_qos = granted_qos_of(
3047 "client request publisher",
3048 service.name,
3049 &qos_struct,
3050 &v,
3051 req,
3052 );
3053 cli_state.response_qos = granted_qos_of(
3054 "client response subscription",
3055 service.name,
3056 &qos_struct,
3057 &v,
3058 resp,
3059 );
3060 }
3061 let _ = &mut v;
3062 }
3063 Ok(cli_state)
3064 }
3065
3066 fn close(&mut self) -> Result<(), TransportError> {
3067 if self.backend_data.is_null() {
3068 return Ok(());
3069 }
3070 // Phase 376 W5/B1 — hand the node slots back BEFORE the backend loses
3071 // its session state. A static table that is never reclaimed would let a
3072 // long-running image that opens and closes sessions exhaust it.
3073 let mut view = self.make_view();
3074 release_session_nodes(self.backend_data as usize, self.vtable, &mut view);
3075 let ret = unsafe {
3076 (self
3077 .vtable
3078 .destroy_session
3079 .expect("rmw vtable: destroy_session"))(&mut view)
3080 };
3081 if ret != NROS_RMW_RET_OK {
3082 return Err(error_from_ret(ret));
3083 }
3084 self.backend_data = core::ptr::null_mut();
3085 Ok(())
3086 }
3087
3088 fn drive_io(&mut self, timeout_ms: i32) -> Result<(), TransportError> {
3089 let mut view = self.make_view();
3090 let ret =
3091 unsafe { (self.vtable.drive_io.expect("rmw vtable: drive_io"))(&mut view, timeout_ms) };
3092 if ret != NROS_RMW_RET_OK {
3093 return Err(error_from_ret(ret));
3094 }
3095 Ok(())
3096 }
3097
3098 fn next_deadline_ms(&self) -> Option<u32> {
3099 let f = self.vtable.next_deadline_ms?;
3100 // SAFETY: build a transient `&self`-only view of the session
3101 // fields the C side may inspect; matches the layout `make_view`
3102 // produces but doesn't require `&mut self`.
3103 let view = NrosRmwSession {
3104 node_name: self.node_name_buf.as_ptr().cast(),
3105 namespace_: self.namespace_buf.as_ptr().cast(),
3106 _reserved: [0u8; 8],
3107 backend_data: self.backend_data,
3108 };
3109 // Phase 376 W3.d step A — the trait returns `Option<u32>` and has no
3110 // error channel, so a FAILING probe maps to `None` here. That is the
3111 // same answer a quiet link gives, but it is now a decision rather than
3112 // the arithmetic of a negative sentinel — and the backend can at least
3113 // distinguish the two on its side of the seam.
3114 let mut out_ms: u32 = 0;
3115 let mut has_deadline = false;
3116 let ret = unsafe { f(&view as *const _, &mut out_ms, &mut has_deadline) };
3117 if ret != NROS_RMW_RET_OK || !has_deadline {
3118 return None;
3119 }
3120 Some(out_ms)
3121 }
3122
3123 unsafe fn set_wake_callback(
3124 &mut self,
3125 cb: Option<unsafe extern "C" fn(ctx: *mut core::ffi::c_void)>,
3126 ctx: *mut core::ffi::c_void,
3127 ) {
3128 let Some(f) = self.vtable.set_wake_callback else {
3129 return;
3130 };
3131 let mut view = NrosRmwSession {
3132 node_name: self.node_name_buf.as_ptr().cast(),
3133 namespace_: self.namespace_buf.as_ptr().cast(),
3134 _reserved: [0u8; 8],
3135 backend_data: self.backend_data,
3136 };
3137 // SAFETY: vtable trampoline owns the install/clear; result is
3138 // ignored — best-effort.
3139 let _ = unsafe { f(&mut view as *mut _, cb, ctx) };
3140 }
3141
3142 fn supports_wake_callback(&self) -> bool {
3143 // Phase 130.4 — the vtable slot's presence is the truthful
3144 // signal. Poll-only backends (XRCE-DDS-Client, current dust-DDS
3145 // shim) leave the slot NULL; only backends with an async wake
3146 // source fill it. Cyclone DOES fill it (`vtable.cpp:322`) and
3147 // backs it with a real `dds_set_listener` / `on_data_available`
3148 // firing on a Cyclone worker thread — this comment named it
3149 // poll-only long after that landed, which is misleading for
3150 // exactly the reader asking whether their lane gets async wakes.
3151 self.vtable.set_wake_callback.is_some()
3152 }
3153
3154 /// phase-467 Row 8 — forward to the backend's graph-change slot; NULL
3155 /// means UNSUPPORTED.
3156 ///
3157 /// **This is the CONSUMER the slot never had.** It was declared in
3158 /// phase-376 W4 and read by nothing for five phases, so
3159 /// `check-rmw-slot-producers` classified it `inert` and a family entry
3160 /// carried the reason. Both real backends already received the graph-change
3161 /// edge and discarded the discrimination — one line each — which is why the
3162 /// expensive half of closing this was never the backends.
3163 ///
3164 /// NULL surfaces `Unsupported` rather than `Ok(())`, for the same reason
3165 /// the eleven enumeration slots do: accepting a callback that will never
3166 /// fire is the silent difference RFC-0089 Part I refuses, and it is what
3167 /// "hand back a guard condition nothing triggers" would look like from the
3168 /// caller's side.
3169 unsafe fn set_graph_change_callback(
3170 &mut self,
3171 callback: Option<unsafe extern "C" fn(user_data: *const core::ffi::c_void, count: usize)>,
3172 user_data: *const core::ffi::c_void,
3173 ) -> Result<(), TransportError> {
3174 let Some(f) = self.vtable.node_get_graph_guard_condition else {
3175 return Err(TransportError::Unsupported);
3176 };
3177 let mut view = self.make_view();
3178 // SAFETY: the slot takes a `rmw_session_t *`, and the view carries the
3179 // backend data pointer the trampoline reads. The callback/user_data
3180 // lifetime contract is the caller's (see the trait's `# Safety`).
3181 let rc = unsafe { f(&mut view as *mut NrosRmwSession, callback, user_data) };
3182 if rc == NROS_RMW_RET_OK {
3183 Ok(())
3184 } else {
3185 Err(error_from_ret(rc))
3186 }
3187 }
3188
3189 /// phase-381 W5/W6 — forward to the backend's slot; NULL means UNSUPPORTED.
3190 ///
3191 /// Without this the graph slots were unreachable for every C backend: the
3192 /// trait default returns `Unsupported`, so a wired slot — cyclone's, as of
3193 /// W5 — would never be called and would look implemented while being dead
3194 /// code. That is exactly the "a slot exists, therefore it works"
3195 /// overstatement issue 0800 measured, one layer above where 0800 found it.
3196 ///
3197 /// NULL surfaces `Unsupported` rather than an empty enumeration, which is
3198 /// W6's requirement: XRCE has no graph and must say "cannot tell you", not
3199 /// "nothing is there".
3200 fn get_node_names(
3201 &mut self,
3202 visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
3203 ) -> Result<(), TransportError> {
3204 let Some(f) = self.vtable.get_node_names else {
3205 return Err(TransportError::Unsupported);
3206 };
3207 // The C visitor gets a pointer to this closure as its `ctx`; the
3208 // trampoline below turns the borrowed C strings back into `&str`.
3209 // `&view`, not `&mut`: the slot takes a `const rmw_session_t *`.
3210 let view = self.make_view();
3211 let mut cb: &mut dyn FnMut(&str, &str, Option<&str>) -> bool = visit;
3212 let rc = unsafe {
3213 f(
3214 &view as *const NrosRmwSession,
3215 generated::rmw_node_visitor_t {
3216 visit: Some(node_visit_trampoline),
3217 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
3218 },
3219 )
3220 };
3221 if rc == NROS_RMW_RET_OK {
3222 Ok(())
3223 } else {
3224 Err(error_from_ret(rc))
3225 }
3226 }
3227
3228 /// phase-381 / issue 0903 — the REST of the graph family.
3229 ///
3230 /// W5 added `get_node_names` here and stopped, which made this the same
3231 /// defect one method wide: every other graph call fell through to the trait
3232 /// default and returned `Unsupported`, so the zenoh backend — which reaches
3233 /// the runtime through this vtable — answered node names and NOTHING else.
3234 /// Measured against a live `rmw_zenoh_cpp` talker: node enumeration worked
3235 /// and `get_topic_names_and_types` returned empty, because the entity query
3236 /// was never even STARTED.
3237 ///
3238 /// Fixing one method of eleven is how the first version passed every unit
3239 /// test in the phase.
3240 fn get_topic_names_and_types(
3241 &mut self,
3242 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3243 ) -> Result<(), TransportError> {
3244 let Some(f) = self.vtable.get_topic_names_and_types else {
3245 return Err(TransportError::Unsupported);
3246 };
3247 let view = self.make_view();
3248 let mut cb: &mut dyn FnMut(&str, &[&str]) -> bool = visit;
3249 let rc = unsafe {
3250 f(
3251 &view as *const NrosRmwSession,
3252 false,
3253 generated::rmw_names_and_types_visitor_t {
3254 visit: Some(names_and_types_visit_trampoline),
3255 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
3256 },
3257 )
3258 };
3259 if rc == NROS_RMW_RET_OK {
3260 Ok(())
3261 } else {
3262 Err(error_from_ret(rc))
3263 }
3264 }
3265
3266 /// phase-381 W4, wired here by the live acceptance run.
3267 ///
3268 /// The zenoh shim implemented this and `get_endpoint_info_by_topic` all
3269 /// along; `CffiSession` never dispatched them, so both fell through to the
3270 /// trait default and every caller got `Unsupported`. That is issue 0903's
3271 /// third defect for the SIX slots the 0903 fix did not cover — it wired the
3272 /// five that had a failing symptom in front of it and left these, and
3273 /// `check-rmw-slot-producers` calls them `produced` either way because it
3274 /// asks whether a slot has a producer, not whether anything reaches it.
3275 ///
3276 /// Found by `graph_interop.rs` on its first run against a real peer, which
3277 /// is the only place it could have been found.
3278 fn get_names_and_types_by_node(
3279 &mut self,
3280 kind: GraphEntityKind,
3281 node_name: &str,
3282 node_namespace: &str,
3283 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3284 ) -> Result<(), TransportError> {
3285 // The four slots do NOT share a signature: the publisher and subscriber
3286 // forms take `no_demangle`, the service and client forms do not. That
3287 // asymmetry is upstream's (`rmw_get_service_names_and_types_by_node`
3288 // has no demangle argument), and RFC-0054 makes the C headers the SSoT,
3289 // so it is mirrored rather than smoothed over — which is why this is
3290 // two calls instead of one `match` producing a function pointer.
3291 let mut name_buf = [0u8; GRAPH_NAME_CAP];
3292 let mut ns_buf = [0u8; GRAPH_NAME_CAP];
3293 if node_name.len() >= GRAPH_NAME_CAP || node_namespace.len() >= GRAPH_NAME_CAP {
3294 return Err(TransportError::BufferTooSmall);
3295 }
3296 name_buf[..node_name.len()].copy_from_slice(node_name.as_bytes());
3297 ns_buf[..node_namespace.len()].copy_from_slice(node_namespace.as_bytes());
3298
3299 let view = self.make_view();
3300 let mut cb: &mut dyn FnMut(&str, &[&str]) -> bool = visit;
3301 let ctx = &mut cb as *mut _ as *mut core::ffi::c_void;
3302 let name = name_buf.as_ptr() as *const core::ffi::c_char;
3303 let ns = ns_buf.as_ptr() as *const core::ffi::c_char;
3304
3305 let rc = match kind {
3306 GraphEntityKind::Publisher | GraphEntityKind::Subscriber => {
3307 let slot = if matches!(kind, GraphEntityKind::Publisher) {
3308 self.vtable.get_publisher_names_and_types_by_node
3309 } else {
3310 self.vtable.get_subscriber_names_and_types_by_node
3311 };
3312 let Some(f) = slot else {
3313 return Err(TransportError::Unsupported);
3314 };
3315 unsafe {
3316 f(
3317 &view as *const NrosRmwSession,
3318 name,
3319 ns,
3320 false,
3321 generated::rmw_names_and_types_visitor_t {
3322 visit: Some(names_and_types_visit_trampoline),
3323 ctx,
3324 },
3325 )
3326 }
3327 }
3328 GraphEntityKind::Service | GraphEntityKind::Client => {
3329 let slot = if matches!(kind, GraphEntityKind::Service) {
3330 self.vtable.get_service_names_and_types_by_node
3331 } else {
3332 self.vtable.get_client_names_and_types_by_node
3333 };
3334 let Some(f) = slot else {
3335 return Err(TransportError::Unsupported);
3336 };
3337 unsafe {
3338 f(
3339 &view as *const NrosRmwSession,
3340 name,
3341 ns,
3342 generated::rmw_names_and_types_visitor_t {
3343 visit: Some(names_and_types_visit_trampoline),
3344 ctx,
3345 },
3346 )
3347 }
3348 }
3349 };
3350 if rc == NROS_RMW_RET_OK {
3351 Ok(())
3352 } else {
3353 Err(error_from_ret(rc))
3354 }
3355 }
3356
3357 /// The endpoints on a topic — see [`Self::get_names_and_types_by_node`] for
3358 /// why this was unreachable until the live run.
3359 fn get_endpoint_info_by_topic(
3360 &mut self,
3361 publishers: bool,
3362 topic_name: &str,
3363 visit: &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool,
3364 ) -> Result<(), TransportError> {
3365 let slot = if publishers {
3366 self.vtable.get_publishers_info_by_topic
3367 } else {
3368 self.vtable.get_subscriptions_info_by_topic
3369 };
3370 let Some(f) = slot else {
3371 return Err(TransportError::Unsupported);
3372 };
3373 let mut topic_buf = [0u8; GRAPH_NAME_CAP];
3374 if topic_name.len() >= GRAPH_NAME_CAP {
3375 return Err(TransportError::BufferTooSmall);
3376 }
3377 topic_buf[..topic_name.len()].copy_from_slice(topic_name.as_bytes());
3378
3379 let view = self.make_view();
3380 let mut cb: &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool = visit;
3381 let rc = unsafe {
3382 f(
3383 &view as *const NrosRmwSession,
3384 topic_buf.as_ptr() as *const core::ffi::c_char,
3385 false,
3386 generated::rmw_topic_endpoint_info_visitor_t {
3387 visit: Some(endpoint_info_visit_trampoline),
3388 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
3389 },
3390 )
3391 };
3392 if rc == NROS_RMW_RET_OK {
3393 Ok(())
3394 } else {
3395 Err(error_from_ret(rc))
3396 }
3397 }
3398
3399 fn get_service_names_and_types(
3400 &mut self,
3401 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
3402 ) -> Result<(), TransportError> {
3403 let Some(f) = self.vtable.get_service_names_and_types else {
3404 return Err(TransportError::Unsupported);
3405 };
3406 let view = self.make_view();
3407 let mut cb: &mut dyn FnMut(&str, &[&str]) -> bool = visit;
3408 let rc = unsafe {
3409 f(
3410 &view as *const NrosRmwSession,
3411 generated::rmw_names_and_types_visitor_t {
3412 visit: Some(names_and_types_visit_trampoline),
3413 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
3414 },
3415 )
3416 };
3417 if rc == NROS_RMW_RET_OK {
3418 Ok(())
3419 } else {
3420 Err(error_from_ret(rc))
3421 }
3422 }
3423
3424 fn count_publishers(&mut self, topic_name: &str) -> Result<usize, TransportError> {
3425 let Some(f) = self.vtable.count_publishers else {
3426 return Err(TransportError::Unsupported);
3427 };
3428 count_on_topic(&self.make_view(), f, topic_name)
3429 }
3430
3431 fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, TransportError> {
3432 let Some(f) = self.vtable.count_subscribers else {
3433 return Err(TransportError::Unsupported);
3434 };
3435 count_on_topic(&self.make_view(), f, topic_name)
3436 }
3437
3438 fn ping_session(&mut self, timeout_ms: i32) -> Result<(), TransportError> {
3439 // Phase 124.F.1 — forward to the backend's vtable slot when
3440 // available; NULL surfaces `Unsupported` to the caller (no
3441 // implicit emulation — backends without a wire-level
3442 // round-trip can't probe honestly).
3443 let Some(f) = self.vtable.ping_session else {
3444 return Err(TransportError::Unsupported);
3445 };
3446 let mut view = self.make_view();
3447 let rc = unsafe { f(&mut view, timeout_ms) };
3448 if rc == NROS_RMW_RET_OK {
3449 Ok(())
3450 } else {
3451 Err(error_from_ret(rc))
3452 }
3453 }
3454
3455 /// This session MULTIPLEXES: it has no QoS behaviour of its own, so it
3456 /// ROUTES the question to whichever backend registered, through
3457 /// `vtable.supported_qos_policies`, and answers what that backend said.
3458 ///
3459 /// nros-qos-mux: packages/rmw/cyclonedds packages/rmw/xrce packages/rmw/uorb
3460 ///
3461 /// # This used to be a union, and a union over-claims for every member
3462 ///
3463 /// Until issue 1329 the vtable had no slot to ask through, so this method
3464 /// returned the UNION of what any backend it routes to honours. phase-428
3465 /// W9 made that union DERIVED — every bit was claimed by a
3466 /// `nros-qos-honours:` site in one of the crates above — which caught
3467 /// `LIVELINESS_MANUAL_BY_NODE`, a bit advertised here and implemented by
3468 /// nobody (issue 1328), and did nothing about the shape itself. Measured
3469 /// 2026-09-11, before the slot:
3470 ///
3471 /// | policy | cyclonedds | xrce | uorb |
3472 /// | --- | --- | --- | --- |
3473 /// | reliability / durability / history / depth | yes | yes (to the Agent) | no |
3474 /// | deadline / lifespan | yes | no | no |
3475 /// | liveliness kind + lease | yes (AUTOMATIC, MANUAL_BY_TOPIC) | no | no |
3476 /// | avoid_ros_namespace_conventions | no | pub + sub only | no |
3477 ///
3478 /// Every `no` in that table was admitted here and then ignored downstream
3479 /// — the no-silent-downgrade contract broken one layer below where it is
3480 /// enforced. The answer is now the registered backend's own, read once at
3481 /// `open` (`read_supported_qos_policies`, cached in this session's
3482 /// `qos_policies` field); the union is gone and no second copy of it
3483 /// survives to drift.
3484 ///
3485 /// A backend that fills no slot has declared nothing, and this answers
3486 /// `NONE` for it — loudly, with one WARN at open. See the slot's header
3487 /// block for why the absence resolves that way rather than to a union.
3488 fn supported_qos_policies(&self) -> nros_rmw::QoSPolicyMask {
3489 self.qos_policies
3490 }
3491}
3492
3493impl Drop for CffiSession {
3494 fn drop(&mut self) {
3495 if !self.backend_data.is_null() {
3496 let mut view = self.make_view();
3497 unsafe {
3498 (self
3499 .vtable
3500 .destroy_session
3501 .expect("rmw vtable: destroy_session"))(&mut view)
3502 };
3503 }
3504 }
3505}
3506
3507// ============================================================================
3508// CffiPublisher
3509// ============================================================================
3510
3511/// Publisher backed by a C vtable.
3512pub struct CffiPublisher {
3513 vtable: &'static NrosRmwVtable,
3514 topic_name_buf: [u8; NAME_BUF_LEN],
3515 type_name_buf: [u8; NAME_BUF_LEN],
3516 qos: NrosRmwQos,
3517 /// The profile the backend GRANTED, read back at create time.
3518 ///
3519 /// Not the request: `qos` above is what was asked for, this is what the
3520 /// entity runs, and every policy the backend could not report carries its
3521 /// `*_UNKNOWN` sentinel. `qos_unknown_c()` for a backend with no read-back
3522 /// slot at all — every policy an absence, which is the honest answer and
3523 /// is what [`nros_rmw::Publisher::actual_qos`] returns there.
3524 ///
3525 /// Read once at create rather than on every call: upstream's
3526 /// `rmw_*_get_actual_qos` is a property of the entity, and a slot that
3527 /// re-enters the backend would make an accessor a transport operation.
3528 actual_qos: NrosRmwQos,
3529 can_loan_messages: bool,
3530 backend_data: *mut c_void,
3531 /// Issue 1164 — callbacks the runtime serves by polling
3532 /// `publisher_take_event`, indexed by [`PUB_EVENT_KINDS`]. Empty on a
3533 /// backend that fills `publisher_event_init`; the registration goes
3534 /// straight through in that case.
3535 polled_events: [Cell<Option<PolledEventReg>>; PUB_EVENT_KINDS.len()],
3536 /// Re-entrancy guard for [`CffiPublisher::poll_status_events`]. A user
3537 /// callback is free to publish, and publishing polls — so the drain must
3538 /// not call itself. A `Cell`, not a lock: the entity is `!Sync` (it holds
3539 /// the backend's raw pointer), so there is no second thread to exclude,
3540 /// and a lock here would be one a backend listener could also want, which
3541 /// is the self-relock that hangs on POSIX and passes on Zephyr.
3542 polling_events: Cell<bool>,
3543}
3544
3545impl CffiPublisher {
3546 fn make_view(&mut self) -> NrosRmwPublisher {
3547 NrosRmwPublisher {
3548 topic_name: self.topic_name_buf.as_ptr().cast(),
3549 type_name: self.type_name_buf.as_ptr().cast(),
3550 qos: self.qos,
3551 can_loan_messages: self.can_loan_messages,
3552 _reserved: [0u8; 7],
3553 backend_data: self.backend_data,
3554 }
3555 }
3556
3557 /// `make_view` from a shared borrow. The view is a by-value snapshot of
3558 /// this publisher's fields; the backend reads it and never writes through
3559 /// it, which is the same reasoning `has_data` uses one entity over.
3560 fn view_shared(&self) -> NrosRmwPublisher {
3561 NrosRmwPublisher {
3562 topic_name: self.topic_name_buf.as_ptr().cast(),
3563 type_name: self.type_name_buf.as_ptr().cast(),
3564 qos: self.qos,
3565 can_loan_messages: self.can_loan_messages,
3566 _reserved: [0u8; 7],
3567 backend_data: self.backend_data,
3568 }
3569 }
3570
3571 /// Issue 1164 — drain `publisher_take_event` for every kind this
3572 /// publisher has a registered callback for, and fire the callbacks.
3573 ///
3574 /// Called from the publisher's ordinary data path (`publish_raw`,
3575 /// `publish_streamed`, `assert_liveliness`), which is where the zenoh
3576 /// shim fires its own publisher-side events from. A publisher that stops
3577 /// publishing therefore stops polling — the same limitation zenoh has,
3578 /// and the reason `OfferedDeadlineMissed` is observed on the NEXT publish
3579 /// rather than at the instant the deadline lapsed.
3580 ///
3581 /// No callback is invoked while the backend holds anything: `take_event`
3582 /// copies the status out and returns, and only then is the user's
3583 /// callback called. Nothing here is re-entrant into the backend.
3584 fn poll_status_events(&self) {
3585 let Some(take) = self.vtable.publisher_take_event else {
3586 return;
3587 };
3588 if self.polling_events.replace(true) {
3589 return;
3590 }
3591 for (slot, kind) in PUB_EVENT_KINDS.iter().enumerate() {
3592 let Some(reg) = self.polled_events[slot].get() else {
3593 continue;
3594 };
3595 let Some(cb) = reg.cb else {
3596 continue;
3597 };
3598 let view = self.view_shared();
3599 let mut payload = empty_event_payload();
3600 let mut taken = false;
3601 // SAFETY: `view` is a live snapshot of this publisher, `payload`
3602 // and `taken` are stack locals the slot writes through. The slot
3603 // writes `payload` only when it sets `taken`.
3604 let rc = unsafe { take(&view, event_kind_to_c(*kind), &mut payload, &mut taken) };
3605 if rc != NROS_RMW_RET_OK || !taken {
3606 continue;
3607 }
3608 // SAFETY: `cb` and `user_ctx` were supplied together at
3609 // registration and the registrant keeps `user_ctx` alive for the
3610 // entity's lifetime (`nros-node`'s `EventRegs`). `payload` lives
3611 // to the end of this statement, which is the whole contract the
3612 // callback gets.
3613 unsafe { cb(event_kind_to_c(*kind), &payload, reg.user_ctx) };
3614 }
3615 self.polling_events.set(false);
3616 }
3617
3618 /// Topic name. Result is the null-terminated string written at
3619 /// publisher creation; never re-resolved from the backend.
3620 pub fn topic_name(&self) -> &str {
3621 cstr_buf_to_str(&self.topic_name_buf)
3622 }
3623
3624 /// Fully-qualified type name (`"std_msgs/msg/Int32"`).
3625 pub fn type_name(&self) -> &str {
3626 cstr_buf_to_str(&self.type_name_buf)
3627 }
3628
3629 /// QoS used to create this publisher.
3630 pub fn qos(&self) -> NrosRmwQos {
3631 self.qos
3632 }
3633
3634 /// `true` iff the backend exposes the publish loan primitive
3635 /// (Phase 99). Mirrors upstream `rmw_publisher_t::can_loan_messages`.
3636 pub fn can_loan_messages(&self) -> bool {
3637 self.can_loan_messages
3638 }
3639
3640 /// Issue 1164 — record a callback the runtime will serve by polling
3641 /// `publisher_take_event`. Returns `Unsupported` when there is no poll
3642 /// slot either, or when `kind` is not a publisher-side kind.
3643 fn register_polled_event(
3644 &self,
3645 kind: nros_rmw::EventKind,
3646 cb: NrosRmwEventCallback,
3647 user_ctx: *mut c_void,
3648 ) -> Result<(), TransportError> {
3649 if self.vtable.publisher_take_event.is_none() || cb.is_none() {
3650 return Err(TransportError::Unsupported);
3651 }
3652 let Some(slot) = pub_event_slot(kind) else {
3653 return Err(TransportError::Unsupported);
3654 };
3655 self.polled_events[slot].set(Some(PolledEventReg { cb, user_ctx }));
3656 Ok(())
3657 }
3658}
3659
3660/// Phase 124.A — writable slot returned by
3661/// [`CffiPublisher::try_lend_slot`]. Holds the backend's raw buffer
3662/// and opaque token until `commit_slot` consumes it or `Drop` fires
3663/// `pub_discard`.
3664#[cfg(feature = "lending")]
3665pub struct CffiSlot<'a> {
3666 buf: *mut u8,
3667 cap: usize,
3668 cursor: usize,
3669 token: *mut generated::rmw_loan_token_t,
3670 /// `None` after `commit_slot` consumes the slot — Drop skips the
3671 /// discard call in that case.
3672 publisher: Option<&'a CffiPublisher>,
3673 /// Phase 124.A.3 — `true` when this slot came from the runtime's
3674 /// arena fallback (backend had NULL `pub_loan`). Commit performs
3675 /// a `publish_raw` of the staged bytes; discard / Drop reclaims
3676 /// the staging buffer. `false` for native backend loans —
3677 /// commit / discard go through the vtable slots.
3678 fallback: bool,
3679}
3680
3681#[cfg(feature = "lending")]
3682impl<'a> CffiSlot<'a> {
3683 /// Mark the actual bytes written before commit. Defaults to the
3684 /// full capacity; callers that write a shorter prefix MUST call
3685 /// `set_len` first.
3686 pub fn set_len(&mut self, len: usize) {
3687 debug_assert!(len <= self.cap);
3688 self.cursor = len.min(self.cap);
3689 }
3690}
3691
3692/// Phase 124.A.3 — staging buffer for the arena-fallback loan path.
3693/// Allocated on each `try_lend_slot` when the backend's `pub_loan`
3694/// slot is NULL; commit copies into a `publish_raw` call; Drop /
3695/// discard reclaims the allocation. `Box::into_raw` of this struct
3696/// becomes the slot's opaque `token` so commit / discard can find
3697/// it back.
3698#[cfg(all(feature = "lending", feature = "alloc"))]
3699struct ArenaStaging {
3700 buf: alloc::vec::Vec<u8>,
3701}
3702
3703#[cfg(feature = "lending")]
3704impl<'a> AsMut<[u8]> for CffiSlot<'a> {
3705 fn as_mut(&mut self) -> &mut [u8] {
3706 // SAFETY: `buf` came from `pub_loan` with capacity `cap`. The
3707 // loan contract guarantees the slot stays valid until commit
3708 // or discard. The lifetime `'a` borrows the publisher so the
3709 // returned slice can't outlive the loan.
3710 unsafe { core::slice::from_raw_parts_mut(self.buf, self.cap) }
3711 }
3712}
3713
3714#[cfg(feature = "lending")]
3715impl<'a> Drop for CffiSlot<'a> {
3716 fn drop(&mut self) {
3717 if self.publisher.is_none() {
3718 // commit_slot consumed the loan — nothing to release.
3719 return;
3720 }
3721 if self.fallback {
3722 // Phase 124.A.3 — reclaim the staging allocation.
3723 #[cfg(feature = "alloc")]
3724 unsafe {
3725 let _ = alloc::boxed::Box::from_raw(self.token as *mut ArenaStaging);
3726 }
3727 return;
3728 }
3729 if let Some(p) = self.publisher
3730 && let Some(discard) = p.vtable.return_loaned_message_from_publisher
3731 {
3732 // Re-materialise the publisher view so the backend sees
3733 // the same `NrosRmwPublisher` shape it created the loan
3734 // against.
3735 let view = NrosRmwPublisher {
3736 topic_name: p.topic_name_buf.as_ptr().cast(),
3737 type_name: p.type_name_buf.as_ptr().cast(),
3738 qos: p.qos,
3739 can_loan_messages: p.can_loan_messages,
3740 _reserved: [0u8; 7],
3741 backend_data: p.backend_data,
3742 };
3743 // SAFETY: `token` came from a paired `pub_loan` on this
3744 // publisher and the publisher is still alive (lifetime
3745 // `'a` borrows it).
3746 let ret = unsafe { discard(&view, self.token) };
3747 if ret != NROS_RMW_RET_OK {
3748 nros_log::log_error!(
3749 nros_log::get_logger("nros_rmw_cffi"),
3750 "return_loaned_message_from_publisher failed with {}; the loan slot may be stranded",
3751 ret
3752 );
3753 }
3754 }
3755 }
3756}
3757
3758#[cfg(feature = "lending")]
3759impl nros_rmw::SlotLending for CffiPublisher {
3760 type Slot<'a> = CffiSlot<'a>;
3761
3762 fn try_lend_slot(&self, len: usize) -> Result<Option<CffiSlot<'_>>, TransportError> {
3763 let Some(loan) = self.vtable.borrow_loaned_message else {
3764 // Phase 124.A.3 — backend doesn't natively lend; allocate
3765 // a staging buffer and stash it in `token` so commit can
3766 // memcpy → publish_raw and discard / Drop can reclaim.
3767 // Requires `alloc` for the dynamic staging; without it there
3768 // is no staging to hand out and the answer is PERMANENT — see
3769 // the `not(alloc)` arm below.
3770 #[cfg(feature = "alloc")]
3771 {
3772 let mut staging = alloc::boxed::Box::new(ArenaStaging {
3773 buf: alloc::vec![0u8; len],
3774 });
3775 let buf_ptr = staging.buf.as_mut_ptr();
3776 // phase-406 W3 — the FALLBACK carries its own staging box in the token
3777 // slot. It is not a backend loan handle, so the cast is the
3778 // honest thing here: `fallback: true` below is what tells the
3779 // return path to unbox rather than call the backend.
3780 let token =
3781 alloc::boxed::Box::into_raw(staging) as *mut generated::rmw_loan_token_t;
3782 return Ok(Some(CffiSlot {
3783 buf: buf_ptr,
3784 cap: len,
3785 cursor: len,
3786 token,
3787 publisher: Some(self),
3788 fallback: true,
3789 }));
3790 }
3791 #[cfg(not(feature = "alloc"))]
3792 {
3793 // issue 0814 — this arm used to return `Ok(None)`, which every
3794 // caller above reads as "no slot RIGHT NOW, retry":
3795 // `EmbeddedRawPublisher::try_loan` maps it to
3796 // `LoanError::WouldBlock`, `loan_with_timeout` then spins the
3797 // executor until its whole budget is gone, and `LoanFuture`
3798 // returns `Pending` after a self-wake — a hot loop that never
3799 // resolves. But the condition is a COMPILE-TIME fact about this
3800 // image (no `alloc`) and a STATIC fact about this publisher
3801 // (its vtable has no `borrow_loaned_message`); neither can
3802 // change while the publisher lives, so no amount of retrying
3803 // will ever clear it. A permanent condition must not present as
3804 // a transient one.
3805 //
3806 // `Unsupported` rather than `LoanNotSupported`: the latter's
3807 // own doc bundles "or the loan slot is currently in use", which
3808 // is exactly the transient reading this arm must not offer.
3809 //
3810 // What a caller should do on seeing it: stop asking for a loan
3811 // on this publisher and take a non-loan path — `publish_raw`,
3812 // or better `publish_streamed`, which needs no token, no arena
3813 // and no heap, and which the XRCE and zenoh backends fill
3814 // natively.
3815 let _ = len;
3816 return Err(TransportError::Unsupported);
3817 }
3818 };
3819 let view = NrosRmwPublisher {
3820 topic_name: self.topic_name_buf.as_ptr().cast(),
3821 type_name: self.type_name_buf.as_ptr().cast(),
3822 qos: self.qos,
3823 can_loan_messages: self.can_loan_messages,
3824 _reserved: [0u8; 7],
3825 backend_data: self.backend_data,
3826 };
3827 let mut slot = generated::rmw_mut_byte_span_t {
3828 data: core::ptr::null_mut(),
3829 capacity: 0,
3830 len: 0,
3831 };
3832 let mut out_token: *mut generated::rmw_loan_token_t = core::ptr::null_mut();
3833 // SAFETY: vtable contract — slot pointers stay valid until
3834 // commit / discard.
3835 let ret = unsafe { loan(&view, len, &mut slot, &mut out_token) };
3836 let (out_buf, out_cap) = (slot.data, slot.capacity);
3837 if ret == NROS_RMW_RET_WOULD_BLOCK || ret == NROS_RMW_RET_NO_DATA {
3838 return Ok(None);
3839 }
3840 if ret != NROS_RMW_RET_OK {
3841 return Err(error_from_ret(ret));
3842 }
3843 if out_buf.is_null() || out_cap < len {
3844 // Defensive: a buggy backend returned OK with a too-small
3845 // slot. Treat as transient.
3846 if let Some(discard) = self.vtable.return_loaned_message_from_publisher {
3847 // The loan is already being abandoned; a failure to hand it back
3848 // does not change what this function returns, but it is the
3849 // second fault in a row and worth a line.
3850 let ret = unsafe { discard(&view, out_token) };
3851 if ret != NROS_RMW_RET_OK {
3852 nros_log::log_error!(
3853 nros_log::get_logger("nros_rmw_cffi"),
3854 "discarding an undersized loan also failed with {}",
3855 ret
3856 );
3857 }
3858 }
3859 return Ok(None);
3860 }
3861 Ok(Some(CffiSlot {
3862 buf: out_buf,
3863 cap: out_cap,
3864 cursor: len,
3865 token: out_token,
3866 publisher: Some(self),
3867 fallback: false,
3868 }))
3869 }
3870
3871 fn commit_slot(&self, mut slot: CffiSlot<'_>) -> Result<(), TransportError> {
3872 // Cancel Drop's discard — we're committing, not abandoning.
3873 let publisher = slot
3874 .publisher
3875 .take()
3876 .ok_or(TransportError::InvalidArgument)?;
3877 debug_assert!(core::ptr::eq(publisher, self));
3878 if slot.fallback {
3879 // Phase 124.A.3 — fallback path: reclaim the staging
3880 // box, run a single publish_raw of the cursor-truncated
3881 // contents.
3882 #[cfg(feature = "alloc")]
3883 {
3884 // SAFETY: `slot.token` came from
3885 // `Box::into_raw(Box<ArenaStaging>)` in try_lend_slot.
3886 let staging =
3887 unsafe { alloc::boxed::Box::from_raw(slot.token as *mut ArenaStaging) };
3888 let bytes = &staging.buf[..slot.cursor.min(staging.buf.len())];
3889 return Publisher::publish_raw(self, bytes);
3890 }
3891 #[cfg(not(feature = "alloc"))]
3892 {
3893 return Err(TransportError::Unsupported);
3894 }
3895 }
3896 let commit = self
3897 .vtable
3898 .publish_loaned_message
3899 .ok_or(TransportError::Unsupported)?;
3900 let view = NrosRmwPublisher {
3901 topic_name: self.topic_name_buf.as_ptr().cast(),
3902 type_name: self.type_name_buf.as_ptr().cast(),
3903 qos: self.qos,
3904 can_loan_messages: self.can_loan_messages,
3905 _reserved: [0u8; 7],
3906 backend_data: self.backend_data,
3907 };
3908 let len = slot.cursor;
3909 let token = slot.token;
3910 // `slot` drops here without firing `pub_discard` because
3911 // `publisher` is `None`.
3912 let ret = unsafe { commit(&view, token, len) };
3913 if ret != NROS_RMW_RET_OK {
3914 return Err(error_from_ret(ret));
3915 }
3916 Ok(())
3917 }
3918}
3919
3920// ---------------------------------------------------------------------------
3921// Issue 0812 — the loan path across the C / C++ boundary, without a heap.
3922//
3923// `SlotLending` hands back a `CffiSlot`, which is a Rust value with a `Drop`
3924// impl. An FFI entry point has to keep that value alive between `_loan` and
3925// `_commit` / `_discard` and has nowhere to put it, so `nros-c` / `nros-cpp`
3926// each boxed one per loan: a `malloc` on the path whose whole purpose is to
3927// remove copies and, worse, a hard `alloc` dependency on a surface that a
3928// heap-free image is supposed to be able to use.
3929//
3930// None of that state has to cross the boundary. Commit and discard are handed
3931// the publisher back as a parameter; whether the loan took the staging
3932// fallback is a property of that publisher's vtable, not of the individual
3933// loan; and the backend already minted a stable token of its own. So the FFI
3934// layers pass the BACKEND's token straight through and store nothing at all.
3935//
3936// One implementation, not two: `commit_raw` / `discard_raw` rebuild the
3937// `CffiSlot` and delegate to `commit_slot` / `Drop`, so the loan lifecycle
3938// keeps exactly one body and cannot drift from the `SlotLending` path.
3939// ---------------------------------------------------------------------------
3940#[cfg(feature = "lending")]
3941impl CffiPublisher {
3942 /// `true` when loans on this publisher take the runtime's staging
3943 /// fallback rather than the backend's own slot. A property of the
3944 /// vtable, so every loan on a given publisher answers the same — which
3945 /// is why the per-loan `fallback` bit need not travel in the token.
3946 #[inline]
3947 fn loan_is_fallback(&self) -> bool {
3948 self.vtable.borrow_loaned_message.is_none()
3949 }
3950
3951 /// Rebuild the [`CffiSlot`] describing an outstanding loan from its
3952 /// token, so the shared commit / discard bodies can run on it.
3953 ///
3954 /// `buf` and `cap` are only ever read by `as_mut` / `set_len`, neither of
3955 /// which runs on the commit or discard path (the fallback arm reads the
3956 /// staging buffer back out of `token`, the native arm hands `token` to
3957 /// the backend), so the rebuilt slot deliberately carries no buffer.
3958 ///
3959 /// # Safety
3960 /// `token` must be the token of a loan that is still outstanding on
3961 /// `self`, and the returned slot must be committed or dropped exactly
3962 /// once.
3963 unsafe fn slot_from_token(
3964 &self,
3965 token: *mut generated::rmw_loan_token_t,
3966 len: usize,
3967 ) -> CffiSlot<'_> {
3968 CffiSlot {
3969 buf: core::ptr::null_mut(),
3970 cap: len,
3971 cursor: len,
3972 token,
3973 publisher: Some(self),
3974 fallback: self.loan_is_fallback(),
3975 }
3976 }
3977
3978 /// Allocation-free loan: reserve a slot and hand back the backend's own
3979 /// opaque token instead of a Rust value the caller would have to store.
3980 ///
3981 /// Returns `(buf, cap, token)`. `Ok(None)` means no slot is available
3982 /// right now — the same meaning `SlotLending::try_lend_slot` gives it.
3983 ///
3984 /// The loan stays outstanding until [`commit_raw`](Self::commit_raw) or
3985 /// [`discard_raw`](Self::discard_raw) consumes `token` — exactly one of
3986 /// them, exactly once. Callers that can hold a Rust value across the
3987 /// whole loan should use `SlotLending` instead and let `Drop` do it.
3988 pub fn try_lend_raw(
3989 &self,
3990 len: usize,
3991 ) -> Result<Option<(*mut u8, usize, *mut generated::rmw_loan_token_t)>, TransportError> {
3992 use nros_rmw::SlotLending as _;
3993 let Some(mut slot) = self.try_lend_slot(len)? else {
3994 return Ok(None);
3995 };
3996 let buf = slot.as_mut().as_mut_ptr();
3997 let cap = slot.as_mut().len();
3998 let token = slot.token;
3999 // Keep the loan outstanding — dropping the slot here would hand it
4000 // straight back to the backend.
4001 core::mem::forget(slot);
4002 Ok(Some((buf, cap, token)))
4003 }
4004
4005 /// Commit a loan taken with [`try_lend_raw`](Self::try_lend_raw).
4006 ///
4007 /// `actual_len` is the number of bytes written. On the native path it
4008 /// reaches the backend's own commit, which is the layer that knows the
4009 /// slot's capacity and is responsible for clamping; the staging fallback
4010 /// clamps to the staging buffer here.
4011 ///
4012 /// # Safety
4013 /// `token` must come from a prior `try_lend_raw` on THIS publisher, must
4014 /// still be outstanding, and must not be used again after this call.
4015 pub unsafe fn commit_raw(
4016 &self,
4017 token: *mut generated::rmw_loan_token_t,
4018 actual_len: usize,
4019 ) -> Result<(), TransportError> {
4020 use nros_rmw::SlotLending as _;
4021 // SAFETY: forwarded from this function's own contract.
4022 let slot = unsafe { self.slot_from_token(token, actual_len) };
4023 self.commit_slot(slot)
4024 }
4025
4026 /// Abandon a loan taken with [`try_lend_raw`](Self::try_lend_raw)
4027 /// without sending it.
4028 ///
4029 /// # Safety
4030 /// `token` must come from a prior `try_lend_raw` on THIS publisher, must
4031 /// still be outstanding, and must not be used again after this call.
4032 pub unsafe fn discard_raw(
4033 &self,
4034 token: *mut generated::rmw_loan_token_t,
4035 ) -> Result<(), TransportError> {
4036 // SAFETY: forwarded from this function's own contract. Dropping the
4037 // rebuilt slot is what fires the backend's discard (or reclaims the
4038 // staging buffer).
4039 drop(unsafe { self.slot_from_token(token, 0) });
4040 Ok(())
4041 }
4042}
4043
4044impl Publisher for CffiPublisher {
4045 type Error = TransportError;
4046
4047 fn publish_raw(&self, data: &[u8]) -> Result<(), TransportError> {
4048 // Issue 1164 — the publisher's data path is its poll point for a
4049 // backend that fills `publisher_take_event`. No-op when nothing is
4050 // registered, and no-op for a backend that fills `*_event_init`.
4051 self.poll_status_events();
4052 let mut view = NrosRmwPublisher {
4053 topic_name: self.topic_name_buf.as_ptr().cast(),
4054 type_name: self.type_name_buf.as_ptr().cast(),
4055 qos: self.qos,
4056 can_loan_messages: self.can_loan_messages,
4057 _reserved: [0u8; 7],
4058 backend_data: self.backend_data,
4059 };
4060 let ret = unsafe {
4061 (self.vtable.publish.expect("rmw vtable: publish"))(
4062 &mut view,
4063 // phase-406 W2 — by VALUE: a read span has nothing to report back.
4064 generated::rmw_byte_span_t {
4065 data: data.as_ptr(),
4066 len: data.len(),
4067 },
4068 )
4069 };
4070 if ret != NROS_RMW_RET_OK {
4071 return Err(error_from_ret(ret));
4072 }
4073 Ok(())
4074 }
4075
4076 unsafe fn publish_streamed(
4077 &self,
4078 size_cb: unsafe extern "C" fn(out_total_len: *mut usize, user_ctx: *mut core::ffi::c_void),
4079 chunk_cb: unsafe extern "C" fn(
4080 out_buf: *mut u8,
4081 cap: usize,
4082 out_written: *mut usize,
4083 user_ctx: *mut core::ffi::c_void,
4084 ),
4085 user_ctx: *mut core::ffi::c_void,
4086 ) -> Result<(), TransportError> {
4087 // Phase 124.E.1+2 — vtable forwarder. If the backend exposes
4088 // `publish_streamed` natively, dispatch in one hop so the
4089 // callbacks land directly inside the backend's outbound
4090 // buffer (no staging copy). Otherwise fall back to the
4091 // `Publisher::publish_streamed` default body, which runs a
4092 // stack staging buffer + `publish_raw`.
4093 if let Some(f) = self.vtable.publish_streamed {
4094 // Issue 1164 — the native arm is a second publish path and needs
4095 // its own poll point. The staging fallback below does not: it
4096 // ends in `publish_raw`, which polls.
4097 self.poll_status_events();
4098 let mut view = NrosRmwPublisher {
4099 topic_name: self.topic_name_buf.as_ptr().cast(),
4100 type_name: self.type_name_buf.as_ptr().cast(),
4101 qos: self.qos,
4102 can_loan_messages: self.can_loan_messages,
4103 _reserved: [0u8; 7],
4104 backend_data: self.backend_data,
4105 };
4106 // Generated slot takes nullable callbacks; ours are live fn pointers.
4107 let ret = unsafe { f(&mut view, Some(size_cb), Some(chunk_cb), user_ctx) };
4108 if ret != NROS_RMW_RET_OK {
4109 return Err(error_from_ret(ret));
4110 }
4111 return Ok(());
4112 }
4113 // Inlined staging-buffer fallback. Mirrors the trait default
4114 // body so the override doesn't recurse through dynamic
4115 // dispatch — the default body would resolve back to this
4116 // function and deadlock.
4117 const STAGE_CAP: usize = 4096;
4118 let mut total = 0usize;
4119 unsafe { size_cb(&mut total as *mut usize, user_ctx) };
4120 if total > STAGE_CAP {
4121 return Err(TransportError::BufferTooSmall);
4122 }
4123 let mut stage = [0u8; STAGE_CAP];
4124 let mut written_so_far = 0usize;
4125 while written_so_far < total {
4126 let mut chunk_written = 0usize;
4127 let remaining = total - written_so_far;
4128 unsafe {
4129 chunk_cb(
4130 stage.as_mut_ptr().add(written_so_far),
4131 remaining,
4132 &mut chunk_written as *mut usize,
4133 user_ctx,
4134 );
4135 }
4136 if chunk_written == 0 {
4137 return Err(TransportError::BufferTooSmall);
4138 }
4139 written_so_far += chunk_written;
4140 }
4141 self.publish_raw(&stage[..total])
4142 }
4143
4144 fn buffer_error(&self) -> TransportError {
4145 TransportError::BufferTooSmall
4146 }
4147
4148 fn serialization_error(&self) -> TransportError {
4149 TransportError::SerializationError
4150 }
4151
4152 fn unsupported_event_error(&self) -> TransportError {
4153 TransportError::Unsupported
4154 }
4155
4156 unsafe fn register_event_callback(
4157 &mut self,
4158 kind: nros_rmw::EventKind,
4159 deadline_ms: u32,
4160 cb: nros_rmw::EventCallback,
4161 user_ctx: *mut core::ffi::c_void,
4162 ) -> Result<(), TransportError> {
4163 let view = NrosRmwPublisher {
4164 topic_name: self.topic_name_buf.as_ptr().cast(),
4165 type_name: self.type_name_buf.as_ptr().cast(),
4166 qos: self.qos,
4167 can_loan_messages: self.can_loan_messages,
4168 _reserved: [0u8; 7],
4169 backend_data: self.backend_data,
4170 };
4171 // Cffi event callback ABI matches nros_rmw::EventCallback (layout
4172 // notes in `rust_adapter`); the generated slot is nullable, so the
4173 // live fn pointer is wrapped in `Some`.
4174 let cb: NrosRmwEventCallback = Some(unsafe {
4175 core::mem::transmute::<
4176 nros_rmw::EventCallback,
4177 unsafe extern "C" fn(NrosRmwEventKind, *const NrosRmwEventPayload, *mut c_void),
4178 >(cb)
4179 });
4180 // Issue 0349 — a NULL slot means the backend does not implement this
4181 // OPTIONAL capability (xrce NULLs all three). Report it as
4182 // `Unsupported`; never panic, and never make it a registration error.
4183 //
4184 // Issue 1164 — NULL is ALSO the deliberate answer of a backend that
4185 // fills `publisher_take_event` instead, and for those the
4186 // registration is served here rather than refused. cyclonedds is one:
4187 // its `drive_io` is a sleep, so it has nowhere safe to call a
4188 // callback from and hands us its counters to drain.
4189 let Some(register) = self.vtable.publisher_event_init else {
4190 return self.register_polled_event(kind, cb, user_ctx);
4191 };
4192 let ret = unsafe { register(&view, event_kind_to_c(kind), deadline_ms, cb, user_ctx) };
4193 if ret != NROS_RMW_RET_OK {
4194 return Err(error_from_ret(ret));
4195 }
4196 Ok(())
4197 }
4198
4199 fn supports_event(&self, kind: nros_rmw::EventKind) -> bool {
4200 // Issue 1164 — SLOT granularity, not per-kind: the C ABI carries no
4201 // capability query, so the honest answer is "a path exists that could
4202 // carry this kind for this entity". A backend may still decline the
4203 // specific kind at registration, which is why `register_event_callback`
4204 // stays the authority.
4205 //
4206 // This used to be the trait default — a flat `false` for every cffi
4207 // backend, including the ones whose events work. `book/src/concepts/
4208 // status-events.md` tells applications to gate registration on this
4209 // call, so a flat `false` made every backend behind this vtable look
4210 // eventless whether it was or not.
4211 pub_event_slot(kind).is_some()
4212 && (self.vtable.publisher_event_init.is_some()
4213 || self.vtable.publisher_take_event.is_some())
4214 }
4215
4216 fn assert_liveliness(&self) -> Result<(), TransportError> {
4217 // Phase 108.B — manual liveliness assertion.
4218 let view_ptr = self as *const _ as *mut Self;
4219 let view = unsafe { (*view_ptr).make_view() };
4220 // Issue 0349 — a NULL slot means the backend does not implement this
4221 // OPTIONAL capability (xrce NULLs all three). Report it as
4222 // `Unsupported`; never panic, and never make it a registration error.
4223 //
4224 // ...but ONLY when the caller actually asked for manual assertion.
4225 // `rmw_vtable.h` has stated this split since phase 108 — "runtime
4226 // returns `NROS_RMW_RET_OK` for AUTOMATIC / NONE callers and
4227 // `NROS_RMW_RET_UNSUPPORTED` for MANUAL_*" — and it was implemented
4228 // NOWHERE: this comment used to say "the runtime caller (Node) gates
4229 // the call by liveliness_kind so we just delegate", and
4230 // `EmbeddedPublisher::assert_liveliness` does no such gating, it
4231 // forwards. So a publisher with AUTOMATIC liveliness on a backend
4232 // with a NULL slot read `Unsupported` for a call that had nothing to
4233 // do. `self.qos` is the REQUEST, which is the right side to ask:
4234 // `actual_qos` may be the `Unknown` absence on a backend with no
4235 // read-back, and an absence is not a reason to refuse.
4236 // (The phase-467 RMW gap-closure design study's Row 7.)
4237 let Some(assert_liveliness) = self.vtable.publisher_assert_liveliness else {
4238 return if qos_from_c(&self.qos).liveliness_kind.is_manual() {
4239 Err(TransportError::Unsupported)
4240 } else {
4241 Ok(())
4242 };
4243 };
4244 let ret = unsafe { assert_liveliness(&view) };
4245 if ret != NROS_RMW_RET_OK {
4246 return Err(error_from_ret(ret));
4247 }
4248 // Issue 1164 — asserting liveliness is the other publisher-side data
4249 // path, and the one after which `LivelinessLost` is most likely to
4250 // have moved. Same poll point the zenoh shim uses.
4251 self.poll_status_events();
4252 Ok(())
4253 }
4254
4255 /// The profile the backend GRANTED, read back at create time.
4256 ///
4257 /// Answers off the retained value rather than re-entering the backend:
4258 /// upstream's `rmw_publisher_get_actual_qos` is a property of the entity,
4259 /// and a backend call here would make an accessor a transport operation on
4260 /// a `&self` that any callback may hold.
4261 fn actual_qos(&self) -> QoSProfile {
4262 qos_from_c(&self.actual_qos)
4263 }
4264
4265 /// Upstream `rmw_get_gid_for_publisher`, through the vtable slot.
4266 ///
4267 /// The phase-467 RMW gap-closure design study's Q1, step 2. A NULL slot
4268 /// answers `Unsupported` — uORB and XRCE NULL it. The Rust adapter table
4269 /// zenoh rides FILLS it now (it was NULL there too until this study, so a
4270 /// Rust backend with a real identity could not report one). Never an
4271 /// all-zero gid: that is what the uninitialised `rmw_gid_t` below holds,
4272 /// so returning it on the NULL path would make "no identity" read exactly
4273 /// like an answer.
4274 ///
4275 /// The backend's `implementation_identifier` is deliberately DROPPED
4276 /// here. `nros_rmw_cffi_register_named` admits several backends in one
4277 /// image and gids from two of them are not comparable, so upstream's
4278 /// pairing matters — but the trait answers bytes, and the identifier a
4279 /// caller needs is the one on the SESSION it asked. Carrying a second
4280 /// copy per publisher would be a second place for it to disagree.
4281 fn get_gid(&self) -> Result<[u8; nros_rmw::PUBLISHER_GID_SIZE], TransportError> {
4282 let view_ptr = self as *const _ as *mut Self;
4283 let view = unsafe { (*view_ptr).make_view() };
4284 let Some(get_gid_for_publisher) = self.vtable.get_gid_for_publisher else {
4285 return Err(TransportError::Unsupported);
4286 };
4287 let mut gid = NrosRmwGid {
4288 implementation_identifier: core::ptr::null(),
4289 data: [0u8; nros_rmw::PUBLISHER_GID_SIZE],
4290 };
4291 let ret = unsafe { get_gid_for_publisher(&view, &mut gid) };
4292 if ret != NROS_RMW_RET_OK {
4293 return Err(error_from_ret(ret));
4294 }
4295 Ok(gid.data)
4296 }
4297
4298 /// Upstream `rmw_publisher_count_matched_subscriptions`, through the
4299 /// vtable slot. Issue 1643 — the slot's first RUNTIME reader (the node
4300 /// API's `get_subscription_count`); until then only an interop probe and
4301 /// the backend's own tests asked. A NULL slot answers `Unsupported`.
4302 fn count_matched_subscriptions(&self) -> Result<usize, TransportError> {
4303 let Some(count) = self.vtable.publisher_count_matched_subscriptions else {
4304 return Err(TransportError::Unsupported);
4305 };
4306 let view = self.view_shared();
4307 let mut n: usize = 0;
4308 // SAFETY: the slot is the backend's, called with a live view of this
4309 // entity and an out-pointer to a local, per the vtable contract.
4310 let ret = unsafe { count(&view, &mut n) };
4311 if ret != NROS_RMW_RET_OK {
4312 return Err(error_from_ret(ret));
4313 }
4314 Ok(n)
4315 }
4316}
4317
4318impl Drop for CffiPublisher {
4319 fn drop(&mut self) {
4320 if !self.backend_data.is_null() {
4321 let mut view = self.make_view();
4322 let ret = unsafe {
4323 (self
4324 .vtable
4325 .destroy_publisher
4326 .expect("rmw vtable: destroy_publisher"))(&mut view)
4327 };
4328 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
4329 // that cannot propagate. Logging is not a consolation prize: a
4330 // teardown that failed is a leak, and a leak with no message
4331 // surfaces later as an allocation failure with no provenance.
4332 // `nros_log`, never std stdio — issue 0589.
4333 if ret != NROS_RMW_RET_OK {
4334 nros_log::log_error!(
4335 nros_log::get_logger("nros_rmw_cffi"),
4336 "destroy_publisher failed with {}; the backend may have leaked the publisher",
4337 ret
4338 );
4339 }
4340 }
4341 }
4342}
4343
4344// ============================================================================
4345// CffiSubscription
4346// ============================================================================
4347
4348/// Subscription backed by a C vtable.
4349pub struct CffiSubscription {
4350 vtable: &'static NrosRmwVtable,
4351 topic_name_buf: [u8; NAME_BUF_LEN],
4352 type_name_buf: [u8; NAME_BUF_LEN],
4353 qos: NrosRmwQos,
4354 /// The profile the backend GRANTED, read back at create time.
4355 ///
4356 /// Not the request: `qos` above is what was asked for, this is what the
4357 /// entity runs, and every policy the backend could not report carries its
4358 /// `*_UNKNOWN` sentinel. `qos_unknown_c()` for a backend with no read-back
4359 /// slot at all — every policy an absence, which is the honest answer and
4360 /// is what [`nros_rmw::Publisher::actual_qos`] returns there.
4361 ///
4362 /// Read once at create rather than on every call: upstream's
4363 /// `rmw_*_get_actual_qos` is a property of the entity, and a slot that
4364 /// re-enters the backend would make an accessor a transport operation.
4365 actual_qos: NrosRmwQos,
4366 can_loan_messages: bool,
4367 backend_data: *mut c_void,
4368 /// Phase 231 (RFC-0038) — cached `subscription_supports_in_place` capability,
4369 /// queried once at creation so `supports_process_in_place(&self)` is cheap.
4370 supports_in_place: bool,
4371 /// Issue 0971 — a status that happened where it could not be returned.
4372 ///
4373 /// `take_sequence` is contractually a COUNT: a partial drain reports
4374 /// what it got rather than erroring (`rmw_vtable.h`). So when the drain
4375 /// stops because a message did not fit the caller's slot, there is nowhere
4376 /// to put `BufferTooSmall` — and this loop used to answer it with `?`,
4377 /// throwing away the count for the messages it HAD delivered, while the
4378 /// native Cyclone path answered the same condition with `Ok(count)`.
4379 ///
4380 /// The status is parked here and returned by the next call instead. That is
4381 /// the shape `nros-verification`'s `take_post_fix` already proves for
4382 /// the single take — check the flag first, clear it, return the error, take
4383 /// nothing — moved one call later because that is where the contract leaves
4384 /// room for it.
4385 pending_status: Option<TransportError>,
4386 /// Issue 1612 — what the last refused take said the sample needed, as
4387 /// [`nros_rmw::Subscription::refused_sample_len`] reports it.
4388 refused_len: Option<usize>,
4389 /// Issue 1164 — callbacks the runtime serves by polling
4390 /// `subscription_take_event`, indexed by [`SUB_EVENT_KINDS`]. See the
4391 /// note above [`PolledEventReg`].
4392 polled_events: [Cell<Option<PolledEventReg>>; SUB_EVENT_KINDS.len()],
4393 /// Re-entrancy guard for [`CffiSubscription::poll_status_events`] — a
4394 /// user callback is free to take, and taking polls. A `Cell`, not a
4395 /// lock; see the publisher's field of the same name.
4396 polling_events: Cell<bool>,
4397}
4398
4399impl CffiSubscription {
4400 /// Issue 1495 — `take_serialized` through the `take_with_info` slot, the
4401 /// sample's `rmw_message_info_t` converted to a [`MessageInfo`].
4402 ///
4403 /// The same pending-status and event-poll rules as `take_serialized`, so
4404 /// a caller cannot tell the two entry points apart except by the metadata.
4405 fn take_serialized_via_slot(
4406 &mut self,
4407 buf: &mut [u8],
4408 take_with_info: unsafe extern "C" fn(
4409 *const generated::rmw_subscription_t,
4410 *mut generated::rmw_mut_byte_span_t,
4411 *mut bool,
4412 *mut generated::rmw_message_info_t,
4413 ) -> generated::rmw_ret_t,
4414 ) -> Result<Option<(usize, Option<MessageInfo>)>, TransportError> {
4415 if let Some(status) = self.pending_status.take() {
4416 return Err(status);
4417 }
4418 self.poll_status_events();
4419 let view = self.make_view();
4420 let mut taken = false;
4421 let mut span = generated::rmw_mut_byte_span_t {
4422 data: buf.as_mut_ptr(),
4423 capacity: buf.len(),
4424 len: 0,
4425 };
4426 // SAFETY: an all-zero `rmw_message_info_t` is valid (null identifier,
4427 // zero gid, false flag); the backend overwrites it on a take.
4428 let mut raw: generated::rmw_message_info_t = unsafe { core::mem::zeroed() };
4429 // SAFETY: `view` borrows this subscription's backend handle, `span`
4430 // points into `buf` with its true capacity, and every out-pointer is
4431 // a live local for the duration of the call.
4432 let rc = unsafe { take_with_info(&view, &mut span, &mut taken, &mut raw) };
4433 if rc != NROS_RMW_RET_OK {
4434 return Err(error_from_ret(rc));
4435 }
4436 if !taken {
4437 return Ok(None);
4438 }
4439 let len = checked_take_len(span.len, buf.len())?;
4440 Ok(Some((len, Some(message_info_from_raw(&raw)))))
4441 }
4442}
4443
4444/// Issue 1495 — `rmw_message_info_t` → [`MessageInfo`].
4445///
4446/// The ABI's "unsupported" sequence sentinel becomes 0, `MessageInfo`'s
4447/// default — it has no unsupported spelling, and a sentinel cast to `i64` would
4448/// read as `-1`, a sequence number nobody sent.
4449pub(crate) fn message_info_from_raw(raw: &generated::rmw_message_info_t) -> MessageInfo {
4450 const UNSUPPORTED: u64 = u64::MAX;
4451 let seq = |v: u64| if v == UNSUPPORTED { 0 } else { v as i64 };
4452 let mut info = MessageInfo::new();
4453 info.set_source_timestamp(nros_rmw::Time::from_nanos(raw.source_timestamp));
4454 info.set_received_timestamp(nros_rmw::Time::from_nanos(raw.received_timestamp));
4455 info.set_publication_sequence_number(seq(raw.publication_sequence_number));
4456 info.set_reception_sequence_number(seq(raw.reception_sequence_number));
4457 info.set_publisher_gid(raw.publisher_gid.data);
4458 info
4459}
4460
4461impl CffiSubscription {
4462 fn make_view(&mut self) -> NrosRmwSubscription {
4463 NrosRmwSubscription {
4464 topic_name: self.topic_name_buf.as_ptr().cast(),
4465 type_name: self.type_name_buf.as_ptr().cast(),
4466 qos: self.qos,
4467 can_loan_messages: self.can_loan_messages,
4468 _reserved: [0u8; 7],
4469 backend_data: self.backend_data,
4470 }
4471 }
4472
4473 /// `make_view` from a shared borrow — the view is a by-value snapshot the
4474 /// backend reads and never writes through.
4475 fn view_shared(&self) -> NrosRmwSubscription {
4476 NrosRmwSubscription {
4477 topic_name: self.topic_name_buf.as_ptr().cast(),
4478 type_name: self.type_name_buf.as_ptr().cast(),
4479 qos: self.qos,
4480 can_loan_messages: self.can_loan_messages,
4481 _reserved: [0u8; 7],
4482 backend_data: self.backend_data,
4483 }
4484 }
4485
4486 /// Issue 1164 — record a callback the runtime will serve by polling
4487 /// `subscription_take_event`. Returns `Unsupported` when there is no poll
4488 /// slot either, or when `kind` is not a subscription-side kind.
4489 fn register_polled_event(
4490 &self,
4491 kind: nros_rmw::EventKind,
4492 cb: NrosRmwEventCallback,
4493 user_ctx: *mut c_void,
4494 ) -> Result<(), TransportError> {
4495 if self.vtable.subscription_take_event.is_none() || cb.is_none() {
4496 return Err(TransportError::Unsupported);
4497 }
4498 let Some(slot) = sub_event_slot(kind) else {
4499 return Err(TransportError::Unsupported);
4500 };
4501 self.polled_events[slot].set(Some(PolledEventReg { cb, user_ctx }));
4502 Ok(())
4503 }
4504
4505 /// Issue 1164 — drain `subscription_take_event` for every kind this
4506 /// subscription has a registered callback for, and fire the callbacks.
4507 ///
4508 /// Driven from `has_data` and `take_serialized`, which is literally what
4509 /// `subscriber.cpp` asks of a caller ("A caller polls them the way it
4510 /// already polls `has_data`") and the same pair the zenoh shim fires its
4511 /// own subscription events from. `has_data` is the executor's per-spin
4512 /// readiness scan, so a subscription owned by the dispatch loop is polled
4513 /// every spin without the application doing anything.
4514 ///
4515 /// The status is copied out of the backend before any callback runs, so
4516 /// no user code executes while the backend holds a lock of its own.
4517 fn poll_status_events(&self) {
4518 let Some(take) = self.vtable.subscription_take_event else {
4519 return;
4520 };
4521 if self.polling_events.replace(true) {
4522 return;
4523 }
4524 for (slot, kind) in SUB_EVENT_KINDS.iter().enumerate() {
4525 let Some(reg) = self.polled_events[slot].get() else {
4526 continue;
4527 };
4528 let Some(cb) = reg.cb else {
4529 continue;
4530 };
4531 let view = self.view_shared();
4532 let mut payload = empty_event_payload();
4533 let mut taken = false;
4534 // SAFETY: `view` is a live snapshot of this subscription;
4535 // `payload` and `taken` are stack locals the slot writes through,
4536 // and the slot writes `payload` only when it sets `taken`.
4537 let rc = unsafe { take(&view, event_kind_to_c(*kind), &mut payload, &mut taken) };
4538 if rc != NROS_RMW_RET_OK || !taken {
4539 continue;
4540 }
4541 // SAFETY: `cb` and `user_ctx` were supplied together at
4542 // registration; the registrant keeps `user_ctx` alive for the
4543 // entity's lifetime. `payload` outlives the call.
4544 unsafe { cb(event_kind_to_c(*kind), &payload, reg.user_ctx) };
4545 }
4546 self.polling_events.set(false);
4547 }
4548
4549 /// Phase 231 (RFC-0038) — drive the `process_raw_in_place` vtable slot,
4550 /// marshalling the Rust `FnOnce` through the C `ctx`/`cb`. A monomorphized
4551 /// trampoline takes the closure out of a stack `Option` cell and calls it
4552 /// with the borrowed slice. The named generic `G` is why the public trait
4553 /// method (which uses APIT) delegates here.
4554 fn run_process_in_place<G: FnOnce(&[u8])>(&mut self, f: G) -> Result<bool, TransportError> {
4555 let Some(slot) = self.vtable.process_raw_in_place else {
4556 return Err(TransportError::MessageTooLarge);
4557 };
4558 unsafe extern "C" fn cb_tramp<G: FnOnce(&[u8])>(
4559 ctx: *mut c_void,
4560 message: generated::rmw_byte_span_t,
4561 ) {
4562 let cell = unsafe { &mut *(ctx as *mut Option<G>) };
4563 if let Some(g) = cell.take() {
4564 g(unsafe { core::slice::from_raw_parts(message.data, message.len) });
4565 }
4566 }
4567 let mut cell: Option<G> = Some(f);
4568 let mut view = self.make_view();
4569 // Phase 376 W3.d step A — "processed one" arrives in the out-parameter.
4570 // The NO_DATA arm is gone: an empty subscription is now OK with
4571 // `processed = false`, which is what upstream's `taken = false` means.
4572 let mut processed = false;
4573 let rc = unsafe {
4574 slot(
4575 &mut view,
4576 &mut cell as *mut Option<G> as *mut c_void,
4577 Some(cb_tramp::<G>),
4578 &mut processed,
4579 )
4580 };
4581 if rc != NROS_RMW_RET_OK {
4582 return Err(error_from_ret(rc));
4583 }
4584 Ok(processed)
4585 }
4586
4587 pub fn topic_name(&self) -> &str {
4588 cstr_buf_to_str(&self.topic_name_buf)
4589 }
4590
4591 pub fn type_name(&self) -> &str {
4592 cstr_buf_to_str(&self.type_name_buf)
4593 }
4594
4595 pub fn qos(&self) -> NrosRmwQos {
4596 self.qos
4597 }
4598
4599 /// `true` iff the backend exposes the receive loan primitive
4600 /// (Phase 99).
4601 pub fn can_loan_messages(&self) -> bool {
4602 self.can_loan_messages
4603 }
4604}
4605
4606/// Phase 124.A — read-only view returned by
4607/// [`CffiSubscription::try_borrow`]. Holds the backend's raw buffer +
4608/// opaque token until `Drop` fires `sub_release`.
4609#[cfg(feature = "lending")]
4610pub struct CffiView<'a> {
4611 buf: *const u8,
4612 len: usize,
4613 token: *mut generated::rmw_loan_token_t,
4614 subscriber: Option<&'a mut CffiSubscription>,
4615}
4616
4617#[cfg(feature = "lending")]
4618impl<'a> AsRef<[u8]> for CffiView<'a> {
4619 fn as_ref(&self) -> &[u8] {
4620 // SAFETY: `buf` came from `sub_borrow` with length `len`.
4621 // The borrow contract guarantees the buffer stays valid until
4622 // `sub_release` fires (in Drop). Lifetime `'a` borrows the
4623 // subscriber so the slice can't outlive the borrow.
4624 unsafe { core::slice::from_raw_parts(self.buf, self.len) }
4625 }
4626}
4627
4628#[cfg(feature = "lending")]
4629impl<'a> Drop for CffiView<'a> {
4630 fn drop(&mut self) {
4631 if let Some(sub) = self.subscriber.take()
4632 && let Some(release) = sub.vtable.return_loaned_message_from_subscription
4633 {
4634 let view = sub.make_view();
4635 // SAFETY: `token` paired with a prior `sub_borrow` on
4636 // this subscriber and the subscriber is still alive.
4637 let ret = unsafe { release(&view, self.token) };
4638 if ret != NROS_RMW_RET_OK {
4639 nros_log::log_error!(
4640 nros_log::get_logger("nros_rmw_cffi"),
4641 "return_loaned_message_from_subscription failed with {}; the sample may stay checked out",
4642 ret
4643 );
4644 }
4645 }
4646 }
4647}
4648
4649#[cfg(feature = "lending")]
4650impl nros_rmw::SlotBorrowing for CffiSubscription {
4651 type View<'a> = CffiView<'a>;
4652
4653 fn try_borrow(&mut self) -> Result<Option<CffiView<'_>>, TransportError> {
4654 let Some(borrow) = self.vtable.take_loaned_message else {
4655 // Phase 124.A — backend doesn't natively borrow; runtime
4656 // falls back to `take_serialized` into a staging buffer
4657 // (124.A.3). `None` lets the caller use the slow path.
4658 return Ok(None);
4659 };
4660 let view = self.make_view();
4661 let mut out_view = generated::rmw_byte_span_t {
4662 data: core::ptr::null(),
4663 len: 0,
4664 };
4665 let mut out_token: *mut generated::rmw_loan_token_t = core::ptr::null_mut();
4666 // SAFETY: vtable contract — borrowed pointers stay valid
4667 // until `sub_release` runs.
4668 // Phase 376 W3.b/W3.d step A — status returned, `taken` out. The old
4669 // shape carried the length TWICE (returned and written to `*out_len`)
4670 // and reconciled them with `min(rc, max(out_len, rc))`, which is `rc`
4671 // for every input — so a backend whose two answers disagreed had one
4672 // silently ignored. There is one length now.
4673 let mut taken = false;
4674 let rc = unsafe { borrow(&view, &mut out_view, &mut out_token, &mut taken) };
4675 if rc != NROS_RMW_RET_OK {
4676 return Err(error_from_ret(rc));
4677 }
4678 let (out_buf, len) = (out_view.data, out_view.len);
4679 if !taken || out_buf.is_null() {
4680 return Ok(None);
4681 }
4682 Ok(Some(CffiView {
4683 buf: out_buf,
4684 len,
4685 token: out_token,
4686 subscriber: Some(self),
4687 }))
4688 }
4689}
4690
4691/// A take reported more bytes than the buffer it was handed — issue 0773.
4692///
4693/// Every copying take passes the vtable BOTH a pointer and the capacity, so an
4694/// `out_len` above that capacity is an ABI violation by the backend: it was
4695/// told how much room there was. The Rust side used to take the number on
4696/// faith, and a Cyclone service reply of 1005 bytes into a 256-byte buffer
4697/// panicked the SERVER process with `range end index 1005 out of range for
4698/// slice of length 256`.
4699///
4700/// It fails rather than truncating. `&buf[..cap]` would hand the caller a
4701/// silently short message — a corrupted payload presented as a good one, which
4702/// is worse than a loud stop and is the shape issue 0757 spent a phase
4703/// removing. `BufferTooSmall` is already this crate's word for it (the batch
4704/// take pre-checks with the same variant), and a caller that wants the sample
4705/// raises its buffer knob.
4706fn checked_take_len(out_len: usize, cap: usize) -> Result<usize, TransportError> {
4707 if out_len > cap {
4708 return Err(TransportError::BufferTooSmall);
4709 }
4710 Ok(out_len)
4711}
4712
4713/// Issue 1612 — read the size a refused `take` says its sample needed.
4714///
4715/// `NROS_RMW_TAKE_LEN_UNKNOWN` (zero, which the caller pre-sets) is a backend
4716/// that cannot know, or one written before the rule. A value that would have
4717/// FIT is not a size anybody needed — a backend that wrote it broke the
4718/// contract — so it is reported as unknown too, rather than as a number the
4719/// drop log would print beside a buffer it is smaller than.
4720fn refused_len_from_span(len: usize, cap: usize) -> Option<usize> {
4721 (len != generated::NROS_RMW_TAKE_LEN_UNKNOWN as usize && len > cap).then_some(len)
4722}
4723
4724impl nros_rmw::Subscription for CffiSubscription {
4725 type Error = TransportError;
4726
4727 fn supports_process_in_place(&self) -> bool {
4728 self.supports_in_place
4729 }
4730
4731 fn process_raw_in_place(&mut self, f: impl FnOnce(&[u8])) -> Result<bool, Self::Error> {
4732 self.run_process_in_place(f)
4733 }
4734
4735 fn has_data(&self) -> bool {
4736 // Issue 1164 — the readiness scan is the poll point `subscriber.cpp`
4737 // names for `subscription_take_event`. No-op unless something is
4738 // registered on a backend that fills that slot.
4739 self.poll_status_events();
4740 // has_data takes &mut to match the C signature; cast away const
4741 // because the predicate is logically read-only — backends must
4742 // not mutate state from has_data.
4743 let view_ptr = self as *const _ as *mut Self;
4744 let mut view = unsafe { (*view_ptr).make_view() };
4745 // Phase 376 W3.d step A — the flag arrives in an out-parameter and the
4746 // return is a plain status. The old `rc > 0` read a NEGATIVE error as
4747 // "no data", which is the same answer an empty subscription gives: a
4748 // broken backend and a quiet one were indistinguishable here. The trait
4749 // returns `bool` and has no error channel, so an error still maps to
4750 // false — but now by an explicit decision rather than by the arithmetic
4751 // happening to say so.
4752 let mut has = false;
4753 let rc =
4754 unsafe { (self.vtable.has_data.expect("rmw vtable: has_data"))(&mut view, &mut has) };
4755 rc == NROS_RMW_RET_OK && has
4756 }
4757
4758 fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
4759 // Issue 0971 — a status parked by an earlier `take_sequence` is
4760 // delivered here too, so a caller that mixes the two entry points still
4761 // hears about it rather than having it silently outlive the batch that
4762 // produced it. Cyclone's native flag is checked at both of its take
4763 // entry points for the same reason.
4764 if let Some(status) = self.pending_status.take() {
4765 return Err(status);
4766 }
4767 // Issue 1164 — the second poll point, mirroring the zenoh shim, which
4768 // fires its subscription events from `try_recv_raw` as well as from
4769 // `has_data`. A caller that only takes still sees its events.
4770 self.poll_status_events();
4771 let mut view = self.make_view();
4772 // Phase 376 W3.b/W3.d step A — `take` reports through out-parameters.
4773 // Three arms collapse into one: NO_DATA, a negative error, and the
4774 // `rc == 0` case that used to mean "zero bytes, treat as nothing" are
4775 // now `taken = false`, a status check, and `taken = true` with
4776 // `out_len == 0` respectively. That last one is a real behaviour fix:
4777 // a legitimately EMPTY message was previously indistinguishable from an
4778 // empty subscription.
4779 let mut taken = false;
4780 // phase-406 W2 — by POINTER: the callee sets `len`, and a by-value copy
4781 // would discard it. `capacity` in, `len` out.
4782 //
4783 // Issue 1612 — `len` starts at `NROS_RMW_TAKE_LEN_UNKNOWN`, and that is
4784 // load-bearing: it is what lets a backend that never writes `len` on a
4785 // refusal report "unknown" instead of whatever was on the stack.
4786 let mut span = generated::rmw_mut_byte_span_t {
4787 data: buf.as_mut_ptr(),
4788 capacity: buf.len(),
4789 len: generated::NROS_RMW_TAKE_LEN_UNKNOWN as usize,
4790 };
4791 let rc = unsafe {
4792 (self.vtable.take.expect("rmw vtable: take"))(&mut view, &mut span, &mut taken)
4793 };
4794 let out_len = span.len;
4795 if rc != NROS_RMW_RET_OK {
4796 if rc == NROS_RMW_RET_BUFFER_TOO_SMALL {
4797 self.refused_len = refused_len_from_span(out_len, buf.len());
4798 }
4799 return Err(error_from_ret(rc));
4800 }
4801 if !taken {
4802 return Ok(None);
4803 }
4804 // An over-long `out_len` (issue 0773) is refused here, and the number
4805 // that broke the contract is exactly the size the sample needed.
4806 checked_take_len(out_len, buf.len())
4807 .inspect_err(|_| self.refused_len = Some(out_len))
4808 .map(Some)
4809 }
4810
4811 fn refused_sample_len(&self) -> Option<usize> {
4812 self.refused_len
4813 }
4814
4815 fn take_serialized_with_info(
4816 &mut self,
4817 buf: &mut [u8],
4818 ) -> Result<Option<(usize, Option<MessageInfo>)>, TransportError> {
4819 // Issue 1495 — a backend that fills the `take_with_info` slot reports
4820 // metadata through caller-owned storage, which is the only channel a
4821 // pure C/C++ backend has: the side table below is written by the Rust
4822 // adapter alone, so before this dispatch existed Cyclone's
4823 // `message_info()` callback saw `None` for every sample. A NULL slot
4824 // keeps the side-table path unchanged (the Rust adapter leaves it
4825 // NULL and writes the table).
4826 if let Some(take_with_info) = self.vtable.take_with_info {
4827 return self.take_serialized_via_slot(buf, take_with_info);
4828 }
4829 let key = self.backend_data as usize;
4830 self.take_serialized(buf)
4831 .map(|opt| opt.map(|len| (len, take_cffi_message_info(key))))
4832 }
4833
4834 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
4835 fn take_validated(
4836 &mut self,
4837 buf: &mut [u8],
4838 ) -> Result<Option<(usize, nros_rmw::IntegrityStatus)>, Self::Error> {
4839 let key = self.backend_data as usize;
4840 request_cffi_integrity_status(key);
4841 self.take_serialized(buf).map(|opt| {
4842 opt.map(|len| {
4843 (
4844 len,
4845 take_cffi_integrity_status(key).unwrap_or(nros_rmw::IntegrityStatus {
4846 gap: 0,
4847 duplicate: false,
4848 crc_valid: None,
4849 }),
4850 )
4851 })
4852 })
4853 }
4854
4855 fn take_sequence(
4856 &mut self,
4857 buf: &mut [u8],
4858 per_msg_cap: usize,
4859 max_msgs: usize,
4860 out_lens: &mut [usize],
4861 ) -> Result<usize, TransportError> {
4862 // Phase 124.D.2 — runtime fallback. If the backend exposes
4863 // `take_sequence` natively, call it in one hop; otherwise
4864 // delegate to the trait's default body which loop-drives
4865 // `take_serialized`. Either way the caller sees the same shape:
4866 // contiguous slot block + per-slot length array + count
4867 // return.
4868 if let Some(f) = self.vtable.take_sequence {
4869 if per_msg_cap == 0 || max_msgs == 0 {
4870 return Ok(0);
4871 }
4872 let limit = max_msgs.min(out_lens.len());
4873 if buf.len() < limit.saturating_mul(per_msg_cap) {
4874 return Err(TransportError::BufferTooSmall);
4875 }
4876 let view = self.make_view();
4877 // Phase 376 W3.b/W3.d step A — the count arrives in `taken`.
4878 let mut taken = 0usize;
4879 let rc = unsafe {
4880 f(
4881 &view,
4882 buf.as_mut_ptr(),
4883 per_msg_cap,
4884 limit,
4885 out_lens.as_mut_ptr(),
4886 &mut taken,
4887 )
4888 };
4889 if rc != NROS_RMW_RET_OK {
4890 // Issue 1612 — the batch slot has no span to carry a size, so a
4891 // refusal here is an UNKNOWN one; an earlier take's number must
4892 // not be read as this sample's.
4893 self.refused_len = None;
4894 return Err(error_from_ret(rc));
4895 }
4896 return Ok(taken);
4897 }
4898 // Phase 124.D.2 — `take_serialized` loop fallback. Inlined
4899 // here (rather than dispatching back through the trait
4900 // default body) so the recursion is structurally
4901 // impossible — `Subscription::take_sequence` on
4902 // `CffiSubscription` is THIS function, and forwarding to
4903 // the default body would deadlock the override.
4904 if per_msg_cap == 0 || max_msgs == 0 {
4905 return Ok(0);
4906 }
4907 // Issue 0971 — a status parked by an earlier drain is delivered before
4908 // any new take, and taking nothing this call is the point: the caller
4909 // has to be able to act on it before it asks for more.
4910 if let Some(status) = self.pending_status.take() {
4911 return Err(status);
4912 }
4913 let limit = max_msgs.min(out_lens.len());
4914 if buf.len() < limit.saturating_mul(per_msg_cap) {
4915 return Err(TransportError::BufferTooSmall);
4916 }
4917 let mut count = 0;
4918 for i in 0..limit {
4919 let slot = &mut buf[i * per_msg_cap..(i + 1) * per_msg_cap];
4920 match self.take_serialized(slot) {
4921 Ok(Some(len)) => {
4922 out_lens[i] = len;
4923 count += 1;
4924 }
4925 Ok(None) => break,
4926 // Issue 0971 — this arm used to be `?`, which discarded `count`
4927 // and reported the error, so a caller could not tell how many
4928 // of the slots it can see are valid. The messages already
4929 // written are real and are reported; the status is parked for
4930 // the next call. With nothing delivered yet there is no count
4931 // worth protecting, so it goes out immediately — which is also
4932 // what makes the single-message case identical to `take_serialized`.
4933 Err(e) => {
4934 if count == 0 {
4935 return Err(e);
4936 }
4937 self.pending_status = Some(e);
4938 break;
4939 }
4940 }
4941 }
4942 Ok(count)
4943 }
4944
4945 fn deserialization_error(&self) -> TransportError {
4946 TransportError::DeserializationError
4947 }
4948
4949 fn unsupported_event_error(&self) -> TransportError {
4950 TransportError::Unsupported
4951 }
4952
4953 unsafe fn register_event_callback(
4954 &mut self,
4955 kind: nros_rmw::EventKind,
4956 deadline_ms: u32,
4957 cb: nros_rmw::EventCallback,
4958 user_ctx: *mut core::ffi::c_void,
4959 ) -> Result<(), TransportError> {
4960 let view = self.make_view();
4961 let cb: NrosRmwEventCallback = Some(unsafe {
4962 core::mem::transmute::<
4963 nros_rmw::EventCallback,
4964 unsafe extern "C" fn(NrosRmwEventKind, *const NrosRmwEventPayload, *mut c_void),
4965 >(cb)
4966 });
4967 // Issue 0349 — a NULL slot means the backend does not implement this
4968 // OPTIONAL capability (xrce NULLs all three). Report it as
4969 // `Unsupported`; never panic, and never make it a registration error.
4970 //
4971 // Issue 1164 — NULL is ALSO the deliberate answer of a backend that
4972 // fills `subscription_take_event` instead. cyclonedds is one, and
4973 // says so where it declines: "So `*_event_init` stays NULL here and
4974 // these two slots carry the surface." Serve those here instead of
4975 // refusing them.
4976 let Some(register) = self.vtable.subscription_event_init else {
4977 return self.register_polled_event(kind, cb, user_ctx);
4978 };
4979 let ret = unsafe { register(&view, event_kind_to_c(kind), deadline_ms, cb, user_ctx) };
4980 if ret != NROS_RMW_RET_OK {
4981 return Err(error_from_ret(ret));
4982 }
4983 Ok(())
4984 }
4985
4986 fn supports_event(&self, kind: nros_rmw::EventKind) -> bool {
4987 // Issue 1164 — slot granularity; see `CffiPublisher::supports_event`
4988 // for why that is the honest answer and why a flat `false` was not.
4989 sub_event_slot(kind).is_some()
4990 && (self.vtable.subscription_event_init.is_some()
4991 || self.vtable.subscription_take_event.is_some())
4992 }
4993
4994 /// The profile the backend GRANTED. See
4995 /// [`CffiPublisher::actual_qos`](Publisher::actual_qos).
4996 fn actual_qos(&self) -> QoSProfile {
4997 qos_from_c(&self.actual_qos)
4998 }
4999
5000 /// Upstream `rmw_subscription_count_matched_publishers`, through the
5001 /// vtable slot. See [`CffiPublisher::count_matched_subscriptions`]
5002 /// (`Publisher::count_matched_subscriptions`).
5003 fn count_matched_publishers(&self) -> Result<usize, TransportError> {
5004 let Some(count) = self.vtable.subscription_count_matched_publishers else {
5005 return Err(TransportError::Unsupported);
5006 };
5007 let view = self.view_shared();
5008 let mut n: usize = 0;
5009 // SAFETY: the slot is the backend's, called with a live view of this
5010 // entity and an out-pointer to a local, per the vtable contract.
5011 let ret = unsafe { count(&view, &mut n) };
5012 if ret != NROS_RMW_RET_OK {
5013 return Err(error_from_ret(ret));
5014 }
5015 Ok(n)
5016 }
5017}
5018
5019impl Drop for CffiSubscription {
5020 fn drop(&mut self) {
5021 if !self.backend_data.is_null() {
5022 clear_cffi_message_info(self.backend_data as usize);
5023 let mut view = self.make_view();
5024 let ret = unsafe {
5025 (self
5026 .vtable
5027 .destroy_subscription
5028 .expect("rmw vtable: destroy_subscription"))(&mut view)
5029 };
5030 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
5031 // that cannot propagate. Logging is not a consolation prize: a
5032 // teardown that failed is a leak, and a leak with no message
5033 // surfaces later as an allocation failure with no provenance.
5034 // `nros_log`, never std stdio — issue 0589.
5035 if ret != NROS_RMW_RET_OK {
5036 nros_log::log_error!(
5037 nros_log::get_logger("nros_rmw_cffi"),
5038 "destroy_subscription failed with {}; the backend may have leaked the subscription",
5039 ret
5040 );
5041 }
5042 }
5043 }
5044}
5045
5046// ============================================================================
5047// CffiService
5048// ============================================================================
5049
5050/// Service server backed by a C vtable.
5051pub struct CffiService {
5052 vtable: &'static NrosRmwVtable,
5053 service_name_buf: [u8; NAME_BUF_LEN],
5054 type_name_buf: [u8; NAME_BUF_LEN],
5055 backend_data: *mut c_void,
5056 /// The profiles the backend GRANTED for this entity's TWO endpoints.
5057 ///
5058 /// `create_*` takes ONE profile and builds a request endpoint and a
5059 /// response endpoint from it; they negotiate against different peers and
5060 /// may be granted different things, and neither `rmw_service_t` nor
5061 /// `rmw_client_t` has a `qos` field, so this is the only place the
5062 /// per-direction answer exists. `qos_unknown_c()` where the backend has no
5063 /// read-back slot.
5064 request_qos: NrosRmwQos,
5065 /// See [`Self::request_qos`].
5066 response_qos: NrosRmwQos,
5067 /// Issue 1632 — what the last refused `take_request` said the request
5068 /// needed, as [`ServiceTrait::refused_request_len`] reports it.
5069 refused_len: Option<usize>,
5070}
5071
5072impl CffiService {
5073 fn make_view(&mut self) -> NrosRmwService {
5074 NrosRmwService {
5075 service_name: self.service_name_buf.as_ptr().cast(),
5076 type_name: self.type_name_buf.as_ptr().cast(),
5077 _reserved: [0u8; 8],
5078 backend_data: self.backend_data,
5079 }
5080 }
5081
5082 pub fn service_name(&self) -> &str {
5083 cstr_buf_to_str(&self.service_name_buf)
5084 }
5085
5086 pub fn type_name(&self) -> &str {
5087 cstr_buf_to_str(&self.type_name_buf)
5088 }
5089}
5090
5091impl ServiceTrait for CffiService {
5092 type Error = TransportError;
5093
5094 fn has_request(&self) -> bool {
5095 let view_ptr = self as *const _ as *mut Self;
5096 let mut view = unsafe { (*view_ptr).make_view() };
5097 // Phase 376 W3.d step A — see `has_data` above for why an error maps to
5098 // false explicitly rather than through `rc > 0`.
5099 let mut has = false;
5100 let rc = unsafe {
5101 (self.vtable.has_request.expect("rmw vtable: has_request"))(&mut view, &mut has)
5102 };
5103 rc == NROS_RMW_RET_OK && has
5104 }
5105
5106 fn take_request<'a>(
5107 &mut self,
5108 buf: &'a mut [u8],
5109 ) -> Result<Option<ServiceRequest<'a>>, TransportError> {
5110 let mut seq: i64 = 0;
5111 let mut view = self.make_view();
5112 // Phase 376 W3.b/W3.d step A — status returned, payload length and
5113 // `taken` in out-parameters. The `rc == 0` arm is gone with the same
5114 // fix `take` got: a zero-length REQUEST is a legitimate message and
5115 // used to be indistinguishable from an empty queue.
5116 // Issue 1632 — `len` starts at `NROS_RMW_TAKE_LEN_UNKNOWN`, so a
5117 // backend that never writes it on a refusal reports "unknown".
5118 let mut request_span = generated::rmw_mut_byte_span_t {
5119 data: buf.as_mut_ptr(),
5120 capacity: buf.len(),
5121 len: generated::NROS_RMW_TAKE_LEN_UNKNOWN as usize,
5122 };
5123 let mut taken = false;
5124 let rc = unsafe {
5125 (self.vtable.take_request.expect("rmw vtable: take_request"))(
5126 &mut view,
5127 &mut request_span,
5128 &mut seq,
5129 &mut taken,
5130 )
5131 };
5132 if rc != NROS_RMW_RET_OK {
5133 if rc == NROS_RMW_RET_BUFFER_TOO_SMALL {
5134 self.refused_len = refused_len_from_span(request_span.len, buf.len());
5135 }
5136 return Err(error_from_ret(rc));
5137 }
5138 if !taken {
5139 return Ok(None);
5140 }
5141 let out_len = request_span.len;
5142 let len = checked_take_len(out_len, buf.len())
5143 .inspect_err(|_| self.refused_len = Some(out_len))?;
5144 Ok(Some(ServiceRequest {
5145 data: &buf[..len],
5146 sequence_number: seq,
5147 }))
5148 }
5149
5150 fn refused_request_len(&self) -> Option<usize> {
5151 self.refused_len
5152 }
5153
5154 fn send_response(&mut self, sequence_number: i64, data: &[u8]) -> Result<(), TransportError> {
5155 let mut view = self.make_view();
5156 let ret = unsafe {
5157 (self
5158 .vtable
5159 .send_response
5160 .expect("rmw vtable: send_response"))(
5161 &mut view,
5162 sequence_number,
5163 generated::rmw_byte_span_t {
5164 data: data.as_ptr(),
5165 len: data.len(),
5166 },
5167 )
5168 };
5169 if ret != NROS_RMW_RET_OK {
5170 return Err(error_from_ret(ret));
5171 }
5172 Ok(())
5173 }
5174
5175 /// What the REQUEST endpoint — the subscription this service takes
5176 /// requests on — was granted.
5177 fn request_subscription_actual_qos(&self) -> QoSProfile {
5178 qos_from_c(&self.request_qos)
5179 }
5180
5181 /// What the RESPONSE endpoint — the publisher this service answers on —
5182 /// was granted. Not necessarily the same profile: one request built both
5183 /// endpoints, and they negotiate against different peers.
5184 fn response_publisher_actual_qos(&self) -> QoSProfile {
5185 qos_from_c(&self.response_qos)
5186 }
5187}
5188
5189impl Drop for CffiService {
5190 fn drop(&mut self) {
5191 if !self.backend_data.is_null() {
5192 let mut view = self.make_view();
5193 let ret = unsafe {
5194 (self
5195 .vtable
5196 .destroy_service
5197 .expect("rmw vtable: destroy_service"))(&mut view)
5198 };
5199 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
5200 // that cannot propagate. Logging is not a consolation prize: a
5201 // teardown that failed is a leak, and a leak with no message
5202 // surfaces later as an allocation failure with no provenance.
5203 // `nros_log`, never std stdio — issue 0589.
5204 if ret != NROS_RMW_RET_OK {
5205 nros_log::log_error!(
5206 nros_log::get_logger("nros_rmw_cffi"),
5207 "destroy_service failed with {}; the backend may have leaked the service",
5208 ret
5209 );
5210 }
5211 }
5212 }
5213}
5214
5215// ============================================================================
5216// CffiClient
5217// ============================================================================
5218
5219/// Service client backed by a C vtable.
5220pub struct CffiClient {
5221 vtable: &'static NrosRmwVtable,
5222 service_name_buf: [u8; NAME_BUF_LEN],
5223 type_name_buf: [u8; NAME_BUF_LEN],
5224 backend_data: *mut c_void,
5225 /// The profiles the backend GRANTED for this entity's TWO endpoints.
5226 ///
5227 /// `create_*` takes ONE profile and builds a request endpoint and a
5228 /// response endpoint from it; they negotiate against different peers and
5229 /// may be granted different things, and neither `rmw_service_t` nor
5230 /// `rmw_client_t` has a `qos` field, so this is the only place the
5231 /// per-direction answer exists. `qos_unknown_c()` where the backend has no
5232 /// read-back slot.
5233 request_qos: NrosRmwQos,
5234 /// See [`Self::request_qos`].
5235 response_qos: NrosRmwQos,
5236 /// Issue 1632 — what the last refused `take_response` said the reply
5237 /// needed, as [`ClientTrait::refused_response_len`] reports it.
5238 refused_len: Option<usize>,
5239}
5240
5241impl CffiClient {
5242 fn make_view(&mut self) -> NrosRmwClient {
5243 NrosRmwClient {
5244 service_name: self.service_name_buf.as_ptr().cast(),
5245 type_name: self.type_name_buf.as_ptr().cast(),
5246 _reserved: [0u8; 8],
5247 backend_data: self.backend_data,
5248 }
5249 }
5250
5251 pub fn service_name(&self) -> &str {
5252 cstr_buf_to_str(&self.service_name_buf)
5253 }
5254
5255 pub fn type_name(&self) -> &str {
5256 cstr_buf_to_str(&self.type_name_buf)
5257 }
5258}
5259
5260impl ClientTrait for CffiClient {
5261 type Error = TransportError;
5262
5263 fn send_request_raw(&mut self, request: &[u8]) -> Result<i64, TransportError> {
5264 // Phase-301 (issue 0240) — `send_request_raw` +
5265 // `take_response_raw` is the ONE request/reply path (the
5266 // blocking `call_raw` slot is gone from the vtable). Backends
5267 // that omit the slot get `Unsupported`; the executor surfaces
5268 // the error instead of silently degrading.
5269 let Some(f) = self.vtable.send_request else {
5270 return Err(TransportError::Unsupported);
5271 };
5272 let view = self.make_view();
5273 // Issue 0778 — the id the backend assigned. A backend that leaves it
5274 // untouched reports 0, which is a legal id; nothing here treats it as
5275 // a sentinel.
5276 let mut sequence_id: i64 = 0;
5277 let rc = unsafe {
5278 f(
5279 &view,
5280 generated::rmw_byte_span_t {
5281 data: request.as_ptr(),
5282 len: request.len(),
5283 },
5284 &mut sequence_id,
5285 )
5286 };
5287 if rc != NROS_RMW_RET_OK {
5288 return Err(error_from_ret(rc));
5289 }
5290 Ok(sequence_id)
5291 }
5292
5293 fn take_response_raw(
5294 &mut self,
5295 reply_buf: &mut [u8],
5296 ) -> Result<Option<(usize, i64)>, TransportError> {
5297 // Non-blocking poll only. NULL slot = backend doesn't implement
5298 // the service-client path; surface Unsupported.
5299 let Some(f) = self.vtable.take_response else {
5300 return Err(TransportError::Unsupported);
5301 };
5302 let view = self.make_view();
5303 // Phase 376 W3.b/W3.d step A — see `take_request`.
5304 // Issue 1632 — pre-set to UNKNOWN, as in `take_request`.
5305 let mut reply_span = generated::rmw_mut_byte_span_t {
5306 data: reply_buf.as_mut_ptr(),
5307 capacity: reply_buf.len(),
5308 len: generated::NROS_RMW_TAKE_LEN_UNKNOWN as usize,
5309 };
5310 let mut taken = false;
5311 let mut seq: i64 = 0;
5312 let rc = unsafe { f(&view, &mut reply_span, &mut seq, &mut taken) };
5313 if rc != NROS_RMW_RET_OK {
5314 if rc == NROS_RMW_RET_BUFFER_TOO_SMALL {
5315 self.refused_len = refused_len_from_span(reply_span.len, reply_buf.len());
5316 }
5317 return Err(error_from_ret(rc));
5318 }
5319 if !taken {
5320 return Ok(None);
5321 }
5322 let out_len = reply_span.len;
5323 let len = checked_take_len(out_len, reply_buf.len())
5324 .inspect_err(|_| self.refused_len = Some(out_len))?;
5325 Ok(Some((len, seq)))
5326 }
5327
5328 fn refused_response_len(&self) -> Option<usize> {
5329 self.refused_len
5330 }
5331
5332 fn service_is_ready(&self) -> Result<bool, TransportError> {
5333 let Some(f) = self.vtable.service_server_is_available else {
5334 return Err(TransportError::Unsupported);
5335 };
5336 // SAFETY: `f` accepts a `*mut NrosRmwClient`. We
5337 // construct a transient view from this client's fields the
5338 // same way `make_view` does, but on `&self` (no mutation
5339 // required for a graph probe). The borrowed pointers all
5340 // alias into `&self`, so the lifetime is bounded by the
5341 // call.
5342 let view = NrosRmwClient {
5343 service_name: self.service_name_buf.as_ptr().cast(),
5344 type_name: self.type_name_buf.as_ptr().cast(),
5345 _reserved: [0u8; 8],
5346 backend_data: self.backend_data,
5347 };
5348 // Phase 376 W3.d step A — the slot answers through an out-parameter and
5349 // returns a plain status, so there is no non-spec value left to be
5350 // lenient about: the old arm treating "any positive other than 1" as
5351 // available existed only because a count and a status shared one int.
5352 let mut available = false;
5353 let rc = unsafe { f(&view, &mut available) };
5354 if rc != NROS_RMW_RET_OK {
5355 return Err(error_from_ret(rc));
5356 }
5357 Ok(available)
5358 }
5359
5360 /// What the REQUEST endpoint — the publisher this client sends requests on
5361 /// — was granted.
5362 fn request_publisher_actual_qos(&self) -> QoSProfile {
5363 qos_from_c(&self.request_qos)
5364 }
5365
5366 /// What the RESPONSE endpoint — the subscription this client takes replies
5367 /// on — was granted. See [`Self::request_publisher_actual_qos`].
5368 fn response_subscription_actual_qos(&self) -> QoSProfile {
5369 qos_from_c(&self.response_qos)
5370 }
5371}
5372
5373impl Drop for CffiClient {
5374 fn drop(&mut self) {
5375 if !self.backend_data.is_null() {
5376 let mut view = self.make_view();
5377 let ret = unsafe {
5378 (self
5379 .vtable
5380 .destroy_client
5381 .expect("rmw vtable: destroy_client"))(&mut view)
5382 };
5383 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
5384 // that cannot propagate. Logging is not a consolation prize: a
5385 // teardown that failed is a leak, and a leak with no message
5386 // surfaces later as an allocation failure with no provenance.
5387 // `nros_log`, never std stdio — issue 0589.
5388 if ret != NROS_RMW_RET_OK {
5389 nros_log::log_error!(
5390 nros_log::get_logger("nros_rmw_cffi"),
5391 "destroy_client failed with {}; the backend may have leaked the client",
5392 ret
5393 );
5394 }
5395 }
5396 }
5397}
5398
5399// ============================================================================
5400// Factory
5401// ============================================================================
5402
5403/// RMW factory for the C function table backend.
5404#[derive(Default)]
5405pub struct CffiRmw;
5406
5407impl nros_rmw::Rmw for CffiRmw {
5408 type Session = CffiSession;
5409 type Error = TransportError;
5410
5411 fn open(self, config: &nros_rmw::RmwConfig) -> Result<CffiSession, TransportError> {
5412 // issue 0331 — the wire values are specified by
5413 // `nros_rmw_session_mode_t` in rmw_vtable.h; keep this match aligned
5414 // with it rather than restating bare literals.
5415 let mode = match config.mode {
5416 nros_rmw::SessionMode::Client => {
5417 generated::nros_rmw_session_mode_t::NROS_RMW_SESSION_MODE_CLIENT as u8
5418 }
5419 nros_rmw::SessionMode::Peer => {
5420 generated::nros_rmw_session_mode_t::NROS_RMW_SESSION_MODE_PEER as u8
5421 }
5422 };
5423 // phase-206 W3 — forward `config.properties` instead of dropping it.
5424 // This was the outbound half of the same silence the cffi adapter had
5425 // on the inbound side: the trait handed us properties, and the seam
5426 // threw them away without a word.
5427 // Issue 1444 — `config.namespace` used to stop here. `Executor::open`
5428 // fills `RmwConfig { namespace: config.namespace, .. }`, and this seam
5429 // read every other field and not that one, so the session's own
5430 // liveliness token sat at the ROOT on every namespaced image — visible
5431 // as an EXTRA `ros2 node list` line rather than as a wrong one.
5432 CffiSession::open_with_properties(
5433 config.locator,
5434 mode,
5435 config.domain_id,
5436 config.node_name,
5437 config.namespace,
5438 config.properties,
5439 )
5440 }
5441}
5442
5443impl CffiRmw {
5444 /// Phase 104.C.1 — open a session against a named backend.
5445 /// `rmw_name` selects an entry from the registry populated by
5446 /// `nros_rmw_cffi_register_named` (Phase 104.B.2).
5447 pub fn open_with_rmw(
5448 rmw_name: &str,
5449 config: &nros_rmw::RmwConfig,
5450 ) -> Result<CffiSession, TransportError> {
5451 let mode = match config.mode {
5452 nros_rmw::SessionMode::Client => 0u8,
5453 nros_rmw::SessionMode::Peer => 1u8,
5454 };
5455 CffiSession::open_named_with_properties(
5456 rmw_name,
5457 config.locator,
5458 mode,
5459 config.domain_id,
5460 config.node_name,
5461 config.namespace,
5462 config.properties,
5463 )
5464 }
5465}
5466
5467// ============================================================================
5468// Phase 102.5 — typed-struct roundtrip test
5469// ============================================================================
5470//
5471// Verifies the visible-struct contract end-to-end:
5472// 1. Runtime fills `topic_name` / `type_name` / `qos` before
5473// `create_publisher`.
5474// 2. Backend's `create_publisher` writes `backend_data` and
5475// `can_loan_messages` into the same struct.
5476// 3. Rust accessors (`CffiPublisher::topic_name()`, `qos()`,
5477// `can_loan_messages()`) read back the values without any
5478// vtable callback.
5479
5480// ============================================================================
5481// Phase 376 W5 — backend log severity
5482// ============================================================================
5483
5484/// Map `nros_log`'s ladder onto upstream's.
5485///
5486/// `Trace` has no upstream counterpart and folds into `DEBUG`. Losing a
5487/// distinction upstream never had is better than inventing a value a ROS-side
5488/// caller could not produce — the mapping is deliberately lossy in the
5489/// direction that keeps the wire vocabulary standard.
5490#[must_use]
5491pub fn rmw_severity_of(severity: nros_log::Severity) -> generated::rmw_log_severity_t::Type {
5492 match severity {
5493 nros_log::Severity::Trace | nros_log::Severity::Debug => {
5494 generated::rmw_log_severity_t::RMW_LOG_SEVERITY_DEBUG
5495 }
5496 nros_log::Severity::Info => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_INFO,
5497 nros_log::Severity::Warn => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_WARN,
5498 nros_log::Severity::Error => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_ERROR,
5499 nros_log::Severity::Fatal => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_FATAL,
5500 }
5501}
5502
5503/// Set the verbosity of every registered BACKEND's own logging.
5504///
5505/// This is the backend's logger — Cyclone's `dds_log`, zenoh-pico's — not
5506/// `nros_log`, which is the runtime's and is set directly through
5507/// `nros_log::Logger::set_level` with no ABI involved.
5508///
5509/// Applies to EVERY registered backend, because an image may link more than one
5510/// (`nros_rmw_cffi_register_named`) and verbosity is a property of the process
5511/// rather than of a session. Upstream has no equivalent decision to make: it
5512/// loads one implementation.
5513///
5514/// Returns `Unsupported` when no registered backend implements the slot, the
5515/// first error any backend reported otherwise, and `Ok` when at least one
5516/// accepted it.
5517pub fn set_backend_log_severity(severity: nros_log::Severity) -> Result<(), TransportError> {
5518 const MAX: usize = 8;
5519 let mut names: [*const core::ffi::c_char; MAX] = [core::ptr::null(); MAX];
5520 // SAFETY: `names` is `MAX` entries and the callee writes at most that many.
5521 let n = unsafe { nros_rmw_cffi_registered_names(names.as_mut_ptr(), MAX) };
5522
5523 let wire = rmw_severity_of(severity);
5524 let mut applied = false;
5525 let mut first_err = None;
5526 for name in names.iter().take(n.min(MAX)) {
5527 // SAFETY: the registry hands back NUL-terminated static names.
5528 let vt = unsafe { nros_rmw_cffi_lookup(*name) };
5529 if vt.is_null() {
5530 continue;
5531 }
5532 // SAFETY: a non-null lookup yields a vtable valid for the image's life.
5533 let Some(f) = (unsafe { (*vt).set_log_severity }) else {
5534 continue;
5535 };
5536 // SAFETY: the slot takes a plain enum by value.
5537 let rc = unsafe { f(wire) };
5538 if rc == NROS_RMW_RET_OK {
5539 applied = true;
5540 } else if first_err.is_none() {
5541 first_err = Some(error_from_ret(rc));
5542 }
5543 }
5544
5545 match (applied, first_err) {
5546 (true, _) => Ok(()),
5547 (false, Some(e)) => Err(e),
5548 (false, None) => Err(TransportError::Unsupported),
5549 }
5550}
5551
5552// ============================================================================
5553// Phase 376 W4 — the two PURE functions, defined ONCE
5554// ============================================================================
5555//
5556// Declared in `nros/rmw_entity.h`, which explains at length why they are plain
5557// exported functions rather than vtable slots. The one-line version: a vtable
5558// slot is the mechanism for letting backends DIFFER, and these two must not.
5559// They compute over types the ABI defines, they take no entity, and they are
5560// wanted at create time before any backend has registered.
5561//
5562// Defined here for the same reason `nros_rmw_cffi_register_named` is: it keeps
5563// `nros-rmw-abi` a header-only INTERFACE target, with no compiled TU and no new
5564// link edge, and it keeps the implementation in ONE place — which is the whole
5565// point.
5566
5567/// Reason strings, one per clash bit. SELECTED, never formatted: upstream's
5568/// implementations `snprintf` their reason, which would pull the printf engine
5569/// into images that deliberately excluded it.
5570const CLASH_REASONS: &[(u32, &str)] = &[
5571 (
5572 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_RELIABILITY,
5573 "reliability: publisher is best-effort, subscription requires reliable; ",
5574 ),
5575 (
5576 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DURABILITY,
5577 "durability: publisher is volatile, subscription requires transient-local; ",
5578 ),
5579 (
5580 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DEADLINE,
5581 "deadline: publisher's period is longer than the subscription requires; ",
5582 ),
5583 (
5584 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_KIND,
5585 "liveliness: publisher's kind is weaker than the subscription requires; ",
5586 ),
5587 (
5588 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_LEASE,
5589 "liveliness lease: publisher's lease is longer than the subscription requires; ",
5590 ),
5591];
5592
5593/// `0` means "unset/no check", and `NROS_RMW_DURATION_INFINITE_MS` means
5594/// explicit infinity — so neither can be compared as a plain number. Map both to
5595/// "infinitely lax" for an OFFERED duration and "infinitely tolerant" for a
5596/// REQUESTED one, which is the same thing: `u64::MAX`.
5597fn duration_or_infinite(ms: u32) -> u64 {
5598 if ms == 0 || u64::from(ms) == generated::NROS_RMW_DURATION_INFINITE_MS as u64 {
5599 u64::MAX
5600 } else {
5601 u64::from(ms)
5602 }
5603}
5604
5605/// How strict a liveliness kind is. A publisher must assert at least as
5606/// strongly as the subscription asks.
5607fn liveliness_strength(kind: u8) -> u8 {
5608 match u32::from(kind) {
5609 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_MANUAL_BY_TOPIC => 3,
5610 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_MANUAL_BY_NODE => 2,
5611 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_AUTOMATIC => 1,
5612 _ => 0,
5613 }
5614}
5615
5616/// The DDS request-offered rules, in one place.
5617/// Does this profile leave any policy UNDETERMINED?
5618///
5619/// Phase 376 W5/B2. `*_UNKNOWN` means "the backend could not read this back",
5620/// which is an ABSENCE, not a value: comparing it as if it were one produces a
5621/// confident wrong verdict. Upstream's answer to that is
5622/// `RMW_QOS_COMPATIBILITY_WARNING` — "these look compatible, but I could not
5623/// check everything" — which was unreachable here until there was a sentinel
5624/// that could trigger it.
5625fn qos_has_unknown(q: &NrosRmwQos) -> bool {
5626 i32::from(q.reliability) == generated::NROS_RMW_RELIABILITY_UNKNOWN
5627 || i32::from(q.durability) == generated::NROS_RMW_DURABILITY_UNKNOWN
5628 || i32::from(q.history) == generated::NROS_RMW_HISTORY_UNKNOWN
5629 || u32::from(q.liveliness_kind)
5630 == generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_UNKNOWN
5631}
5632
5633fn qos_clash_mask(offered: &NrosRmwQos, requested: &NrosRmwQos) -> u32 {
5634 let mut mask = 0u32;
5635 if requested.reliability == generated::NROS_RMW_RELIABILITY_RELIABLE as u8
5636 && offered.reliability == generated::NROS_RMW_RELIABILITY_BEST_EFFORT as u8
5637 {
5638 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_RELIABILITY;
5639 }
5640 if requested.durability == generated::NROS_RMW_DURABILITY_TRANSIENT_LOCAL as u8
5641 && offered.durability == generated::NROS_RMW_DURABILITY_VOLATILE as u8
5642 {
5643 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DURABILITY;
5644 }
5645 // A publisher promising samples no more often than every N ms cannot
5646 // satisfy a subscription that demands one at least every M ms when N > M.
5647 if duration_or_infinite(offered.deadline_ms) > duration_or_infinite(requested.deadline_ms) {
5648 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DEADLINE;
5649 }
5650 if liveliness_strength(offered.liveliness_kind) < liveliness_strength(requested.liveliness_kind)
5651 {
5652 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_KIND;
5653 }
5654 if duration_or_infinite(offered.liveliness_lease_ms)
5655 > duration_or_infinite(requested.liveliness_lease_ms)
5656 {
5657 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_LEASE;
5658 }
5659 mask
5660}
5661
5662/// See `nros/rmw_entity.h`.
5663///
5664/// # Safety
5665/// `compatibility` and `clash_mask` must be valid for writes when non-NULL.
5666#[unsafe(no_mangle)]
5667pub unsafe extern "C" fn nros_rmw_qos_incompatibility_mask(
5668 offered: NrosRmwQos,
5669 requested: NrosRmwQos,
5670 compatibility: *mut generated::rmw_qos_compatibility_type_t::Type,
5671 clash_mask: *mut u32,
5672) -> NrosRmwRet {
5673 if compatibility.is_null() || clash_mask.is_null() {
5674 return NROS_RMW_RET_INVALID_ARGUMENT;
5675 }
5676 let mask = qos_clash_mask(&offered, &requested);
5677 let undetermined = qos_has_unknown(&offered) || qos_has_unknown(&requested);
5678 // SAFETY: both checked non-null above.
5679 unsafe {
5680 *clash_mask = mask;
5681 // A definite clash outranks an unknown: policies we COULD compare are
5682 // already incompatible, and reporting that as a warning would soften a
5683 // verdict the caller can act on.
5684 *compatibility = if mask != 0 {
5685 generated::rmw_qos_compatibility_type_t::RMW_QOS_COMPATIBILITY_ERROR
5686 } else if undetermined {
5687 generated::rmw_qos_compatibility_type_t::RMW_QOS_COMPATIBILITY_WARNING
5688 } else {
5689 generated::rmw_qos_compatibility_type_t::RMW_QOS_COMPATIBILITY_OK
5690 };
5691 }
5692 NROS_RMW_RET_OK
5693}
5694
5695/// See `nros/rmw_entity.h`.
5696///
5697/// # Safety
5698/// `compatibility` must be valid for writes; `reason` must be valid for
5699/// `reason_size` bytes when non-NULL.
5700#[unsafe(no_mangle)]
5701pub unsafe extern "C" fn rmw_qos_profile_check_compatible(
5702 publisher_profile: NrosRmwQos,
5703 subscription_profile: NrosRmwQos,
5704 compatibility: *mut generated::rmw_qos_compatibility_type_t::Type,
5705 reason: *mut core::ffi::c_char,
5706 reason_size: usize,
5707) -> NrosRmwRet {
5708 let mut mask = 0u32;
5709 // SAFETY: forwarding the caller's `compatibility` pointer, which the callee
5710 // null-checks; `mask` is a local.
5711 let rc = unsafe {
5712 nros_rmw_qos_incompatibility_mask(
5713 publisher_profile,
5714 subscription_profile,
5715 compatibility,
5716 &mut mask,
5717 )
5718 };
5719 if rc != NROS_RMW_RET_OK {
5720 return rc;
5721 }
5722 if reason.is_null() || reason_size == 0 {
5723 // The create-time path: the verdict is the whole answer.
5724 return NROS_RMW_RET_OK;
5725 }
5726 // Bounded copy, always NUL-terminated. Truncation is NOT an error: a
5727 // `BUFFER_TOO_SMALL` here would cost the caller the verdict, which is the
5728 // half that matters.
5729 let mut written = 0usize;
5730 for (bit, text) in CLASH_REASONS {
5731 if mask & bit == 0 {
5732 continue;
5733 }
5734 for byte in text.as_bytes() {
5735 if written + 1 >= reason_size {
5736 break;
5737 }
5738 // SAFETY: `written + 1 < reason_size`, so this and the NUL below are
5739 // both inside the caller's buffer.
5740 unsafe { *reason.add(written) = *byte as core::ffi::c_char };
5741 written += 1;
5742 }
5743 }
5744 // SAFETY: `written < reason_size` by the bound above.
5745 unsafe { *reason.add(written) = 0 };
5746 NROS_RMW_RET_OK
5747}
5748
5749/// See `nros/rmw_entity.h`.
5750///
5751/// # Safety
5752/// Both gids must be valid for reads and `result` valid for writes when
5753/// non-NULL.
5754#[unsafe(no_mangle)]
5755pub unsafe extern "C" fn rmw_compare_gids_equal(
5756 gid1: *const generated::rmw_gid_t,
5757 gid2: *const generated::rmw_gid_t,
5758 result: *mut bool,
5759) -> NrosRmwRet {
5760 if gid1.is_null() || gid2.is_null() || result.is_null() {
5761 return NROS_RMW_RET_INVALID_ARGUMENT;
5762 }
5763 // SAFETY: all three checked non-null above.
5764 let (a, b) = unsafe { (&*gid1, &*gid2) };
5765 // Identity first: gids from two backends are never equal, whatever their
5766 // bytes say. `register_named` admits several backends in one image, so this
5767 // is reachable here in a way it is not upstream.
5768 let same_impl = match (a.implementation_identifier, b.implementation_identifier) {
5769 (x, y) if x == y => true,
5770 (x, y) if x.is_null() || y.is_null() => false,
5771 // SAFETY: both non-null, and a backend's identifier is a static C string.
5772 (x, y) => unsafe { core::ffi::CStr::from_ptr(x) == core::ffi::CStr::from_ptr(y) },
5773 };
5774 // SAFETY: checked non-null above.
5775 unsafe { *result = same_impl && a.data == b.data };
5776 NROS_RMW_RET_OK
5777}
5778
5779#[cfg(test)]
5780#[allow(static_mut_refs)]
5781mod tests {
5782 use super::*;
5783 use nros_rmw::{Rmw, RmwConfig, Session, SessionMode, TopicInfo};
5784
5785 // Stub backend state. Statically allocated; the vtable's
5786 // `backend_data` round-trips a `&'static mut StubBackend`.
5787 static mut STUB_OPEN_CALLED: bool = false;
5788 static mut STUB_CREATE_PUB_CALLED: bool = false;
5789 static mut STUB_PUBLISH_CALLED: bool = false;
5790 static mut STUB_LAST_TOPIC_NAME: [u8; 64] = [0u8; 64];
5791 static mut STUB_LAST_TYPE_NAME: [u8; 64] = [0u8; 64];
5792 static mut STUB_LAST_QOS: NrosRmwQos = NrosRmwQos {
5793 reliability: 0,
5794 durability: 0,
5795 history: 0,
5796 liveliness_kind: 0,
5797 depth: 0,
5798 _reserved0: 0,
5799 deadline_ms: 0,
5800 lifespan_ms: 0,
5801 liveliness_lease_ms: 0,
5802 avoid_ros_namespace_conventions: 0,
5803 _reserved1: [0; 3],
5804 };
5805
5806 /// Read a null-terminated `*const u8` into the supplied byte
5807 /// buffer. Used by the stub backend to capture the topic / type
5808 /// names that the runtime hands it.
5809 unsafe fn copy_cstr(src: *const core::ffi::c_char, dst: &mut [u8]) {
5810 let src = src.cast::<u8>();
5811 let mut i = 0;
5812 while i < dst.len() {
5813 let b = unsafe { *src.add(i) };
5814 dst[i] = b;
5815 if b == 0 {
5816 break;
5817 }
5818 i += 1;
5819 }
5820 }
5821
5822 unsafe extern "C" fn stub_create_session(
5823 _locator: *const core::ffi::c_char,
5824 _mode: u8,
5825 _domain_id: u32,
5826 _node_name: *const core::ffi::c_char,
5827 _options: *const NrosRmwSessionOptions,
5828 out: *mut NrosRmwSession,
5829 ) -> NrosRmwRet {
5830 unsafe {
5831 STUB_OPEN_CALLED = true;
5832 (*out).backend_data = 0xDEAD_BEEFusize as *mut c_void;
5833 }
5834 NROS_RMW_RET_OK
5835 }
5836
5837 unsafe extern "C" fn stub_destroy_session(_session: *mut NrosRmwSession) -> NrosRmwRet {
5838 NROS_RMW_RET_OK
5839 }
5840
5841 unsafe extern "C" fn stub_drive_io(
5842 _session: *mut NrosRmwSession,
5843 _timeout_ms: i32,
5844 ) -> NrosRmwRet {
5845 NROS_RMW_RET_OK
5846 }
5847
5848 unsafe extern "C" fn stub_create_publisher(
5849 _session: *const NrosRmwNode,
5850 _type_support: *const generated::rmw_message_type_support_t,
5851 _topic_name: *const core::ffi::c_char,
5852 _domain_id: u32,
5853 qos: *const NrosRmwQos,
5854 _options: *const rmw_publisher_options_t,
5855 out: *mut NrosRmwPublisher,
5856 ) -> NrosRmwRet {
5857 // Capture the typed-struct fields the runtime supplied.
5858 unsafe {
5859 STUB_CREATE_PUB_CALLED = true;
5860 copy_cstr((*out).topic_name, &mut STUB_LAST_TOPIC_NAME);
5861 copy_cstr((*out).type_name, &mut STUB_LAST_TYPE_NAME);
5862 STUB_LAST_QOS = *qos;
5863 (*out).backend_data = 0xCAFEusize as *mut c_void;
5864 (*out).can_loan_messages = true;
5865 }
5866 NROS_RMW_RET_OK
5867 }
5868
5869 unsafe extern "C" fn stub_destroy_publisher(_publisher: *mut NrosRmwPublisher) -> NrosRmwRet {
5870 NROS_RMW_RET_OK
5871 }
5872
5873 unsafe extern "C" fn stub_publish_raw(
5874 publisher: *const NrosRmwPublisher,
5875 _payload: generated::rmw_byte_span_t,
5876 ) -> NrosRmwRet {
5877 // Verify the runtime is still passing the same backend_data
5878 // and topic_name on every call.
5879 unsafe {
5880 STUB_PUBLISH_CALLED = true;
5881 assert_eq!((*publisher).backend_data as usize, 0xCAFE);
5882 let mut buf = [0u8; 64];
5883 copy_cstr((*publisher).topic_name, &mut buf);
5884 assert_eq!(&buf[..], &STUB_LAST_TOPIC_NAME);
5885 }
5886 NROS_RMW_RET_OK
5887 }
5888
5889 unsafe extern "C" fn stub_create_subscription(
5890 _: *const NrosRmwNode,
5891 _: *const generated::rmw_message_type_support_t,
5892 _: *const core::ffi::c_char,
5893 _: u32,
5894 _: *const NrosRmwQos,
5895 _: *const rmw_subscription_options_t,
5896 out: *mut NrosRmwSubscription,
5897 ) -> NrosRmwRet {
5898 unsafe {
5899 (*out).backend_data = core::ptr::dangling_mut::<c_void>();
5900 }
5901 NROS_RMW_RET_OK
5902 }
5903 unsafe extern "C" fn stub_destroy_subscription(_: *mut NrosRmwSubscription) -> NrosRmwRet {
5904 NROS_RMW_RET_OK
5905 }
5906 unsafe extern "C" fn stub_take(
5907 _: *const NrosRmwSubscription,
5908 _out: *mut generated::rmw_mut_byte_span_t,
5909 taken: *mut bool,
5910 ) -> NrosRmwRet {
5911 // Phase 376 W3.d step A — the stub takes nothing, which is now stated
5912 // rather than encoded as a zero byte count.
5913 unsafe { *taken = false };
5914 NROS_RMW_RET_OK
5915 }
5916 unsafe extern "C" fn stub_has_data(
5917 _: *mut NrosRmwSubscription,
5918 out_has_data: *mut bool,
5919 ) -> NrosRmwRet {
5920 // Phase 376 W3.d step A — flag out, status returned.
5921 unsafe { *out_has_data = false };
5922 NROS_RMW_RET_OK
5923 }
5924
5925 unsafe extern "C" fn stub_create_service(
5926 _: *const NrosRmwNode,
5927 _: *const generated::rmw_service_type_support_t,
5928 _: *const core::ffi::c_char,
5929 _: u32,
5930 _: *const NrosRmwQos,
5931 out: *mut NrosRmwService,
5932 ) -> NrosRmwRet {
5933 unsafe {
5934 (*out).backend_data = core::ptr::dangling_mut::<c_void>();
5935 }
5936 NROS_RMW_RET_OK
5937 }
5938 unsafe extern "C" fn stub_destroy_service(_: *mut NrosRmwService) -> NrosRmwRet {
5939 NROS_RMW_RET_OK
5940 }
5941 unsafe extern "C" fn stub_take_request(
5942 _: *const NrosRmwService,
5943 _: *mut crate::generated::rmw_mut_byte_span_t,
5944 _: *mut i64,
5945 taken: *mut bool,
5946 ) -> NrosRmwRet {
5947 // Phase 376 W3.d step A — NO_DATA retires: nothing to take is
5948 // `taken = false` with OK.
5949 unsafe { *taken = false };
5950 NROS_RMW_RET_OK
5951 }
5952 unsafe extern "C" fn stub_has_request(
5953 _: *mut NrosRmwService,
5954 out_has_request: *mut bool,
5955 ) -> NrosRmwRet {
5956 // Phase 376 W3.d step A — flag out, status returned.
5957 unsafe { *out_has_request = false };
5958 NROS_RMW_RET_OK
5959 }
5960 unsafe extern "C" fn stub_send_response(
5961 _: *const NrosRmwService,
5962 _: i64,
5963 _: crate::generated::rmw_byte_span_t,
5964 ) -> NrosRmwRet {
5965 NROS_RMW_RET_OK
5966 }
5967
5968 unsafe extern "C" fn stub_create_client(
5969 _: *const NrosRmwNode,
5970 _: *const generated::rmw_service_type_support_t,
5971 _: *const core::ffi::c_char,
5972 _: u32,
5973 _: *const NrosRmwQos,
5974 out: *mut NrosRmwClient,
5975 ) -> NrosRmwRet {
5976 unsafe {
5977 (*out).backend_data = core::ptr::dangling_mut::<c_void>();
5978 }
5979 NROS_RMW_RET_OK
5980 }
5981 unsafe extern "C" fn stub_destroy_client(_: *mut NrosRmwClient) -> NrosRmwRet {
5982 NROS_RMW_RET_OK
5983 }
5984 unsafe extern "C" fn stub_register_subscription_event(
5985 _: *const NrosRmwSubscription,
5986 _: NrosRmwEventKind,
5987 _: u32,
5988 _: NrosRmwEventCallback,
5989 _: *mut c_void,
5990 ) -> NrosRmwRet {
5991 NROS_RMW_RET_UNSUPPORTED
5992 }
5993 unsafe extern "C" fn stub_register_publisher_event(
5994 _: *const NrosRmwPublisher,
5995 _: NrosRmwEventKind,
5996 _: u32,
5997 _: NrosRmwEventCallback,
5998 _: *mut c_void,
5999 ) -> NrosRmwRet {
6000 NROS_RMW_RET_UNSUPPORTED
6001 }
6002 unsafe extern "C" fn stub_assert_publisher_liveliness(
6003 _: *const NrosRmwPublisher,
6004 ) -> NrosRmwRet {
6005 NROS_RMW_RET_UNSUPPORTED
6006 }
6007
6008 static STUB_VTABLE: NrosRmwVtable = NrosRmwVtable {
6009 create_session: Some(stub_create_session),
6010 destroy_session: Some(stub_destroy_session),
6011 drive_io: Some(stub_drive_io),
6012 create_publisher: Some(stub_create_publisher),
6013 destroy_publisher: Some(stub_destroy_publisher),
6014 publish: Some(stub_publish_raw),
6015 create_subscription: Some(stub_create_subscription),
6016 destroy_subscription: Some(stub_destroy_subscription),
6017 take: Some(stub_take),
6018 has_data: Some(stub_has_data),
6019 create_service: Some(stub_create_service),
6020 destroy_service: Some(stub_destroy_service),
6021 take_request: Some(stub_take_request),
6022 has_request: Some(stub_has_request),
6023 send_response: Some(stub_send_response),
6024 create_client: Some(stub_create_client),
6025 destroy_client: Some(stub_destroy_client),
6026 subscription_event_init: Some(stub_register_subscription_event),
6027 publisher_event_init: Some(stub_register_publisher_event),
6028 publisher_assert_liveliness: Some(stub_assert_publisher_liveliness),
6029 ..EMPTY_VTABLE
6030 };
6031
6032 // Phase-301 (issue 0241) — boundary semantics of the QoS lowering.
6033
6034 #[test]
6035 fn qos_depth_at_u16_max_lowers() {
6036 let qos = nros_rmw::QoSProfile::default().keep_last(u16::MAX as u32);
6037 let lowered = NrosRmwQos::try_from(qos).expect("depth 65535 must lower");
6038 assert_eq!(lowered.depth, u16::MAX);
6039 }
6040
6041 #[test]
6042 fn qos_depth_past_u16_max_is_create_time_error() {
6043 let qos = nros_rmw::QoSProfile::default().keep_last(u16::MAX as u32 + 1);
6044 assert_eq!(
6045 NrosRmwQos::try_from(qos),
6046 Err(TransportError::InvalidArgument)
6047 );
6048 }
6049
6050 /// issue 0829 — the POLICY sentinel lowers rather than resolving, and the
6051 /// hand-mirrored C constant agrees with what the Rust profile lowers to.
6052 ///
6053 /// Two things at once, both hand-mirror class (issues 0088/0160/0245):
6054 /// `NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT` here is a second copy of the macro
6055 /// in `nros/rmw_entity.h`, and the lowering is a third statement of the same
6056 /// mapping. All three must say "every field is zero", because a C backend
6057 /// resolving the sentinel can only do so if it actually receives one.
6058 #[test]
6059 fn system_default_lowers_to_all_sentinel_and_matches_the_c_mirror() {
6060 let lowered = NrosRmwQos::try_from(nros_rmw::QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT)
6061 .expect("the sentinel profile must lower");
6062
6063 assert_eq!(
6064 lowered.reliability as i32,
6065 generated::NROS_RMW_RELIABILITY_SYSTEM_DEFAULT
6066 );
6067 assert_eq!(
6068 lowered.durability as i32,
6069 generated::NROS_RMW_DURABILITY_SYSTEM_DEFAULT
6070 );
6071 assert_eq!(
6072 lowered.history as i32,
6073 generated::NROS_RMW_HISTORY_SYSTEM_DEFAULT
6074 );
6075 assert_eq!(lowered.depth, 0, "depth sentinel must lower as 0");
6076 assert_eq!(
6077 lowered.liveliness_kind as u32,
6078 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_SYSTEM_DEFAULT
6079 );
6080
6081 // The hand-mirrored C constant says the same thing.
6082 assert_eq!(
6083 lowered, NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT,
6084 "the C mirror drifted from what the Rust profile lowers to"
6085 );
6086 // And it is NOT `_DEFAULT` — the aliasing was half the 0829 defect.
6087 assert_ne!(
6088 NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT, NROS_RMW_QOS_PROFILE_DEFAULT,
6089 "SYSTEM_DEFAULT aliased DEFAULT again — see issue 0829"
6090 );
6091 }
6092
6093 #[test]
6094 fn qos_infinite_sentinel_passes_through_and_reads_as_unset() {
6095 use nros_rmw::{DURATION_INFINITE_MS, QoSPolicyMask};
6096 let qos = nros_rmw::QoSProfile {
6097 deadline_ms: DURATION_INFINITE_MS,
6098 lifespan_ms: DURATION_INFINITE_MS,
6099 liveliness_lease_ms: DURATION_INFINITE_MS,
6100 ..Default::default()
6101 };
6102 // Sentinel behaves like 0 at the check sites: no extra policy demanded.
6103 let required = qos.required_policies();
6104 assert!(!required.contains(QoSPolicyMask::DEADLINE));
6105 assert!(!required.contains(QoSPolicyMask::LIFESPAN));
6106 assert!(!required.contains(QoSPolicyMask::LIVELINESS_LEASE));
6107 // And lowers unchanged — the C side sees the explicit spelling.
6108 let lowered = NrosRmwQos::try_from(qos).expect("sentinel must lower");
6109 assert_eq!(lowered.deadline_ms, DURATION_INFINITE_MS);
6110 assert_eq!(lowered.lifespan_ms, DURATION_INFINITE_MS);
6111 assert_eq!(lowered.liveliness_lease_ms, DURATION_INFINITE_MS);
6112 }
6113
6114 #[test]
6115 fn duration_lowering_boundaries() {
6116 use core::time::Duration;
6117 use nros_rmw::{DURATION_INFINITE_MS, duration_to_qos_ms};
6118 // 0 keeps its unset/no-check meaning.
6119 assert_eq!(duration_to_qos_ms(Duration::ZERO), Ok(0));
6120 // Sub-ms CEILs to 1 ms — never floors to "no deadline".
6121 assert_eq!(duration_to_qos_ms(Duration::from_nanos(1)), Ok(1));
6122 assert_eq!(duration_to_qos_ms(Duration::from_micros(999)), Ok(1));
6123 assert_eq!(duration_to_qos_ms(Duration::from_millis(1)), Ok(1));
6124 assert_eq!(duration_to_qos_ms(Duration::from_micros(1001)), Ok(2));
6125 // Largest representable finite value.
6126 assert_eq!(
6127 duration_to_qos_ms(Duration::from_millis(DURATION_INFINITE_MS as u64 - 1)),
6128 Ok(DURATION_INFINITE_MS - 1)
6129 );
6130 // At / past the sentinel: create-time error, never a clamp (infinite
6131 // is spelled via the sentinel or 0, not a huge finite duration).
6132 assert_eq!(
6133 duration_to_qos_ms(Duration::from_millis(DURATION_INFINITE_MS as u64)),
6134 Err(TransportError::InvalidArgument)
6135 );
6136 assert_eq!(
6137 duration_to_qos_ms(Duration::from_secs(u64::MAX / 1_000)),
6138 Err(TransportError::InvalidArgument)
6139 );
6140 }
6141
6142 #[test]
6143 fn service_server_no_data_maps_to_none() {
6144 use nros_rmw::ServiceTrait as _;
6145
6146 let mut server = CffiService {
6147 vtable: &STUB_VTABLE,
6148 service_name_buf: [0u8; NAME_BUF_LEN],
6149 type_name_buf: [0u8; NAME_BUF_LEN],
6150 backend_data: core::ptr::dangling_mut::<c_void>(),
6151 request_qos: qos_unknown_c(),
6152 response_qos: qos_unknown_c(),
6153 refused_len: None,
6154 };
6155 let mut buf = [0u8; 16];
6156
6157 assert!(server.take_request(&mut buf).unwrap().is_none());
6158 }
6159
6160 #[test]
6161 fn typed_struct_roundtrip() {
6162 // Register the stub vtable under its canonical name.
6163 let ret = unsafe { nros_rmw_cffi_register_named(c"default".as_ptr(), &STUB_VTABLE) };
6164 assert_eq!(ret, NROS_RMW_RET_OK);
6165
6166 // Open a session.
6167 let cfg = RmwConfig {
6168 mode: SessionMode::Client,
6169 locator: "tcp/127.0.0.1:7447",
6170 domain_id: 0,
6171 node_name: "test_node",
6172 namespace: "",
6173 properties: &[],
6174 };
6175 let mut session = Rmw::open(CffiRmw, &cfg).expect("session open");
6176 assert!(unsafe { STUB_OPEN_CALLED });
6177 assert_eq!(session.node_name(), "test_node");
6178
6179 // Create a publisher; verify backend received the typed
6180 // struct with topic_name + qos populated.
6181 let topic = TopicInfo::new("/chatter", "std_msgs/msg/Int32", "RIHS01_abc");
6182 let qos = nros_rmw::QoSProfile::default();
6183 let publisher = session
6184 .create_publisher(&topic, qos)
6185 .expect("publisher create");
6186 assert!(unsafe { STUB_CREATE_PUB_CALLED });
6187 let topic_buf = unsafe { &STUB_LAST_TOPIC_NAME };
6188 assert_eq!(
6189 core::str::from_utf8(topic_buf)
6190 .unwrap_or("")
6191 .trim_end_matches('\0'),
6192 "/chatter"
6193 );
6194 let type_buf = unsafe { &STUB_LAST_TYPE_NAME };
6195 assert_eq!(
6196 core::str::from_utf8(type_buf)
6197 .unwrap_or("")
6198 .trim_end_matches('\0'),
6199 "std_msgs/msg/Int32"
6200 );
6201
6202 // Rust accessors read back the typed-struct fields.
6203 assert_eq!(publisher.topic_name(), "/chatter");
6204 assert_eq!(publisher.type_name(), "std_msgs/msg/Int32");
6205 // issue 0814 — `can_loan_messages` is DERIVED, so a backend cannot
6206 // over-claim it. `stub_create_publisher` writes `true` into the view
6207 // while `STUB_VTABLE` inherits `borrow_loaned_message: None` from
6208 // `EMPTY_VTABLE`; a backend that cannot be asked for a loan does not
6209 // advertise loans, whatever it wrote. This assertion read `true`
6210 // before the fix — it was the over-claim, believed.
6211 assert!(
6212 !publisher.can_loan_messages(),
6213 "the stub declared can_loan_messages=true with a NULL \
6214 borrow_loaned_message slot; the declaration must not win"
6215 );
6216
6217 // Publish — verify backend_data round-trips correctly via
6218 // the typed view.
6219 use nros_rmw::Publisher as _;
6220 publisher.publish_raw(&[1u8, 2, 3]).expect("publish");
6221 assert!(unsafe { STUB_PUBLISH_CALLED });
6222 }
6223
6224 // Issue 0332 — an incomplete vtable must be rejected at registration, not
6225 // panic mid-spin. (The stub tests above register a COMPLETE vtable, so they
6226 // are the "complete → accepted" guard: if the required-slot list ever
6227 // over-rejected, those tests would fail at registration.)
6228 #[test]
6229 fn register_rejects_incomplete_vtable() {
6230 // SAFETY: `NrosRmwVtable` is a plain struct of `Option<extern fn>` +
6231 // POD fields; an all-zero bit pattern is every slot `None` (null-ptr
6232 // niche) — a valid, empty vtable.
6233 let empty: NrosRmwVtable = unsafe { core::mem::zeroed() };
6234 assert_eq!(first_missing_vtable_slot(&empty), Some("create_session"));
6235
6236 let rc = unsafe { nros_rmw_cffi_register_named(c"incomplete_0332".as_ptr(), &empty) };
6237 assert_eq!(rc, NROS_RMW_RET_INVALID_ARGUMENT);
6238 }
6239
6240 // Issue 0349 — the other direction. The 0332 list used to include three
6241 // OPTIONAL capability slots, which refused the xrce backend outright (its
6242 // vtable NULLs all three deliberately). A backend that can publish,
6243 // subscribe, serve and call is a working backend.
6244 //
6245 // This test is the pair to `register_rejects_incomplete_vtable` above: one
6246 // asserts the gate still bites, this asserts it does not over-bite. Keep
6247 // both — dropping either turns the gate into a one-way ratchet.
6248 /// phase-381 W6 — a NULL graph slot must say UNSUPPORTED, not "empty".
6249 ///
6250 /// The distinction is the whole of W6. XRCE has no graph at all: its C
6251 /// vtable is a designated initializer, so every graph slot is NULL by
6252 /// omission. If a NULL slot produced `Ok(())` with no visits, a caller
6253 /// could not tell "this backend cannot answer" from "the graph is empty",
6254 /// and would conclude a peer is absent when it simply cannot be seen.
6255 ///
6256 /// This also guards the reverse defect, which nearly shipped: `CffiSession`
6257 /// had NO `get_node_names` at all, so it fell through to the trait default
6258 /// and returned `Unsupported` even for a backend whose slot WAS wired —
6259 /// cyclone's, once W5 filled it. The slot would have looked implemented
6260 /// while being dead code, which is issue 0800's overstatement one layer up.
6261 #[test]
6262 fn a_null_graph_slot_reports_unsupported_not_an_empty_graph() {
6263 use nros_rmw::{Session, TransportError};
6264
6265 // A vtable with every required slot and NO graph slots — the shape a C
6266 // backend gets from `.field = ...` designated init.
6267 let mut session = CffiSession {
6268 vtable: &STUB_VTABLE,
6269 node_name_buf: [0u8; NAME_BUF_LEN],
6270 namespace_buf: [0u8; NAME_BUF_LEN],
6271 backend_data: core::ptr::dangling_mut::<c_void>(),
6272 domain_id: 0,
6273 // Not exercised here: the QoS mask decides ADMISSION, which the
6274 // runtime layer does, and these tests script the vtable directly.
6275 qos_policies: nros_rmw::QoSPolicyMask::NONE,
6276 };
6277 let mut seen = 0usize;
6278 let mut visit = |_n: &str, _ns: &str, _e: Option<&str>| {
6279 seen += 1;
6280 true
6281 };
6282 let r = Session::get_node_names(&mut session, &mut visit);
6283 assert!(
6284 matches!(r, Err(TransportError::Unsupported)),
6285 "a NULL slot must report Unsupported, got {r:?}"
6286 );
6287 assert_eq!(seen, 0, "nothing may be visited when the slot is absent");
6288 }
6289
6290 #[test]
6291 fn register_accepts_vtable_without_optional_capability_slots() {
6292 let mut vt = STUB_VTABLE;
6293 vt.publisher_event_init = None;
6294 vt.subscription_event_init = None;
6295 vt.publisher_assert_liveliness = None;
6296
6297 assert_eq!(
6298 first_missing_vtable_slot(&vt),
6299 None,
6300 "QoS-event and liveliness slots are capabilities, not core transport"
6301 );
6302
6303 // Deliberately NOT calling `nros_rmw_cffi_register_named` here. The
6304 // registry is a process global with no removal, so a successful
6305 // registration leaks a second backend into every other test in this
6306 // binary and turns single-backend resolution into `Ambiguous`
6307 // (`typed_struct_roundtrip` goes red). `first_missing_vtable_slot` is
6308 // the pure function that decides acceptance, so asserting on it tests
6309 // the same decision without the shared state. The end-to-end
6310 // "this really does register now" proof is
6311 // `nros-rmw-xrce-cffi`'s `register_smoke`, which is exactly the
6312 // backend this over-strict list was refusing.
6313 }
6314
6315 // ------------------------------------------------------------------
6316 // issue 1327 — the four service/client `*_get_actual_qos` slots.
6317 //
6318 // Cyclonedds filled all four and NOTHING dispatched them, so the fix for
6319 // the bug issue 0823 describes sat unreached in the tree. These tests are
6320 // about the CONSUMER: that a `create_service` / `create_client` reaches
6321 // each direction's slot, that it hands the slot `UNKNOWN` rather than the
6322 // request or a zeroed struct, and — issue 1437 — that it KEEPS the answer,
6323 // because a read-back that is logged and dropped cannot be asked.
6324 //
6325 // The pre-load was the REQUEST until issue 1437, which inverted the slot's
6326 // own contract: `rmw_vtable.h` says a backend writes `*_UNKNOWN` for a
6327 // policy it cannot determine, so a field nobody overwrote read back as
6328 // GRANTED and the one consumer saw agreement exactly where the backend had
6329 // said nothing.
6330 //
6331 // They refuse to pass vacuously the same way 0823's ctest does: the
6332 // scripted backend writes values the requested profile cannot have, so an
6333 // implementation that echoes its input and one that zeroes it both fail.
6334 // ------------------------------------------------------------------
6335
6336 /// Four counters, indexed [service-request, service-response,
6337 /// client-request, client-response].
6338 #[cfg(feature = "alloc")]
6339 static mut QOS_READBACK_CALLS: [u32; 4] = [0; 4];
6340 /// What each slot was HANDED — the pre-loaded out-struct.
6341 #[cfg(feature = "alloc")]
6342 static mut QOS_READBACK_SAW: [Option<NrosRmwQos>; 4] = [None; 4];
6343
6344 /// A depth no request in these tests states, so reading it back proves the
6345 /// value came from the entity.
6346 #[cfg(feature = "alloc")]
6347 const GRANTED_DEPTH: u16 = 3;
6348
6349 #[cfg(feature = "alloc")]
6350 unsafe fn record_and_grant(idx: usize, qos: *mut NrosRmwQos) -> NrosRmwRet {
6351 unsafe {
6352 QOS_READBACK_CALLS[idx] += 1;
6353 QOS_READBACK_SAW[idx] = Some(*qos);
6354 // Grant something the caller did not ask for, on the two fields
6355 // `report_qos_downgrade` reads.
6356 (*qos).reliability = NROS_RMW_RELIABILITY_BEST_EFFORT as u8;
6357 (*qos).depth = GRANTED_DEPTH;
6358 }
6359 NROS_RMW_RET_OK
6360 }
6361
6362 #[cfg(feature = "alloc")]
6363 unsafe extern "C" fn stub_service_request_qos(
6364 _: *const NrosRmwService,
6365 qos: *mut NrosRmwQos,
6366 ) -> NrosRmwRet {
6367 unsafe { record_and_grant(0, qos) }
6368 }
6369 #[cfg(feature = "alloc")]
6370 unsafe extern "C" fn stub_service_response_qos(
6371 _: *const NrosRmwService,
6372 qos: *mut NrosRmwQos,
6373 ) -> NrosRmwRet {
6374 unsafe { record_and_grant(1, qos) }
6375 }
6376 #[cfg(feature = "alloc")]
6377 unsafe extern "C" fn stub_client_request_qos(
6378 _: *const NrosRmwClient,
6379 qos: *mut NrosRmwQos,
6380 ) -> NrosRmwRet {
6381 unsafe { record_and_grant(2, qos) }
6382 }
6383 #[cfg(feature = "alloc")]
6384 unsafe extern "C" fn stub_client_response_qos(
6385 _: *const NrosRmwClient,
6386 qos: *mut NrosRmwQos,
6387 ) -> NrosRmwRet {
6388 unsafe { record_and_grant(3, qos) }
6389 }
6390
6391 /// The two create slots plus all four read-backs, and nothing else — the
6392 /// shape a backend gets from designated init. `..EMPTY_VTABLE` keeps this
6393 /// from costing a line per slot the next time the struct grows.
6394 #[cfg(feature = "alloc")]
6395 static READBACK_VTABLE: NrosRmwVtable = NrosRmwVtable {
6396 destroy_session: Some(stub_destroy_session),
6397 create_service: Some(stub_create_service),
6398 destroy_service: Some(stub_destroy_service),
6399 create_client: Some(stub_create_client),
6400 destroy_client: Some(stub_destroy_client),
6401 service_request_subscription_get_actual_qos: Some(stub_service_request_qos),
6402 service_response_publisher_get_actual_qos: Some(stub_service_response_qos),
6403 client_request_publisher_get_actual_qos: Some(stub_client_request_qos),
6404 client_response_subscription_get_actual_qos: Some(stub_client_response_qos),
6405 ..EMPTY_VTABLE
6406 };
6407
6408 #[cfg(feature = "alloc")]
6409 #[test]
6410 fn service_and_client_create_read_the_granted_qos_back() {
6411 use nros_rmw::{ServiceInfo, Session};
6412
6413 let mut session = CffiSession {
6414 vtable: &READBACK_VTABLE,
6415 node_name_buf: [0u8; NAME_BUF_LEN],
6416 namespace_buf: [0u8; NAME_BUF_LEN],
6417 backend_data: core::ptr::dangling_mut::<c_void>(),
6418 domain_id: 0,
6419 // Not exercised here: the QoS mask decides ADMISSION, which the
6420 // runtime layer does, and these tests script the vtable directly.
6421 qos_policies: nros_rmw::QoSPolicyMask::NONE,
6422 };
6423
6424 unsafe {
6425 QOS_READBACK_CALLS = [0; 4];
6426 QOS_READBACK_SAW = [None; 4];
6427 }
6428
6429 // A request the scripted grant differs from on both reported fields.
6430 let requested = nros_rmw::QoSProfile::default().reliable().keep_last(10);
6431 let lowered = NrosRmwQos::try_from(requested).expect("profile lowers");
6432 assert_ne!(lowered.depth, GRANTED_DEPTH, "the test's own premise");
6433
6434 let info = ServiceInfo::new(
6435 "/add_two_ints",
6436 "example_interfaces/srv/AddTwoInts",
6437 "RIHS01",
6438 );
6439 let _srv = Session::create_service(&mut session, &info, requested).expect("service");
6440 let _cli = Session::create_client(&mut session, &info, requested).expect("client");
6441
6442 // 1327's acceptance: the four stop being inert.
6443 assert_eq!(
6444 unsafe { QOS_READBACK_CALLS },
6445 [1, 1, 1, 1],
6446 "every service/client direction's read-back must be dispatched once"
6447 );
6448
6449 // …and each was handed UNKNOWN, not the request and not a zeroed
6450 // struct. Both of the wrong pre-loads are a claim the backend did not
6451 // make: the request says "granted what you asked" for a policy nobody
6452 // read, and zero says "granted BEST_EFFORT / KEEP_LAST(0)".
6453 let unknown = qos_unknown_c();
6454 for (i, saw) in unsafe { QOS_READBACK_SAW }.iter().enumerate() {
6455 let saw = saw.expect("slot recorded nothing");
6456 assert_eq!(
6457 saw.reliability, unknown.reliability,
6458 "slot {i} was handed reliability {} rather than UNKNOWN {}",
6459 saw.reliability, unknown.reliability
6460 );
6461 assert_eq!(saw.durability, unknown.durability, "slot {i} durability");
6462 assert_eq!(saw.history, unknown.history, "slot {i} history");
6463 assert_ne!(
6464 saw.reliability, lowered.reliability,
6465 "slot {i} must not be handed the REQUEST — that is the inverted \
6466 contract issue 1437 fixed"
6467 );
6468 }
6469
6470 // issue 1437 — the answer is RETAINED, per direction. This is what an
6471 // accessor can be built on; before it, every one of these four reads
6472 // went into a log line and was dropped.
6473 use nros_rmw::{ClientTrait, ServiceTrait};
6474 assert_eq!(
6475 ServiceTrait::request_subscription_actual_qos(&_srv).depth,
6476 u32::from(GRANTED_DEPTH),
6477 "the service's request direction must report the GRANT"
6478 );
6479 assert_eq!(
6480 ServiceTrait::response_publisher_actual_qos(&_srv).reliability,
6481 nros_rmw::QoSReliabilityPolicy::BestEffort,
6482 "the service's response direction must report the GRANT"
6483 );
6484 assert_eq!(
6485 ClientTrait::request_publisher_actual_qos(&_cli).depth,
6486 u32::from(GRANTED_DEPTH),
6487 "the client's request direction must report the GRANT"
6488 );
6489 assert_ne!(
6490 ClientTrait::response_subscription_actual_qos(&_cli).depth,
6491 requested.depth,
6492 "reporting the request back is the bug, not the feature"
6493 );
6494 }
6495
6496 /// The helper the six sites share: it returns what the BACKEND answered.
6497 /// An implementation that echoed its input would return the request, and a
6498 /// zeroing one would return zeros; only a real read returns the grant.
6499 #[cfg(feature = "alloc")]
6500 #[test]
6501 fn the_read_back_helper_returns_the_grant_not_the_request() {
6502 unsafe {
6503 QOS_READBACK_CALLS = [0; 4];
6504 QOS_READBACK_SAW = [None; 4];
6505 }
6506 let requested =
6507 NrosRmwQos::try_from(nros_rmw::QoSProfile::default().reliable().keep_last(10))
6508 .expect("profile lowers");
6509 let entity = NrosRmwClient {
6510 service_name: core::ptr::null(),
6511 type_name: core::ptr::null(),
6512 _reserved: [0u8; 8],
6513 backend_data: core::ptr::null_mut(),
6514 };
6515 // SAFETY: the scripted slot reads and writes only `qos`.
6516 let granted = unsafe {
6517 granted_qos_of(
6518 "client request publisher",
6519 "/add_two_ints",
6520 &requested,
6521 &entity,
6522 Some(stub_client_request_qos),
6523 )
6524 };
6525 assert_eq!(granted.depth, GRANTED_DEPTH);
6526 assert_eq!(
6527 granted.reliability, NROS_RMW_RELIABILITY_BEST_EFFORT as u8,
6528 "the grant, not the request"
6529 );
6530 assert_ne!(granted.depth, requested.depth);
6531 }
6532
6533 /// A backend with NO read-back slot answers an ABSENCE, never the request.
6534 ///
6535 /// This is the arm that decides what XRCE and zenoh-pico report, and the
6536 /// one where returning the request would be most tempting and most wrong:
6537 /// there is no negotiation to read, so "you got what you asked for" is a
6538 /// claim nobody made.
6539 #[cfg(feature = "alloc")]
6540 #[test]
6541 fn a_null_read_back_slot_answers_unknown_not_the_request() {
6542 let requested =
6543 NrosRmwQos::try_from(nros_rmw::QoSProfile::default().reliable().keep_last(10))
6544 .expect("profile lowers");
6545 let entity = NrosRmwClient {
6546 service_name: core::ptr::null(),
6547 type_name: core::ptr::null(),
6548 _reserved: [0u8; 8],
6549 backend_data: core::ptr::null_mut(),
6550 };
6551 // SAFETY: the slot is NULL, so nothing dereferences `entity`.
6552 let granted = unsafe {
6553 granted_qos_of::<NrosRmwClient>(
6554 "client request publisher",
6555 "/add_two_ints",
6556 &requested,
6557 &entity,
6558 None,
6559 )
6560 };
6561 assert_eq!(granted.reliability, qos_unknown_c().reliability);
6562 assert_ne!(
6563 granted.reliability, requested.reliability,
6564 "a missing read-back must not be reported as a grant of the request"
6565 );
6566 assert!(
6567 qos_from_c(&granted).has_unknown(),
6568 "and it must read back as an absence in the Rust vocabulary too"
6569 );
6570 }
6571
6572 // ------------------------------------------------------------------
6573 // issue 1329 — the route answers the REGISTERED backend, not a union.
6574 // ------------------------------------------------------------------
6575
6576 unsafe extern "C" fn stub_supported_qos_policies(
6577 _: *const NrosRmwSession,
6578 out_mask: *mut u32,
6579 ) -> NrosRmwRet {
6580 // A mask no union in the tree ever produced, so passing this cannot be
6581 // an accident of the old constant still being returned.
6582 unsafe {
6583 *out_mask = (NROS_RMW_QOS_POLICY_RELIABILITY | NROS_RMW_QOS_POLICY_LIFESPAN) as u32
6584 };
6585 NROS_RMW_RET_OK
6586 }
6587
6588 /// Only what `open_with_vtable` and `Drop` reach, plus the slot under
6589 /// test — the shape a C backend gets from designated init.
6590 static QOS_ROUTE_VTABLE: NrosRmwVtable = NrosRmwVtable {
6591 create_session: Some(stub_create_session),
6592 destroy_session: Some(stub_destroy_session),
6593 supported_qos_policies: Some(stub_supported_qos_policies),
6594 ..EMPTY_VTABLE
6595 };
6596
6597 /// The same, with the slot left NULL.
6598 static QOS_NO_SLOT_VTABLE: NrosRmwVtable = NrosRmwVtable {
6599 create_session: Some(stub_create_session),
6600 destroy_session: Some(stub_destroy_session),
6601 ..EMPTY_VTABLE
6602 };
6603
6604 #[test]
6605 fn the_route_returns_the_registered_backends_mask() {
6606 use nros_rmw::{QoSPolicyMask, Session};
6607
6608 let session =
6609 CffiSession::open_with_vtable(&QOS_ROUTE_VTABLE, "", 0, 0, "n", "", core::ptr::null())
6610 .expect("session open");
6611 assert_eq!(
6612 Session::supported_qos_policies(&session),
6613 QoSPolicyMask::RELIABILITY | QoSPolicyMask::LIFESPAN,
6614 "the route must report what the backend said, not a union over every backend"
6615 );
6616 }
6617
6618 /// A NULL slot is the backend declining to say, and the ABI reads that as
6619 /// honouring nothing. Asserted rather than assumed, because the two
6620 /// tempting alternatives — the union, or "skip validation" — are both the
6621 /// bug, and only a test distinguishes this answer from them.
6622 #[test]
6623 fn a_backend_that_fills_no_mask_slot_honours_nothing() {
6624 use nros_rmw::{QoSPolicyMask, Session};
6625
6626 let session = CffiSession::open_with_vtable(
6627 &QOS_NO_SLOT_VTABLE,
6628 "",
6629 0,
6630 0,
6631 "n",
6632 "",
6633 core::ptr::null(),
6634 )
6635 .expect("session open");
6636 assert_eq!(
6637 Session::supported_qos_policies(&session),
6638 QoSPolicyMask::NONE,
6639 "a NULL slot is `has not said`, which is not `honours everything`"
6640 );
6641 }
6642
6643 /// A NULL slot is a declared absence — no read, no report, no error. The
6644 /// caller turns that `None` into UNKNOWN; see
6645 /// `a_null_read_back_slot_answers_unknown_not_the_request`.
6646 #[cfg(feature = "alloc")]
6647 #[test]
6648 fn a_null_read_back_slot_reports_nothing() {
6649 let entity = NrosRmwClient {
6650 service_name: core::ptr::null(),
6651 type_name: core::ptr::null(),
6652 _reserved: [0u8; 8],
6653 backend_data: core::ptr::null_mut(),
6654 };
6655 // SAFETY: no slot is called.
6656 let granted = unsafe {
6657 read_granted_qos(
6658 &entity,
6659 None::<unsafe extern "C" fn(*const NrosRmwClient, *mut NrosRmwQos) -> NrosRmwRet>,
6660 )
6661 };
6662 assert!(granted.is_none());
6663 }
6664
6665 // And a required slot must STILL be refused even when everything else is
6666 // present — so the fix cannot be mistaken for "the gate was weakened".
6667 #[test]
6668 fn register_still_rejects_a_missing_required_slot() {
6669 let mut vt = STUB_VTABLE;
6670 vt.publish = None;
6671
6672 assert_eq!(first_missing_vtable_slot(&vt), Some("publish"));
6673
6674 let rc = unsafe { nros_rmw_cffi_register_named(c"no_publish_0349".as_ptr(), &vt) };
6675 assert_eq!(rc, NROS_RMW_RET_INVALID_ARGUMENT);
6676 }
6677}