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(&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::{cell::UnsafeCell, ffi::c_void, sync::atomic::Ordering};
27
28// RFC-0054 (phase-299 W1.3) — committed bindgen output from
29// `packages/core/nros-rmw-abi/include/nros/*.h`. This module is the ONLY
30// definition of the RMW ABI types (`nros_rmw_*_t`); the `NrosRmw*` names
31// below are compat aliases over the generated items.
32// (bindgen emits the vtable fn-pointer types inline, which trips
33// clippy::type_complexity under `-D warnings`; allowed here rather than
34// editing the generated file.)
35#[allow(clippy::type_complexity)]
36pub mod generated;
37pub use generated::*;
38
39use nros_rmw::{
40 ClientTrait, GraphEndpointInfo, GraphEntityKind, MessageInfo, Publisher, QoSDurabilityPolicy,
41 QoSHistoryPolicy, QoSProfile, QoSReliabilityPolicy, ServiceInfo, ServiceRequest, ServiceTrait,
42 Session, TopicInfo, TransportError,
43};
44
45// Phase 115.L.0 — generic Rust→C-vtable adapter. Lives behind the
46// `alloc` feature because each entity handle is boxed for stable
47// address mgmt; every nros backend already requires alloc.
48#[cfg(feature = "alloc")]
49pub mod rust_adapter;
50
51#[cfg(feature = "alloc")]
52pub use rust_adapter::{RustBackend, RustBackendAdapter};
53
54// Phase 249 P4b.1 — `.init_array` ctor self-registration
55// (`nros_rmw_register_backend!` macro lives here).
56pub mod section;
57
58// ============================================================================
59// Phase 102.1 / RFC-0054 — `rmw_ret_t` named return codes
60// ============================================================================
61//
62// The constants live in `generated` (from `<nros/rmw_ret.h>`); only the
63// compat alias and the re-typed `OK` shadow live here.
64
65/// Compat alias for the generated `rmw_ret_t` typedef.
66/// Zero on success; negative on error.
67pub type NrosRmwRet = rmw_ret_t;
68
69// Anchor every C-stub-transport symbol so they survive
70// `--gc-sections` when integration tests link against
71// `libnros_rmw_cffi`. Only compiled when the c-stub-test feature
72// is on; otherwise no C anchor + no toolchain dep.
73#[cfg(feature = "c-stub-test")]
74unsafe extern "C" {
75 fn nros_c_stub_make_ops(out: *mut core::ffi::c_void);
76 fn nros_c_stub_reset_counters();
77 fn nros_c_stub_get_open_calls() -> u32;
78 fn nros_c_stub_get_close_calls() -> u32;
79 fn nros_c_stub_get_write_calls() -> u32;
80 fn nros_c_stub_get_read_calls() -> u32;
81}
82#[cfg(feature = "c-stub-test")]
83#[doc(hidden)]
84pub fn _c_stub_transport_vtable_anchor() -> [*const core::ffi::c_void; 6] {
85 [
86 nros_c_stub_make_ops as *const _,
87 nros_c_stub_reset_counters as *const _,
88 nros_c_stub_get_open_calls as *const _,
89 nros_c_stub_get_close_calls as *const _,
90 nros_c_stub_get_write_calls as *const _,
91 nros_c_stub_get_read_calls as *const _,
92 ]
93}
94/// Map a `TransportError` to the corresponding `rmw_ret_t` code.
95///
96/// By-reference because `TransportError` carries a `String` on its
97/// dynamic-diagnostic variant and is not `Copy`. The string itself is
98/// dropped at the boundary — embedded RMW callers cannot afford a
99/// thread-local error buffer.
100pub fn ret_from_error(err: &TransportError) -> NrosRmwRet {
101 match err {
102 TransportError::Timeout => NROS_RMW_RET_TIMEOUT,
103 TransportError::WouldBlock => NROS_RMW_RET_WOULD_BLOCK,
104 TransportError::TooLarge => NROS_RMW_RET_MESSAGE_TOO_LARGE,
105 TransportError::BufferTooSmall => NROS_RMW_RET_BUFFER_TOO_SMALL,
106 TransportError::MessageTooLarge => NROS_RMW_RET_MESSAGE_TOO_LARGE,
107 TransportError::InvalidArgument => NROS_RMW_RET_INVALID_ARGUMENT,
108 // Issue 0468 — InvalidConfig used to borrow INVALID_ARGUMENT, so a
109 // capacity the BUILD cannot honour arrived looking like a caller
110 // passing something wrong. It has its own code now.
111 TransportError::InvalidConfig => NROS_RMW_RET_INVALID_CONFIG,
112 TransportError::Unsupported => NROS_RMW_RET_UNSUPPORTED,
113 TransportError::BadAlloc => NROS_RMW_RET_BAD_ALLOC,
114 TransportError::IncompatibleQos => NROS_RMW_RET_INCOMPATIBLE_QOS,
115 TransportError::TopicNameInvalid => NROS_RMW_RET_TOPIC_NAME_INVALID,
116 TransportError::NodeNameNonExistent => NROS_RMW_RET_NODE_NAME_NON_EXISTENT,
117 TransportError::LoanNotSupported => NROS_RMW_RET_LOAN_NOT_SUPPORTED,
118 TransportError::NoData => NROS_RMW_RET_NO_DATA,
119 TransportError::IncompatibleAbi => NROS_RMW_RET_INCOMPATIBLE_ABI,
120 // Phase 155.B.3 — distinguish wire-level connection failure
121 // from generic backend error so the FreeRTOS / RV64 C+C++
122 // `init -> -X` logs identify the actual class. zenoh-pico's
123 // `ZpicoError::Session` (zpico_open returned -3) and
124 // `ZpicoError::Generic` (zpico_init returned -1) both flow
125 // through `ZpicoError → ConnectionFailed`; the cmake-built
126 // FreeRTOS C/C++ tests will now surface NOT_FOUND (the
127 // user-side mapping in `nros_support_init`) instead of the
128 // generic NROS_RET_ERROR catch-all.
129 TransportError::ConnectionFailed | TransportError::Disconnected => {
130 NROS_RMW_RET_CONNECTION_FAILED
131 }
132 // Everything else collapses to NROS_RMW_RET_ERROR. Backends
133 // that want fine-grained reporting should adopt the named
134 // variants above (Phase 102.2 sweep).
135 _ => NROS_RMW_RET_ERROR,
136 }
137}
138
139/// Map a `rmw_ret_t` returned by a C-side vtable function back to
140/// a `TransportError` for the Rust caller. Inverse of `ret_from_error`
141/// — used when `nros-rmw-cffi`'s `CffiSession` etc. receive a code
142/// from the registered C backend.
143///
144/// `NROS_RMW_RET_OK` is mapped to `TransportError::Backend("ok")` as a
145/// programming-error sentinel; callers should branch on the success
146/// path before calling this. Unknown negative codes collapse to the
147/// generic `TransportError::Backend("unknown rmw_ret_t")` so a future
148/// constant added to the C header degrades gracefully on the Rust side.
149pub fn error_from_ret(ret: NrosRmwRet) -> TransportError {
150 match ret {
151 NROS_RMW_RET_OK => {
152 TransportError::Backend("ok (logic error: positive ret_t at error site)")
153 }
154 NROS_RMW_RET_ERROR => TransportError::Backend("rmw_ret error"),
155 NROS_RMW_RET_TIMEOUT => TransportError::Timeout,
156 NROS_RMW_RET_BAD_ALLOC => TransportError::BadAlloc,
157 NROS_RMW_RET_INVALID_ARGUMENT => TransportError::InvalidArgument,
158 NROS_RMW_RET_INVALID_CONFIG => TransportError::InvalidConfig,
159 NROS_RMW_RET_UNSUPPORTED => TransportError::Unsupported,
160 NROS_RMW_RET_INCOMPATIBLE_QOS => TransportError::IncompatibleQos,
161 NROS_RMW_RET_TOPIC_NAME_INVALID => TransportError::TopicNameInvalid,
162 NROS_RMW_RET_NODE_NAME_NON_EXISTENT => TransportError::NodeNameNonExistent,
163 NROS_RMW_RET_LOAN_NOT_SUPPORTED => TransportError::LoanNotSupported,
164 NROS_RMW_RET_NO_DATA => TransportError::NoData,
165 NROS_RMW_RET_WOULD_BLOCK => TransportError::WouldBlock,
166 NROS_RMW_RET_BUFFER_TOO_SMALL => TransportError::BufferTooSmall,
167 NROS_RMW_RET_MESSAGE_TOO_LARGE => TransportError::MessageTooLarge,
168 NROS_RMW_RET_INCOMPATIBLE_ABI => TransportError::IncompatibleAbi,
169 // Phase 155.B.3 — inverse of `ret_from_error`'s
170 // `ConnectionFailed | Disconnected → CONNECTION_FAILED`
171 // mapping. Decodes the new vtable-level code back to the
172 // `TransportError::ConnectionFailed` variant; downstream
173 // `transport_error_to_ret` in nros-c surfaces it as
174 // `NROS_RET_NOT_FOUND` (-4) to the user.
175 NROS_RMW_RET_CONNECTION_FAILED => TransportError::ConnectionFailed,
176 _ => TransportError::Backend("unknown rmw_ret_t"),
177 }
178}
179
180// ============================================================================
181// Phase 102.3 / RFC-0054 — typed entity structs (defined in `generated`)
182// ============================================================================
183//
184// The `nros_rmw_*_t` structs live in `generated` (from
185// `<nros/rmw_entity.h>`); the `NrosRmw*` names are compat aliases.
186
187// The trait-level infinite spelling and the header's sentinel are the
188// same value by contract; a header edit that drifts one fails here.
189const _: () = assert!(nros_rmw::DURATION_INFINITE_MS as i64 == NROS_RMW_DURATION_INFINITE_MS);
190
191/// Compat alias for the generated `rmw_qos_profile_t`.
192pub type NrosRmwQos = rmw_qos_profile_t;
193/// Compat alias for the generated `rmw_session_t`.
194pub type NrosRmwSession = rmw_session_t;
195/// issue 0808 — session creation options; NULL means every default.
196pub type NrosRmwSessionOptions = rmw_session_options_t;
197/// Phase 376 W5/B1 — a graph node. The first argument of the four `create_*`
198/// slots, in place of the fabricated per-call session view they used to take.
199pub type NrosRmwNode = rmw_node_t;
200/// Compat alias for the generated `rmw_publisher_t`.
201pub type NrosRmwPublisher = rmw_publisher_t;
202/// Compat alias for the generated `rmw_subscription_t`.
203pub type NrosRmwSubscription = rmw_subscription_t;
204
205/// A vtable with every slot NULL — the base for struct-update syntax.
206///
207/// Phase 376 W4 — a vtable literal must name EVERY field, and this crate's
208/// tests build 26 of them. Adding a slot therefore meant adding one `None,`
209/// line to each, twenty-six times: tedious, and a place to miss one. The
210/// compiler catches a miss, but only after the diff has grown by the slot count
211/// times twenty-six. With this, a test names the slots it actually scripts and
212/// ends with `..EMPTY_VTABLE`, so a new slot costs one line in the header and
213/// nothing here.
214///
215/// Deliberately a `const` and not a `Default` impl: `Default` would make an
216/// all-NULL vtable constructible by accident, and an all-NULL vtable is exactly
217/// what `nros_rmw_cffi_register` must REFUSE (issue 0349). A const used
218/// explicitly as a literal's base cannot be reached that way.
219pub const EMPTY_VTABLE: NrosRmwVtable = NrosRmwVtable {
220 create_session: None,
221 destroy_session: None,
222 drive_io: None,
223 create_publisher: None,
224 destroy_publisher: None,
225 publish: None,
226 create_subscription: None,
227 destroy_subscription: None,
228 take: None,
229 has_data: None,
230 create_service: None,
231 destroy_service: None,
232 take_request: None,
233 has_request: None,
234 send_response: None,
235 create_client: None,
236 destroy_client: None,
237 send_request: None,
238 take_response: None,
239 subscription_event_init: None,
240 subscription_take_event: None,
241 publisher_take_event: None,
242 publisher_event_init: None,
243 publisher_assert_liveliness: None,
244 next_deadline_ms: None,
245 set_wake_callback: None,
246 borrow_loaned_message: None,
247 publish_loaned_message: None,
248 return_loaned_message_from_publisher: None,
249 take_loaned_message: None,
250 return_loaned_message_from_subscription: None,
251 service_server_is_available: None,
252 take_sequence: None,
253 publish_streamed: None,
254 ping_session: None,
255 subscription_supports_in_place: None,
256 process_raw_in_place: None,
257 get_implementation_identifier: None,
258 get_serialization_format: None,
259 feature_supported: None,
260 get_gid_for_publisher: None,
261 publisher_count_matched_subscriptions: None,
262 subscription_count_matched_publishers: None,
263 publisher_get_actual_qos: None,
264 subscription_get_actual_qos: None,
265 client_request_publisher_get_actual_qos: None,
266 client_response_subscription_get_actual_qos: None,
267 service_request_subscription_get_actual_qos: None,
268 service_response_publisher_get_actual_qos: None,
269 publisher_wait_for_all_acked: None,
270 take_with_info: None,
271 take_loaned_message_with_info: None,
272 get_node_names: None,
273 get_topic_names_and_types: None,
274 get_service_names_and_types: None,
275 get_publisher_names_and_types_by_node: None,
276 get_subscriber_names_and_types_by_node: None,
277 get_service_names_and_types_by_node: None,
278 get_client_names_and_types_by_node: None,
279 get_publishers_info_by_topic: None,
280 get_subscriptions_info_by_topic: None,
281 count_publishers: None,
282 count_subscribers: None,
283 node_get_graph_guard_condition: None,
284 create_node: None,
285 destroy_node: None,
286 set_log_severity: None,
287 required_rx_bytes: None,
288};
289
290/// Compat alias for the generated `rmw_service_t`.
291pub type NrosRmwService = rmw_service_t;
292/// Compat alias for the generated `rmw_client_t`.
293pub type NrosRmwClient = rmw_client_t;
294/// Compat alias for the generated `nros_rmw_vtable_t`.
295pub type NrosRmwVtable = nros_rmw_vtable_t;
296
297// The generated struct intentionally derives only Copy/Clone/Debug;
298// consumers (and the hand-written predecessor) compare QoS profiles.
299impl PartialEq for rmw_qos_profile_t {
300 fn eq(&self, other: &Self) -> bool {
301 self.reliability == other.reliability
302 && self.durability == other.durability
303 && self.history == other.history
304 && self.liveliness_kind == other.liveliness_kind
305 && self.depth == other.depth
306 && self.deadline_ms == other.deadline_ms
307 && self.lifespan_ms == other.lifespan_ms
308 && self.liveliness_lease_ms == other.liveliness_lease_ms
309 && self.avoid_ros_namespace_conventions == other.avoid_ros_namespace_conventions
310 }
311}
312impl Eq for rmw_qos_profile_t {}
313
314// The QoS profile constants below are `#define` struct-literal macros in the
315// C header; bindgen does not translate function-like/struct-literal macros,
316// so the Rust-side literals stay here (built from the generated types).
317//
318// Phase 376 W5/B2 — these used BARE INTEGERS with the policy name in a
319// trailing comment (`reliability: 1, // RELIABLE`). When the values took
320// upstream's numbering, every one of them kept compiling and started meaning a
321// different policy: `history: 0` went from KEEP_LAST to SYSTEM_DEFAULT and
322// `reliability: 0` from BEST_EFFORT to SYSTEM_DEFAULT. A comment is not a
323// binding. They name the generated constant now, so the next renumbering is a
324// compile error or nothing at all.
325
326/// Standard `rmw_qos_profile_default`-equivalent.
327pub const NROS_RMW_QOS_PROFILE_DEFAULT: NrosRmwQos = NrosRmwQos {
328 reliability: generated::NROS_RMW_RELIABILITY_RELIABLE as u8,
329 durability: generated::NROS_RMW_DURABILITY_VOLATILE as u8,
330 history: generated::NROS_RMW_HISTORY_KEEP_LAST as u8,
331 liveliness_kind: rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_AUTOMATIC as u8,
332 depth: 10,
333 _reserved0: 0,
334 deadline_ms: 0,
335 lifespan_ms: 0,
336 liveliness_lease_ms: 0,
337 avoid_ros_namespace_conventions: 0,
338 _reserved1: [0; 3],
339};
340
341/// Standard `rmw_qos_profile_sensor_data`-equivalent.
342pub const NROS_RMW_QOS_PROFILE_SENSOR_DATA: NrosRmwQos = NrosRmwQos {
343 reliability: generated::NROS_RMW_RELIABILITY_BEST_EFFORT as u8,
344 durability: generated::NROS_RMW_DURABILITY_VOLATILE as u8,
345 history: generated::NROS_RMW_HISTORY_KEEP_LAST as u8,
346 liveliness_kind: rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_AUTOMATIC as u8,
347 depth: 5,
348 _reserved0: 0,
349 deadline_ms: 0,
350 lifespan_ms: 0,
351 liveliness_lease_ms: 0,
352 avoid_ros_namespace_conventions: 0,
353 _reserved1: [0; 3],
354};
355
356/// Standard `rmw_qos_profile_services_default`-equivalent.
357pub const NROS_RMW_QOS_PROFILE_SERVICES_DEFAULT: NrosRmwQos = NROS_RMW_QOS_PROFILE_DEFAULT;
358
359/// Standard `rmw_qos_profile_parameters`-equivalent.
360pub const NROS_RMW_QOS_PROFILE_PARAMETERS: NrosRmwQos = NrosRmwQos {
361 depth: 1000,
362 ..NROS_RMW_QOS_PROFILE_DEFAULT
363};
364
365/// Standard `rmw_qos_profile_system_default`-equivalent — **all sentinel**.
366///
367/// issue 0829. This aliased `_DEFAULT` until 2026-09-03, which said the
368/// constant carried no meaning of its own: `SYSTEM_DEFAULT` became a
369/// byte-for-byte synonym for a concrete reliable / volatile / keep-last(10)
370/// profile. Upstream's `rmw_qos_profile_system_default` names no concrete
371/// policy at all, and the RMW resolves the absence — differently per backend
372/// (`rmw_cyclonedds_cpp` → depth 1, `rmw_zenoh_cpp` → depth 42), which is why
373/// no baked number here could be right.
374///
375/// Every field is now zero, so this is also what a `memset` of the struct
376/// gives: the ABI stops having two answers for the same bytes. Mirrors
377/// `NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT` in `nros/rmw_entity.h`.
378pub const NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT: NrosRmwQos = NrosRmwQos {
379 reliability: generated::NROS_RMW_RELIABILITY_SYSTEM_DEFAULT as u8,
380 durability: generated::NROS_RMW_DURABILITY_SYSTEM_DEFAULT as u8,
381 history: generated::NROS_RMW_HISTORY_SYSTEM_DEFAULT as u8,
382 liveliness_kind: generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_SYSTEM_DEFAULT as u8,
383 depth: 0,
384 _reserved0: 0,
385 deadline_ms: 0,
386 lifespan_ms: 0,
387 liveliness_lease_ms: 0,
388 avoid_ros_namespace_conventions: 0,
389 _reserved1: [0; 3],
390};
391
392// Phase-301 (issue 0241) — the QoS lowering is FALLIBLE: a depth the
393// C ABI's u16 cannot represent is a create-time error, never a silent
394// saturate. The duration fields are u32 ms on both sides (0 = unset,
395// `NROS_RMW_DURATION_INFINITE_MS` = explicit infinite) and pass
396// through unchanged; finer-grained callers lower via
397// `nros_rmw::duration_to_qos_ms` (sub-ms CEILs to 1 ms, past-u32
398// errors).
399impl TryFrom<QoSProfile> for NrosRmwQos {
400 type Error = TransportError;
401
402 fn try_from(qos: QoSProfile) -> Result<Self, TransportError> {
403 if qos.depth > u16::MAX as u32 {
404 return Err(TransportError::InvalidArgument);
405 }
406 Ok(Self {
407 // issue 0829 — the sentinel LOWERS; it is not resolved here.
408 // `NrosRmwQos` is what a C backend receives, and resolving the
409 // absence is that backend's job (cyclone answers depth 1, xrce
410 // leaves it for the Agent). Folding it to a concrete value on this
411 // side would hand every C backend the same answer and make the
412 // per-backend resolution unreachable.
413 reliability: match qos.reliability {
414 QoSReliabilityPolicy::SystemDefault => {
415 generated::NROS_RMW_RELIABILITY_SYSTEM_DEFAULT as u8
416 }
417 QoSReliabilityPolicy::BestEffort => {
418 generated::NROS_RMW_RELIABILITY_BEST_EFFORT as u8
419 }
420 QoSReliabilityPolicy::Reliable => generated::NROS_RMW_RELIABILITY_RELIABLE as u8,
421 },
422 durability: match qos.durability {
423 QoSDurabilityPolicy::SystemDefault => {
424 generated::NROS_RMW_DURABILITY_SYSTEM_DEFAULT as u8
425 }
426 QoSDurabilityPolicy::Volatile => generated::NROS_RMW_DURABILITY_VOLATILE as u8,
427 QoSDurabilityPolicy::TransientLocal => {
428 generated::NROS_RMW_DURABILITY_TRANSIENT_LOCAL as u8
429 }
430 },
431 history: match qos.history {
432 QoSHistoryPolicy::SystemDefault => generated::NROS_RMW_HISTORY_SYSTEM_DEFAULT as u8,
433 QoSHistoryPolicy::KeepLast => generated::NROS_RMW_HISTORY_KEEP_LAST as u8,
434 QoSHistoryPolicy::KeepAll => generated::NROS_RMW_HISTORY_KEEP_ALL as u8,
435 },
436 liveliness_kind: qos.liveliness_kind as u8,
437 depth: qos.depth as u16,
438 _reserved0: 0,
439 deadline_ms: qos.deadline_ms,
440 lifespan_ms: qos.lifespan_ms,
441 liveliness_lease_ms: qos.liveliness_lease_ms,
442 avoid_ros_namespace_conventions: qos.avoid_ros_namespace_conventions as u8,
443 _reserved1: [0; 3],
444 })
445 }
446}
447
448// ============================================================================
449// Phase 108 / RFC-0054 — status-event types (defined in `generated`)
450// ============================================================================
451//
452// `rmw_event_type_t` is a module-consts alias (bindgen
453// `--default-enum-style=moduleconsts`), not a Rust enum, so the retired
454// `From` impls between it and `nros_rmw::EventKind` become plain functions.
455
456/// Compat alias for the generated `rmw_event_type_t::Type`
457/// (C-`unsigned`-sized event-kind discriminant).
458pub type NrosRmwEventKind = rmw_event_type_t::Type;
459/// Compat alias for the generated `rmw_liveliness_changed_status_t`.
460pub type NrosRmwLivelinessChangedStatus = rmw_liveliness_changed_status_t;
461/// Compat alias for the generated `rmw_count_status_t`.
462pub type NrosRmwCountStatus = rmw_count_status_t;
463/// Compat alias for the generated `rmw_event_payload_t` union.
464pub type NrosRmwEventPayload = rmw_event_payload_t;
465/// Compat alias for the generated `rmw_status_event_callback_t`
466/// (nullable — `Option`-wrapped fn pointer, per C ABI).
467pub type NrosRmwEventCallback = rmw_status_event_callback_t;
468
469/// Convert a trait-level [`nros_rmw::EventKind`] to the C ABI discriminant.
470/// Replaces the retired `From<nros_rmw::EventKind> for NrosRmwEventKind`.
471pub fn event_kind_to_c(k: nros_rmw::EventKind) -> NrosRmwEventKind {
472 use nros_rmw::EventKind as K;
473 use rmw_event_type_t as C;
474 match k {
475 K::LivelinessChanged => C::NROS_RMW_EVENT_LIVELINESS_CHANGED,
476 K::RequestedDeadlineMissed => C::NROS_RMW_EVENT_REQUESTED_DEADLINE_MISSED,
477 K::MessageLost => C::NROS_RMW_EVENT_MESSAGE_LOST,
478 K::LivelinessLost => C::NROS_RMW_EVENT_LIVELINESS_LOST,
479 K::OfferedDeadlineMissed => C::NROS_RMW_EVENT_OFFERED_DEADLINE_MISSED,
480 // unreachable for now (#[non_exhaustive])
481 _ => C::NROS_RMW_EVENT_MESSAGE_LOST,
482 }
483}
484
485/// Convert a C ABI event-kind discriminant to the trait-level
486/// [`nros_rmw::EventKind`]. Replaces the retired
487/// `From<NrosRmwEventKind> for nros_rmw::EventKind`. Unknown values map to
488/// `MessageLost`, mirroring the forward direction's fallback.
489pub fn event_kind_from_c(k: NrosRmwEventKind) -> nros_rmw::EventKind {
490 use nros_rmw::EventKind as K;
491 use rmw_event_type_t as C;
492 match k {
493 C::NROS_RMW_EVENT_LIVELINESS_CHANGED => K::LivelinessChanged,
494 C::NROS_RMW_EVENT_REQUESTED_DEADLINE_MISSED => K::RequestedDeadlineMissed,
495 C::NROS_RMW_EVENT_MESSAGE_LOST => K::MessageLost,
496 C::NROS_RMW_EVENT_LIVELINESS_LOST => K::LivelinessLost,
497 C::NROS_RMW_EVENT_OFFERED_DEADLINE_MISSED => K::OfferedDeadlineMissed,
498 _ => K::MessageLost,
499 }
500}
501
502// ============================================================================
503// Registration
504// ============================================================================
505//
506// Phase 104.B.2 — named registry replaces the singleton vtable.
507// Backends register under a stable identifier (`"zenoh"`, `"dds"`,
508// `"xrce"`, future `"uorb"`, `"cyclonedds"`); consumers look up
509// vtables by name via `nros_rmw_cffi_lookup`. Multiple backends can
510// coexist in the same process (bridge nodes).
511//
512// Capacity comes from the `NROS_RMW_MAX_BACKENDS` build-time env
513// var (default 8). See `build.rs`.
514//
515// Implementation: a fixed-size `[BackendSlot; MAX_BACKENDS]`
516// guarded by an atomic length counter. No alloc; `no_std`
517// compatible. Slot scan is O(N) for lookup but N is tiny (8 by
518// default). Each slot owns its name buffer; `name_ptr` returned
519// to consumers points into the slot and stays valid for the
520// program's lifetime.
521
522/// Compile-time max number of concurrently registered backends.
523/// Set via `NROS_RMW_MAX_BACKENDS` env var at build time
524/// (`build.rs`). Default 8.
525pub const MAX_BACKENDS: usize = parse_max_backends(env!("NROS_RMW_MAX_BACKENDS"));
526
527const fn parse_max_backends(s: &str) -> usize {
528 parse_env_usize(s, "NROS_RMW_MAX_BACKENDS must be a decimal integer")
529}
530
531/// Const decimal parser for build.rs-emitted envs (`MAX_BACKENDS`,
532/// `NROS_RMW_SUBSCRIBER_SLOTS`).
533pub(crate) const fn parse_env_usize(s: &str, msg: &str) -> usize {
534 let bytes = s.as_bytes();
535 let mut i = 0usize;
536 let mut acc: usize = 0;
537 while i < bytes.len() {
538 let d = bytes[i];
539 if !d.is_ascii_digit() {
540 let _ = msg;
541 panic!("nros-rmw-cffi: build-time env must be a decimal integer");
542 }
543 acc = acc * 10 + (d - b'0') as usize;
544 i += 1;
545 }
546 acc
547}
548
549/// Maximum length of a backend name. Names are short ASCII
550/// identifiers (`"zenoh"`, `"cyclonedds"`); 32 bytes is generous.
551const BACKEND_NAME_MAX: usize = 32;
552
553#[repr(C)]
554struct BackendSlot {
555 /// Null-terminated UTF-8 backend name. Zero-initialized when
556 /// unused (`name[0] == 0`).
557 name: [u8; BACKEND_NAME_MAX],
558 vtable: *const NrosRmwVtable,
559}
560
561impl BackendSlot {
562 const fn empty() -> Self {
563 Self {
564 name: [0u8; BACKEND_NAME_MAX],
565 vtable: core::ptr::null(),
566 }
567 }
568
569 #[inline]
570 fn is_empty(&self) -> bool {
571 self.name[0] == 0
572 }
573
574 #[inline]
575 fn name_matches(&self, candidate: &[u8]) -> bool {
576 if self.is_empty() {
577 return false;
578 }
579 // Compare up to the first NUL or candidate length.
580 let mut i = 0usize;
581 while i < self.name.len() && i < candidate.len() {
582 if self.name[i] == 0 {
583 return false; // slot name shorter than candidate
584 }
585 if self.name[i] != candidate[i] {
586 return false;
587 }
588 i += 1;
589 }
590 // candidate fully consumed; slot must be NUL at i (same length)
591 i == candidate.len() && (i == self.name.len() || self.name[i] == 0)
592 }
593}
594
595// SAFETY: `BackendSlot::vtable` is a `*const` pointer used in a
596// `'static` context; once written it's never freed and the registry
597// is guarded by an atomic length counter for publication. Marker
598// trait implementations are required so the static array is
599// `Sync` across threads.
600unsafe impl Sync for BackendSlot {}
601
602/// Fixed-size registry. `slots[0..len]` are live; `slots[len..]`
603/// are zero-initialized. `len` is the publication fence.
604///
605/// `slots` lives in an `UnsafeCell` because we mutate through
606/// `&'static REGISTRY`. Safety invariants:
607/// * Slot writes happen only inside `nros_rmw_cffi_register_named`,
608/// which is documented "call before `Executor::open`" — backend
609/// ctors fire pre-main, manual calls precede session creation.
610/// * Slot reads via `nros_rmw_cffi_lookup` and `get_vtable` happen
611/// after `Executor::open`, well after registration completes.
612/// * The atomic `len` provides the release-acquire fence so any
613/// reader that sees `len = N` also sees the populated slot
614/// contents for indices `< N`.
615#[doc(hidden)]
616pub struct Registry {
617 slots: core::cell::UnsafeCell<[BackendSlot; MAX_BACKENDS]>,
618 len: portable_atomic::AtomicUsize,
619}
620
621impl Registry {
622 #[doc(hidden)]
623 pub const fn new() -> Self {
624 let slots = {
625 #[allow(clippy::declare_interior_mutable_const)]
626 const E: BackendSlot = BackendSlot::empty();
627 [E; MAX_BACKENDS]
628 };
629 Self {
630 slots: core::cell::UnsafeCell::new(slots),
631 len: portable_atomic::AtomicUsize::new(0),
632 }
633 }
634
635 /// Borrow slot `i` immutably. Caller must guarantee
636 /// `i < self.len.load(Acquire)`.
637 #[inline]
638 unsafe fn slot(&self, i: usize) -> &BackendSlot {
639 // SAFETY: registry protocol guarantees slot stability once
640 // published via the atomic len fence.
641 unsafe { &(*self.slots.get())[i] }
642 }
643
644 /// Borrow slot `i` mutably. Caller must guarantee exclusive
645 /// access — either pre-publication (idx > current `len`) or
646 /// during an idempotent overwrite of an already-registered name.
647 #[inline]
648 #[allow(clippy::mut_from_ref)]
649 unsafe fn slot_mut(&self, i: usize) -> &mut BackendSlot {
650 // SAFETY: see Registry doc — writer-side discipline.
651 unsafe { &mut (*self.slots.get())[i] }
652 }
653}
654
655// SAFETY: see `Registry` doc-comment on the mutation protocol.
656unsafe impl Sync for Registry {}
657
658// Phase 241.D3-rev — `REGISTRY` is DEFINED once in this rlib (plain
659// `#[no_mangle]`). The single-runtime model puts exactly one Rust staticlib in any
660// link (the umbrella `nros-c` / `nros-cpp` bundles the backend as an rlib), so the
661// cffi rlib appears once and one strong definition is correct everywhere: pure-Rust
662// firmware, the NuttX build-std ELF, and the umbrella C/C++ staticlib alike. This
663// supersedes the slice-4 `external-registry`/provider split, which existed only
664// because the C/C++ link used to carry multiple Rust staticlibs.
665#[unsafe(no_mangle)]
666static REGISTRY: Registry = Registry::new();
667
668/// The single process-wide backend registry.
669#[inline]
670fn registry() -> &'static Registry {
671 ®ISTRY
672}
673
674// ============================================================================
675// Rust-adapter MessageInfo side channel
676// ============================================================================
677//
678// The stable C subscriber ABI returns only a `(payload, len)` pair from
679// `take_serialized`. Rust backends can produce `MessageInfo`, so the generic
680// Rust->C adapter stores that metadata keyed by the backend handle pointer
681// immediately before returning the payload length. The Rust CFFI subscriber
682// consumes it after the vtable call. Pure C/C++ backends never write this table
683// and keep the documented `None` metadata behavior.
684
685/// Issue 0271 — build-time configurable via `NROS_RMW_MESSAGE_INFO_SLOTS`
686/// (default 64). Under-sizing costs metadata, not correctness: a subscriber
687/// that finds no free slot reads back `None` for `MessageInfo`, which is the
688/// documented behaviour for backends that never populate the table at all.
689const MESSAGE_INFO_SLOTS: usize = crate::parse_env_usize(
690 env!("NROS_RMW_MESSAGE_INFO_SLOTS"),
691 "NROS_RMW_MESSAGE_INFO_SLOTS must be a decimal integer",
692);
693
694struct MessageInfoSlot {
695 key: portable_atomic::AtomicUsize,
696 valid: portable_atomic::AtomicBool,
697 info: UnsafeCell<MessageInfo>,
698 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
699 validate_requested: portable_atomic::AtomicBool,
700 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
701 integrity_valid: portable_atomic::AtomicBool,
702 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
703 integrity: UnsafeCell<nros_rmw::IntegrityStatus>,
704}
705
706impl MessageInfoSlot {
707 const fn empty() -> Self {
708 Self {
709 key: portable_atomic::AtomicUsize::new(0),
710 valid: portable_atomic::AtomicBool::new(false),
711 info: UnsafeCell::new(MessageInfo::new()),
712 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
713 validate_requested: portable_atomic::AtomicBool::new(false),
714 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
715 integrity_valid: portable_atomic::AtomicBool::new(false),
716 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
717 integrity: UnsafeCell::new(nros_rmw::IntegrityStatus {
718 gap: 0,
719 duplicate: false,
720 crc_valid: None,
721 }),
722 }
723 }
724}
725
726// SAFETY: each slot is published by `key` and `valid` atomics. Writers store
727// `info` before setting `valid = true` with Release ordering; readers take
728// `valid` with AcqRel before copying the `MessageInfo`.
729unsafe impl Sync for MessageInfoSlot {}
730
731// issue 0739 — deliberately NOT annotated with a `// nros-pool:` formula.
732// `MessageInfoSlot`'s width depends on cfg (`alloc` + `safety-e2e` add three
733// more fields), so any constant here would be right for one build and wrong for
734// the rest. Issue 0271 measured 3,584 bytes at 64 slots in ITS configuration;
735// stating that as the cost would be the fabrication the inventory exists to
736// avoid. The knob still appears in the table with its default — the table says
737// "no byte figure", which is true, rather than implying it is free.
738static MESSAGE_INFO_TABLE: [MessageInfoSlot; MESSAGE_INFO_SLOTS] = {
739 #[allow(clippy::declare_interior_mutable_const)]
740 const E: MessageInfoSlot = MessageInfoSlot::empty();
741 [E; MESSAGE_INFO_SLOTS]
742};
743
744fn lookup_message_info_slot(key: usize) -> Option<&'static MessageInfoSlot> {
745 if key == 0 {
746 return None;
747 }
748 MESSAGE_INFO_TABLE
749 .iter()
750 .find(|slot| slot.key.load(Ordering::Acquire) == key)
751}
752
753#[cfg(feature = "alloc")]
754fn get_or_insert_message_info_slot(key: usize) -> Option<&'static MessageInfoSlot> {
755 if key == 0 {
756 return None;
757 }
758 for slot in &MESSAGE_INFO_TABLE {
759 let current = slot.key.load(Ordering::Acquire);
760 if current == key {
761 return Some(slot);
762 }
763 if current == 0
764 && slot
765 .key
766 .compare_exchange(0, key, Ordering::AcqRel, Ordering::Acquire)
767 .is_ok()
768 {
769 return Some(slot);
770 }
771 }
772 None
773}
774
775#[cfg(feature = "alloc")]
776pub(crate) fn store_cffi_message_info(key: usize, info: Option<MessageInfo>) {
777 let Some(slot) = get_or_insert_message_info_slot(key) else {
778 return;
779 };
780 match info {
781 Some(info) => {
782 // SAFETY: this slot is keyed to one subscriber backend handle. The
783 // executor owns each subscriber mutably while receiving, so writes
784 // for the same key are serialized.
785 unsafe {
786 *slot.info.get() = info;
787 }
788 slot.valid.store(true, Ordering::Release);
789 }
790 None => slot.valid.store(false, Ordering::Release),
791 }
792}
793
794fn take_cffi_message_info(key: usize) -> Option<MessageInfo> {
795 let slot = lookup_message_info_slot(key)?;
796 if !slot.valid.swap(false, Ordering::AcqRel) {
797 return None;
798 }
799 // SAFETY: `valid.swap(false)` gives this reader exclusive consumption of the
800 // last stored `MessageInfo` for this key.
801 Some(unsafe { *slot.info.get() })
802}
803
804#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
805fn request_cffi_integrity_status(key: usize) {
806 let Some(slot) = get_or_insert_message_info_slot(key) else {
807 return;
808 };
809 slot.integrity_valid.store(false, Ordering::Release);
810 slot.validate_requested.store(true, Ordering::Release);
811}
812
813#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
814pub(crate) fn take_cffi_integrity_request(key: usize) -> bool {
815 lookup_message_info_slot(key)
816 .map(|slot| slot.validate_requested.swap(false, Ordering::AcqRel))
817 .unwrap_or(false)
818}
819
820#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
821pub(crate) fn store_cffi_integrity_status(key: usize, status: nros_rmw::IntegrityStatus) {
822 let Some(slot) = get_or_insert_message_info_slot(key) else {
823 return;
824 };
825 // SAFETY: integrity status follows the same per-subscriber handoff as
826 // `info`; the CFFI subscriber owns receive calls mutably for this key.
827 unsafe {
828 *slot.integrity.get() = status;
829 }
830 slot.integrity_valid.store(true, Ordering::Release);
831}
832
833#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
834fn take_cffi_integrity_status(key: usize) -> Option<nros_rmw::IntegrityStatus> {
835 let slot = lookup_message_info_slot(key)?;
836 if !slot.integrity_valid.swap(false, Ordering::AcqRel) {
837 return None;
838 }
839 Some(unsafe { *slot.integrity.get() })
840}
841
842fn clear_cffi_message_info(key: usize) {
843 let Some(slot) = lookup_message_info_slot(key) else {
844 return;
845 };
846 slot.valid.store(false, Ordering::Release);
847 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
848 {
849 slot.validate_requested.store(false, Ordering::Release);
850 slot.integrity_valid.store(false, Ordering::Release);
851 }
852 slot.key.store(0, Ordering::Release);
853}
854
855/// Register a custom RMW backend vtable (legacy single-arg form).
856///
857/// Phase 104.B.2 — internally forwards to
858/// [`nros_rmw_cffi_register_named`] with the literal name `"default"`.
859/// Preserved as a one-release source-compat shim so backend ctors
860/// authored before the named-registry switchover keep working.
861///
862/// **Deprecated (Phase 128.B.5).** All in-tree callers now use
863/// [`nros_rmw_cffi_register_named`] directly so the registry slot is
864/// keyed by the backend's canonical name (`"zenoh"`, `"dds"`,
865/// `"xrce"`, `"cyclonedds"`, …). New backends MUST follow the same
866/// pattern; the unnamed shim will be removed in a follow-up phase
867/// once external callers have migrated.
868///
869/// # Safety
870///
871/// The vtable pointer must remain valid for the lifetime of the program.
872/// All function pointers in the vtable must be valid.
873#[deprecated(
874 since = "0.2.0",
875 note = "use nros_rmw_cffi_register_named with the backend's canonical name; the unnamed shim will be removed"
876)]
877#[unsafe(no_mangle)]
878pub unsafe extern "C" fn nros_rmw_cffi_register(vtable: *const NrosRmwVtable) -> NrosRmwRet {
879 unsafe { nros_rmw_cffi_register_named(c"default".as_ptr(), vtable) }
880}
881
882/// Issue 0332 — a vtable slot the runtime `.expect()`s on the hot path is
883/// mandatory: a `None` there is a panic mid-spin, on a no_std target, the worst
884/// place to discover an incomplete backend. Returns the name of the first
885/// missing slot so registration can reject such a vtable loudly and early.
886///
887/// Issue 0349 — the list is CORE TRANSPORT only. It originally also required
888/// `register_publisher_event`, `register_subscription_event` and
889/// `assert_publisher_liveliness`, which refused the **xrce backend outright**:
890/// its vtable NULLs all three deliberately, alongside ~14 other optional
891/// capability slots this list correctly never required, so
892/// `nros_rmw_xrce_register()` returned INVALID_ARGUMENT and xrce could not
893/// register at all.
894///
895/// Those three are QoS-event and liveliness CAPABILITIES, not transport — and
896/// the slots are `Option<fn>` precisely because C nullability encodes "not
897/// provided" (RFC-0054). Requiring a slot whose type says it is optional was
898/// the contradiction. `assert_publisher_liveliness`' own dispatch site had
899/// documented "NULL function pointer = backend doesn't support manual
900/// liveliness" the whole time, while the code `.expect()`ed it.
901///
902/// The three now report `TransportError::Unsupported` when used and absent.
903/// That is the refinement this function's doc used to defer — the difference
904/// between an optional slot and a missing required one is that the optional one
905/// has a typed error at the point of use, which is exactly what makes dropping
906/// it from this list safe.
907fn first_missing_vtable_slot(v: &NrosRmwVtable) -> Option<&'static str> {
908 macro_rules! require {
909 ($($slot:ident),+ $(,)?) => {
910 $( if v.$slot.is_none() { return Some(stringify!($slot)); } )+
911 };
912 }
913 require!(
914 create_session,
915 destroy_session,
916 create_publisher,
917 destroy_publisher,
918 create_subscription,
919 destroy_subscription,
920 publish,
921 drive_io,
922 has_data,
923 take,
924 create_service,
925 destroy_service,
926 create_client,
927 destroy_client,
928 send_response,
929 has_request,
930 take_request,
931 );
932 // NOT required (issue 0349) — optional capabilities with a typed
933 // `Unsupported` error at the point of use, exactly like the ~14 other
934 // nullable slots (`borrow_loaned_message`, `take_loaned_message`, `next_deadline_ms`,
935 // `service_server_is_available`, …) this list has always allowed to be NULL:
936 // publisher_event_init, subscription_event_init,
937 // assert_publisher_liveliness
938 None
939}
940
941/// Register a backend under a stable name. Multiple backends can
942/// coexist; consumers select via [`nros_rmw_cffi_lookup`] or the
943/// higher-level `Executor::node_builder(...).rmw(...)` path.
944///
945/// Names must be UTF-8, NUL-terminated, ≤ 31 bytes (excluding NUL).
946/// Reserved names today: `"zenoh"`, `"dds"`, `"xrce"`,
947/// `"cyclonedds"`, future `"uorb"`. The string `"default"` is the
948/// implicit name used by the legacy single-arg
949/// [`nros_rmw_cffi_register`] shim.
950///
951/// Returns:
952/// * `NROS_RMW_RET_OK` on success.
953/// * `NROS_RMW_RET_INVALID_ARGUMENT` if `name` / `vtable` is
954/// NULL, the name is empty, or exceeds 31 bytes.
955/// * `NROS_RMW_RET_ERROR` if the registry is full
956/// (`MAX_BACKENDS` reached without a matching entry).
957///
958/// Duplicate registration of the same name overwrites the
959/// previous vtable (idempotent for ctor-fires-twice cases).
960///
961/// # Safety
962///
963/// * `name` must be a valid NUL-terminated UTF-8 string.
964/// * `vtable` must remain valid for the program's lifetime.
965#[unsafe(no_mangle)]
966pub unsafe extern "C" fn nros_rmw_cffi_register_named(
967 name: *const core::ffi::c_char,
968 vtable: *const NrosRmwVtable,
969) -> NrosRmwRet {
970 if name.is_null() || vtable.is_null() {
971 return NROS_RMW_RET_INVALID_ARGUMENT;
972 }
973
974 // Issue 0332 — reject an incomplete vtable at registration rather than
975 // panicking mid-spin. SAFETY: `vtable` is non-null (checked) and the caller
976 // guarantees it is valid for the program's lifetime (see `# Safety`).
977 if let Some(missing) = first_missing_vtable_slot(unsafe { &*vtable }) {
978 let _ = missing; // named for debuggers; INVALID_ARGUMENT is the ABI signal
979 return NROS_RMW_RET_INVALID_ARGUMENT;
980 }
981
982 let name_u8 = name.cast::<u8>();
983
984 // Length-check the input. We scan up to BACKEND_NAME_MAX + 1
985 // bytes; anything longer is rejected.
986 let mut len = 0usize;
987 while len < BACKEND_NAME_MAX {
988 let b = unsafe { *name_u8.add(len) };
989 if b == 0 {
990 break;
991 }
992 len += 1;
993 }
994 if len == 0 {
995 return NROS_RMW_RET_INVALID_ARGUMENT;
996 }
997 // Must have found a NUL within BACKEND_NAME_MAX.
998 if unsafe { *name_u8.add(len) } != 0 {
999 return NROS_RMW_RET_INVALID_ARGUMENT;
1000 }
1001
1002 let name_bytes = unsafe { core::slice::from_raw_parts(name_u8, len) };
1003
1004 // First pass: look for existing entry with same name → overwrite.
1005 let current_len = registry().len.load(Ordering::Acquire);
1006 for i in 0..current_len {
1007 // SAFETY: i < current_len, indices in bounds.
1008 let slot = unsafe { registry().slot(i) };
1009 if slot.name_matches(name_bytes) {
1010 // SAFETY: writer-side idempotent overwrite. The slot is
1011 // already published; concurrent readers will see either
1012 // the old or new vtable consistently, both valid.
1013 unsafe {
1014 let slot_mut = registry().slot_mut(i);
1015 slot_mut.vtable = vtable;
1016 }
1017 core::sync::atomic::fence(Ordering::Release);
1018 return NROS_RMW_RET_OK;
1019 }
1020 }
1021
1022 // No existing entry; append. Reserve a slot via atomic increment.
1023 let idx = registry().len.fetch_add(1, Ordering::AcqRel);
1024 if idx >= MAX_BACKENDS {
1025 // Roll back the increment so subsequent registers don't see a
1026 // stale `len > MAX_BACKENDS`. (Race window negligible — once
1027 // we hit capacity, no further append succeeds.)
1028 registry().len.store(MAX_BACKENDS, Ordering::Release);
1029 return NROS_RMW_RET_ERROR;
1030 }
1031
1032 // SAFETY: idx < MAX_BACKENDS, mutating an as-yet-unpublished slot.
1033 unsafe {
1034 let slot = registry().slot_mut(idx);
1035 slot.name[..len].copy_from_slice(name_bytes);
1036 slot.name[len] = 0;
1037 slot.vtable = vtable;
1038 }
1039 // Release-fence so concurrent lookups see both the name and the
1040 // vtable consistently with the updated `len`.
1041 core::sync::atomic::fence(Ordering::Release);
1042 NROS_RMW_RET_OK
1043}
1044
1045/// Look up a backend's vtable by name. Returns NULL if no backend
1046/// is registered under `name`.
1047///
1048/// # Safety
1049///
1050/// * `name` must be a valid NUL-terminated UTF-8 string.
1051#[unsafe(no_mangle)]
1052pub unsafe extern "C" fn nros_rmw_cffi_lookup(
1053 name: *const core::ffi::c_char,
1054) -> *const NrosRmwVtable {
1055 if name.is_null() {
1056 return core::ptr::null();
1057 }
1058 let name_u8 = name.cast::<u8>();
1059 let mut len = 0usize;
1060 while len < BACKEND_NAME_MAX {
1061 if unsafe { *name_u8.add(len) } == 0 {
1062 break;
1063 }
1064 len += 1;
1065 }
1066 if len == 0 || len == BACKEND_NAME_MAX {
1067 return core::ptr::null();
1068 }
1069 let name_bytes = unsafe { core::slice::from_raw_parts(name_u8, len) };
1070
1071 let current_len = registry().len.load(Ordering::Acquire);
1072 for i in 0..current_len {
1073 // SAFETY: i < current_len, indices in bounds; publication
1074 // fence via the atomic-len Acquire load.
1075 let slot = unsafe { registry().slot(i) };
1076 if slot.name_matches(name_bytes) {
1077 return slot.vtable;
1078 }
1079 }
1080 core::ptr::null()
1081}
1082
1083/// Diagnostic helper — fills `buf` with pointers to up to `cap`
1084/// registered backend names. Returns the number of names available
1085/// (may exceed `cap`). Pointer-valid for the program's lifetime.
1086///
1087/// # Safety
1088///
1089/// * `buf` must either be NULL (when `cap == 0`) or point at writable
1090/// memory of at least `cap * sizeof(*const c_char)` bytes.
1091#[unsafe(no_mangle)]
1092pub unsafe extern "C" fn nros_rmw_cffi_registered_names(
1093 buf: *mut *const core::ffi::c_char,
1094 cap: usize,
1095) -> usize {
1096 let n = registry().len.load(Ordering::Acquire);
1097 if !buf.is_null() && cap > 0 {
1098 let limit = n.min(cap);
1099 for i in 0..limit {
1100 // SAFETY: i < limit <= cap, buf capacity guaranteed by caller.
1101 let slot = unsafe { registry().slot(i) };
1102 unsafe {
1103 buf.add(i)
1104 .write(slot.name.as_ptr() as *const core::ffi::c_char)
1105 };
1106 }
1107 }
1108 n
1109}
1110
1111/// Phase 104.A — registry-presence probe. Returns `true` iff at
1112/// least one backend is registered. Used by `Executor::open` to
1113/// detect "user forgot to register a backend before opening the
1114/// session" and fail with a meaningful error.
1115#[inline]
1116pub fn backend_registered() -> bool {
1117 registry().len.load(Ordering::Acquire) > 0
1118}
1119
1120/// Phase 104.B — internal access to the registry for the Rust-side
1121/// adapter. `nros-node`'s `register_active_backend` removal already
1122/// switched to `backend_registered()` for the presence check; this
1123/// returns the vtable for any single-backend fast-path callers.
1124fn default_vtable() -> Option<&'static NrosRmwVtable> {
1125 let n = registry().len.load(Ordering::Acquire);
1126 if n == 0 {
1127 return None;
1128 }
1129 // SAFETY: index 0 < n, registry's len-Acquire fence orders the
1130 // slot read.
1131 let slot = unsafe { registry().slot(0) };
1132 if slot.vtable.is_null() {
1133 return None;
1134 }
1135 Some(unsafe { &*slot.vtable })
1136}
1137
1138/// Phase 128.A.3 — outcome of `resolve_backend`.
1139pub enum BackendResolution {
1140 /// Exactly one matching backend; use its vtable.
1141 Single(&'static NrosRmwVtable),
1142 /// No backend linked into the binary. Maps to
1143 /// [`NROS_RMW_RET_NO_BACKEND`].
1144 NoBackend,
1145 /// More than one backend linked and no selector given. Maps to
1146 /// [`NROS_RMW_RET_AMBIGUOUS_BACKEND`].
1147 Ambiguous,
1148 /// Selector did not match any registered backend. Maps to
1149 /// [`NROS_RMW_RET_UNKNOWN_BACKEND`].
1150 Unknown,
1151}
1152
1153/// Phase 128.A.3 — selection policy for the single-backend
1154/// `Executor::open` / `nros::init` path.
1155///
1156/// Algorithm:
1157///
1158/// 1. If `selector` is `Some(name)` (typically from `$NROS_RMW`),
1159/// look it up in the registry. Hit → [`BackendResolution::Single`];
1160/// miss → [`BackendResolution::Unknown`].
1161/// 2. Otherwise, if exactly one backend is registered, return it.
1162/// 3. Otherwise, if zero, [`BackendResolution::NoBackend`]; if more
1163/// than one, [`BackendResolution::Ambiguous`].
1164///
1165/// Callers convert the resolution to a [`NrosRmwRet`] via
1166/// [`backend_resolution_to_ret`].
1167///
1168/// Bridge consumers (`Executor::open_multi`) bypass this function and
1169/// call `nros_rmw_cffi_lookup` per spec instead.
1170pub fn resolve_backend(selector: Option<&[u8]>) -> BackendResolution {
1171 let n = registry().len.load(Ordering::Acquire);
1172 if let Some(name) = selector {
1173 let mut i = 0usize;
1174 while i < n {
1175 // SAFETY: i < n, registry len-Acquire fence orders the read.
1176 let slot = unsafe { registry().slot(i) };
1177 if slot.name_matches(name) {
1178 if slot.vtable.is_null() {
1179 return BackendResolution::Unknown;
1180 }
1181 return BackendResolution::Single(unsafe { &*slot.vtable });
1182 }
1183 i += 1;
1184 }
1185 return BackendResolution::Unknown;
1186 }
1187 match n {
1188 0 => BackendResolution::NoBackend,
1189 1 => default_vtable()
1190 .map(BackendResolution::Single)
1191 .unwrap_or(BackendResolution::NoBackend),
1192 _ => BackendResolution::Ambiguous,
1193 }
1194}
1195
1196/// Phase 128.A.3 — map a [`BackendResolution`] to its canonical
1197/// [`NrosRmwRet`]. [`BackendResolution::Single`] is *not* an error and
1198/// returns [`NROS_RMW_RET_OK`]; callers needing the vtable should
1199/// pattern-match on the resolution itself.
1200pub fn backend_resolution_to_ret(res: &BackendResolution) -> NrosRmwRet {
1201 match res {
1202 BackendResolution::Single(_) => NROS_RMW_RET_OK,
1203 BackendResolution::NoBackend => NROS_RMW_RET_NO_BACKEND,
1204 BackendResolution::Ambiguous => NROS_RMW_RET_AMBIGUOUS_BACKEND,
1205 BackendResolution::Unknown => NROS_RMW_RET_UNKNOWN_BACKEND,
1206 }
1207}
1208
1209// issue 0331 — `nros_rmw_cffi_set_custom_transport` takes the GENERATED
1210// `nros_transport_ops_t`, not the hand-written `nros_rmw::NrosTransportOps`.
1211//
1212// Under RFC-0054 the C header is the ABI SSoT and Rust consumes the committed
1213// bindgen output. The export used to take the hand-written Rust mirror while
1214// `rmw_transport.h` declared the generated type, so the two could drift and a
1215// C caller's struct layout was only accidentally correct.
1216//
1217// The two are still bridged by a `transmute_copy`, because
1218// `nros_rmw::set_custom_transport` takes the Rust type — but the bridge is
1219// guarded at COMPILE TIME here, so a drift that used to be silent is a build
1220// failure.
1221const _: () = {
1222 assert!(
1223 core::mem::size_of::<generated::nros_transport_ops_t>()
1224 == core::mem::size_of::<nros_rmw::NrosTransportOps>(),
1225 "nros_transport_ops_t and NrosTransportOps must have identical size \
1226 (RFC-0054: the header is the SSoT; regenerate with scripts/gen-abi-bindings.sh)"
1227 );
1228 assert!(
1229 core::mem::align_of::<generated::nros_transport_ops_t>()
1230 == core::mem::align_of::<nros_rmw::NrosTransportOps>(),
1231 "nros_transport_ops_t and NrosTransportOps must have identical alignment"
1232 );
1233};
1234
1235/// Phase 115.A.2 — C entry point for installing a custom transport.
1236///
1237/// Mirrors the Rust-side `nros_rmw::set_custom_transport(Some(...))`
1238/// (or `None` when `ops == NULL`) but returns the canonical
1239/// `rmw_ret_t` codes so non-Rust consumers don't have to
1240/// reach into nros-c's higher-level error enum.
1241///
1242/// The struct's contents are copied internally; the caller may
1243/// stack-allocate. Pass `NULL` to clear the slot.
1244///
1245/// # Safety
1246///
1247/// `ops` must either be `NULL` or point at a valid
1248/// `nros_transport_ops_t` whose four fn pointers stay live for the
1249/// lifetime of the registration (i.e. until a subsequent
1250/// `nros_rmw_cffi_set_custom_transport(NULL)` or a replacement
1251/// install).
1252#[unsafe(no_mangle)]
1253pub unsafe extern "C" fn nros_rmw_cffi_set_custom_transport(
1254 ops: *const generated::nros_transport_ops_t,
1255) -> NrosRmwRet {
1256 if ops.is_null() {
1257 // Clear: ignore any error (None is always accepted).
1258 let _ = unsafe { nros_rmw::set_custom_transport(None) };
1259 return NROS_RMW_RET_OK;
1260 }
1261 // SAFETY: caller guarantees `ops` is valid for one read.
1262 let src = unsafe { &*ops };
1263
1264 // issue 0331 — the generated type's fn-pointer slots are `Option<fn>`
1265 // (C nullability); `NrosTransportOps`' are plain `fn`. The two are
1266 // layout-identical via the null-pointer optimization, so a NULL slot
1267 // transmutes into a `fn` that is UB the moment the runtime calls it.
1268 // Taking the hand-written Rust type at this boundary made that
1269 // unrepresentable-looking but not unreachable — a C caller could always
1270 // pass NULL. Reject it here, before the copy.
1271 if src.open.is_none() || src.close.is_none() || src.write.is_none() || src.read.is_none() {
1272 return NROS_RMW_RET_INVALID_ARGUMENT;
1273 }
1274
1275 // SAFETY: layout equivalence of the two representations is asserted
1276 // above, and every fn slot is non-NULL per the check just made, so this
1277 // reinterpretation can neither silently mismatch nor produce a null `fn`.
1278 let copy: nros_rmw::NrosTransportOps = unsafe { core::mem::transmute_copy(src) };
1279 match unsafe { nros_rmw::set_custom_transport(Some(copy)) } {
1280 Ok(()) => NROS_RMW_RET_OK,
1281 Err(e) => ret_from_error(&e),
1282 }
1283}
1284
1285/// The UNNAMED open's answer to "which backend?" — issue 1050 defect (3).
1286///
1287/// This used to be `default_vtable()`, i.e. **registry slot 0**, described as a
1288/// single-backend fast path. It is not a fast path when two backends are
1289/// registered: it is a silent choice, and on a hosted target nobody makes it.
1290/// A Rust backend compiled into `libnros_cpp.a` registers from its
1291/// `.init_array` ctor, before `main`, and therefore before the image's own
1292/// generated `nros_app_register_backends()` runs — so slot 0 goes to whichever
1293/// backend the ARCHIVE happens to carry, not to the one the image declared. A
1294/// PX4 module declaring `BACKENDS uorb` opened zenoh that way.
1295///
1296/// `resolve_backend(None)` is the policy phase-128.A.3 already wrote for this
1297/// exact question, and `Executor::open_in` has consulted it since. It did not
1298/// reach here, so the tree carried TWO answers to one question and only one of
1299/// them refused an ambiguous registry — which is why the C++ surface was safe
1300/// and the C surface (`nros::internals::open_session` → [`CffiRmw::open`]) was
1301/// not. There is one answer now.
1302///
1303/// `InvalidConfig`, not `InvalidArgument`, for the ambiguous case: nothing has
1304/// been connected at this point, and reporting an unresolvable selection as a
1305/// transport failure is what sent issue 1050 looking for a missing router. The
1306/// empty-registry case keeps `InvalidArgument`, which is what it always
1307/// answered.
1308fn get_vtable() -> Result<&'static NrosRmwVtable, TransportError> {
1309 match resolve_backend(None) {
1310 BackendResolution::Single(vtable) => Ok(vtable),
1311 BackendResolution::Ambiguous => {
1312 nros_log::nros_error!(
1313 nros_log::get_logger("nros_rmw_cffi"),
1314 "more than one RMW backend is registered and this open named \
1315 none; select one (hosted: $NROS_RMW=<name>; embedded: the \
1316 baked NROS_ENTRY_RMW rung), or open per-backend sessions"
1317 );
1318 Err(TransportError::InvalidConfig)
1319 }
1320 // `Unknown` is unreachable for a `None` selector; it collapses with
1321 // `NoBackend` rather than growing an arm that cannot be observed.
1322 BackendResolution::NoBackend | BackendResolution::Unknown => {
1323 Err(TransportError::InvalidArgument)
1324 }
1325 }
1326}
1327
1328// ============================================================================
1329// Helper: null-terminated string on the stack
1330// ============================================================================
1331
1332/// Write a Rust `&str` as a null-terminated byte sequence into a fixed buffer.
1333/// Returns a pointer to the buffer start (as C `char*`, matching the
1334/// generated ABI's string parameters).
1335fn to_c_str<const N: usize>(s: &str, buf: &mut [u8; N]) -> *const core::ffi::c_char {
1336 let len = s.len().min(N - 1);
1337 buf[..len].copy_from_slice(&s.as_bytes()[..len]);
1338 buf[len] = 0;
1339 buf.as_ptr().cast()
1340}
1341
1342/// Inverse of [`to_c_str`] — read a null-terminated byte buffer back
1343/// as a `&str`, stopping at the first NUL byte. Used by the
1344/// `topic_name()` / `type_name()` / `node_name()` accessors on the
1345/// `Cffi*` types so callers can introspect without round-tripping
1346/// through the vtable. Phase 102.5.
1347fn cstr_buf_to_str<const N: usize>(buf: &[u8; N]) -> &str {
1348 let len = buf.iter().position(|&b| b == 0).unwrap_or(N);
1349 // The buffers are written via `to_c_str` from a `&str`, so the
1350 // bytes between [..len] are guaranteed valid UTF-8. `from_utf8`
1351 // handles the (impossible) corruption case by returning empty.
1352 core::str::from_utf8(&buf[..len]).unwrap_or("")
1353}
1354
1355// ============================================================================
1356// Node table (phase-376 W5/B1)
1357// ============================================================================
1358//
1359// A SIDE table, not a field on `CffiSession`, for the same reason
1360// `MESSAGE_INFO_TABLE` above is one: the session's size is an ABI surface. It
1361// is what every C and C++ `_opaque` buffer is sized from, through a build-time
1362// probe that has to agree across every coordinate, and the guards that catch a
1363// disagreement are compile-time asserts in `nros-c` (issue 0472). Per-node
1364// bookkeeping is runtime state, not part of the session's shape — putting four
1365// name buffers inside the session grew `_opaque` by ~544 bytes in every C
1366// consumer and tripped those guards, which is the machinery working correctly
1367// and saying "this does not belong here".
1368
1369/// Distinct `(session, name, namespace)` triples the shim tracks. Raise with
1370/// `NROS_RMW_MAX_NODES`; the default mirrors the executor's `MAX_NODES`.
1371pub const MAX_NODES: usize = parse_env_usize(
1372 env!("NROS_RMW_MAX_NODES"),
1373 "NROS_RMW_MAX_NODES must be a decimal integer",
1374);
1375
1376struct NodeSlot {
1377 /// The owning session's `backend_data`, or 0 when free.
1378 session_key: portable_atomic::AtomicUsize,
1379 name: UnsafeCell<[u8; NODE_NAME_BUF_LEN]>,
1380 namespace_: UnsafeCell<[u8; NODE_NAME_BUF_LEN]>,
1381 backend_data: UnsafeCell<*mut c_void>,
1382}
1383
1384impl NodeSlot {
1385 const fn empty() -> Self {
1386 Self {
1387 session_key: portable_atomic::AtomicUsize::new(0),
1388 name: UnsafeCell::new([0u8; NODE_NAME_BUF_LEN]),
1389 namespace_: UnsafeCell::new([0u8; NODE_NAME_BUF_LEN]),
1390 backend_data: UnsafeCell::new(core::ptr::null_mut()),
1391 }
1392 }
1393}
1394
1395// SAFETY: a slot is claimed by a CAS on `session_key` from 0, and only the
1396// claiming thread writes its cells before any other reader can match it (a
1397// reader matches on `session_key` AND the names, which are written first).
1398// Entity creation is a setup-time operation on the executor thread.
1399unsafe impl Sync for NodeSlot {}
1400
1401static NODE_TABLE: [NodeSlot; MAX_NODES] = {
1402 #[allow(clippy::declare_interior_mutable_const)]
1403 const E: NodeSlot = NodeSlot::empty();
1404 [E; MAX_NODES]
1405};
1406
1407/// Release every node this session claimed. Called from `destroy_session`: a
1408/// static table that is never reclaimed would let a long-running image that
1409/// opens and closes sessions exhaust it.
1410/// Hand this session's node slots back, telling the backend first.
1411///
1412/// Issue 0800 — `create_node` was dispatched and `destroy_node` never was, so a
1413/// backend that allocated state in the create leaked it on every session close:
1414/// the shim forgot the node and the backend was never told it was gone. Nothing
1415/// caught it because `destroy_node` had no producer AND no consumer, which is
1416/// the state that reads as "optional slot" and is indistinguishable from
1417/// "nobody wired the other half".
1418///
1419/// Order matters: the nodes go before `destroy_session`, because a backend's
1420/// node state hangs off its session state.
1421fn release_session_nodes(
1422 session_key: usize,
1423 vtable: &'static NrosRmwVtable,
1424 session_view: &mut NrosRmwSession,
1425) {
1426 if session_key == 0 {
1427 return;
1428 }
1429 for slot in NODE_TABLE.iter() {
1430 if slot.session_key.load(Ordering::Acquire) != session_key {
1431 continue;
1432 }
1433 // SAFETY: written once by the claiming thread before the slot became
1434 // findable, and this session owns it.
1435 let backend_data = unsafe { *slot.backend_data.get() };
1436 if let Some(destroy) = vtable.destroy_node
1437 && !backend_data.is_null()
1438 {
1439 let mut view = node_view_of(slot, session_view);
1440 // SAFETY: `view` describes a node this session created through
1441 // `create_node`, and the session is still open.
1442 let ret = unsafe { destroy(&mut view) };
1443 if ret != NROS_RMW_RET_OK {
1444 nros_log::nros_error!(
1445 nros_log::get_logger("nros_rmw_cffi"),
1446 "destroy_node failed with {}; the backend may still hold node state",
1447 ret
1448 );
1449 }
1450 }
1451 // SAFETY: same slot ownership as above; cleared before the slot is
1452 // published as free so a re-claim cannot see a stale pointer.
1453 unsafe { *slot.backend_data.get() = core::ptr::null_mut() };
1454 slot.session_key.store(0, Ordering::Release);
1455 }
1456}
1457
1458fn slot_str(cell: &UnsafeCell<[u8; NODE_NAME_BUF_LEN]>) -> &str {
1459 // SAFETY: cells are written once by the thread that claimed the slot,
1460 // before `session_key` makes it findable.
1461 cstr_buf_to_str(unsafe { &*cell.get() })
1462}
1463
1464fn find_node_slot(key: usize, name: &str, namespace: &str) -> Option<&'static NodeSlot> {
1465 NODE_TABLE.iter().find(|slot| {
1466 slot.session_key.load(Ordering::Acquire) == key
1467 && slot_str(&slot.name) == name
1468 && slot_str(&slot.namespace_) == namespace
1469 })
1470}
1471
1472fn claim_node_slot(key: usize, name: &str, namespace: &str) -> Option<&'static NodeSlot> {
1473 for slot in NODE_TABLE.iter() {
1474 if slot
1475 .session_key
1476 .compare_exchange(0, key, Ordering::AcqRel, Ordering::Acquire)
1477 .is_ok()
1478 {
1479 // SAFETY: the CAS above makes this thread the slot's owner, and a
1480 // reader cannot match it until the names are in place.
1481 unsafe {
1482 let _ = to_c_str(name, &mut *slot.name.get());
1483 let _ = to_c_str(namespace, &mut *slot.namespace_.get());
1484 *slot.backend_data.get() = core::ptr::null_mut();
1485 }
1486 return Some(slot);
1487 }
1488 }
1489 None
1490}
1491
1492fn node_view_of(slot: &'static NodeSlot, session_view: &mut NrosRmwSession) -> NrosRmwNode {
1493 NrosRmwNode {
1494 name: slot.name.get().cast(),
1495 namespace_: slot.namespace_.get().cast(),
1496 session: session_view as *mut NrosRmwSession,
1497 _reserved: [0u8; 8],
1498 // SAFETY: written once by the claiming thread before use.
1499 backend_data: unsafe { *slot.backend_data.get() },
1500 }
1501}
1502
1503// ============================================================================
1504// CffiSession
1505// ============================================================================
1506//
1507// Storage discipline:
1508// * Each Cffi* struct owns null-terminated name buffers as inline
1509// arrays. The C-side typed entity struct is rebuilt fresh on every
1510// FFI call via `make_*_view`, so move-invalidation of pointers
1511// into the buffer is impossible — the pointer always points to the
1512// *current* address of the buffer, computed at call time.
1513// * The backend writes `backend_data` (and `can_loan_messages` for
1514// pub/sub entities)
1515// into the FFI view; we copy the writes back into the Cffi*
1516// struct's fields after the call.
1517// * Strings ARE immutable for the entity's lifetime, so backends that
1518// stash the topic_name pointer for diagnostics see stable storage
1519// *as long as the Cffi* struct is not moved.* The Phase 102.4
1520// contract is "do not move a Cffi* struct after construction" —
1521// nano-ros embeds them inside the executor arena, which doesn't
1522// relocate.
1523
1524const NAME_BUF_LEN: usize = 256;
1525/// Node name / namespace storage. 64, not `NAME_BUF_LEN`: the executor bounds
1526/// both at `heapless::String<64>`, so a name that reached us through it cannot
1527/// be longer, and four nodes of two 256-byte buffers would put 2 KiB of mostly
1528/// zeroes in every session on an MCU.
1529const NODE_NAME_BUF_LEN: usize = 64;
1530/// Per-string bound for a marshalled session property. 256 so a TLS
1531/// certificate PATH fits; anything longer is refused rather than truncated.
1532const SESSION_PROPERTY_BUF_LEN: usize = 256;
1533/// `usize` spelling of the header's `RMW_SESSION_MAX_PROPERTIES` (RFC-0054:
1534/// the header is the SSoT; bindgen emits it as `i32`).
1535const MAX_SESSION_PROPERTIES: usize = generated::RMW_SESSION_MAX_PROPERTIES as usize;
1536
1537const HASH_BUF_LEN: usize = 128;
1538
1539/// Session backed by a C vtable.
1540pub struct CffiSession {
1541 vtable: &'static NrosRmwVtable,
1542 /// Borrowed-pointer storage for `node_name`. Outlives the session.
1543 node_name_buf: [u8; NAME_BUF_LEN],
1544 /// Borrowed-pointer storage for `namespace_`. Empty for now —
1545 /// `RmwConfig` does not yet carry a namespace through the cffi
1546 /// path; reserved for future use.
1547 namespace_buf: [u8; NAME_BUF_LEN],
1548 /// Backend-private state, written by `vtable.create_session`.
1549 backend_data: *mut c_void,
1550 /// The domain this session was opened on. Kept because the entity paths
1551 /// need it and could not otherwise recover it: `create_session` consumed
1552 /// it and nothing stored it, so a caller with no support context silently
1553 /// fell back to domain 0 while the session itself was on another domain
1554 /// (issue 0801).
1555 domain_id: u32,
1556}
1557
1558/// phase-381 W5/W6 — the C graph visitor, turned back into a Rust closure call.
1559///
1560/// `ctx` is a `*mut &mut dyn FnMut(...)`, which is how the borrowed closure
1561/// crosses the C boundary. The strings are BORROWED for this call only, which
1562/// is the contract on both sides.
1563///
1564/// A name that is not valid UTF-8 is SKIPPED rather than lossily converted: a
1565/// mangled node name is a different, plausible node.
1566///
1567/// # Safety
1568/// Called only by a backend slot this crate handed `ctx` to.
1569unsafe extern "C" fn node_visit_trampoline(
1570 ctx: *mut core::ffi::c_void,
1571 node_name: *const core::ffi::c_char,
1572 node_namespace: *const core::ffi::c_char,
1573 enclave: *const core::ffi::c_char,
1574) -> bool {
1575 if ctx.is_null() || node_name.is_null() || node_namespace.is_null() {
1576 return true; // skip this entry, keep enumerating
1577 }
1578 let cb = unsafe { &mut *(ctx as *mut &mut dyn FnMut(&str, &str, Option<&str>) -> bool) };
1579 let (Ok(name), Ok(ns)) = (
1580 unsafe { core::ffi::CStr::from_ptr(node_name) }.to_str(),
1581 unsafe { core::ffi::CStr::from_ptr(node_namespace) }.to_str(),
1582 ) else {
1583 return true;
1584 };
1585 let enc = if enclave.is_null() {
1586 None
1587 } else {
1588 unsafe { core::ffi::CStr::from_ptr(enclave) }.to_str().ok()
1589 };
1590 cb(name, ns, enc)
1591}
1592
1593/// Longest node name / namespace / topic this seam passes to a backend slot.
1594///
1595/// A longer one is REFUSED (`BufferTooSmall`), not truncated: a truncated node
1596/// name is a different, plausible node, and this seam's whole job is answering
1597/// "which node".
1598const GRAPH_NAME_CAP: usize = 256;
1599
1600/// phase-381 — the C endpoint-info visitor, back into a Rust closure call.
1601///
1602/// Mirrors [`names_and_types_visit_trampoline`]. `qos_profile` is deliberately
1603/// dropped: `GraphEndpointInfo` carries no `qos` field, because no backend can
1604/// fill it with a GRANTED profile today and reporting the requested one would
1605/// be the plausible wrong answer the slot exists to avoid.
1606///
1607/// # Safety
1608/// Called only by a backend slot this crate handed `ctx` to.
1609unsafe extern "C" fn endpoint_info_visit_trampoline(
1610 ctx: *mut core::ffi::c_void,
1611 info: *const generated::rmw_topic_endpoint_info_t,
1612) -> bool {
1613 if ctx.is_null() || info.is_null() {
1614 return true; // skip this entry, keep enumerating
1615 }
1616 let info = unsafe { &*info };
1617 let cb = unsafe { &mut *(ctx as *mut &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool) };
1618
1619 // Each borrowed string is skipped rather than lossily converted, for the
1620 // same reason as the names-and-types side.
1621 let cstr = |p: *const core::ffi::c_char| -> Option<&str> {
1622 if p.is_null() {
1623 None
1624 } else {
1625 unsafe { core::ffi::CStr::from_ptr(p) }.to_str().ok()
1626 }
1627 };
1628 let (Some(node_name), Some(node_namespace), Some(topic_type)) = (
1629 cstr(info.node_name),
1630 cstr(info.node_namespace),
1631 cstr(info.topic_type),
1632 ) else {
1633 return true;
1634 };
1635
1636 let view = GraphEndpointInfo {
1637 node_name,
1638 node_namespace,
1639 topic_type,
1640 is_publisher: info.endpoint_type == generated::rmw_endpoint_type_t::RMW_ENDPOINT_PUBLISHER,
1641 endpoint_gid: info.endpoint_gid.data,
1642 };
1643 cb(&view)
1644}
1645
1646/// phase-381 — the C names-and-types visitor, back into a Rust closure call.
1647///
1648/// # Safety
1649/// Called only by a backend slot this crate handed `ctx` to.
1650unsafe extern "C" fn names_and_types_visit_trampoline(
1651 ctx: *mut core::ffi::c_void,
1652 name: *const core::ffi::c_char,
1653 types: *const *const core::ffi::c_char,
1654 types_count: usize,
1655) -> bool {
1656 if ctx.is_null() || name.is_null() {
1657 return true; // skip this entry, keep enumerating
1658 }
1659 let cb = unsafe { &mut *(ctx as *mut &mut dyn FnMut(&str, &[&str]) -> bool) };
1660 let Ok(name) = (unsafe { core::ffi::CStr::from_ptr(name) }).to_str() else {
1661 return true;
1662 };
1663 // Bounded on the stack: the C side owns the array for this call only, and a
1664 // type that is not valid UTF-8 is SKIPPED rather than lossily converted — a
1665 // mangled type name is a different, plausible type.
1666 const TYPES_MAX: usize = 8;
1667 let mut buf: [&str; TYPES_MAX] = [""; TYPES_MAX];
1668 let mut n = 0usize;
1669 if !types.is_null() {
1670 for i in 0..types_count.min(TYPES_MAX) {
1671 let p = unsafe { *types.add(i) };
1672 if p.is_null() {
1673 continue;
1674 }
1675 if let Ok(t) = (unsafe { core::ffi::CStr::from_ptr(p) }).to_str() {
1676 buf[n] = t;
1677 n += 1;
1678 }
1679 }
1680 }
1681 cb(name, &buf[..n])
1682}
1683
1684/// Shared body for `count_publishers` / `count_subscribers`.
1685fn count_on_topic(
1686 view: &NrosRmwSession,
1687 f: unsafe extern "C" fn(
1688 *const NrosRmwSession,
1689 *const core::ffi::c_char,
1690 *mut usize,
1691 ) -> NrosRmwRet,
1692 topic_name: &str,
1693) -> Result<usize, TransportError> {
1694 // NUL-terminate on the stack; the C side borrows for the call only.
1695 const NAME_MAX: usize = 256;
1696 if topic_name.len() >= NAME_MAX {
1697 return Err(TransportError::InvalidArgument);
1698 }
1699 let mut buf = [0u8; NAME_MAX];
1700 buf[..topic_name.len()].copy_from_slice(topic_name.as_bytes());
1701 let mut out: usize = 0;
1702 let rc = unsafe {
1703 f(
1704 view as *const NrosRmwSession,
1705 buf.as_ptr() as *const core::ffi::c_char,
1706 &mut out,
1707 )
1708 };
1709 if rc == NROS_RMW_RET_OK {
1710 Ok(out)
1711 } else {
1712 Err(error_from_ret(rc))
1713 }
1714}
1715
1716impl CffiSession {
1717 /// Domain this session was opened on. Authoritative: it is the value the
1718 /// backend actually got, not a re-derivation that can disagree with it.
1719 pub fn domain_id(&self) -> u32 {
1720 self.domain_id
1721 }
1722
1723 /// RFC-0088 D4 / phase-421 W2 — the serialization format THIS session's
1724 /// backend speaks, as a NUL-terminated C string with `'static` lifetime,
1725 /// or NULL when the backend does not declare one.
1726 ///
1727 /// **Not** `<Self as Session>::SERIALIZATION_FORMAT`. That const is the
1728 /// trait default, `"cdr"`, and it is a lie for this type specifically:
1729 /// `CffiSession` is the one session that does not know its own backend at
1730 /// compile time — the vtable arrives at run time through
1731 /// `nros_rmw_cffi_register_named`, and an image may register several. The
1732 /// per-session answer therefore has to come from the vtable, which is the
1733 /// whole reason the slot got a body.
1734 ///
1735 /// A NULL slot answers NULL rather than `"cdr"`: a backend that declines
1736 /// to say what it speaks has not said `"cdr"`, and guessing on its behalf
1737 /// is the mistake `get_implementation_identifier`'s doc made for two
1738 /// phases (corrected phase-393 W2). Every in-tree backend fills the slot,
1739 /// so NULL means a foreign or pre-phase-421 vtable.
1740 pub fn serialization_format_cstr(&self) -> *const core::ffi::c_char {
1741 match self.vtable.get_serialization_format {
1742 // SAFETY: the slot is a non-NULL fn pointer a registered backend
1743 // installed; it takes no arguments and returns static storage.
1744 Some(f) => unsafe { f() },
1745 None => core::ptr::null(),
1746 }
1747 }
1748
1749 /// [`serialization_format_cstr`](Self::serialization_format_cstr) as a
1750 /// Rust string. `None` for a NULL slot or a non-UTF-8 answer.
1751 pub fn serialization_format(&self) -> Option<&'static str> {
1752 let ptr = self.serialization_format_cstr();
1753 if ptr.is_null() {
1754 return None;
1755 }
1756 // SAFETY: a backend's format name is a `'static` NUL-terminated string
1757 // literal — the slot's contract, and what every in-tree body returns.
1758 unsafe { core::ffi::CStr::from_ptr(ptr) }.to_str().ok()
1759 }
1760
1761 fn make_view(&mut self) -> NrosRmwSession {
1762 NrosRmwSession {
1763 node_name: self.node_name_buf.as_ptr().cast(),
1764 namespace_: self.namespace_buf.as_ptr().cast(),
1765 _reserved: [0u8; 8],
1766 backend_data: self.backend_data,
1767 }
1768 }
1769
1770 /// Phase 376 W5/B1 — find or create the node an entity belongs to, and
1771 /// return a view a `create_*` slot can take.
1772 ///
1773 /// This replaces `entity_view`, which fabricated a per-call
1774 /// `NrosRmwSession` whose `node_name` carried the entity's owning node.
1775 /// That worked and cost no ABI change, which is why phase-268 chose it —
1776 /// but it meant a backend learned about a node by reading a STRING off a
1777 /// session it was also using for transport state, and had to re-derive the
1778 /// set of nodes itself. zenoh does precisely that, linear-scanning declared
1779 /// liveliness tokens to answer "have I seen this node before".
1780 ///
1781 /// Now the runtime owns that question: `create_node` fires once per
1782 /// distinct `(name, namespace)`, which is only true because
1783 /// `Executor::create_node` deduplicates too (W5/B1.a).
1784 ///
1785 /// The returned `rmw_node_t` borrows `session_view` and the table's name
1786 /// cells, so the caller keeps `session_view` alive across the slot call —
1787 /// the same discipline `entity_view` documented, moved one level up.
1788 fn node_slot(
1789 &mut self,
1790 node_name: Option<&str>,
1791 namespace: &str,
1792 session_view: &mut NrosRmwSession,
1793 ) -> Result<NrosRmwNode, TransportError> {
1794 let key = self.backend_data as usize;
1795 if key == 0 {
1796 return Err(TransportError::InvalidArgument);
1797 }
1798
1799 // No node identity on the entity (direct-RMW / single-node path): fall
1800 // back to the session's own open-time name, which is what phase-268's
1801 // fallback did and keeps a one-node image working unchanged.
1802 let mut scratch = [0u8; NODE_NAME_BUF_LEN];
1803 let name: &str = match node_name {
1804 Some(n) if !n.is_empty() => n,
1805 _ => {
1806 let owned = self.node_name();
1807 let len = owned.len().min(NODE_NAME_BUF_LEN - 1);
1808 scratch[..len].copy_from_slice(&owned.as_bytes()[..len]);
1809 cstr_buf_to_str(&scratch)
1810 }
1811 };
1812
1813 // Truncating would MERGE two distinct nodes into one record and then
1814 // declare the wrong identity on the graph, so it is refused. The
1815 // executor bounds both at 64, so a name that arrived through it fits.
1816 if name.len() >= NODE_NAME_BUF_LEN || namespace.len() >= NODE_NAME_BUF_LEN {
1817 return Err(TransportError::InvalidArgument);
1818 }
1819
1820 if let Some(slot) = find_node_slot(key, name, namespace) {
1821 return Ok(node_view_of(slot, session_view));
1822 }
1823
1824 let slot = claim_node_slot(key, name, namespace).ok_or(TransportError::ConnectionFailed)?;
1825
1826 // A backend with no `create_node` keeps working: the node is then a
1827 // pure identity carrier with a NULL `backend_data`, which is what every
1828 // backend that has not implemented the slot already sees.
1829 if let Some(create) = self.vtable.create_node {
1830 let mut view = node_view_of(slot, session_view);
1831 let ret = unsafe {
1832 create(
1833 session_view as *mut NrosRmwSession,
1834 view.name,
1835 view.namespace_,
1836 &mut view,
1837 )
1838 };
1839 if ret != NROS_RMW_RET_OK {
1840 slot.session_key.store(0, Ordering::Release);
1841 return Err(error_from_ret(ret));
1842 }
1843 // SAFETY: this thread claimed the slot above and nothing else
1844 // writes it.
1845 unsafe { *slot.backend_data.get() = view.backend_data };
1846 }
1847 Ok(node_view_of(slot, session_view))
1848 }
1849
1850 /// Node name passed at session-open time.
1851 pub fn node_name(&self) -> &str {
1852 cstr_buf_to_str(&self.node_name_buf)
1853 }
1854
1855 /// Open a new session via the **default** registered vtable
1856 /// (first entry in the registry — the RMW_IMPLEMENTATION-style
1857 /// fast path for single-backend builds).
1858 ///
1859 /// For explicit backend selection in multi-backend (bridge)
1860 /// binaries, use [`open_named`](Self::open_named).
1861 pub fn open(
1862 locator: &str,
1863 mode: u8,
1864 domain_id: u32,
1865 node_name: &str,
1866 ) -> Result<Self, TransportError> {
1867 let vtable = get_vtable()?;
1868 Self::open_with_vtable(
1869 vtable,
1870 locator,
1871 mode,
1872 domain_id,
1873 node_name,
1874 core::ptr::null(),
1875 )
1876 }
1877
1878 /// phase-206 W3 — open a session carrying backend-specific configuration
1879 /// properties (`RmwConfig::properties` across the C seam).
1880 ///
1881 /// This is the rung that was missing. `RmwConfig` has carried
1882 /// `properties` since it existed and every Rust backend reads them, but
1883 /// nothing between the runtime and the vtable passed them on: this
1884 /// function's non-properties sibling handed `create_session` a NULL
1885 /// options pointer, and the Rust-backend adapter on the far side built
1886 /// `properties: &[]` unconditionally. So the only way to set a zenoh
1887 /// `listen` endpoint, a TLS certificate or a scouting timeout was to build
1888 /// an `RmwConfig` by hand in hosted Rust — a C or C++ image could state
1889 /// none of it, on any platform.
1890 ///
1891 /// Refused, never truncated: more than `RMW_SESSION_MAX_PROPERTIES`
1892 /// entries, an empty key or value, or either longer than
1893 /// `SESSION_PROPERTY_BUF_LEN`. All are `TransportError::InvalidArgument`.
1894 pub fn open_with_properties(
1895 locator: &str,
1896 mode: u8,
1897 domain_id: u32,
1898 node_name: &str,
1899 properties: &[(&str, &str)],
1900 ) -> Result<Self, TransportError> {
1901 let vtable = get_vtable()?;
1902 if properties.is_empty() {
1903 return Self::open_with_vtable(
1904 vtable,
1905 locator,
1906 mode,
1907 domain_id,
1908 node_name,
1909 core::ptr::null(),
1910 );
1911 }
1912 Self::open_marshalling_properties(vtable, locator, mode, domain_id, node_name, properties)
1913 }
1914
1915 /// The property-carrying half of [`open_with_properties`], kept in its own
1916 /// never-inlined frame so the marshalling buffers (up to
1917 /// `RMW_SESSION_MAX_PROPERTIES` × 2 × `SESSION_PROPERTY_BUF_LEN`) are only
1918 /// on the stack of a call that actually has properties to carry. An MCU
1919 /// image that configures nothing pays nothing.
1920 #[inline(never)]
1921 fn open_marshalling_properties(
1922 vtable: &'static NrosRmwVtable,
1923 locator: &str,
1924 mode: u8,
1925 domain_id: u32,
1926 node_name: &str,
1927 properties: &[(&str, &str)],
1928 ) -> Result<Self, TransportError> {
1929 if properties.len() > MAX_SESSION_PROPERTIES {
1930 return Err(TransportError::InvalidArgument);
1931 }
1932 let mut key_bufs = [[0u8; SESSION_PROPERTY_BUF_LEN]; MAX_SESSION_PROPERTIES];
1933 let mut val_bufs = [[0u8; SESSION_PROPERTY_BUF_LEN]; MAX_SESSION_PROPERTIES];
1934 let mut entries = [rmw_session_property_t {
1935 key: core::ptr::null(),
1936 value: core::ptr::null(),
1937 }; MAX_SESSION_PROPERTIES];
1938 for (i, (key, value)) in properties.iter().enumerate() {
1939 // Empty or over-long is REFUSED. `to_c_str` truncates, which for a
1940 // name is a wrong-but-visible entity and for a TLS certificate
1941 // path is a session that fails somewhere unrelated.
1942 if key.is_empty()
1943 || value.is_empty()
1944 || key.len() >= SESSION_PROPERTY_BUF_LEN
1945 || value.len() >= SESSION_PROPERTY_BUF_LEN
1946 {
1947 return Err(TransportError::InvalidArgument);
1948 }
1949 key_bufs[i][..key.len()].copy_from_slice(key.as_bytes());
1950 val_bufs[i][..value.len()].copy_from_slice(value.as_bytes());
1951 entries[i] = rmw_session_property_t {
1952 key: key_bufs[i].as_ptr().cast(),
1953 value: val_bufs[i].as_ptr().cast(),
1954 };
1955 }
1956 let options = NrosRmwSessionOptions {
1957 localhost_only: 0,
1958 _reserved: [0u8; 7],
1959 enclave: core::ptr::null(),
1960 properties: entries.as_ptr(),
1961 property_count: properties.len(),
1962 };
1963 Self::open_with_vtable(vtable, locator, mode, domain_id, node_name, &options)
1964 }
1965
1966 /// Phase 104.C.1 — open a new session against a named backend.
1967 /// Resolves `rmw_name` against the registry (Phase 104.B.2),
1968 /// returns `Err(TransportError::InvalidArgument)` if no backend
1969 /// is registered under that name.
1970 pub fn open_named(
1971 rmw_name: &str,
1972 locator: &str,
1973 mode: u8,
1974 domain_id: u32,
1975 node_name: &str,
1976 ) -> Result<Self, TransportError> {
1977 // C-string-marshal `rmw_name` on the stack — registry lookup
1978 // expects NUL-terminated UTF-8.
1979 let mut name_buf = [0u8; BACKEND_NAME_MAX];
1980 if rmw_name.len() >= BACKEND_NAME_MAX {
1981 return Err(TransportError::InvalidArgument);
1982 }
1983 name_buf[..rmw_name.len()].copy_from_slice(rmw_name.as_bytes());
1984 // name_buf[rmw_name.len()] is already 0.
1985 let raw = unsafe { nros_rmw_cffi_lookup(name_buf.as_ptr() as *const _) };
1986 if raw.is_null() {
1987 return Err(TransportError::InvalidArgument);
1988 }
1989 // SAFETY: registry-issued pointer; valid for the program's lifetime.
1990 let vtable = unsafe { &*raw };
1991 Self::open_with_vtable(
1992 vtable,
1993 locator,
1994 mode,
1995 domain_id,
1996 node_name,
1997 core::ptr::null(),
1998 )
1999 }
2000
2001 /// [`open_named`](Self::open_named) carrying backend-specific
2002 /// configuration properties — see [`open_with_properties`](Self::open_with_properties).
2003 pub fn open_named_with_properties(
2004 rmw_name: &str,
2005 locator: &str,
2006 mode: u8,
2007 domain_id: u32,
2008 node_name: &str,
2009 properties: &[(&str, &str)],
2010 ) -> Result<Self, TransportError> {
2011 let mut name_buf = [0u8; BACKEND_NAME_MAX];
2012 if rmw_name.len() >= BACKEND_NAME_MAX {
2013 return Err(TransportError::InvalidArgument);
2014 }
2015 name_buf[..rmw_name.len()].copy_from_slice(rmw_name.as_bytes());
2016 let raw = unsafe { nros_rmw_cffi_lookup(name_buf.as_ptr() as *const _) };
2017 if raw.is_null() {
2018 return Err(TransportError::InvalidArgument);
2019 }
2020 // SAFETY: registry-issued pointer; valid for the program's lifetime.
2021 let vtable = unsafe { &*raw };
2022 if properties.is_empty() {
2023 return Self::open_with_vtable(
2024 vtable,
2025 locator,
2026 mode,
2027 domain_id,
2028 node_name,
2029 core::ptr::null(),
2030 );
2031 }
2032 Self::open_marshalling_properties(vtable, locator, mode, domain_id, node_name, properties)
2033 }
2034
2035 fn open_with_vtable(
2036 vtable: &'static NrosRmwVtable,
2037 locator: &str,
2038 mode: u8,
2039 domain_id: u32,
2040 node_name: &str,
2041 options: *const NrosRmwSessionOptions,
2042 ) -> Result<Self, TransportError> {
2043 let mut loc_buf = [0u8; NAME_BUF_LEN];
2044 let loc_ptr = to_c_str(locator, &mut loc_buf);
2045
2046 let mut session = Self {
2047 vtable,
2048 node_name_buf: [0u8; NAME_BUF_LEN],
2049 namespace_buf: [0u8; NAME_BUF_LEN],
2050 backend_data: core::ptr::null_mut(),
2051 domain_id,
2052 };
2053 let _ = to_c_str(node_name, &mut session.node_name_buf);
2054
2055 let mut view = NrosRmwSession {
2056 node_name: session.node_name_buf.as_ptr().cast(),
2057 namespace_: session.namespace_buf.as_ptr().cast(),
2058 _reserved: [0u8; 8],
2059 backend_data: core::ptr::null_mut(),
2060 };
2061 let ret = unsafe {
2062 (vtable.create_session.expect("rmw vtable: create_session"))(
2063 loc_ptr,
2064 mode,
2065 domain_id,
2066 session.node_name_buf.as_ptr().cast(),
2067 // issue 0808 — NULL is "every default"; phase-206 W3 made this
2068 // a real argument. A caller reaches the backend's own
2069 // configuration through this struct rather than through a
2070 // locator it would have to parse
2071 // ([`open_with_properties`](Self::open_with_properties)).
2072 options,
2073 &mut view,
2074 )
2075 };
2076 // Phase 156.4 — diagnostic for bridge runtime
2077 // ConnectionFailed investigation. Logs the raw ret +
2078 // post-open backend_data state so callers see which of
2079 // the two failure paths fired. Gated on env var so
2080 // production traffic stays quiet.
2081 // issue 0589 — `nros_log`, never std stdio (fatal on Zephyr
2082 // native_sim). The env gate stays: it decides whether to FORMAT, which
2083 // is the cost worth avoiding on a hot open path; the level would only
2084 // decide whether to emit.
2085 #[cfg(feature = "std")]
2086 if std::env::var_os("NROS_RMW_TRACE_OPEN").is_some() {
2087 nros_log::nros_info!(
2088 nros_log::get_logger("nros_rmw_cffi"),
2089 "open: locator={locator:?} mode={mode} ret={ret} backend_data={:p}",
2090 view.backend_data
2091 );
2092 }
2093 if ret != NROS_RMW_RET_OK {
2094 return Err(error_from_ret(ret));
2095 }
2096 if view.backend_data.is_null() {
2097 return Err(TransportError::ConnectionFailed);
2098 }
2099 session.backend_data = view.backend_data;
2100 Ok(session)
2101 }
2102}
2103
2104/// Report a QoS DOWNGRADE — the granted profile differing from the requested
2105/// one — once per entity, at creation.
2106///
2107/// Issue 0823 gave the runtime the ability to READ the granted QoS. This is the
2108/// half that makes it a diagnostic. Silence from a QoS mismatch is
2109/// indistinguishable from a topic-name typo, a domain split (issue 0801) and a
2110/// discovery failure (issue 0803); all three were run to ground this month and
2111/// each cost hours precisely because nothing separated them. A line naming the
2112/// field that changed separates this one in a sentence.
2113///
2114/// Only a DIFFERENCE is reported. Equality is the common case and printing it
2115/// would bury the signal — the same reason `nros_platform_task_init` only warns
2116/// when the kernel disagreed with the priority it was asked for.
2117///
2118/// Best-effort by construction: a backend with no read-back slot reports
2119/// nothing, which is correct rather than missing (zenoh-pico's QoS is
2120/// per-message flags with no negotiation to read).
2121#[cfg(feature = "alloc")]
2122fn report_qos_downgrade(kind: &str, name: &str, requested: &NrosRmwQos, granted: &NrosRmwQos) {
2123 // The generated constants are `i32` (bindgen) while the struct field is
2124 // `u8`; compare in the constants' type rather than casting them down.
2125 fn reliability(v: u8) -> &'static str {
2126 match v as i32 {
2127 NROS_RMW_RELIABILITY_RELIABLE => "RELIABLE",
2128 NROS_RMW_RELIABILITY_BEST_EFFORT => "BEST_EFFORT",
2129 _ => "?",
2130 }
2131 }
2132 fn durability(v: u8) -> &'static str {
2133 match v as i32 {
2134 NROS_RMW_DURABILITY_TRANSIENT_LOCAL => "TRANSIENT_LOCAL",
2135 NROS_RMW_DURABILITY_VOLATILE => "VOLATILE",
2136 _ => "?",
2137 }
2138 }
2139 if requested.reliability != granted.reliability {
2140 nros_log::nros_warn!(
2141 nros_log::get_logger("nros_rmw_cffi"),
2142 "{kind} `{name}`: asked for reliability {} and the backend granted {}. A RELIABLE \
2143 reader does not match a BEST_EFFORT writer, so this is the usual reason a pair \
2144 goes silent.",
2145 reliability(requested.reliability),
2146 reliability(granted.reliability)
2147 );
2148 }
2149 if requested.durability != granted.durability {
2150 nros_log::nros_warn!(
2151 nros_log::get_logger("nros_rmw_cffi"),
2152 "{kind} `{name}`: asked for durability {} and the backend granted {}.",
2153 durability(requested.durability),
2154 durability(granted.durability)
2155 );
2156 }
2157 if requested.depth != granted.depth && granted.depth != 0 {
2158 nros_log::nros_warn!(
2159 nros_log::get_logger("nros_rmw_cffi"),
2160 "{kind} `{name}`: asked for history depth {} and the backend granted {}.",
2161 requested.depth,
2162 granted.depth
2163 );
2164 }
2165}
2166
2167impl Session for CffiSession {
2168 /// The backend is chosen at run time here, so the trait's compile-time
2169 /// default would be a guess. Ask the vtable, and fall back to the constant
2170 /// only for a backend that installed no body — which reads as "this backend
2171 /// has not said", not as "cdr".
2172 fn serialization_format(&self) -> &'static str {
2173 CffiSession::serialization_format(self).unwrap_or(Self::SERIALIZATION_FORMAT)
2174 }
2175
2176 type Error = TransportError;
2177 type PublisherHandle = CffiPublisher;
2178 type SubscriptionHandle = CffiSubscription;
2179 type ServiceHandle = CffiService;
2180 type ClientHandle = CffiClient;
2181
2182 fn create_publisher(
2183 &mut self,
2184 topic: &TopicInfo,
2185 qos: QoSProfile,
2186 ) -> Result<CffiPublisher, TransportError> {
2187 let mut hash_buf = [0u8; HASH_BUF_LEN];
2188 let hash_ptr = to_c_str(topic.type_hash, &mut hash_buf);
2189 let qos_struct = NrosRmwQos::try_from(qos)?;
2190 // phase-301 (issue 0240) — the express hint travels in the options
2191 // struct, not the QoS profile. Either surface wins: the QoS profile
2192 // field (language APIs) or `TopicInfo::with_tx_express` (direct RMW).
2193 let options = rmw_publisher_options_t {
2194 tx_express: (topic.tx_express || qos.tx_express) as u8,
2195 _reserved: [0u8; 7],
2196 };
2197
2198 let mut pub_state = CffiPublisher {
2199 vtable: self.vtable,
2200 topic_name_buf: [0u8; NAME_BUF_LEN],
2201 type_name_buf: [0u8; NAME_BUF_LEN],
2202 qos: qos_struct,
2203 can_loan_messages: false,
2204 backend_data: core::ptr::null_mut(),
2205 };
2206 let topic_ptr = to_c_str(topic.name, &mut pub_state.topic_name_buf);
2207 let type_ptr = to_c_str(topic.type_name, &mut pub_state.type_name_buf);
2208
2209 let mut view = NrosRmwPublisher {
2210 topic_name: topic_ptr,
2211 type_name: type_ptr,
2212 qos: qos_struct,
2213 can_loan_messages: false,
2214 _reserved: [0u8; 7],
2215 backend_data: core::ptr::null_mut(),
2216 };
2217 // Phase 376 W5/B1 — the entity is created ON ITS NODE. `session_view`
2218 // must outlive the call: `node_view` points the node's `session` field
2219 // at it.
2220 let mut session_view = self.make_view();
2221 let node_view = self.node_slot(topic.node_name, topic.namespace, &mut session_view)?;
2222 let ret = unsafe {
2223 (self
2224 .vtable
2225 .create_publisher
2226 .expect("rmw vtable: create_publisher"))(
2227 &node_view,
2228 // phase-406 W1 — the identity as ONE argument, in upstream's
2229 // position. Built here on the stack: it borrows the same two
2230 // pointers the two loose arguments did, so nothing changed about
2231 // their lifetime, only about how many arguments carry them.
2232 &generated::rmw_message_type_support_t {
2233 type_name: type_ptr,
2234 type_hash: hash_ptr,
2235 },
2236 topic_ptr,
2237 topic.domain_id,
2238 &qos_struct,
2239 &options,
2240 &mut view,
2241 )
2242 };
2243 if ret != NROS_RMW_RET_OK {
2244 return Err(error_from_ret(ret));
2245 }
2246 if view.backend_data.is_null() {
2247 return Err(TransportError::PublisherCreationFailed);
2248 }
2249 pub_state.backend_data = view.backend_data;
2250 // issue 0814 — DERIVED from the vtable, never merely declared.
2251 //
2252 // The field is the same fact as `borrow_loaned_message` being
2253 // non-NULL — the header defines it as "the backend exposes the
2254 // loan_publish / commit_publish primitive" — and a fact with two
2255 // spellings drifts. It had drifted BOTH ways at once:
2256 //
2257 // * UNDER-claim. Nothing wrote `true` anywhere outside a test.
2258 // A C backend writes the field, but `RustBackendAdapter`'s
2259 // generic `create_publisher_trampoline` cannot: it is generic
2260 // over the Session and does not know which vtable it was
2261 // registered under, so it writes only `backend_data`
2262 // (`rust_adapter.rs:541-548`) and leaves the runtime's pre-zero
2263 // (`:1951`) standing. That is not an oversight to fix in the
2264 // trampoline; it is structural. So zenoh built with `lending` —
2265 // whose vtable DOES fill all three loan slots
2266 // (`nros-rmw-zenoh/src/lib.rs:427-432`) and whose loans work —
2267 // reported that it cannot loan.
2268 // * OVER-claim. A backend may write `true` with the slot NULL and
2269 // be believed; the test stub at `:4390` does exactly that.
2270 //
2271 // Neither was doing damage YET, because nothing branches on the
2272 // field (every read is a copy into the C view or an accessor) —
2273 // dispatch keys on the slot at `:2760`. The damage is scheduled:
2274 // the moment anyone implements the branch the header promises,
2275 // zenoh's working loan path silently drops to the copy fallback.
2276 //
2277 // A backend that must refuse a loan for a PARTICULAR entity has not
2278 // lost anything — it returns `NROS_RMW_RET_UNSUPPORTED` from
2279 // `borrow_loaned_message` and `try_lend_slot` surfaces that as an
2280 // error. The per-entity answer belongs on the call, which is
2281 // consulted; not on a flag, which is not.
2282 pub_state.can_loan_messages = pub_state.vtable.borrow_loaned_message.is_some();
2283 // phase-393 W1 — say so when the backend granted something else.
2284 #[cfg(feature = "alloc")]
2285 if let Some(read) = pub_state.vtable.publisher_get_actual_qos {
2286 let mut granted = qos_struct;
2287 let mut v = pub_state.make_view();
2288 // SAFETY: the entity was created above and `v` describes it.
2289 if unsafe { read(&v, &mut granted) } == NROS_RMW_RET_OK {
2290 report_qos_downgrade("publisher", topic.name, &qos_struct, &granted);
2291 }
2292 let _ = &mut v;
2293 }
2294 Ok(pub_state)
2295 }
2296
2297 fn create_subscription(
2298 &mut self,
2299 topic: &TopicInfo,
2300 qos: QoSProfile,
2301 ) -> Result<CffiSubscription, TransportError> {
2302 let mut hash_buf = [0u8; HASH_BUF_LEN];
2303 let hash_ptr = to_c_str(topic.type_hash, &mut hash_buf);
2304 let qos_struct = NrosRmwQos::try_from(qos)?;
2305 // Phase 231 (RFC-0038) / phase-301 (issue 0240) — the receive-buffer
2306 // size hint travels in the options struct so a size-classing backend
2307 // can route its receive storage. A hint, not a policy: oversize
2308 // saturates.
2309 let options = rmw_subscription_options_t {
2310 rx_buffer_hint: topic.rx_buffer_hint.min(u32::MAX as usize) as u32,
2311 _reserved: [0u8; 4],
2312 };
2313
2314 let mut sub_state = CffiSubscription {
2315 vtable: self.vtable,
2316 topic_name_buf: [0u8; NAME_BUF_LEN],
2317 type_name_buf: [0u8; NAME_BUF_LEN],
2318 qos: qos_struct,
2319 can_loan_messages: false,
2320 backend_data: core::ptr::null_mut(),
2321 supports_in_place: false,
2322 pending_status: None,
2323 };
2324 let topic_ptr = to_c_str(topic.name, &mut sub_state.topic_name_buf);
2325 let type_ptr = to_c_str(topic.type_name, &mut sub_state.type_name_buf);
2326
2327 let mut view = NrosRmwSubscription {
2328 topic_name: topic_ptr,
2329 type_name: type_ptr,
2330 qos: qos_struct,
2331 can_loan_messages: false,
2332 _reserved: [0u8; 7],
2333 backend_data: core::ptr::null_mut(),
2334 };
2335 // Phase 376 W5/B1 — the entity is created ON ITS NODE. `session_view`
2336 // must outlive the call: `node_view` points the node's `session` field
2337 // at it.
2338 let mut session_view = self.make_view();
2339 let node_view = self.node_slot(topic.node_name, topic.namespace, &mut session_view)?;
2340 let ret = unsafe {
2341 (self
2342 .vtable
2343 .create_subscription
2344 .expect("rmw vtable: create_subscription"))(
2345 &node_view,
2346 // phase-406 W1 — the identity as ONE argument, in upstream's
2347 // position. Built here on the stack: it borrows the same two
2348 // pointers the two loose arguments did, so nothing changed about
2349 // their lifetime, only about how many arguments carry them.
2350 &generated::rmw_message_type_support_t {
2351 type_name: type_ptr,
2352 type_hash: hash_ptr,
2353 },
2354 topic_ptr,
2355 topic.domain_id,
2356 &qos_struct,
2357 &options,
2358 &mut view,
2359 )
2360 };
2361 if ret != NROS_RMW_RET_OK {
2362 return Err(error_from_ret(ret));
2363 }
2364 if view.backend_data.is_null() {
2365 return Err(TransportError::SubscriberCreationFailed);
2366 }
2367 sub_state.backend_data = view.backend_data;
2368 // issue 0814 — the receive-side twin, derived from its own slot for
2369 // the same reason as the publisher above. `take_loaned_message` is
2370 // NULL in every backend we ship, so this is `false` everywhere today
2371 // — but now it is false BECAUSE no backend fills the slot, which is
2372 // checkable, rather than because nobody remembered to write it.
2373 sub_state.can_loan_messages = sub_state.vtable.take_loaned_message.is_some();
2374 // Phase 231 (RFC-0038) — cache the in-place capability once.
2375 // The capability is the CONJUNCTION of the probe and the slot that
2376 // would serve it (issue 0781). The probe alone was the whole answer,
2377 // so a backend answering true with a NULL `process_raw_in_place` chose
2378 // the in-place dispatch path and then failed every take; and the slot
2379 // alone cannot answer, because `RustBackendAdapter::<R>::VTABLE` is a
2380 // `const` that installs `process_raw_in_place` for every `R` while the
2381 // Rust-side answer is a runtime method (zenoh true, metadata false
2382 // behind the same nullity). Neither mechanism subsumes the other.
2383 sub_state.supports_in_place = sub_state.vtable.process_raw_in_place.is_some()
2384 && match sub_state.vtable.subscription_supports_in_place {
2385 Some(f) => {
2386 let mut v = sub_state.make_view();
2387 // Phase 376 W3.d step A — a backend that FAILS the probe is not
2388 // one that supports in-place: both answer false, but only one of
2389 // them is an error, and the status now says which.
2390 let mut supports = false;
2391 let rc = unsafe { f(&mut v, &mut supports) };
2392 rc == NROS_RMW_RET_OK && supports
2393 }
2394 None => false,
2395 };
2396 Ok(sub_state)
2397 }
2398
2399 fn create_service(
2400 &mut self,
2401 service: &ServiceInfo,
2402 qos: QoSProfile,
2403 ) -> Result<CffiService, TransportError> {
2404 let qos_struct = NrosRmwQos::try_from(qos)?;
2405 let mut hash_buf = [0u8; HASH_BUF_LEN];
2406 let hash_ptr = to_c_str(service.type_hash, &mut hash_buf);
2407
2408 let mut srv_state = CffiService {
2409 vtable: self.vtable,
2410 service_name_buf: [0u8; NAME_BUF_LEN],
2411 type_name_buf: [0u8; NAME_BUF_LEN],
2412 backend_data: core::ptr::null_mut(),
2413 };
2414 let svc_ptr = to_c_str(service.name, &mut srv_state.service_name_buf);
2415 let type_ptr = to_c_str(service.type_name, &mut srv_state.type_name_buf);
2416
2417 let mut view = NrosRmwService {
2418 service_name: svc_ptr,
2419 type_name: type_ptr,
2420 _reserved: [0u8; 8],
2421 backend_data: core::ptr::null_mut(),
2422 };
2423 // Phase 376 W5/B1 — see `create_publisher`.
2424 let mut session_view = self.make_view();
2425 let node_view = self.node_slot(service.node_name, service.namespace, &mut session_view)?;
2426 let ret = unsafe {
2427 (self
2428 .vtable
2429 .create_service
2430 .expect("rmw vtable: create_service"))(
2431 &node_view,
2432 // phase-406 W1 — see the publisher site. A SERVICE type support
2433 // is its own type: upstream keeps `rosidl_service_type_support_t`
2434 // distinct from the message form, and collapsing them here would
2435 // let a service type be passed where a message type is required
2436 // with no diagnostic.
2437 &generated::rmw_service_type_support_t {
2438 type_name: type_ptr,
2439 type_hash: hash_ptr,
2440 },
2441 svc_ptr,
2442 service.domain_id,
2443 &qos_struct,
2444 &mut view,
2445 )
2446 };
2447 if ret != NROS_RMW_RET_OK {
2448 return Err(error_from_ret(ret));
2449 }
2450 if view.backend_data.is_null() {
2451 return Err(TransportError::ServiceServerCreationFailed);
2452 }
2453 srv_state.backend_data = view.backend_data;
2454 Ok(srv_state)
2455 }
2456
2457 fn create_client(
2458 &mut self,
2459 service: &ServiceInfo,
2460 qos: QoSProfile,
2461 ) -> Result<CffiClient, TransportError> {
2462 let qos_struct = NrosRmwQos::try_from(qos)?;
2463 let mut hash_buf = [0u8; HASH_BUF_LEN];
2464 let hash_ptr = to_c_str(service.type_hash, &mut hash_buf);
2465
2466 let mut cli_state = CffiClient {
2467 vtable: self.vtable,
2468 service_name_buf: [0u8; NAME_BUF_LEN],
2469 type_name_buf: [0u8; NAME_BUF_LEN],
2470 backend_data: core::ptr::null_mut(),
2471 };
2472 let svc_ptr = to_c_str(service.name, &mut cli_state.service_name_buf);
2473 let type_ptr = to_c_str(service.type_name, &mut cli_state.type_name_buf);
2474
2475 let mut view = NrosRmwClient {
2476 service_name: svc_ptr,
2477 type_name: type_ptr,
2478 _reserved: [0u8; 8],
2479 backend_data: core::ptr::null_mut(),
2480 };
2481 // Phase 376 W5/B1 — see `create_publisher`.
2482 let mut session_view = self.make_view();
2483 let node_view = self.node_slot(service.node_name, service.namespace, &mut session_view)?;
2484 let ret = unsafe {
2485 (self
2486 .vtable
2487 .create_client
2488 .expect("rmw vtable: create_client"))(
2489 &node_view,
2490 // phase-406 W1 — see the publisher site. A SERVICE type support
2491 // is its own type: upstream keeps `rosidl_service_type_support_t`
2492 // distinct from the message form, and collapsing them here would
2493 // let a service type be passed where a message type is required
2494 // with no diagnostic.
2495 &generated::rmw_service_type_support_t {
2496 type_name: type_ptr,
2497 type_hash: hash_ptr,
2498 },
2499 svc_ptr,
2500 service.domain_id,
2501 &qos_struct,
2502 &mut view,
2503 )
2504 };
2505 if ret != NROS_RMW_RET_OK {
2506 return Err(error_from_ret(ret));
2507 }
2508 if view.backend_data.is_null() {
2509 return Err(TransportError::ServiceClientCreationFailed);
2510 }
2511 cli_state.backend_data = view.backend_data;
2512 Ok(cli_state)
2513 }
2514
2515 fn close(&mut self) -> Result<(), TransportError> {
2516 if self.backend_data.is_null() {
2517 return Ok(());
2518 }
2519 // Phase 376 W5/B1 — hand the node slots back BEFORE the backend loses
2520 // its session state. A static table that is never reclaimed would let a
2521 // long-running image that opens and closes sessions exhaust it.
2522 let mut view = self.make_view();
2523 release_session_nodes(self.backend_data as usize, self.vtable, &mut view);
2524 let ret = unsafe {
2525 (self
2526 .vtable
2527 .destroy_session
2528 .expect("rmw vtable: destroy_session"))(&mut view)
2529 };
2530 if ret != NROS_RMW_RET_OK {
2531 return Err(error_from_ret(ret));
2532 }
2533 self.backend_data = core::ptr::null_mut();
2534 Ok(())
2535 }
2536
2537 fn drive_io(&mut self, timeout_ms: i32) -> Result<(), TransportError> {
2538 let mut view = self.make_view();
2539 let ret =
2540 unsafe { (self.vtable.drive_io.expect("rmw vtable: drive_io"))(&mut view, timeout_ms) };
2541 if ret != NROS_RMW_RET_OK {
2542 return Err(error_from_ret(ret));
2543 }
2544 Ok(())
2545 }
2546
2547 fn next_deadline_ms(&self) -> Option<u32> {
2548 let f = self.vtable.next_deadline_ms?;
2549 // SAFETY: build a transient `&self`-only view of the session
2550 // fields the C side may inspect; matches the layout `make_view`
2551 // produces but doesn't require `&mut self`.
2552 let view = NrosRmwSession {
2553 node_name: self.node_name_buf.as_ptr().cast(),
2554 namespace_: self.namespace_buf.as_ptr().cast(),
2555 _reserved: [0u8; 8],
2556 backend_data: self.backend_data,
2557 };
2558 // Phase 376 W3.d step A — the trait returns `Option<u32>` and has no
2559 // error channel, so a FAILING probe maps to `None` here. That is the
2560 // same answer a quiet link gives, but it is now a decision rather than
2561 // the arithmetic of a negative sentinel — and the backend can at least
2562 // distinguish the two on its side of the seam.
2563 let mut out_ms: u32 = 0;
2564 let mut has_deadline = false;
2565 let ret = unsafe { f(&view as *const _, &mut out_ms, &mut has_deadline) };
2566 if ret != NROS_RMW_RET_OK || !has_deadline {
2567 return None;
2568 }
2569 Some(out_ms)
2570 }
2571
2572 unsafe fn set_wake_callback(
2573 &mut self,
2574 cb: Option<unsafe extern "C" fn(ctx: *mut core::ffi::c_void)>,
2575 ctx: *mut core::ffi::c_void,
2576 ) {
2577 let Some(f) = self.vtable.set_wake_callback else {
2578 return;
2579 };
2580 let mut view = NrosRmwSession {
2581 node_name: self.node_name_buf.as_ptr().cast(),
2582 namespace_: self.namespace_buf.as_ptr().cast(),
2583 _reserved: [0u8; 8],
2584 backend_data: self.backend_data,
2585 };
2586 // SAFETY: vtable trampoline owns the install/clear; result is
2587 // ignored — best-effort.
2588 let _ = unsafe { f(&mut view as *mut _, cb, ctx) };
2589 }
2590
2591 fn supports_wake_callback(&self) -> bool {
2592 // Phase 130.4 — the vtable slot's presence is the truthful
2593 // signal. Poll-only backends (XRCE-DDS-Client, current
2594 // Cyclone wrapper, current dust-DDS shim) leave the slot
2595 // NULL; only backends with an async wake source fill it.
2596 self.vtable.set_wake_callback.is_some()
2597 }
2598
2599 /// phase-381 W5/W6 — forward to the backend's slot; NULL means UNSUPPORTED.
2600 ///
2601 /// Without this the graph slots were unreachable for every C backend: the
2602 /// trait default returns `Unsupported`, so a wired slot — cyclone's, as of
2603 /// W5 — would never be called and would look implemented while being dead
2604 /// code. That is exactly the "a slot exists, therefore it works"
2605 /// overstatement issue 0800 measured, one layer above where 0800 found it.
2606 ///
2607 /// NULL surfaces `Unsupported` rather than an empty enumeration, which is
2608 /// W6's requirement: XRCE has no graph and must say "cannot tell you", not
2609 /// "nothing is there".
2610 fn get_node_names(
2611 &mut self,
2612 visit: &mut dyn FnMut(&str, &str, Option<&str>) -> bool,
2613 ) -> Result<(), TransportError> {
2614 let Some(f) = self.vtable.get_node_names else {
2615 return Err(TransportError::Unsupported);
2616 };
2617 // The C visitor gets a pointer to this closure as its `ctx`; the
2618 // trampoline below turns the borrowed C strings back into `&str`.
2619 // `&view`, not `&mut`: the slot takes a `const rmw_session_t *`.
2620 let view = self.make_view();
2621 let mut cb: &mut dyn FnMut(&str, &str, Option<&str>) -> bool = visit;
2622 let rc = unsafe {
2623 f(
2624 &view as *const NrosRmwSession,
2625 generated::rmw_node_visitor_t {
2626 visit: Some(node_visit_trampoline),
2627 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
2628 },
2629 )
2630 };
2631 if rc == NROS_RMW_RET_OK {
2632 Ok(())
2633 } else {
2634 Err(error_from_ret(rc))
2635 }
2636 }
2637
2638 /// phase-381 / issue 0903 — the REST of the graph family.
2639 ///
2640 /// W5 added `get_node_names` here and stopped, which made this the same
2641 /// defect one method wide: every other graph call fell through to the trait
2642 /// default and returned `Unsupported`, so the zenoh backend — which reaches
2643 /// the runtime through this vtable — answered node names and NOTHING else.
2644 /// Measured against a live `rmw_zenoh_cpp` talker: node enumeration worked
2645 /// and `get_topic_names_and_types` returned empty, because the entity query
2646 /// was never even STARTED.
2647 ///
2648 /// Fixing one method of eleven is how the first version passed every unit
2649 /// test in the phase.
2650 fn get_topic_names_and_types(
2651 &mut self,
2652 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2653 ) -> Result<(), TransportError> {
2654 let Some(f) = self.vtable.get_topic_names_and_types else {
2655 return Err(TransportError::Unsupported);
2656 };
2657 let view = self.make_view();
2658 let mut cb: &mut dyn FnMut(&str, &[&str]) -> bool = visit;
2659 let rc = unsafe {
2660 f(
2661 &view as *const NrosRmwSession,
2662 false,
2663 generated::rmw_names_and_types_visitor_t {
2664 visit: Some(names_and_types_visit_trampoline),
2665 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
2666 },
2667 )
2668 };
2669 if rc == NROS_RMW_RET_OK {
2670 Ok(())
2671 } else {
2672 Err(error_from_ret(rc))
2673 }
2674 }
2675
2676 /// phase-381 W4, wired here by the live acceptance run.
2677 ///
2678 /// The zenoh shim implemented this and `get_endpoint_info_by_topic` all
2679 /// along; `CffiSession` never dispatched them, so both fell through to the
2680 /// trait default and every caller got `Unsupported`. That is issue 0903's
2681 /// third defect for the SIX slots the 0903 fix did not cover — it wired the
2682 /// five that had a failing symptom in front of it and left these, and
2683 /// `check-rmw-slot-producers` calls them `produced` either way because it
2684 /// asks whether a slot has a producer, not whether anything reaches it.
2685 ///
2686 /// Found by `graph_interop.rs` on its first run against a real peer, which
2687 /// is the only place it could have been found.
2688 fn get_names_and_types_by_node(
2689 &mut self,
2690 kind: GraphEntityKind,
2691 node_name: &str,
2692 node_namespace: &str,
2693 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2694 ) -> Result<(), TransportError> {
2695 // The four slots do NOT share a signature: the publisher and subscriber
2696 // forms take `no_demangle`, the service and client forms do not. That
2697 // asymmetry is upstream's (`rmw_get_service_names_and_types_by_node`
2698 // has no demangle argument), and RFC-0054 makes the C headers the SSoT,
2699 // so it is mirrored rather than smoothed over — which is why this is
2700 // two calls instead of one `match` producing a function pointer.
2701 let mut name_buf = [0u8; GRAPH_NAME_CAP];
2702 let mut ns_buf = [0u8; GRAPH_NAME_CAP];
2703 if node_name.len() >= GRAPH_NAME_CAP || node_namespace.len() >= GRAPH_NAME_CAP {
2704 return Err(TransportError::BufferTooSmall);
2705 }
2706 name_buf[..node_name.len()].copy_from_slice(node_name.as_bytes());
2707 ns_buf[..node_namespace.len()].copy_from_slice(node_namespace.as_bytes());
2708
2709 let view = self.make_view();
2710 let mut cb: &mut dyn FnMut(&str, &[&str]) -> bool = visit;
2711 let ctx = &mut cb as *mut _ as *mut core::ffi::c_void;
2712 let name = name_buf.as_ptr() as *const core::ffi::c_char;
2713 let ns = ns_buf.as_ptr() as *const core::ffi::c_char;
2714
2715 let rc = match kind {
2716 GraphEntityKind::Publisher | GraphEntityKind::Subscriber => {
2717 let slot = if matches!(kind, GraphEntityKind::Publisher) {
2718 self.vtable.get_publisher_names_and_types_by_node
2719 } else {
2720 self.vtable.get_subscriber_names_and_types_by_node
2721 };
2722 let Some(f) = slot else {
2723 return Err(TransportError::Unsupported);
2724 };
2725 unsafe {
2726 f(
2727 &view as *const NrosRmwSession,
2728 name,
2729 ns,
2730 false,
2731 generated::rmw_names_and_types_visitor_t {
2732 visit: Some(names_and_types_visit_trampoline),
2733 ctx,
2734 },
2735 )
2736 }
2737 }
2738 GraphEntityKind::Service | GraphEntityKind::Client => {
2739 let slot = if matches!(kind, GraphEntityKind::Service) {
2740 self.vtable.get_service_names_and_types_by_node
2741 } else {
2742 self.vtable.get_client_names_and_types_by_node
2743 };
2744 let Some(f) = slot else {
2745 return Err(TransportError::Unsupported);
2746 };
2747 unsafe {
2748 f(
2749 &view as *const NrosRmwSession,
2750 name,
2751 ns,
2752 generated::rmw_names_and_types_visitor_t {
2753 visit: Some(names_and_types_visit_trampoline),
2754 ctx,
2755 },
2756 )
2757 }
2758 }
2759 };
2760 if rc == NROS_RMW_RET_OK {
2761 Ok(())
2762 } else {
2763 Err(error_from_ret(rc))
2764 }
2765 }
2766
2767 /// The endpoints on a topic — see [`Self::get_names_and_types_by_node`] for
2768 /// why this was unreachable until the live run.
2769 fn get_endpoint_info_by_topic(
2770 &mut self,
2771 publishers: bool,
2772 topic_name: &str,
2773 visit: &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool,
2774 ) -> Result<(), TransportError> {
2775 let slot = if publishers {
2776 self.vtable.get_publishers_info_by_topic
2777 } else {
2778 self.vtable.get_subscriptions_info_by_topic
2779 };
2780 let Some(f) = slot else {
2781 return Err(TransportError::Unsupported);
2782 };
2783 let mut topic_buf = [0u8; GRAPH_NAME_CAP];
2784 if topic_name.len() >= GRAPH_NAME_CAP {
2785 return Err(TransportError::BufferTooSmall);
2786 }
2787 topic_buf[..topic_name.len()].copy_from_slice(topic_name.as_bytes());
2788
2789 let view = self.make_view();
2790 let mut cb: &mut dyn FnMut(&GraphEndpointInfo<'_>) -> bool = visit;
2791 let rc = unsafe {
2792 f(
2793 &view as *const NrosRmwSession,
2794 topic_buf.as_ptr() as *const core::ffi::c_char,
2795 false,
2796 generated::rmw_topic_endpoint_info_visitor_t {
2797 visit: Some(endpoint_info_visit_trampoline),
2798 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
2799 },
2800 )
2801 };
2802 if rc == NROS_RMW_RET_OK {
2803 Ok(())
2804 } else {
2805 Err(error_from_ret(rc))
2806 }
2807 }
2808
2809 fn get_service_names_and_types(
2810 &mut self,
2811 visit: &mut dyn FnMut(&str, &[&str]) -> bool,
2812 ) -> Result<(), TransportError> {
2813 let Some(f) = self.vtable.get_service_names_and_types else {
2814 return Err(TransportError::Unsupported);
2815 };
2816 let view = self.make_view();
2817 let mut cb: &mut dyn FnMut(&str, &[&str]) -> bool = visit;
2818 let rc = unsafe {
2819 f(
2820 &view as *const NrosRmwSession,
2821 generated::rmw_names_and_types_visitor_t {
2822 visit: Some(names_and_types_visit_trampoline),
2823 ctx: &mut cb as *mut _ as *mut core::ffi::c_void,
2824 },
2825 )
2826 };
2827 if rc == NROS_RMW_RET_OK {
2828 Ok(())
2829 } else {
2830 Err(error_from_ret(rc))
2831 }
2832 }
2833
2834 fn count_publishers(&mut self, topic_name: &str) -> Result<usize, TransportError> {
2835 let Some(f) = self.vtable.count_publishers else {
2836 return Err(TransportError::Unsupported);
2837 };
2838 count_on_topic(&self.make_view(), f, topic_name)
2839 }
2840
2841 fn count_subscribers(&mut self, topic_name: &str) -> Result<usize, TransportError> {
2842 let Some(f) = self.vtable.count_subscribers else {
2843 return Err(TransportError::Unsupported);
2844 };
2845 count_on_topic(&self.make_view(), f, topic_name)
2846 }
2847
2848 fn ping_session(&mut self, timeout_ms: i32) -> Result<(), TransportError> {
2849 // Phase 124.F.1 — forward to the backend's vtable slot when
2850 // available; NULL surfaces `Unsupported` to the caller (no
2851 // implicit emulation — backends without a wire-level
2852 // round-trip can't probe honestly).
2853 let Some(f) = self.vtable.ping_session else {
2854 return Err(TransportError::Unsupported);
2855 };
2856 let mut view = self.make_view();
2857 let rc = unsafe { f(&mut view, timeout_ms) };
2858 if rc == NROS_RMW_RET_OK {
2859 Ok(())
2860 } else {
2861 Err(error_from_ret(rc))
2862 }
2863 }
2864
2865 /// Phase 115.K.2.5.1.2 — declare a permissive QoS-policy mask
2866 /// here so backends behind the cffi vtable don't get rejected by
2867 /// the runtime's pre-validate step before they ever see the
2868 /// `create_publisher` / `create_subscription` call. The vtable
2869 /// doesn't expose a per-backend policy mask yet; until it does,
2870 /// the cffi route has to assume the registered backend supports
2871 /// the union of every policy any nros-supported RMW honours.
2872 /// Backends that don't support a policy MUST surface
2873 /// `NROS_RMW_RET_INCOMPATIBLE_QOS` from `create_publisher` etc.
2874 /// to keep the no-silent-degradation contract.
2875 ///
2876 /// TODO 115.K.2.x: extend `nros_rmw_vtable_t` with a
2877 /// `supported_qos_policies()` callback so the runtime queries
2878 /// the backend instead of guessing.
2879 fn supported_qos_policies(&self) -> nros_rmw::QoSPolicyMask {
2880 use nros_rmw::QoSPolicyMask;
2881 QoSPolicyMask::CORE
2882 | QoSPolicyMask::DURABILITY_TRANSIENT_LOCAL
2883 | QoSPolicyMask::AVOID_ROS_NAMESPACE_CONVENTIONS
2884 | QoSPolicyMask::DEADLINE
2885 | QoSPolicyMask::LIFESPAN
2886 | QoSPolicyMask::LIVELINESS_AUTOMATIC
2887 | QoSPolicyMask::LIVELINESS_MANUAL_BY_TOPIC
2888 | QoSPolicyMask::LIVELINESS_MANUAL_BY_NODE
2889 | QoSPolicyMask::LIVELINESS_LEASE
2890 }
2891}
2892
2893impl Drop for CffiSession {
2894 fn drop(&mut self) {
2895 if !self.backend_data.is_null() {
2896 let mut view = self.make_view();
2897 unsafe {
2898 (self
2899 .vtable
2900 .destroy_session
2901 .expect("rmw vtable: destroy_session"))(&mut view)
2902 };
2903 }
2904 }
2905}
2906
2907// ============================================================================
2908// CffiPublisher
2909// ============================================================================
2910
2911/// Publisher backed by a C vtable.
2912pub struct CffiPublisher {
2913 vtable: &'static NrosRmwVtable,
2914 topic_name_buf: [u8; NAME_BUF_LEN],
2915 type_name_buf: [u8; NAME_BUF_LEN],
2916 qos: NrosRmwQos,
2917 can_loan_messages: bool,
2918 backend_data: *mut c_void,
2919}
2920
2921impl CffiPublisher {
2922 fn make_view(&mut self) -> NrosRmwPublisher {
2923 NrosRmwPublisher {
2924 topic_name: self.topic_name_buf.as_ptr().cast(),
2925 type_name: self.type_name_buf.as_ptr().cast(),
2926 qos: self.qos,
2927 can_loan_messages: self.can_loan_messages,
2928 _reserved: [0u8; 7],
2929 backend_data: self.backend_data,
2930 }
2931 }
2932
2933 /// Topic name. Result is the null-terminated string written at
2934 /// publisher creation; never re-resolved from the backend.
2935 pub fn topic_name(&self) -> &str {
2936 cstr_buf_to_str(&self.topic_name_buf)
2937 }
2938
2939 /// Fully-qualified type name (`"std_msgs/msg/Int32"`).
2940 pub fn type_name(&self) -> &str {
2941 cstr_buf_to_str(&self.type_name_buf)
2942 }
2943
2944 /// QoS used to create this publisher.
2945 pub fn qos(&self) -> NrosRmwQos {
2946 self.qos
2947 }
2948
2949 /// `true` iff the backend exposes the publish loan primitive
2950 /// (Phase 99). Mirrors upstream `rmw_publisher_t::can_loan_messages`.
2951 pub fn can_loan_messages(&self) -> bool {
2952 self.can_loan_messages
2953 }
2954}
2955
2956/// Phase 124.A — writable slot returned by
2957/// [`CffiPublisher::try_lend_slot`]. Holds the backend's raw buffer
2958/// and opaque token until `commit_slot` consumes it or `Drop` fires
2959/// `pub_discard`.
2960#[cfg(feature = "lending")]
2961pub struct CffiSlot<'a> {
2962 buf: *mut u8,
2963 cap: usize,
2964 cursor: usize,
2965 token: *mut generated::rmw_loan_token_t,
2966 /// `None` after `commit_slot` consumes the slot — Drop skips the
2967 /// discard call in that case.
2968 publisher: Option<&'a CffiPublisher>,
2969 /// Phase 124.A.3 — `true` when this slot came from the runtime's
2970 /// arena fallback (backend had NULL `pub_loan`). Commit performs
2971 /// a `publish_raw` of the staged bytes; discard / Drop reclaims
2972 /// the staging buffer. `false` for native backend loans —
2973 /// commit / discard go through the vtable slots.
2974 fallback: bool,
2975}
2976
2977#[cfg(feature = "lending")]
2978impl<'a> CffiSlot<'a> {
2979 /// Mark the actual bytes written before commit. Defaults to the
2980 /// full capacity; callers that write a shorter prefix MUST call
2981 /// `set_len` first.
2982 pub fn set_len(&mut self, len: usize) {
2983 debug_assert!(len <= self.cap);
2984 self.cursor = len.min(self.cap);
2985 }
2986}
2987
2988/// Phase 124.A.3 — staging buffer for the arena-fallback loan path.
2989/// Allocated on each `try_lend_slot` when the backend's `pub_loan`
2990/// slot is NULL; commit copies into a `publish_raw` call; Drop /
2991/// discard reclaims the allocation. `Box::into_raw` of this struct
2992/// becomes the slot's opaque `token` so commit / discard can find
2993/// it back.
2994#[cfg(all(feature = "lending", feature = "alloc"))]
2995struct ArenaStaging {
2996 buf: alloc::vec::Vec<u8>,
2997}
2998
2999#[cfg(feature = "lending")]
3000impl<'a> AsMut<[u8]> for CffiSlot<'a> {
3001 fn as_mut(&mut self) -> &mut [u8] {
3002 // SAFETY: `buf` came from `pub_loan` with capacity `cap`. The
3003 // loan contract guarantees the slot stays valid until commit
3004 // or discard. The lifetime `'a` borrows the publisher so the
3005 // returned slice can't outlive the loan.
3006 unsafe { core::slice::from_raw_parts_mut(self.buf, self.cap) }
3007 }
3008}
3009
3010#[cfg(feature = "lending")]
3011impl<'a> Drop for CffiSlot<'a> {
3012 fn drop(&mut self) {
3013 if self.publisher.is_none() {
3014 // commit_slot consumed the loan — nothing to release.
3015 return;
3016 }
3017 if self.fallback {
3018 // Phase 124.A.3 — reclaim the staging allocation.
3019 #[cfg(feature = "alloc")]
3020 unsafe {
3021 let _ = alloc::boxed::Box::from_raw(self.token as *mut ArenaStaging);
3022 }
3023 return;
3024 }
3025 if let Some(p) = self.publisher
3026 && let Some(discard) = p.vtable.return_loaned_message_from_publisher
3027 {
3028 // Re-materialise the publisher view so the backend sees
3029 // the same `NrosRmwPublisher` shape it created the loan
3030 // against.
3031 let view = NrosRmwPublisher {
3032 topic_name: p.topic_name_buf.as_ptr().cast(),
3033 type_name: p.type_name_buf.as_ptr().cast(),
3034 qos: p.qos,
3035 can_loan_messages: p.can_loan_messages,
3036 _reserved: [0u8; 7],
3037 backend_data: p.backend_data,
3038 };
3039 // SAFETY: `token` came from a paired `pub_loan` on this
3040 // publisher and the publisher is still alive (lifetime
3041 // `'a` borrows it).
3042 let ret = unsafe { discard(&view, self.token) };
3043 if ret != NROS_RMW_RET_OK {
3044 nros_log::nros_error!(
3045 nros_log::get_logger("nros_rmw_cffi"),
3046 "return_loaned_message_from_publisher failed with {}; the loan slot may be stranded",
3047 ret
3048 );
3049 }
3050 }
3051 }
3052}
3053
3054#[cfg(feature = "lending")]
3055impl nros_rmw::SlotLending for CffiPublisher {
3056 type Slot<'a> = CffiSlot<'a>;
3057
3058 fn try_lend_slot(&self, len: usize) -> Result<Option<CffiSlot<'_>>, TransportError> {
3059 let Some(loan) = self.vtable.borrow_loaned_message else {
3060 // Phase 124.A.3 — backend doesn't natively lend; allocate
3061 // a staging buffer and stash it in `token` so commit can
3062 // memcpy → publish_raw and discard / Drop can reclaim.
3063 // Requires `alloc` for the dynamic staging; without it there
3064 // is no staging to hand out and the answer is PERMANENT — see
3065 // the `not(alloc)` arm below.
3066 #[cfg(feature = "alloc")]
3067 {
3068 let mut staging = alloc::boxed::Box::new(ArenaStaging {
3069 buf: alloc::vec![0u8; len],
3070 });
3071 let buf_ptr = staging.buf.as_mut_ptr();
3072 // phase-406 W3 — the FALLBACK carries its own staging box in the token
3073 // slot. It is not a backend loan handle, so the cast is the
3074 // honest thing here: `fallback: true` below is what tells the
3075 // return path to unbox rather than call the backend.
3076 let token =
3077 alloc::boxed::Box::into_raw(staging) as *mut generated::rmw_loan_token_t;
3078 return Ok(Some(CffiSlot {
3079 buf: buf_ptr,
3080 cap: len,
3081 cursor: len,
3082 token,
3083 publisher: Some(self),
3084 fallback: true,
3085 }));
3086 }
3087 #[cfg(not(feature = "alloc"))]
3088 {
3089 // issue 0814 — this arm used to return `Ok(None)`, which every
3090 // caller above reads as "no slot RIGHT NOW, retry":
3091 // `EmbeddedRawPublisher::try_loan` maps it to
3092 // `LoanError::WouldBlock`, `loan_with_timeout` then spins the
3093 // executor until its whole budget is gone, and `LoanFuture`
3094 // returns `Pending` after a self-wake — a hot loop that never
3095 // resolves. But the condition is a COMPILE-TIME fact about this
3096 // image (no `alloc`) and a STATIC fact about this publisher
3097 // (its vtable has no `borrow_loaned_message`); neither can
3098 // change while the publisher lives, so no amount of retrying
3099 // will ever clear it. A permanent condition must not present as
3100 // a transient one.
3101 //
3102 // `Unsupported` rather than `LoanNotSupported`: the latter's
3103 // own doc bundles "or the loan slot is currently in use", which
3104 // is exactly the transient reading this arm must not offer.
3105 //
3106 // What a caller should do on seeing it: stop asking for a loan
3107 // on this publisher and take a non-loan path — `publish_raw`,
3108 // or better `publish_streamed`, which needs no token, no arena
3109 // and no heap, and which the XRCE and zenoh backends fill
3110 // natively.
3111 let _ = len;
3112 return Err(TransportError::Unsupported);
3113 }
3114 };
3115 let view = NrosRmwPublisher {
3116 topic_name: self.topic_name_buf.as_ptr().cast(),
3117 type_name: self.type_name_buf.as_ptr().cast(),
3118 qos: self.qos,
3119 can_loan_messages: self.can_loan_messages,
3120 _reserved: [0u8; 7],
3121 backend_data: self.backend_data,
3122 };
3123 let mut slot = generated::rmw_mut_byte_span_t {
3124 data: core::ptr::null_mut(),
3125 capacity: 0,
3126 len: 0,
3127 };
3128 let mut out_token: *mut generated::rmw_loan_token_t = core::ptr::null_mut();
3129 // SAFETY: vtable contract — slot pointers stay valid until
3130 // commit / discard.
3131 let ret = unsafe { loan(&view, len, &mut slot, &mut out_token) };
3132 let (out_buf, out_cap) = (slot.data, slot.capacity);
3133 if ret == NROS_RMW_RET_WOULD_BLOCK || ret == NROS_RMW_RET_NO_DATA {
3134 return Ok(None);
3135 }
3136 if ret != NROS_RMW_RET_OK {
3137 return Err(error_from_ret(ret));
3138 }
3139 if out_buf.is_null() || out_cap < len {
3140 // Defensive: a buggy backend returned OK with a too-small
3141 // slot. Treat as transient.
3142 if let Some(discard) = self.vtable.return_loaned_message_from_publisher {
3143 // The loan is already being abandoned; a failure to hand it back
3144 // does not change what this function returns, but it is the
3145 // second fault in a row and worth a line.
3146 let ret = unsafe { discard(&view, out_token) };
3147 if ret != NROS_RMW_RET_OK {
3148 nros_log::nros_error!(
3149 nros_log::get_logger("nros_rmw_cffi"),
3150 "discarding an undersized loan also failed with {}",
3151 ret
3152 );
3153 }
3154 }
3155 return Ok(None);
3156 }
3157 Ok(Some(CffiSlot {
3158 buf: out_buf,
3159 cap: out_cap,
3160 cursor: len,
3161 token: out_token,
3162 publisher: Some(self),
3163 fallback: false,
3164 }))
3165 }
3166
3167 fn commit_slot(&self, mut slot: CffiSlot<'_>) -> Result<(), TransportError> {
3168 // Cancel Drop's discard — we're committing, not abandoning.
3169 let publisher = slot
3170 .publisher
3171 .take()
3172 .ok_or(TransportError::InvalidArgument)?;
3173 debug_assert!(core::ptr::eq(publisher, self));
3174 if slot.fallback {
3175 // Phase 124.A.3 — fallback path: reclaim the staging
3176 // box, run a single publish_raw of the cursor-truncated
3177 // contents.
3178 #[cfg(feature = "alloc")]
3179 {
3180 // SAFETY: `slot.token` came from
3181 // `Box::into_raw(Box<ArenaStaging>)` in try_lend_slot.
3182 let staging =
3183 unsafe { alloc::boxed::Box::from_raw(slot.token as *mut ArenaStaging) };
3184 let bytes = &staging.buf[..slot.cursor.min(staging.buf.len())];
3185 return Publisher::publish_raw(self, bytes);
3186 }
3187 #[cfg(not(feature = "alloc"))]
3188 {
3189 return Err(TransportError::Unsupported);
3190 }
3191 }
3192 let commit = self
3193 .vtable
3194 .publish_loaned_message
3195 .ok_or(TransportError::Unsupported)?;
3196 let view = NrosRmwPublisher {
3197 topic_name: self.topic_name_buf.as_ptr().cast(),
3198 type_name: self.type_name_buf.as_ptr().cast(),
3199 qos: self.qos,
3200 can_loan_messages: self.can_loan_messages,
3201 _reserved: [0u8; 7],
3202 backend_data: self.backend_data,
3203 };
3204 let len = slot.cursor;
3205 let token = slot.token;
3206 // `slot` drops here without firing `pub_discard` because
3207 // `publisher` is `None`.
3208 let ret = unsafe { commit(&view, token, len) };
3209 if ret != NROS_RMW_RET_OK {
3210 return Err(error_from_ret(ret));
3211 }
3212 Ok(())
3213 }
3214}
3215
3216// ---------------------------------------------------------------------------
3217// Issue 0812 — the loan path across the C / C++ boundary, without a heap.
3218//
3219// `SlotLending` hands back a `CffiSlot`, which is a Rust value with a `Drop`
3220// impl. An FFI entry point has to keep that value alive between `_loan` and
3221// `_commit` / `_discard` and has nowhere to put it, so `nros-c` / `nros-cpp`
3222// each boxed one per loan: a `malloc` on the path whose whole purpose is to
3223// remove copies and, worse, a hard `alloc` dependency on a surface that a
3224// heap-free image is supposed to be able to use.
3225//
3226// None of that state has to cross the boundary. Commit and discard are handed
3227// the publisher back as a parameter; whether the loan took the staging
3228// fallback is a property of that publisher's vtable, not of the individual
3229// loan; and the backend already minted a stable token of its own. So the FFI
3230// layers pass the BACKEND's token straight through and store nothing at all.
3231//
3232// One implementation, not two: `commit_raw` / `discard_raw` rebuild the
3233// `CffiSlot` and delegate to `commit_slot` / `Drop`, so the loan lifecycle
3234// keeps exactly one body and cannot drift from the `SlotLending` path.
3235// ---------------------------------------------------------------------------
3236#[cfg(feature = "lending")]
3237impl CffiPublisher {
3238 /// `true` when loans on this publisher take the runtime's staging
3239 /// fallback rather than the backend's own slot. A property of the
3240 /// vtable, so every loan on a given publisher answers the same — which
3241 /// is why the per-loan `fallback` bit need not travel in the token.
3242 #[inline]
3243 fn loan_is_fallback(&self) -> bool {
3244 self.vtable.borrow_loaned_message.is_none()
3245 }
3246
3247 /// Rebuild the [`CffiSlot`] describing an outstanding loan from its
3248 /// token, so the shared commit / discard bodies can run on it.
3249 ///
3250 /// `buf` and `cap` are only ever read by `as_mut` / `set_len`, neither of
3251 /// which runs on the commit or discard path (the fallback arm reads the
3252 /// staging buffer back out of `token`, the native arm hands `token` to
3253 /// the backend), so the rebuilt slot deliberately carries no buffer.
3254 ///
3255 /// # Safety
3256 /// `token` must be the token of a loan that is still outstanding on
3257 /// `self`, and the returned slot must be committed or dropped exactly
3258 /// once.
3259 unsafe fn slot_from_token(
3260 &self,
3261 token: *mut generated::rmw_loan_token_t,
3262 len: usize,
3263 ) -> CffiSlot<'_> {
3264 CffiSlot {
3265 buf: core::ptr::null_mut(),
3266 cap: len,
3267 cursor: len,
3268 token,
3269 publisher: Some(self),
3270 fallback: self.loan_is_fallback(),
3271 }
3272 }
3273
3274 /// Allocation-free loan: reserve a slot and hand back the backend's own
3275 /// opaque token instead of a Rust value the caller would have to store.
3276 ///
3277 /// Returns `(buf, cap, token)`. `Ok(None)` means no slot is available
3278 /// right now — the same meaning `SlotLending::try_lend_slot` gives it.
3279 ///
3280 /// The loan stays outstanding until [`commit_raw`](Self::commit_raw) or
3281 /// [`discard_raw`](Self::discard_raw) consumes `token` — exactly one of
3282 /// them, exactly once. Callers that can hold a Rust value across the
3283 /// whole loan should use `SlotLending` instead and let `Drop` do it.
3284 pub fn try_lend_raw(
3285 &self,
3286 len: usize,
3287 ) -> Result<Option<(*mut u8, usize, *mut generated::rmw_loan_token_t)>, TransportError> {
3288 use nros_rmw::SlotLending as _;
3289 let Some(mut slot) = self.try_lend_slot(len)? else {
3290 return Ok(None);
3291 };
3292 let buf = slot.as_mut().as_mut_ptr();
3293 let cap = slot.as_mut().len();
3294 let token = slot.token;
3295 // Keep the loan outstanding — dropping the slot here would hand it
3296 // straight back to the backend.
3297 core::mem::forget(slot);
3298 Ok(Some((buf, cap, token)))
3299 }
3300
3301 /// Commit a loan taken with [`try_lend_raw`](Self::try_lend_raw).
3302 ///
3303 /// `actual_len` is the number of bytes written. On the native path it
3304 /// reaches the backend's own commit, which is the layer that knows the
3305 /// slot's capacity and is responsible for clamping; the staging fallback
3306 /// clamps to the staging buffer here.
3307 ///
3308 /// # Safety
3309 /// `token` must come from a prior `try_lend_raw` on THIS publisher, must
3310 /// still be outstanding, and must not be used again after this call.
3311 pub unsafe fn commit_raw(
3312 &self,
3313 token: *mut generated::rmw_loan_token_t,
3314 actual_len: usize,
3315 ) -> Result<(), TransportError> {
3316 use nros_rmw::SlotLending as _;
3317 // SAFETY: forwarded from this function's own contract.
3318 let slot = unsafe { self.slot_from_token(token, actual_len) };
3319 self.commit_slot(slot)
3320 }
3321
3322 /// Abandon a loan taken with [`try_lend_raw`](Self::try_lend_raw)
3323 /// without sending it.
3324 ///
3325 /// # Safety
3326 /// `token` must come from a prior `try_lend_raw` on THIS publisher, must
3327 /// still be outstanding, and must not be used again after this call.
3328 pub unsafe fn discard_raw(
3329 &self,
3330 token: *mut generated::rmw_loan_token_t,
3331 ) -> Result<(), TransportError> {
3332 // SAFETY: forwarded from this function's own contract. Dropping the
3333 // rebuilt slot is what fires the backend's discard (or reclaims the
3334 // staging buffer).
3335 drop(unsafe { self.slot_from_token(token, 0) });
3336 Ok(())
3337 }
3338}
3339
3340impl Publisher for CffiPublisher {
3341 type Error = TransportError;
3342
3343 fn publish_raw(&self, data: &[u8]) -> Result<(), TransportError> {
3344 let mut view = NrosRmwPublisher {
3345 topic_name: self.topic_name_buf.as_ptr().cast(),
3346 type_name: self.type_name_buf.as_ptr().cast(),
3347 qos: self.qos,
3348 can_loan_messages: self.can_loan_messages,
3349 _reserved: [0u8; 7],
3350 backend_data: self.backend_data,
3351 };
3352 let ret = unsafe {
3353 (self.vtable.publish.expect("rmw vtable: publish"))(
3354 &mut view,
3355 // phase-406 W2 — by VALUE: a read span has nothing to report back.
3356 generated::rmw_byte_span_t {
3357 data: data.as_ptr(),
3358 len: data.len(),
3359 },
3360 )
3361 };
3362 if ret != NROS_RMW_RET_OK {
3363 return Err(error_from_ret(ret));
3364 }
3365 Ok(())
3366 }
3367
3368 unsafe fn publish_streamed(
3369 &self,
3370 size_cb: unsafe extern "C" fn(out_total_len: *mut usize, user_ctx: *mut core::ffi::c_void),
3371 chunk_cb: unsafe extern "C" fn(
3372 out_buf: *mut u8,
3373 cap: usize,
3374 out_written: *mut usize,
3375 user_ctx: *mut core::ffi::c_void,
3376 ),
3377 user_ctx: *mut core::ffi::c_void,
3378 ) -> Result<(), TransportError> {
3379 // Phase 124.E.1+2 — vtable forwarder. If the backend exposes
3380 // `publish_streamed` natively, dispatch in one hop so the
3381 // callbacks land directly inside the backend's outbound
3382 // buffer (no staging copy). Otherwise fall back to the
3383 // `Publisher::publish_streamed` default body, which runs a
3384 // stack staging buffer + `publish_raw`.
3385 if let Some(f) = self.vtable.publish_streamed {
3386 let mut view = NrosRmwPublisher {
3387 topic_name: self.topic_name_buf.as_ptr().cast(),
3388 type_name: self.type_name_buf.as_ptr().cast(),
3389 qos: self.qos,
3390 can_loan_messages: self.can_loan_messages,
3391 _reserved: [0u8; 7],
3392 backend_data: self.backend_data,
3393 };
3394 // Generated slot takes nullable callbacks; ours are live fn pointers.
3395 let ret = unsafe { f(&mut view, Some(size_cb), Some(chunk_cb), user_ctx) };
3396 if ret != NROS_RMW_RET_OK {
3397 return Err(error_from_ret(ret));
3398 }
3399 return Ok(());
3400 }
3401 // Inlined staging-buffer fallback. Mirrors the trait default
3402 // body so the override doesn't recurse through dynamic
3403 // dispatch — the default body would resolve back to this
3404 // function and deadlock.
3405 const STAGE_CAP: usize = 4096;
3406 let mut total = 0usize;
3407 unsafe { size_cb(&mut total as *mut usize, user_ctx) };
3408 if total > STAGE_CAP {
3409 return Err(TransportError::BufferTooSmall);
3410 }
3411 let mut stage = [0u8; STAGE_CAP];
3412 let mut written_so_far = 0usize;
3413 while written_so_far < total {
3414 let mut chunk_written = 0usize;
3415 let remaining = total - written_so_far;
3416 unsafe {
3417 chunk_cb(
3418 stage.as_mut_ptr().add(written_so_far),
3419 remaining,
3420 &mut chunk_written as *mut usize,
3421 user_ctx,
3422 );
3423 }
3424 if chunk_written == 0 {
3425 return Err(TransportError::BufferTooSmall);
3426 }
3427 written_so_far += chunk_written;
3428 }
3429 self.publish_raw(&stage[..total])
3430 }
3431
3432 fn buffer_error(&self) -> TransportError {
3433 TransportError::BufferTooSmall
3434 }
3435
3436 fn serialization_error(&self) -> TransportError {
3437 TransportError::SerializationError
3438 }
3439
3440 fn unsupported_event_error(&self) -> TransportError {
3441 TransportError::Unsupported
3442 }
3443
3444 unsafe fn register_event_callback(
3445 &mut self,
3446 kind: nros_rmw::EventKind,
3447 deadline_ms: u32,
3448 cb: nros_rmw::EventCallback,
3449 user_ctx: *mut core::ffi::c_void,
3450 ) -> Result<(), TransportError> {
3451 let view = NrosRmwPublisher {
3452 topic_name: self.topic_name_buf.as_ptr().cast(),
3453 type_name: self.type_name_buf.as_ptr().cast(),
3454 qos: self.qos,
3455 can_loan_messages: self.can_loan_messages,
3456 _reserved: [0u8; 7],
3457 backend_data: self.backend_data,
3458 };
3459 // Cffi event callback ABI matches nros_rmw::EventCallback (layout
3460 // notes in `rust_adapter`); the generated slot is nullable, so the
3461 // live fn pointer is wrapped in `Some`.
3462 let cb: NrosRmwEventCallback = Some(unsafe {
3463 core::mem::transmute::<
3464 nros_rmw::EventCallback,
3465 unsafe extern "C" fn(NrosRmwEventKind, *const NrosRmwEventPayload, *mut c_void),
3466 >(cb)
3467 });
3468 // Issue 0349 — a NULL slot means the backend does not implement this
3469 // OPTIONAL capability (xrce NULLs all three). Report it as
3470 // `Unsupported`; never panic, and never make it a registration error.
3471 let Some(register) = self.vtable.publisher_event_init else {
3472 return Err(TransportError::Unsupported);
3473 };
3474 let ret = unsafe { register(&view, event_kind_to_c(kind), deadline_ms, cb, user_ctx) };
3475 if ret != NROS_RMW_RET_OK {
3476 return Err(error_from_ret(ret));
3477 }
3478 Ok(())
3479 }
3480
3481 fn assert_liveliness(&self) -> Result<(), TransportError> {
3482 // Phase 108.B — manual liveliness assertion. NULL function
3483 // pointer = backend doesn't support manual liveliness; the
3484 // runtime caller (Node) gates the call by liveliness_kind so
3485 // we just delegate.
3486 let view_ptr = self as *const _ as *mut Self;
3487 let view = unsafe { (*view_ptr).make_view() };
3488 // Issue 0349 — a NULL slot means the backend does not implement this
3489 // OPTIONAL capability (xrce NULLs all three). Report it as
3490 // `Unsupported`; never panic, and never make it a registration error.
3491 let Some(assert_liveliness) = self.vtable.publisher_assert_liveliness else {
3492 return Err(TransportError::Unsupported);
3493 };
3494 let ret = unsafe { assert_liveliness(&view) };
3495 if ret != NROS_RMW_RET_OK {
3496 return Err(error_from_ret(ret));
3497 }
3498 Ok(())
3499 }
3500}
3501
3502impl Drop for CffiPublisher {
3503 fn drop(&mut self) {
3504 if !self.backend_data.is_null() {
3505 let mut view = self.make_view();
3506 let ret = unsafe {
3507 (self
3508 .vtable
3509 .destroy_publisher
3510 .expect("rmw vtable: destroy_publisher"))(&mut view)
3511 };
3512 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
3513 // that cannot propagate. Logging is not a consolation prize: a
3514 // teardown that failed is a leak, and a leak with no message
3515 // surfaces later as an allocation failure with no provenance.
3516 // `nros_log`, never std stdio — issue 0589.
3517 if ret != NROS_RMW_RET_OK {
3518 nros_log::nros_error!(
3519 nros_log::get_logger("nros_rmw_cffi"),
3520 "destroy_publisher failed with {}; the backend may have leaked the publisher",
3521 ret
3522 );
3523 }
3524 }
3525 }
3526}
3527
3528// ============================================================================
3529// CffiSubscription
3530// ============================================================================
3531
3532/// Subscription backed by a C vtable.
3533pub struct CffiSubscription {
3534 vtable: &'static NrosRmwVtable,
3535 topic_name_buf: [u8; NAME_BUF_LEN],
3536 type_name_buf: [u8; NAME_BUF_LEN],
3537 qos: NrosRmwQos,
3538 can_loan_messages: bool,
3539 backend_data: *mut c_void,
3540 /// Phase 231 (RFC-0038) — cached `subscription_supports_in_place` capability,
3541 /// queried once at creation so `supports_process_in_place(&self)` is cheap.
3542 supports_in_place: bool,
3543 /// Issue 0971 — a status that happened where it could not be returned.
3544 ///
3545 /// `take_sequence` is contractually a COUNT: a partial drain reports
3546 /// what it got rather than erroring (`rmw_vtable.h`). So when the drain
3547 /// stops because a message did not fit the caller's slot, there is nowhere
3548 /// to put `BufferTooSmall` — and this loop used to answer it with `?`,
3549 /// throwing away the count for the messages it HAD delivered, while the
3550 /// native Cyclone path answered the same condition with `Ok(count)`.
3551 ///
3552 /// The status is parked here and returned by the next call instead. That is
3553 /// the shape `nros-verification`'s `take_post_fix` already proves for
3554 /// the single take — check the flag first, clear it, return the error, take
3555 /// nothing — moved one call later because that is where the contract leaves
3556 /// room for it.
3557 pending_status: Option<TransportError>,
3558}
3559
3560impl CffiSubscription {
3561 fn make_view(&mut self) -> NrosRmwSubscription {
3562 NrosRmwSubscription {
3563 topic_name: self.topic_name_buf.as_ptr().cast(),
3564 type_name: self.type_name_buf.as_ptr().cast(),
3565 qos: self.qos,
3566 can_loan_messages: self.can_loan_messages,
3567 _reserved: [0u8; 7],
3568 backend_data: self.backend_data,
3569 }
3570 }
3571
3572 /// Phase 231 (RFC-0038) — drive the `process_raw_in_place` vtable slot,
3573 /// marshalling the Rust `FnOnce` through the C `ctx`/`cb`. A monomorphized
3574 /// trampoline takes the closure out of a stack `Option` cell and calls it
3575 /// with the borrowed slice. The named generic `G` is why the public trait
3576 /// method (which uses APIT) delegates here.
3577 fn run_process_in_place<G: FnOnce(&[u8])>(&mut self, f: G) -> Result<bool, TransportError> {
3578 let Some(slot) = self.vtable.process_raw_in_place else {
3579 return Err(TransportError::MessageTooLarge);
3580 };
3581 unsafe extern "C" fn cb_tramp<G: FnOnce(&[u8])>(
3582 ctx: *mut c_void,
3583 message: generated::rmw_byte_span_t,
3584 ) {
3585 let cell = unsafe { &mut *(ctx as *mut Option<G>) };
3586 if let Some(g) = cell.take() {
3587 g(unsafe { core::slice::from_raw_parts(message.data, message.len) });
3588 }
3589 }
3590 let mut cell: Option<G> = Some(f);
3591 let mut view = self.make_view();
3592 // Phase 376 W3.d step A — "processed one" arrives in the out-parameter.
3593 // The NO_DATA arm is gone: an empty subscription is now OK with
3594 // `processed = false`, which is what upstream's `taken = false` means.
3595 let mut processed = false;
3596 let rc = unsafe {
3597 slot(
3598 &mut view,
3599 &mut cell as *mut Option<G> as *mut c_void,
3600 Some(cb_tramp::<G>),
3601 &mut processed,
3602 )
3603 };
3604 if rc != NROS_RMW_RET_OK {
3605 return Err(error_from_ret(rc));
3606 }
3607 Ok(processed)
3608 }
3609
3610 pub fn topic_name(&self) -> &str {
3611 cstr_buf_to_str(&self.topic_name_buf)
3612 }
3613
3614 pub fn type_name(&self) -> &str {
3615 cstr_buf_to_str(&self.type_name_buf)
3616 }
3617
3618 pub fn qos(&self) -> NrosRmwQos {
3619 self.qos
3620 }
3621
3622 /// `true` iff the backend exposes the receive loan primitive
3623 /// (Phase 99).
3624 pub fn can_loan_messages(&self) -> bool {
3625 self.can_loan_messages
3626 }
3627}
3628
3629/// Phase 124.A — read-only view returned by
3630/// [`CffiSubscription::try_borrow`]. Holds the backend's raw buffer +
3631/// opaque token until `Drop` fires `sub_release`.
3632#[cfg(feature = "lending")]
3633pub struct CffiView<'a> {
3634 buf: *const u8,
3635 len: usize,
3636 token: *mut generated::rmw_loan_token_t,
3637 subscriber: Option<&'a mut CffiSubscription>,
3638}
3639
3640#[cfg(feature = "lending")]
3641impl<'a> AsRef<[u8]> for CffiView<'a> {
3642 fn as_ref(&self) -> &[u8] {
3643 // SAFETY: `buf` came from `sub_borrow` with length `len`.
3644 // The borrow contract guarantees the buffer stays valid until
3645 // `sub_release` fires (in Drop). Lifetime `'a` borrows the
3646 // subscriber so the slice can't outlive the borrow.
3647 unsafe { core::slice::from_raw_parts(self.buf, self.len) }
3648 }
3649}
3650
3651#[cfg(feature = "lending")]
3652impl<'a> Drop for CffiView<'a> {
3653 fn drop(&mut self) {
3654 if let Some(sub) = self.subscriber.take()
3655 && let Some(release) = sub.vtable.return_loaned_message_from_subscription
3656 {
3657 let view = sub.make_view();
3658 // SAFETY: `token` paired with a prior `sub_borrow` on
3659 // this subscriber and the subscriber is still alive.
3660 let ret = unsafe { release(&view, self.token) };
3661 if ret != NROS_RMW_RET_OK {
3662 nros_log::nros_error!(
3663 nros_log::get_logger("nros_rmw_cffi"),
3664 "return_loaned_message_from_subscription failed with {}; the sample may stay checked out",
3665 ret
3666 );
3667 }
3668 }
3669 }
3670}
3671
3672#[cfg(feature = "lending")]
3673impl nros_rmw::SlotBorrowing for CffiSubscription {
3674 type View<'a> = CffiView<'a>;
3675
3676 fn try_borrow(&mut self) -> Result<Option<CffiView<'_>>, TransportError> {
3677 let Some(borrow) = self.vtable.take_loaned_message else {
3678 // Phase 124.A — backend doesn't natively borrow; runtime
3679 // falls back to `take_serialized` into a staging buffer
3680 // (124.A.3). `None` lets the caller use the slow path.
3681 return Ok(None);
3682 };
3683 let view = self.make_view();
3684 let mut out_view = generated::rmw_byte_span_t {
3685 data: core::ptr::null(),
3686 len: 0,
3687 };
3688 let mut out_token: *mut generated::rmw_loan_token_t = core::ptr::null_mut();
3689 // SAFETY: vtable contract — borrowed pointers stay valid
3690 // until `sub_release` runs.
3691 // Phase 376 W3.b/W3.d step A — status returned, `taken` out. The old
3692 // shape carried the length TWICE (returned and written to `*out_len`)
3693 // and reconciled them with `min(rc, max(out_len, rc))`, which is `rc`
3694 // for every input — so a backend whose two answers disagreed had one
3695 // silently ignored. There is one length now.
3696 let mut taken = false;
3697 let rc = unsafe { borrow(&view, &mut out_view, &mut out_token, &mut taken) };
3698 if rc != NROS_RMW_RET_OK {
3699 return Err(error_from_ret(rc));
3700 }
3701 let (out_buf, len) = (out_view.data, out_view.len);
3702 if !taken || out_buf.is_null() {
3703 return Ok(None);
3704 }
3705 Ok(Some(CffiView {
3706 buf: out_buf,
3707 len,
3708 token: out_token,
3709 subscriber: Some(self),
3710 }))
3711 }
3712}
3713
3714/// A take reported more bytes than the buffer it was handed — issue 0771.
3715///
3716/// Every copying take passes the vtable BOTH a pointer and the capacity, so an
3717/// `out_len` above that capacity is an ABI violation by the backend: it was
3718/// told how much room there was. The Rust side used to take the number on
3719/// faith, and a Cyclone service reply of 1005 bytes into a 256-byte buffer
3720/// panicked the SERVER process with `range end index 1005 out of range for
3721/// slice of length 256`.
3722///
3723/// It fails rather than truncating. `&buf[..cap]` would hand the caller a
3724/// silently short message — a corrupted payload presented as a good one, which
3725/// is worse than a loud stop and is the shape issue 0757 spent a phase
3726/// removing. `BufferTooSmall` is already this crate's word for it (the batch
3727/// take pre-checks with the same variant), and a caller that wants the sample
3728/// raises its buffer knob.
3729fn checked_take_len(out_len: usize, cap: usize) -> Result<usize, TransportError> {
3730 if out_len > cap {
3731 return Err(TransportError::BufferTooSmall);
3732 }
3733 Ok(out_len)
3734}
3735
3736impl nros_rmw::Subscription for CffiSubscription {
3737 type Error = TransportError;
3738
3739 fn supports_process_in_place(&self) -> bool {
3740 self.supports_in_place
3741 }
3742
3743 fn process_raw_in_place(&mut self, f: impl FnOnce(&[u8])) -> Result<bool, Self::Error> {
3744 self.run_process_in_place(f)
3745 }
3746
3747 fn has_data(&self) -> bool {
3748 // has_data takes &mut to match the C signature; cast away const
3749 // because the predicate is logically read-only — backends must
3750 // not mutate state from has_data.
3751 let view_ptr = self as *const _ as *mut Self;
3752 let mut view = unsafe { (*view_ptr).make_view() };
3753 // Phase 376 W3.d step A — the flag arrives in an out-parameter and the
3754 // return is a plain status. The old `rc > 0` read a NEGATIVE error as
3755 // "no data", which is the same answer an empty subscription gives: a
3756 // broken backend and a quiet one were indistinguishable here. The trait
3757 // returns `bool` and has no error channel, so an error still maps to
3758 // false — but now by an explicit decision rather than by the arithmetic
3759 // happening to say so.
3760 let mut has = false;
3761 let rc =
3762 unsafe { (self.vtable.has_data.expect("rmw vtable: has_data"))(&mut view, &mut has) };
3763 rc == NROS_RMW_RET_OK && has
3764 }
3765
3766 fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
3767 // Issue 0971 — a status parked by an earlier `take_sequence` is
3768 // delivered here too, so a caller that mixes the two entry points still
3769 // hears about it rather than having it silently outlive the batch that
3770 // produced it. Cyclone's native flag is checked at both of its take
3771 // entry points for the same reason.
3772 if let Some(status) = self.pending_status.take() {
3773 return Err(status);
3774 }
3775 let mut view = self.make_view();
3776 // Phase 376 W3.b/W3.d step A — `take` reports through out-parameters.
3777 // Three arms collapse into one: NO_DATA, a negative error, and the
3778 // `rc == 0` case that used to mean "zero bytes, treat as nothing" are
3779 // now `taken = false`, a status check, and `taken = true` with
3780 // `out_len == 0` respectively. That last one is a real behaviour fix:
3781 // a legitimately EMPTY message was previously indistinguishable from an
3782 // empty subscription.
3783 let mut taken = false;
3784 // phase-406 W2 — by POINTER: the callee sets `len`, and a by-value copy
3785 // would discard it. `capacity` in, `len` out.
3786 let mut span = generated::rmw_mut_byte_span_t {
3787 data: buf.as_mut_ptr(),
3788 capacity: buf.len(),
3789 len: 0,
3790 };
3791 let rc = unsafe {
3792 (self.vtable.take.expect("rmw vtable: take"))(&mut view, &mut span, &mut taken)
3793 };
3794 let out_len = span.len;
3795 if rc != NROS_RMW_RET_OK {
3796 return Err(error_from_ret(rc));
3797 }
3798 if !taken {
3799 return Ok(None);
3800 }
3801 Ok(Some(checked_take_len(out_len, buf.len())?))
3802 }
3803
3804 fn take_serialized_with_info(
3805 &mut self,
3806 buf: &mut [u8],
3807 ) -> Result<Option<(usize, Option<MessageInfo>)>, TransportError> {
3808 let key = self.backend_data as usize;
3809 self.take_serialized(buf)
3810 .map(|opt| opt.map(|len| (len, take_cffi_message_info(key))))
3811 }
3812
3813 #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
3814 fn take_validated(
3815 &mut self,
3816 buf: &mut [u8],
3817 ) -> Result<Option<(usize, nros_rmw::IntegrityStatus)>, Self::Error> {
3818 let key = self.backend_data as usize;
3819 request_cffi_integrity_status(key);
3820 self.take_serialized(buf).map(|opt| {
3821 opt.map(|len| {
3822 (
3823 len,
3824 take_cffi_integrity_status(key).unwrap_or(nros_rmw::IntegrityStatus {
3825 gap: 0,
3826 duplicate: false,
3827 crc_valid: None,
3828 }),
3829 )
3830 })
3831 })
3832 }
3833
3834 fn take_sequence(
3835 &mut self,
3836 buf: &mut [u8],
3837 per_msg_cap: usize,
3838 max_msgs: usize,
3839 out_lens: &mut [usize],
3840 ) -> Result<usize, TransportError> {
3841 // Phase 124.D.2 — runtime fallback. If the backend exposes
3842 // `take_sequence` natively, call it in one hop; otherwise
3843 // delegate to the trait's default body which loop-drives
3844 // `take_serialized`. Either way the caller sees the same shape:
3845 // contiguous slot block + per-slot length array + count
3846 // return.
3847 if let Some(f) = self.vtable.take_sequence {
3848 if per_msg_cap == 0 || max_msgs == 0 {
3849 return Ok(0);
3850 }
3851 let limit = max_msgs.min(out_lens.len());
3852 if buf.len() < limit.saturating_mul(per_msg_cap) {
3853 return Err(TransportError::BufferTooSmall);
3854 }
3855 let view = self.make_view();
3856 // Phase 376 W3.b/W3.d step A — the count arrives in `taken`.
3857 let mut taken = 0usize;
3858 let rc = unsafe {
3859 f(
3860 &view,
3861 buf.as_mut_ptr(),
3862 per_msg_cap,
3863 limit,
3864 out_lens.as_mut_ptr(),
3865 &mut taken,
3866 )
3867 };
3868 if rc != NROS_RMW_RET_OK {
3869 return Err(error_from_ret(rc));
3870 }
3871 return Ok(taken);
3872 }
3873 // Phase 124.D.2 — `take_serialized` loop fallback. Inlined
3874 // here (rather than dispatching back through the trait
3875 // default body) so the recursion is structurally
3876 // impossible — `Subscription::take_sequence` on
3877 // `CffiSubscription` is THIS function, and forwarding to
3878 // the default body would deadlock the override.
3879 if per_msg_cap == 0 || max_msgs == 0 {
3880 return Ok(0);
3881 }
3882 // Issue 0971 — a status parked by an earlier drain is delivered before
3883 // any new take, and taking nothing this call is the point: the caller
3884 // has to be able to act on it before it asks for more.
3885 if let Some(status) = self.pending_status.take() {
3886 return Err(status);
3887 }
3888 let limit = max_msgs.min(out_lens.len());
3889 if buf.len() < limit.saturating_mul(per_msg_cap) {
3890 return Err(TransportError::BufferTooSmall);
3891 }
3892 let mut count = 0;
3893 for i in 0..limit {
3894 let slot = &mut buf[i * per_msg_cap..(i + 1) * per_msg_cap];
3895 match self.take_serialized(slot) {
3896 Ok(Some(len)) => {
3897 out_lens[i] = len;
3898 count += 1;
3899 }
3900 Ok(None) => break,
3901 // Issue 0971 — this arm used to be `?`, which discarded `count`
3902 // and reported the error, so a caller could not tell how many
3903 // of the slots it can see are valid. The messages already
3904 // written are real and are reported; the status is parked for
3905 // the next call. With nothing delivered yet there is no count
3906 // worth protecting, so it goes out immediately — which is also
3907 // what makes the single-message case identical to `take_serialized`.
3908 Err(e) => {
3909 if count == 0 {
3910 return Err(e);
3911 }
3912 self.pending_status = Some(e);
3913 break;
3914 }
3915 }
3916 }
3917 Ok(count)
3918 }
3919
3920 fn deserialization_error(&self) -> TransportError {
3921 TransportError::DeserializationError
3922 }
3923
3924 fn unsupported_event_error(&self) -> TransportError {
3925 TransportError::Unsupported
3926 }
3927
3928 unsafe fn register_event_callback(
3929 &mut self,
3930 kind: nros_rmw::EventKind,
3931 deadline_ms: u32,
3932 cb: nros_rmw::EventCallback,
3933 user_ctx: *mut core::ffi::c_void,
3934 ) -> Result<(), TransportError> {
3935 let view = self.make_view();
3936 let cb: NrosRmwEventCallback = Some(unsafe {
3937 core::mem::transmute::<
3938 nros_rmw::EventCallback,
3939 unsafe extern "C" fn(NrosRmwEventKind, *const NrosRmwEventPayload, *mut c_void),
3940 >(cb)
3941 });
3942 // Issue 0349 — a NULL slot means the backend does not implement this
3943 // OPTIONAL capability (xrce NULLs all three). Report it as
3944 // `Unsupported`; never panic, and never make it a registration error.
3945 let Some(register) = self.vtable.subscription_event_init else {
3946 return Err(TransportError::Unsupported);
3947 };
3948 let ret = unsafe { register(&view, event_kind_to_c(kind), deadline_ms, cb, user_ctx) };
3949 if ret != NROS_RMW_RET_OK {
3950 return Err(error_from_ret(ret));
3951 }
3952 Ok(())
3953 }
3954}
3955
3956impl Drop for CffiSubscription {
3957 fn drop(&mut self) {
3958 if !self.backend_data.is_null() {
3959 clear_cffi_message_info(self.backend_data as usize);
3960 let mut view = self.make_view();
3961 let ret = unsafe {
3962 (self
3963 .vtable
3964 .destroy_subscription
3965 .expect("rmw vtable: destroy_subscription"))(&mut view)
3966 };
3967 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
3968 // that cannot propagate. Logging is not a consolation prize: a
3969 // teardown that failed is a leak, and a leak with no message
3970 // surfaces later as an allocation failure with no provenance.
3971 // `nros_log`, never std stdio — issue 0589.
3972 if ret != NROS_RMW_RET_OK {
3973 nros_log::nros_error!(
3974 nros_log::get_logger("nros_rmw_cffi"),
3975 "destroy_subscription failed with {}; the backend may have leaked the subscription",
3976 ret
3977 );
3978 }
3979 }
3980 }
3981}
3982
3983// ============================================================================
3984// CffiService
3985// ============================================================================
3986
3987/// Service server backed by a C vtable.
3988pub struct CffiService {
3989 vtable: &'static NrosRmwVtable,
3990 service_name_buf: [u8; NAME_BUF_LEN],
3991 type_name_buf: [u8; NAME_BUF_LEN],
3992 backend_data: *mut c_void,
3993}
3994
3995impl CffiService {
3996 fn make_view(&mut self) -> NrosRmwService {
3997 NrosRmwService {
3998 service_name: self.service_name_buf.as_ptr().cast(),
3999 type_name: self.type_name_buf.as_ptr().cast(),
4000 _reserved: [0u8; 8],
4001 backend_data: self.backend_data,
4002 }
4003 }
4004
4005 pub fn service_name(&self) -> &str {
4006 cstr_buf_to_str(&self.service_name_buf)
4007 }
4008
4009 pub fn type_name(&self) -> &str {
4010 cstr_buf_to_str(&self.type_name_buf)
4011 }
4012}
4013
4014impl ServiceTrait for CffiService {
4015 type Error = TransportError;
4016
4017 fn has_request(&self) -> bool {
4018 let view_ptr = self as *const _ as *mut Self;
4019 let mut view = unsafe { (*view_ptr).make_view() };
4020 // Phase 376 W3.d step A — see `has_data` above for why an error maps to
4021 // false explicitly rather than through `rc > 0`.
4022 let mut has = false;
4023 let rc = unsafe {
4024 (self.vtable.has_request.expect("rmw vtable: has_request"))(&mut view, &mut has)
4025 };
4026 rc == NROS_RMW_RET_OK && has
4027 }
4028
4029 fn take_request<'a>(
4030 &mut self,
4031 buf: &'a mut [u8],
4032 ) -> Result<Option<ServiceRequest<'a>>, TransportError> {
4033 let mut seq: i64 = 0;
4034 let mut view = self.make_view();
4035 // Phase 376 W3.b/W3.d step A — status returned, payload length and
4036 // `taken` in out-parameters. The `rc == 0` arm is gone with the same
4037 // fix `take` got: a zero-length REQUEST is a legitimate message and
4038 // used to be indistinguishable from an empty queue.
4039 let mut request_span = generated::rmw_mut_byte_span_t {
4040 data: buf.as_mut_ptr(),
4041 capacity: buf.len(),
4042 len: 0,
4043 };
4044 let mut taken = false;
4045 let rc = unsafe {
4046 (self.vtable.take_request.expect("rmw vtable: take_request"))(
4047 &mut view,
4048 &mut request_span,
4049 &mut seq,
4050 &mut taken,
4051 )
4052 };
4053 if rc != NROS_RMW_RET_OK {
4054 return Err(error_from_ret(rc));
4055 }
4056 if !taken {
4057 return Ok(None);
4058 }
4059 let len = checked_take_len(request_span.len, buf.len())?;
4060 Ok(Some(ServiceRequest {
4061 data: &buf[..len],
4062 sequence_number: seq,
4063 }))
4064 }
4065
4066 fn send_response(&mut self, sequence_number: i64, data: &[u8]) -> Result<(), TransportError> {
4067 let mut view = self.make_view();
4068 let ret = unsafe {
4069 (self
4070 .vtable
4071 .send_response
4072 .expect("rmw vtable: send_response"))(
4073 &mut view,
4074 sequence_number,
4075 generated::rmw_byte_span_t {
4076 data: data.as_ptr(),
4077 len: data.len(),
4078 },
4079 )
4080 };
4081 if ret != NROS_RMW_RET_OK {
4082 return Err(error_from_ret(ret));
4083 }
4084 Ok(())
4085 }
4086}
4087
4088impl Drop for CffiService {
4089 fn drop(&mut self) {
4090 if !self.backend_data.is_null() {
4091 let mut view = self.make_view();
4092 let ret = unsafe {
4093 (self
4094 .vtable
4095 .destroy_service
4096 .expect("rmw vtable: destroy_service"))(&mut view)
4097 };
4098 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
4099 // that cannot propagate. Logging is not a consolation prize: a
4100 // teardown that failed is a leak, and a leak with no message
4101 // surfaces later as an allocation failure with no provenance.
4102 // `nros_log`, never std stdio — issue 0589.
4103 if ret != NROS_RMW_RET_OK {
4104 nros_log::nros_error!(
4105 nros_log::get_logger("nros_rmw_cffi"),
4106 "destroy_service failed with {}; the backend may have leaked the service",
4107 ret
4108 );
4109 }
4110 }
4111 }
4112}
4113
4114// ============================================================================
4115// CffiClient
4116// ============================================================================
4117
4118/// Service client backed by a C vtable.
4119pub struct CffiClient {
4120 vtable: &'static NrosRmwVtable,
4121 service_name_buf: [u8; NAME_BUF_LEN],
4122 type_name_buf: [u8; NAME_BUF_LEN],
4123 backend_data: *mut c_void,
4124}
4125
4126impl CffiClient {
4127 fn make_view(&mut self) -> NrosRmwClient {
4128 NrosRmwClient {
4129 service_name: self.service_name_buf.as_ptr().cast(),
4130 type_name: self.type_name_buf.as_ptr().cast(),
4131 _reserved: [0u8; 8],
4132 backend_data: self.backend_data,
4133 }
4134 }
4135
4136 pub fn service_name(&self) -> &str {
4137 cstr_buf_to_str(&self.service_name_buf)
4138 }
4139
4140 pub fn type_name(&self) -> &str {
4141 cstr_buf_to_str(&self.type_name_buf)
4142 }
4143}
4144
4145impl ClientTrait for CffiClient {
4146 type Error = TransportError;
4147
4148 fn send_request_raw(&mut self, request: &[u8]) -> Result<i64, TransportError> {
4149 // Phase-301 (issue 0240) — `send_request_raw` +
4150 // `take_response_raw` is the ONE request/reply path (the
4151 // blocking `call_raw` slot is gone from the vtable). Backends
4152 // that omit the slot get `Unsupported`; the executor surfaces
4153 // the error instead of silently degrading.
4154 let Some(f) = self.vtable.send_request else {
4155 return Err(TransportError::Unsupported);
4156 };
4157 let view = self.make_view();
4158 // Issue 0778 — the id the backend assigned. A backend that leaves it
4159 // untouched reports 0, which is a legal id; nothing here treats it as
4160 // a sentinel.
4161 let mut sequence_id: i64 = 0;
4162 let rc = unsafe {
4163 f(
4164 &view,
4165 generated::rmw_byte_span_t {
4166 data: request.as_ptr(),
4167 len: request.len(),
4168 },
4169 &mut sequence_id,
4170 )
4171 };
4172 if rc != NROS_RMW_RET_OK {
4173 return Err(error_from_ret(rc));
4174 }
4175 Ok(sequence_id)
4176 }
4177
4178 fn take_response_raw(
4179 &mut self,
4180 reply_buf: &mut [u8],
4181 ) -> Result<Option<(usize, i64)>, TransportError> {
4182 // Non-blocking poll only. NULL slot = backend doesn't implement
4183 // the service-client path; surface Unsupported.
4184 let Some(f) = self.vtable.take_response else {
4185 return Err(TransportError::Unsupported);
4186 };
4187 let view = self.make_view();
4188 // Phase 376 W3.b/W3.d step A — see `take_request`.
4189 let mut reply_span = generated::rmw_mut_byte_span_t {
4190 data: reply_buf.as_mut_ptr(),
4191 capacity: reply_buf.len(),
4192 len: 0,
4193 };
4194 let mut taken = false;
4195 let mut seq: i64 = 0;
4196 let rc = unsafe { f(&view, &mut reply_span, &mut seq, &mut taken) };
4197 if rc != NROS_RMW_RET_OK {
4198 return Err(error_from_ret(rc));
4199 }
4200 if !taken {
4201 return Ok(None);
4202 }
4203 Ok(Some((
4204 checked_take_len(reply_span.len, reply_buf.len())?,
4205 seq,
4206 )))
4207 }
4208
4209 fn service_is_ready(&self) -> Result<bool, TransportError> {
4210 let Some(f) = self.vtable.service_server_is_available else {
4211 return Err(TransportError::Unsupported);
4212 };
4213 // SAFETY: `f` accepts a `*mut NrosRmwClient`. We
4214 // construct a transient view from this client's fields the
4215 // same way `make_view` does, but on `&self` (no mutation
4216 // required for a graph probe). The borrowed pointers all
4217 // alias into `&self`, so the lifetime is bounded by the
4218 // call.
4219 let view = NrosRmwClient {
4220 service_name: self.service_name_buf.as_ptr().cast(),
4221 type_name: self.type_name_buf.as_ptr().cast(),
4222 _reserved: [0u8; 8],
4223 backend_data: self.backend_data,
4224 };
4225 // Phase 376 W3.d step A — the slot answers through an out-parameter and
4226 // returns a plain status, so there is no non-spec value left to be
4227 // lenient about: the old arm treating "any positive other than 1" as
4228 // available existed only because a count and a status shared one int.
4229 let mut available = false;
4230 let rc = unsafe { f(&view, &mut available) };
4231 if rc != NROS_RMW_RET_OK {
4232 return Err(error_from_ret(rc));
4233 }
4234 Ok(available)
4235 }
4236}
4237
4238impl Drop for CffiClient {
4239 fn drop(&mut self) {
4240 if !self.backend_data.is_null() {
4241 let mut view = self.make_view();
4242 let ret = unsafe {
4243 (self
4244 .vtable
4245 .destroy_client
4246 .expect("rmw vtable: destroy_client"))(&mut view)
4247 };
4248 // Phase 376 W5 — the slot reports now, and `Drop` is the one caller
4249 // that cannot propagate. Logging is not a consolation prize: a
4250 // teardown that failed is a leak, and a leak with no message
4251 // surfaces later as an allocation failure with no provenance.
4252 // `nros_log`, never std stdio — issue 0589.
4253 if ret != NROS_RMW_RET_OK {
4254 nros_log::nros_error!(
4255 nros_log::get_logger("nros_rmw_cffi"),
4256 "destroy_client failed with {}; the backend may have leaked the client",
4257 ret
4258 );
4259 }
4260 }
4261 }
4262}
4263
4264// ============================================================================
4265// Factory
4266// ============================================================================
4267
4268/// RMW factory for the C function table backend.
4269#[derive(Default)]
4270pub struct CffiRmw;
4271
4272impl nros_rmw::Rmw for CffiRmw {
4273 type Session = CffiSession;
4274 type Error = TransportError;
4275
4276 fn open(self, config: &nros_rmw::RmwConfig) -> Result<CffiSession, TransportError> {
4277 // issue 0331 — the wire values are specified by
4278 // `nros_rmw_session_mode_t` in rmw_vtable.h; keep this match aligned
4279 // with it rather than restating bare literals.
4280 let mode = match config.mode {
4281 nros_rmw::SessionMode::Client => {
4282 generated::nros_rmw_session_mode_t::NROS_RMW_SESSION_MODE_CLIENT as u8
4283 }
4284 nros_rmw::SessionMode::Peer => {
4285 generated::nros_rmw_session_mode_t::NROS_RMW_SESSION_MODE_PEER as u8
4286 }
4287 };
4288 // phase-206 W3 — forward `config.properties` instead of dropping it.
4289 // This was the outbound half of the same silence the cffi adapter had
4290 // on the inbound side: the trait handed us properties, and the seam
4291 // threw them away without a word.
4292 CffiSession::open_with_properties(
4293 config.locator,
4294 mode,
4295 config.domain_id,
4296 config.node_name,
4297 config.properties,
4298 )
4299 }
4300}
4301
4302impl CffiRmw {
4303 /// Phase 104.C.1 — open a session against a named backend.
4304 /// `rmw_name` selects an entry from the registry populated by
4305 /// `nros_rmw_cffi_register_named` (Phase 104.B.2).
4306 pub fn open_with_rmw(
4307 rmw_name: &str,
4308 config: &nros_rmw::RmwConfig,
4309 ) -> Result<CffiSession, TransportError> {
4310 let mode = match config.mode {
4311 nros_rmw::SessionMode::Client => 0u8,
4312 nros_rmw::SessionMode::Peer => 1u8,
4313 };
4314 CffiSession::open_named_with_properties(
4315 rmw_name,
4316 config.locator,
4317 mode,
4318 config.domain_id,
4319 config.node_name,
4320 config.properties,
4321 )
4322 }
4323}
4324
4325// ============================================================================
4326// Phase 102.5 — typed-struct roundtrip test
4327// ============================================================================
4328//
4329// Verifies the visible-struct contract end-to-end:
4330// 1. Runtime fills `topic_name` / `type_name` / `qos` before
4331// `create_publisher`.
4332// 2. Backend's `create_publisher` writes `backend_data` and
4333// `can_loan_messages` into the same struct.
4334// 3. Rust accessors (`CffiPublisher::topic_name()`, `qos()`,
4335// `can_loan_messages()`) read back the values without any
4336// vtable callback.
4337
4338// ============================================================================
4339// Phase 376 W5 — backend log severity
4340// ============================================================================
4341
4342/// Map `nros_log`'s ladder onto upstream's.
4343///
4344/// `Trace` has no upstream counterpart and folds into `DEBUG`. Losing a
4345/// distinction upstream never had is better than inventing a value a ROS-side
4346/// caller could not produce — the mapping is deliberately lossy in the
4347/// direction that keeps the wire vocabulary standard.
4348#[must_use]
4349pub fn rmw_severity_of(severity: nros_log::Severity) -> generated::rmw_log_severity_t::Type {
4350 match severity {
4351 nros_log::Severity::Trace | nros_log::Severity::Debug => {
4352 generated::rmw_log_severity_t::RMW_LOG_SEVERITY_DEBUG
4353 }
4354 nros_log::Severity::Info => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_INFO,
4355 nros_log::Severity::Warn => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_WARN,
4356 nros_log::Severity::Error => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_ERROR,
4357 nros_log::Severity::Fatal => generated::rmw_log_severity_t::RMW_LOG_SEVERITY_FATAL,
4358 }
4359}
4360
4361/// Set the verbosity of every registered BACKEND's own logging.
4362///
4363/// This is the backend's logger — Cyclone's `dds_log`, zenoh-pico's — not
4364/// `nros_log`, which is the runtime's and is set directly through
4365/// `nros_log::Logger::set_level` with no ABI involved.
4366///
4367/// Applies to EVERY registered backend, because an image may link more than one
4368/// (`nros_rmw_cffi_register_named`) and verbosity is a property of the process
4369/// rather than of a session. Upstream has no equivalent decision to make: it
4370/// loads one implementation.
4371///
4372/// Returns `Unsupported` when no registered backend implements the slot, the
4373/// first error any backend reported otherwise, and `Ok` when at least one
4374/// accepted it.
4375pub fn set_backend_log_severity(severity: nros_log::Severity) -> Result<(), TransportError> {
4376 const MAX: usize = 8;
4377 let mut names: [*const core::ffi::c_char; MAX] = [core::ptr::null(); MAX];
4378 // SAFETY: `names` is `MAX` entries and the callee writes at most that many.
4379 let n = unsafe { nros_rmw_cffi_registered_names(names.as_mut_ptr(), MAX) };
4380
4381 let wire = rmw_severity_of(severity);
4382 let mut applied = false;
4383 let mut first_err = None;
4384 for name in names.iter().take(n.min(MAX)) {
4385 // SAFETY: the registry hands back NUL-terminated static names.
4386 let vt = unsafe { nros_rmw_cffi_lookup(*name) };
4387 if vt.is_null() {
4388 continue;
4389 }
4390 // SAFETY: a non-null lookup yields a vtable valid for the image's life.
4391 let Some(f) = (unsafe { (*vt).set_log_severity }) else {
4392 continue;
4393 };
4394 // SAFETY: the slot takes a plain enum by value.
4395 let rc = unsafe { f(wire) };
4396 if rc == NROS_RMW_RET_OK {
4397 applied = true;
4398 } else if first_err.is_none() {
4399 first_err = Some(error_from_ret(rc));
4400 }
4401 }
4402
4403 match (applied, first_err) {
4404 (true, _) => Ok(()),
4405 (false, Some(e)) => Err(e),
4406 (false, None) => Err(TransportError::Unsupported),
4407 }
4408}
4409
4410// ============================================================================
4411// Phase 376 W4 — the two PURE functions, defined ONCE
4412// ============================================================================
4413//
4414// Declared in `nros/rmw_entity.h`, which explains at length why they are plain
4415// exported functions rather than vtable slots. The one-line version: a vtable
4416// slot is the mechanism for letting backends DIFFER, and these two must not.
4417// They compute over types the ABI defines, they take no entity, and they are
4418// wanted at create time before any backend has registered.
4419//
4420// Defined here for the same reason `nros_rmw_cffi_register_named` is: it keeps
4421// `nros-rmw-abi` a header-only INTERFACE target, with no compiled TU and no new
4422// link edge, and it keeps the implementation in ONE place — which is the whole
4423// point.
4424
4425/// Reason strings, one per clash bit. SELECTED, never formatted: upstream's
4426/// implementations `snprintf` their reason, which would pull the printf engine
4427/// into images that deliberately excluded it.
4428const CLASH_REASONS: &[(u32, &str)] = &[
4429 (
4430 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_RELIABILITY,
4431 "reliability: publisher is best-effort, subscription requires reliable; ",
4432 ),
4433 (
4434 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DURABILITY,
4435 "durability: publisher is volatile, subscription requires transient-local; ",
4436 ),
4437 (
4438 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DEADLINE,
4439 "deadline: publisher's period is longer than the subscription requires; ",
4440 ),
4441 (
4442 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_KIND,
4443 "liveliness: publisher's kind is weaker than the subscription requires; ",
4444 ),
4445 (
4446 generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_LEASE,
4447 "liveliness lease: publisher's lease is longer than the subscription requires; ",
4448 ),
4449];
4450
4451/// `0` means "unset/no check", and `NROS_RMW_DURATION_INFINITE_MS` means
4452/// explicit infinity — so neither can be compared as a plain number. Map both to
4453/// "infinitely lax" for an OFFERED duration and "infinitely tolerant" for a
4454/// REQUESTED one, which is the same thing: `u64::MAX`.
4455fn duration_or_infinite(ms: u32) -> u64 {
4456 if ms == 0 || u64::from(ms) == generated::NROS_RMW_DURATION_INFINITE_MS as u64 {
4457 u64::MAX
4458 } else {
4459 u64::from(ms)
4460 }
4461}
4462
4463/// How strict a liveliness kind is. A publisher must assert at least as
4464/// strongly as the subscription asks.
4465fn liveliness_strength(kind: u8) -> u8 {
4466 match u32::from(kind) {
4467 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_MANUAL_BY_TOPIC => 3,
4468 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_MANUAL_BY_NODE => 2,
4469 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_AUTOMATIC => 1,
4470 _ => 0,
4471 }
4472}
4473
4474/// The DDS request-offered rules, in one place.
4475/// Does this profile leave any policy UNDETERMINED?
4476///
4477/// Phase 376 W5/B2. `*_UNKNOWN` means "the backend could not read this back",
4478/// which is an ABSENCE, not a value: comparing it as if it were one produces a
4479/// confident wrong verdict. Upstream's answer to that is
4480/// `RMW_QOS_COMPATIBILITY_WARNING` — "these look compatible, but I could not
4481/// check everything" — which was unreachable here until there was a sentinel
4482/// that could trigger it.
4483fn qos_has_unknown(q: &NrosRmwQos) -> bool {
4484 i32::from(q.reliability) == generated::NROS_RMW_RELIABILITY_UNKNOWN
4485 || i32::from(q.durability) == generated::NROS_RMW_DURABILITY_UNKNOWN
4486 || i32::from(q.history) == generated::NROS_RMW_HISTORY_UNKNOWN
4487 || u32::from(q.liveliness_kind)
4488 == generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_UNKNOWN
4489}
4490
4491fn qos_clash_mask(offered: &NrosRmwQos, requested: &NrosRmwQos) -> u32 {
4492 let mut mask = 0u32;
4493 if requested.reliability == generated::NROS_RMW_RELIABILITY_RELIABLE as u8
4494 && offered.reliability == generated::NROS_RMW_RELIABILITY_BEST_EFFORT as u8
4495 {
4496 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_RELIABILITY;
4497 }
4498 if requested.durability == generated::NROS_RMW_DURABILITY_TRANSIENT_LOCAL as u8
4499 && offered.durability == generated::NROS_RMW_DURABILITY_VOLATILE as u8
4500 {
4501 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DURABILITY;
4502 }
4503 // A publisher promising samples no more often than every N ms cannot
4504 // satisfy a subscription that demands one at least every M ms when N > M.
4505 if duration_or_infinite(offered.deadline_ms) > duration_or_infinite(requested.deadline_ms) {
4506 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_DEADLINE;
4507 }
4508 if liveliness_strength(offered.liveliness_kind) < liveliness_strength(requested.liveliness_kind)
4509 {
4510 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_KIND;
4511 }
4512 if duration_or_infinite(offered.liveliness_lease_ms)
4513 > duration_or_infinite(requested.liveliness_lease_ms)
4514 {
4515 mask |= generated::nros_rmw_qos_clash_t::NROS_RMW_QOS_CLASH_LIVELINESS_LEASE;
4516 }
4517 mask
4518}
4519
4520/// See `nros/rmw_entity.h`.
4521///
4522/// # Safety
4523/// `compatibility` and `clash_mask` must be valid for writes when non-NULL.
4524#[unsafe(no_mangle)]
4525pub unsafe extern "C" fn nros_rmw_qos_incompatibility_mask(
4526 offered: NrosRmwQos,
4527 requested: NrosRmwQos,
4528 compatibility: *mut generated::rmw_qos_compatibility_type_t::Type,
4529 clash_mask: *mut u32,
4530) -> NrosRmwRet {
4531 if compatibility.is_null() || clash_mask.is_null() {
4532 return NROS_RMW_RET_INVALID_ARGUMENT;
4533 }
4534 let mask = qos_clash_mask(&offered, &requested);
4535 let undetermined = qos_has_unknown(&offered) || qos_has_unknown(&requested);
4536 // SAFETY: both checked non-null above.
4537 unsafe {
4538 *clash_mask = mask;
4539 // A definite clash outranks an unknown: policies we COULD compare are
4540 // already incompatible, and reporting that as a warning would soften a
4541 // verdict the caller can act on.
4542 *compatibility = if mask != 0 {
4543 generated::rmw_qos_compatibility_type_t::RMW_QOS_COMPATIBILITY_ERROR
4544 } else if undetermined {
4545 generated::rmw_qos_compatibility_type_t::RMW_QOS_COMPATIBILITY_WARNING
4546 } else {
4547 generated::rmw_qos_compatibility_type_t::RMW_QOS_COMPATIBILITY_OK
4548 };
4549 }
4550 NROS_RMW_RET_OK
4551}
4552
4553/// See `nros/rmw_entity.h`.
4554///
4555/// # Safety
4556/// `compatibility` must be valid for writes; `reason` must be valid for
4557/// `reason_size` bytes when non-NULL.
4558#[unsafe(no_mangle)]
4559pub unsafe extern "C" fn rmw_qos_profile_check_compatible(
4560 publisher_profile: NrosRmwQos,
4561 subscription_profile: NrosRmwQos,
4562 compatibility: *mut generated::rmw_qos_compatibility_type_t::Type,
4563 reason: *mut core::ffi::c_char,
4564 reason_size: usize,
4565) -> NrosRmwRet {
4566 let mut mask = 0u32;
4567 // SAFETY: forwarding the caller's `compatibility` pointer, which the callee
4568 // null-checks; `mask` is a local.
4569 let rc = unsafe {
4570 nros_rmw_qos_incompatibility_mask(
4571 publisher_profile,
4572 subscription_profile,
4573 compatibility,
4574 &mut mask,
4575 )
4576 };
4577 if rc != NROS_RMW_RET_OK {
4578 return rc;
4579 }
4580 if reason.is_null() || reason_size == 0 {
4581 // The create-time path: the verdict is the whole answer.
4582 return NROS_RMW_RET_OK;
4583 }
4584 // Bounded copy, always NUL-terminated. Truncation is NOT an error: a
4585 // `BUFFER_TOO_SMALL` here would cost the caller the verdict, which is the
4586 // half that matters.
4587 let mut written = 0usize;
4588 for (bit, text) in CLASH_REASONS {
4589 if mask & bit == 0 {
4590 continue;
4591 }
4592 for byte in text.as_bytes() {
4593 if written + 1 >= reason_size {
4594 break;
4595 }
4596 // SAFETY: `written + 1 < reason_size`, so this and the NUL below are
4597 // both inside the caller's buffer.
4598 unsafe { *reason.add(written) = *byte as core::ffi::c_char };
4599 written += 1;
4600 }
4601 }
4602 // SAFETY: `written < reason_size` by the bound above.
4603 unsafe { *reason.add(written) = 0 };
4604 NROS_RMW_RET_OK
4605}
4606
4607/// See `nros/rmw_entity.h`.
4608///
4609/// # Safety
4610/// Both gids must be valid for reads and `result` valid for writes when
4611/// non-NULL.
4612#[unsafe(no_mangle)]
4613pub unsafe extern "C" fn rmw_compare_gids_equal(
4614 gid1: *const generated::rmw_gid_t,
4615 gid2: *const generated::rmw_gid_t,
4616 result: *mut bool,
4617) -> NrosRmwRet {
4618 if gid1.is_null() || gid2.is_null() || result.is_null() {
4619 return NROS_RMW_RET_INVALID_ARGUMENT;
4620 }
4621 // SAFETY: all three checked non-null above.
4622 let (a, b) = unsafe { (&*gid1, &*gid2) };
4623 // Identity first: gids from two backends are never equal, whatever their
4624 // bytes say. `register_named` admits several backends in one image, so this
4625 // is reachable here in a way it is not upstream.
4626 let same_impl = match (a.implementation_identifier, b.implementation_identifier) {
4627 (x, y) if x == y => true,
4628 (x, y) if x.is_null() || y.is_null() => false,
4629 // SAFETY: both non-null, and a backend's identifier is a static C string.
4630 (x, y) => unsafe { core::ffi::CStr::from_ptr(x) == core::ffi::CStr::from_ptr(y) },
4631 };
4632 // SAFETY: checked non-null above.
4633 unsafe { *result = same_impl && a.data == b.data };
4634 NROS_RMW_RET_OK
4635}
4636
4637#[cfg(test)]
4638#[allow(static_mut_refs)]
4639mod tests {
4640 use super::*;
4641 use nros_rmw::{Rmw, RmwConfig, Session, SessionMode, TopicInfo};
4642
4643 // Stub backend state. Statically allocated; the vtable's
4644 // `backend_data` round-trips a `&'static mut StubBackend`.
4645 static mut STUB_OPEN_CALLED: bool = false;
4646 static mut STUB_CREATE_PUB_CALLED: bool = false;
4647 static mut STUB_PUBLISH_CALLED: bool = false;
4648 static mut STUB_LAST_TOPIC_NAME: [u8; 64] = [0u8; 64];
4649 static mut STUB_LAST_TYPE_NAME: [u8; 64] = [0u8; 64];
4650 static mut STUB_LAST_QOS: NrosRmwQos = NrosRmwQos {
4651 reliability: 0,
4652 durability: 0,
4653 history: 0,
4654 liveliness_kind: 0,
4655 depth: 0,
4656 _reserved0: 0,
4657 deadline_ms: 0,
4658 lifespan_ms: 0,
4659 liveliness_lease_ms: 0,
4660 avoid_ros_namespace_conventions: 0,
4661 _reserved1: [0; 3],
4662 };
4663
4664 /// Read a null-terminated `*const u8` into the supplied byte
4665 /// buffer. Used by the stub backend to capture the topic / type
4666 /// names that the runtime hands it.
4667 unsafe fn copy_cstr(src: *const core::ffi::c_char, dst: &mut [u8]) {
4668 let src = src.cast::<u8>();
4669 let mut i = 0;
4670 while i < dst.len() {
4671 let b = unsafe { *src.add(i) };
4672 dst[i] = b;
4673 if b == 0 {
4674 break;
4675 }
4676 i += 1;
4677 }
4678 }
4679
4680 unsafe extern "C" fn stub_create_session(
4681 _locator: *const core::ffi::c_char,
4682 _mode: u8,
4683 _domain_id: u32,
4684 _node_name: *const core::ffi::c_char,
4685 _options: *const NrosRmwSessionOptions,
4686 out: *mut NrosRmwSession,
4687 ) -> NrosRmwRet {
4688 unsafe {
4689 STUB_OPEN_CALLED = true;
4690 (*out).backend_data = 0xDEAD_BEEFusize as *mut c_void;
4691 }
4692 NROS_RMW_RET_OK
4693 }
4694
4695 unsafe extern "C" fn stub_destroy_session(_session: *mut NrosRmwSession) -> NrosRmwRet {
4696 NROS_RMW_RET_OK
4697 }
4698
4699 unsafe extern "C" fn stub_drive_io(
4700 _session: *mut NrosRmwSession,
4701 _timeout_ms: i32,
4702 ) -> NrosRmwRet {
4703 NROS_RMW_RET_OK
4704 }
4705
4706 unsafe extern "C" fn stub_create_publisher(
4707 _session: *const NrosRmwNode,
4708 _type_support: *const generated::rmw_message_type_support_t,
4709 _topic_name: *const core::ffi::c_char,
4710 _domain_id: u32,
4711 qos: *const NrosRmwQos,
4712 _options: *const rmw_publisher_options_t,
4713 out: *mut NrosRmwPublisher,
4714 ) -> NrosRmwRet {
4715 // Capture the typed-struct fields the runtime supplied.
4716 unsafe {
4717 STUB_CREATE_PUB_CALLED = true;
4718 copy_cstr((*out).topic_name, &mut STUB_LAST_TOPIC_NAME);
4719 copy_cstr((*out).type_name, &mut STUB_LAST_TYPE_NAME);
4720 STUB_LAST_QOS = *qos;
4721 (*out).backend_data = 0xCAFEusize as *mut c_void;
4722 (*out).can_loan_messages = true;
4723 }
4724 NROS_RMW_RET_OK
4725 }
4726
4727 unsafe extern "C" fn stub_destroy_publisher(_publisher: *mut NrosRmwPublisher) -> NrosRmwRet {
4728 NROS_RMW_RET_OK
4729 }
4730
4731 unsafe extern "C" fn stub_publish_raw(
4732 publisher: *const NrosRmwPublisher,
4733 _payload: generated::rmw_byte_span_t,
4734 ) -> NrosRmwRet {
4735 // Verify the runtime is still passing the same backend_data
4736 // and topic_name on every call.
4737 unsafe {
4738 STUB_PUBLISH_CALLED = true;
4739 assert_eq!((*publisher).backend_data as usize, 0xCAFE);
4740 let mut buf = [0u8; 64];
4741 copy_cstr((*publisher).topic_name, &mut buf);
4742 assert_eq!(&buf[..], &STUB_LAST_TOPIC_NAME);
4743 }
4744 NROS_RMW_RET_OK
4745 }
4746
4747 unsafe extern "C" fn stub_create_subscription(
4748 _: *const NrosRmwNode,
4749 _: *const generated::rmw_message_type_support_t,
4750 _: *const core::ffi::c_char,
4751 _: u32,
4752 _: *const NrosRmwQos,
4753 _: *const rmw_subscription_options_t,
4754 out: *mut NrosRmwSubscription,
4755 ) -> NrosRmwRet {
4756 unsafe {
4757 (*out).backend_data = core::ptr::dangling_mut::<c_void>();
4758 }
4759 NROS_RMW_RET_OK
4760 }
4761 unsafe extern "C" fn stub_destroy_subscription(_: *mut NrosRmwSubscription) -> NrosRmwRet {
4762 NROS_RMW_RET_OK
4763 }
4764 unsafe extern "C" fn stub_take(
4765 _: *const NrosRmwSubscription,
4766 _out: *mut generated::rmw_mut_byte_span_t,
4767 taken: *mut bool,
4768 ) -> NrosRmwRet {
4769 // Phase 376 W3.d step A — the stub takes nothing, which is now stated
4770 // rather than encoded as a zero byte count.
4771 unsafe { *taken = false };
4772 NROS_RMW_RET_OK
4773 }
4774 unsafe extern "C" fn stub_has_data(
4775 _: *mut NrosRmwSubscription,
4776 out_has_data: *mut bool,
4777 ) -> NrosRmwRet {
4778 // Phase 376 W3.d step A — flag out, status returned.
4779 unsafe { *out_has_data = false };
4780 NROS_RMW_RET_OK
4781 }
4782
4783 unsafe extern "C" fn stub_create_service(
4784 _: *const NrosRmwNode,
4785 _: *const generated::rmw_service_type_support_t,
4786 _: *const core::ffi::c_char,
4787 _: u32,
4788 _: *const NrosRmwQos,
4789 out: *mut NrosRmwService,
4790 ) -> NrosRmwRet {
4791 unsafe {
4792 (*out).backend_data = core::ptr::dangling_mut::<c_void>();
4793 }
4794 NROS_RMW_RET_OK
4795 }
4796 unsafe extern "C" fn stub_destroy_service(_: *mut NrosRmwService) -> NrosRmwRet {
4797 NROS_RMW_RET_OK
4798 }
4799 unsafe extern "C" fn stub_take_request(
4800 _: *const NrosRmwService,
4801 _: *mut crate::generated::rmw_mut_byte_span_t,
4802 _: *mut i64,
4803 taken: *mut bool,
4804 ) -> NrosRmwRet {
4805 // Phase 376 W3.d step A — NO_DATA retires: nothing to take is
4806 // `taken = false` with OK.
4807 unsafe { *taken = false };
4808 NROS_RMW_RET_OK
4809 }
4810 unsafe extern "C" fn stub_has_request(
4811 _: *mut NrosRmwService,
4812 out_has_request: *mut bool,
4813 ) -> NrosRmwRet {
4814 // Phase 376 W3.d step A — flag out, status returned.
4815 unsafe { *out_has_request = false };
4816 NROS_RMW_RET_OK
4817 }
4818 unsafe extern "C" fn stub_send_response(
4819 _: *const NrosRmwService,
4820 _: i64,
4821 _: crate::generated::rmw_byte_span_t,
4822 ) -> NrosRmwRet {
4823 NROS_RMW_RET_OK
4824 }
4825
4826 unsafe extern "C" fn stub_create_client(
4827 _: *const NrosRmwNode,
4828 _: *const generated::rmw_service_type_support_t,
4829 _: *const core::ffi::c_char,
4830 _: u32,
4831 _: *const NrosRmwQos,
4832 out: *mut NrosRmwClient,
4833 ) -> NrosRmwRet {
4834 unsafe {
4835 (*out).backend_data = core::ptr::dangling_mut::<c_void>();
4836 }
4837 NROS_RMW_RET_OK
4838 }
4839 unsafe extern "C" fn stub_destroy_client(_: *mut NrosRmwClient) -> NrosRmwRet {
4840 NROS_RMW_RET_OK
4841 }
4842 unsafe extern "C" fn stub_register_subscription_event(
4843 _: *const NrosRmwSubscription,
4844 _: NrosRmwEventKind,
4845 _: u32,
4846 _: NrosRmwEventCallback,
4847 _: *mut c_void,
4848 ) -> NrosRmwRet {
4849 NROS_RMW_RET_UNSUPPORTED
4850 }
4851 unsafe extern "C" fn stub_register_publisher_event(
4852 _: *const NrosRmwPublisher,
4853 _: NrosRmwEventKind,
4854 _: u32,
4855 _: NrosRmwEventCallback,
4856 _: *mut c_void,
4857 ) -> NrosRmwRet {
4858 NROS_RMW_RET_UNSUPPORTED
4859 }
4860 unsafe extern "C" fn stub_assert_publisher_liveliness(
4861 _: *const NrosRmwPublisher,
4862 ) -> NrosRmwRet {
4863 NROS_RMW_RET_UNSUPPORTED
4864 }
4865
4866 static STUB_VTABLE: NrosRmwVtable = NrosRmwVtable {
4867 create_session: Some(stub_create_session),
4868 destroy_session: Some(stub_destroy_session),
4869 drive_io: Some(stub_drive_io),
4870 create_publisher: Some(stub_create_publisher),
4871 destroy_publisher: Some(stub_destroy_publisher),
4872 publish: Some(stub_publish_raw),
4873 create_subscription: Some(stub_create_subscription),
4874 destroy_subscription: Some(stub_destroy_subscription),
4875 take: Some(stub_take),
4876 has_data: Some(stub_has_data),
4877 create_service: Some(stub_create_service),
4878 destroy_service: Some(stub_destroy_service),
4879 take_request: Some(stub_take_request),
4880 has_request: Some(stub_has_request),
4881 send_response: Some(stub_send_response),
4882 create_client: Some(stub_create_client),
4883 destroy_client: Some(stub_destroy_client),
4884 subscription_event_init: Some(stub_register_subscription_event),
4885 publisher_event_init: Some(stub_register_publisher_event),
4886 publisher_assert_liveliness: Some(stub_assert_publisher_liveliness),
4887 ..EMPTY_VTABLE
4888 };
4889
4890 // Phase-301 (issue 0241) — boundary semantics of the QoS lowering.
4891
4892 #[test]
4893 fn qos_depth_at_u16_max_lowers() {
4894 let qos = nros_rmw::QoSProfile::default().keep_last(u16::MAX as u32);
4895 let lowered = NrosRmwQos::try_from(qos).expect("depth 65535 must lower");
4896 assert_eq!(lowered.depth, u16::MAX);
4897 }
4898
4899 #[test]
4900 fn qos_depth_past_u16_max_is_create_time_error() {
4901 let qos = nros_rmw::QoSProfile::default().keep_last(u16::MAX as u32 + 1);
4902 assert_eq!(
4903 NrosRmwQos::try_from(qos),
4904 Err(TransportError::InvalidArgument)
4905 );
4906 }
4907
4908 /// issue 0829 — the POLICY sentinel lowers rather than resolving, and the
4909 /// hand-mirrored C constant agrees with what the Rust profile lowers to.
4910 ///
4911 /// Two things at once, both hand-mirror class (issues 0088/0160/0245):
4912 /// `NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT` here is a second copy of the macro
4913 /// in `nros/rmw_entity.h`, and the lowering is a third statement of the same
4914 /// mapping. All three must say "every field is zero", because a C backend
4915 /// resolving the sentinel can only do so if it actually receives one.
4916 #[test]
4917 fn system_default_lowers_to_all_sentinel_and_matches_the_c_mirror() {
4918 let lowered = NrosRmwQos::try_from(nros_rmw::QoSProfile::QOS_PROFILE_SYSTEM_DEFAULT)
4919 .expect("the sentinel profile must lower");
4920
4921 assert_eq!(
4922 lowered.reliability as i32,
4923 generated::NROS_RMW_RELIABILITY_SYSTEM_DEFAULT
4924 );
4925 assert_eq!(
4926 lowered.durability as i32,
4927 generated::NROS_RMW_DURABILITY_SYSTEM_DEFAULT
4928 );
4929 assert_eq!(
4930 lowered.history as i32,
4931 generated::NROS_RMW_HISTORY_SYSTEM_DEFAULT
4932 );
4933 assert_eq!(lowered.depth, 0, "depth sentinel must lower as 0");
4934 assert_eq!(
4935 lowered.liveliness_kind as u32,
4936 generated::rmw_liveliness_kind_t::NROS_RMW_LIVELINESS_SYSTEM_DEFAULT
4937 );
4938
4939 // The hand-mirrored C constant says the same thing.
4940 assert_eq!(
4941 lowered, NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT,
4942 "the C mirror drifted from what the Rust profile lowers to"
4943 );
4944 // And it is NOT `_DEFAULT` — the aliasing was half the 0829 defect.
4945 assert_ne!(
4946 NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT, NROS_RMW_QOS_PROFILE_DEFAULT,
4947 "SYSTEM_DEFAULT aliased DEFAULT again — see issue 0829"
4948 );
4949 }
4950
4951 #[test]
4952 fn qos_infinite_sentinel_passes_through_and_reads_as_unset() {
4953 use nros_rmw::{DURATION_INFINITE_MS, QoSPolicyMask};
4954 let qos = nros_rmw::QoSProfile {
4955 deadline_ms: DURATION_INFINITE_MS,
4956 lifespan_ms: DURATION_INFINITE_MS,
4957 liveliness_lease_ms: DURATION_INFINITE_MS,
4958 ..Default::default()
4959 };
4960 // Sentinel behaves like 0 at the check sites: no extra policy demanded.
4961 let required = qos.required_policies();
4962 assert!(!required.contains(QoSPolicyMask::DEADLINE));
4963 assert!(!required.contains(QoSPolicyMask::LIFESPAN));
4964 assert!(!required.contains(QoSPolicyMask::LIVELINESS_LEASE));
4965 // And lowers unchanged — the C side sees the explicit spelling.
4966 let lowered = NrosRmwQos::try_from(qos).expect("sentinel must lower");
4967 assert_eq!(lowered.deadline_ms, DURATION_INFINITE_MS);
4968 assert_eq!(lowered.lifespan_ms, DURATION_INFINITE_MS);
4969 assert_eq!(lowered.liveliness_lease_ms, DURATION_INFINITE_MS);
4970 }
4971
4972 #[test]
4973 fn duration_lowering_boundaries() {
4974 use core::time::Duration;
4975 use nros_rmw::{DURATION_INFINITE_MS, duration_to_qos_ms};
4976 // 0 keeps its unset/no-check meaning.
4977 assert_eq!(duration_to_qos_ms(Duration::ZERO), Ok(0));
4978 // Sub-ms CEILs to 1 ms — never floors to "no deadline".
4979 assert_eq!(duration_to_qos_ms(Duration::from_nanos(1)), Ok(1));
4980 assert_eq!(duration_to_qos_ms(Duration::from_micros(999)), Ok(1));
4981 assert_eq!(duration_to_qos_ms(Duration::from_millis(1)), Ok(1));
4982 assert_eq!(duration_to_qos_ms(Duration::from_micros(1001)), Ok(2));
4983 // Largest representable finite value.
4984 assert_eq!(
4985 duration_to_qos_ms(Duration::from_millis(DURATION_INFINITE_MS as u64 - 1)),
4986 Ok(DURATION_INFINITE_MS - 1)
4987 );
4988 // At / past the sentinel: create-time error, never a clamp (infinite
4989 // is spelled via the sentinel or 0, not a huge finite duration).
4990 assert_eq!(
4991 duration_to_qos_ms(Duration::from_millis(DURATION_INFINITE_MS as u64)),
4992 Err(TransportError::InvalidArgument)
4993 );
4994 assert_eq!(
4995 duration_to_qos_ms(Duration::from_secs(u64::MAX / 1_000)),
4996 Err(TransportError::InvalidArgument)
4997 );
4998 }
4999
5000 #[test]
5001 fn service_server_no_data_maps_to_none() {
5002 use nros_rmw::ServiceTrait as _;
5003
5004 let mut server = CffiService {
5005 vtable: &STUB_VTABLE,
5006 service_name_buf: [0u8; NAME_BUF_LEN],
5007 type_name_buf: [0u8; NAME_BUF_LEN],
5008 backend_data: core::ptr::dangling_mut::<c_void>(),
5009 };
5010 let mut buf = [0u8; 16];
5011
5012 assert!(server.take_request(&mut buf).unwrap().is_none());
5013 }
5014
5015 #[test]
5016 fn typed_struct_roundtrip() {
5017 // Register the stub vtable under its canonical name.
5018 let ret = unsafe { nros_rmw_cffi_register_named(c"default".as_ptr(), &STUB_VTABLE) };
5019 assert_eq!(ret, NROS_RMW_RET_OK);
5020
5021 // Open a session.
5022 let cfg = RmwConfig {
5023 mode: SessionMode::Client,
5024 locator: "tcp/127.0.0.1:7447",
5025 domain_id: 0,
5026 node_name: "test_node",
5027 namespace: "",
5028 properties: &[],
5029 };
5030 let mut session = Rmw::open(CffiRmw, &cfg).expect("session open");
5031 assert!(unsafe { STUB_OPEN_CALLED });
5032 assert_eq!(session.node_name(), "test_node");
5033
5034 // Create a publisher; verify backend received the typed
5035 // struct with topic_name + qos populated.
5036 let topic = TopicInfo::new("/chatter", "std_msgs/msg/Int32", "RIHS01_abc");
5037 let qos = nros_rmw::QoSProfile::default();
5038 let publisher = session
5039 .create_publisher(&topic, qos)
5040 .expect("publisher create");
5041 assert!(unsafe { STUB_CREATE_PUB_CALLED });
5042 let topic_buf = unsafe { &STUB_LAST_TOPIC_NAME };
5043 assert_eq!(
5044 core::str::from_utf8(topic_buf)
5045 .unwrap_or("")
5046 .trim_end_matches('\0'),
5047 "/chatter"
5048 );
5049 let type_buf = unsafe { &STUB_LAST_TYPE_NAME };
5050 assert_eq!(
5051 core::str::from_utf8(type_buf)
5052 .unwrap_or("")
5053 .trim_end_matches('\0'),
5054 "std_msgs/msg/Int32"
5055 );
5056
5057 // Rust accessors read back the typed-struct fields.
5058 assert_eq!(publisher.topic_name(), "/chatter");
5059 assert_eq!(publisher.type_name(), "std_msgs/msg/Int32");
5060 // issue 0814 — `can_loan_messages` is DERIVED, so a backend cannot
5061 // over-claim it. `stub_create_publisher` writes `true` into the view
5062 // while `STUB_VTABLE` inherits `borrow_loaned_message: None` from
5063 // `EMPTY_VTABLE`; a backend that cannot be asked for a loan does not
5064 // advertise loans, whatever it wrote. This assertion read `true`
5065 // before the fix — it was the over-claim, believed.
5066 assert!(
5067 !publisher.can_loan_messages(),
5068 "the stub declared can_loan_messages=true with a NULL \
5069 borrow_loaned_message slot; the declaration must not win"
5070 );
5071
5072 // Publish — verify backend_data round-trips correctly via
5073 // the typed view.
5074 use nros_rmw::Publisher as _;
5075 publisher.publish_raw(&[1u8, 2, 3]).expect("publish");
5076 assert!(unsafe { STUB_PUBLISH_CALLED });
5077 }
5078
5079 // Issue 0332 — an incomplete vtable must be rejected at registration, not
5080 // panic mid-spin. (The stub tests above register a COMPLETE vtable, so they
5081 // are the "complete → accepted" guard: if the required-slot list ever
5082 // over-rejected, those tests would fail at registration.)
5083 #[test]
5084 fn register_rejects_incomplete_vtable() {
5085 // SAFETY: `NrosRmwVtable` is a plain struct of `Option<extern fn>` +
5086 // POD fields; an all-zero bit pattern is every slot `None` (null-ptr
5087 // niche) — a valid, empty vtable.
5088 let empty: NrosRmwVtable = unsafe { core::mem::zeroed() };
5089 assert_eq!(first_missing_vtable_slot(&empty), Some("create_session"));
5090
5091 let rc = unsafe { nros_rmw_cffi_register_named(c"incomplete_0332".as_ptr(), &empty) };
5092 assert_eq!(rc, NROS_RMW_RET_INVALID_ARGUMENT);
5093 }
5094
5095 // Issue 0349 — the other direction. The 0332 list used to include three
5096 // OPTIONAL capability slots, which refused the xrce backend outright (its
5097 // vtable NULLs all three deliberately). A backend that can publish,
5098 // subscribe, serve and call is a working backend.
5099 //
5100 // This test is the pair to `register_rejects_incomplete_vtable` above: one
5101 // asserts the gate still bites, this asserts it does not over-bite. Keep
5102 // both — dropping either turns the gate into a one-way ratchet.
5103 /// phase-381 W6 — a NULL graph slot must say UNSUPPORTED, not "empty".
5104 ///
5105 /// The distinction is the whole of W6. XRCE has no graph at all: its C
5106 /// vtable is a designated initializer, so every graph slot is NULL by
5107 /// omission. If a NULL slot produced `Ok(())` with no visits, a caller
5108 /// could not tell "this backend cannot answer" from "the graph is empty",
5109 /// and would conclude a peer is absent when it simply cannot be seen.
5110 ///
5111 /// This also guards the reverse defect, which nearly shipped: `CffiSession`
5112 /// had NO `get_node_names` at all, so it fell through to the trait default
5113 /// and returned `Unsupported` even for a backend whose slot WAS wired —
5114 /// cyclone's, once W5 filled it. The slot would have looked implemented
5115 /// while being dead code, which is issue 0800's overstatement one layer up.
5116 #[test]
5117 fn a_null_graph_slot_reports_unsupported_not_an_empty_graph() {
5118 use nros_rmw::{Session, TransportError};
5119
5120 // A vtable with every required slot and NO graph slots — the shape a C
5121 // backend gets from `.field = ...` designated init.
5122 let mut session = CffiSession {
5123 vtable: &STUB_VTABLE,
5124 node_name_buf: [0u8; NAME_BUF_LEN],
5125 namespace_buf: [0u8; NAME_BUF_LEN],
5126 backend_data: core::ptr::dangling_mut::<c_void>(),
5127 domain_id: 0,
5128 };
5129 let mut seen = 0usize;
5130 let mut visit = |_n: &str, _ns: &str, _e: Option<&str>| {
5131 seen += 1;
5132 true
5133 };
5134 let r = Session::get_node_names(&mut session, &mut visit);
5135 assert!(
5136 matches!(r, Err(TransportError::Unsupported)),
5137 "a NULL slot must report Unsupported, got {r:?}"
5138 );
5139 assert_eq!(seen, 0, "nothing may be visited when the slot is absent");
5140 }
5141
5142 #[test]
5143 fn register_accepts_vtable_without_optional_capability_slots() {
5144 let mut vt = STUB_VTABLE;
5145 vt.publisher_event_init = None;
5146 vt.subscription_event_init = None;
5147 vt.publisher_assert_liveliness = None;
5148
5149 assert_eq!(
5150 first_missing_vtable_slot(&vt),
5151 None,
5152 "QoS-event and liveliness slots are capabilities, not core transport"
5153 );
5154
5155 // Deliberately NOT calling `nros_rmw_cffi_register_named` here. The
5156 // registry is a process global with no removal, so a successful
5157 // registration leaks a second backend into every other test in this
5158 // binary and turns single-backend resolution into `Ambiguous`
5159 // (`typed_struct_roundtrip` goes red). `first_missing_vtable_slot` is
5160 // the pure function that decides acceptance, so asserting on it tests
5161 // the same decision without the shared state. The end-to-end
5162 // "this really does register now" proof is
5163 // `nros-rmw-xrce-cffi`'s `register_smoke`, which is exactly the
5164 // backend this over-strict list was refusing.
5165 }
5166
5167 // And a required slot must STILL be refused even when everything else is
5168 // present — so the fix cannot be mistaken for "the gate was weakened".
5169 #[test]
5170 fn register_still_rejects_a_missing_required_slot() {
5171 let mut vt = STUB_VTABLE;
5172 vt.publish = None;
5173
5174 assert_eq!(first_missing_vtable_slot(&vt), Some("publish"));
5175
5176 let rc = unsafe { nros_rmw_cffi_register_named(c"no_publish_0349".as_ptr(), &vt) };
5177 assert_eq!(rc, NROS_RMW_RET_INVALID_ARGUMENT);
5178 }
5179}