Skip to main content

nros_rmw/
custom_transport.rs

1//! Phase 115.A — runtime-pluggable custom transport vtable.
2//!
3//! Defines the platform-side hook that lets users plug a custom
4//! transport (USB-CDC, BLE, RS-485, semihosting bridge, ring-buffer
5//! loopback) at runtime without changing board crate, Cargo features,
6//! or rebuilding.
7//!
8//! The shape mirrors micro-ROS's
9//! `rmw_uros_set_custom_transport(framing, params, open, close, write, read)`
10//! and the C ABI exposed by `nros-c` / `nros-cpp` as
11//! `nros_transport_ops_t`.
12//!
13//! ## Why a fn-pointer vtable, not a Rust trait
14//!
15//! 1. **alloc-free.** A `Box<dyn CustomTransport>` would force the
16//!    `alloc` crate on every no_std backend that wants to use the
17//!    runtime hook. nano-ros's bare-metal / FreeRTOS / NuttX /
18//!    ThreadX targets ship without a global allocator on the default
19//!    feature flags, so `dyn` is a non-starter.
20//! 2. **C ABI parity.** The user-facing surface is `nros_transport_ops_t`
21//!    (a `#[repr(C)]` struct of fn pointers + a `void *`). A
22//!    Rust-side fn-ptr vtable means the `set_custom_transport` C
23//!    entry just memcpys the incoming struct into the static — no
24//!    glue, no shims, no trampolines.
25//! 3. **Matches XRCE's existing shape.** `uxr_set_custom_transport_callbacks`
26//!    already takes 4 raw fn pointers; the Rust wrapper at
27//!    `nros-rmw-xrce::init_transport` likewise. A trait would just
28//!    be an extra layer that has to be type-erased into fn pointers
29//!    anyway.
30//!
31//! See `docs/roadmap/phase-115-runtime-transport-vtable.md` § A.1
32//! for the full discussion.
33//!
34//! ## Threading contract (v1)
35//!
36//! - `read` and `write` may NOT be called concurrently from
37//!   different threads. The active backend serialises them through
38//!   the `drive_io` / spin-once path. Custom transports written
39//!   against this contract can use a single-buffer state machine
40//!   without internal locking.
41//! - Callbacks must NOT be invoked from interrupt context. Wrap
42//!   ISR-driven hardware in a queue + `read` poller.
43//! - `user_data` is opaque to the runtime — its `Send` / `Sync`
44//!   discipline is the caller's responsibility. The vtable struct
45//!   itself is `Send + Sync` because the four fn pointers always
46//!   are.
47
48use core::ffi::c_void;
49
50use crate::sync::Mutex;
51
52/// Phase 115.A.2 — current ABI version of [`NrosTransportOps`].
53///
54/// Embedded as the first field of the struct (see § *Versioning*
55/// below). Consumers fill in this exact value before passing the
56/// struct to [`set_custom_transport`]; runtime entry points reject
57/// any other value with [`crate::TransportError::IncompatibleAbi`]
58/// (or `NROS_RMW_RET_INCOMPATIBLE_ABI` at the C boundary).
59///
60/// The version bumps under two rules (per the portable-ABI design
61/// note R5 in `docs/design/0006-portable-rmw-platform-interface.md`):
62///
63/// - **Major** (e.g. `V1` → `V2`): existing fields removed or
64///   reordered. Old consumers fail cleanly via the version check.
65/// - **Minor** (e.g. struct gains an appended fn pointer): version
66///   stays the same. New consumers detect the new fn via the size
67///   of the trailing `_reserved` region. Today there is none — v1 is
68///   the inaugural version.
69pub const NROS_TRANSPORT_OPS_ABI_VERSION_V1: u32 = 1;
70
71/// Phase 115.A — runtime-pluggable custom transport. Caller fills in
72/// the four fn pointers, hands the struct to [`set_custom_transport`],
73/// and the active backend treats it as the read / write surface for
74/// every wire frame.
75///
76/// `#[repr(C)]` so this is the SAME struct that
77/// `nros_transport_ops_t` (C) and `nros::TransportOps` (C++) point at
78/// — single layout, no parallel definitions to drift.
79///
80/// # Return-code conventions
81///
82/// `open` / `write` return `NROS_RMW_RET_OK` (== 0) on success and
83/// a negative `rcl_ret_t` (see `nros-rmw-cffi`) on failure. `read`
84/// returns the non-negative byte count on success or a negative
85/// `rcl_ret_t` on error / timeout.
86///
87/// # Safety contract for the four fn pointers
88///
89/// - All callbacks receive `user_data` as their first argument. The
90///   pointer is whatever the caller stored at registration time; the
91///   runtime never dereferences it.
92/// - `buf` / `len` describe a contiguous byte region the callback
93///   may read (`write`) or write (`read`). The callback must NOT
94///   retain pointers across the call.
95/// - `params` (in `open`) is opaque per-transport metadata
96///   threaded through from `set_custom_transport`. May be `NULL`.
97#[repr(C)]
98#[derive(Copy, Clone)]
99pub struct NrosTransportOps {
100    /// Phase 115.A.2 — ABI version. Consumers MUST fill in
101    /// [`NROS_TRANSPORT_OPS_ABI_VERSION_V1`]; mismatched values are
102    /// rejected at registration time with
103    /// `TransportError::IncompatibleAbi` (`NROS_RMW_RET_INCOMPATIBLE_ABI`
104    /// at the C boundary). Reserved for future minor-version
105    /// detection — see the const's doc-comment.
106    pub abi_version: u32,
107    /// Phase 115.A.2 — reserved padding to keep the struct
108    /// alignment-stable across appends. Must be zero.
109    pub _reserved: u32,
110    /// Opaque caller context, threaded back into every callback as
111    /// the first argument. Lifetime: must outlive the transport's
112    /// active period (i.e. until `close` returns).
113    pub user_data: *mut c_void,
114    /// Open the underlying medium. `params` is opaque per-transport
115    /// metadata (e.g. UART baud rate, USB-CDC endpoint id) supplied
116    /// at registration time.
117    pub open: unsafe extern "C" fn(user_data: *mut c_void, params: *const c_void) -> i32,
118    /// Tear the transport down. Complement of `open`. After `close`
119    /// returns, the runtime will not invoke `read` or `write` on this
120    /// transport unless `set_custom_transport` is called again.
121    pub close: unsafe extern "C" fn(user_data: *mut c_void),
122    /// Send `len` bytes from `buf`. Returns 0 on success, negative
123    /// `rcl_ret_t` on failure. Must NOT block beyond a brief
124    /// hardware retry; long blocking should surface as
125    /// `NROS_RMW_RET_TIMEOUT` (-2).
126    pub write: unsafe extern "C" fn(user_data: *mut c_void, buf: *const u8, len: usize) -> i32,
127    /// Receive up to `len` bytes into `buf` within `timeout_ms`.
128    /// Returns the non-negative byte count on success (may be less
129    /// than `len`), or a negative `rcl_ret_t` on error / timeout.
130    pub read: unsafe extern "C" fn(
131        user_data: *mut c_void,
132        buf: *mut u8,
133        len: usize,
134        timeout_ms: u32,
135    ) -> i32,
136}
137
138// SAFETY: the struct is just four fn pointers + a *mut. Send / Sync
139// are sound because (a) fn pointers are always Send+Sync, and (b) the
140// caller owns synchronisation of `user_data` per the threading contract
141// (no concurrent read/write, no ISR invocation). Cross-thread
142// observability of the registered struct is guarded by the surrounding
143// Mutex, not by any property of this struct.
144unsafe impl Send for NrosTransportOps {}
145unsafe impl Sync for NrosTransportOps {}
146
147/// Phase 115.A — single-slot storage for the registered transport.
148///
149/// `set_custom_transport` writes the struct in; backends read it via
150/// [`take_custom_transport`] (during `Rmw::open`) or
151/// [`peek_custom_transport`] (during steady-state for liveliness /
152/// reconnect logic). One transport per process; a second
153/// `set_custom_transport` call before `take` overwrites the slot
154/// (documented as "register early, register once").
155static SLOT: Mutex<Option<NrosTransportOps>> = Mutex::new(None);
156
157/// Phase 115.A — register a custom transport vtable. Must be called
158/// **before** the first `Rmw::open` (or
159/// `rclc_support_init` from the C surface). v1 leaves enforcement of
160/// "before init" to backend code, and NO backend enforces it: a call
161/// after `Rmw::open` is implementation-defined, which is also what the
162/// C entry point `rclc_set_custom_transport` documents. This line
163/// used to say backends "reject re-registration with
164/// `ALREADY_INIT`" — `rmw_ret.h` defines no such code
165/// (issue 1126), so the sentence promised both a behaviour and a
166/// constant that do not exist.
167///
168/// Pass `None` to clear a previously-registered vtable (e.g. for
169/// teardown in tests).
170///
171/// Returns `Err(TransportError::IncompatibleAbi)` when `ops.is_some()`
172/// but `abi_version != NROS_TRANSPORT_OPS_ABI_VERSION_V1`. C / C++
173/// wrappers map this to `NROS_RMW_RET_INCOMPATIBLE_ABI`.
174///
175/// # Safety
176///
177/// The four fn pointers in `ops` must follow the threading contract
178/// documented on [`NrosTransportOps`] — no concurrent read/write, no
179/// ISR invocation, `user_data` outlives the transport's active period.
180pub unsafe fn set_custom_transport(
181    ops: Option<NrosTransportOps>,
182) -> Result<(), crate::TransportError> {
183    if let Some(o) = ops.as_ref()
184        && o.abi_version != NROS_TRANSPORT_OPS_ABI_VERSION_V1
185    {
186        return Err(crate::TransportError::IncompatibleAbi);
187    }
188    SLOT.with(|slot| *slot = ops);
189    Ok(())
190}
191
192/// Phase 115.A — peek at the currently-registered transport without
193/// consuming it. Used by backends that need to re-attach on session
194/// reconnect, or by tests that want to verify a registration landed.
195/// Returns `None` if nothing was registered.
196pub fn peek_custom_transport() -> Option<NrosTransportOps> {
197    SLOT.with(|slot| *slot)
198}
199
200/// Phase 115.A — drain the registered transport. Returns the
201/// previously-registered vtable (`None` if nothing was registered)
202/// and clears the slot. Backends call this from `Rmw::open` when
203/// `platform-custom` is the active platform; the vtable then lives
204/// inside the session for the rest of the process lifetime.
205pub fn take_custom_transport() -> Option<NrosTransportOps> {
206    SLOT.with(|slot| slot.take())
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    unsafe extern "C" fn stub_open(_ud: *mut c_void, _params: *const c_void) -> i32 {
214        0
215    }
216    unsafe extern "C" fn stub_close(_ud: *mut c_void) {}
217    unsafe extern "C" fn stub_write(_ud: *mut c_void, _buf: *const u8, _len: usize) -> i32 {
218        0
219    }
220    unsafe extern "C" fn stub_read(
221        _ud: *mut c_void,
222        _buf: *mut u8,
223        _len: usize,
224        _timeout_ms: u32,
225    ) -> i32 {
226        0
227    }
228
229    fn make_ops() -> NrosTransportOps {
230        NrosTransportOps {
231            abi_version: NROS_TRANSPORT_OPS_ABI_VERSION_V1,
232            _reserved: 0,
233            user_data: core::ptr::null_mut(),
234            open: stub_open,
235            close: stub_close,
236            write: stub_write,
237            read: stub_read,
238        }
239    }
240
241    /// Lifecycle: register, peek, take, peek-after-take.
242    #[test]
243    fn lifecycle() {
244        // Drain anything a previous test left behind so this test
245        // is order-independent under shared SLOT.
246        let _ = take_custom_transport();
247
248        assert!(peek_custom_transport().is_none());
249
250        unsafe {
251            set_custom_transport(Some(make_ops())).expect("set");
252        }
253
254        let peeked = peek_custom_transport().expect("peek after set");
255        assert!(peeked.user_data.is_null());
256        assert_eq!(peeked.abi_version, NROS_TRANSPORT_OPS_ABI_VERSION_V1);
257
258        // Peek again — slot still occupied.
259        assert!(peek_custom_transport().is_some());
260
261        let taken = take_custom_transport().expect("take");
262        assert!(taken.user_data.is_null());
263
264        // Slot is now empty.
265        assert!(peek_custom_transport().is_none());
266        assert!(take_custom_transport().is_none());
267    }
268
269    /// `set_custom_transport(None)` clears the slot.
270    #[test]
271    fn explicit_clear() {
272        let _ = take_custom_transport();
273        unsafe {
274            set_custom_transport(Some(make_ops())).expect("set");
275            set_custom_transport(None).expect("clear");
276        }
277        assert!(peek_custom_transport().is_none());
278    }
279
280    /// Phase 115.A.2 — abi_version mismatch is rejected with
281    /// `TransportError::IncompatibleAbi`. Slot stays whatever it was
282    /// before the bad call.
283    #[test]
284    fn rejects_unknown_abi_version() {
285        let _ = take_custom_transport();
286        let mut ops = make_ops();
287        ops.abi_version = 0xBAD0_BAD0; // not V1.
288        let err = unsafe { set_custom_transport(Some(ops)) };
289        assert!(matches!(err, Err(crate::TransportError::IncompatibleAbi)));
290        // Bad call did NOT install — slot stays empty.
291        assert!(peek_custom_transport().is_none());
292    }
293
294    /// Struct stays `Copy + Send + Sync` — the static-slot pattern
295    /// relies on these bounds at compile time.
296    #[test]
297    fn ops_is_copy_send_sync() {
298        fn assert_copy_send_sync<T: Copy + Send + Sync>() {}
299        assert_copy_send_sync::<NrosTransportOps>();
300    }
301}