Skip to main content

nros_rmw/
service_inbox.rs

1//! phase-461 W2 -- the caller-owned service inbox, stated once and
2//! RMW-agnostically.
3//!
4//! A service server is a queryable, and every queryable needs somewhere for a
5//! request to land between the transport's read task (the producer) and the
6//! executor's spin (the consumer). Until phase-461 the backend owned that ring
7//! and every family paid the same geometry: on the safety island 26 queryables
8//! x 4 slots x 1,024 B = 115,128 B, 24 of them parameter services whose worst
9//! well-formed request is 669 B and whose clients are sequential (issue 1352).
10//!
11//! W1 made the zenoh ring a HEADER over storage its owner supplies. This
12//! module is the other half of that seam: the description a caller hands the
13//! backend, in the one crate both the runtime (`nros-node`) and every backend
14//! already depend on. Two types:
15//!
16//! * [`CallerInboxStorage`] -- the backing a family declares as a `static`,
17//!   `DEPTH` slots of `SLOT_BYTES` bytes with one [`InboxEntry`] each;
18//! * [`CallerInbox`] -- the `Copy` header over it (geometry plus two
19//!   pointers), which is what crosses the seam and what an ABI slot would
20//!   carry.
21//!
22//! **Which backends accept one.** [`Session::SUPPORTS_CALLER_INBOX`] is a
23//! const, so the caller picks at compile time and a family that cannot bring
24//! its own ring says so in a `const` rather than falling back silently
25//! (RFC-0052: no dropped knob). Every backend in tree answers `false` today
26//! and the reason is per backend, stated at each one: Cyclone holds requests
27//! in reader history, XRCE in a C `req_ring`, and `nros-rmw-cffi` FORWARDS
28//! over a C vtable that has no slot to carry a ring through. zenoh's own ring
29//! is already caller-visible (`nros_rmw_zenoh::InboxStorage`, W1) and answers
30//! `true` the moment that vtable can reach it -- see this module's note on
31//! `CallerInbox` for what that costs.
32//!
33//! [`Session::SUPPORTS_CALLER_INBOX`]: crate::Session::SUPPORTS_CALLER_INBOX
34
35use core::{
36    cell::UnsafeCell,
37    sync::atomic::{AtomicBool, AtomicUsize},
38};
39
40/// One ring entry's bookkeeping: how many bytes of its slot are valid, the
41/// reply-correlation token the backend replies against, and whether the
42/// request that arrived was larger than the slot.
43///
44/// Per ENTRY, whatever storage the ring is over, and never inline in the
45/// per-queryable header -- that is the split W1 made and the reason a family
46/// can size its own ring without the backend's table growing a variant.
47#[repr(C)]
48pub struct InboxEntry {
49    /// Valid bytes in this entry's slot.
50    pub len: AtomicUsize,
51    /// Reply-correlation token. What it means is the backend's business (the
52    /// zenoh shim stores a reply-slot index); the ring only carries it from
53    /// the producer to the consumer.
54    pub seq: AtomicUsize,
55    /// Set when the incoming request exceeded `slot_bytes`. The payload is
56    /// then skipped and the consumer reports it, rather than the ring
57    /// truncating a request into a well-formed-looking short one.
58    pub overflow: AtomicBool,
59}
60
61impl InboxEntry {
62    /// A free entry.
63    pub const fn new() -> Self {
64        Self {
65            len: AtomicUsize::new(0),
66            seq: AtomicUsize::new(0),
67            overflow: AtomicBool::new(false),
68        }
69    }
70}
71
72impl Default for InboxEntry {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78/// Backing storage for one queryable's request ring: `DEPTH` slots of
79/// `SLOT_BYTES` bytes, plus one [`InboxEntry`] each.
80///
81/// Declared as a `static` by the family that knows its own bound -- for the
82/// ROS parameter services that is `nros-node`, the only crate that can see
83/// both the contract's declared parameters and the store's capacities
84/// (phase-446 F3's `param_service_bound`). It is `static` and not heap for
85/// the reason phase-391 keeps every payload buffer static: the Zephyr heap
86/// gate prices `arena + 24576` and a new term in it is a new way for an image
87/// to stop linking.
88///
89/// `Sync` because the ring over it is SPSC. The producer is the transport's
90/// read task, the consumer is the executor's spin, they are never on the same
91/// slot, and the ordering is carried by the cursors in the backend's
92/// per-queryable header -- the contract a backend-owned inline array already
93/// relied on, now stated on the type that leaves the backend.
94#[repr(C)]
95pub struct CallerInboxStorage<const SLOT_BYTES: usize, const DEPTH: usize> {
96    entries: [InboxEntry; DEPTH],
97    data: UnsafeCell<[[u8; SLOT_BYTES]; DEPTH]>,
98}
99
100// SAFETY: see the type's doc -- one producer, one consumer, never on the same
101// slot, ordered by the cursors in the backend's header.
102unsafe impl<const S: usize, const D: usize> Sync for CallerInboxStorage<S, D> {}
103
104impl<const S: usize, const D: usize> CallerInboxStorage<S, D> {
105    /// What one queryable's ring costs at this geometry: the slots plus their
106    /// entries. The per-queryable header is NOT in it -- that is paid once per
107    /// queryable whatever the ring is, and it stays the backend's.
108    pub const BYTES: usize = D * (S + core::mem::size_of::<InboxEntry>());
109
110    /// An empty ring.
111    pub const fn new() -> Self {
112        Self {
113            entries: [const { InboxEntry::new() }; D],
114            data: UnsafeCell::new([[0u8; S]; D]),
115        }
116    }
117
118    /// Bytes one request slot holds.
119    pub const fn slot_bytes(&self) -> usize {
120        S
121    }
122
123    /// Slots in the ring.
124    pub const fn depth(&self) -> usize {
125        D
126    }
127}
128
129impl<const S: usize, const D: usize> Default for CallerInboxStorage<S, D> {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135/// The header a caller hands the backend: a ring's geometry and where its
136/// bytes are.
137///
138/// `#[repr(C)]` and four plain words, because this is the shape an ABI slot
139/// has to carry. The road from `nros-node` to the zenoh shim runs through
140/// `nros-rmw-cffi`'s C vtable (`packages/core/nros-rmw-abi/include/nros/`),
141/// which today has no `create_service` variant that takes one; until it does,
142/// [`crate::Session::SUPPORTS_CALLER_INBOX`] is `false` on every backend the
143/// runtime can reach and a family's [`ServiceInboxSpec`] resolves to
144/// [`ServiceInboxSpec::Backend`]. Laying this out as an ABI struct now is what
145/// keeps that a forwarding problem rather than a redesign.
146#[derive(Clone, Copy)]
147#[repr(C)]
148pub struct CallerInbox {
149    slot_bytes: usize,
150    depth: usize,
151    entries: *const InboxEntry,
152    data: *mut u8,
153}
154
155// SAFETY: the pointers address a `'static` `CallerInboxStorage`, whose own
156// `Sync` argument covers every access made through them.
157unsafe impl Sync for CallerInbox {}
158unsafe impl Send for CallerInbox {}
159
160impl CallerInbox {
161    /// A header over storage the caller owns for the life of the program.
162    ///
163    /// `const`, so a family writes
164    /// `static INBOX: CallerInbox = CallerInbox::over(&STORAGE);` and hands
165    /// the reference to [`ServiceInboxSpec::Caller`].
166    pub const fn over<const S: usize, const D: usize>(
167        storage: &'static CallerInboxStorage<S, D>,
168    ) -> Self {
169        Self {
170            slot_bytes: S,
171            depth: D,
172            entries: storage.entries.as_ptr(),
173            data: storage.data.get().cast::<u8>(),
174        }
175    }
176
177    /// Bytes one request slot holds.
178    pub const fn slot_bytes(&self) -> usize {
179        self.slot_bytes
180    }
181
182    /// Slots in the ring.
183    pub const fn depth(&self) -> usize {
184        self.depth
185    }
186
187    /// Bytes this ring's storage occupies (slots and entries).
188    pub const fn storage_bytes(&self) -> usize {
189        self.depth * (self.slot_bytes + core::mem::size_of::<InboxEntry>())
190    }
191
192    /// Does this header sit over `storage`? For tests, which need to say "the
193    /// bytes landed in the caller's static" rather than infer it.
194    pub fn is_over<const S: usize, const D: usize>(
195        &self,
196        storage: &'static CallerInboxStorage<S, D>,
197    ) -> bool {
198        core::ptr::eq(self.entries, storage.entries.as_ptr())
199    }
200}
201
202/// Which inbox a service server receives through.
203///
204/// [`Backend`](Self::Backend) is what every user service passes and what the
205/// transport has always done. [`Caller`](Self::Caller) is a builtin family
206/// bringing storage it sized itself, and is accepted only where
207/// [`crate::Session::SUPPORTS_CALLER_INBOX`] is `true`.
208#[derive(Clone, Copy, Default)]
209pub enum ServiceInboxSpec {
210    /// The backend's own table, at the backend's geometry.
211    #[default]
212    Backend,
213    /// The caller's ring, over storage it owns for the life of the program.
214    Caller(&'static CallerInbox),
215}
216
217impl ServiceInboxSpec {
218    /// The caller's ring, if this spec names one.
219    pub const fn caller(&self) -> Option<&'static CallerInbox> {
220        match self {
221            Self::Backend => None,
222            Self::Caller(inbox) => Some(inbox),
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    static STORAGE: CallerInboxStorage<672, 1> = CallerInboxStorage::new();
232    static INBOX: CallerInbox = CallerInbox::over(&STORAGE);
233
234    #[test]
235    fn a_header_reports_the_storages_geometry() {
236        assert_eq!(INBOX.slot_bytes(), 672);
237        assert_eq!(INBOX.depth(), 1);
238        assert!(INBOX.is_over(&STORAGE));
239        assert_eq!(
240            INBOX.storage_bytes(),
241            CallerInboxStorage::<672, 1>::BYTES,
242            "the header and the storage must price the same ring"
243        );
244    }
245
246    #[test]
247    fn the_ring_costs_its_slots_plus_one_entry_each() {
248        assert_eq!(
249            CallerInboxStorage::<672, 1>::BYTES,
250            672 + core::mem::size_of::<InboxEntry>()
251        );
252        // Depth is the multiplier the parameter family's saving comes from:
253        // the same slot at depth 4 is four rings' worth of bytes.
254        assert_eq!(
255            CallerInboxStorage::<672, 4>::BYTES,
256            4 * CallerInboxStorage::<672, 1>::BYTES
257        );
258    }
259
260    #[test]
261    fn a_backend_spec_names_no_ring() {
262        assert!(ServiceInboxSpec::Backend.caller().is_none());
263        assert!(ServiceInboxSpec::default().caller().is_none());
264        assert!(ServiceInboxSpec::Caller(&INBOX).caller().is_some());
265    }
266}