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}