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}