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 `nros_ret_t` (see `nros-rmw-cffi`) on failure. `read`
84/// returns the non-negative byte count on success or a negative
85/// `nros_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    /// `nros_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 `nros_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/// `nros_support_init` from the C surface). v1 leaves enforcement of
160/// "before init" to backend code — they reject re-registration with
161/// `NROS_RMW_RET_ALREADY_INIT` after `Rmw::open` succeeds.
162///
163/// Pass `None` to clear a previously-registered vtable (e.g. for
164/// teardown in tests).
165///
166/// Returns `Err(TransportError::IncompatibleAbi)` when `ops.is_some()`
167/// but `abi_version != NROS_TRANSPORT_OPS_ABI_VERSION_V1`. C / C++
168/// wrappers map this to `NROS_RMW_RET_INCOMPATIBLE_ABI`.
169///
170/// # Safety
171///
172/// The four fn pointers in `ops` must follow the threading contract
173/// documented on [`NrosTransportOps`] — no concurrent read/write, no
174/// ISR invocation, `user_data` outlives the transport's active period.
175pub unsafe fn set_custom_transport(
176    ops: Option<NrosTransportOps>,
177) -> Result<(), crate::TransportError> {
178    if let Some(o) = ops.as_ref()
179        && o.abi_version != NROS_TRANSPORT_OPS_ABI_VERSION_V1
180    {
181        return Err(crate::TransportError::IncompatibleAbi);
182    }
183    SLOT.with(|slot| *slot = ops);
184    Ok(())
185}
186
187/// Phase 115.A — peek at the currently-registered transport without
188/// consuming it. Used by backends that need to re-attach on session
189/// reconnect, or by tests that want to verify a registration landed.
190/// Returns `None` if nothing was registered.
191pub fn peek_custom_transport() -> Option<NrosTransportOps> {
192    SLOT.with(|slot| *slot)
193}
194
195/// Phase 115.A — drain the registered transport. Returns the
196/// previously-registered vtable (`None` if nothing was registered)
197/// and clears the slot. Backends call this from `Rmw::open` when
198/// `platform-custom` is the active platform; the vtable then lives
199/// inside the session for the rest of the process lifetime.
200pub fn take_custom_transport() -> Option<NrosTransportOps> {
201    SLOT.with(|slot| slot.take())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    unsafe extern "C" fn stub_open(_ud: *mut c_void, _params: *const c_void) -> i32 {
209        0
210    }
211    unsafe extern "C" fn stub_close(_ud: *mut c_void) {}
212    unsafe extern "C" fn stub_write(_ud: *mut c_void, _buf: *const u8, _len: usize) -> i32 {
213        0
214    }
215    unsafe extern "C" fn stub_read(
216        _ud: *mut c_void,
217        _buf: *mut u8,
218        _len: usize,
219        _timeout_ms: u32,
220    ) -> i32 {
221        0
222    }
223
224    fn make_ops() -> NrosTransportOps {
225        NrosTransportOps {
226            abi_version: NROS_TRANSPORT_OPS_ABI_VERSION_V1,
227            _reserved: 0,
228            user_data: core::ptr::null_mut(),
229            open: stub_open,
230            close: stub_close,
231            write: stub_write,
232            read: stub_read,
233        }
234    }
235
236    /// Lifecycle: register, peek, take, peek-after-take.
237    #[test]
238    fn lifecycle() {
239        // Drain anything a previous test left behind so this test
240        // is order-independent under shared SLOT.
241        let _ = take_custom_transport();
242
243        assert!(peek_custom_transport().is_none());
244
245        unsafe {
246            set_custom_transport(Some(make_ops())).expect("set");
247        }
248
249        let peeked = peek_custom_transport().expect("peek after set");
250        assert!(peeked.user_data.is_null());
251        assert_eq!(peeked.abi_version, NROS_TRANSPORT_OPS_ABI_VERSION_V1);
252
253        // Peek again — slot still occupied.
254        assert!(peek_custom_transport().is_some());
255
256        let taken = take_custom_transport().expect("take");
257        assert!(taken.user_data.is_null());
258
259        // Slot is now empty.
260        assert!(peek_custom_transport().is_none());
261        assert!(take_custom_transport().is_none());
262    }
263
264    /// `set_custom_transport(None)` clears the slot.
265    #[test]
266    fn explicit_clear() {
267        let _ = take_custom_transport();
268        unsafe {
269            set_custom_transport(Some(make_ops())).expect("set");
270            set_custom_transport(None).expect("clear");
271        }
272        assert!(peek_custom_transport().is_none());
273    }
274
275    /// Phase 115.A.2 — abi_version mismatch is rejected with
276    /// `TransportError::IncompatibleAbi`. Slot stays whatever it was
277    /// before the bad call.
278    #[test]
279    fn rejects_unknown_abi_version() {
280        let _ = take_custom_transport();
281        let mut ops = make_ops();
282        ops.abi_version = 0xBAD0_BAD0; // not V1.
283        let err = unsafe { set_custom_transport(Some(ops)) };
284        assert!(matches!(err, Err(crate::TransportError::IncompatibleAbi)));
285        // Bad call did NOT install — slot stays empty.
286        assert!(peek_custom_transport().is_none());
287    }
288
289    /// Struct stays `Copy + Send + Sync` — the static-slot pattern
290    /// relies on these bounds at compile time.
291    #[test]
292    fn ops_is_copy_send_sync() {
293        fn assert_copy_send_sync<T: Copy + Send + Sync>() {}
294        assert_copy_send_sync::<NrosTransportOps>();
295    }
296}