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