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