Skip to main content

nros_rmw_cffi/
lib.rs

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