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` / `Subscriber` / service traits without
6//! writing Rust code.
7//!
8//! # Usage (C backend implementor)
9//!
10//! 1. Include `<nros/rmw_vtable.h>`
11//! 2. Implement all function pointers in `nros_rmw_vtable_t`
12//! 3. Call `nros_rmw_cffi_register(&my_vtable)` before creating sessions
13//!
14//! # Usage (Rust consumer)
15//!
16//! Enable the `rmw-cffi` feature on `nros` and use `Executor<CffiSession>`.
17
18#![no_std]
19
20#[cfg(feature = "alloc")]
21extern crate alloc;
22
23#[cfg(feature = "std")]
24extern crate std;
25
26use core::{cell::UnsafeCell, ffi::c_void, sync::atomic::Ordering};
27
28use nros_rmw::{
29    MessageInfo, Publisher, QosDurabilityPolicy, QosHistoryPolicy, QosReliabilityPolicy,
30    QosSettings, ServiceClientTrait, ServiceInfo, ServiceRequest, ServiceServerTrait, Session,
31    TopicInfo, TransportError,
32};
33
34// Phase 115.L.0 — generic Rust→C-vtable adapter. Lives behind the
35// `alloc` feature because each entity handle is boxed for stable
36// address mgmt; every nros backend already requires alloc.
37#[cfg(feature = "alloc")]
38pub mod rust_adapter;
39
40#[cfg(feature = "alloc")]
41pub use rust_adapter::{RustBackend, RustBackendAdapter};
42
43// Phase 249 P4b.1 — `.init_array` ctor self-registration
44// (`nros_rmw_register_backend!` macro lives here).
45pub mod section;
46
47// ============================================================================
48// Phase 102.1 — `nros_rmw_ret_t` named return codes
49// ============================================================================
50//
51// Mirrors the macro constants in `<nros/rmw_ret.h>`. The C side uses
52// `#define` so future additions don't widen the type; the Rust side
53// uses `pub const` so the same names are usable by Rust code that
54// crosses the C-vtable boundary.
55
56/// Signed 32-bit status code mirroring the C `nros_rmw_ret_t` typedef.
57/// Zero on success; negative on error.
58pub type NrosRmwRet = i32;
59
60/// Operation completed successfully.
61pub const NROS_RMW_RET_OK: NrosRmwRet = 0;
62/// Generic failure not covered by a more specific code.
63pub const NROS_RMW_RET_ERROR: NrosRmwRet = -1;
64/// Operation deadline elapsed before completion.
65pub const NROS_RMW_RET_TIMEOUT: NrosRmwRet = -2;
66/// Memory allocation failed.
67pub const NROS_RMW_RET_BAD_ALLOC: NrosRmwRet = -3;
68/// Caller supplied a NULL pointer or an out-of-range value.
69pub const NROS_RMW_RET_INVALID_ARGUMENT: NrosRmwRet = -4;
70/// The backend does not implement this operation.
71pub const NROS_RMW_RET_UNSUPPORTED: NrosRmwRet = -5;
72/// QoS profiles incompatible in a way the backend cannot reconcile.
73pub const NROS_RMW_RET_INCOMPATIBLE_QOS: NrosRmwRet = -6;
74/// Topic, service, or action name failed validation.
75pub const NROS_RMW_RET_TOPIC_NAME_INVALID: NrosRmwRet = -7;
76/// Request referenced a node that does not exist in this session.
77pub const NROS_RMW_RET_NODE_NAME_NON_EXISTENT: NrosRmwRet = -8;
78/// Backend does not support loaned messages on this entity, or slot in use.
79pub const NROS_RMW_RET_LOAN_NOT_SUPPORTED: NrosRmwRet = -9;
80/// No data on a non-blocking receive (distinct from `TIMEOUT`).
81pub const NROS_RMW_RET_NO_DATA: NrosRmwRet = -10;
82/// Resource momentarily unavailable; caller should retry.
83pub const NROS_RMW_RET_WOULD_BLOCK: NrosRmwRet = -11;
84/// Caller buffer smaller than the data the backend wants to deliver.
85pub const NROS_RMW_RET_BUFFER_TOO_SMALL: NrosRmwRet = -12;
86/// Incoming message exceeded the backend's static capacity.
87pub const NROS_RMW_RET_MESSAGE_TOO_LARGE: NrosRmwRet = -13;
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/// Phase 115.A.2 — caller's vtable struct has an `abi_version` the
115/// runtime doesn't know. Returned by entry points that take a
116/// versioned vtable struct (`nros_set_custom_transport`,
117/// `nros_cpp_set_custom_transport`, …) when
118/// `vtable.abi_version != NROS_RMW_*_ABI_VERSION_VN`.
119pub const NROS_RMW_RET_INCOMPATIBLE_ABI: NrosRmwRet = -14;
120
121/// Phase 128.A.3 — `Executor::open` / `nros::init` could not pick a
122/// unique backend because no `nros-rmw-*` crate (or static lib) is
123/// linked into this binary.
124pub const NROS_RMW_RET_NO_BACKEND: NrosRmwRet = -15;
125
126/// Phase 128.A.3 — more than one backend is linked and no
127/// `NROS_RMW=<name>` selector was supplied. Caller must either set
128/// the env var or use `Executor::open_multi`.
129pub const NROS_RMW_RET_AMBIGUOUS_BACKEND: NrosRmwRet = -16;
130
131/// Phase 128.A.3 — selector pointed at a backend name that is not
132/// in the registry (mis-spelling or missing `nros-rmw-<name>` dep).
133pub const NROS_RMW_RET_UNKNOWN_BACKEND: NrosRmwRet = -17;
134
135/// Phase 155.B.3 — backend reached the wire but couldn't establish a
136/// session. Maps to / from `TransportError::ConnectionFailed` /
137/// `Disconnected`. Distinct from `NROS_RMW_RET_ERROR` so callers can
138/// tell "can't reach the router" from "internal backend invariant
139/// tripped".
140pub const NROS_RMW_RET_CONNECTION_FAILED: NrosRmwRet = -18;
141
142/// Map a `TransportError` to the corresponding `nros_rmw_ret_t` code.
143///
144/// By-reference because `TransportError` carries a `String` on its
145/// dynamic-diagnostic variant and is not `Copy`. The string itself is
146/// dropped at the boundary — embedded RMW callers cannot afford a
147/// thread-local error buffer.
148pub fn ret_from_error(err: &TransportError) -> NrosRmwRet {
149    match err {
150        TransportError::Timeout => NROS_RMW_RET_TIMEOUT,
151        TransportError::WouldBlock => NROS_RMW_RET_WOULD_BLOCK,
152        TransportError::TooLarge => NROS_RMW_RET_MESSAGE_TOO_LARGE,
153        TransportError::BufferTooSmall => NROS_RMW_RET_BUFFER_TOO_SMALL,
154        TransportError::MessageTooLarge => NROS_RMW_RET_MESSAGE_TOO_LARGE,
155        TransportError::InvalidArgument => NROS_RMW_RET_INVALID_ARGUMENT,
156        TransportError::InvalidConfig => NROS_RMW_RET_INVALID_ARGUMENT,
157        TransportError::Unsupported => NROS_RMW_RET_UNSUPPORTED,
158        TransportError::BadAlloc => NROS_RMW_RET_BAD_ALLOC,
159        TransportError::IncompatibleQos => NROS_RMW_RET_INCOMPATIBLE_QOS,
160        TransportError::TopicNameInvalid => NROS_RMW_RET_TOPIC_NAME_INVALID,
161        TransportError::NodeNameNonExistent => NROS_RMW_RET_NODE_NAME_NON_EXISTENT,
162        TransportError::LoanNotSupported => NROS_RMW_RET_LOAN_NOT_SUPPORTED,
163        TransportError::NoData => NROS_RMW_RET_NO_DATA,
164        TransportError::IncompatibleAbi => NROS_RMW_RET_INCOMPATIBLE_ABI,
165        // Phase 155.B.3 — distinguish wire-level connection failure
166        // from generic backend error so the FreeRTOS / RV64 C+C++
167        // `init -> -X` logs identify the actual class. zenoh-pico's
168        // `ZpicoError::Session` (zpico_open returned -3) and
169        // `ZpicoError::Generic` (zpico_init returned -1) both flow
170        // through `ZpicoError → ConnectionFailed`; the cmake-built
171        // FreeRTOS C/C++ tests will now surface NOT_FOUND (the
172        // user-side mapping in `nros_support_init`) instead of the
173        // generic NROS_RET_ERROR catch-all.
174        TransportError::ConnectionFailed | TransportError::Disconnected => {
175            NROS_RMW_RET_CONNECTION_FAILED
176        }
177        // Everything else collapses to NROS_RMW_RET_ERROR. Backends
178        // that want fine-grained reporting should adopt the named
179        // variants above (Phase 102.2 sweep).
180        _ => NROS_RMW_RET_ERROR,
181    }
182}
183
184/// Map a `nros_rmw_ret_t` returned by a C-side vtable function back to
185/// a `TransportError` for the Rust caller. Inverse of `ret_from_error`
186/// — used when `nros-rmw-cffi`'s `CffiSession` etc. receive a code
187/// from the registered C backend.
188///
189/// `NROS_RMW_RET_OK` is mapped to `TransportError::Backend("ok")` as a
190/// programming-error sentinel; callers should branch on the success
191/// path before calling this. Unknown negative codes collapse to the
192/// generic `TransportError::Backend("unknown rmw_ret_t")` so a future
193/// constant added to the C header degrades gracefully on the Rust side.
194pub fn error_from_ret(ret: NrosRmwRet) -> TransportError {
195    match ret {
196        NROS_RMW_RET_OK => {
197            TransportError::Backend("ok (logic error: positive ret_t at error site)")
198        }
199        NROS_RMW_RET_ERROR => TransportError::Backend("rmw_ret error"),
200        NROS_RMW_RET_TIMEOUT => TransportError::Timeout,
201        NROS_RMW_RET_BAD_ALLOC => TransportError::BadAlloc,
202        NROS_RMW_RET_INVALID_ARGUMENT => TransportError::InvalidArgument,
203        NROS_RMW_RET_UNSUPPORTED => TransportError::Unsupported,
204        NROS_RMW_RET_INCOMPATIBLE_QOS => TransportError::IncompatibleQos,
205        NROS_RMW_RET_TOPIC_NAME_INVALID => TransportError::TopicNameInvalid,
206        NROS_RMW_RET_NODE_NAME_NON_EXISTENT => TransportError::NodeNameNonExistent,
207        NROS_RMW_RET_LOAN_NOT_SUPPORTED => TransportError::LoanNotSupported,
208        NROS_RMW_RET_NO_DATA => TransportError::NoData,
209        NROS_RMW_RET_WOULD_BLOCK => TransportError::WouldBlock,
210        NROS_RMW_RET_BUFFER_TOO_SMALL => TransportError::BufferTooSmall,
211        NROS_RMW_RET_MESSAGE_TOO_LARGE => TransportError::MessageTooLarge,
212        NROS_RMW_RET_INCOMPATIBLE_ABI => TransportError::IncompatibleAbi,
213        // Phase 155.B.3 — inverse of `ret_from_error`'s
214        // `ConnectionFailed | Disconnected → CONNECTION_FAILED`
215        // mapping. Decodes the new vtable-level code back to the
216        // `TransportError::ConnectionFailed` variant; downstream
217        // `transport_error_to_ret` in nros-c surfaces it as
218        // `NROS_RET_NOT_FOUND` (-4) to the user.
219        NROS_RMW_RET_CONNECTION_FAILED => TransportError::ConnectionFailed,
220        _ => TransportError::Backend("unknown rmw_ret_t"),
221    }
222}
223
224// ============================================================================
225// Phase 102.3 — typed entity structs (mirrors `<nros/rmw_entity.h>`)
226// ============================================================================
227//
228// These structs are layout-compatible with the typed entity structs
229// in the C header. Same shape as upstream `rmw.h`'s `rmw_publisher_t`
230// / `rmw_subscription_t` family: visible metadata + a `void * data`
231// tail (named `backend_data` here).
232
233/// Liveliness kind values for `NrosRmwQos::liveliness_kind`.
234#[repr(u8)]
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum NrosRmwLivelinessKind {
237    None = 0,
238    Automatic = 1,
239    ManualByTopic = 2,
240    ManualByNode = 3,
241}
242
243/// Full DDS-shaped QoS profile. Mirrors `nros_rmw_qos_t` from
244/// `<nros/rmw_entity.h>`.
245#[repr(C)]
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct NrosRmwQos {
248    /// Reliability policy: `0` = best-effort, `1` = reliable.
249    pub reliability: u8,
250    /// Durability policy: `0` = volatile, `1` = transient-local.
251    pub durability: u8,
252    /// History policy: `0` = keep-last, `1` = keep-all.
253    pub history: u8,
254    /// Liveliness kind. See [`NrosRmwLivelinessKind`].
255    pub liveliness_kind: u8,
256    /// History depth (0–65 535).
257    pub depth: u16,
258    /// phase-279 (#145) — publisher-side express hint (`TopicInfo::tx_express`
259    /// across the C ABI): non-zero = this publisher's samples bypass transport
260    /// tx batching. Carved from the former `_reserved0: u16` (layout-identical);
261    /// ignored by every slot except `create_publisher`.
262    pub tx_express: u8,
263    /// Reserved; must be zero.
264    pub _reserved0: u8,
265
266    /// Subscriber max-inter-arrival / publisher offered-rate, ms.
267    /// `0` = infinite (no deadline).
268    pub deadline_ms: u32,
269    /// Sample expiry, ms. `0` = infinite.
270    pub lifespan_ms: u32,
271    /// Liveliness lease, ms. `0` = infinite.
272    pub liveliness_lease_ms: u32,
273    /// If non-zero, topic name encoding skips the ROS `/rt/` prefix.
274    /// `u8` instead of `bool` for ABI parity with C — `sizeof(_Bool)`
275    /// is impl-defined per C99.
276    pub avoid_ros_namespace_conventions: u8,
277    /// Reserved; must be zero.
278    pub _reserved1: [u8; 3],
279    /// Phase 231 (RFC-0038) — subscription receive-buffer size hint, bytes.
280    /// Carries `TopicInfo::rx_buffer_hint` across the C ABI to `create_subscriber`
281    /// so a size-classing backend (zenoh-pico) can pick a small/large receive
282    /// buffer. `0` = unset. Appended at the struct tail (ABI-append); ignored by
283    /// every slot except `create_subscriber`.
284    pub rx_buffer_hint: u32,
285}
286
287/// Standard `rmw_qos_profile_default`-equivalent.
288pub const NROS_RMW_QOS_PROFILE_DEFAULT: NrosRmwQos = NrosRmwQos {
289    reliability: 1, // RELIABLE
290    durability: 0,  // VOLATILE
291    history: 0,     // KEEP_LAST
292    liveliness_kind: NrosRmwLivelinessKind::Automatic as u8,
293    depth: 10,
294    tx_express: 0,
295    _reserved0: 0,
296    deadline_ms: 0,
297    lifespan_ms: 0,
298    liveliness_lease_ms: 0,
299    avoid_ros_namespace_conventions: 0,
300    _reserved1: [0; 3],
301    rx_buffer_hint: 0,
302};
303
304/// Standard `rmw_qos_profile_sensor_data`-equivalent.
305pub const NROS_RMW_QOS_PROFILE_SENSOR_DATA: NrosRmwQos = NrosRmwQos {
306    reliability: 0, // BEST_EFFORT
307    durability: 0,  // VOLATILE
308    history: 0,     // KEEP_LAST
309    liveliness_kind: NrosRmwLivelinessKind::Automatic as u8,
310    depth: 5,
311    tx_express: 0,
312    _reserved0: 0,
313    deadline_ms: 0,
314    lifespan_ms: 0,
315    liveliness_lease_ms: 0,
316    avoid_ros_namespace_conventions: 0,
317    _reserved1: [0; 3],
318    rx_buffer_hint: 0,
319};
320
321/// Standard `rmw_qos_profile_services_default`-equivalent.
322pub const NROS_RMW_QOS_PROFILE_SERVICES_DEFAULT: NrosRmwQos = NROS_RMW_QOS_PROFILE_DEFAULT;
323
324/// Standard `rmw_qos_profile_parameters`-equivalent.
325pub const NROS_RMW_QOS_PROFILE_PARAMETERS: NrosRmwQos = NrosRmwQos {
326    depth: 1000,
327    ..NROS_RMW_QOS_PROFILE_DEFAULT
328};
329
330/// Standard `rmw_qos_profile_system_default`-equivalent.
331pub const NROS_RMW_QOS_PROFILE_SYSTEM_DEFAULT: NrosRmwQos = NROS_RMW_QOS_PROFILE_DEFAULT;
332
333/// Per-process RMW session. Mirrors `nros_rmw_session_t`.
334#[repr(C)]
335pub struct NrosRmwSession {
336    /// Borrowed; outlives the session.
337    pub node_name: *const u8,
338    /// Borrowed; outlives the session.
339    pub namespace_: *const u8,
340    /// Reserved for future fields (Phase 104 vtable pointer slot);
341    /// must be zero.
342    pub _reserved: [u8; 8],
343    /// Opaque backend state. NULL when uninitialised.
344    pub backend_data: *mut c_void,
345}
346
347/// Publisher entity. Mirrors `nros_rmw_publisher_t`.
348///
349/// `can_loan_messages` matches upstream `rmw_publisher_t`'s field of
350/// the same name: `true` means the backend exposes the
351/// `loan_publish` / `commit_publish` primitive (Phase 99).
352#[repr(C)]
353pub struct NrosRmwPublisher {
354    /// Borrowed; outlives the publisher.
355    pub topic_name: *const u8,
356    /// Borrowed; outlives the publisher.
357    pub type_name: *const u8,
358    pub qos: NrosRmwQos,
359    /// Backend exposes loan_publish / commit_publish (Phase 99).
360    pub can_loan_messages: bool,
361    /// Reserved for future fields; must be zero.
362    pub _reserved: [u8; 7],
363    /// Opaque backend state. NULL when creation failed.
364    pub backend_data: *mut c_void,
365}
366
367/// Subscriber entity. Mirrors `nros_rmw_subscriber_t`.
368#[repr(C)]
369pub struct NrosRmwSubscriber {
370    /// Borrowed; outlives the subscriber.
371    pub topic_name: *const u8,
372    /// Borrowed; outlives the subscriber.
373    pub type_name: *const u8,
374    pub qos: NrosRmwQos,
375    /// Backend exposes loan_recv / release_recv (Phase 99).
376    pub can_loan_messages: bool,
377    /// Reserved for future fields; must be zero.
378    pub _reserved: [u8; 7],
379    /// Opaque backend state. NULL when creation failed.
380    pub backend_data: *mut c_void,
381}
382
383/// Service-server entity. Mirrors `nros_rmw_service_server_t`.
384#[repr(C)]
385pub struct NrosRmwServiceServer {
386    /// Borrowed; outlives the server.
387    pub service_name: *const u8,
388    /// Borrowed; outlives the server.
389    pub type_name: *const u8,
390    /// Reserved for future fields; must be zero.
391    pub _reserved: [u8; 8],
392    /// Opaque backend state. NULL when creation failed.
393    pub backend_data: *mut c_void,
394}
395
396/// Service-client entity. Mirrors `nros_rmw_service_client_t`.
397#[repr(C)]
398pub struct NrosRmwServiceClient {
399    /// Borrowed; outlives the client.
400    pub service_name: *const u8,
401    /// Borrowed; outlives the client.
402    pub type_name: *const u8,
403    /// Reserved for future fields; must be zero.
404    pub _reserved: [u8; 8],
405    /// Opaque backend state. NULL when creation failed.
406    pub backend_data: *mut c_void,
407}
408
409impl From<QosSettings> for NrosRmwQos {
410    fn from(qos: QosSettings) -> Self {
411        Self {
412            reliability: match qos.reliability {
413                QosReliabilityPolicy::BestEffort => 0,
414                QosReliabilityPolicy::Reliable => 1,
415            },
416            durability: match qos.durability {
417                QosDurabilityPolicy::Volatile => 0,
418                QosDurabilityPolicy::TransientLocal => 1,
419            },
420            history: match qos.history {
421                QosHistoryPolicy::KeepLast => 0,
422                QosHistoryPolicy::KeepAll => 1,
423            },
424            liveliness_kind: qos.liveliness_kind as u8,
425            // QosSettings::depth is u32; clamp to u16 max. Embedded
426            // ROS queue depths are typically 1–100; oversize values
427            // are saturated at 65 535 rather than wrapped.
428            depth: qos.depth.min(u16::MAX as u32) as u16,
429            tx_express: qos.tx_express as u8,
430            _reserved0: 0,
431            deadline_ms: qos.deadline_ms,
432            lifespan_ms: qos.lifespan_ms,
433            liveliness_lease_ms: qos.liveliness_lease_ms,
434            avoid_ros_namespace_conventions: qos.avoid_ros_namespace_conventions as u8,
435            _reserved1: [0; 3],
436            rx_buffer_hint: 0,
437        }
438    }
439}
440
441// ============================================================================
442// Vtable type (mirrors C header)
443// ============================================================================
444
445/// C function table for an RMW backend.
446///
447/// Mirrors `nros_rmw_vtable_t` from `<nros/rmw_vtable.h>`. Phase 102.4
448/// signatures: every entity entry point takes a typed-struct pointer
449/// instead of `void *`; every status-only return is `nros_rmw_ret_t`
450/// (typedef of `i32`); byte-count returns stay `i32` (positive bytes,
451/// negative `nros_rmw_ret_t`).
452#[repr(C)]
453pub struct NrosRmwVtable {
454    // ---- Session lifecycle ----
455    pub open: unsafe extern "C" fn(
456        locator: *const u8,
457        mode: u8,
458        domain_id: u32,
459        node_name: *const u8,
460        out: *mut NrosRmwSession,
461    ) -> NrosRmwRet,
462    pub close: unsafe extern "C" fn(session: *mut NrosRmwSession) -> NrosRmwRet,
463    pub drive_io: unsafe extern "C" fn(session: *mut NrosRmwSession, timeout_ms: i32) -> NrosRmwRet,
464
465    // ---- Publisher ----
466    pub create_publisher: unsafe extern "C" fn(
467        session: *mut NrosRmwSession,
468        topic_name: *const u8,
469        type_name: *const u8,
470        type_hash: *const u8,
471        domain_id: u32,
472        qos: *const NrosRmwQos,
473        out: *mut NrosRmwPublisher,
474    ) -> NrosRmwRet,
475    pub destroy_publisher: unsafe extern "C" fn(publisher: *mut NrosRmwPublisher),
476    pub publish_raw: unsafe extern "C" fn(
477        publisher: *mut NrosRmwPublisher,
478        data: *const u8,
479        len: usize,
480    ) -> NrosRmwRet,
481
482    // ---- Subscriber ----
483    pub create_subscriber: unsafe extern "C" fn(
484        session: *mut NrosRmwSession,
485        topic_name: *const u8,
486        type_name: *const u8,
487        type_hash: *const u8,
488        domain_id: u32,
489        qos: *const NrosRmwQos,
490        out: *mut NrosRmwSubscriber,
491    ) -> NrosRmwRet,
492    pub destroy_subscriber: unsafe extern "C" fn(subscriber: *mut NrosRmwSubscriber),
493    pub try_recv_raw: unsafe extern "C" fn(
494        subscriber: *mut NrosRmwSubscriber,
495        buf: *mut u8,
496        buf_len: usize,
497    ) -> i32,
498    pub has_data: unsafe extern "C" fn(subscriber: *mut NrosRmwSubscriber) -> i32,
499
500    // ---- Service Server ----
501    // Phase 193.1b — `qos` applies to both request + reply endpoints.
502    pub create_service_server: unsafe extern "C" fn(
503        session: *mut NrosRmwSession,
504        service_name: *const u8,
505        type_name: *const u8,
506        type_hash: *const u8,
507        domain_id: u32,
508        qos: *const NrosRmwQos,
509        out: *mut NrosRmwServiceServer,
510    ) -> NrosRmwRet,
511    pub destroy_service_server: unsafe extern "C" fn(server: *mut NrosRmwServiceServer),
512    pub try_recv_request: unsafe extern "C" fn(
513        server: *mut NrosRmwServiceServer,
514        buf: *mut u8,
515        buf_len: usize,
516        seq_out: *mut i64,
517    ) -> i32,
518    pub has_request: unsafe extern "C" fn(server: *mut NrosRmwServiceServer) -> i32,
519    pub send_reply: unsafe extern "C" fn(
520        server: *mut NrosRmwServiceServer,
521        seq: i64,
522        data: *const u8,
523        len: usize,
524    ) -> NrosRmwRet,
525
526    // ---- Service Client ----
527    pub create_service_client: unsafe extern "C" fn(
528        session: *mut NrosRmwSession,
529        service_name: *const u8,
530        type_name: *const u8,
531        type_hash: *const u8,
532        domain_id: u32,
533        qos: *const NrosRmwQos,
534        out: *mut NrosRmwServiceClient,
535    ) -> NrosRmwRet,
536    pub destroy_service_client: unsafe extern "C" fn(client: *mut NrosRmwServiceClient),
537    pub call_raw: unsafe extern "C" fn(
538        client: *mut NrosRmwServiceClient,
539        request: *const u8,
540        req_len: usize,
541        reply_buf: *mut u8,
542        reply_buf_len: usize,
543    ) -> i32,
544
545    // ---- Phase 130.4 — non-blocking send/recv split (optional) ----
546    pub send_request_raw: Option<
547        unsafe extern "C" fn(
548            client: *mut NrosRmwServiceClient,
549            request: *const u8,
550            req_len: usize,
551        ) -> NrosRmwRet,
552    >,
553    pub try_recv_reply_raw: Option<
554        unsafe extern "C" fn(
555            client: *mut NrosRmwServiceClient,
556            reply_buf: *mut u8,
557            reply_buf_len: usize,
558        ) -> i32,
559    >,
560
561    // ---- Phase 108 — status events (optional) ----
562    pub register_subscriber_event: unsafe extern "C" fn(
563        subscriber: *mut NrosRmwSubscriber,
564        kind: NrosRmwEventKind,
565        deadline_ms: u32,
566        cb: NrosRmwEventCallback,
567        user_context: *mut c_void,
568    ) -> NrosRmwRet,
569
570    pub register_publisher_event: unsafe extern "C" fn(
571        publisher: *mut NrosRmwPublisher,
572        kind: NrosRmwEventKind,
573        deadline_ms: u32,
574        cb: NrosRmwEventCallback,
575        user_context: *mut c_void,
576    ) -> NrosRmwRet,
577
578    // ---- Phase 108.B — manual liveliness assertion (optional) ----
579    pub assert_publisher_liveliness:
580        unsafe extern "C" fn(publisher: *mut NrosRmwPublisher) -> NrosRmwRet,
581
582    // ---- Phase 110.0 — backend's next internal-event deadline ----
583    /// Returns next deadline in ms (≥ 0) or a negative value for
584    /// "no deadline". NULL function pointer = treat as no deadline.
585    pub next_deadline_ms: Option<unsafe extern "C" fn(session: *const NrosRmwSession) -> i32>,
586
587    /// Phase 124.B.1 — executor wake callback. Backend stores
588    /// `(cb, ctx)` and invokes `cb(ctx)` on async wake. The
589    /// runtime-supplied `cb` does flag-write + condvar-signal
590    /// atomically, giving sub-poll-period wake latency for spin
591    /// loops blocked on the executor's wake condvar.
592    ///
593    /// NULL fn pointer = backend has no async wake path (poll-only:
594    /// XRCE, bare-metal). The runtime still drains the session on
595    /// its deadline-bound cv-wait boundary.
596    pub set_wake_callback: Option<
597        unsafe extern "C" fn(
598            session: *mut NrosRmwSession,
599            cb: Option<unsafe extern "C" fn(ctx: *mut core::ffi::c_void)>,
600            ctx: *mut core::ffi::c_void,
601        ) -> NrosRmwRet,
602    >,
603
604    // ---- Phase 124.A — zero-copy publisher loan ----
605    /// Reserve a writable slot of at least `requested_len` bytes in
606    /// the backend's outbound buffer. NULL = arena fallback. See the
607    /// C header for the full semantics + lifetime contract.
608    pub pub_loan: Option<
609        unsafe extern "C" fn(
610            publisher: *mut NrosRmwPublisher,
611            requested_len: usize,
612            out_buf: *mut *mut u8,
613            out_cap: *mut usize,
614            out_token: *mut *mut core::ffi::c_void,
615        ) -> NrosRmwRet,
616    >,
617    /// Commit a previously loaned slot. NULL = paired with NULL
618    /// `pub_loan`.
619    pub pub_commit: Option<
620        unsafe extern "C" fn(
621            publisher: *mut NrosRmwPublisher,
622            token: *mut core::ffi::c_void,
623            actual_len: usize,
624        ) -> NrosRmwRet,
625    >,
626    /// Abandon a previously loaned slot. NULL = paired with NULL
627    /// `pub_loan`.
628    pub pub_discard: Option<
629        unsafe extern "C" fn(publisher: *mut NrosRmwPublisher, token: *mut core::ffi::c_void),
630    >,
631
632    // ---- Phase 124.A — zero-copy subscriber borrow ----
633    /// Borrow the next message in place. Returns length (≥ 0) or a
634    /// negative error code. NULL = staging-buffer fallback via
635    /// `try_recv_raw`.
636    pub sub_borrow: Option<
637        unsafe extern "C" fn(
638            subscriber: *mut NrosRmwSubscriber,
639            out_buf: *mut *const u8,
640            out_len: *mut usize,
641            out_token: *mut *mut core::ffi::c_void,
642        ) -> i32,
643    >,
644    /// Release a previously borrowed view. NULL = paired with NULL
645    /// `sub_borrow`.
646    pub sub_release: Option<
647        unsafe extern "C" fn(subscriber: *mut NrosRmwSubscriber, token: *mut core::ffi::c_void),
648    >,
649
650    // ---- Phase 124.C.1 — service-server availability probe ----
651    /// Returns `1` if ≥ 1 matching server has been discovered on the
652    /// RMW graph, `0` if none yet, or a negative `NrosRmwRet`
653    /// constant on backend error. Clients use this to gate the first
654    /// `call_raw` so a startup-ordering race doesn't surface as a
655    /// request-side timeout.
656    ///
657    /// NULL fn pointer = backend cannot answer; the runtime maps the
658    /// missing slot to `NROS_RMW_RET_UNSUPPORTED`.
659    pub service_server_available:
660        Option<unsafe extern "C" fn(client: *mut NrosRmwServiceClient) -> i32>,
661
662    // ---- Phase 124.D.1 — burst-take ----
663    /// Drains up to `max_msgs` queued messages into a contiguous
664    /// caller buffer in a single backend call. The i-th delivered
665    /// message lives at `buf + i * per_msg_cap` and has length
666    /// `out_lens[i]`. Returns the message count (≥ 0) or a negative
667    /// `NrosRmwRet` error code; partial drains MUST report the
668    /// count, never error out.
669    ///
670    /// NULL fn pointer = backend doesn't batch; the runtime falls
671    /// back to a `try_recv_raw` loop in
672    /// `CffiSubscriber::try_recv_sequence` so user code can commit
673    /// to the batched API regardless of backend support.
674    pub try_recv_sequence: Option<
675        unsafe extern "C" fn(
676            subscriber: *mut NrosRmwSubscriber,
677            buf: *mut u8,
678            per_msg_cap: usize,
679            max_msgs: usize,
680            out_lens: *mut usize,
681        ) -> i32,
682    >,
683
684    // ---- Phase 124.E.1 — streamed publish ----
685    /// Caller hands the backend two callbacks. The backend invokes
686    /// `size_cb` once to learn the total payload length, then
687    /// `chunk_cb` repeatedly to fill the slot in chunks. Lets big
688    /// messages skip a per-publisher staging buffer on RAM-
689    /// constrained nodes.
690    ///
691    /// NULL fn pointer = backend doesn't stream; the runtime falls
692    /// back to a stack staging buffer (capped at the configured
693    /// `NROS_MAX_STREAM_CHUNK`) + `publish_raw` so user code can
694    /// commit to the streamed API regardless of backend support.
695    pub publish_streamed: Option<
696        unsafe extern "C" fn(
697            publisher: *mut NrosRmwPublisher,
698            size_cb: unsafe extern "C" fn(
699                out_total_len: *mut usize,
700                user_ctx: *mut core::ffi::c_void,
701            ),
702            chunk_cb: unsafe extern "C" fn(
703                out_buf: *mut u8,
704                cap: usize,
705                out_written: *mut usize,
706                user_ctx: *mut core::ffi::c_void,
707            ),
708            user_ctx: *mut core::ffi::c_void,
709        ) -> NrosRmwRet,
710    >,
711
712    // ---- Phase 124.F.1 — session-level connectivity probe ----
713    /// Wire-level round-trip "is the peer / agent / router still
714    /// reachable?" probe. Cheaper than the service-availability
715    /// probe — no discovery state required.
716    ///
717    /// Returns `NROS_RMW_RET_OK` on reply within `timeout_ms`,
718    /// `NROS_RMW_RET_TIMEOUT` on no reply, or
719    /// `NROS_RMW_RET_UNSUPPORTED` when the backend can't probe.
720    /// NULL slot = runtime surfaces `Unsupported` to the caller.
721    pub ping_session:
722        Option<unsafe extern "C" fn(session: *mut NrosRmwSession, timeout_ms: i32) -> NrosRmwRet>,
723
724    // ---- Phase 231 (RFC-0038) — zero-copy in-place subscription take ----
725    /// Capability query: does this subscriber support
726    /// [`process_raw_in_place`](Self::process_raw_in_place)? Returns `1` if yes,
727    /// `0` if no. The executor consults this at registration to pick the in-place
728    /// arena dispatch over the buffered one. NULL slot = treated as unsupported.
729    pub subscriber_supports_in_place:
730        Option<unsafe extern "C" fn(subscriber: *mut NrosRmwSubscriber) -> i32>,
731
732    /// Borrow one ready message in place and hand its raw CDR bytes to `cb`
733    /// (along with the opaque `ctx`) for the duration of the call, then release
734    /// the slot — no copy into a caller buffer. Returns `1` if a message was
735    /// processed (`cb` invoked), `NROS_RMW_RET_NO_DATA` if none was ready, or a
736    /// negative error. `cb` MUST NOT re-enter this subscriber's receive. NULL
737    /// slot = unsupported (the runtime uses the buffered path).
738    pub process_raw_in_place: Option<
739        unsafe extern "C" fn(
740            subscriber: *mut NrosRmwSubscriber,
741            ctx: *mut core::ffi::c_void,
742            cb: unsafe extern "C" fn(ctx: *mut core::ffi::c_void, ptr: *const u8, len: usize),
743        ) -> i32,
744    >,
745}
746
747// ============================================================================
748// Phase 108 — status-event types (mirror `<nros/rmw_event.h>`)
749// ============================================================================
750
751/// Tier-1 event kinds. Stable u8 values matching
752/// `nros_rmw_event_kind_t` in the C header.
753#[repr(u8)]
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755pub enum NrosRmwEventKind {
756    LivelinessChanged = 0,
757    RequestedDeadlineMissed = 1,
758    MessageLost = 2,
759    LivelinessLost = 3,
760    OfferedDeadlineMissed = 4,
761}
762
763impl From<nros_rmw::EventKind> for NrosRmwEventKind {
764    fn from(k: nros_rmw::EventKind) -> Self {
765        use nros_rmw::EventKind as K;
766        match k {
767            K::LivelinessChanged => NrosRmwEventKind::LivelinessChanged,
768            K::RequestedDeadlineMissed => NrosRmwEventKind::RequestedDeadlineMissed,
769            K::MessageLost => NrosRmwEventKind::MessageLost,
770            K::LivelinessLost => NrosRmwEventKind::LivelinessLost,
771            K::OfferedDeadlineMissed => NrosRmwEventKind::OfferedDeadlineMissed,
772            _ => NrosRmwEventKind::MessageLost, // unreachable for now (#[non_exhaustive])
773        }
774    }
775}
776
777/// Liveliness payload mirror.
778#[repr(C)]
779#[derive(Debug, Clone, Copy, Default)]
780pub struct NrosRmwLivelinessChangedStatus {
781    pub alive_count: u16,
782    pub not_alive_count: u16,
783    pub alive_count_change: i16,
784    pub not_alive_count_change: i16,
785}
786
787/// Count payload mirror.
788#[repr(C)]
789#[derive(Debug, Clone, Copy, Default)]
790pub struct NrosRmwCountStatus {
791    pub total_count: u32,
792    pub total_count_change: u32,
793}
794
795/// Borrow-shaped payload union mirror. C-side ABI — runtime-checked
796/// kind tag selects which member is valid.
797#[repr(C)]
798pub union NrosRmwEventPayload {
799    pub liveliness_changed: NrosRmwLivelinessChangedStatus,
800    pub count: NrosRmwCountStatus,
801}
802
803/// C callback signature. Matches `nros_rmw_event_callback_t`.
804pub type NrosRmwEventCallback = unsafe extern "C" fn(
805    kind: NrosRmwEventKind,
806    payload: *const NrosRmwEventPayload,
807    user_context: *mut c_void,
808);
809
810// ============================================================================
811// Registration
812// ============================================================================
813//
814// Phase 104.B.2 — named registry replaces the singleton vtable.
815// Backends register under a stable identifier (`"zenoh"`, `"dds"`,
816// `"xrce"`, future `"uorb"`, `"cyclonedds"`); consumers look up
817// vtables by name via `nros_rmw_cffi_lookup`. Multiple backends can
818// coexist in the same process (bridge nodes).
819//
820// Capacity comes from the `NROS_RMW_MAX_BACKENDS` build-time env
821// var (default 8). See `build.rs`.
822//
823// Implementation: a fixed-size `[BackendSlot; MAX_BACKENDS]`
824// guarded by an atomic length counter. No alloc; `no_std`
825// compatible. Slot scan is O(N) for lookup but N is tiny (8 by
826// default). Each slot owns its name buffer; `name_ptr` returned
827// to consumers points into the slot and stays valid for the
828// program's lifetime.
829
830/// Compile-time max number of concurrently registered backends.
831/// Set via `NROS_RMW_MAX_BACKENDS` env var at build time
832/// (`build.rs`). Default 8.
833pub const MAX_BACKENDS: usize = parse_max_backends(env!("NROS_RMW_MAX_BACKENDS"));
834
835const fn parse_max_backends(s: &str) -> usize {
836    let bytes = s.as_bytes();
837    let mut i = 0usize;
838    let mut acc: usize = 0;
839    while i < bytes.len() {
840        let d = bytes[i];
841        assert!(
842            d.is_ascii_digit(),
843            "NROS_RMW_MAX_BACKENDS must be a decimal integer"
844        );
845        acc = acc * 10 + (d - b'0') as usize;
846        i += 1;
847    }
848    acc
849}
850
851/// Maximum length of a backend name. Names are short ASCII
852/// identifiers (`"zenoh"`, `"cyclonedds"`); 32 bytes is generous.
853const BACKEND_NAME_MAX: usize = 32;
854
855#[repr(C)]
856struct BackendSlot {
857    /// Null-terminated UTF-8 backend name. Zero-initialized when
858    /// unused (`name[0] == 0`).
859    name: [u8; BACKEND_NAME_MAX],
860    vtable: *const NrosRmwVtable,
861}
862
863impl BackendSlot {
864    const fn empty() -> Self {
865        Self {
866            name: [0u8; BACKEND_NAME_MAX],
867            vtable: core::ptr::null(),
868        }
869    }
870
871    #[inline]
872    fn is_empty(&self) -> bool {
873        self.name[0] == 0
874    }
875
876    #[inline]
877    fn name_matches(&self, candidate: &[u8]) -> bool {
878        if self.is_empty() {
879            return false;
880        }
881        // Compare up to the first NUL or candidate length.
882        let mut i = 0usize;
883        while i < self.name.len() && i < candidate.len() {
884            if self.name[i] == 0 {
885                return false; // slot name shorter than candidate
886            }
887            if self.name[i] != candidate[i] {
888                return false;
889            }
890            i += 1;
891        }
892        // candidate fully consumed; slot must be NUL at i (same length)
893        i == candidate.len() && (i == self.name.len() || self.name[i] == 0)
894    }
895}
896
897// SAFETY: `BackendSlot::vtable` is a `*const` pointer used in a
898// `'static` context; once written it's never freed and the registry
899// is guarded by an atomic length counter for publication. Marker
900// trait implementations are required so the static array is
901// `Sync` across threads.
902unsafe impl Sync for BackendSlot {}
903
904/// Fixed-size registry. `slots[0..len]` are live; `slots[len..]`
905/// are zero-initialized. `len` is the publication fence.
906///
907/// `slots` lives in an `UnsafeCell` because we mutate through
908/// `&'static REGISTRY`. Safety invariants:
909/// * Slot writes happen only inside `nros_rmw_cffi_register_named`,
910///   which is documented "call before `Executor::open`" — backend
911///   ctors fire pre-main, manual calls precede session creation.
912/// * Slot reads via `nros_rmw_cffi_lookup` and `get_vtable` happen
913///   after `Executor::open`, well after registration completes.
914/// * The atomic `len` provides the release-acquire fence so any
915///   reader that sees `len = N` also sees the populated slot
916///   contents for indices `< N`.
917#[doc(hidden)]
918pub struct Registry {
919    slots: core::cell::UnsafeCell<[BackendSlot; MAX_BACKENDS]>,
920    len: portable_atomic::AtomicUsize,
921}
922
923impl Registry {
924    #[doc(hidden)]
925    pub const fn new() -> Self {
926        let slots = {
927            #[allow(clippy::declare_interior_mutable_const)]
928            const E: BackendSlot = BackendSlot::empty();
929            [E; MAX_BACKENDS]
930        };
931        Self {
932            slots: core::cell::UnsafeCell::new(slots),
933            len: portable_atomic::AtomicUsize::new(0),
934        }
935    }
936
937    /// Borrow slot `i` immutably. Caller must guarantee
938    /// `i < self.len.load(Acquire)`.
939    #[inline]
940    unsafe fn slot(&self, i: usize) -> &BackendSlot {
941        // SAFETY: registry protocol guarantees slot stability once
942        // published via the atomic len fence.
943        unsafe { &(*self.slots.get())[i] }
944    }
945
946    /// Borrow slot `i` mutably. Caller must guarantee exclusive
947    /// access — either pre-publication (idx > current `len`) or
948    /// during an idempotent overwrite of an already-registered name.
949    #[inline]
950    #[allow(clippy::mut_from_ref)]
951    unsafe fn slot_mut(&self, i: usize) -> &mut BackendSlot {
952        // SAFETY: see Registry doc — writer-side discipline.
953        unsafe { &mut (*self.slots.get())[i] }
954    }
955}
956
957// SAFETY: see `Registry` doc-comment on the mutation protocol.
958unsafe impl Sync for Registry {}
959
960// Phase 241.D3-rev — `REGISTRY` is DEFINED once in this rlib (plain
961// `#[no_mangle]`). The single-runtime model puts exactly one Rust staticlib in any
962// link (the umbrella `nros-c` / `nros-cpp` bundles the backend as an rlib), so the
963// cffi rlib appears once and one strong definition is correct everywhere: pure-Rust
964// firmware, the NuttX build-std ELF, and the umbrella C/C++ staticlib alike. This
965// supersedes the slice-4 `external-registry`/provider split, which existed only
966// because the C/C++ link used to carry multiple Rust staticlibs.
967#[unsafe(no_mangle)]
968static REGISTRY: Registry = Registry::new();
969
970/// The single process-wide backend registry.
971#[inline]
972fn registry() -> &'static Registry {
973    &REGISTRY
974}
975
976// ============================================================================
977// Rust-adapter MessageInfo side channel
978// ============================================================================
979//
980// The stable C subscriber ABI returns only a `(payload, len)` pair from
981// `try_recv_raw`. Rust backends can produce `MessageInfo`, so the generic
982// Rust->C adapter stores that metadata keyed by the backend handle pointer
983// immediately before returning the payload length. The Rust CFFI subscriber
984// consumes it after the vtable call. Pure C/C++ backends never write this table
985// and keep the documented `None` metadata behavior.
986
987const MESSAGE_INFO_SLOTS: usize = 64;
988
989struct MessageInfoSlot {
990    key: portable_atomic::AtomicUsize,
991    valid: portable_atomic::AtomicBool,
992    info: UnsafeCell<MessageInfo>,
993    #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
994    validate_requested: portable_atomic::AtomicBool,
995    #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
996    integrity_valid: portable_atomic::AtomicBool,
997    #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
998    integrity: UnsafeCell<nros_rmw::IntegrityStatus>,
999}
1000
1001impl MessageInfoSlot {
1002    const fn empty() -> Self {
1003        Self {
1004            key: portable_atomic::AtomicUsize::new(0),
1005            valid: portable_atomic::AtomicBool::new(false),
1006            info: UnsafeCell::new(MessageInfo::new()),
1007            #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1008            validate_requested: portable_atomic::AtomicBool::new(false),
1009            #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1010            integrity_valid: portable_atomic::AtomicBool::new(false),
1011            #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1012            integrity: UnsafeCell::new(nros_rmw::IntegrityStatus {
1013                gap: 0,
1014                duplicate: false,
1015                crc_valid: None,
1016            }),
1017        }
1018    }
1019}
1020
1021// SAFETY: each slot is published by `key` and `valid` atomics. Writers store
1022// `info` before setting `valid = true` with Release ordering; readers take
1023// `valid` with AcqRel before copying the `MessageInfo`.
1024unsafe impl Sync for MessageInfoSlot {}
1025
1026static MESSAGE_INFO_TABLE: [MessageInfoSlot; MESSAGE_INFO_SLOTS] = {
1027    #[allow(clippy::declare_interior_mutable_const)]
1028    const E: MessageInfoSlot = MessageInfoSlot::empty();
1029    [E; MESSAGE_INFO_SLOTS]
1030};
1031
1032fn lookup_message_info_slot(key: usize) -> Option<&'static MessageInfoSlot> {
1033    if key == 0 {
1034        return None;
1035    }
1036    MESSAGE_INFO_TABLE
1037        .iter()
1038        .find(|slot| slot.key.load(Ordering::Acquire) == key)
1039}
1040
1041#[cfg(feature = "alloc")]
1042fn get_or_insert_message_info_slot(key: usize) -> Option<&'static MessageInfoSlot> {
1043    if key == 0 {
1044        return None;
1045    }
1046    for slot in &MESSAGE_INFO_TABLE {
1047        let current = slot.key.load(Ordering::Acquire);
1048        if current == key {
1049            return Some(slot);
1050        }
1051        if current == 0
1052            && slot
1053                .key
1054                .compare_exchange(0, key, Ordering::AcqRel, Ordering::Acquire)
1055                .is_ok()
1056        {
1057            return Some(slot);
1058        }
1059    }
1060    None
1061}
1062
1063#[cfg(feature = "alloc")]
1064pub(crate) fn store_cffi_message_info(key: usize, info: Option<MessageInfo>) {
1065    let Some(slot) = get_or_insert_message_info_slot(key) else {
1066        return;
1067    };
1068    match info {
1069        Some(info) => {
1070            // SAFETY: this slot is keyed to one subscriber backend handle. The
1071            // executor owns each subscriber mutably while receiving, so writes
1072            // for the same key are serialized.
1073            unsafe {
1074                *slot.info.get() = info;
1075            }
1076            slot.valid.store(true, Ordering::Release);
1077        }
1078        None => slot.valid.store(false, Ordering::Release),
1079    }
1080}
1081
1082fn take_cffi_message_info(key: usize) -> Option<MessageInfo> {
1083    let slot = lookup_message_info_slot(key)?;
1084    if !slot.valid.swap(false, Ordering::AcqRel) {
1085        return None;
1086    }
1087    // SAFETY: `valid.swap(false)` gives this reader exclusive consumption of the
1088    // last stored `MessageInfo` for this key.
1089    Some(unsafe { *slot.info.get() })
1090}
1091
1092#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1093fn request_cffi_integrity_status(key: usize) {
1094    let Some(slot) = get_or_insert_message_info_slot(key) else {
1095        return;
1096    };
1097    slot.integrity_valid.store(false, Ordering::Release);
1098    slot.validate_requested.store(true, Ordering::Release);
1099}
1100
1101#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1102pub(crate) fn take_cffi_integrity_request(key: usize) -> bool {
1103    lookup_message_info_slot(key)
1104        .map(|slot| slot.validate_requested.swap(false, Ordering::AcqRel))
1105        .unwrap_or(false)
1106}
1107
1108#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1109pub(crate) fn store_cffi_integrity_status(key: usize, status: nros_rmw::IntegrityStatus) {
1110    let Some(slot) = get_or_insert_message_info_slot(key) else {
1111        return;
1112    };
1113    // SAFETY: integrity status follows the same per-subscriber handoff as
1114    // `info`; the CFFI subscriber owns receive calls mutably for this key.
1115    unsafe {
1116        *slot.integrity.get() = status;
1117    }
1118    slot.integrity_valid.store(true, Ordering::Release);
1119}
1120
1121#[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1122fn take_cffi_integrity_status(key: usize) -> Option<nros_rmw::IntegrityStatus> {
1123    let slot = lookup_message_info_slot(key)?;
1124    if !slot.integrity_valid.swap(false, Ordering::AcqRel) {
1125        return None;
1126    }
1127    Some(unsafe { *slot.integrity.get() })
1128}
1129
1130fn clear_cffi_message_info(key: usize) {
1131    let Some(slot) = lookup_message_info_slot(key) else {
1132        return;
1133    };
1134    slot.valid.store(false, Ordering::Release);
1135    #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
1136    {
1137        slot.validate_requested.store(false, Ordering::Release);
1138        slot.integrity_valid.store(false, Ordering::Release);
1139    }
1140    slot.key.store(0, Ordering::Release);
1141}
1142
1143/// Register a custom RMW backend vtable (legacy single-arg form).
1144///
1145/// Phase 104.B.2 — internally forwards to
1146/// [`nros_rmw_cffi_register_named`] with the literal name `"default"`.
1147/// Preserved as a one-release source-compat shim so backend ctors
1148/// authored before the named-registry switchover keep working.
1149///
1150/// **Deprecated (Phase 128.B.5).** All in-tree callers now use
1151/// [`nros_rmw_cffi_register_named`] directly so the registry slot is
1152/// keyed by the backend's canonical name (`"zenoh"`, `"dds"`,
1153/// `"xrce"`, `"cyclonedds"`, …). New backends MUST follow the same
1154/// pattern; the unnamed shim will be removed in a follow-up phase
1155/// once external callers have migrated.
1156///
1157/// # Safety
1158///
1159/// The vtable pointer must remain valid for the lifetime of the program.
1160/// All function pointers in the vtable must be valid.
1161#[deprecated(
1162    since = "0.2.0",
1163    note = "use nros_rmw_cffi_register_named with the backend's canonical name; the unnamed shim will be removed"
1164)]
1165#[unsafe(no_mangle)]
1166pub unsafe extern "C" fn nros_rmw_cffi_register(vtable: *const NrosRmwVtable) -> NrosRmwRet {
1167    unsafe { nros_rmw_cffi_register_named(c"default".as_ptr(), vtable) }
1168}
1169
1170/// Register a backend under a stable name. Multiple backends can
1171/// coexist; consumers select via [`nros_rmw_cffi_lookup`] or the
1172/// higher-level `Executor::node_builder(...).rmw(...)` path.
1173///
1174/// Names must be UTF-8, NUL-terminated, ≤ 31 bytes (excluding NUL).
1175/// Reserved names today: `"zenoh"`, `"dds"`, `"xrce"`,
1176/// `"cyclonedds"`, future `"uorb"`. The string `"default"` is the
1177/// implicit name used by the legacy single-arg
1178/// [`nros_rmw_cffi_register`] shim.
1179///
1180/// Returns:
1181/// * `NROS_RMW_RET_OK` on success.
1182/// * `NROS_RMW_RET_INVALID_ARGUMENT` if `name` / `vtable` is
1183///   NULL, the name is empty, or exceeds 31 bytes.
1184/// * `NROS_RMW_RET_ERROR` if the registry is full
1185///   (`MAX_BACKENDS` reached without a matching entry).
1186///
1187/// Duplicate registration of the same name overwrites the
1188/// previous vtable (idempotent for ctor-fires-twice cases).
1189///
1190/// # Safety
1191///
1192/// * `name` must be a valid NUL-terminated UTF-8 string.
1193/// * `vtable` must remain valid for the program's lifetime.
1194#[unsafe(no_mangle)]
1195pub unsafe extern "C" fn nros_rmw_cffi_register_named(
1196    name: *const core::ffi::c_char,
1197    vtable: *const NrosRmwVtable,
1198) -> NrosRmwRet {
1199    if name.is_null() || vtable.is_null() {
1200        return NROS_RMW_RET_INVALID_ARGUMENT;
1201    }
1202    let name_u8 = name.cast::<u8>();
1203
1204    // Length-check the input. We scan up to BACKEND_NAME_MAX + 1
1205    // bytes; anything longer is rejected.
1206    let mut len = 0usize;
1207    while len < BACKEND_NAME_MAX {
1208        let b = unsafe { *name_u8.add(len) };
1209        if b == 0 {
1210            break;
1211        }
1212        len += 1;
1213    }
1214    if len == 0 {
1215        return NROS_RMW_RET_INVALID_ARGUMENT;
1216    }
1217    // Must have found a NUL within BACKEND_NAME_MAX.
1218    if unsafe { *name_u8.add(len) } != 0 {
1219        return NROS_RMW_RET_INVALID_ARGUMENT;
1220    }
1221
1222    let name_bytes = unsafe { core::slice::from_raw_parts(name_u8, len) };
1223
1224    // First pass: look for existing entry with same name → overwrite.
1225    let current_len = registry().len.load(Ordering::Acquire);
1226    for i in 0..current_len {
1227        // SAFETY: i < current_len, indices in bounds.
1228        let slot = unsafe { registry().slot(i) };
1229        if slot.name_matches(name_bytes) {
1230            // SAFETY: writer-side idempotent overwrite. The slot is
1231            // already published; concurrent readers will see either
1232            // the old or new vtable consistently, both valid.
1233            unsafe {
1234                let slot_mut = registry().slot_mut(i);
1235                slot_mut.vtable = vtable;
1236            }
1237            core::sync::atomic::fence(Ordering::Release);
1238            return NROS_RMW_RET_OK;
1239        }
1240    }
1241
1242    // No existing entry; append. Reserve a slot via atomic increment.
1243    let idx = registry().len.fetch_add(1, Ordering::AcqRel);
1244    if idx >= MAX_BACKENDS {
1245        // Roll back the increment so subsequent registers don't see a
1246        // stale `len > MAX_BACKENDS`. (Race window negligible — once
1247        // we hit capacity, no further append succeeds.)
1248        registry().len.store(MAX_BACKENDS, Ordering::Release);
1249        return NROS_RMW_RET_ERROR;
1250    }
1251
1252    // SAFETY: idx < MAX_BACKENDS, mutating an as-yet-unpublished slot.
1253    unsafe {
1254        let slot = registry().slot_mut(idx);
1255        slot.name[..len].copy_from_slice(name_bytes);
1256        slot.name[len] = 0;
1257        slot.vtable = vtable;
1258    }
1259    // Release-fence so concurrent lookups see both the name and the
1260    // vtable consistently with the updated `len`.
1261    core::sync::atomic::fence(Ordering::Release);
1262    NROS_RMW_RET_OK
1263}
1264
1265/// Look up a backend's vtable by name. Returns NULL if no backend
1266/// is registered under `name`.
1267///
1268/// # Safety
1269///
1270/// * `name` must be a valid NUL-terminated UTF-8 string.
1271#[unsafe(no_mangle)]
1272pub unsafe extern "C" fn nros_rmw_cffi_lookup(
1273    name: *const core::ffi::c_char,
1274) -> *const NrosRmwVtable {
1275    if name.is_null() {
1276        return core::ptr::null();
1277    }
1278    let name_u8 = name.cast::<u8>();
1279    let mut len = 0usize;
1280    while len < BACKEND_NAME_MAX {
1281        if unsafe { *name_u8.add(len) } == 0 {
1282            break;
1283        }
1284        len += 1;
1285    }
1286    if len == 0 || len == BACKEND_NAME_MAX {
1287        return core::ptr::null();
1288    }
1289    let name_bytes = unsafe { core::slice::from_raw_parts(name_u8, len) };
1290
1291    let current_len = registry().len.load(Ordering::Acquire);
1292    for i in 0..current_len {
1293        // SAFETY: i < current_len, indices in bounds; publication
1294        // fence via the atomic-len Acquire load.
1295        let slot = unsafe { registry().slot(i) };
1296        if slot.name_matches(name_bytes) {
1297            return slot.vtable;
1298        }
1299    }
1300    core::ptr::null()
1301}
1302
1303/// Diagnostic helper — fills `buf` with pointers to up to `cap`
1304/// registered backend names. Returns the number of names available
1305/// (may exceed `cap`). Pointer-valid for the program's lifetime.
1306///
1307/// # Safety
1308///
1309/// * `buf` must either be NULL (when `cap == 0`) or point at writable
1310///   memory of at least `cap * sizeof(*const c_char)` bytes.
1311#[unsafe(no_mangle)]
1312pub unsafe extern "C" fn nros_rmw_cffi_registered_names(
1313    buf: *mut *const core::ffi::c_char,
1314    cap: usize,
1315) -> usize {
1316    let n = registry().len.load(Ordering::Acquire);
1317    if !buf.is_null() && cap > 0 {
1318        let limit = n.min(cap);
1319        for i in 0..limit {
1320            // SAFETY: i < limit <= cap, buf capacity guaranteed by caller.
1321            let slot = unsafe { registry().slot(i) };
1322            unsafe {
1323                buf.add(i)
1324                    .write(slot.name.as_ptr() as *const core::ffi::c_char)
1325            };
1326        }
1327    }
1328    n
1329}
1330
1331/// Phase 104.A — registry-presence probe. Returns `true` iff at
1332/// least one backend is registered. Used by `Executor::open` to
1333/// detect "user forgot to register a backend before opening the
1334/// session" and fail with a meaningful error.
1335#[inline]
1336pub fn backend_registered() -> bool {
1337    registry().len.load(Ordering::Acquire) > 0
1338}
1339
1340/// Phase 104.B — internal access to the registry for the Rust-side
1341/// adapter. `nros-node`'s `register_active_backend` removal already
1342/// switched to `backend_registered()` for the presence check; this
1343/// returns the vtable for any single-backend fast-path callers.
1344fn default_vtable() -> Option<&'static NrosRmwVtable> {
1345    let n = registry().len.load(Ordering::Acquire);
1346    if n == 0 {
1347        return None;
1348    }
1349    // SAFETY: index 0 < n, registry's len-Acquire fence orders the
1350    // slot read.
1351    let slot = unsafe { registry().slot(0) };
1352    if slot.vtable.is_null() {
1353        return None;
1354    }
1355    Some(unsafe { &*slot.vtable })
1356}
1357
1358/// Phase 128.A.3 — outcome of `resolve_backend`.
1359pub enum BackendResolution {
1360    /// Exactly one matching backend; use its vtable.
1361    Single(&'static NrosRmwVtable),
1362    /// No backend linked into the binary. Maps to
1363    /// [`NROS_RMW_RET_NO_BACKEND`].
1364    NoBackend,
1365    /// More than one backend linked and no selector given. Maps to
1366    /// [`NROS_RMW_RET_AMBIGUOUS_BACKEND`].
1367    Ambiguous,
1368    /// Selector did not match any registered backend. Maps to
1369    /// [`NROS_RMW_RET_UNKNOWN_BACKEND`].
1370    Unknown,
1371}
1372
1373/// Phase 128.A.3 — selection policy for the single-backend
1374/// `Executor::open` / `nros::init` path.
1375///
1376/// Algorithm:
1377///
1378/// 1. If `selector` is `Some(name)` (typically from `$NROS_RMW`),
1379///    look it up in the registry. Hit → [`BackendResolution::Single`];
1380///    miss → [`BackendResolution::Unknown`].
1381/// 2. Otherwise, if exactly one backend is registered, return it.
1382/// 3. Otherwise, if zero, [`BackendResolution::NoBackend`]; if more
1383///    than one, [`BackendResolution::Ambiguous`].
1384///
1385/// Callers convert the resolution to a [`NrosRmwRet`] via
1386/// [`backend_resolution_to_ret`].
1387///
1388/// Bridge consumers (`Executor::open_multi`) bypass this function and
1389/// call `nros_rmw_cffi_lookup` per spec instead.
1390pub fn resolve_backend(selector: Option<&[u8]>) -> BackendResolution {
1391    let n = registry().len.load(Ordering::Acquire);
1392    if let Some(name) = selector {
1393        let mut i = 0usize;
1394        while i < n {
1395            // SAFETY: i < n, registry len-Acquire fence orders the read.
1396            let slot = unsafe { registry().slot(i) };
1397            if slot.name_matches(name) {
1398                if slot.vtable.is_null() {
1399                    return BackendResolution::Unknown;
1400                }
1401                return BackendResolution::Single(unsafe { &*slot.vtable });
1402            }
1403            i += 1;
1404        }
1405        return BackendResolution::Unknown;
1406    }
1407    match n {
1408        0 => BackendResolution::NoBackend,
1409        1 => default_vtable()
1410            .map(BackendResolution::Single)
1411            .unwrap_or(BackendResolution::NoBackend),
1412        _ => BackendResolution::Ambiguous,
1413    }
1414}
1415
1416/// Phase 128.A.3 — map a [`BackendResolution`] to its canonical
1417/// [`NrosRmwRet`]. [`BackendResolution::Single`] is *not* an error and
1418/// returns [`NROS_RMW_RET_OK`]; callers needing the vtable should
1419/// pattern-match on the resolution itself.
1420pub fn backend_resolution_to_ret(res: &BackendResolution) -> NrosRmwRet {
1421    match res {
1422        BackendResolution::Single(_) => NROS_RMW_RET_OK,
1423        BackendResolution::NoBackend => NROS_RMW_RET_NO_BACKEND,
1424        BackendResolution::Ambiguous => NROS_RMW_RET_AMBIGUOUS_BACKEND,
1425        BackendResolution::Unknown => NROS_RMW_RET_UNKNOWN_BACKEND,
1426    }
1427}
1428
1429/// Phase 115.A.2 — C entry point for installing a custom transport.
1430///
1431/// Mirrors the Rust-side `nros_rmw::set_custom_transport(Some(...))`
1432/// (or `None` when `ops == NULL`) but returns the canonical
1433/// `nros_rmw_ret_t` codes so non-Rust consumers don't have to
1434/// reach into nros-c's higher-level error enum.
1435///
1436/// The struct's contents are copied internally; the caller may
1437/// stack-allocate. Pass `NULL` to clear the slot.
1438///
1439/// # Safety
1440///
1441/// `ops` must either be `NULL` or point at a valid
1442/// `nros_transport_ops_t` whose four fn pointers stay live for the
1443/// lifetime of the registration (i.e. until a subsequent
1444/// `nros_rmw_cffi_set_custom_transport(NULL)` or a replacement
1445/// install).
1446#[unsafe(no_mangle)]
1447pub unsafe extern "C" fn nros_rmw_cffi_set_custom_transport(
1448    ops: *const nros_rmw::NrosTransportOps,
1449) -> NrosRmwRet {
1450    if ops.is_null() {
1451        // Clear: ignore any error (None is always accepted).
1452        let _ = unsafe { nros_rmw::set_custom_transport(None) };
1453        return NROS_RMW_RET_OK;
1454    }
1455    let copy = unsafe { *ops };
1456    match unsafe { nros_rmw::set_custom_transport(Some(copy)) } {
1457        Ok(()) => NROS_RMW_RET_OK,
1458        Err(e) => ret_from_error(&e),
1459    }
1460}
1461
1462fn get_vtable() -> Result<&'static NrosRmwVtable, TransportError> {
1463    // Phase 104.B.2 — fast path: registry has exactly one backend.
1464    // Mirror the single-backend hot path the singleton-VTABLE
1465    // implementation had. Bridge / multi-backend users should call
1466    // a forthcoming `get_vtable_named` API (104.C work) instead.
1467    default_vtable().ok_or(TransportError::InvalidArgument)
1468}
1469
1470// ============================================================================
1471// Helper: null-terminated string on the stack
1472// ============================================================================
1473
1474/// Write a Rust `&str` as a null-terminated byte sequence into a fixed buffer.
1475/// Returns a pointer to the buffer start.
1476fn to_c_str<const N: usize>(s: &str, buf: &mut [u8; N]) -> *const u8 {
1477    let len = s.len().min(N - 1);
1478    buf[..len].copy_from_slice(&s.as_bytes()[..len]);
1479    buf[len] = 0;
1480    buf.as_ptr()
1481}
1482
1483/// Inverse of [`to_c_str`] — read a null-terminated byte buffer back
1484/// as a `&str`, stopping at the first NUL byte. Used by the
1485/// `topic_name()` / `type_name()` / `node_name()` accessors on the
1486/// `Cffi*` types so callers can introspect without round-tripping
1487/// through the vtable. Phase 102.5.
1488fn cstr_buf_to_str<const N: usize>(buf: &[u8; N]) -> &str {
1489    let len = buf.iter().position(|&b| b == 0).unwrap_or(N);
1490    // The buffers are written via `to_c_str` from a `&str`, so the
1491    // bytes between [..len] are guaranteed valid UTF-8. `from_utf8`
1492    // handles the (impossible) corruption case by returning empty.
1493    core::str::from_utf8(&buf[..len]).unwrap_or("")
1494}
1495
1496// ============================================================================
1497// CffiSession
1498// ============================================================================
1499//
1500// Storage discipline:
1501// * Each Cffi* struct owns null-terminated name buffers as inline
1502//   arrays. The C-side typed entity struct is rebuilt fresh on every
1503//   FFI call via `make_*_view`, so move-invalidation of pointers
1504//   into the buffer is impossible — the pointer always points to the
1505//   *current* address of the buffer, computed at call time.
1506// * The backend writes `backend_data` (and `can_loan_messages` for
1507//   pub/sub entities)
1508//   into the FFI view; we copy the writes back into the Cffi*
1509//   struct's fields after the call.
1510// * Strings ARE immutable for the entity's lifetime, so backends that
1511//   stash the topic_name pointer for diagnostics see stable storage
1512//   *as long as the Cffi* struct is not moved.* The Phase 102.4
1513//   contract is "do not move a Cffi* struct after construction" —
1514//   nano-ros embeds them inside the executor arena, which doesn't
1515//   relocate.
1516
1517const NAME_BUF_LEN: usize = 256;
1518const HASH_BUF_LEN: usize = 128;
1519
1520/// Session backed by a C vtable.
1521pub struct CffiSession {
1522    vtable: &'static NrosRmwVtable,
1523    /// Borrowed-pointer storage for `node_name`. Outlives the session.
1524    node_name_buf: [u8; NAME_BUF_LEN],
1525    /// Borrowed-pointer storage for `namespace_`. Empty for now —
1526    /// `RmwConfig` does not yet carry a namespace through the cffi
1527    /// path; reserved for future use.
1528    namespace_buf: [u8; NAME_BUF_LEN],
1529    /// Backend-private state, written by `vtable.open`.
1530    backend_data: *mut c_void,
1531}
1532
1533impl CffiSession {
1534    fn make_view(&mut self) -> NrosRmwSession {
1535        NrosRmwSession {
1536            node_name: self.node_name_buf.as_ptr(),
1537            namespace_: self.namespace_buf.as_ptr(),
1538            _reserved: [0u8; 8],
1539            backend_data: self.backend_data,
1540        }
1541    }
1542
1543    /// Phase 268 — build a per-call session view whose `node_name` / `namespace_`
1544    /// carry the ENTITY's owning-node identity (when the entity declares one),
1545    /// not the session's open-time default.
1546    ///
1547    /// A backend reads `session->node_name` to tag the entity it is creating for
1548    /// ROS 2 graph discovery (`ros2 node list`). One session can host N graph
1549    /// nodes (e.g. a multi-node launch entry), so the session's single open-time
1550    /// name is wrong for any entity owned by a different node. #104 threaded the
1551    /// node name only into the session, so multi-node entries collapsed every
1552    /// entity onto the one session name (`/node`). Overriding per entity here is
1553    /// the fix — no vtable ABI / signature change, every backend benefits (it
1554    /// already reads `session->node_name`).
1555    ///
1556    /// Falls back to the session buffers when the entity carries no node identity
1557    /// (direct-API / single-node path) — backward-compatible. The staging buffers
1558    /// must outlive the synchronous trampoline call; callers keep them on the
1559    /// stack across the `(vtable.create_*)` call.
1560    fn entity_view(
1561        &self,
1562        node_name: Option<&str>,
1563        namespace: &str,
1564        nn_buf: &mut [u8; NAME_BUF_LEN],
1565        ns_buf: &mut [u8; NAME_BUF_LEN],
1566    ) -> NrosRmwSession {
1567        let node_name_ptr = match node_name {
1568            Some(n) if !n.is_empty() => to_c_str(n, nn_buf),
1569            _ => self.node_name_buf.as_ptr(),
1570        };
1571        let namespace_ptr = if namespace.is_empty() {
1572            self.namespace_buf.as_ptr()
1573        } else {
1574            to_c_str(namespace, ns_buf)
1575        };
1576        NrosRmwSession {
1577            node_name: node_name_ptr,
1578            namespace_: namespace_ptr,
1579            _reserved: [0u8; 8],
1580            backend_data: self.backend_data,
1581        }
1582    }
1583
1584    /// Node name passed at session-open time.
1585    pub fn node_name(&self) -> &str {
1586        cstr_buf_to_str(&self.node_name_buf)
1587    }
1588
1589    /// Open a new session via the **default** registered vtable
1590    /// (first entry in the registry — the RMW_IMPLEMENTATION-style
1591    /// fast path for single-backend builds).
1592    ///
1593    /// For explicit backend selection in multi-backend (bridge)
1594    /// binaries, use [`open_named`](Self::open_named).
1595    pub fn open(
1596        locator: &str,
1597        mode: u8,
1598        domain_id: u32,
1599        node_name: &str,
1600    ) -> Result<Self, TransportError> {
1601        let vtable = get_vtable()?;
1602        Self::open_with_vtable(vtable, locator, mode, domain_id, node_name)
1603    }
1604
1605    /// Phase 104.C.1 — open a new session against a named backend.
1606    /// Resolves `rmw_name` against the registry (Phase 104.B.2),
1607    /// returns `Err(TransportError::InvalidArgument)` if no backend
1608    /// is registered under that name.
1609    pub fn open_named(
1610        rmw_name: &str,
1611        locator: &str,
1612        mode: u8,
1613        domain_id: u32,
1614        node_name: &str,
1615    ) -> Result<Self, TransportError> {
1616        // C-string-marshal `rmw_name` on the stack — registry lookup
1617        // expects NUL-terminated UTF-8.
1618        let mut name_buf = [0u8; BACKEND_NAME_MAX];
1619        if rmw_name.len() >= BACKEND_NAME_MAX {
1620            return Err(TransportError::InvalidArgument);
1621        }
1622        name_buf[..rmw_name.len()].copy_from_slice(rmw_name.as_bytes());
1623        // name_buf[rmw_name.len()] is already 0.
1624        let raw = unsafe { nros_rmw_cffi_lookup(name_buf.as_ptr() as *const _) };
1625        if raw.is_null() {
1626            return Err(TransportError::InvalidArgument);
1627        }
1628        // SAFETY: registry-issued pointer; valid for the program's lifetime.
1629        let vtable = unsafe { &*raw };
1630        Self::open_with_vtable(vtable, locator, mode, domain_id, node_name)
1631    }
1632
1633    fn open_with_vtable(
1634        vtable: &'static NrosRmwVtable,
1635        locator: &str,
1636        mode: u8,
1637        domain_id: u32,
1638        node_name: &str,
1639    ) -> Result<Self, TransportError> {
1640        let mut loc_buf = [0u8; NAME_BUF_LEN];
1641        let loc_ptr = to_c_str(locator, &mut loc_buf);
1642
1643        let mut session = Self {
1644            vtable,
1645            node_name_buf: [0u8; NAME_BUF_LEN],
1646            namespace_buf: [0u8; NAME_BUF_LEN],
1647            backend_data: core::ptr::null_mut(),
1648        };
1649        let _ = to_c_str(node_name, &mut session.node_name_buf);
1650
1651        let mut view = NrosRmwSession {
1652            node_name: session.node_name_buf.as_ptr(),
1653            namespace_: session.namespace_buf.as_ptr(),
1654            _reserved: [0u8; 8],
1655            backend_data: core::ptr::null_mut(),
1656        };
1657        let ret = unsafe {
1658            (vtable.open)(
1659                loc_ptr,
1660                mode,
1661                domain_id,
1662                session.node_name_buf.as_ptr(),
1663                &mut view,
1664            )
1665        };
1666        // Phase 156.4 — diagnostic for bridge runtime
1667        // ConnectionFailed investigation. Logs the raw ret +
1668        // post-open backend_data state so callers see which of
1669        // the two failure paths fired. Gated on env var so
1670        // production traffic stays quiet.
1671        #[cfg(feature = "std")]
1672        if std::env::var_os("NROS_RMW_TRACE_OPEN").is_some() {
1673            std::eprintln!(
1674                "[nros-rmw-cffi] open: locator={locator:?} mode={mode} ret={ret} backend_data={:p}",
1675                view.backend_data,
1676            );
1677        }
1678        if ret != NROS_RMW_RET_OK {
1679            return Err(error_from_ret(ret));
1680        }
1681        if view.backend_data.is_null() {
1682            return Err(TransportError::ConnectionFailed);
1683        }
1684        session.backend_data = view.backend_data;
1685        Ok(session)
1686    }
1687}
1688
1689impl Session for CffiSession {
1690    type Error = TransportError;
1691    type PublisherHandle = CffiPublisher;
1692    type SubscriberHandle = CffiSubscriber;
1693    type ServiceServerHandle = CffiServiceServer;
1694    type ServiceClientHandle = CffiServiceClient;
1695
1696    fn create_publisher(
1697        &mut self,
1698        topic: &TopicInfo,
1699        qos: QosSettings,
1700    ) -> Result<CffiPublisher, TransportError> {
1701        let mut hash_buf = [0u8; HASH_BUF_LEN];
1702        let hash_ptr = to_c_str(topic.type_hash, &mut hash_buf);
1703        let mut qos_struct = NrosRmwQos::from(qos);
1704        // phase-279/282 (#145) — carry the express hint across the C ABI so a
1705        // batching backend can declare this publisher express (bypass batch).
1706        // Either surface wins: the QoS profile field (language APIs) or the
1707        // lower-level `TopicInfo::with_tx_express` (direct RMW users).
1708        qos_struct.tx_express = (topic.tx_express || qos.tx_express) as u8;
1709
1710        let mut pub_state = CffiPublisher {
1711            vtable: self.vtable,
1712            topic_name_buf: [0u8; NAME_BUF_LEN],
1713            type_name_buf: [0u8; NAME_BUF_LEN],
1714            qos: qos_struct,
1715            can_loan_messages: false,
1716            backend_data: core::ptr::null_mut(),
1717        };
1718        let topic_ptr = to_c_str(topic.name, &mut pub_state.topic_name_buf);
1719        let type_ptr = to_c_str(topic.type_name, &mut pub_state.type_name_buf);
1720
1721        let mut view = NrosRmwPublisher {
1722            topic_name: topic_ptr,
1723            type_name: type_ptr,
1724            qos: qos_struct,
1725            can_loan_messages: false,
1726            _reserved: [0u8; 7],
1727            backend_data: core::ptr::null_mut(),
1728        };
1729        // Phase 268 — tag the entity with its owning node, not the session default.
1730        let mut nn_buf = [0u8; NAME_BUF_LEN];
1731        let mut ns_buf = [0u8; NAME_BUF_LEN];
1732        let mut session_view =
1733            self.entity_view(topic.node_name, topic.namespace, &mut nn_buf, &mut ns_buf);
1734        let ret = unsafe {
1735            (self.vtable.create_publisher)(
1736                &mut session_view,
1737                topic_ptr,
1738                type_ptr,
1739                hash_ptr,
1740                topic.domain_id,
1741                &qos_struct,
1742                &mut view,
1743            )
1744        };
1745        if ret != NROS_RMW_RET_OK {
1746            return Err(error_from_ret(ret));
1747        }
1748        if view.backend_data.is_null() {
1749            return Err(TransportError::PublisherCreationFailed);
1750        }
1751        pub_state.backend_data = view.backend_data;
1752        pub_state.can_loan_messages = view.can_loan_messages;
1753        Ok(pub_state)
1754    }
1755
1756    fn create_subscriber(
1757        &mut self,
1758        topic: &TopicInfo,
1759        qos: QosSettings,
1760    ) -> Result<CffiSubscriber, TransportError> {
1761        let mut hash_buf = [0u8; HASH_BUF_LEN];
1762        let hash_ptr = to_c_str(topic.type_hash, &mut hash_buf);
1763        let mut qos_struct = NrosRmwQos::from(qos);
1764        // Phase 231 (RFC-0038) — carry the receive-buffer size hint across the
1765        // C ABI so a size-classing backend can route its receive storage.
1766        qos_struct.rx_buffer_hint = topic.rx_buffer_hint.min(u32::MAX as usize) as u32;
1767
1768        let mut sub_state = CffiSubscriber {
1769            vtable: self.vtable,
1770            topic_name_buf: [0u8; NAME_BUF_LEN],
1771            type_name_buf: [0u8; NAME_BUF_LEN],
1772            qos: qos_struct,
1773            can_loan_messages: false,
1774            backend_data: core::ptr::null_mut(),
1775            supports_in_place: false,
1776        };
1777        let topic_ptr = to_c_str(topic.name, &mut sub_state.topic_name_buf);
1778        let type_ptr = to_c_str(topic.type_name, &mut sub_state.type_name_buf);
1779
1780        let mut view = NrosRmwSubscriber {
1781            topic_name: topic_ptr,
1782            type_name: type_ptr,
1783            qos: qos_struct,
1784            can_loan_messages: false,
1785            _reserved: [0u8; 7],
1786            backend_data: core::ptr::null_mut(),
1787        };
1788        // Phase 268 — tag the entity with its owning node, not the session default.
1789        let mut nn_buf = [0u8; NAME_BUF_LEN];
1790        let mut ns_buf = [0u8; NAME_BUF_LEN];
1791        let mut session_view =
1792            self.entity_view(topic.node_name, topic.namespace, &mut nn_buf, &mut ns_buf);
1793        let ret = unsafe {
1794            (self.vtable.create_subscriber)(
1795                &mut session_view,
1796                topic_ptr,
1797                type_ptr,
1798                hash_ptr,
1799                topic.domain_id,
1800                &qos_struct,
1801                &mut view,
1802            )
1803        };
1804        if ret != NROS_RMW_RET_OK {
1805            return Err(error_from_ret(ret));
1806        }
1807        if view.backend_data.is_null() {
1808            return Err(TransportError::SubscriberCreationFailed);
1809        }
1810        sub_state.backend_data = view.backend_data;
1811        sub_state.can_loan_messages = view.can_loan_messages;
1812        // Phase 231 (RFC-0038) — cache the in-place capability once.
1813        sub_state.supports_in_place = match sub_state.vtable.subscriber_supports_in_place {
1814            Some(f) => {
1815                let mut v = sub_state.make_view();
1816                unsafe { f(&mut v) == 1 }
1817            }
1818            None => false,
1819        };
1820        Ok(sub_state)
1821    }
1822
1823    fn create_service_server(
1824        &mut self,
1825        service: &ServiceInfo,
1826        qos: QosSettings,
1827    ) -> Result<CffiServiceServer, TransportError> {
1828        let qos_struct = NrosRmwQos::from(qos);
1829        let mut hash_buf = [0u8; HASH_BUF_LEN];
1830        let hash_ptr = to_c_str(service.type_hash, &mut hash_buf);
1831
1832        let mut srv_state = CffiServiceServer {
1833            vtable: self.vtable,
1834            service_name_buf: [0u8; NAME_BUF_LEN],
1835            type_name_buf: [0u8; NAME_BUF_LEN],
1836            backend_data: core::ptr::null_mut(),
1837        };
1838        let svc_ptr = to_c_str(service.name, &mut srv_state.service_name_buf);
1839        let type_ptr = to_c_str(service.type_name, &mut srv_state.type_name_buf);
1840
1841        let mut view = NrosRmwServiceServer {
1842            service_name: svc_ptr,
1843            type_name: type_ptr,
1844            _reserved: [0u8; 8],
1845            backend_data: core::ptr::null_mut(),
1846        };
1847        // Phase 268 — tag the entity with its owning node, not the session default.
1848        let mut nn_buf = [0u8; NAME_BUF_LEN];
1849        let mut ns_buf = [0u8; NAME_BUF_LEN];
1850        let mut session_view = self.entity_view(
1851            service.node_name,
1852            service.namespace,
1853            &mut nn_buf,
1854            &mut ns_buf,
1855        );
1856        let ret = unsafe {
1857            (self.vtable.create_service_server)(
1858                &mut session_view,
1859                svc_ptr,
1860                type_ptr,
1861                hash_ptr,
1862                service.domain_id,
1863                &qos_struct,
1864                &mut view,
1865            )
1866        };
1867        if ret != NROS_RMW_RET_OK {
1868            return Err(error_from_ret(ret));
1869        }
1870        if view.backend_data.is_null() {
1871            return Err(TransportError::ServiceServerCreationFailed);
1872        }
1873        srv_state.backend_data = view.backend_data;
1874        Ok(srv_state)
1875    }
1876
1877    fn create_service_client(
1878        &mut self,
1879        service: &ServiceInfo,
1880        qos: QosSettings,
1881    ) -> Result<CffiServiceClient, TransportError> {
1882        let qos_struct = NrosRmwQos::from(qos);
1883        let mut hash_buf = [0u8; HASH_BUF_LEN];
1884        let hash_ptr = to_c_str(service.type_hash, &mut hash_buf);
1885
1886        let mut cli_state = CffiServiceClient {
1887            vtable: self.vtable,
1888            service_name_buf: [0u8; NAME_BUF_LEN],
1889            type_name_buf: [0u8; NAME_BUF_LEN],
1890            backend_data: core::ptr::null_mut(),
1891            pending_len: 0,
1892        };
1893        let svc_ptr = to_c_str(service.name, &mut cli_state.service_name_buf);
1894        let type_ptr = to_c_str(service.type_name, &mut cli_state.type_name_buf);
1895
1896        let mut view = NrosRmwServiceClient {
1897            service_name: svc_ptr,
1898            type_name: type_ptr,
1899            _reserved: [0u8; 8],
1900            backend_data: core::ptr::null_mut(),
1901        };
1902        // Phase 268 — tag the entity with its owning node, not the session default.
1903        let mut nn_buf = [0u8; NAME_BUF_LEN];
1904        let mut ns_buf = [0u8; NAME_BUF_LEN];
1905        let mut session_view = self.entity_view(
1906            service.node_name,
1907            service.namespace,
1908            &mut nn_buf,
1909            &mut ns_buf,
1910        );
1911        let ret = unsafe {
1912            (self.vtable.create_service_client)(
1913                &mut session_view,
1914                svc_ptr,
1915                type_ptr,
1916                hash_ptr,
1917                service.domain_id,
1918                &qos_struct,
1919                &mut view,
1920            )
1921        };
1922        if ret != NROS_RMW_RET_OK {
1923            return Err(error_from_ret(ret));
1924        }
1925        if view.backend_data.is_null() {
1926            return Err(TransportError::ServiceClientCreationFailed);
1927        }
1928        cli_state.backend_data = view.backend_data;
1929        Ok(cli_state)
1930    }
1931
1932    fn close(&mut self) -> Result<(), TransportError> {
1933        if self.backend_data.is_null() {
1934            return Ok(());
1935        }
1936        let mut view = self.make_view();
1937        let ret = unsafe { (self.vtable.close)(&mut view) };
1938        if ret != NROS_RMW_RET_OK {
1939            return Err(error_from_ret(ret));
1940        }
1941        self.backend_data = core::ptr::null_mut();
1942        Ok(())
1943    }
1944
1945    fn drive_io(&mut self, timeout_ms: i32) -> Result<(), TransportError> {
1946        let mut view = self.make_view();
1947        let ret = unsafe { (self.vtable.drive_io)(&mut view, timeout_ms) };
1948        if ret != NROS_RMW_RET_OK {
1949            return Err(error_from_ret(ret));
1950        }
1951        Ok(())
1952    }
1953
1954    fn next_deadline_ms(&self) -> Option<u32> {
1955        let f = self.vtable.next_deadline_ms?;
1956        // SAFETY: build a transient `&self`-only view of the session
1957        // fields the C side may inspect; matches the layout `make_view`
1958        // produces but doesn't require `&mut self`.
1959        let view = NrosRmwSession {
1960            node_name: self.node_name_buf.as_ptr(),
1961            namespace_: self.namespace_buf.as_ptr(),
1962            _reserved: [0u8; 8],
1963            backend_data: self.backend_data,
1964        };
1965        let ret = unsafe { f(&view as *const _) };
1966        if ret < 0 { None } else { Some(ret as u32) }
1967    }
1968
1969    unsafe fn set_wake_callback(
1970        &mut self,
1971        cb: Option<unsafe extern "C" fn(ctx: *mut core::ffi::c_void)>,
1972        ctx: *mut core::ffi::c_void,
1973    ) {
1974        let Some(f) = self.vtable.set_wake_callback else {
1975            return;
1976        };
1977        let mut view = NrosRmwSession {
1978            node_name: self.node_name_buf.as_ptr(),
1979            namespace_: self.namespace_buf.as_ptr(),
1980            _reserved: [0u8; 8],
1981            backend_data: self.backend_data,
1982        };
1983        // SAFETY: vtable trampoline owns the install/clear; result is
1984        // ignored — best-effort.
1985        let _ = unsafe { f(&mut view as *mut _, cb, ctx) };
1986    }
1987
1988    fn supports_wake_callback(&self) -> bool {
1989        // Phase 130.4 — the vtable slot's presence is the truthful
1990        // signal. Poll-only backends (XRCE-DDS-Client, current
1991        // Cyclone wrapper, current dust-DDS shim) leave the slot
1992        // NULL; only backends with an async wake source fill it.
1993        self.vtable.set_wake_callback.is_some()
1994    }
1995
1996    fn ping_session(&mut self, timeout_ms: i32) -> Result<(), TransportError> {
1997        // Phase 124.F.1 — forward to the backend's vtable slot when
1998        // available; NULL surfaces `Unsupported` to the caller (no
1999        // implicit emulation — backends without a wire-level
2000        // round-trip can't probe honestly).
2001        let Some(f) = self.vtable.ping_session else {
2002            return Err(TransportError::Unsupported);
2003        };
2004        let mut view = self.make_view();
2005        let rc = unsafe { f(&mut view, timeout_ms) };
2006        if rc == NROS_RMW_RET_OK {
2007            Ok(())
2008        } else {
2009            Err(error_from_ret(rc))
2010        }
2011    }
2012
2013    /// Phase 115.K.2.5.1.2 — declare a permissive QoS-policy mask
2014    /// here so backends behind the cffi vtable don't get rejected by
2015    /// the runtime's pre-validate step before they ever see the
2016    /// `create_publisher` / `create_subscriber` call. The vtable
2017    /// doesn't expose a per-backend policy mask yet; until it does,
2018    /// the cffi route has to assume the registered backend supports
2019    /// the union of every policy any nros-supported RMW honours.
2020    /// Backends that don't support a policy MUST surface
2021    /// `NROS_RMW_RET_INCOMPATIBLE_QOS` from `create_publisher` etc.
2022    /// to keep the no-silent-degradation contract.
2023    ///
2024    /// TODO 115.K.2.x: extend `nros_rmw_vtable_t` with a
2025    /// `supported_qos_policies()` callback so the runtime queries
2026    /// the backend instead of guessing.
2027    fn supported_qos_policies(&self) -> nros_rmw::QosPolicyMask {
2028        use nros_rmw::QosPolicyMask;
2029        QosPolicyMask::CORE
2030            | QosPolicyMask::DURABILITY_TRANSIENT_LOCAL
2031            | QosPolicyMask::AVOID_ROS_NAMESPACE_CONVENTIONS
2032            | QosPolicyMask::DEADLINE
2033            | QosPolicyMask::LIFESPAN
2034            | QosPolicyMask::LIVELINESS_AUTOMATIC
2035            | QosPolicyMask::LIVELINESS_MANUAL_BY_TOPIC
2036            | QosPolicyMask::LIVELINESS_MANUAL_BY_NODE
2037            | QosPolicyMask::LIVELINESS_LEASE
2038    }
2039}
2040
2041impl Drop for CffiSession {
2042    fn drop(&mut self) {
2043        if !self.backend_data.is_null() {
2044            let mut view = self.make_view();
2045            unsafe { (self.vtable.close)(&mut view) };
2046        }
2047    }
2048}
2049
2050// ============================================================================
2051// CffiPublisher
2052// ============================================================================
2053
2054/// Publisher backed by a C vtable.
2055pub struct CffiPublisher {
2056    vtable: &'static NrosRmwVtable,
2057    topic_name_buf: [u8; NAME_BUF_LEN],
2058    type_name_buf: [u8; NAME_BUF_LEN],
2059    qos: NrosRmwQos,
2060    can_loan_messages: bool,
2061    backend_data: *mut c_void,
2062}
2063
2064impl CffiPublisher {
2065    fn make_view(&mut self) -> NrosRmwPublisher {
2066        NrosRmwPublisher {
2067            topic_name: self.topic_name_buf.as_ptr(),
2068            type_name: self.type_name_buf.as_ptr(),
2069            qos: self.qos,
2070            can_loan_messages: self.can_loan_messages,
2071            _reserved: [0u8; 7],
2072            backend_data: self.backend_data,
2073        }
2074    }
2075
2076    /// Topic name. Result is the null-terminated string written at
2077    /// publisher creation; never re-resolved from the backend.
2078    pub fn topic_name(&self) -> &str {
2079        cstr_buf_to_str(&self.topic_name_buf)
2080    }
2081
2082    /// Fully-qualified type name (`"std_msgs/msg/Int32"`).
2083    pub fn type_name(&self) -> &str {
2084        cstr_buf_to_str(&self.type_name_buf)
2085    }
2086
2087    /// QoS used to create this publisher.
2088    pub fn qos(&self) -> NrosRmwQos {
2089        self.qos
2090    }
2091
2092    /// `true` iff the backend exposes the publish loan primitive
2093    /// (Phase 99). Mirrors upstream `rmw_publisher_t::can_loan_messages`.
2094    pub fn can_loan_messages(&self) -> bool {
2095        self.can_loan_messages
2096    }
2097}
2098
2099/// Phase 124.A — writable slot returned by
2100/// [`CffiPublisher::try_lend_slot`]. Holds the backend's raw buffer
2101/// + opaque token until `commit_slot` consumes it or `Drop` fires
2102/// `pub_discard`.
2103#[cfg(feature = "lending")]
2104pub struct CffiSlot<'a> {
2105    buf: *mut u8,
2106    cap: usize,
2107    cursor: usize,
2108    token: *mut c_void,
2109    /// `None` after `commit_slot` consumes the slot — Drop skips the
2110    /// discard call in that case.
2111    publisher: Option<&'a CffiPublisher>,
2112    /// Phase 124.A.3 — `true` when this slot came from the runtime's
2113    /// arena fallback (backend had NULL `pub_loan`). Commit performs
2114    /// a `publish_raw` of the staged bytes; discard / Drop reclaims
2115    /// the staging buffer. `false` for native backend loans —
2116    /// commit / discard go through the vtable slots.
2117    fallback: bool,
2118}
2119
2120#[cfg(feature = "lending")]
2121impl<'a> CffiSlot<'a> {
2122    /// Mark the actual bytes written before commit. Defaults to the
2123    /// full capacity; callers that write a shorter prefix MUST call
2124    /// `set_len` first.
2125    pub fn set_len(&mut self, len: usize) {
2126        debug_assert!(len <= self.cap);
2127        self.cursor = len.min(self.cap);
2128    }
2129}
2130
2131/// Phase 124.A.3 — staging buffer for the arena-fallback loan path.
2132/// Allocated on each `try_lend_slot` when the backend's `pub_loan`
2133/// slot is NULL; commit copies into a `publish_raw` call; Drop /
2134/// discard reclaims the allocation. `Box::into_raw` of this struct
2135/// becomes the slot's opaque `token` so commit / discard can find
2136/// it back.
2137#[cfg(all(feature = "lending", feature = "alloc"))]
2138struct ArenaStaging {
2139    buf: alloc::vec::Vec<u8>,
2140}
2141
2142#[cfg(feature = "lending")]
2143impl<'a> AsMut<[u8]> for CffiSlot<'a> {
2144    fn as_mut(&mut self) -> &mut [u8] {
2145        // SAFETY: `buf` came from `pub_loan` with capacity `cap`. The
2146        // loan contract guarantees the slot stays valid until commit
2147        // or discard. The lifetime `'a` borrows the publisher so the
2148        // returned slice can't outlive the loan.
2149        unsafe { core::slice::from_raw_parts_mut(self.buf, self.cap) }
2150    }
2151}
2152
2153#[cfg(feature = "lending")]
2154impl<'a> Drop for CffiSlot<'a> {
2155    fn drop(&mut self) {
2156        if self.publisher.is_none() {
2157            // commit_slot consumed the loan — nothing to release.
2158            return;
2159        }
2160        if self.fallback {
2161            // Phase 124.A.3 — reclaim the staging allocation.
2162            #[cfg(feature = "alloc")]
2163            unsafe {
2164                let _ = alloc::boxed::Box::from_raw(self.token as *mut ArenaStaging);
2165            }
2166            return;
2167        }
2168        if let Some(p) = self.publisher
2169            && let Some(discard) = p.vtable.pub_discard
2170        {
2171            // Re-materialise the publisher view so the backend sees
2172            // the same `NrosRmwPublisher` shape it created the loan
2173            // against.
2174            let mut view = NrosRmwPublisher {
2175                topic_name: p.topic_name_buf.as_ptr(),
2176                type_name: p.type_name_buf.as_ptr(),
2177                qos: p.qos,
2178                can_loan_messages: p.can_loan_messages,
2179                _reserved: [0u8; 7],
2180                backend_data: p.backend_data,
2181            };
2182            // SAFETY: `token` came from a paired `pub_loan` on this
2183            // publisher and the publisher is still alive (lifetime
2184            // `'a` borrows it).
2185            unsafe { discard(&mut view, self.token) };
2186        }
2187    }
2188}
2189
2190#[cfg(feature = "lending")]
2191impl nros_rmw::SlotLending for CffiPublisher {
2192    type Slot<'a> = CffiSlot<'a>;
2193
2194    fn try_lend_slot(&self, len: usize) -> Result<Option<CffiSlot<'_>>, TransportError> {
2195        let Some(loan) = self.vtable.pub_loan else {
2196            // Phase 124.A.3 — backend doesn't natively lend; allocate
2197            // a staging buffer and stash it in `token` so commit can
2198            // memcpy → publish_raw and discard / Drop can reclaim.
2199            // Requires `alloc` for the dynamic staging; no_std-no_alloc
2200            // builds return None and let the caller fall back to a
2201            // non-loan path.
2202            #[cfg(feature = "alloc")]
2203            {
2204                let mut staging = alloc::boxed::Box::new(ArenaStaging {
2205                    buf: alloc::vec![0u8; len],
2206                });
2207                let buf_ptr = staging.buf.as_mut_ptr();
2208                let token = alloc::boxed::Box::into_raw(staging) as *mut c_void;
2209                return Ok(Some(CffiSlot {
2210                    buf: buf_ptr,
2211                    cap: len,
2212                    cursor: len,
2213                    token,
2214                    publisher: Some(self),
2215                    fallback: true,
2216                }));
2217            }
2218            #[cfg(not(feature = "alloc"))]
2219            {
2220                let _ = len;
2221                return Ok(None);
2222            }
2223        };
2224        let mut view = NrosRmwPublisher {
2225            topic_name: self.topic_name_buf.as_ptr(),
2226            type_name: self.type_name_buf.as_ptr(),
2227            qos: self.qos,
2228            can_loan_messages: self.can_loan_messages,
2229            _reserved: [0u8; 7],
2230            backend_data: self.backend_data,
2231        };
2232        let mut out_buf: *mut u8 = core::ptr::null_mut();
2233        let mut out_cap: usize = 0;
2234        let mut out_token: *mut c_void = core::ptr::null_mut();
2235        // SAFETY: vtable contract — slot pointers stay valid until
2236        // commit / discard.
2237        let ret = unsafe { loan(&mut view, len, &mut out_buf, &mut out_cap, &mut out_token) };
2238        if ret == NROS_RMW_RET_WOULD_BLOCK || ret == NROS_RMW_RET_NO_DATA {
2239            return Ok(None);
2240        }
2241        if ret != NROS_RMW_RET_OK {
2242            return Err(error_from_ret(ret));
2243        }
2244        if out_buf.is_null() || out_cap < len {
2245            // Defensive: a buggy backend returned OK with a too-small
2246            // slot. Treat as transient.
2247            if let Some(discard) = self.vtable.pub_discard {
2248                unsafe { discard(&mut view, out_token) };
2249            }
2250            return Ok(None);
2251        }
2252        Ok(Some(CffiSlot {
2253            buf: out_buf,
2254            cap: out_cap,
2255            cursor: len,
2256            token: out_token,
2257            publisher: Some(self),
2258            fallback: false,
2259        }))
2260    }
2261
2262    fn commit_slot(&self, mut slot: CffiSlot<'_>) -> Result<(), TransportError> {
2263        // Cancel Drop's discard — we're committing, not abandoning.
2264        let publisher = slot
2265            .publisher
2266            .take()
2267            .ok_or(TransportError::InvalidArgument)?;
2268        debug_assert!(core::ptr::eq(publisher, self));
2269        if slot.fallback {
2270            // Phase 124.A.3 — fallback path: reclaim the staging
2271            // box, run a single publish_raw of the cursor-truncated
2272            // contents.
2273            #[cfg(feature = "alloc")]
2274            {
2275                // SAFETY: `slot.token` came from
2276                // `Box::into_raw(Box<ArenaStaging>)` in try_lend_slot.
2277                let staging =
2278                    unsafe { alloc::boxed::Box::from_raw(slot.token as *mut ArenaStaging) };
2279                let bytes = &staging.buf[..slot.cursor.min(staging.buf.len())];
2280                return Publisher::publish_raw(self, bytes);
2281            }
2282            #[cfg(not(feature = "alloc"))]
2283            {
2284                return Err(TransportError::Unsupported);
2285            }
2286        }
2287        let commit = self.vtable.pub_commit.ok_or(TransportError::Unsupported)?;
2288        let mut view = NrosRmwPublisher {
2289            topic_name: self.topic_name_buf.as_ptr(),
2290            type_name: self.type_name_buf.as_ptr(),
2291            qos: self.qos,
2292            can_loan_messages: self.can_loan_messages,
2293            _reserved: [0u8; 7],
2294            backend_data: self.backend_data,
2295        };
2296        let len = slot.cursor;
2297        let token = slot.token;
2298        // `slot` drops here without firing `pub_discard` because
2299        // `publisher` is `None`.
2300        let ret = unsafe { commit(&mut view, token, len) };
2301        if ret != NROS_RMW_RET_OK {
2302            return Err(error_from_ret(ret));
2303        }
2304        Ok(())
2305    }
2306}
2307
2308impl Publisher for CffiPublisher {
2309    type Error = TransportError;
2310
2311    fn publish_raw(&self, data: &[u8]) -> Result<(), TransportError> {
2312        let mut view = NrosRmwPublisher {
2313            topic_name: self.topic_name_buf.as_ptr(),
2314            type_name: self.type_name_buf.as_ptr(),
2315            qos: self.qos,
2316            can_loan_messages: self.can_loan_messages,
2317            _reserved: [0u8; 7],
2318            backend_data: self.backend_data,
2319        };
2320        let ret = unsafe { (self.vtable.publish_raw)(&mut view, data.as_ptr(), data.len()) };
2321        if ret != NROS_RMW_RET_OK {
2322            return Err(error_from_ret(ret));
2323        }
2324        Ok(())
2325    }
2326
2327    unsafe fn publish_streamed(
2328        &self,
2329        size_cb: unsafe extern "C" fn(out_total_len: *mut usize, user_ctx: *mut core::ffi::c_void),
2330        chunk_cb: unsafe extern "C" fn(
2331            out_buf: *mut u8,
2332            cap: usize,
2333            out_written: *mut usize,
2334            user_ctx: *mut core::ffi::c_void,
2335        ),
2336        user_ctx: *mut core::ffi::c_void,
2337    ) -> Result<(), TransportError> {
2338        // Phase 124.E.1+2 — vtable forwarder. If the backend exposes
2339        // `publish_streamed` natively, dispatch in one hop so the
2340        // callbacks land directly inside the backend's outbound
2341        // buffer (no staging copy). Otherwise fall back to the
2342        // `Publisher::publish_streamed` default body, which runs a
2343        // stack staging buffer + `publish_raw`.
2344        if let Some(f) = self.vtable.publish_streamed {
2345            let mut view = NrosRmwPublisher {
2346                topic_name: self.topic_name_buf.as_ptr(),
2347                type_name: self.type_name_buf.as_ptr(),
2348                qos: self.qos,
2349                can_loan_messages: self.can_loan_messages,
2350                _reserved: [0u8; 7],
2351                backend_data: self.backend_data,
2352            };
2353            let ret = unsafe { f(&mut view, size_cb, chunk_cb, user_ctx) };
2354            if ret != NROS_RMW_RET_OK {
2355                return Err(error_from_ret(ret));
2356            }
2357            return Ok(());
2358        }
2359        // Inlined staging-buffer fallback. Mirrors the trait default
2360        // body so the override doesn't recurse through dynamic
2361        // dispatch — the default body would resolve back to this
2362        // function and deadlock.
2363        const STAGE_CAP: usize = 4096;
2364        let mut total = 0usize;
2365        unsafe { size_cb(&mut total as *mut usize, user_ctx) };
2366        if total > STAGE_CAP {
2367            return Err(TransportError::BufferTooSmall);
2368        }
2369        let mut stage = [0u8; STAGE_CAP];
2370        let mut written_so_far = 0usize;
2371        while written_so_far < total {
2372            let mut chunk_written = 0usize;
2373            let remaining = total - written_so_far;
2374            unsafe {
2375                chunk_cb(
2376                    stage.as_mut_ptr().add(written_so_far),
2377                    remaining,
2378                    &mut chunk_written as *mut usize,
2379                    user_ctx,
2380                );
2381            }
2382            if chunk_written == 0 {
2383                return Err(TransportError::BufferTooSmall);
2384            }
2385            written_so_far += chunk_written;
2386        }
2387        self.publish_raw(&stage[..total])
2388    }
2389
2390    fn buffer_error(&self) -> TransportError {
2391        TransportError::BufferTooSmall
2392    }
2393
2394    fn serialization_error(&self) -> TransportError {
2395        TransportError::SerializationError
2396    }
2397
2398    fn unsupported_event_error(&self) -> TransportError {
2399        TransportError::Unsupported
2400    }
2401
2402    unsafe fn register_event_callback(
2403        &mut self,
2404        kind: nros_rmw::EventKind,
2405        deadline_ms: u32,
2406        cb: nros_rmw::EventCallback,
2407        user_ctx: *mut core::ffi::c_void,
2408    ) -> Result<(), TransportError> {
2409        let mut view = NrosRmwPublisher {
2410            topic_name: self.topic_name_buf.as_ptr(),
2411            type_name: self.type_name_buf.as_ptr(),
2412            qos: self.qos,
2413            can_loan_messages: self.can_loan_messages,
2414            _reserved: [0u8; 7],
2415            backend_data: self.backend_data,
2416        };
2417        // Cffi NrosRmwEventCallback ABI matches nros_rmw::EventCallback —
2418        // both are `unsafe extern "C" fn(EventKind, *const c_void, *mut c_void)`.
2419        // The C-side enum is bitwise-equivalent to the Rust enum (same #[repr(u8)]).
2420        let cb: NrosRmwEventCallback =
2421            unsafe { core::mem::transmute::<nros_rmw::EventCallback, NrosRmwEventCallback>(cb) };
2422        let ret = unsafe {
2423            (self.vtable.register_publisher_event)(
2424                &mut view,
2425                kind.into(),
2426                deadline_ms,
2427                cb,
2428                user_ctx,
2429            )
2430        };
2431        if ret != NROS_RMW_RET_OK {
2432            return Err(error_from_ret(ret));
2433        }
2434        Ok(())
2435    }
2436
2437    fn assert_liveliness(&self) -> Result<(), TransportError> {
2438        // Phase 108.B — manual liveliness assertion. NULL function
2439        // pointer = backend doesn't support manual liveliness; the
2440        // runtime caller (Node) gates the call by liveliness_kind so
2441        // we just delegate.
2442        let view_ptr = self as *const _ as *mut Self;
2443        let mut view = unsafe { (*view_ptr).make_view() };
2444        let ret = unsafe { (self.vtable.assert_publisher_liveliness)(&mut view) };
2445        if ret != NROS_RMW_RET_OK {
2446            return Err(error_from_ret(ret));
2447        }
2448        Ok(())
2449    }
2450}
2451
2452impl Drop for CffiPublisher {
2453    fn drop(&mut self) {
2454        if !self.backend_data.is_null() {
2455            let mut view = self.make_view();
2456            unsafe { (self.vtable.destroy_publisher)(&mut view) };
2457        }
2458    }
2459}
2460
2461// ============================================================================
2462// CffiSubscriber
2463// ============================================================================
2464
2465/// Subscriber backed by a C vtable.
2466pub struct CffiSubscriber {
2467    vtable: &'static NrosRmwVtable,
2468    topic_name_buf: [u8; NAME_BUF_LEN],
2469    type_name_buf: [u8; NAME_BUF_LEN],
2470    qos: NrosRmwQos,
2471    can_loan_messages: bool,
2472    backend_data: *mut c_void,
2473    /// Phase 231 (RFC-0038) — cached `subscriber_supports_in_place` capability,
2474    /// queried once at creation so `supports_process_in_place(&self)` is cheap.
2475    supports_in_place: bool,
2476}
2477
2478impl CffiSubscriber {
2479    fn make_view(&mut self) -> NrosRmwSubscriber {
2480        NrosRmwSubscriber {
2481            topic_name: self.topic_name_buf.as_ptr(),
2482            type_name: self.type_name_buf.as_ptr(),
2483            qos: self.qos,
2484            can_loan_messages: self.can_loan_messages,
2485            _reserved: [0u8; 7],
2486            backend_data: self.backend_data,
2487        }
2488    }
2489
2490    /// Phase 231 (RFC-0038) — drive the `process_raw_in_place` vtable slot,
2491    /// marshalling the Rust `FnOnce` through the C `ctx`/`cb`. A monomorphized
2492    /// trampoline takes the closure out of a stack `Option` cell and calls it
2493    /// with the borrowed slice. The named generic `G` is why the public trait
2494    /// method (which uses APIT) delegates here.
2495    fn run_process_in_place<G: FnOnce(&[u8])>(&mut self, f: G) -> Result<bool, TransportError> {
2496        let Some(slot) = self.vtable.process_raw_in_place else {
2497            return Err(TransportError::MessageTooLarge);
2498        };
2499        unsafe extern "C" fn cb_tramp<G: FnOnce(&[u8])>(
2500            ctx: *mut c_void,
2501            ptr: *const u8,
2502            len: usize,
2503        ) {
2504            let cell = unsafe { &mut *(ctx as *mut Option<G>) };
2505            if let Some(g) = cell.take() {
2506                g(unsafe { core::slice::from_raw_parts(ptr, len) });
2507            }
2508        }
2509        let mut cell: Option<G> = Some(f);
2510        let mut view = self.make_view();
2511        let rc = unsafe {
2512            slot(
2513                &mut view,
2514                &mut cell as *mut Option<G> as *mut c_void,
2515                cb_tramp::<G>,
2516            )
2517        };
2518        if rc == NROS_RMW_RET_NO_DATA {
2519            Ok(false)
2520        } else if rc < 0 {
2521            Err(error_from_ret(rc))
2522        } else {
2523            Ok(rc > 0)
2524        }
2525    }
2526
2527    pub fn topic_name(&self) -> &str {
2528        cstr_buf_to_str(&self.topic_name_buf)
2529    }
2530
2531    pub fn type_name(&self) -> &str {
2532        cstr_buf_to_str(&self.type_name_buf)
2533    }
2534
2535    pub fn qos(&self) -> NrosRmwQos {
2536        self.qos
2537    }
2538
2539    /// `true` iff the backend exposes the receive loan primitive
2540    /// (Phase 99).
2541    pub fn can_loan_messages(&self) -> bool {
2542        self.can_loan_messages
2543    }
2544}
2545
2546/// Phase 124.A — read-only view returned by
2547/// [`CffiSubscriber::try_borrow`]. Holds the backend's raw buffer +
2548/// opaque token until `Drop` fires `sub_release`.
2549#[cfg(feature = "lending")]
2550pub struct CffiView<'a> {
2551    buf: *const u8,
2552    len: usize,
2553    token: *mut c_void,
2554    subscriber: Option<&'a mut CffiSubscriber>,
2555}
2556
2557#[cfg(feature = "lending")]
2558impl<'a> AsRef<[u8]> for CffiView<'a> {
2559    fn as_ref(&self) -> &[u8] {
2560        // SAFETY: `buf` came from `sub_borrow` with length `len`.
2561        // The borrow contract guarantees the buffer stays valid until
2562        // `sub_release` fires (in Drop). Lifetime `'a` borrows the
2563        // subscriber so the slice can't outlive the borrow.
2564        unsafe { core::slice::from_raw_parts(self.buf, self.len) }
2565    }
2566}
2567
2568#[cfg(feature = "lending")]
2569impl<'a> Drop for CffiView<'a> {
2570    fn drop(&mut self) {
2571        if let Some(sub) = self.subscriber.take()
2572            && let Some(release) = sub.vtable.sub_release
2573        {
2574            let mut view = sub.make_view();
2575            // SAFETY: `token` paired with a prior `sub_borrow` on
2576            // this subscriber and the subscriber is still alive.
2577            unsafe { release(&mut view, self.token) };
2578        }
2579    }
2580}
2581
2582#[cfg(feature = "lending")]
2583impl nros_rmw::SlotBorrowing for CffiSubscriber {
2584    type View<'a> = CffiView<'a>;
2585
2586    fn try_borrow(&mut self) -> Result<Option<CffiView<'_>>, TransportError> {
2587        let Some(borrow) = self.vtable.sub_borrow else {
2588            // Phase 124.A — backend doesn't natively borrow; runtime
2589            // falls back to `try_recv_raw` into a staging buffer
2590            // (124.A.3). `None` lets the caller use the slow path.
2591            return Ok(None);
2592        };
2593        let mut view = self.make_view();
2594        let mut out_buf: *const u8 = core::ptr::null();
2595        let mut out_len: usize = 0;
2596        let mut out_token: *mut c_void = core::ptr::null_mut();
2597        // SAFETY: vtable contract — borrowed pointers stay valid
2598        // until `sub_release` runs.
2599        let rc = unsafe { borrow(&mut view, &mut out_buf, &mut out_len, &mut out_token) };
2600        if rc == 0 {
2601            // No message ready.
2602            return Ok(None);
2603        }
2604        if rc < 0 {
2605            return Err(error_from_ret(rc));
2606        }
2607        if out_buf.is_null() {
2608            return Ok(None);
2609        }
2610        let len = (rc as usize).min(out_len.max(rc as usize));
2611        Ok(Some(CffiView {
2612            buf: out_buf,
2613            len,
2614            token: out_token,
2615            subscriber: Some(self),
2616        }))
2617    }
2618}
2619
2620impl nros_rmw::Subscriber for CffiSubscriber {
2621    type Error = TransportError;
2622
2623    fn supports_process_in_place(&self) -> bool {
2624        self.supports_in_place
2625    }
2626
2627    fn process_raw_in_place(&mut self, f: impl FnOnce(&[u8])) -> Result<bool, Self::Error> {
2628        self.run_process_in_place(f)
2629    }
2630
2631    fn has_data(&self) -> bool {
2632        // has_data takes &mut to match the C signature; cast away const
2633        // because the predicate is logically read-only — backends must
2634        // not mutate state from has_data.
2635        let view_ptr = self as *const _ as *mut Self;
2636        let mut view = unsafe { (*view_ptr).make_view() };
2637        let rc = unsafe { (self.vtable.has_data)(&mut view) };
2638        rc > 0
2639    }
2640
2641    fn try_recv_raw(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
2642        let mut view = self.make_view();
2643        let rc = unsafe { (self.vtable.try_recv_raw)(&mut view, buf.as_mut_ptr(), buf.len()) };
2644        if rc == NROS_RMW_RET_NO_DATA {
2645            return Ok(None);
2646        }
2647        if rc < 0 {
2648            return Err(error_from_ret(rc));
2649        }
2650        if rc == 0 {
2651            return Ok(None);
2652        }
2653        Ok(Some(rc as usize))
2654    }
2655
2656    fn try_recv_raw_with_info(
2657        &mut self,
2658        buf: &mut [u8],
2659    ) -> Result<Option<(usize, Option<MessageInfo>)>, TransportError> {
2660        let key = self.backend_data as usize;
2661        self.try_recv_raw(buf)
2662            .map(|opt| opt.map(|len| (len, take_cffi_message_info(key))))
2663    }
2664
2665    #[cfg(all(feature = "alloc", feature = "safety-e2e"))]
2666    fn try_recv_validated(
2667        &mut self,
2668        buf: &mut [u8],
2669    ) -> Result<Option<(usize, nros_rmw::IntegrityStatus)>, Self::Error> {
2670        let key = self.backend_data as usize;
2671        request_cffi_integrity_status(key);
2672        self.try_recv_raw(buf).map(|opt| {
2673            opt.map(|len| {
2674                (
2675                    len,
2676                    take_cffi_integrity_status(key).unwrap_or(nros_rmw::IntegrityStatus {
2677                        gap: 0,
2678                        duplicate: false,
2679                        crc_valid: None,
2680                    }),
2681                )
2682            })
2683        })
2684    }
2685
2686    fn try_recv_sequence(
2687        &mut self,
2688        buf: &mut [u8],
2689        per_msg_cap: usize,
2690        max_msgs: usize,
2691        out_lens: &mut [usize],
2692    ) -> Result<usize, TransportError> {
2693        // Phase 124.D.2 — runtime fallback. If the backend exposes
2694        // `try_recv_sequence` natively, call it in one hop; otherwise
2695        // delegate to the trait's default body which loop-drives
2696        // `try_recv_raw`. Either way the caller sees the same shape:
2697        // contiguous slot block + per-slot length array + count
2698        // return.
2699        if let Some(f) = self.vtable.try_recv_sequence {
2700            if per_msg_cap == 0 || max_msgs == 0 {
2701                return Ok(0);
2702            }
2703            let limit = max_msgs.min(out_lens.len());
2704            if buf.len() < limit.saturating_mul(per_msg_cap) {
2705                return Err(TransportError::BufferTooSmall);
2706            }
2707            let mut view = self.make_view();
2708            let rc = unsafe {
2709                f(
2710                    &mut view,
2711                    buf.as_mut_ptr(),
2712                    per_msg_cap,
2713                    limit,
2714                    out_lens.as_mut_ptr(),
2715                )
2716            };
2717            if rc < 0 {
2718                return Err(error_from_ret(rc));
2719            }
2720            return Ok(rc as usize);
2721        }
2722        // Phase 124.D.2 — `try_recv_raw` loop fallback. Inlined
2723        // here (rather than dispatching back through the trait
2724        // default body) so the recursion is structurally
2725        // impossible — `Subscriber::try_recv_sequence` on
2726        // `CffiSubscriber` is THIS function, and forwarding to
2727        // the default body would deadlock the override.
2728        if per_msg_cap == 0 || max_msgs == 0 {
2729            return Ok(0);
2730        }
2731        let limit = max_msgs.min(out_lens.len());
2732        if buf.len() < limit.saturating_mul(per_msg_cap) {
2733            return Err(TransportError::BufferTooSmall);
2734        }
2735        let mut count = 0;
2736        for i in 0..limit {
2737            let slot = &mut buf[i * per_msg_cap..(i + 1) * per_msg_cap];
2738            match self.try_recv_raw(slot)? {
2739                Some(len) => {
2740                    out_lens[i] = len;
2741                    count += 1;
2742                }
2743                None => break,
2744            }
2745        }
2746        Ok(count)
2747    }
2748
2749    fn deserialization_error(&self) -> TransportError {
2750        TransportError::DeserializationError
2751    }
2752
2753    fn unsupported_event_error(&self) -> TransportError {
2754        TransportError::Unsupported
2755    }
2756
2757    unsafe fn register_event_callback(
2758        &mut self,
2759        kind: nros_rmw::EventKind,
2760        deadline_ms: u32,
2761        cb: nros_rmw::EventCallback,
2762        user_ctx: *mut core::ffi::c_void,
2763    ) -> Result<(), TransportError> {
2764        let mut view = self.make_view();
2765        let cb: NrosRmwEventCallback =
2766            unsafe { core::mem::transmute::<nros_rmw::EventCallback, NrosRmwEventCallback>(cb) };
2767        let ret = unsafe {
2768            (self.vtable.register_subscriber_event)(
2769                &mut view,
2770                kind.into(),
2771                deadline_ms,
2772                cb,
2773                user_ctx,
2774            )
2775        };
2776        if ret != NROS_RMW_RET_OK {
2777            return Err(error_from_ret(ret));
2778        }
2779        Ok(())
2780    }
2781}
2782
2783impl Drop for CffiSubscriber {
2784    fn drop(&mut self) {
2785        if !self.backend_data.is_null() {
2786            clear_cffi_message_info(self.backend_data as usize);
2787            let mut view = self.make_view();
2788            unsafe { (self.vtable.destroy_subscriber)(&mut view) };
2789        }
2790    }
2791}
2792
2793// ============================================================================
2794// CffiServiceServer
2795// ============================================================================
2796
2797/// Service server backed by a C vtable.
2798pub struct CffiServiceServer {
2799    vtable: &'static NrosRmwVtable,
2800    service_name_buf: [u8; NAME_BUF_LEN],
2801    type_name_buf: [u8; NAME_BUF_LEN],
2802    backend_data: *mut c_void,
2803}
2804
2805impl CffiServiceServer {
2806    fn make_view(&mut self) -> NrosRmwServiceServer {
2807        NrosRmwServiceServer {
2808            service_name: self.service_name_buf.as_ptr(),
2809            type_name: self.type_name_buf.as_ptr(),
2810            _reserved: [0u8; 8],
2811            backend_data: self.backend_data,
2812        }
2813    }
2814
2815    pub fn service_name(&self) -> &str {
2816        cstr_buf_to_str(&self.service_name_buf)
2817    }
2818
2819    pub fn type_name(&self) -> &str {
2820        cstr_buf_to_str(&self.type_name_buf)
2821    }
2822}
2823
2824impl ServiceServerTrait for CffiServiceServer {
2825    type Error = TransportError;
2826
2827    fn has_request(&self) -> bool {
2828        let view_ptr = self as *const _ as *mut Self;
2829        let mut view = unsafe { (*view_ptr).make_view() };
2830        let rc = unsafe { (self.vtable.has_request)(&mut view) };
2831        rc > 0
2832    }
2833
2834    fn try_recv_request<'a>(
2835        &mut self,
2836        buf: &'a mut [u8],
2837    ) -> Result<Option<ServiceRequest<'a>>, TransportError> {
2838        let mut seq: i64 = 0;
2839        let mut view = self.make_view();
2840        let rc = unsafe {
2841            (self.vtable.try_recv_request)(&mut view, buf.as_mut_ptr(), buf.len(), &mut seq)
2842        };
2843        if rc == NROS_RMW_RET_NO_DATA {
2844            return Ok(None);
2845        }
2846        if rc < 0 {
2847            return Err(error_from_ret(rc));
2848        }
2849        if rc == 0 {
2850            return Ok(None);
2851        }
2852        let len = rc as usize;
2853        Ok(Some(ServiceRequest {
2854            data: &buf[..len],
2855            sequence_number: seq,
2856        }))
2857    }
2858
2859    fn send_reply(&mut self, sequence_number: i64, data: &[u8]) -> Result<(), TransportError> {
2860        let mut view = self.make_view();
2861        let ret = unsafe {
2862            (self.vtable.send_reply)(&mut view, sequence_number, data.as_ptr(), data.len())
2863        };
2864        if ret != NROS_RMW_RET_OK {
2865            return Err(error_from_ret(ret));
2866        }
2867        Ok(())
2868    }
2869}
2870
2871impl Drop for CffiServiceServer {
2872    fn drop(&mut self) {
2873        if !self.backend_data.is_null() {
2874            let mut view = self.make_view();
2875            unsafe { (self.vtable.destroy_service_server)(&mut view) };
2876        }
2877    }
2878}
2879
2880// ============================================================================
2881// CffiServiceClient
2882// ============================================================================
2883
2884/// Service client backed by a C vtable.
2885pub struct CffiServiceClient {
2886    vtable: &'static NrosRmwVtable,
2887    service_name_buf: [u8; NAME_BUF_LEN],
2888    type_name_buf: [u8; NAME_BUF_LEN],
2889    backend_data: *mut c_void,
2890    /// Phase 130.8 — flag (request length, or 0) tracking whether a
2891    /// request is in flight via the non-blocking `send_request_raw`
2892    /// / `try_recv_reply_raw` vtable slots. The legacy
2893    /// blocking-call_raw fallback that previously needed a local
2894    /// 4 KiB pending-request buffer has been removed; backends own
2895    /// the request bytes from `send_request_raw` onward.
2896    pending_len: usize,
2897}
2898
2899impl CffiServiceClient {
2900    fn make_view(&mut self) -> NrosRmwServiceClient {
2901        NrosRmwServiceClient {
2902            service_name: self.service_name_buf.as_ptr(),
2903            type_name: self.type_name_buf.as_ptr(),
2904            _reserved: [0u8; 8],
2905            backend_data: self.backend_data,
2906        }
2907    }
2908
2909    pub fn service_name(&self) -> &str {
2910        cstr_buf_to_str(&self.service_name_buf)
2911    }
2912
2913    pub fn type_name(&self) -> &str {
2914        cstr_buf_to_str(&self.type_name_buf)
2915    }
2916}
2917
2918impl ServiceClientTrait for CffiServiceClient {
2919    type Error = TransportError;
2920
2921    #[allow(deprecated)]
2922    fn call_raw(&mut self, request: &[u8], reply_buf: &mut [u8]) -> Result<usize, TransportError> {
2923        let mut view = self.make_view();
2924        let rc = unsafe {
2925            (self.vtable.call_raw)(
2926                &mut view,
2927                request.as_ptr(),
2928                request.len(),
2929                reply_buf.as_mut_ptr(),
2930                reply_buf.len(),
2931            )
2932        };
2933        if rc < 0 {
2934            return Err(error_from_ret(rc));
2935        }
2936        Ok(rc as usize)
2937    }
2938
2939    fn send_request_raw(&mut self, request: &[u8]) -> Result<(), TransportError> {
2940        // Phase 130.8 — every shipping backend now provides the
2941        // non-blocking `send_request_raw` + `try_recv_reply_raw`
2942        // vtable slots: XRCE-DDS-Client (native C),
2943        // Cyclone DDS C++ wrapper (native C++), Rust adapters
2944        // (dust-DDS + zenoh-pico via `rust_adapter`). The legacy
2945        // blocking-call_raw fallback that starved the executor's
2946        // spin loop (Phase 127.C.4 root cause) has been removed.
2947        // Backends that omit the slot get `Unsupported`; the
2948        // executor surfaces the error to the caller instead of
2949        // silently degrading to a multi-second blocking burst.
2950        let Some(f) = self.vtable.send_request_raw else {
2951            return Err(TransportError::Unsupported);
2952        };
2953        let mut view = self.make_view();
2954        let rc = unsafe { f(&mut view, request.as_ptr(), request.len()) };
2955        if rc != NROS_RMW_RET_OK {
2956            return Err(error_from_ret(rc));
2957        }
2958        self.pending_len = request.len().max(1);
2959        Ok(())
2960    }
2961
2962    fn try_recv_reply_raw(
2963        &mut self,
2964        reply_buf: &mut [u8],
2965    ) -> Result<Option<usize>, TransportError> {
2966        // Phase 130.8 — non-blocking poll only. NULL slot = backend
2967        // doesn't implement the service-client path; surface
2968        // Unsupported rather than the deprecated blocking fallback.
2969        let Some(f) = self.vtable.try_recv_reply_raw else {
2970            return Err(TransportError::Unsupported);
2971        };
2972        let mut view = self.make_view();
2973        let rc = unsafe { f(&mut view, reply_buf.as_mut_ptr(), reply_buf.len()) };
2974        if rc == NROS_RMW_RET_NO_DATA {
2975            return Ok(None);
2976        }
2977        if rc < 0 {
2978            self.pending_len = 0;
2979            return Err(error_from_ret(rc));
2980        }
2981        self.pending_len = 0;
2982        Ok(Some(rc as usize))
2983    }
2984
2985    fn server_available(&self) -> Result<bool, TransportError> {
2986        let Some(f) = self.vtable.service_server_available else {
2987            return Err(TransportError::Unsupported);
2988        };
2989        // SAFETY: `f` accepts a `*mut NrosRmwServiceClient`. We
2990        // construct a transient view from this client's fields the
2991        // same way `make_view` does, but on `&self` (no mutation
2992        // required for a graph probe). The borrowed pointers all
2993        // alias into `&self`, so the lifetime is bounded by the
2994        // call.
2995        let mut view = NrosRmwServiceClient {
2996            service_name: self.service_name_buf.as_ptr(),
2997            type_name: self.type_name_buf.as_ptr(),
2998            _reserved: [0u8; 8],
2999            backend_data: self.backend_data,
3000        };
3001        let rc = unsafe { f(&mut view) };
3002        match rc {
3003            0 => Ok(false),
3004            1 => Ok(true),
3005            n if n < 0 => Err(error_from_ret(n)),
3006            // Any positive value other than 1 is non-spec; treat as
3007            // "server available" — backends signalling availability
3008            // counts ≥ 1 still mean "ready".
3009            _ => Ok(true),
3010        }
3011    }
3012}
3013
3014impl Drop for CffiServiceClient {
3015    fn drop(&mut self) {
3016        if !self.backend_data.is_null() {
3017            let mut view = self.make_view();
3018            unsafe { (self.vtable.destroy_service_client)(&mut view) };
3019        }
3020    }
3021}
3022
3023// ============================================================================
3024// Factory
3025// ============================================================================
3026
3027/// RMW factory for the C function table backend.
3028#[derive(Default)]
3029pub struct CffiRmw;
3030
3031impl nros_rmw::Rmw for CffiRmw {
3032    type Session = CffiSession;
3033    type Error = TransportError;
3034
3035    fn open(self, config: &nros_rmw::RmwConfig) -> Result<CffiSession, TransportError> {
3036        let mode = match config.mode {
3037            nros_rmw::SessionMode::Client => 0u8,
3038            nros_rmw::SessionMode::Peer => 1u8,
3039        };
3040        CffiSession::open(config.locator, mode, config.domain_id, config.node_name)
3041    }
3042}
3043
3044impl CffiRmw {
3045    /// Phase 104.C.1 — open a session against a named backend.
3046    /// `rmw_name` selects an entry from the registry populated by
3047    /// `nros_rmw_cffi_register_named` (Phase 104.B.2).
3048    pub fn open_with_rmw(
3049        rmw_name: &str,
3050        config: &nros_rmw::RmwConfig,
3051    ) -> Result<CffiSession, TransportError> {
3052        let mode = match config.mode {
3053            nros_rmw::SessionMode::Client => 0u8,
3054            nros_rmw::SessionMode::Peer => 1u8,
3055        };
3056        CffiSession::open_named(
3057            rmw_name,
3058            config.locator,
3059            mode,
3060            config.domain_id,
3061            config.node_name,
3062        )
3063    }
3064}
3065
3066// ============================================================================
3067// Phase 102.5 — typed-struct roundtrip test
3068// ============================================================================
3069//
3070// Verifies the visible-struct contract end-to-end:
3071// 1. Runtime fills `topic_name` / `type_name` / `qos` before
3072//    `create_publisher`.
3073// 2. Backend's `create_publisher` writes `backend_data` and
3074//    `can_loan_messages` into the same struct.
3075// 3. Rust accessors (`CffiPublisher::topic_name()`, `qos()`,
3076//    `can_loan_messages()`) read back the values without any
3077//    vtable callback.
3078
3079#[cfg(test)]
3080#[allow(static_mut_refs)]
3081mod tests {
3082    use super::*;
3083    use nros_rmw::{Rmw, RmwConfig, Session, SessionMode, TopicInfo};
3084
3085    // Stub backend state. Statically allocated; the vtable's
3086    // `backend_data` round-trips a `&'static mut StubBackend`.
3087    static mut STUB_OPEN_CALLED: bool = false;
3088    static mut STUB_CREATE_PUB_CALLED: bool = false;
3089    static mut STUB_PUBLISH_CALLED: bool = false;
3090    static mut STUB_LAST_TOPIC_NAME: [u8; 64] = [0u8; 64];
3091    static mut STUB_LAST_TYPE_NAME: [u8; 64] = [0u8; 64];
3092    static mut STUB_LAST_QOS: NrosRmwQos = NrosRmwQos {
3093        reliability: 0,
3094        durability: 0,
3095        history: 0,
3096        liveliness_kind: 0,
3097        depth: 0,
3098        tx_express: 0,
3099        _reserved0: 0,
3100        deadline_ms: 0,
3101        lifespan_ms: 0,
3102        liveliness_lease_ms: 0,
3103        avoid_ros_namespace_conventions: 0,
3104        _reserved1: [0; 3],
3105        rx_buffer_hint: 0,
3106    };
3107
3108    /// Read a null-terminated `*const u8` into the supplied byte
3109    /// buffer. Used by the stub backend to capture the topic / type
3110    /// names that the runtime hands it.
3111    unsafe fn copy_cstr(src: *const u8, dst: &mut [u8]) {
3112        let mut i = 0;
3113        while i < dst.len() {
3114            let b = unsafe { *src.add(i) };
3115            dst[i] = b;
3116            if b == 0 {
3117                break;
3118            }
3119            i += 1;
3120        }
3121    }
3122
3123    unsafe extern "C" fn stub_open(
3124        _locator: *const u8,
3125        _mode: u8,
3126        _domain_id: u32,
3127        _node_name: *const u8,
3128        out: *mut NrosRmwSession,
3129    ) -> NrosRmwRet {
3130        unsafe {
3131            STUB_OPEN_CALLED = true;
3132            (*out).backend_data = 0xDEAD_BEEFusize as *mut c_void;
3133        }
3134        NROS_RMW_RET_OK
3135    }
3136
3137    unsafe extern "C" fn stub_close(_session: *mut NrosRmwSession) -> NrosRmwRet {
3138        NROS_RMW_RET_OK
3139    }
3140
3141    unsafe extern "C" fn stub_drive_io(
3142        _session: *mut NrosRmwSession,
3143        _timeout_ms: i32,
3144    ) -> NrosRmwRet {
3145        NROS_RMW_RET_OK
3146    }
3147
3148    unsafe extern "C" fn stub_create_publisher(
3149        _session: *mut NrosRmwSession,
3150        _topic_name: *const u8,
3151        _type_name: *const u8,
3152        _type_hash: *const u8,
3153        _domain_id: u32,
3154        qos: *const NrosRmwQos,
3155        out: *mut NrosRmwPublisher,
3156    ) -> NrosRmwRet {
3157        // Capture the typed-struct fields the runtime supplied.
3158        unsafe {
3159            STUB_CREATE_PUB_CALLED = true;
3160            copy_cstr((*out).topic_name, &mut STUB_LAST_TOPIC_NAME);
3161            copy_cstr((*out).type_name, &mut STUB_LAST_TYPE_NAME);
3162            STUB_LAST_QOS = *qos;
3163            (*out).backend_data = 0xCAFEusize as *mut c_void;
3164            (*out).can_loan_messages = true;
3165        }
3166        NROS_RMW_RET_OK
3167    }
3168
3169    unsafe extern "C" fn stub_destroy_publisher(_publisher: *mut NrosRmwPublisher) {}
3170
3171    unsafe extern "C" fn stub_publish_raw(
3172        publisher: *mut NrosRmwPublisher,
3173        _data: *const u8,
3174        _len: usize,
3175    ) -> NrosRmwRet {
3176        // Verify the runtime is still passing the same backend_data
3177        // and topic_name on every call.
3178        unsafe {
3179            STUB_PUBLISH_CALLED = true;
3180            assert_eq!((*publisher).backend_data as usize, 0xCAFE);
3181            let mut buf = [0u8; 64];
3182            copy_cstr((*publisher).topic_name, &mut buf);
3183            assert_eq!(&buf[..], &STUB_LAST_TOPIC_NAME);
3184        }
3185        NROS_RMW_RET_OK
3186    }
3187
3188    unsafe extern "C" fn stub_create_subscriber(
3189        _: *mut NrosRmwSession,
3190        _: *const u8,
3191        _: *const u8,
3192        _: *const u8,
3193        _: u32,
3194        _: *const NrosRmwQos,
3195        out: *mut NrosRmwSubscriber,
3196    ) -> NrosRmwRet {
3197        unsafe {
3198            (*out).backend_data = core::ptr::dangling_mut::<c_void>();
3199        }
3200        NROS_RMW_RET_OK
3201    }
3202    unsafe extern "C" fn stub_destroy_subscriber(_: *mut NrosRmwSubscriber) {}
3203    unsafe extern "C" fn stub_try_recv_raw(_: *mut NrosRmwSubscriber, _: *mut u8, _: usize) -> i32 {
3204        0
3205    }
3206    unsafe extern "C" fn stub_has_data(_: *mut NrosRmwSubscriber) -> i32 {
3207        0
3208    }
3209
3210    unsafe extern "C" fn stub_create_service_server(
3211        _: *mut NrosRmwSession,
3212        _: *const u8,
3213        _: *const u8,
3214        _: *const u8,
3215        _: u32,
3216        _: *const NrosRmwQos,
3217        out: *mut NrosRmwServiceServer,
3218    ) -> NrosRmwRet {
3219        unsafe {
3220            (*out).backend_data = core::ptr::dangling_mut::<c_void>();
3221        }
3222        NROS_RMW_RET_OK
3223    }
3224    unsafe extern "C" fn stub_destroy_service_server(_: *mut NrosRmwServiceServer) {}
3225    unsafe extern "C" fn stub_try_recv_request(
3226        _: *mut NrosRmwServiceServer,
3227        _: *mut u8,
3228        _: usize,
3229        _: *mut i64,
3230    ) -> i32 {
3231        NROS_RMW_RET_NO_DATA
3232    }
3233    unsafe extern "C" fn stub_has_request(_: *mut NrosRmwServiceServer) -> i32 {
3234        0
3235    }
3236    unsafe extern "C" fn stub_send_reply(
3237        _: *mut NrosRmwServiceServer,
3238        _: i64,
3239        _: *const u8,
3240        _: usize,
3241    ) -> NrosRmwRet {
3242        NROS_RMW_RET_OK
3243    }
3244
3245    unsafe extern "C" fn stub_create_service_client(
3246        _: *mut NrosRmwSession,
3247        _: *const u8,
3248        _: *const u8,
3249        _: *const u8,
3250        _: u32,
3251        _: *const NrosRmwQos,
3252        out: *mut NrosRmwServiceClient,
3253    ) -> NrosRmwRet {
3254        unsafe {
3255            (*out).backend_data = core::ptr::dangling_mut::<c_void>();
3256        }
3257        NROS_RMW_RET_OK
3258    }
3259    unsafe extern "C" fn stub_destroy_service_client(_: *mut NrosRmwServiceClient) {}
3260    unsafe extern "C" fn stub_call_raw(
3261        _: *mut NrosRmwServiceClient,
3262        _: *const u8,
3263        _: usize,
3264        _: *mut u8,
3265        _: usize,
3266    ) -> i32 {
3267        0
3268    }
3269
3270    unsafe extern "C" fn stub_register_subscriber_event(
3271        _: *mut NrosRmwSubscriber,
3272        _: NrosRmwEventKind,
3273        _: u32,
3274        _: NrosRmwEventCallback,
3275        _: *mut c_void,
3276    ) -> NrosRmwRet {
3277        NROS_RMW_RET_UNSUPPORTED
3278    }
3279    unsafe extern "C" fn stub_register_publisher_event(
3280        _: *mut NrosRmwPublisher,
3281        _: NrosRmwEventKind,
3282        _: u32,
3283        _: NrosRmwEventCallback,
3284        _: *mut c_void,
3285    ) -> NrosRmwRet {
3286        NROS_RMW_RET_UNSUPPORTED
3287    }
3288    unsafe extern "C" fn stub_assert_publisher_liveliness(_: *mut NrosRmwPublisher) -> NrosRmwRet {
3289        NROS_RMW_RET_UNSUPPORTED
3290    }
3291
3292    static STUB_VTABLE: NrosRmwVtable = NrosRmwVtable {
3293        open: stub_open,
3294        close: stub_close,
3295        drive_io: stub_drive_io,
3296        create_publisher: stub_create_publisher,
3297        destroy_publisher: stub_destroy_publisher,
3298        publish_raw: stub_publish_raw,
3299        create_subscriber: stub_create_subscriber,
3300        destroy_subscriber: stub_destroy_subscriber,
3301        try_recv_raw: stub_try_recv_raw,
3302        has_data: stub_has_data,
3303        create_service_server: stub_create_service_server,
3304        destroy_service_server: stub_destroy_service_server,
3305        try_recv_request: stub_try_recv_request,
3306        has_request: stub_has_request,
3307        send_reply: stub_send_reply,
3308        create_service_client: stub_create_service_client,
3309        destroy_service_client: stub_destroy_service_client,
3310        call_raw: stub_call_raw,
3311        send_request_raw: None,
3312        try_recv_reply_raw: None,
3313        register_subscriber_event: stub_register_subscriber_event,
3314        register_publisher_event: stub_register_publisher_event,
3315        assert_publisher_liveliness: stub_assert_publisher_liveliness,
3316        next_deadline_ms: None,
3317        set_wake_callback: None,
3318        pub_loan: None,
3319        pub_commit: None,
3320        pub_discard: None,
3321        sub_borrow: None,
3322        sub_release: None,
3323        service_server_available: None,
3324        try_recv_sequence: None,
3325        publish_streamed: None,
3326        ping_session: None,
3327        subscriber_supports_in_place: None,
3328        process_raw_in_place: None,
3329    };
3330
3331    #[test]
3332    fn service_server_no_data_maps_to_none() {
3333        use nros_rmw::ServiceServerTrait as _;
3334
3335        let mut server = CffiServiceServer {
3336            vtable: &STUB_VTABLE,
3337            service_name_buf: [0u8; NAME_BUF_LEN],
3338            type_name_buf: [0u8; NAME_BUF_LEN],
3339            backend_data: core::ptr::dangling_mut::<c_void>(),
3340        };
3341        let mut buf = [0u8; 16];
3342
3343        assert!(server.try_recv_request(&mut buf).unwrap().is_none());
3344    }
3345
3346    #[test]
3347    fn typed_struct_roundtrip() {
3348        // Register the stub vtable under its canonical name.
3349        let ret = unsafe { nros_rmw_cffi_register_named(c"default".as_ptr(), &STUB_VTABLE) };
3350        assert_eq!(ret, NROS_RMW_RET_OK);
3351
3352        // Open a session.
3353        let cfg = RmwConfig {
3354            mode: SessionMode::Client,
3355            locator: "tcp/127.0.0.1:7447",
3356            domain_id: 0,
3357            node_name: "test_node",
3358            namespace: "",
3359            properties: &[],
3360        };
3361        let mut session = Rmw::open(CffiRmw, &cfg).expect("session open");
3362        assert!(unsafe { STUB_OPEN_CALLED });
3363        assert_eq!(session.node_name(), "test_node");
3364
3365        // Create a publisher; verify backend received the typed
3366        // struct with topic_name + qos populated.
3367        let topic = TopicInfo::new("/chatter", "std_msgs/msg/Int32", "RIHS01_abc");
3368        let qos = nros_rmw::QosSettings::default();
3369        let publisher = session
3370            .create_publisher(&topic, qos)
3371            .expect("publisher create");
3372        assert!(unsafe { STUB_CREATE_PUB_CALLED });
3373        let topic_buf = unsafe { &STUB_LAST_TOPIC_NAME };
3374        assert_eq!(
3375            core::str::from_utf8(topic_buf)
3376                .unwrap_or("")
3377                .trim_end_matches('\0'),
3378            "/chatter"
3379        );
3380        let type_buf = unsafe { &STUB_LAST_TYPE_NAME };
3381        assert_eq!(
3382            core::str::from_utf8(type_buf)
3383                .unwrap_or("")
3384                .trim_end_matches('\0'),
3385            "std_msgs/msg/Int32"
3386        );
3387
3388        // Rust accessors read back the typed-struct fields.
3389        assert_eq!(publisher.topic_name(), "/chatter");
3390        assert_eq!(publisher.type_name(), "std_msgs/msg/Int32");
3391        assert!(publisher.can_loan_messages());
3392
3393        // Publish — verify backend_data round-trips correctly via
3394        // the typed view.
3395        use nros_rmw::Publisher as _;
3396        publisher.publish_raw(&[1u8, 2, 3]).expect("publish");
3397        assert!(unsafe { STUB_PUBLISH_CALLED });
3398    }
3399}