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