nros_node/executor/arena.rs
1//! Callback arena infrastructure (all pub(crate)).
2
3use core::marker::PhantomData;
4
5use nros_core::{
6 CdrReader, DeserializeView, MessageInfo, RawMessageInfo, RosAction, RosMessage, RosService,
7 ViewableMessage,
8};
9use nros_rmw::{ServiceTrait, Subscription, TransportError};
10
11use super::{
12 action_core::{ActionClientCore, ActionServerCore},
13 handles::{ActionServer, ActiveGoal},
14 spsc_ring::SpscRing,
15 triple_buffer::TripleBuffer,
16 types::{
17 InvocationMode, NodeError, RawAcceptedCallback, RawCancelCallback,
18 RawCancelResponseCallback, RawFeedbackCallback, RawGoalCallback, RawGoalResponseCallback,
19 RawMessageDeserializeFn, RawRequestId, RawResponseCallback, RawResponseRequestIdCallback,
20 RawResultCallback, RawServiceCallback, RawServiceRequestIdCallback,
21 RawSubscriptionCallback, RawSubscriptionInfoCallback, TypedSubscriptionCallback,
22 },
23};
24use crate::session;
25// phase-430 W4 — the clock-source enum moved to the ungated `timer` module so
26// the declarative metadata layer can name it; `executor` re-exports it.
27pub(crate) use crate::timer::{TimerCallInfo, TimerClockSource};
28
29// ============================================================================
30// Callback metadata
31// ============================================================================
32
33// ============================================================================
34// Phase 8 — callback dispatch hooks (paired stubs)
35// ============================================================================
36//
37// `docs/design/callback_tracing.rst`. The hooks bracket the LEAF callback
38// invocation rather than the `try_process` boundary, because that boundary
39// gets both granularity and truth wrong: one `try_process` for an action
40// server fires up to three distinct user callbacks, a ring-buffered
41// subscription fires the user callback once per queued message, and
42// `Ok(false)` — "ran, fired nothing", the common outcome for a timer that is
43// not yet due — would be recorded as an invocation that never happened.
44//
45// Paired stubs (the `entry_tiers.rs` idiom) so the call sites read IDENTICALLY
46// whether or not the feature is on; the `#[cfg]` lives here, once, instead of
47// wrapping every hook site.
48
49/// Emit callback_start(handle) immediately before invoking a user callback.
50#[cfg(feature = "trace-callbacks")]
51#[inline]
52fn trace_cb_start(desc_idx: u8) {
53 super::callback_trace::start(desc_idx);
54}
55
56#[cfg(not(feature = "trace-callbacks"))]
57#[inline]
58fn trace_cb_start(_desc_idx: u8) {}
59
60/// phase-474 I3 -- a sample TAKEN for the subscription in `desc_idx`, emitted
61/// before its callback's start: the take event, and the sample's source stamp
62/// when `type_stamp` (the type's `STAMP_OFFSET`) or the slot's setting names
63/// one. Opt-in per slot or image-wide (`NROS_TRACE_TAKES`).
64#[cfg(feature = "trace-callbacks")]
65#[inline]
66fn trace_take(desc_idx: u8, raw: &[u8], type_stamp: Option<usize>) {
67 super::callback_trace::take(desc_idx, raw, type_stamp);
68}
69
70#[cfg(not(feature = "trace-callbacks"))]
71#[inline]
72fn trace_take(_desc_idx: u8, _raw: &[u8], _type_stamp: Option<usize>) {}
73
74/// phase-474 I3 -- whether this tick of the timer in `desc_idx` gets its
75/// start/end pair (one in N, per slot or `NROS_TRACE_TIMER_EVERY`).
76#[cfg(feature = "trace-callbacks")]
77#[inline]
78fn timer_tick_traced(desc_idx: u8) -> bool {
79 super::callback_trace::timer_tick_traced(desc_idx)
80}
81
82#[cfg(not(feature = "trace-callbacks"))]
83#[inline]
84fn timer_tick_traced(_desc_idx: u8) -> bool {
85 false
86}
87
88/// Emit callback_end(handle) immediately after a user callback returns.
89#[cfg(feature = "trace-callbacks")]
90#[inline]
91fn trace_cb_end(desc_idx: u8) {
92 super::callback_trace::end(desc_idx);
93}
94
95#[cfg(not(feature = "trace-callbacks"))]
96#[inline]
97fn trace_cb_end(_desc_idx: u8) {}
98
99/// Kind of registered callback entry.
100#[derive(Clone, Copy, PartialEq, Eq)]
101pub(crate) enum EntryKind {
102 Subscription,
103 Service,
104 ServiceClient,
105 Timer,
106 ActionServer,
107 ActionClient,
108 GuardCondition,
109}
110
111/// What a registration site calls the callback it is registering.
112///
113/// Phase 8 (`docs/design/callback_tracing.rst`) — the payload of the
114/// nros_callback_register event, resolved to a string only inside the
115/// feature-gated emitter so a build without `trace-callbacks` pays nothing
116/// for the two synthesised forms.
117///
118/// The three variants exist because the executor does NOT have a name for
119/// every entry kind:
120///
121/// * subscriptions / services / actions carry a topic or service name;
122/// * a timer carries only a period — so the period IS its identity;
123/// * a guard condition, and an arena subscription attached to an already-open
124/// `RmwSubscriber`, carry nothing at all — the slot index is the only
125/// thing that distinguishes one from another.
126///
127/// Naming them here rather than at the 25 emplace sites keeps the synthesis
128/// rules in one place; a new registration site that gets the name wrong is
129/// then a wrong ARGUMENT, not a second convention.
130// Only the feature-gated emitter READS these, so a build with the feature off
131// constructs them and never matches on them. That is the intended shape (the
132// call sites must read identically either way), not dead code to delete.
133#[cfg_attr(not(feature = "trace-callbacks"), allow(dead_code))]
134#[derive(Clone, Copy)]
135pub(crate) enum TraceName<'a> {
136 /// A real name the caller already has: topic, service, or action name.
137 Text(&'a str),
138 /// A timer, rendered `timer@<period_us>us`.
139 TimerPeriod(u64),
140 /// An entry with no name anywhere in the executor, rendered
141 /// `<label>#<slot>` — `guard#3`, `sub#7`. The slot index is exactly what
142 /// the runtime callback_start / callback_end events key on, so this
143 /// label is not a placeholder: it is the identity, spelled out.
144 Slot(&'static str, usize),
145}
146
147/// Metadata for a type-erased callback stored in the arena.
148///
149/// Each entry records where the concrete entry struct lives in the arena
150/// and carries monomorphized function pointers for dispatch and cleanup.
151#[derive(Clone, Copy)]
152pub(crate) struct CallbackMeta {
153 /// Byte offset into the arena where the concrete entry starts.
154 pub(crate) offset: usize,
155 /// What kind of entry this is (for `SpinOnceResult` counters).
156 pub(crate) kind: EntryKind,
157 /// Monomorphized dispatch: tries to receive and process one message/request.
158 /// Returns `Ok(true)` if work was done, `Ok(false)` if nothing available.
159 /// The `u64` parameter is `delta_us` (used by timer entries, ignored by others).
160 ///
161 /// The trailing `u8` is the entry's SLOT INDEX (phase 8,
162 /// `docs/design/callback_tracing.rst`). Inside a leaf the only identity
163 /// otherwise in scope is the address `arena_base + offset` — unique and
164 /// stable, but an address, and `arena_base` is not reachable from the
165 /// leaf. Threading the index in is the design's preferred fix over
166 /// publishing an `offset -> index` map: it needs no side table, it cannot
167 /// go stale if the arena moves, and attribution is exact at the point of
168 /// use. Both producers already have the index (the drain loop, the
169 /// trigger-fail timer sweep).
170 ///
171 /// Passed unconditionally rather than behind `feature = "trace-callbacks"`:
172 /// a cfg-dependent function-pointer signature would have to be threaded
173 /// through 21 leaf definitions as a macro. The cost when tracing is off is
174 /// one constant register argument per dispatch that the leaf ignores.
175 pub(crate) try_process: unsafe fn(*mut u8, u64, u8) -> Result<bool, TransportError>,
176 /// Monomorphized readiness check: returns true if the entry has data.
177 pub(crate) has_data: unsafe fn(*const u8) -> bool,
178 /// Monomorphized LET pre-sample: reads data from transport into the entry's
179 /// buffer without invoking the callback. No-op for non-subscription entries.
180 pub(crate) pre_sample: unsafe fn(*mut u8),
181 /// Per-callback invocation mode.
182 pub(crate) invocation: InvocationMode,
183 /// Issue 1631 — the arena bytes this entry's registration claimed,
184 /// starting at `offset` (the entry plus any trailing receive region).
185 ///
186 /// Registration sites write [`ArenaLen::UNRECORDED`]; the ONE choke point
187 /// `Executor::emplace_entry` overwrites it with the region the allocator
188 /// actually handed out, so no site can state a length that disagrees with
189 /// the allocation. `release_entry` gives exactly this back. Before 1631
190 /// the releasing CALLER had to restate the size from the concrete type,
191 /// which only works for an entry with no trailing region and a type the
192 /// caller can name — neither holds for a buffered subscription or for a
193 /// timer whose closure type lives in another crate.
194 pub(crate) arena_len: ArenaLen,
195 /// Monomorphized drop: runs destructors on the concrete entry.
196 pub(crate) drop_fn: unsafe fn(*mut u8),
197}
198
199// Issue 1631 — the no-growth claim, on EVERY target this crate compiles for
200// (the host test `callback_meta_len_rides_in_padding` only sees the host).
201const _: () = assert!(
202 core::mem::size_of::<Option<CallbackMeta>>() == 6 * core::mem::size_of::<usize>(),
203 "CallbackMeta grew: `arena_len` must ride in the tail padding (issue 1631)"
204);
205
206/// Issue 1631 — the recorded length of one entry's arena region.
207///
208/// Sized to ride in `CallbackMeta`'s existing tail padding, so the entry
209/// table (and every stated executor backing size) does not grow: the two
210/// one-byte enums leave six spare bytes on a 64-bit target and two on a
211/// 32-bit one. A 32-bit region of 64 KiB or more does not fit and is stored
212/// as [`UNRECORDED`](Self::UNRECORDED): its release still drops the entry and
213/// frees the slot, and keeps the bytes — a leak of one region, never a reuse
214/// of bytes that are still in use. `callback_meta_len_rides_in_padding` pins
215/// the no-growth claim.
216#[derive(Clone, Copy, PartialEq, Eq, Debug)]
217pub(crate) struct ArenaLen(ArenaLenRepr);
218
219#[cfg(target_pointer_width = "64")]
220type ArenaLenRepr = u32;
221#[cfg(not(target_pointer_width = "64"))]
222type ArenaLenRepr = u16;
223
224impl ArenaLen {
225 /// No length known: a release frees the slot and keeps the bytes.
226 pub(crate) const UNRECORDED: Self = Self(ArenaLenRepr::MAX);
227
228 /// The length, or [`UNRECORDED`](Self::UNRECORDED) when it does not fit.
229 pub(crate) fn new(len: usize) -> Self {
230 match ArenaLenRepr::try_from(len) {
231 Ok(v) if v != ArenaLenRepr::MAX => Self(v),
232 _ => Self::UNRECORDED,
233 }
234 }
235
236 /// `None` when unrecorded.
237 pub(crate) fn get(self) -> Option<usize> {
238 (self != Self::UNRECORDED).then_some(self.0 as usize)
239 }
240}
241
242/// phase-476 W2 — the hook that destroys a capture the arena holds.
243///
244/// A capture's bytes are copied into its entry's trailing region, which MOVES
245/// ownership of whatever the capture owns (a C++ `std::shared_ptr`, a
246/// `std::string`): the C++ side relinquishes its own copy without destroying
247/// it. So the arena copy has to be destroyed exactly once, when its entry is,
248/// and this `Drop` is that once — every entry that holds a capture carries one
249/// of these, so it runs inside `release_entry`'s `drop_fn` (or the executor's
250/// teardown), before the region is handed back.
251///
252/// `drop: None` is a capture with nothing to destroy, or no capture at all.
253pub(crate) struct CaptureGuard {
254 pub(crate) ctx: *mut core::ffi::c_void,
255 pub(crate) drop: Option<unsafe extern "C" fn(*mut core::ffi::c_void)>,
256}
257
258impl Drop for CaptureGuard {
259 fn drop(&mut self) {
260 if let Some(drop) = self.drop {
261 // SAFETY: `ctx` is the arena's copy of the capture the registration
262 // supplied `drop` for, still inside the entry's region; this runs
263 // once because the entry is dropped once.
264 unsafe { drop(self.ctx) }
265 }
266 }
267}
268
269/// phase-476 W2 — a C timer callback over a capture the arena holds.
270pub(crate) struct CapturedCallback {
271 pub(crate) callback: unsafe extern "C" fn(*mut core::ffi::c_void),
272 pub(crate) guard: CaptureGuard,
273}
274
275impl CapturedCallback {
276 pub(crate) fn call(&self) {
277 // SAFETY: `ctx` is the arena's copy of the caller's capture (or null
278 // when there is none), alive as long as this entry; the registration's
279 // contract is that `callback` reads its capture through it.
280 unsafe { (self.callback)(self.guard.ctx) }
281 }
282}
283
284// ============================================================================
285// Concrete entry types
286// ============================================================================
287
288/// Concrete subscription entry stored in the arena (with MessageInfo).
289#[repr(C)]
290pub(crate) struct SubInfoEntry<M, F> {
291 pub(crate) handle: session::RmwSubscriber,
292 /// Issue 1860 / phase-498 W1 -- the receive slot, in the arena's trailing
293 /// region and sized at registration (from the type's bound when the
294 /// caller names none), as `SubBufferedRawInfoCEntry` has been since
295 /// phase-408 W5b. It was an inline `[u8; RX_BUF]`, so every entry of this
296 /// shape reserved the image's closure maximum whatever its own type was.
297 pub(crate) buffer: TrailingBuf,
298 /// Length of pre-sampled LET data (0 = not sampled).
299 pub(crate) sampled_len: usize,
300 pub(crate) callback: F,
301 pub(crate) _phantom: PhantomData<M>,
302}
303
304/// Concrete subscription entry stored in the arena (with safety validation).
305#[cfg(feature = "safety-e2e")]
306#[repr(C)]
307pub(crate) struct SubSafetyEntry<M, F> {
308 pub(crate) handle: session::RmwSubscriber,
309 /// Issue 1860 / phase-498 W1 -- the receive slot, in the arena's trailing
310 /// region and sized at registration (from the type's bound when the
311 /// caller names none), as `SubBufferedRawInfoCEntry` has been since
312 /// phase-408 W5b. It was an inline `[u8; RX_BUF]`, so every entry of this
313 /// shape reserved the image's closure maximum whatever its own type was.
314 pub(crate) buffer: TrailingBuf,
315 /// Length of pre-sampled LET data (0 = not sampled).
316 pub(crate) sampled_len: usize,
317 pub(crate) callback: F,
318 pub(crate) _phantom: PhantomData<M>,
319}
320
321/// Concrete service entry stored in the arena.
322#[repr(C)]
323pub(crate) struct SrvEntry<Svc: RosService, F, const REQ_BUF: usize, const REPLY_BUF: usize> {
324 pub(crate) handle: session::RmwServiceServer,
325 pub(crate) req_buffer: [u8; REQ_BUF],
326 pub(crate) reply_buffer: [u8; REPLY_BUF],
327 pub(crate) callback: F,
328 pub(crate) _phantom: PhantomData<Svc>,
329}
330
331/// What a periodic timer does with periods it missed while its executor
332/// was blocked (issue #505).
333///
334/// A tier that loses the CPU for several periods — a long-running
335/// callback, or preemption by a higher-priority band — resumes with
336/// accumulated elapsed time worth more than one period.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
338pub enum TimerOverrunPolicy {
339 /// Coalesce the backlog: fire ONCE and drop the missed periods,
340 /// counting them in `overruns`. Phase is preserved (the sub-period
341 /// remainder carries over), so activations stay aligned to the
342 /// original cadence grid instead of drifting by each stall.
343 ///
344 /// The default, matching rclcpp (which advances the next call time
345 /// past "now") and Zephyr's `k_timer` (which coalesces expiries and
346 /// reports a count).
347 #[default]
348 Skip,
349 /// Replay every missed period, one activation per `try_process`
350 /// pass, until the backlog drains. Correct for timers that count or
351 /// accumulate, where each activation is a unit of work that must
352 /// not be lost. A replay burst runs back-to-back at dispatch speed,
353 /// NOT at the declared cadence — control loops usually want `Skip`.
354 CatchUp,
355}
356
357/// Concrete timer entry stored in the arena.
358///
359/// The first fields (up to `callback`) share layout with [`TimerHeader`],
360/// enabling type-erased access to timer state (cancel, reset, period query).
361#[repr(C)]
362pub(crate) struct TimerEntry<F> {
363 pub(crate) period_us: u64,
364 pub(crate) elapsed_us: u64,
365 /// Periods dropped by [`TimerOverrunPolicy::Skip`] (issue #505).
366 /// Saturating; never cleared by the dispatcher.
367 pub(crate) overruns: u32,
368 /// `overruns` as of the last `timer-overrun-runtime` monitor check,
369 /// so the rule reports newly dropped activations rather than the
370 /// running total. Lives here rather than in a table parallel to
371 /// `entries`, whose capacity is a runtime slice length.
372 pub(crate) overruns_reported: u32,
373 pub(crate) oneshot: bool,
374 pub(crate) fired: bool,
375 pub(crate) cancelled: bool,
376 pub(crate) overrun_policy: TimerOverrunPolicy,
377 /// Which clock advances `elapsed_us` (phase-425 W4). `Steady` consumes the
378 /// executor's spin delta; the others read their clock in `try_process`.
379 pub(crate) clock_source: TimerClockSource,
380 /// Last reading of `clock_source`, in nanoseconds. Meaningless — and never
381 /// read — while `clock_source` is `Steady`.
382 pub(crate) last_clock_ns: i64,
383 pub(crate) callback: F,
384}
385
386/// Type-erased header for timer entries.
387///
388/// Shares layout with the initial fields of `TimerEntry<F>` (both `#[repr(C)]`),
389/// so a `*mut TimerHeader` can safely read/write the timer state fields
390/// regardless of the concrete closure type `F`.
391#[repr(C)]
392pub(crate) struct TimerHeader {
393 pub(crate) period_us: u64,
394 pub(crate) elapsed_us: u64,
395 pub(crate) overruns: u32,
396 pub(crate) overruns_reported: u32,
397 pub(crate) oneshot: bool,
398 pub(crate) fired: bool,
399 pub(crate) cancelled: bool,
400 pub(crate) overrun_policy: TimerOverrunPolicy,
401 pub(crate) clock_source: TimerClockSource,
402 pub(crate) last_clock_ns: i64,
403}
404
405/// Concrete action server entry stored in the arena.
406#[repr(C)]
407pub(crate) struct ActionServerArenaEntry<
408 A: RosAction,
409 GoalF,
410 CancelF,
411 const GOAL_BUF: usize,
412 const RESULT_BUF: usize,
413 const FEEDBACK_BUF: usize,
414 const MAX_GOALS: usize,
415> {
416 pub(crate) server: ActionServer<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
417 pub(crate) goal_callback: GoalF,
418 pub(crate) cancel_callback: CancelF,
419}
420
421/// Concrete action server entry for raw (untyped) callbacks.
422///
423/// Uses [`ActionServerCore`] directly (no typed `ActionServer<A>` wrapper).
424#[repr(C)]
425pub(crate) struct ActionServerRawArenaEntry<
426 const GOAL_BUF: usize,
427 const RESULT_BUF: usize,
428 const FEEDBACK_BUF: usize,
429 const MAX_GOALS: usize,
430> {
431 pub(crate) core: ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
432 pub(crate) goal_callback: RawGoalCallback,
433 pub(crate) cancel_callback: RawCancelCallback,
434 /// Optional hook fired after the accept reply has been sent. Used by the
435 /// C API so user-supplied long-running `accepted_callback`s run *after*
436 /// the client has observed the accept instead of blocking the reply.
437 pub(crate) accepted_callback: Option<RawAcceptedCallback>,
438 pub(crate) context: *mut core::ffi::c_void,
439}
440
441/// Concrete action client entry for raw (untyped) async callbacks.
442///
443/// Contains the `ActionClientCore` plus callback function pointers for
444/// goal response, feedback, and result. The executor polls the core's
445/// non-blocking methods during `spin_once` and invokes the callbacks.
446#[repr(C)]
447pub(crate) struct ActionClientRawArenaEntry<
448 const GOAL_BUF: usize,
449 const RESULT_BUF: usize,
450 const FEEDBACK_BUF: usize,
451> {
452 pub(crate) core: ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
453 pub(crate) goal_response_callback: Option<RawGoalResponseCallback>,
454 pub(crate) feedback_callback: Option<RawFeedbackCallback>,
455 pub(crate) result_callback: Option<RawResultCallback>,
456 /// Issue 2030 — `Some` makes the dispatcher take cancel replies (freeing
457 /// their request slots) and hand each to this callback; see
458 /// [`RawCancelResponseCallback`].
459 pub(crate) cancel_response_callback: Option<RawCancelResponseCallback>,
460 pub(crate) context: *mut core::ffi::c_void,
461}
462
463/// Concrete service-client entry for raw (untyped) async polling.
464///
465/// Holds the `RmwServiceClient` plus a single-shot reply buffer and a
466/// callback fn pointer. The executor dispatches via
467/// `service_client_raw_try_process` which checks `reply_ready` (set by
468/// the transport waker) before calling `take_response_raw`. This
469/// avoids busy-polling `get_check` on every spin tick.
470///
471/// Single in-flight request per entry: a second `send_request` while
472/// `pending` is still `true` is the user's responsibility to avoid (the
473/// C wrapper checks at the call site).
474///
475/// Issue 2009 — `sequences` holds the ids that logical call went out under
476/// (one per re-send), and a reply is dispatched only if it names one of them.
477/// Every sender records its id through [`ServiceClientRawArenaEntry::record_send`].
478#[repr(C)]
479pub struct ServiceClientRawArenaEntry<const REPLY_BUF: usize> {
480 pub handle: session::RmwServiceClient,
481 pub reply_buffer: [u8; REPLY_BUF],
482 pub pending: bool,
483 /// Set by the transport waker when a reply arrives for this slot.
484 /// Checked by `try_process` to avoid blind polling.
485 pub reply_ready: core::sync::atomic::AtomicBool,
486 pub callback: Option<RawResponseCallback>,
487 /// Issue 2136 — when set, dispatched IN PLACE of `callback`, with the id
488 /// of the request the reply answers (rclc's
489 /// `rclc_executor_add_client_with_request_id`). Same `context`.
490 pub request_id_callback: Option<RawResponseRequestIdCallback>,
491 pub context: *mut core::ffi::c_void,
492 /// Issue 2009 — the ids of the call in flight; see the type docs.
493 pub sequences: ClientSequences,
494}
495
496/// Issue 2009 — the request ids of an executor-owned client's one logical
497/// call: every re-send generation of it (see
498/// [`MAX_CLIENT_RESEND_GENERATIONS`](crate::limits::MAX_CLIENT_RESEND_GENERATIONS)).
499pub type ClientSequences =
500 nros_rmw::RequestCorrelator<(), { crate::limits::MAX_CLIENT_RESEND_GENERATIONS }>;
501
502impl<const REPLY_BUF: usize> ServiceClientRawArenaEntry<REPLY_BUF> {
503 /// Record a request this entry just sent. A FRESH call (`resend` false)
504 /// first forgets the ids of any earlier call, so that call's late reply is
505 /// dropped instead of being dispatched as this one's; a re-send of the
506 /// same call adds its generation beside the others.
507 pub fn record_send(&mut self, seq: i64, resend: bool) {
508 record_client_send(&mut self.sequences, seq, resend);
509 }
510
511 /// Take the reply to the call in flight, dropping any reply that names
512 /// another request. On a match every generation of the call is retired,
513 /// so the replies the other generations still draw are dropped too.
514 pub fn take_reply(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
515 Ok(take_client_reply(&mut self.handle, buf, &mut self.sequences)?.map(|(len, _)| len))
516 }
517}
518
519/// Issue 2009 — [`ServiceClientRawArenaEntry::record_send`]'s rule, shared with
520/// the typed callback entry's header.
521pub(crate) fn record_client_send(seqs: &mut ClientSequences, seq: i64, resend: bool) {
522 if !resend {
523 seqs.clear();
524 } else if seq == nros_rmw::SEQUENCE_ID_UNKNOWN && seqs.contains(seq) {
525 // An id-less backend's re-sends are one request: nothing to add.
526 return;
527 }
528 seqs.insert_evicting(seq, ());
529}
530
531/// Issue 2009 — [`ServiceClientRawArenaEntry::take_reply`]'s rule.
532/// Returns the reply's length and the id it was matched on.
533pub(crate) fn take_client_reply(
534 handle: &mut session::RmwServiceClient,
535 buf: &mut [u8],
536 seqs: &mut ClientSequences,
537) -> Result<Option<(usize, i64)>, TransportError> {
538 match nros_rmw::take_correlated(handle, buf, seqs)? {
539 Some((len, seq, ())) => {
540 // The remaining generations answer a call that is now complete.
541 seqs.clear();
542 Ok(Some((len, seq)))
543 }
544 None => Ok(None),
545 }
546}
547
548/// Concrete service entry for raw (untyped) callbacks.
549///
550/// Issue 2081 — `C` is the callback's shape: [`RawServiceCallback`] (the
551/// request bytes alone) or [`RawServiceRequestIdCallback`] (plus the request's
552/// id). Both are one function pointer, so the entry's size does not depend on
553/// which.
554#[repr(C)]
555pub(crate) struct SrvRawEntry<
556 const REQ_BUF: usize,
557 const REPLY_BUF: usize,
558 C: RawServiceDispatch = RawServiceCallback,
559> {
560 pub(crate) handle: session::RmwServiceServer,
561 pub(crate) req_buffer: [u8; REQ_BUF],
562 pub(crate) reply_buffer: [u8; REPLY_BUF],
563 pub(crate) callback: C,
564 pub(crate) context: *mut core::ffi::c_void,
565 /// phase-488 — the arena's copy of a C++ handler's capture (a `std::bind`
566 /// over `this`, a capturing lambda), destroyed with the entry. `drop: None`
567 /// when the context is not a capture, which is every C caller.
568 pub(crate) capture: CaptureGuard,
569}
570
571/// Concrete guard condition entry stored in the arena.
572#[repr(C)]
573pub(crate) struct GuardConditionEntry<F> {
574 pub(crate) flag: portable_atomic::AtomicBool,
575 pub(crate) callback: F,
576}
577
578// ============================================================================
579// QoS-driven buffered subscription entries (Phase 73)
580// ============================================================================
581
582/// Buffer strategy selected by QoS depth at subscription registration time.
583///
584/// The buffer data lives in a trailing region immediately after the
585/// `SubBufferedEntry` struct in the arena.
586pub(crate) enum BufferStrategy {
587 /// `KEEP_LAST(1)`: 3 slots, latest-value semantics, writer never blocks.
588 Triple(TripleBuffer),
589 /// `KEEP_LAST(N)` where N > 1: N+1 slots, FIFO ordering, bounded drops.
590 Ring(SpscRing),
591}
592
593impl BufferStrategy {
594 /// Check if new data is available.
595 pub(crate) fn has_data(&self) -> bool {
596 match self {
597 BufferStrategy::Triple(tb) => tb.has_data(),
598 BufferStrategy::Ring(ring) => ring.has_data(),
599 }
600 }
601}
602
603/// The QoS depth an action client's FEEDBACK stream registers with.
604///
605/// Issue 1190 — was an `8u16` literal at
606/// `NodeHandle::create_action_client_with_callbacks_sized`'s one call site.
607/// Named because `nros-node/build.rs` restates it to price the action-client
608/// arena slot, and a literal is a restatement nothing can be held to; see
609/// [`arena_model_tests`].
610pub const DEFAULT_ACTION_FEEDBACK_DEPTH: u16 = 8;
611
612/// Compute the number of buffer slots and trailing region size for a given
613/// QoS depth and per-slot buffer size.
614///
615/// Returns `(slot_count, trailing_bytes)`.
616pub(crate) fn buffered_region_size(depth: u32, slot_size: usize) -> (usize, usize) {
617 if depth <= 1 {
618 // Triple buffer: 3 fixed slots
619 (
620 TripleBuffer::SLOT_COUNT,
621 TripleBuffer::SLOT_COUNT * slot_size,
622 )
623 } else {
624 let d = depth as usize;
625 (SpscRing::slot_count(d), SpscRing::region_size(d, slot_size))
626 }
627}
628
629/// phase-408 W5b — a SINGLE flat payload slot living in the arena's trailing
630/// region, sized at REGISTRATION from the caller's `rx_buffer_hint` instead of
631/// baked into the entry as `[u8; RX_BUF]`.
632///
633/// Why this and not [`BufferStrategy`], which the plain C raw path uses: a
634/// triple buffer / ring DECOUPLES the producer's slot from the consumer's, and
635/// the two entries that hold a `TrailingBuf` carry PER-SAMPLE side data beside
636/// the payload — the wire attachment for
637/// [`SubBufferedRawInfoCEntry`], the integrity status for
638/// `SubBufferedRawSafetyCEntry`. Decoupled slots cannot carry that, which is
639/// the reason both were flat in the first place (see
640/// [`SubBufferedRawInfoEntry`]'s note) and it has not changed. What made them
641/// EXPENSIVE was the const, not the flatness: `RX_BUF` is
642/// `DEFAULT_RX_BUF_SIZE` at every call site, so a subscription that knows its
643/// type's bound was still charged the image-wide default. Moving the bytes out
644/// to the trailing region spends the hint on the allocation while keeping one
645/// sample per dispatch.
646///
647/// The buffer does not own its memory — the arena does, and it outlives every
648/// entry in it.
649#[repr(C)]
650pub(crate) struct TrailingBuf {
651 ptr: *mut u8,
652 len: usize,
653}
654
655impl TrailingBuf {
656 /// Adopt `len` bytes of arena trailing region at `ptr`, zeroing them.
657 ///
658 /// The zeroing is what makes [`as_mut_slice`](Self::as_mut_slice) a safe
659 /// method: the executor arena is `&mut [MaybeUninit<u8>]`, so the region is
660 /// UNINITIALISED until someone writes it, and handing out a `&mut [u8]`
661 /// over uninit bytes is UB even if every reader stays inside the length a
662 /// receive reported. The entries this replaced held an inline
663 /// `[0u8; RX_BUF]` and were therefore initialised; one memset per
664 /// REGISTRATION (not per sample) keeps that property rather than trading it
665 /// for the allocation saving.
666 ///
667 /// # Safety
668 /// `ptr` must point to at least `len` writable bytes that stay valid for
669 /// the lifetime of this `TrailingBuf` — i.e. a region handed back by
670 /// `arena_alloc_with_trailing`, which is never reused while the entry
671 /// lives.
672 pub(crate) unsafe fn init(ptr: *mut u8, len: usize) -> Self {
673 // Safety: the caller's contract — `len` writable bytes at `ptr`.
674 unsafe { core::ptr::write_bytes(ptr, 0, len) };
675 Self { ptr, len }
676 }
677
678 /// The whole slot, read-only, for a callback to borrow a received prefix.
679 pub(crate) fn as_slice(&self) -> &[u8] {
680 // Safety: as `as_mut_slice` -- `len` initialised bytes at `ptr`.
681 unsafe { core::slice::from_raw_parts(self.ptr, self.len) }
682 }
683
684 /// The whole slot, for a receive to fill.
685 pub(crate) fn as_mut_slice(&mut self) -> &mut [u8] {
686 // Safety: the `init` contract — `len` writable bytes at `ptr`, valid
687 // for as long as `self`, and zeroed there so they are initialised `u8`.
688 unsafe { core::slice::from_raw_parts_mut(self.ptr, self.len) }
689 }
690
691 /// The slot's base address, for handing to a C callback.
692 pub(crate) fn as_ptr(&self) -> *const u8 {
693 self.ptr
694 }
695}
696
697/// Subscription entry with QoS-driven buffer strategy (Phase 73).
698///
699/// Unlike the legacy single-buffer pattern, this entry
700/// stores a [`BufferStrategy`] that manages a trailing buffer region
701/// allocated from the arena at registration time.
702///
703/// # Arena layout
704///
705/// ```text
706/// [SubBufferedEntry<M, F> struct][trailing: slot_count × slot_size bytes]
707/// ↑ offset ↑ buffer managed by BufferStrategy
708/// ```
709/// W3b.5 — a contracted subscriber's age hook: the endpoint's cell plus
710/// the epoch clock, captured at registration. `None` = uncontracted (or
711/// no epoch source / no stamp in the type) — the take path costs one
712/// `Option` branch.
713pub(crate) type AgeMon = (
714 &'static crate::executor::monitor::SubMonitorCell,
715 Option<fn() -> u64>,
716);
717
718/// Record one stamped take on an age cell: its age when an epoch source is
719/// installed, else the take alone (issue 2215, `observe_unaged`).
720#[inline]
721fn observe_stamp(
722 cell: &crate::executor::monitor::SubMonitorCell,
723 epoch: Option<fn() -> u64>,
724 raw: &[u8],
725 off: usize,
726) {
727 if let Some(stamp_us) = crate::executor::monitor::peek_stamp_us(raw, off) {
728 match epoch {
729 Some(epoch) => cell.observe(stamp_us, epoch()),
730 None => cell.observe_unaged(),
731 }
732 }
733}
734
735/// W3b.5 — peek the message stamp from the raw CDR buffer and record its
736/// take-age. Compiled away per-type when `M::STAMP_OFFSET` is `None`.
737#[inline]
738pub(crate) fn observe_age<M: RosMessage>(raw: &[u8], mon: &Option<AgeMon>) {
739 if let (Some((cell, epoch)), Some(off)) = (mon, M::STAMP_OFFSET) {
740 observe_stamp(cell, *epoch, raw, off);
741 }
742}
743
744/// Issue 2215 — the C/C++ callback path's age hook. That path carries no Rust
745/// type, so the stamp's CDR offset comes from the registration (the C++
746/// template reads it off the message type, `M::STAMP_OFFSET`, and passes it in
747/// `nros_cpp_subscription_options_t::stamp_offset`). Bound after registration
748/// by `Executor::bind_raw_subscription_stamp`; `None` = no age row on this
749/// topic, or the type carries no stamp.
750#[derive(Clone, Copy)]
751pub(crate) struct RawAgeMon {
752 pub(crate) cell: &'static crate::executor::monitor::SubMonitorCell,
753 pub(crate) epoch: Option<fn() -> u64>,
754 pub(crate) stamp_offset: u16,
755}
756
757/// Issue 2215 — record a C/C++ subscription's take on its age row.
758#[inline]
759pub(crate) fn observe_age_raw(raw: &[u8], mon: &Option<RawAgeMon>) {
760 if let Some(m) = mon {
761 observe_stamp(m.cell, m.epoch, raw, m.stamp_offset as usize);
762 }
763}
764
765#[repr(C)]
766pub(crate) struct SubBufferedEntry<M, F> {
767 pub(crate) handle: session::RmwSubscriber,
768 pub(crate) buffer: BufferStrategy,
769 pub(crate) callback: F,
770 /// W3b.5 — age hook for contracted endpoints.
771 pub(crate) age_mon: Option<AgeMon>,
772 pub(crate) _phantom: PhantomData<M>,
773}
774
775/// Drain the RMW subscriber handle into the buffer strategy.
776///
777/// Calls `take_serialized()` on the subscriber handle and writes received data
778/// into the triple buffer's write slot or the SPSC ring's next push slot.
779///
780/// # Safety
781/// `entry` must be a valid mutable reference to a `SubBufferedEntry`.
782/// Issue 0757 — how many takes this process has dropped, for throttling.
783///
784/// A COUNTER, not a per-entry field, and deliberately: the arena's entry
785/// structs are sized by knob at build time (`EXECUTOR_OPAQUE_U64S`), so adding
786/// a field here would move every image's executor footprint to buy a log line.
787/// The cost is that the report cannot name the topic — see `report_dropped_take`.
788static DROPPED_TAKES: portable_atomic::AtomicU32 = portable_atomic::AtomicU32::new(0);
789
790/// issue 0900 — has the arena-headroom advisory been emitted?
791///
792/// A STATIC, for the reason [`DROPPED_TAKES`] is one: an `Executor` field would
793/// move `EXECUTOR_OPAQUE_U64S` and therefore every image's executor footprint,
794/// to buy a diagnostic. Process-scoped rather than per-executor is also the
795/// RIGHT scope here, not merely the cheap one — the number this names
796/// (`NROS_EXECUTOR_ARENA_SIZE`) is a BUILD-TIME constant, identical for every
797/// executor in the image, so saying it twice adds nothing.
798static ARENA_ADVISORY_DONE: portable_atomic::AtomicBool = portable_atomic::AtomicBool::new(false);
799
800/// Report an arena that is far larger than the entities registered in it.
801///
802/// `ARENA_SIZE` is derived by budgeting EVERY slot at the ActionClient worst
803/// case (`nros-node/build.rs`) unless the image declares its entities, so an
804/// image with no action client carries several times what it can use — 74,240
805/// bytes against ~51 KiB for four default-QoS subscriptions. (That comparison
806/// read "~16 KiB" until issue 1190: the pub/sub figure it named came from a
807/// model that priced a subscription's history at three slots when the ROS
808/// default is eleven.) The arena is a BUMP allocator, so
809/// `arena_used` is the exact claimed total rather than a reservation, and the
810/// allocator has therefore always known the right answer and never said it.
811///
812/// The override exists (`NROS_EXECUTOR_ARENA_SIZE`, or
813/// `CONFIG_NROS_EXECUTOR_ARENA_SIZE` on Zephyr) and is already load-bearing:
814/// the FreeRTOS action examples pin it to 8192 and still need a 64 KB app task
815/// stack, a number someone found by hitting "Invalid mbox" and working
816/// backwards. That is the shape of issues 0271/0739 — a knob nobody can
817/// enumerate is a knob nobody sets — and this turns it from folklore into a
818/// measurement.
819///
820/// **Where the arena lives is the CALLER's choice**, and THREE answers are real
821/// -- phase-392 W6 surveyed every entry path and found a third this comment had
822/// missed. `Executor` holds `arena: &'s mut [MaybeUninit<u8>]` -- a borrowed
823/// slice, since phase-271 (issue 0110) moved the sized tables off build-time
824/// consts. What is inline is `ExecutorInlineStorage::backing`, which the C FFI
825/// sizes its `_opaque` from.
826///
827/// * **`.bss`** -- all 34 in-tree C `rclc_executor_t` objects are file-scope
828/// statics; the C++ entry reaches `Node::GlobalStorageHolder`, also a static;
829/// and the `alloc` convenience constructors serve from
830/// `super::backing::EXECUTOR_BACKING`. Visible to `nm` and `mem-report`.
831/// Measured on mr-canhubk344: DTCM tracked ARENA_SIZE one-for-one across
832/// MAX_CBS 24 -> 36. Measured on the native zenoh talker: 21,560 B.
833/// * **the heap** -- the `alloc` constructors' fallback (a second executor, or
834/// an entry sized past the reservation, or `NROS_EXECUTOR_BACKING_U64S=0`).
835/// This was the ONLY Rust answer before W6, and it has no symbol, which is
836/// why the campaign's own instrument could not see its largest consumer.
837/// * **a task stack** -- a stack-declared `rclc_executor_t`. Legal, and nothing
838/// in the tree does it.
839///
840/// Do not size against one placement and assume another.
841///
842/// `nros_log`, never stdio: this is reached on `no_std` targets and inside
843/// Zephyr `native_sim`, where a Rust `std` stdio call is FATAL (issue 0589).
844#[cold]
845fn report_arena_headroom(used: usize, capacity: usize) {
846 // Round the suggestion up so a small later registration still fits, and
847 // keep it a multiple of 1024 because that is the unit the knob is written
848 // in everywhere else in the tree.
849 let suggest = used.next_multiple_of(1024).max(1024);
850 // BUDGET: `nros_log`'s call-site format buffer is 256 bytes by default
851 // (`NROS_LOG_BUFFER_SIZE`), and overflow truncates with a `…` rather than
852 // dropping the record. The first draft of this line ran ~450 bytes and was
853 // cut mid-number, so the sink received "executor arena is 74240 bytes and
854 // 32…" — every word of the explanation and NONE of the value to set. A
855 // diagnostic that explains itself past the budget delivers exactly the
856 // folklore it was written to replace. So: the actionable value FIRST, the
857 // reasoning in this comment and issue 0900, and a test that fails on `…`.
858 nros_log::log_info!(
859 nros_log::get_logger("nros"),
860 // phase-392 W6 — the clause "and the arena is INLINE ON THE TASK STACK"
861 // stood here and was FALSE on every path (see the doc comment above);
862 // dropping it is also what buys the budget back. Placement is the
863 // caller's, so this line names the NUMBER, which is the same whichever
864 // memory the backing came from.
865 "arena over-provisioned: set NROS_EXECUTOR_ARENA_SIZE={suggest} \
866 (Zephyr: CONFIG_ prefix). {used}/{capacity} bytes claimed at first \
867 spin; later registrations need more."
868 );
869}
870
871/// One-shot arena-headroom check, called on the first `spin_once`.
872///
873/// First spin, not registration end, because there is no "registration end" —
874/// an app may register lazily, and this is why the message says
875/// "at first spin" and names what it measured rather than asserting a total.
876///
877/// Hot-path cost is one relaxed load on a branch that is taken exactly once.
878pub(crate) fn maybe_report_arena_headroom(used: usize, capacity: usize) {
879 if ARENA_ADVISORY_DONE.load(portable_atomic::Ordering::Relaxed) {
880 return;
881 }
882 // Only ever set, never cleared, so a racing second spin at worst emits the
883 // line twice — cheaper than an AcqRel on every spin to prevent a duplicate
884 // advisory.
885 ARENA_ADVISORY_DONE.store(true, portable_atomic::Ordering::Relaxed);
886 if arena_is_over_provisioned(used, capacity) {
887 report_arena_headroom(used, capacity);
888 }
889}
890
891/// Say WHY a registration ran out of arena, once.
892///
893/// `NodeError::BufferTooSmall` is the same code a dozen other paths return, so
894/// on a target where a return code is all you get, exhaustion here is
895/// indistinguishable from a message that did not fit a receive buffer. This
896/// names the entity that failed, the numbers involved, and the knobs that
897/// actually govern it.
898///
899/// `NROS_PUBSUB_QOS_DEPTH` comes FIRST, with the depth this image was budgeted
900/// for. Since phase-412 W3b the derived arena prices every subscription at that
901/// depth, so a subscription created deeper than it is the ordinary way a
902/// derived arena runs out — and raising the depth re-derives the arena, where a
903/// hand-set `NROS_EXECUTOR_ARENA_SIZE` goes stale the next time any other term
904/// moves (the rationale #775 gave for the depth knob). The line named only the
905/// size and the action-client count until then, i.e. not the knob the likeliest
906/// failure needs.
907///
908/// The counterpart to [`report_arena_headroom`], and the reason lowering
909/// `NROS_EXECUTOR_ACTION_CLIENTS` is safe to suggest: too small fails at
910/// REGISTRATION rather than at link, so the failure has to say so itself.
911///
912/// One-shot for the same reason the advisory is — the numbers do not change
913/// between registrations, and a per-registration line on an RTOS target is a
914/// flood (issue 0371's shape). `nros_log`, never stdio (issue 0589), and inside
915/// the 256-byte format budget that truncated the first advisory — at the widest
916/// realistic values too (10-digit counts, a 32-byte entity, a 5-digit depth),
917/// which
918/// `executor::tests::an_exhausted_arena_decodes_to_the_knob_an_operator_must_set`
919/// derives from the real line and holds under the buffer.
920#[cold]
921pub(crate) fn report_arena_exhausted(
922 entity: &'static str,
923 want: usize,
924 used: usize,
925 capacity: usize,
926 released: usize,
927 request: usize,
928) {
929 if ARENA_EXHAUSTED_REPORTED.swap(true, portable_atomic::Ordering::Relaxed) {
930 return;
931 }
932 let entity = entity_label(entity);
933 let depth = crate::config::arena_model::BUDGETED_QOS_DEPTH;
934 nros_log::log_error!(
935 nros_log::get_logger("nros"),
936 "arena exhausted at {entity}: {want} B short, {used}/{capacity} used. \
937 Raise NROS_PUBSUB_QOS_DEPTH (budgeted {depth}) if a sub is deeper, \
938 else NROS_EXECUTOR_ARENA_SIZE or NROS_EXECUTOR_ACTION_CLIENTS \
939 (Zephyr: CONFIG_*)."
940 );
941 // Issue 1496 / 1370's question asked of THIS arena: since entries can be
942 // released, the bytes may be there in holes none of which fits. A second
943 // line, only then, so the common case keeps its one line and its budget.
944 if arena_refusal_is_fragmented(request, released) {
945 nros_log::log_error!(
946 nros_log::get_logger("nros"),
947 "arena FRAGMENTED: {released} B released, no hole holds {request} B \
948 (issue 1496)"
949 );
950 }
951}
952
953/// Issue 1496 — the arena's own exhaustion classification, the same rule
954/// `zpico_alloc::Exhaustion::classify` states for the platform heap (issue
955/// 1370): the released bytes would hold the request, so it was the SHAPE that
956/// refused it, not the size. (Released regions are reused first-fit before the
957/// bump pointer moves, so reaching exhaustion with enough released bytes means
958/// no single region fitted.)
959pub(crate) const fn arena_refusal_is_fragmented(request: usize, released: usize) -> bool {
960 released >= request && request > 0
961}
962
963/// Issue 1036 — arena exhaustion reaches the platform's FATAL HOOK where the
964/// image asked for it (`NROS_ARENA_EXHAUSTION_IS_FATAL`, Zephyr
965/// `CONFIG_NROS_ARENA_EXHAUSTION_IS_FATAL`, default ON with the boot report),
966/// the arena's twin of `CONFIG_NROS_HEAP_EXHAUSTION_IS_FATAL` (issue 1425).
967///
968/// The log line goes nowhere on a board with no console, and a returned
969/// `BufferTooSmall` is handled only by code written to handle it — so without
970/// this, the observable outcome of an exhausted arena on the island is an image
971/// that keeps running with an entity missing. The record is written BEFORE
972/// this (`arena_exhausted`), so it survives the halt.
973///
974/// In a unit-test build the hook is a latch the test reads instead of a call
975/// into a platform the test does not link.
976#[cold]
977pub(crate) fn arena_exhaustion_is_fatal() {
978 #[cfg(all(nros_arena_exhaustion_fatal, not(test)))]
979 {
980 unsafe extern "C" {
981 fn nros_platform_panic(msg: *const u8, len: usize) -> !;
982 }
983 const MSG: &[u8] = b"executor arena exhausted (see the boot report's failed_alloc_size)";
984 // SAFETY: a static byte string and its length; the platform contract
985 // takes any bytes and never returns.
986 unsafe { nros_platform_panic(MSG.as_ptr(), MSG.len()) }
987 }
988 #[cfg(all(nros_arena_exhaustion_fatal, test))]
989 ARENA_FATAL_HOOK_REACHED.store(true, portable_atomic::Ordering::Relaxed);
990}
991
992/// Test-build stand-in for the fatal hook — see [`arena_exhaustion_is_fatal`].
993#[cfg(all(nros_arena_exhaustion_fatal, test))]
994pub(crate) static ARENA_FATAL_HOOK_REACHED: portable_atomic::AtomicBool =
995 portable_atomic::AtomicBool::new(false);
996
997/// The registering entity, short enough for the log budget.
998///
999/// From `core::any::type_name`, whose full spelling carries every generic
1000/// argument and runs past 150 bytes. The generics go, and so does all but the
1001/// last TWO path segments: most arena entries are a module's `Entry`, so the
1002/// last segment alone would name nothing (`sub_buffered::Entry`, not `Entry`).
1003/// Capped at 32 bytes; type names are ASCII, so the cut is on a char boundary.
1004pub(crate) fn entity_label(full: &'static str) -> &'static str {
1005 let path = full.split('<').next().unwrap_or(full);
1006 let label = match path.rmatch_indices("::").nth(1) {
1007 Some((i, _)) => &path[i + 2..],
1008 None => path,
1009 };
1010 let cut = label.len().min(32);
1011 label.get(..cut).unwrap_or(label)
1012}
1013
1014#[cfg(test)]
1015mod entity_label_tests {
1016 use super::entity_label;
1017
1018 mod sub_buffered {
1019 pub struct Entry<T>(pub T);
1020 }
1021
1022 /// The shape nearly every arena entry has: a module's `Entry`, generic.
1023 /// The last segment alone is `Entry` for all of them, which names nothing.
1024 #[test]
1025 fn a_module_entry_keeps_its_module_and_drops_its_generics() {
1026 let full = core::any::type_name::<sub_buffered::Entry<[u8; 1024]>>();
1027 assert!(full.contains('<'), "precondition: a generic name: {full}");
1028 assert_eq!(entity_label(full), "sub_buffered::Entry");
1029 }
1030
1031 #[test]
1032 fn a_primitive_is_itself() {
1033 assert_eq!(entity_label(core::any::type_name::<u8>()), "u8");
1034 }
1035
1036 #[test]
1037 fn a_long_label_is_capped_for_the_log_budget() {
1038 let label = entity_label("a::a_module_name_well_past_the_cap::AnEntryTypeName<X>");
1039 assert_eq!(label.len(), 32, "{label}");
1040 assert!(label.starts_with("a_module_name_well_past_the_cap"));
1041 }
1042}
1043
1044/// One-shot latch for [`report_arena_exhausted`]. A static for the reason
1045/// [`DROPPED_TAKES`] is one.
1046static ARENA_EXHAUSTED_REPORTED: portable_atomic::AtomicBool =
1047 portable_atomic::AtomicBool::new(false);
1048
1049/// Is this arena grossly larger than what registered in it?
1050///
1051/// Half is the threshold because the derivation's own over-provision at the
1052/// defaults is larger than that (74,240 bytes budgeted against ~51 KiB for four
1053/// default-QoS subscriptions, and against a few hundred bytes for a timer-only
1054/// image), so "under half" is unambiguous rather than a rounding artifact, and
1055/// an image that genuinely uses most of its arena stays quiet. Issue 1190 moved
1056/// the pub/sub half of that comparison from ~16 KiB to ~51 KiB, which
1057/// deliberately takes a four-subscription image OUT of the advisory: it is not
1058/// over-provisioned, it was mis-priced.
1059///
1060/// A zero capacity is NOT over-provisioned: that is the sentinel bug issue 0460
1061/// produced on Zephyr (a literal `0` forwarded instead of the derivation), and
1062/// it fails loudly on the first registration. Calling it over-provisioned would
1063/// bury a fatal misconfiguration under an advisory about wasted space.
1064///
1065/// Separated from [`maybe_report_arena_headroom`] so it is testable: the
1066/// reporter is one-shot on a process-scoped flag, so a test that called it
1067/// twice would silently check nothing the second time.
1068pub(crate) const fn arena_is_over_provisioned(used: usize, capacity: usize) -> bool {
1069 capacity != 0 && used.saturating_mul(2) <= capacity
1070}
1071
1072// ============================================================================
1073// Issue 1190 — the arena model is an UPPER BOUND, asserted where the sizes are
1074// ============================================================================
1075//
1076// `nros-node/build.rs` sums a per-kind cost model into `ARENA_SIZE` and says of
1077// itself that subscription and service entries are "strictly smaller" than the
1078// action-client slot it budgets at. That sentence was false for four phases:
1079// the pub/sub term charged `3 * rx_buf + 512`, which is a `TripleBuffer` (the
1080// `depth <= 1` case) plus an entry allowance smaller than any entry this crate
1081// defines, while an ordinary `KEEP_LAST(10)` subscription claims
1082// `size_of(entry) + (depth + 1) * (slot + 8)`. Measured live: 12,144 bytes into
1083// an 8,192-byte arena, dying at `Node::register` with `BufferTooSmall`.
1084//
1085// A build script cannot check this. It has no `size_of`, and the dominant term
1086// is the BACKEND's handle — 584 bytes of `CffiSubscription`, most of it two
1087// 256-byte name buffers — which is a fact of the linked backend, not of the
1088// build script's environment. So the model is emitted (`config::arena_model`)
1089// and bounded HERE, per image, per backend, at compile time.
1090//
1091// Gated on `rmw-cffi` because that is the configuration a real image is built
1092// in. The unit-test configuration substitutes `MockSubscriber`, whose 8-deep
1093// queue of 256-byte canned takes makes it ~2.1 KiB — a test fixture, not a
1094// handle any image links, and sizing the model to cover it would spend 1.4 KiB
1095// of every slot on a mock. The depth half of the model is backend-independent
1096// and is checked in the unit tests below, which do run in that configuration.
1097#[cfg(feature = "rmw-cffi")]
1098const _: () = {
1099 use crate::config::arena_model as model;
1100
1101 // The buffered subscription entries. `<(), ()>` and the C entries carry no
1102 // user closure, so this bounds the STRUCT the crate owns plus the backend
1103 // handle; a caller's own callback is captured by value on top of it and is
1104 // the one part no static bound can reach (`PUBSUB_STRUCT` is sized to leave
1105 // room for the declarative runtime's, which is the largest in this tree).
1106 assert!(
1107 core::mem::size_of::<SubBufferedEntry<(), ()>>() <= model::PUBSUB_STRUCT,
1108 "arena model: PUBSUB_STRUCT is below size_of::<SubBufferedEntry>() for \
1109 this backend — the derived arena would under-size every subscription \
1110 (issue 1190). Raise PUBSUB_ENTRY_STRUCT in nros-node/build.rs."
1111 );
1112 assert!(
1113 core::mem::size_of::<SubBufferedRawEntry<()>>() <= model::PUBSUB_STRUCT,
1114 "arena model: PUBSUB_STRUCT is below size_of::<SubBufferedRawEntry>() \
1115 for this backend (issue 1190)."
1116 );
1117 assert!(
1118 core::mem::size_of::<SubBufferedRawCEntry>() <= model::PUBSUB_STRUCT,
1119 "arena model: PUBSUB_STRUCT is below size_of::<SubBufferedRawCEntry>() \
1120 for this backend (issue 1190)."
1121 );
1122 assert!(
1123 core::mem::size_of::<SubBufferedTypedCEntry>() <= model::PUBSUB_STRUCT,
1124 "arena model: PUBSUB_STRUCT is below \
1125 size_of::<SubBufferedTypedCEntry>() for this backend (issue 1190)."
1126 );
1127 assert!(
1128 core::mem::size_of::<SubBufferedRawInfoCEntry>() <= model::PUBSUB_STRUCT,
1129 "arena model: PUBSUB_STRUCT is below \
1130 size_of::<SubBufferedRawInfoCEntry>() for this backend (issue 1190)."
1131 );
1132
1133 // A service server has NO trailing region: both buffers are inline and it
1134 // reaches the arena through `arena_alloc`. So the whole entry is bounded.
1135 assert!(
1136 core::mem::size_of::<
1137 SrvRawEntry<
1138 { crate::config::DEFAULT_RX_BUF_SIZE },
1139 { crate::config::DEFAULT_RX_BUF_SIZE },
1140 >,
1141 >() <= model::SERVICE_ENTRY,
1142 "arena model: SERVICE_ENTRY is below size_of::<SrvRawEntry>() for this \
1143 backend (issue 1190)."
1144 );
1145
1146 // A timer has no buffer at all.
1147 assert!(
1148 core::mem::size_of::<TimerEntry<()>>() <= model::TIMER_ENTRY,
1149 "arena model: TIMER_ENTRY is below size_of::<TimerEntry>() (issue 1190)."
1150 );
1151};
1152
1153#[cfg(test)]
1154mod arena_model_tests {
1155 use crate::config::{DEFAULT_RX_BUF_SIZE, arena_model as model};
1156 use nros_rmw::QoSProfile;
1157
1158 /// Issue 1190, the failure itself. The derivation's pub/sub term must cover
1159 /// the buffered region an ORDINARY subscription claims — the one created
1160 /// with no QoS argument, which is `QoSProfile::default()`, which is
1161 /// `rmw_qos_profile_default`, which is `KEEP_LAST(10)`.
1162 ///
1163 /// This is the assertion the old model fails: it charged
1164 /// `3 * DEFAULT_RX_BUF_SIZE` = 3,072 bytes, a `TripleBuffer`, where the
1165 /// allocator claims `11 * (1024 + 8)` = 11,352 — short by 8,280 per
1166 /// subscription, on top of a 280-byte shortfall in the entry allowance. An
1167 /// image with one declared subscription therefore derived an 8,192-byte
1168 /// arena for a 12,144-byte registration.
1169 ///
1170 /// phase-454 W5 / issue 1319 — measured at the slot size the derivation
1171 /// PRICED, not at `DEFAULT_RX_BUF_SIZE`.
1172 ///
1173 /// Those were the same number until issue 1255 narrowed the term to the
1174 /// image-wide subscribed class, and this assertion went on reading `RX_BUF`
1175 /// — which made it right about the two registration paths that take the
1176 /// closure buffer and wrong about the three that do not.
1177 /// A Cyclone image priced at its bound would have failed here for sizing
1178 /// CORRECTLY. `PUBSUB_SLOT_BYTES` is what the build actually charged, and
1179 /// `the_modelled_slot_never_exceeds_the_closure_buffer` below is the other
1180 /// half — the one that says the price is an upper bound on every path.
1181 ///
1182 /// Deliberately measured through `buffered_region_size`, the function the
1183 /// allocator itself calls, and not through a second copy of its arithmetic.
1184 #[test]
1185 fn the_modelled_pubsub_region_covers_a_default_qos_subscription() {
1186 // phase-412 W3b — against the depth this image BUDGETED, not against
1187 // ROS 2's default. They are the same number until something states
1188 // otherwise, and once something does, "does the model cover a DEFAULT
1189 // subscription" stops being the question the image is asking: an image
1190 // that budgets KEEP_LAST(1) is claiming its subscriptions are
1191 // KEEP_LAST(1), and this assertion holding at depth 10 would only mean
1192 // it had over-provisioned.
1193 //
1194 // What it still catches is the thing that matters — a budget that does
1195 // not cover its own claim.
1196 let (slots, region) =
1197 super::buffered_region_size(model::BUDGETED_QOS_DEPTH, model::PUBSUB_SLOT_BYTES);
1198 assert!(
1199 model::PUBSUB_REGION >= region,
1200 "the arena derivation budgets {} bytes for a subscription's \
1201 buffered region and the allocator claims {region} for it \
1202 ({slots} slots of {} at the budgeted \
1203 KEEP_LAST({}) ) — every image that derives its arena from a \
1204 declared subscription count is short by {} bytes per \
1205 subscription (issue 1190)",
1206 model::PUBSUB_REGION,
1207 model::PUBSUB_SLOT_BYTES,
1208 model::BUDGETED_QOS_DEPTH,
1209 region.saturating_sub(model::PUBSUB_REGION),
1210 );
1211 }
1212
1213 /// **Issue 1319** — the slot the model prices may never exceed `RX_BUF`,
1214 /// because `RX_BUF` is what the widest registration path claims and the
1215 /// model is an upper bound or it is nothing.
1216 ///
1217 /// The direction that shipped the bug is the other one, and it has no
1218 /// TOTAL assertion available here: a slot priced BELOW `RX_BUF` is correct
1219 /// for a `TypedBound` registration (once ~~`c_typed_hint`~~ /
1220 /// ~~`rust_typed_descriptors`~~) and 1,848 bytes per subscription short for
1221 /// an `Unbounded` one, and which one this image
1222 /// takes is composed from the entry's language and the linked backend —
1223 /// neither of which the runtime can read out of a `cfg`. That is why the
1224 /// fact travels in the sizing descriptor and the check is on the DERIVATION
1225 /// (`nros-sizing-descriptor`'s slot table, `nros-node/build.rs`'s
1226 /// `row_slot_bytes`) rather than here. What stands here is the cheap half
1227 /// and the reproduction in `executor::tests`.
1228 ///
1229 /// Both sides go through `black_box`, for the reason `config.rs`'s
1230 /// `opaque` exists: they are build-emitted CONSTANTS, and clippy
1231 /// const-propagates the comparison and refuses the `assert!` as
1232 /// `assertions_on_constants` under `-D warnings`. A `const { assert!(…) }`
1233 /// would satisfy it and cost the message its two numbers, which are the
1234 /// whole diagnostic.
1235 #[test]
1236 fn the_modelled_slot_never_exceeds_the_closure_buffer() {
1237 fn opaque(v: usize) -> usize {
1238 core::hint::black_box(v)
1239 }
1240 assert!(
1241 opaque(model::PUBSUB_SLOT_BYTES) <= opaque(DEFAULT_RX_BUF_SIZE),
1242 "the arena model prices a subscription slot at {} bytes while the \
1243 widest registration path claims DEFAULT_RX_BUF_SIZE = \
1244 {DEFAULT_RX_BUF_SIZE} — a model term above every path it stands \
1245 for is not an upper bound, it is a second number (issue 1319)",
1246 model::PUBSUB_SLOT_BYTES,
1247 );
1248 }
1249
1250 /// The depth the derivation restates must be the depth the runtime uses. A
1251 /// build script cannot read `QOS_PROFILE_DEFAULT`, so the number is
1252 /// retyped there; this is what stops the two drifting, which is exactly how
1253 /// the term came to model a depth nothing creates.
1254 #[test]
1255 fn the_modelled_qos_depth_is_the_runtime_default() {
1256 assert_eq!(
1257 model::QOS_DEPTH,
1258 QoSProfile::default().depth,
1259 "nros-node/build.rs budgets a subscription at KEEP_LAST({}) while \
1260 `QoSProfile::default()` is KEEP_LAST({}) (issue 1190)",
1261 model::QOS_DEPTH,
1262 QoSProfile::default().depth,
1263 );
1264 }
1265
1266 /// Same, for the action client's feedback stream.
1267 #[test]
1268 fn the_modelled_action_feedback_depth_is_what_an_action_client_registers() {
1269 assert_eq!(
1270 model::ACTION_FEEDBACK_DEPTH,
1271 super::DEFAULT_ACTION_FEEDBACK_DEPTH,
1272 "nros-node/build.rs prices the action-client slot at feedback \
1273 depth {} while the registration uses {} (issue 1190)",
1274 model::ACTION_FEEDBACK_DEPTH,
1275 super::DEFAULT_ACTION_FEEDBACK_DEPTH,
1276 );
1277 }
1278
1279 /// The action-client term is left depth-blind on purpose (see
1280 /// `build.rs`), and that is only defensible while it stays an upper bound.
1281 /// Its feedback region is real and depth-scaled even though the model does
1282 /// not spell it that way, so the region alone must fit inside the term with
1283 /// the entry struct still to pay for.
1284 #[test]
1285 fn the_modelled_action_client_entry_covers_its_feedback_region() {
1286 let (_slots, region) = super::buffered_region_size(
1287 super::DEFAULT_ACTION_FEEDBACK_DEPTH as u32,
1288 DEFAULT_RX_BUF_SIZE,
1289 );
1290 assert!(
1291 model::ACTION_CLIENT_ENTRY > region,
1292 "the action-client slot is budgeted at {} bytes and its feedback \
1293 region alone claims {region} — the margin that hid issue 1190 has \
1294 closed and the term now needs the same treatment the pub/sub one \
1295 got",
1296 model::ACTION_CLIENT_ENTRY,
1297 );
1298 }
1299}
1300
1301/// Issue 0810 — every per-kind term the DECLARED model sums is held to the type
1302/// the raw C/C++ registration actually allocates, at compile time, with the
1303/// backend this image LINKS. A build script cannot see `size_of` of a backend
1304/// handle; this compilation can, so a backend whose handle grows past
1305/// `RMW_HANDLE_BOUND` — or an entry that outgrows its term — is a build error
1306/// naming the term rather than a `BufferTooSmall` at the first registration.
1307///
1308/// `rmw-cffi` is the only real `ConcreteSession`; the unit-test mock's handles
1309/// carry `Vec`s and `RefCell`s and say nothing about a target. `+ 8` is the
1310/// worst alignment pad a bump allocation pays in front of an entry.
1311#[cfg(feature = "rmw-cffi")]
1312const _: () = {
1313 use crate::config::{DEFAULT_RX_BUF_SIZE as B, arena_model as m};
1314 use core::mem::size_of;
1315 const G: usize = crate::limits::MAX_CONCURRENT_GOALS;
1316 const fn fits(actual: usize, term: usize) -> bool {
1317 actual + 8 <= term
1318 }
1319 assert!(
1320 size_of::<session::RmwPublisher>() <= m::RMW_HANDLE_BOUND
1321 && size_of::<session::RmwSubscriber>() <= m::RMW_HANDLE_BOUND
1322 && size_of::<session::RmwServiceServer>() <= m::RMW_HANDLE_BOUND
1323 && size_of::<session::RmwServiceClient>() <= m::RMW_HANDLE_BOUND,
1324 "issue 0810: an RMW handle of the linked backend exceeds RMW_HANDLE_BOUND in \
1325 nros-node/build.rs, so every declared action / service term under-prices it"
1326 );
1327 assert!(
1328 fits(
1329 size_of::<ActionServerRawArenaEntry<B, B, B, G>>(),
1330 m::ACTION_SERVER_ENTRY
1331 ),
1332 "issue 0810: ActionServerRawArenaEntry outgrew ACTION_SERVER_ENTRY (nros-node/build.rs)"
1333 );
1334 assert!(
1335 fits(
1336 size_of::<ActionClientRawArenaEntry<B, B, B>>(),
1337 m::ACTION_CLIENT_ENTRY
1338 ),
1339 "issue 0810: ActionClientRawArenaEntry outgrew ACTION_CLIENT_ENTRY (nros-node/build.rs)"
1340 );
1341 assert!(
1342 fits(size_of::<SrvRawEntry<B, B>>(), m::SERVICE_ENTRY),
1343 "issue 0810: SrvRawEntry outgrew SERVICE_ENTRY (nros-node/build.rs)"
1344 );
1345 assert!(
1346 fits(
1347 size_of::<ServiceClientRawArenaEntry<B>>(),
1348 m::SERVICE_CLIENT_ENTRY
1349 ),
1350 "issue 0810: ServiceClientRawArenaEntry outgrew SERVICE_CLIENT_ENTRY (nros-node/build.rs)"
1351 );
1352 assert!(
1353 fits(
1354 size_of::<GuardConditionEntry<(unsafe extern "C" fn(*mut core::ffi::c_void), usize)>>(),
1355 m::GUARD_CONDITION_ENTRY
1356 ),
1357 "issue 0810: GuardConditionEntry outgrew GUARD_CONDITION_ENTRY (nros-node/build.rs)"
1358 );
1359 assert!(
1360 fits(size_of::<SubBufferedRawCEntry>(), m::PUBSUB_STRUCT)
1361 && fits(size_of::<SubBufferedTypedCEntry>(), m::PUBSUB_STRUCT),
1362 "issue 0810: a C subscription entry outgrew PUBSUB_STRUCT (nros-node/build.rs)"
1363 );
1364};
1365
1366#[cfg(test)]
1367mod per_kind_term_tests {
1368 use crate::config::arena_model as model;
1369
1370 fn opaque(v: usize) -> usize {
1371 core::hint::black_box(v)
1372 }
1373
1374 /// Issue 0810 — the fallback budgets an undeclared slot at
1375 /// `WORST_SLOT_ENTRY`, which is a worst case only while it covers every
1376 /// kind a slot could turn out to be.
1377 #[test]
1378 fn the_undeclared_slot_budget_covers_every_declared_kind() {
1379 for (kind, term) in [
1380 ("subscription", model::PUBSUB_ENTRY),
1381 ("service server", model::SERVICE_ENTRY),
1382 ("service client", model::SERVICE_CLIENT_ENTRY),
1383 ("timer", model::TIMER_ENTRY),
1384 ("guard condition", model::GUARD_CONDITION_ENTRY),
1385 ("action client", model::ACTION_CLIENT_ENTRY),
1386 ("action server", model::ACTION_SERVER_ENTRY),
1387 ] {
1388 assert!(
1389 opaque(model::WORST_SLOT_ENTRY) >= opaque(term),
1390 "WORST_SLOT_ENTRY {} is below the {kind} term {term}: an undeclared \
1391 image's per-slot budget no longer bounds what a slot can hold",
1392 model::WORST_SLOT_ENTRY
1393 );
1394 }
1395 }
1396
1397 /// Issue 0810 — the measured shape the issue was filed on, held as a
1398 /// direction rather than a literal: a declared action server costs LESS
1399 /// than the undeclared worst case (it was billed exactly that), and a
1400 /// declared service client costs something (it was billed nothing).
1401 #[test]
1402 fn declared_actions_and_service_clients_are_priced_on_their_own_terms() {
1403 assert!(opaque(model::ACTION_SERVER_ENTRY) < opaque(model::WORST_SLOT_ENTRY));
1404 assert!(opaque(model::ACTION_CLIENT_ENTRY) < opaque(model::WORST_SLOT_ENTRY));
1405 assert!(opaque(model::SERVICE_CLIENT_ENTRY) > opaque(crate::config::DEFAULT_RX_BUF_SIZE));
1406 assert!(opaque(model::GUARD_CONDITION_ENTRY) > 0);
1407 }
1408}
1409
1410#[cfg(test)]
1411mod arena_headroom_tests {
1412 use super::arena_is_over_provisioned;
1413 use crate::config::{
1414 ARENA_ACTION_CLIENTS, ARENA_SIZE, DEFAULT_RX_BUF_SIZE, MAX_CBS, arena_model as model,
1415 };
1416
1417 /// issue 0900 — the per-kind derivation must reproduce the OLD
1418 /// `max_cbs * action_client_entry + base` arithmetic byte for byte when
1419 /// every slot is still budgeted at ActionClient size, which is the default.
1420 ///
1421 /// This is the compatibility gate: the knob exists so an image CAN shrink
1422 /// its arena, not so every image silently does. A change here that moves
1423 /// the default is a change to every image's stack frame.
1424 #[test]
1425 fn the_default_derivation_is_unchanged() {
1426 if ARENA_ACTION_CLIENTS != MAX_CBS {
1427 // The test build set the knob; the identity below is not the claim
1428 // being made then. Fail rather than pass vacuously.
1429 panic!(
1430 "this test asserts the DEFAULT derivation, but \
1431 NROS_EXECUTOR_ACTION_CLIENTS was set to {ARENA_ACTION_CLIENTS} \
1432 against MAX_CBS {MAX_CBS}"
1433 );
1434 }
1435 // Issue 1190 — these six constants used to be RETYPED here, which is a
1436 // second copy of the model that agrees with the first until one of them
1437 // moves. They come from `config::arena_model` now, which is what
1438 // `build.rs` actually summed.
1439 // Issue 0810 — the fallback's per-slot budget is `WORST_SLOT_ENTRY`
1440 // now; `ACTION_CLIENT_ENTRY` prices a DECLARED action client.
1441 let want = (MAX_CBS * model::WORST_SLOT_ENTRY + model::BASE_OVERHEAD).max(model::FLOOR);
1442 assert_eq!(
1443 ARENA_SIZE, want,
1444 "the per-kind derivation moved the default arena; every image's \
1445 executor backing moves with it (issue 0900)"
1446 );
1447 // And the shipped number itself, because reading it out of the model
1448 // proves the SHAPE and not the size: with both sides emitted from one
1449 // build script, a term could move and the identity above would still
1450 // hold. 74,240 is what 1,524 of the 1,547 built configs in this tree
1451 // carry; issue 1190's fix is required to leave it exactly there,
1452 // because the pub/sub term it corrects multiplies
1453 // `MAX_CBS - ARENA_ACTION_CLIENTS`, which is zero at the defaults.
1454 //
1455 // Only when the INPUTS are the shipped ones. A test build that raised
1456 // `NROS_EXECUTOR_MAX_CBS` or the buffer size derives a different number
1457 // legitimately, and asserting a literal at it would be this file's own
1458 // "second copy" mistake wearing a different hat.
1459 if MAX_CBS == 4 && DEFAULT_RX_BUF_SIZE == 1024 {
1460 assert_eq!(
1461 ARENA_SIZE, 74_240,
1462 "the shipped default arena moved. That is not forbidden, but \
1463 it is a change to every image built without an entity \
1464 declaration and it has to be a decision, not a side effect \
1465 (issues 0900, 1190)"
1466 );
1467 }
1468 }
1469
1470 /// The advisory must actually fire for the shipped defaults — otherwise W1
1471 /// installed a diagnostic that is dead on the very configuration that
1472 /// needs it. A timer-only executor claims 32 bytes against ARENA_SIZE.
1473 #[test]
1474 fn the_shipped_default_arena_trips_the_advisory() {
1475 assert!(
1476 arena_is_over_provisioned(32, ARENA_SIZE),
1477 "a timer-only executor must trip the advisory at the shipped \
1478 defaults; ARENA_SIZE is {ARENA_SIZE}"
1479 );
1480 }
1481
1482 #[test]
1483 fn gross_over_provision_is_reported() {
1484 // The measured shape: a talker's handful of entries against the
1485 // worst-case-derived 74,240.
1486 assert!(arena_is_over_provisioned(4_096, 74_240));
1487 assert!(arena_is_over_provisioned(0, 74_240));
1488 }
1489
1490 #[test]
1491 fn a_well_sized_arena_stays_quiet() {
1492 assert!(!arena_is_over_provisioned(60_000, 74_240));
1493 // Exactly half is the boundary and IS reported; one byte more is not.
1494 assert!(arena_is_over_provisioned(37_120, 74_240));
1495 assert!(!arena_is_over_provisioned(37_121, 74_240));
1496 }
1497
1498 #[test]
1499 fn a_zero_capacity_arena_is_a_fault_not_headroom() {
1500 // Issue 0460's sentinel bug. It fails on the first registration; an
1501 // advisory about wasted space would bury that.
1502 assert!(!arena_is_over_provisioned(0, 0));
1503 }
1504
1505 #[test]
1506 fn overflow_cannot_panic_the_check() {
1507 // `used` is bounded by `capacity` in practice, but the doubling must
1508 // not be the thing that decides that.
1509 assert!(!arena_is_over_provisioned(usize::MAX, 74_240));
1510 }
1511}
1512
1513/// Say that a take was thrown away, on the first one and every 64th after.
1514///
1515/// Issue 0757, RFC-0052 fail-loud. `take_serialized` returns `BufferTooSmall` when
1516/// a reassembled sample exceeds the subscription buffer, and this path used to
1517/// discard EVERY non-OK take. At transport level cyclone has already completed
1518/// and ACKed the sample by then, so the subscription looks matched and healthy
1519/// from every outside probe (`ros2 topic info -v`, tshark ACKNACK analysis)
1520/// while the application waits forever. That is how 13.4 KiB Autoware
1521/// trajectories were silently dropped by every Zephyr image for the whole life
1522/// of the lane: small degenerate samples fit the 1 KiB default, so every green
1523/// marker stayed green, and attribution needed a consumer-side tshark session.
1524///
1525/// **What this can and cannot say.** The buffer capacity is known here and is
1526/// the actionable half — it names the knob to raise
1527/// (`NROS_SUBSCRIPTION_BUFFER_SIZE`, or `NROS_SUBSCRIBER_BUFFER_SIZE` /
1528/// `ZPICO_SUBSCRIBER_LARGE_SIZE` on zenoh). The SAMPLE size is known when the
1529/// backend knows it: issue 1612 made a too-small `take` carry the size the
1530/// sample needed (`rmw_entity.h`), which reaches here as
1531/// `Subscription::refused_sample_len`, and issue 1632 made this line print it
1532/// beside the buffer (`?` when unknown — `nros_rmw::RefusedLen`'s rule, which
1533/// the C++ drop log shares). The topic is still not known: `SubBufferedEntry`
1534/// carries no name and adding one changes arena sizing.
1535///
1536/// `nros_log`, never stdio: this site is reached on `no_std` targets and inside
1537/// Zephyr `native_sim`, where a Rust `std` stdio call is FATAL (issue 0589).
1538/// `take_serialized`, with a refusal reported through [`report_dropped_take`]
1539/// together with the size the backend says the sample needed — the one
1540/// spelling of the five buffered drain sites.
1541fn take_reporting_drop(
1542 handle: &mut session::RmwSubscriber,
1543 slot: &mut [u8],
1544) -> Result<Option<usize>, TransportError> {
1545 let cap = slot.len();
1546 handle.take_serialized(slot).inspect_err(|e| {
1547 report_dropped_take(e, cap, handle.refused_sample_len());
1548 })
1549}
1550
1551#[cold]
1552fn report_dropped_take(err: &TransportError, buf_len: usize, recorded: Option<usize>) {
1553 let n = DROPPED_TAKES.fetch_add(1, portable_atomic::Ordering::Relaxed);
1554 if drop_is_reported(n) {
1555 log_dropped_take(err, buf_len, recorded, n);
1556 }
1557}
1558
1559/// First, then every 64th. A 40-participant graph must not turn one
1560/// misconfigured subscription into a log flood (issue 0371's shape).
1561///
1562/// Apart from [`report_dropped_take`] so a test asks it with a count of its
1563/// own, never through [`DROPPED_TAKES`], which every cell in the binary that
1564/// drops a take advances (issue 2082).
1565fn drop_is_reported(n: u32) -> bool {
1566 n == 0 || n.is_multiple_of(64)
1567}
1568
1569/// The line for drop number `n + 1`, unconditionally.
1570#[cold]
1571fn log_dropped_take(err: &TransportError, buf_len: usize, recorded: Option<usize>, n: u32) {
1572 let needed = nros_rmw::RefusedLen::for_error(err, recorded);
1573 nros_log::log_error!(
1574 nros_log::get_logger("nros"),
1575 "subscription take DROPPED ({err:?}): {needed}-byte sample, {buf_len}-byte \
1576 buffer. Received and ACKed, then discarded — raise the subscription \
1577 buffer knob if this is BufferTooSmall. Dropped {} so far (issue 0757)",
1578 n + 1
1579 );
1580}
1581
1582unsafe fn drain_into_buffer<M, F>(
1583 entry: &mut SubBufferedEntry<M, F>,
1584) -> Result<(), TransportError> {
1585 match &entry.buffer {
1586 BufferStrategy::Triple(tb) => {
1587 let slot = tb.write_slot();
1588 if let Some(len) = take_reporting_drop(&mut entry.handle, slot)? {
1589 tb.writer_publish(len);
1590 }
1591 }
1592 BufferStrategy::Ring(ring) => {
1593 while let Some(slot) = ring.try_push() {
1594 match take_reporting_drop(&mut entry.handle, slot)? {
1595 Some(len) => ring.commit_push(len),
1596 None => break,
1597 }
1598 }
1599 }
1600 }
1601 Ok(())
1602}
1603
1604/// Monomorphized dispatch for buffered subscriptions.
1605///
1606/// First drains the RMW subscriber into the buffer strategy (triple buffer
1607/// or SPSC ring), then dispatches from the buffer to the user callback.
1608///
1609/// # Safety
1610/// `ptr` must point to a valid, aligned `SubBufferedEntry<M, F>`.
1611pub(crate) unsafe fn sub_buffered_try_process<M, F>(
1612 ptr: *mut u8,
1613 _delta_us: u64,
1614 desc_idx: u8,
1615) -> Result<bool, TransportError>
1616where
1617 M: RosMessage,
1618 F: FnMut(&M),
1619{
1620 let entry = unsafe { &mut *(ptr as *mut SubBufferedEntry<M, F>) };
1621
1622 // Phase 1: drain RMW subscriber → buffer strategy
1623 // Issue 0757 — let a transport error OUT, exactly as issue 0737 does for
1624 // the C copy below. Anything already buffered from an earlier spin is still
1625 // dispatched on the next call.
1626 unsafe { drain_into_buffer(entry)? };
1627
1628 // Phase 2: dispatch from buffer → user callback
1629 match &entry.buffer {
1630 // Phase 8 — hooked. Triple-buffered: at most one invocation per
1631 // `try_process`, and none at all when `reader_acquire` is empty.
1632 // The two deserialization `?`s sit BEFORE the start hook, so a
1633 // malformed sample returns early without opening a span.
1634 BufferStrategy::Triple(tb) => match tb.reader_acquire() {
1635 Some((data, len)) => {
1636 observe_age::<M>(&data[..len], &entry.age_mon);
1637 let mut reader = CdrReader::new_with_header(&data[..len])
1638 .map_err(|_| TransportError::DeserializationError)?;
1639 let msg = M::deserialize(&mut reader)
1640 .map_err(|_| TransportError::DeserializationError)?;
1641 trace_take(desc_idx, &data[..len], <M as RosMessage>::STAMP_OFFSET);
1642 trace_cb_start(desc_idx);
1643 (entry.callback)(&msg);
1644 trace_cb_end(desc_idx);
1645 Ok(true)
1646 }
1647 None => Ok(false),
1648 },
1649 // Phase 8 — hooked INSIDE the loop: a ring drains N queued messages
1650 // per `try_process` and fires the callback once per message, so N
1651 // messages must produce N spans, not one.
1652 BufferStrategy::Ring(ring) => {
1653 let mut did_work = false;
1654 while let Some((data, len)) = ring.try_pop() {
1655 observe_age::<M>(&data[..len], &entry.age_mon);
1656 let mut reader = CdrReader::new_with_header(&data[..len])
1657 .map_err(|_| TransportError::DeserializationError)?;
1658 let msg = M::deserialize(&mut reader)
1659 .map_err(|_| TransportError::DeserializationError)?;
1660 trace_take(desc_idx, &data[..len], <M as RosMessage>::STAMP_OFFSET);
1661 trace_cb_start(desc_idx);
1662 (entry.callback)(&msg);
1663 trace_cb_end(desc_idx);
1664 ring.commit_pop();
1665 did_work = true;
1666 }
1667 Ok(did_work)
1668 }
1669 }
1670}
1671
1672/// Readiness check for buffered subscriptions.
1673///
1674/// Checks the RMW subscriber handle first (new data available from transport),
1675/// then the buffer strategy (data already drained into triple buffer/ring).
1676///
1677/// # Safety
1678/// `ptr` must point to a valid `SubBufferedEntry<M, F>`.
1679pub(crate) unsafe fn sub_buffered_has_data<M, F>(ptr: *const u8) -> bool {
1680 let entry = unsafe { &*(ptr as *const SubBufferedEntry<M, F>) };
1681 // Check RMW handle first (data may be in static buffer, not yet drained)
1682 entry.handle.has_data() || entry.buffer.has_data()
1683}
1684
1685// ============================================================================
1686// In-place typed subscription (Phase 231 Wave 0.2 — RFC-0038)
1687// ============================================================================
1688
1689/// The most samples ONE in-place dispatch takes before it returns (issue 1956).
1690///
1691/// An in-place entry reads the backend's receive ring directly, and that ring
1692/// is refilled by a transport task that may run at a HIGHER priority than the
1693/// executor. "Drain until empty" was then unbounded: under a flood the check
1694/// for "empty" never came true, one `try_process` never returned, and no other
1695/// entry on the executor (or any lower tier on the core) ran again. The
1696/// buffered shapes never had this — they drain what was copied in.
1697///
1698/// Why 64 and not 1: every sample that was in the ring when the dispatch began
1699/// should be taken in it, because one left behind waits for the NEXT spin, and
1700/// a spin parks until something wakes it. A sample that ARRIVES during the
1701/// dispatch raises that wake itself, so leaving it costs nothing. 64 covers
1702/// every shipped ring depth (zenoh's default is 4) with room; a ring
1703/// configured deeper still makes progress, a spin at a time.
1704pub(crate) const IN_PLACE_DRAIN_LIMIT: usize = 64;
1705
1706/// Run one in-place `step` (one `process_raw_in_place`) until it reports no
1707/// sample or [`IN_PLACE_DRAIN_LIMIT`] samples have been taken. The one
1708/// spelling of the in-place drain, so no shape can fall back to "until
1709/// empty". Returns whether any sample was dispatched.
1710#[inline]
1711pub(crate) fn drain_in_place(
1712 mut step: impl FnMut() -> Result<bool, TransportError>,
1713) -> Result<bool, TransportError> {
1714 let mut did_work = false;
1715 for _ in 0..IN_PLACE_DRAIN_LIMIT {
1716 if !step()? {
1717 break;
1718 }
1719 did_work = true;
1720 }
1721 Ok(did_work)
1722}
1723
1724/// In-place typed subscription entry — **no arena buffer**.
1725///
1726/// Unlike [`SubBufferedEntry`], this carries no trailing `BufferStrategy`: the
1727/// callback deserializes directly from the backend's borrowed receive slot via
1728/// [`Subscriber::process_raw_in_place`], so copy #1 (ring → arena) and the arena
1729/// buffer are both gone. Selected at registration when the backend advertises
1730/// `supports_process_in_place()`.
1731#[repr(C)]
1732pub(crate) struct SubInplaceEntry<M, F> {
1733 pub(crate) handle: session::RmwSubscriber,
1734 pub(crate) callback: F,
1735 /// W3b.5 — age hook for contracted endpoints.
1736 pub(crate) age_mon: Option<AgeMon>,
1737 pub(crate) _phantom: PhantomData<M>,
1738}
1739
1740/// Monomorphized in-place dispatch for typed subscriptions.
1741///
1742/// Drains pending messages from the backend, up to [`IN_PLACE_DRAIN_LIMIT`],
1743/// deserializing + invoking the callback directly from each borrowed slot. Returns `Ok(true)` if any message
1744/// was dispatched.
1745///
1746/// # Safety
1747/// `ptr` must point to a valid, aligned `SubInplaceEntry<M, F>`.
1748pub(crate) unsafe fn sub_inplace_try_process<M, F>(
1749 ptr: *mut u8,
1750 _delta_us: u64,
1751 desc_idx: u8,
1752) -> Result<bool, TransportError>
1753where
1754 M: RosMessage,
1755 F: FnMut(&M),
1756{
1757 let entry = unsafe { &mut *(ptr as *mut SubInplaceEntry<M, F>) };
1758 // Split-borrow the handle and callback (disjoint fields).
1759 let SubInplaceEntry {
1760 handle,
1761 callback,
1762 age_mon,
1763 ..
1764 } = entry;
1765 drain_in_place(|| {
1766 let mut deser_err = false;
1767 // Phase 8 — hooked inside the `Ok(msg)` arm of the borrow closure,
1768 // which the drain loop re-enters once per pending message: N
1769 // messages therefore produce N spans. The pair is fully contained
1770 // in the closure, so the `?` on `process_raw_in_place` below can
1771 // never fire between a start and its end. Deserialization failures
1772 // take the `deser_err` arms and emit nothing.
1773 let processed = handle.process_raw_in_place(|raw| {
1774 observe_age::<M>(raw, age_mon);
1775 match CdrReader::new_with_header(raw) {
1776 Ok(mut reader) => match M::deserialize(&mut reader) {
1777 Ok(msg) => {
1778 trace_take(desc_idx, raw, <M as RosMessage>::STAMP_OFFSET);
1779 trace_cb_start(desc_idx);
1780 (callback)(&msg);
1781 trace_cb_end(desc_idx);
1782 }
1783 Err(_) => deser_err = true,
1784 },
1785 Err(_) => deser_err = true,
1786 }
1787 })?;
1788 if deser_err {
1789 return Err(TransportError::DeserializationError);
1790 }
1791 Ok(processed)
1792 })
1793}
1794
1795#[cfg(test)]
1796mod in_place_drain_tests {
1797 use super::*;
1798
1799 /// Issue 1956 — a ring a higher-priority task keeps refilling is never
1800 /// empty at the check. The drain has to come back anyway.
1801 #[test]
1802 fn a_ring_that_never_empties_still_returns() {
1803 let mut taken = 0usize;
1804 let r = drain_in_place(|| {
1805 taken += 1;
1806 Ok(true)
1807 });
1808 assert_eq!(r, Ok(true));
1809 assert_eq!(taken, IN_PLACE_DRAIN_LIMIT);
1810 }
1811
1812 #[test]
1813 fn an_emptied_ring_stops_the_drain_early() {
1814 let mut left = 3usize;
1815 let mut calls = 0usize;
1816 let r = drain_in_place(|| {
1817 calls += 1;
1818 Ok(if left == 0 {
1819 false
1820 } else {
1821 left -= 1;
1822 true
1823 })
1824 });
1825 assert_eq!(r, Ok(true));
1826 assert_eq!(calls, 4, "three samples, then the empty check");
1827 assert_eq!(drain_in_place(|| Ok(false)), Ok(false));
1828 }
1829
1830 #[test]
1831 fn a_failed_take_ends_the_drain_with_its_error() {
1832 let mut calls = 0usize;
1833 let r = drain_in_place(|| {
1834 calls += 1;
1835 if calls == 2 {
1836 Err(TransportError::DeserializationError)
1837 } else {
1838 Ok(true)
1839 }
1840 });
1841 assert_eq!(r, Err(TransportError::DeserializationError));
1842 assert_eq!(calls, 2);
1843 }
1844}
1845
1846/// Readiness check for in-place typed subscriptions.
1847///
1848/// # Safety
1849/// `ptr` must point to a valid `SubInplaceEntry<M, F>`.
1850pub(crate) unsafe fn sub_inplace_has_data<M, F>(ptr: *const u8) -> bool {
1851 let entry = unsafe { &*(ptr as *const SubInplaceEntry<M, F>) };
1852 entry.handle.has_data()
1853}
1854
1855// ============================================================================
1856// Zero-copy raw buffered subscription (Phase 73.10)
1857// ============================================================================
1858
1859/// Buffered subscription entry for zero-copy raw callbacks.
1860///
1861/// The callback receives `&[u8]` (CDR data) borrowing directly from the
1862/// triple buffer's read slot or SPSC ring's pop slot. For borrowed message
1863/// types (e.g., `Image<'a>`), the callback calls `deserialize_view()`
1864/// on the data, giving the message a lifetime tied to the callback scope.
1865#[repr(C)]
1866pub(crate) struct SubBufferedRawEntry<F> {
1867 pub(crate) handle: session::RmwSubscriber,
1868 pub(crate) buffer: BufferStrategy,
1869 pub(crate) callback: F,
1870}
1871
1872/// Drain helper for raw buffered entries.
1873///
1874/// Issue 0757 — the THIRD copy of this drain, and it had the same swallow as
1875/// the typed one above. Fixed the way issue 0737 fixed the C copy
1876/// (`drain_into_buffer_raw_c`): let the error OUT so `spin_once` counts it,
1877/// rather than inventing a second remedy for one defect.
1878unsafe fn drain_into_buffer_raw<F>(
1879 entry: &mut SubBufferedRawEntry<F>,
1880) -> Result<(), TransportError> {
1881 match &entry.buffer {
1882 BufferStrategy::Triple(tb) => {
1883 let slot = tb.write_slot();
1884 if let Some(len) = take_reporting_drop(&mut entry.handle, slot)? {
1885 tb.writer_publish(len);
1886 }
1887 }
1888 BufferStrategy::Ring(ring) => {
1889 while let Some(slot) = ring.try_push() {
1890 match take_reporting_drop(&mut entry.handle, slot)? {
1891 Some(len) => ring.commit_push(len),
1892 None => break,
1893 }
1894 }
1895 }
1896 }
1897 Ok(())
1898}
1899
1900/// Dispatch for zero-copy raw buffered subscriptions.
1901///
1902/// Drains the RMW handle into the buffer, then passes the raw CDR slice
1903/// to the callback. The callback borrows from the buffer slot — no copy.
1904///
1905/// # Safety
1906/// `ptr` must point to a valid, aligned `SubBufferedRawEntry<F>`.
1907pub(crate) unsafe fn sub_buffered_raw_try_process<F>(
1908 ptr: *mut u8,
1909 _delta_us: u64,
1910 desc_idx: u8,
1911) -> Result<bool, TransportError>
1912where
1913 F: FnMut(&[u8]),
1914{
1915 let entry = unsafe { &mut *(ptr as *mut SubBufferedRawEntry<F>) };
1916
1917 // Issue 0757 — see `drain_into_buffer`; the error reaches `spin_once`'s
1918 // `subscription_errors` instead of vanishing.
1919 unsafe { drain_into_buffer_raw(entry)? };
1920
1921 match &entry.buffer {
1922 // Phase 8 — hooked. Triple-buffered: one invocation per
1923 // `try_process` at most, none when `reader_acquire` is empty.
1924 BufferStrategy::Triple(tb) => match tb.reader_acquire() {
1925 Some((data, len)) => {
1926 trace_take(desc_idx, &data[..len], None);
1927 trace_cb_start(desc_idx);
1928 (entry.callback)(&data[..len]);
1929 trace_cb_end(desc_idx);
1930 Ok(true)
1931 }
1932 None => Ok(false),
1933 },
1934 // Phase 8 — hooked INSIDE the loop: one span per drained message,
1935 // not one per `try_process`.
1936 BufferStrategy::Ring(ring) => {
1937 let mut did_work = false;
1938 while let Some((data, len)) = ring.try_pop() {
1939 trace_take(desc_idx, &data[..len], None);
1940 trace_cb_start(desc_idx);
1941 (entry.callback)(&data[..len]);
1942 trace_cb_end(desc_idx);
1943 ring.commit_pop();
1944 did_work = true;
1945 }
1946 Ok(did_work)
1947 }
1948 }
1949}
1950
1951/// Readiness check for raw buffered subscriptions.
1952///
1953/// # Safety
1954/// `ptr` must point to a valid `SubBufferedRawEntry<F>`.
1955pub(crate) unsafe fn sub_buffered_raw_has_data<F>(ptr: *const u8) -> bool {
1956 let entry = unsafe { &*(ptr as *const SubBufferedRawEntry<F>) };
1957 entry.handle.has_data() || entry.buffer.has_data()
1958}
1959
1960// ============================================================================
1961// Borrowed (zero-copy) buffered subscription (Phase 229.6, issue 0007)
1962// ============================================================================
1963
1964/// Buffered subscription entry for borrowed (zero-copy) message callbacks.
1965///
1966/// The callback receives `&B::View<'a>` — a lifetime-carrying message whose
1967/// unbounded sequence/string fields borrow directly from the triple buffer's
1968/// read slot (no arena copy, no `heapless::Vec` copy). The view is materialised
1969/// per dispatch via [`DeserializeView`] and dropped before the slot is
1970/// released, so the borrow never outlives the buffer.
1971///
1972/// **Triple-buffer only.** A borrowed view must reference exactly one
1973/// well-defined slot for the duration of the callback; an SPSC ring (depth > 1)
1974/// holds several samples in flight with no single such slot. Registration
1975/// rejects `qos.depth > 1` for borrowed subscriptions, so `buffer` is always
1976/// [`BufferStrategy::Triple`] here.
1977#[repr(C)]
1978pub(crate) struct SubBufferedViewEntry<B, F> {
1979 pub(crate) handle: session::RmwSubscriber,
1980 pub(crate) buffer: BufferStrategy,
1981 pub(crate) callback: F,
1982 pub(crate) _phantom: PhantomData<B>,
1983}
1984
1985/// Dispatch for borrowed (zero-copy) buffered subscriptions.
1986///
1987/// Drains the RMW handle into the triple buffer, then materialises a borrowed
1988/// `B::View<'_>` over the read slot and hands it to the callback. The view
1989/// borrows the slot; it is dropped at the end of the callback, before the next
1990/// dispatch can publish over the slot.
1991///
1992/// # Safety
1993/// `ptr` must point to a valid, aligned `SubBufferedViewEntry<B, F>`.
1994pub(crate) unsafe fn sub_buffered_view_try_process<B, F>(
1995 ptr: *mut u8,
1996 _delta_us: u64,
1997 desc_idx: u8,
1998) -> Result<bool, TransportError>
1999where
2000 B: ViewableMessage,
2001 F: for<'a> FnMut(&B::View<'a>),
2002{
2003 let entry = unsafe { &mut *(ptr as *mut SubBufferedViewEntry<B, F>) };
2004
2005 // Borrowed subscriptions are triple-buffer only (enforced at registration).
2006 let tb = match &entry.buffer {
2007 BufferStrategy::Triple(tb) => tb,
2008 // Unreachable: registration rejects depth > 1. Treat as no work.
2009 BufferStrategy::Ring(_) => return Ok(false),
2010 };
2011
2012 // Phase 1: drain RMW subscriber → triple buffer write slot.
2013 //
2014 // Issue 0757 — the FOURTH copy of this drain (the borrowed/zero-copy path)
2015 // and it swallowed non-OK takes like the others. Same remedy as issue 0737's
2016 // C copy: report the actionable size, then let the error out.
2017 {
2018 let slot = tb.write_slot();
2019 if let Some(len) = take_reporting_drop(&mut entry.handle, slot)? {
2020 tb.writer_publish(len);
2021 }
2022 }
2023
2024 // Phase 2: borrow the read slot and deserialize a view over it (no copy).
2025 //
2026 // Phase 8 — hooked. Exactly ONE site: the `BufferStrategy::Ring(_)` arm
2027 // above returns `Ok(false)` without ever reaching a callback (borrowed
2028 // subscriptions are triple-buffer only, enforced at registration), so it
2029 // gets no pair. Both `?`s land before the start hook.
2030 match tb.reader_acquire() {
2031 Some((data, len)) => {
2032 let mut reader = CdrReader::new_with_header(&data[..len])
2033 .map_err(|_| TransportError::DeserializationError)?;
2034 let msg = <B::View<'_> as DeserializeView>::deserialize_view(&mut reader)
2035 .map_err(|_| TransportError::DeserializationError)?;
2036 trace_take(desc_idx, &data[..len], None);
2037 trace_cb_start(desc_idx);
2038 (entry.callback)(&msg);
2039 trace_cb_end(desc_idx);
2040 Ok(true)
2041 }
2042 None => Ok(false),
2043 }
2044}
2045
2046/// Readiness check for borrowed buffered subscriptions.
2047///
2048/// # Safety
2049/// `ptr` must point to a valid `SubBufferedViewEntry<B, F>`.
2050pub(crate) unsafe fn sub_buffered_view_has_data<B, F>(ptr: *const u8) -> bool {
2051 let entry = unsafe { &*(ptr as *const SubBufferedViewEntry<B, F>) };
2052 entry.handle.has_data() || entry.buffer.has_data()
2053}
2054
2055// ============================================================================
2056// Raw buffered subscription with attachment / MessageInfo (Phase 189.M1)
2057// ============================================================================
2058
2059/// Staging cap for a raw subscription's wire attachment (bridge_origin
2060/// tags and similar are small). Attachment bytes longer than this are
2061/// truncated by the backend's `take_serialized_with_attachment`.
2062pub(crate) const RAW_INFO_ATT_CAP: usize = 256;
2063
2064/// Raw buffered subscription entry that surfaces the sample's wire
2065/// attachment as a [`RawMessageInfo`] to the callback
2066/// (`FnMut(&[u8], &RawMessageInfo)`).
2067///
2068/// Unlike [`SubBufferedRawEntry`] (Triple/Ring `BufferStrategy`), this
2069/// uses a flat inline payload buffer + a flat attachment buffer so the
2070/// attachment travels with its message — the decoupled producer/consumer
2071/// slots of a triple/ring buffer cannot carry per-message side data.
2072/// One sample per dispatch (mirrors [`SubInfoEntry`]).
2073#[repr(C)]
2074pub(crate) struct SubBufferedRawInfoEntry<F> {
2075 pub(crate) handle: session::RmwSubscriber,
2076 /// Issue 1860 / phase-498 W1 -- the receive slot, in the arena's trailing
2077 /// region and sized at registration (from the type's bound when the
2078 /// caller names none), as `SubBufferedRawInfoCEntry` has been since
2079 /// phase-408 W5b. It was an inline `[u8; RX_BUF]`, so every entry of this
2080 /// shape reserved the image's closure maximum whatever its own type was.
2081 pub(crate) buffer: TrailingBuf,
2082 pub(crate) att: [u8; RAW_INFO_ATT_CAP],
2083 pub(crate) callback: F,
2084}
2085
2086/// Dispatch for raw buffered subscriptions with attachment.
2087///
2088/// # Safety
2089/// `ptr` must point to a valid, aligned `SubBufferedRawInfoEntry<F>`.
2090pub(crate) unsafe fn sub_buffered_raw_info_try_process<F>(
2091 ptr: *mut u8,
2092 _delta_us: u64,
2093 desc_idx: u8,
2094) -> Result<bool, TransportError>
2095where
2096 F: FnMut(&[u8], &RawMessageInfo),
2097{
2098 let entry = unsafe { &mut *(ptr as *mut SubBufferedRawInfoEntry<F>) };
2099 match entry
2100 .handle
2101 .take_serialized_with_attachment(entry.buffer.as_mut_slice(), &mut entry.att)
2102 {
2103 // Phase 8 — hooked. One sample per dispatch (flat inline buffer, no
2104 // ring), so exactly one pair. `Ok(None)` and `Err(_)` never reach a
2105 // callback and stay bare.
2106 Ok(Some((len, att_len))) => {
2107 let info = RawMessageInfo::new(&entry.att[..att_len]);
2108 trace_take(desc_idx, &entry.buffer.as_slice()[..len], None);
2109 trace_cb_start(desc_idx);
2110 (entry.callback)(&entry.buffer.as_slice()[..len], &info);
2111 trace_cb_end(desc_idx);
2112 Ok(true)
2113 }
2114 Ok(None) => Ok(false),
2115 Err(_) => Err(TransportError::DeserializationError),
2116 }
2117}
2118
2119/// Readiness check for raw buffered subscriptions with attachment.
2120///
2121/// # Safety
2122/// `ptr` must point to a valid `SubBufferedRawInfoEntry<F>`.
2123pub(crate) unsafe fn sub_buffered_raw_info_has_data<F>(ptr: *const u8) -> bool {
2124 let entry = unsafe { &*(ptr as *const SubBufferedRawInfoEntry<F>) };
2125 entry.handle.has_data()
2126}
2127
2128/// Issue 2036 — raw buffered subscription whose closure receives the sample's
2129/// [`MessageInfo`] (`take_serialized_with_info`): the timestamps, sequence
2130/// numbers and publisher gid a backend reports, or `None` from one that
2131/// reports none. The raw twin of [`SubInfoEntry`], for a caller that
2132/// deserializes itself — the C++ `(const M&, const rclcpp::MessageInfo&)`
2133/// subscription callback. [`SubBufferedRawInfoEntry`] carries the ATTACHMENT
2134/// bytes instead, and its `RawMessageInfo` metadata is defaulted.
2135#[repr(C)]
2136pub(crate) struct SubBufferedRawMsgInfoEntry<F> {
2137 pub(crate) handle: session::RmwSubscriber,
2138 /// The receive slot, in the arena's trailing region, sized at registration.
2139 pub(crate) buffer: TrailingBuf,
2140 pub(crate) callback: F,
2141}
2142
2143/// Dispatch for [`SubBufferedRawMsgInfoEntry`]: one sample per call.
2144///
2145/// # Safety
2146/// `ptr` must point to a valid, aligned `SubBufferedRawMsgInfoEntry<F>`.
2147pub(crate) unsafe fn sub_buffered_raw_msg_info_try_process<F>(
2148 ptr: *mut u8,
2149 _delta_us: u64,
2150 desc_idx: u8,
2151) -> Result<bool, TransportError>
2152where
2153 F: FnMut(&[u8], Option<&MessageInfo>),
2154{
2155 let entry = unsafe { &mut *(ptr as *mut SubBufferedRawMsgInfoEntry<F>) };
2156 match entry
2157 .handle
2158 .take_serialized_with_info(entry.buffer.as_mut_slice())
2159 {
2160 // Phase 8 — hooked, one pair per sample, as the other raw entries.
2161 Ok(Some((len, info))) => {
2162 trace_take(desc_idx, &entry.buffer.as_slice()[..len], None);
2163 trace_cb_start(desc_idx);
2164 (entry.callback)(&entry.buffer.as_slice()[..len], info.as_ref());
2165 trace_cb_end(desc_idx);
2166 Ok(true)
2167 }
2168 Ok(None) => Ok(false),
2169 Err(_) => Err(TransportError::DeserializationError),
2170 }
2171}
2172
2173/// Readiness check for [`SubBufferedRawMsgInfoEntry`].
2174///
2175/// # Safety
2176/// `ptr` must point to a valid `SubBufferedRawMsgInfoEntry<F>`.
2177pub(crate) unsafe fn sub_buffered_raw_msg_info_has_data<F>(ptr: *const u8) -> bool {
2178 let entry = unsafe { &*(ptr as *const SubBufferedRawMsgInfoEntry<F>) };
2179 entry.handle.has_data()
2180}
2181
2182/// C-style (fn-ptr + context) raw buffered subscription with attachment
2183/// (Phase 189.M3.4 — the C analog of [`SubBufferedRawInfoEntry`]). Flat
2184/// payload + attachment buffers, one sample per dispatch.
2185///
2186/// phase-408 W5b — the payload slot is a [`TrailingBuf`] in the arena's
2187/// trailing region rather than an inline `[u8; RX_BUF]`, so the registering
2188/// caller's `rx_buffer_hint` sizes the ALLOCATION and not just the backend's
2189/// payload size class. The attachment stays inline and fixed
2190/// ([`RAW_INFO_ATT_CAP`]): it is small, and it is the thing that has to travel
2191/// with its message.
2192#[repr(C)]
2193pub(crate) struct SubBufferedRawInfoCEntry {
2194 pub(crate) handle: session::RmwSubscriber,
2195 pub(crate) buffer: TrailingBuf,
2196 pub(crate) att: [u8; RAW_INFO_ATT_CAP],
2197 pub(crate) callback: RawSubscriptionInfoCallback,
2198 pub(crate) context: *mut core::ffi::c_void,
2199}
2200
2201/// Dispatch for the C-style raw buffered subscription with attachment.
2202///
2203/// # Safety
2204/// `ptr` must point to a valid, aligned `SubBufferedRawInfoCEntry`.
2205pub(crate) unsafe fn sub_buffered_raw_info_c_try_process(
2206 ptr: *mut u8,
2207 _delta_us: u64,
2208 desc_idx: u8,
2209) -> Result<bool, TransportError> {
2210 let entry = unsafe { &mut *(ptr as *mut SubBufferedRawInfoCEntry) };
2211 let payload = entry.buffer.as_mut_slice();
2212 match entry
2213 .handle
2214 .take_serialized_with_attachment(payload, &mut entry.att)
2215 {
2216 // Phase 8 — hooked. One sample per dispatch; the pair brackets the
2217 // whole `unsafe` FFI call, which is the user callback itself.
2218 Ok(Some((len, att_len))) => {
2219 trace_take(
2220 desc_idx,
2221 unsafe { core::slice::from_raw_parts(entry.buffer.as_ptr(), len) },
2222 None,
2223 );
2224 trace_cb_start(desc_idx);
2225 unsafe {
2226 (entry.callback)(
2227 entry.buffer.as_ptr(),
2228 len,
2229 entry.att.as_ptr(),
2230 att_len,
2231 entry.context,
2232 )
2233 };
2234 trace_cb_end(desc_idx);
2235 Ok(true)
2236 }
2237 Ok(None) => Ok(false),
2238 Err(_) => Err(TransportError::DeserializationError),
2239 }
2240}
2241
2242/// Readiness check for the C-style raw buffered subscription with attachment.
2243///
2244/// # Safety
2245/// `ptr` must point to a valid `SubBufferedRawInfoCEntry`.
2246pub(crate) unsafe fn sub_buffered_raw_info_c_has_data(ptr: *const u8) -> bool {
2247 let entry = unsafe { &*(ptr as *const SubBufferedRawInfoCEntry) };
2248 entry.handle.has_data()
2249}
2250
2251/// Phase 250 (Wave 2) — generic (type-erased) raw buffered subscription that
2252/// surfaces E2E [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC + sequence
2253/// gap/dup) alongside the raw CDR bytes (`FnMut(&[u8], &IntegrityStatus)`).
2254///
2255/// The type-erased analog of [`SubSafetyEntry`]: the validator lives in the
2256/// `RmwSubscriber` and `take_validated` produces the status, so no typed
2257/// `M` is needed (the declarative `Node` path is generic). Flat inline payload
2258/// buffer; one sample per dispatch.
2259#[cfg(feature = "safety-e2e")]
2260#[repr(C)]
2261pub(crate) struct SubBufferedRawSafetyEntry<F> {
2262 pub(crate) handle: session::RmwSubscriber,
2263 /// Issue 1860 / phase-498 W1 -- the receive slot, in the arena's trailing
2264 /// region and sized at registration (from the type's bound when the
2265 /// caller names none), as `SubBufferedRawInfoCEntry` has been since
2266 /// phase-408 W5b. It was an inline `[u8; RX_BUF]`, so every entry of this
2267 /// shape reserved the image's closure maximum whatever its own type was.
2268 pub(crate) buffer: TrailingBuf,
2269 pub(crate) callback: F,
2270}
2271
2272/// Dispatch for the generic raw safety subscription: validate-receive into the
2273/// buffer, then pass the raw slice + status to the callback.
2274///
2275/// # Safety
2276/// `ptr` must point to a valid, aligned `SubBufferedRawSafetyEntry<F>`.
2277#[cfg(feature = "safety-e2e")]
2278pub(crate) unsafe fn sub_buffered_raw_safety_try_process<F>(
2279 ptr: *mut u8,
2280 _delta_us: u64,
2281 desc_idx: u8,
2282) -> Result<bool, TransportError>
2283where
2284 F: FnMut(&[u8], &nros_rmw::IntegrityStatus),
2285{
2286 let entry = unsafe { &mut *(ptr as *mut SubBufferedRawSafetyEntry<F>) };
2287 match entry.handle.take_validated(entry.buffer.as_mut_slice()) {
2288 // Phase 8 — hooked. One sample per dispatch. A failed validation
2289 // still delivers the sample WITH its status, so this arm is the only
2290 // callback path; `Ok(None)` / `Err(_)` fire nothing.
2291 Ok(Some((len, status))) => {
2292 trace_take(desc_idx, &entry.buffer.as_slice()[..len], None);
2293 trace_cb_start(desc_idx);
2294 (entry.callback)(&entry.buffer.as_slice()[..len], &status);
2295 trace_cb_end(desc_idx);
2296 Ok(true)
2297 }
2298 Ok(None) => Ok(false),
2299 Err(_) => Err(TransportError::DeserializationError),
2300 }
2301}
2302
2303/// Readiness check for the generic raw safety subscription.
2304///
2305/// # Safety
2306/// `ptr` must point to a valid `SubBufferedRawSafetyEntry<F>`.
2307#[cfg(feature = "safety-e2e")]
2308pub(crate) unsafe fn sub_buffered_raw_safety_has_data<F>(ptr: *const u8) -> bool {
2309 let entry = unsafe { &*(ptr as *const SubBufferedRawSafetyEntry<F>) };
2310 entry.handle.has_data()
2311}
2312
2313/// Phase 269 W3 — the C analog of [`SubBufferedRawSafetyEntry`]: same flat inline
2314/// payload buffer + `take_validated` dispatch, but the callback is a plain
2315/// C function pointer (`RawSubscriptionSafetyCallback`) that receives the integrity
2316/// scalars alongside the CDR bytes.
2317///
2318/// phase-408 W5b — the payload slot is a [`TrailingBuf`] sized from the
2319/// registering caller's `rx_buffer_hint`, so this entry is no longer generic at
2320/// all: the `RX_BUF` const it was monomorphised over only ever arrived as
2321/// `DEFAULT_RX_BUF_SIZE`, and the status a validated sample carries is
2322/// per-sample side data, which is why the slot stays flat rather than becoming
2323/// a [`BufferStrategy`].
2324#[cfg(feature = "safety-e2e")]
2325#[repr(C)]
2326pub(crate) struct SubBufferedRawSafetyCEntry {
2327 pub(crate) handle: session::RmwSubscriber,
2328 pub(crate) buffer: TrailingBuf,
2329 pub(crate) callback: super::types::RawSubscriptionSafetyCallback,
2330 pub(crate) context: *mut core::ffi::c_void,
2331}
2332
2333/// Dispatch for the C-style raw validated subscription: validate-receive into the
2334/// buffer, then pass the raw slice + unpacked integrity scalars to the callback.
2335///
2336/// # Safety
2337/// `ptr` must point to a valid, aligned `SubBufferedRawSafetyCEntry`.
2338#[cfg(feature = "safety-e2e")]
2339pub(crate) unsafe fn sub_buffered_raw_safety_c_try_process(
2340 ptr: *mut u8,
2341 _delta_us: u64,
2342 desc_idx: u8,
2343) -> Result<bool, TransportError> {
2344 let entry = unsafe { &mut *(ptr as *mut SubBufferedRawSafetyCEntry) };
2345 let payload = entry.buffer.as_mut_slice();
2346 match entry.handle.take_validated(payload) {
2347 // Phase 8 — hooked. One sample per dispatch. The `crc_valid` unpack
2348 // is executor bookkeeping, not user code, so it stays OUTSIDE the
2349 // span; the pair brackets only the FFI call.
2350 Ok(Some((len, status))) => {
2351 let crc_valid: i8 = match status.crc_valid {
2352 Some(true) => 1,
2353 Some(false) => 0,
2354 None => -1,
2355 };
2356 trace_take(
2357 desc_idx,
2358 unsafe { core::slice::from_raw_parts(entry.buffer.as_ptr(), len) },
2359 None,
2360 );
2361 trace_cb_start(desc_idx);
2362 unsafe {
2363 (entry.callback)(
2364 entry.buffer.as_ptr(),
2365 len,
2366 status.gap,
2367 status.duplicate,
2368 crc_valid,
2369 entry.context,
2370 )
2371 };
2372 trace_cb_end(desc_idx);
2373 Ok(true)
2374 }
2375 Ok(None) => Ok(false),
2376 Err(_) => Err(TransportError::DeserializationError),
2377 }
2378}
2379
2380/// Readiness check for the C-style raw validated subscription.
2381///
2382/// # Safety
2383/// `ptr` must point to a valid `SubBufferedRawSafetyCEntry`.
2384#[cfg(feature = "safety-e2e")]
2385pub(crate) unsafe fn sub_buffered_raw_safety_c_has_data(ptr: *const u8) -> bool {
2386 let entry = unsafe { &*(ptr as *const SubBufferedRawSafetyCEntry) };
2387 entry.handle.has_data()
2388}
2389
2390/// Buffered subscription entry for C-style raw callbacks (function pointer + context).
2391///
2392/// Same as `SubBufferedRawEntry` but uses `RawSubscriptionCallback` instead of
2393/// a Rust closure. Used by the C API and by the `register_subscription_buffered_raw_*`
2394/// methods.
2395///
2396/// # Where the caller's capture went — phase-456 W8
2397///
2398/// Phase-456 W1 put the caller's capture INSIDE this struct, as a fixed
2399/// `[u8; CALLBACK_CAPTURE_BYTES]` with ~~`CALLBACK_CAPTURE_BYTES`~~ equal to a C++
2400/// macro's value by construction. That made a number an ABI agreement between
2401/// two languages, with a runtime `BufferTooSmall` as the only thing standing
2402/// between a disagreement and a corrupted dispatch — a failure mode reachable
2403/// ONLY when the two sides drifted, which is to say a failure mode nobody could
2404/// test against.
2405///
2406/// W8 made the capture a RUNTIME LENGTH: exactly `capture.len()` bytes of arena,
2407/// with `context` pointing at them. Since phase-476 W0 those bytes sit at the end
2408/// of the entry's own trailing region (`Executor::place_capture`), so releasing
2409/// the entry releases them.
2410/// The bound is now the arena, which every other allocation here already shares,
2411/// and the constant is gone from both sides of the seam. The entry also stops
2412/// carrying 32 bytes that every non-capturing C registration paid for.
2413#[repr(C)]
2414pub(crate) struct SubBufferedRawCEntry {
2415 pub(crate) handle: session::RmwSubscriber,
2416 pub(crate) buffer: BufferStrategy,
2417 pub(crate) callback: RawSubscriptionCallback,
2418 /// The dispatch context. Either what the caller passed, or — when a capture
2419 /// was supplied — a pointer into the arena's own copy of it, so nothing the
2420 /// caller owns is referenced after registration returns.
2421 pub(crate) context: *mut core::ffi::c_void,
2422 /// phase-476 W2 — destroys that copy when the entry is dropped. Declared
2423 /// after `handle`, so the subscriber is gone before its capture is.
2424 pub(crate) capture: CaptureGuard,
2425 /// Issue 2215 — the contracted age row this subscription feeds, if any.
2426 pub(crate) age_mon: Option<RawAgeMon>,
2427}
2428
2429// ============================================================================
2430// In-place raw C subscription (phase-456 W8)
2431// ============================================================================
2432
2433/// In-place C-callback subscription entry — **no arena buffer**.
2434///
2435/// The C-family twin of [`SubInplaceEntry`], and the one the executor should
2436/// have had first: `RawSubscriptionCallback` is `(const uint8_t*, size_t,
2437/// void*)`, which is already a BORROWED-BYTES signature, so handing it the
2438/// backend's own receive slot costs nothing and needs no conversion — where the
2439/// Rust typed path has to deserialize an owned `&M` out of the same bytes.
2440///
2441/// Until phase-456 W8 the C registration path never consulted
2442/// `supports_process_in_place()`, so every C and C++ subscription on zenoh or
2443/// XRCE allocated a receive region the backend does not need — the same order
2444/// as issue 1340's ~9.7 KiB per subscription, and on the language surface that
2445/// had the easiest time doing without it.
2446#[repr(C)]
2447pub(crate) struct SubInplaceRawCEntry {
2448 pub(crate) handle: session::RmwSubscriber,
2449 pub(crate) callback: RawSubscriptionCallback,
2450 /// Same contract as [`SubBufferedRawCEntry::context`].
2451 pub(crate) context: *mut core::ffi::c_void,
2452 /// Same contract as [`SubBufferedRawCEntry::capture`].
2453 pub(crate) capture: CaptureGuard,
2454 /// Same contract as [`SubBufferedRawCEntry::age_mon`].
2455 pub(crate) age_mon: Option<RawAgeMon>,
2456}
2457
2458/// Dispatch for in-place C-style raw subscriptions.
2459///
2460/// Drains pending samples, up to [`IN_PLACE_DRAIN_LIMIT`], invoking the callback
2461/// directly on the backend's borrowed slot. Returns `Ok(true)` if any sample was dispatched.
2462///
2463/// # Safety
2464/// `ptr` must point to a valid, aligned [`SubInplaceRawCEntry`].
2465pub(crate) unsafe fn sub_inplace_raw_c_try_process(
2466 ptr: *mut u8,
2467 _delta_us: u64,
2468 desc_idx: u8,
2469) -> Result<bool, TransportError> {
2470 let entry = unsafe { &mut *(ptr as *mut SubInplaceRawCEntry) };
2471 // Split-borrow the handle from the callback + context (disjoint fields).
2472 let SubInplaceRawCEntry {
2473 handle,
2474 callback,
2475 context,
2476 age_mon,
2477 ..
2478 } = entry;
2479 let context = *context;
2480 let callback = *callback;
2481 let age_mon = *age_mon;
2482 drain_in_place(|| {
2483 // Phase 8 — hooked INSIDE the borrow closure, which the drain loop
2484 // re-enters once per pending sample, so N samples produce N spans. The
2485 // pair is fully contained in the closure, so the `?` below can never
2486 // fire between a start and its end.
2487 handle.process_raw_in_place(|raw| {
2488 observe_age_raw(raw, &age_mon);
2489 trace_take(desc_idx, raw, None);
2490 trace_cb_start(desc_idx);
2491 unsafe { callback(raw.as_ptr(), raw.len(), context) };
2492 trace_cb_end(desc_idx);
2493 })
2494 })
2495}
2496
2497/// Readiness check for in-place C-style raw subscriptions.
2498///
2499/// # Safety
2500/// `ptr` must point to a valid [`SubInplaceRawCEntry`].
2501pub(crate) unsafe fn sub_inplace_raw_c_has_data(ptr: *const u8) -> bool {
2502 let entry = unsafe { &*(ptr as *const SubInplaceRawCEntry) };
2503 entry.handle.has_data()
2504}
2505
2506/// In-place raw subscription entry for the Rust GENERIC path — issue 1340.
2507///
2508/// The Rust twin of [`SubInplaceRawCEntry`]: `F` is `FnMut(&[u8])`, a
2509/// borrowed-bytes callback, which is exactly what `process_raw_in_place` hands
2510/// over. So there is no receive region to reserve — the backend's own slot is
2511/// the buffer, for the duration of the call.
2512///
2513/// `#[repr(C)]` with `handle` first, like every other subscription entry: the
2514/// executor's tests reach the backend subscriber through the entry's offset,
2515/// and that cast is sound only while the handle leads.
2516///
2517/// Carries no age monitor, and loses none: the buffered entry it replaces on an
2518/// in-place backend ([`SubBufferedRawEntry`]) carries none either. The only
2519/// thing that does not move over is the ring, which is the point.
2520#[repr(C)]
2521pub(crate) struct SubInplaceRawEntry<F> {
2522 pub(crate) handle: session::RmwSubscriber,
2523 pub(crate) callback: F,
2524}
2525
2526/// Dispatch for in-place Rust raw subscriptions.
2527///
2528/// Drains pending samples, up to [`IN_PLACE_DRAIN_LIMIT`], invoking the callback
2529/// directly on the backend's borrowed slot. Returns `Ok(true)` if any sample was dispatched.
2530/// Same drain and the same trace pairing as [`sub_inplace_raw_c_try_process`],
2531/// which it mirrors.
2532///
2533/// # Safety
2534/// `ptr` must point to a valid, aligned [`SubInplaceRawEntry<F>`].
2535pub(crate) unsafe fn sub_inplace_raw_try_process<F: FnMut(&[u8])>(
2536 ptr: *mut u8,
2537 _delta_us: u64,
2538 desc_idx: u8,
2539) -> Result<bool, TransportError> {
2540 let entry = unsafe { &mut *(ptr as *mut SubInplaceRawEntry<F>) };
2541 // Split-borrow the handle from the callback (disjoint fields).
2542 let SubInplaceRawEntry { handle, callback } = entry;
2543 drain_in_place(|| {
2544 // Hooked INSIDE the borrow closure, which the drain loop re-enters once
2545 // per pending sample, so N samples produce N spans and the `?` below
2546 // can never fire between a start and its end.
2547 handle.process_raw_in_place(|raw| {
2548 trace_take(desc_idx, raw, None);
2549 trace_cb_start(desc_idx);
2550 callback(raw);
2551 trace_cb_end(desc_idx);
2552 })
2553 })
2554}
2555
2556/// Readiness check for in-place Rust raw subscriptions.
2557///
2558/// # Safety
2559/// `ptr` must point to a valid [`SubInplaceRawEntry<F>`].
2560pub(crate) unsafe fn sub_inplace_raw_has_data<F>(ptr: *const u8) -> bool {
2561 let entry = unsafe { &*(ptr as *const SubInplaceRawEntry<F>) };
2562 entry.handle.has_data()
2563}
2564
2565/// Drain helper for C-style raw buffered entries.
2566/// Issue 0737 — a transport ERROR is not "no data", and conflating them
2567/// destroys the sample without a trace.
2568///
2569/// Both arms used to read `if let Ok(Some(len)) = … else { break }`, which
2570/// treats `Err(_)` exactly like `Ok(None)`. The backend has ALREADY consumed
2571/// the sample by the time it reports the error, so the message is gone and the
2572/// only observable is that nothing arrived — indistinguishable from a publisher
2573/// that never published. 0737 spent two hosts' investigations inside that
2574/// ambiguity while the executor's own `alive — … 0 error(s)` line reported
2575/// health, because the error never reached the counter that prints it.
2576///
2577/// Now it propagates: `spin_once` maps an `Err` from `try_process` to
2578/// `subscription_errors`, so the count stops lying and the failure has a name.
2579unsafe fn drain_into_buffer_raw_c(entry: &mut SubBufferedRawCEntry) -> Result<(), TransportError> {
2580 match &entry.buffer {
2581 BufferStrategy::Triple(tb) => {
2582 let slot = tb.write_slot();
2583 if let Some(len) = entry.handle.take_serialized(slot)? {
2584 tb.writer_publish(len);
2585 }
2586 }
2587 BufferStrategy::Ring(ring) => {
2588 while let Some(slot) = ring.try_push() {
2589 match entry.handle.take_serialized(slot)? {
2590 Some(len) => ring.commit_push(len),
2591 None => break,
2592 }
2593 }
2594 }
2595 }
2596 Ok(())
2597}
2598
2599/// Dispatch for C-style raw buffered subscriptions.
2600///
2601/// # Safety
2602/// `ptr` must point to a valid, aligned `SubBufferedRawCEntry`.
2603pub(crate) unsafe fn sub_buffered_raw_c_try_process(
2604 ptr: *mut u8,
2605 _delta_us: u64,
2606 desc_idx: u8,
2607) -> Result<bool, TransportError> {
2608 let entry = unsafe { &mut *(ptr as *mut SubBufferedRawCEntry) };
2609
2610 // Issue 0737 — drain first, and let a transport error OUT. Anything already
2611 // buffered from an earlier spin is still dispatched below on the next call.
2612 unsafe { drain_into_buffer_raw_c(entry)? };
2613
2614 match &entry.buffer {
2615 // Phase 8 — hooked. Triple-buffered: at most one invocation per
2616 // `try_process`, so exactly one start/end pair, and none at all when
2617 // `reader_acquire` comes back empty.
2618 BufferStrategy::Triple(tb) => match tb.reader_acquire() {
2619 Some((data, len)) => {
2620 observe_age_raw(&data[..len], &entry.age_mon);
2621 trace_take(desc_idx, &data[..len], None);
2622 trace_cb_start(desc_idx);
2623 unsafe { (entry.callback)(data.as_ptr(), len, entry.context) };
2624 trace_cb_end(desc_idx);
2625 Ok(true)
2626 }
2627 None => Ok(false),
2628 },
2629 // Phase 8 — hooked INSIDE the loop, deliberately. A ring drains N
2630 // queued messages per `try_process` and fires the user callback once
2631 // per message; a pair outside the loop would report N invocations as
2632 // one, which is the granularity failure that ruled out hooking the
2633 // `try_process` boundary in the first place.
2634 BufferStrategy::Ring(ring) => {
2635 let mut did_work = false;
2636 while let Some((data, len)) = ring.try_pop() {
2637 observe_age_raw(&data[..len], &entry.age_mon);
2638 trace_take(desc_idx, &data[..len], None);
2639 trace_cb_start(desc_idx);
2640 unsafe { (entry.callback)(data.as_ptr(), len, entry.context) };
2641 trace_cb_end(desc_idx);
2642 ring.commit_pop();
2643 did_work = true;
2644 }
2645 Ok(did_work)
2646 }
2647 }
2648}
2649
2650/// Readiness check for C-style raw buffered subscriptions.
2651///
2652/// # Safety
2653/// `ptr` must point to a valid `SubBufferedRawCEntry`.
2654pub(crate) unsafe fn sub_buffered_raw_c_has_data(ptr: *const u8) -> bool {
2655 let entry = unsafe { &*(ptr as *const SubBufferedRawCEntry) };
2656 entry.handle.has_data() || entry.buffer.has_data()
2657}
2658
2659// ============================================================================
2660// phase-417 W5.a — TYPED C subscription delivery (caller-owned storage)
2661// ============================================================================
2662
2663/// Buffered subscription entry that deserialises into CALLER-OWNED storage
2664/// before dispatching (phase-417 W5.a, RFC-0089 stage 5).
2665///
2666/// Structurally [`SubBufferedRawCEntry`] plus two words: the caller's `msg`
2667/// pointer and the erased deserialiser for its type. Deliberately the same
2668/// buffering machinery — a typed subscription is a raw one with one extra step
2669/// between the ring and the callback, not a second arena and not a second
2670/// receive path.
2671///
2672/// `msg` is not owned here and is not dropped: it is the caller's storage,
2673/// which is exactly what makes typed delivery possible with no allocator (the
2674/// property rclc has and the C ledger wrongly claimed rclc lacked).
2675#[repr(C)]
2676pub(crate) struct SubBufferedTypedCEntry {
2677 pub(crate) handle: session::RmwSubscriber,
2678 pub(crate) buffer: BufferStrategy,
2679 /// Caller-owned message storage. Overwritten on every dispatch.
2680 pub(crate) msg: *mut core::ffi::c_void,
2681 pub(crate) deserialize: RawMessageDeserializeFn,
2682 pub(crate) callback: TypedSubscriptionCallback,
2683 pub(crate) context: *mut core::ffi::c_void,
2684}
2685
2686/// Drain helper for typed buffered entries — the raw one, over the typed entry.
2687///
2688/// Issue 0737's rule applies unchanged: a transport ERROR is not "no data", so
2689/// it propagates instead of being flattened into an empty take.
2690unsafe fn drain_into_buffer_typed_c(
2691 entry: &mut SubBufferedTypedCEntry,
2692) -> Result<(), TransportError> {
2693 match &entry.buffer {
2694 BufferStrategy::Triple(tb) => {
2695 let slot = tb.write_slot();
2696 if let Some(len) = entry.handle.take_serialized(slot)? {
2697 tb.writer_publish(len);
2698 }
2699 }
2700 BufferStrategy::Ring(ring) => {
2701 while let Some(slot) = ring.try_push() {
2702 match entry.handle.take_serialized(slot)? {
2703 Some(len) => ring.commit_push(len),
2704 None => break,
2705 }
2706 }
2707 }
2708 }
2709 Ok(())
2710}
2711
2712/// Deserialise one sample into the caller's storage and dispatch.
2713///
2714/// Returns `false` when the deserialiser refused the sample. The callback is
2715/// NOT invoked in that case — a caller that read `msg` after a failed decode
2716/// would be reading the previous message, or an `_init` default, under the
2717/// impression it was the one that just arrived. That is precisely the
2718/// "compile and differ" RFC-0089 forbids, so the failure costs the dispatch.
2719///
2720/// # Safety
2721/// `entry` must be a live typed entry; `data`/`len` a readable CDR sample.
2722unsafe fn typed_dispatch_one(
2723 entry: &SubBufferedTypedCEntry,
2724 data: *const u8,
2725 len: usize,
2726 desc_idx: u8,
2727) -> bool {
2728 let rc = unsafe { (entry.deserialize)(data, len, entry.msg) };
2729 if rc != 0 {
2730 report_typed_deserialize_failure(rc, len);
2731 return false;
2732 }
2733 trace_take(
2734 desc_idx,
2735 unsafe { core::slice::from_raw_parts(data, len) },
2736 None,
2737 );
2738 trace_cb_start(desc_idx);
2739 unsafe { (entry.callback)(entry.msg as *const core::ffi::c_void, entry.context) };
2740 trace_cb_end(desc_idx);
2741 true
2742}
2743
2744/// Count of samples dropped because the caller's deserialiser refused them.
2745/// A static for the reason [`DROPPED_TAKES`] is one.
2746static TYPED_DESERIALIZE_FAILURES: portable_atomic::AtomicU32 = portable_atomic::AtomicU32::new(0);
2747
2748/// Report a refused decode: first, then every 64th.
2749///
2750/// Rate-limited for the reason [`report_dropped_take`] is — a 40-participant
2751/// graph must not turn one type mismatch into a log flood (issue 0371's shape).
2752/// `nros_log`, never stdio (issue 0589), so this reaches a `no_std` image.
2753///
2754/// The single most likely cause is the one this message names first: the
2755/// generated struct's inline capacity is smaller than the sample. Both
2756/// `rclc_cdr_read_string` (`str_len > max_len`) and the bounded-sequence arm
2757/// (`len > capacity`) FAIL rather than truncate, so an oversized message
2758/// arrives here as a refusal and never as a short message the callback cannot
2759/// tell from a real one.
2760#[cold]
2761fn report_typed_deserialize_failure(rc: i32, len: usize) {
2762 let n = TYPED_DESERIALIZE_FAILURES.fetch_add(1, portable_atomic::Ordering::Relaxed);
2763 if n != 0 && !n.is_multiple_of(64) {
2764 return;
2765 }
2766 nros_log::log_error!(
2767 nros_log::get_logger("nros"),
2768 "typed subscription DROPPED a {len}-byte sample: deserialize returned \
2769 {rc}. The callback was NOT invoked and the storage still holds the \
2770 previous message. Usual cause: the sample does not fit the message \
2771 struct (a string/sequence longer than its declared bound) or the topic \
2772 carries a different type. Dropped {} so far (phase-417 W5.a)",
2773 n + 1
2774 );
2775}
2776
2777/// Test-only accessor for [`TYPED_DESERIALIZE_FAILURES`].
2778///
2779/// The cfg MATCHES `mod tests`' own (`executor/mod.rs:95`), not a bare
2780/// `#[cfg(test)]`: the test module is `not(feature = "rmw-cffi")` too, so under
2781/// `test + rmw-cffi` a bare gate leaves this compiled with its only caller
2782/// gone, and `-D warnings` turns that into a build failure rather than a lint.
2783/// An accessor's gate has to be the gate of the thing that reads it — and
2784/// that gate gained `alloc` after this one was written, so under default
2785/// features this was compiled with no caller at all.
2786#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
2787pub(crate) fn typed_deserialize_failures() -> u32 {
2788 TYPED_DESERIALIZE_FAILURES.load(portable_atomic::Ordering::Relaxed)
2789}
2790
2791/// Dispatch for typed C subscriptions.
2792///
2793/// # Safety
2794/// `ptr` must point to a valid, aligned [`SubBufferedTypedCEntry`].
2795pub(crate) unsafe fn sub_buffered_typed_c_try_process(
2796 ptr: *mut u8,
2797 _delta_us: u64,
2798 desc_idx: u8,
2799) -> Result<bool, TransportError> {
2800 let entry = unsafe { &mut *(ptr as *mut SubBufferedTypedCEntry) };
2801
2802 unsafe { drain_into_buffer_typed_c(entry)? };
2803
2804 match &entry.buffer {
2805 BufferStrategy::Triple(tb) => match tb.reader_acquire() {
2806 Some((data, len)) => {
2807 if unsafe { typed_dispatch_one(entry, data.as_ptr(), len, desc_idx) } {
2808 Ok(true)
2809 } else {
2810 Err(TransportError::DeserializationError)
2811 }
2812 }
2813 None => Ok(false),
2814 },
2815 // A refused sample ENDS the drain and is reported, rather than being
2816 // skipped so the loop can keep going: `try_process` carries one verdict
2817 // per call, and a `Ok(true)` that also swallowed a decode failure would
2818 // never reach `subscription_errors`. The samples still queued behind it
2819 // are dispatched on the next spin — the ring is not cleared.
2820 BufferStrategy::Ring(ring) => {
2821 let mut did_work = false;
2822 while let Some((data, len)) = ring.try_pop() {
2823 let ok = unsafe { typed_dispatch_one(entry, data.as_ptr(), len, desc_idx) };
2824 ring.commit_pop();
2825 if !ok {
2826 return Err(TransportError::DeserializationError);
2827 }
2828 did_work = true;
2829 }
2830 Ok(did_work)
2831 }
2832 }
2833}
2834
2835/// Readiness check for typed C subscriptions.
2836///
2837/// # Safety
2838/// `ptr` must point to a valid [`SubBufferedTypedCEntry`].
2839pub(crate) unsafe fn sub_buffered_typed_c_has_data(ptr: *const u8) -> bool {
2840 let entry = unsafe { &*(ptr as *const SubBufferedTypedCEntry) };
2841 entry.handle.has_data() || entry.buffer.has_data()
2842}
2843
2844// ============================================================================
2845// Dispatch functions
2846// ============================================================================
2847
2848/// Monomorphized subscription dispatch function (with MessageInfo).
2849///
2850/// # Safety
2851/// `ptr` must point to a valid, aligned `SubInfoEntry<M, F>`.
2852pub(crate) unsafe fn sub_info_try_process<M, F>(
2853 ptr: *mut u8,
2854 _delta_us: u64,
2855 desc_idx: u8,
2856) -> Result<bool, TransportError>
2857where
2858 M: RosMessage,
2859 F: FnMut(&M, Option<&MessageInfo>),
2860{
2861 let entry = unsafe { &mut *(ptr as *mut SubInfoEntry<M, F>) };
2862
2863 // LET mode: use pre-sampled data if available (no MessageInfo in snapshot)
2864 //
2865 // Phase 8 — hooked. This is a SECOND callback site, mutually exclusive
2866 // with the receive path below: LET mode dispatches the snapshot and
2867 // returns, so a dispatch emits one pair from here OR one from there,
2868 // never both.
2869 if entry.sampled_len > 0 {
2870 let len = entry.sampled_len;
2871 entry.sampled_len = 0;
2872 let mut reader = CdrReader::new_with_header(&entry.buffer.as_slice()[..len])
2873 .map_err(|_| TransportError::DeserializationError)?;
2874 let msg = M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
2875 trace_take(
2876 desc_idx,
2877 &entry.buffer.as_slice()[..len],
2878 <M as RosMessage>::STAMP_OFFSET,
2879 );
2880 trace_cb_start(desc_idx);
2881 (entry.callback)(&msg, None);
2882 trace_cb_end(desc_idx);
2883 return Ok(true);
2884 }
2885
2886 match entry
2887 .handle
2888 .take_serialized_with_info(entry.buffer.as_mut_slice())
2889 {
2890 // Phase 8 — hooked. One sample per dispatch; `Ok(None)` / `Err(_)`
2891 // fire nothing, and both `?`s precede the start hook.
2892 Ok(Some((len, info))) => {
2893 let mut reader = CdrReader::new_with_header(&entry.buffer.as_slice()[..len])
2894 .map_err(|_| TransportError::DeserializationError)?;
2895 let msg =
2896 M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
2897 trace_take(
2898 desc_idx,
2899 &entry.buffer.as_slice()[..len],
2900 <M as RosMessage>::STAMP_OFFSET,
2901 );
2902 trace_cb_start(desc_idx);
2903 (entry.callback)(&msg, info.as_ref());
2904 trace_cb_end(desc_idx);
2905 Ok(true)
2906 }
2907 Ok(None) => Ok(false),
2908 Err(_) => Err(TransportError::DeserializationError),
2909 }
2910}
2911
2912/// Monomorphized subscription dispatch function (with safety validation).
2913///
2914/// # Safety
2915/// `ptr` must point to a valid, aligned `SubSafetyEntry<M, F>`.
2916#[cfg(feature = "safety-e2e")]
2917pub(crate) unsafe fn sub_safety_try_process<M, F>(
2918 ptr: *mut u8,
2919 _delta_us: u64,
2920 desc_idx: u8,
2921) -> Result<bool, TransportError>
2922where
2923 M: RosMessage,
2924 F: FnMut(&M, &nros_rmw::IntegrityStatus),
2925{
2926 let entry = unsafe { &mut *(ptr as *mut SubSafetyEntry<M, F>) };
2927
2928 // LET mode: use pre-sampled data (no IntegrityStatus in snapshot)
2929 //
2930 // Phase 8 — hooked. A SECOND callback site, mutually exclusive with the
2931 // validated-receive path below (this one `return`s). The synthetic
2932 // all-clear `IntegrityStatus` is built inside the span because it is an
2933 // argument expression, not user code — it is three constant stores.
2934 if entry.sampled_len > 0 {
2935 let len = entry.sampled_len;
2936 entry.sampled_len = 0;
2937 let mut reader = CdrReader::new_with_header(&entry.buffer.as_slice()[..len])
2938 .map_err(|_| TransportError::DeserializationError)?;
2939 let msg = M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
2940 trace_take(
2941 desc_idx,
2942 &entry.buffer.as_slice()[..len],
2943 <M as RosMessage>::STAMP_OFFSET,
2944 );
2945 trace_cb_start(desc_idx);
2946 (entry.callback)(
2947 &msg,
2948 &nros_rmw::IntegrityStatus {
2949 gap: 0,
2950 duplicate: false,
2951 crc_valid: None,
2952 },
2953 );
2954 trace_cb_end(desc_idx);
2955 return Ok(true);
2956 }
2957
2958 match entry.handle.take_validated(entry.buffer.as_mut_slice()) {
2959 // Phase 8 — hooked. One sample per dispatch; both `?`s precede the
2960 // start hook, and `Ok(None)` / `Err(_)` fire nothing.
2961 Ok(Some((len, status))) => {
2962 let mut reader = CdrReader::new_with_header(&entry.buffer.as_slice()[..len])
2963 .map_err(|_| TransportError::DeserializationError)?;
2964 let msg =
2965 M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
2966 trace_take(
2967 desc_idx,
2968 &entry.buffer.as_slice()[..len],
2969 <M as RosMessage>::STAMP_OFFSET,
2970 );
2971 trace_cb_start(desc_idx);
2972 (entry.callback)(&msg, &status);
2973 trace_cb_end(desc_idx);
2974 Ok(true)
2975 }
2976 Ok(None) => Ok(false),
2977 Err(_) => Err(TransportError::DeserializationError),
2978 }
2979}
2980
2981/// Monomorphized service dispatch function.
2982///
2983/// # Safety
2984/// `ptr` must point to a valid, aligned `SrvEntry<Svc, F, REQ_BUF, REPLY_BUF>`.
2985pub(crate) unsafe fn srv_try_process<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
2986 ptr: *mut u8,
2987 _delta_us: u64,
2988 desc_idx: u8,
2989) -> Result<bool, TransportError>
2990where
2991 Svc: RosService,
2992 F: FnMut(&Svc::Request) -> Svc::Reply,
2993{
2994 let entry = unsafe { &mut *(ptr as *mut SrvEntry<Svc, F, REQ_BUF, REPLY_BUF>) };
2995 // Split borrow: destructure entry to avoid aliasing issues
2996 let SrvEntry {
2997 handle,
2998 req_buffer,
2999 reply_buffer,
3000 callback,
3001 ..
3002 } = entry;
3003 handle
3004 // Phase 8 — hooked INSIDE the closure `handle_request` invokes, not
3005 // around `handle_request` itself. The closure runs once per request
3006 // actually received, so a spin with nothing pending emits nothing;
3007 // bracketing the outer call would have timed the receive/reply
3008 // machinery and fired on every empty poll.
3009 .handle_request::<Svc>(req_buffer, reply_buffer, |req| {
3010 trace_cb_start(desc_idx);
3011 let reply = (callback)(req);
3012 trace_cb_end(desc_idx);
3013 reply
3014 })
3015 .map_err(|_| TransportError::ServiceReplyFailed)
3016}
3017
3018/// Issue 1942 — [`srv_try_process`] for a callback that also takes the
3019/// request's [`ServiceInfo`](nros_core::ServiceInfo) (rclrs's shape). Same
3020/// [`SrvEntry`] layout; only the callback's signature differs.
3021///
3022/// # Safety
3023/// `ptr` must point to a valid `SrvEntry<Svc, F, REQ_BUF, REPLY_BUF>`.
3024pub(crate) unsafe fn srv_try_process_with_info<
3025 Svc,
3026 F,
3027 const REQ_BUF: usize,
3028 const REPLY_BUF: usize,
3029>(
3030 ptr: *mut u8,
3031 _delta_us: u64,
3032 desc_idx: u8,
3033) -> Result<bool, TransportError>
3034where
3035 Svc: RosService,
3036 F: FnMut(&Svc::Request, &nros_core::ServiceInfo) -> Svc::Reply,
3037{
3038 let entry = unsafe { &mut *(ptr as *mut SrvEntry<Svc, F, REQ_BUF, REPLY_BUF>) };
3039 let SrvEntry {
3040 handle,
3041 req_buffer,
3042 reply_buffer,
3043 callback,
3044 ..
3045 } = entry;
3046 handle
3047 // Traced inside the closure, as in `srv_try_process`.
3048 .handle_request_with_info::<Svc>(req_buffer, reply_buffer, |req, info| {
3049 trace_cb_start(desc_idx);
3050 let reply = (callback)(req, info);
3051 trace_cb_end(desc_idx);
3052 reply
3053 })
3054 .map_err(|_| TransportError::ServiceReplyFailed)
3055}
3056
3057/// Monomorphized drop function for arena entries.
3058///
3059/// # Safety
3060/// `ptr` must point to a valid, aligned `T` that has not been dropped.
3061pub(crate) unsafe fn drop_entry<T>(ptr: *mut u8) {
3062 unsafe { core::ptr::drop_in_place(ptr as *mut T) };
3063}
3064
3065/// What one executor cycle does to a timer's own `elapsed_us`.
3066pub(crate) enum TimerStep {
3067 /// Add this many microseconds, measured on the TIMER'S clock.
3068 Advance(u64),
3069 /// The timer's clock jumped BACKWARDS — a bag looping, a simulator reset,
3070 /// an NTP step. Restart the period rather than stalling the timer for the
3071 /// length of the jump, which is what accumulating a negative delta would
3072 /// amount to. rclcpp gets here through a jump callback; we need the
3073 /// behaviour, not (yet) the callback surface
3074 /// (`c:clock_add_jump_callback` stays declined).
3075 Restart,
3076}
3077
3078/// One cycle's advance for a timer, in the timer's OWN clock — phase-425 W4,
3079/// made shared by issue 1321.
3080///
3081/// `Steady` consumes the executor's spin delta, unchanged and free. The other
3082/// two READ their clock and diff against the last reading, which is what makes
3083/// a paused simulator pause the timer: `/clock` stops advancing, so the delta
3084/// is zero. A FORWARD jump is deliberately NOT special-cased: it lands in
3085/// `elapsed_us` as a backlog, and the overrun policy is the documented
3086/// mechanism for deciding whether a backlog replays (`CatchUp`) or coalesces
3087/// (`Skip`).
3088///
3089/// One spelling, because there are two callers: the dispatcher below, and the
3090/// budget-skip path in `spin_once`. That second one had the delta computation
3091/// open-coded as "add the executor's `delta_us`", which is a WALL quantity —
3092/// the same units error as issue 1321's park bound, but unbounded: it advanced
3093/// a `Ros` timer while `/clock` was paused, which is the one thing a ROS-time
3094/// timer must never do.
3095pub(crate) fn timer_clock_step(
3096 clock_source: TimerClockSource,
3097 last_clock_ns: &mut i64,
3098 spin_delta_us: u64,
3099) -> TimerStep {
3100 match clock_source {
3101 TimerClockSource::Steady => TimerStep::Advance(spin_delta_us),
3102 source => {
3103 let now_ns = source.now_ns();
3104 let step_ns = now_ns - *last_clock_ns;
3105 *last_clock_ns = now_ns;
3106 if step_ns < 0 {
3107 return TimerStep::Restart;
3108 }
3109 TimerStep::Advance((step_ns as u64) / 1_000)
3110 }
3111 }
3112}
3113
3114/// Advance a timer's own sense of time by one cycle WITHOUT dispatching it —
3115/// issue 0736's accounting, on issue 1321's clock.
3116///
3117/// Type-erased because its caller has a `TimerHeader`, not a `TimerEntry<F>`.
3118pub(crate) fn timer_advance_without_dispatch(header: &mut TimerHeader, spin_delta_us: u64) {
3119 match timer_clock_step(
3120 header.clock_source,
3121 &mut header.last_clock_ns,
3122 spin_delta_us,
3123 ) {
3124 TimerStep::Advance(us) => header.elapsed_us = header.elapsed_us.saturating_add(us),
3125 TimerStep::Restart => header.elapsed_us = 0,
3126 }
3127}
3128
3129/// A `Skip` timer's overrun after it fired: the periods it missed beyond the
3130/// one that fired, and what stays of `elapsed_us` on the phase grid.
3131///
3132/// Saturating, because `elapsed_us` can be BELOW one period here: a callback
3133/// that reset its own timer (`timer->reset()`, which upstream allows inside
3134/// the callback) zeroed it while the timer was firing. `0 / period - 1`
3135/// panicked a debug build (phase-518 W2: issue 2055's callback pump re-arms
3136/// its timer from a callback that sends again).
3137pub(crate) fn skip_overrun(elapsed_us: u64, period_us: u64) -> (u64, u64) {
3138 (
3139 (elapsed_us / period_us).saturating_sub(1),
3140 elapsed_us % period_us,
3141 )
3142}
3143
3144/// The call info for an activation that is due NOW — issue 1974.
3145///
3146/// `actual` is now on the timer's clock ([`TimerClockSource::dispatch_now_ns`]);
3147/// `expected` subtracts how far `elapsed_us` has run past `period_us`. A
3148/// zero-period timer ("fire every spin") has no deadline to be late for, so its
3149/// two times coincide.
3150pub(crate) fn timer_call_info(
3151 clock_source: TimerClockSource,
3152 last_clock_ns: i64,
3153 elapsed_us: u64,
3154 period_us: u64,
3155) -> TimerCallInfo {
3156 let actual = clock_source.dispatch_now_ns(last_clock_ns);
3157 let late_us = if period_us == 0 {
3158 0
3159 } else {
3160 elapsed_us.saturating_sub(period_us)
3161 };
3162 let late_ns = i64::try_from(late_us.saturating_mul(1_000)).unwrap_or(i64::MAX);
3163 TimerCallInfo {
3164 expected_call_time: actual.saturating_sub(late_ns),
3165 actual_call_time: actual,
3166 clock: clock_source,
3167 since_last_call_ns: i64::try_from(elapsed_us.saturating_mul(1_000)).unwrap_or(i64::MAX),
3168 }
3169}
3170
3171/// Monomorphized timer dispatch function.
3172///
3173/// # Safety
3174/// `ptr` must point to a valid, aligned `TimerEntry<F>`.
3175pub(crate) unsafe fn timer_try_process<F>(
3176 ptr: *mut u8,
3177 delta_us: u64,
3178 desc_idx: u8,
3179) -> Result<bool, TransportError>
3180where
3181 F: FnMut(&TimerCallInfo),
3182{
3183 let entry = unsafe { &mut *(ptr as *mut TimerEntry<F>) };
3184
3185 // Cancelled or one-shot already fired
3186 if entry.cancelled || (entry.oneshot && entry.fired) {
3187 return Ok(false);
3188 }
3189
3190 // phase-425 W4 — which clock advanced, and by how much. Shared with the
3191 // budget-skip path in `spin_once`, which advances the same counter without
3192 // dispatching (issue 0736): there is ONE answer to "which clock moves this
3193 // timer", and it is `timer_clock_step`.
3194 let delta_us = match timer_clock_step(entry.clock_source, &mut entry.last_clock_ns, delta_us) {
3195 TimerStep::Advance(us) => us,
3196 TimerStep::Restart => {
3197 entry.elapsed_us = 0;
3198 return Ok(false);
3199 }
3200 };
3201
3202 entry.elapsed_us = entry.elapsed_us.saturating_add(delta_us);
3203
3204 if entry.elapsed_us >= entry.period_us {
3205 // Phase 8 — hooked. Inside the due-check, so a timer polled on every
3206 // spin and not yet due emits nothing: `Ok(false)` is the COMMON
3207 // outcome here, and it is exactly the over-reporting that a hook at
3208 // the `try_process` boundary would have produced.
3209 // phase-474 I3 -- one tick in N when the timer is thinned.
3210 let traced = timer_tick_traced(desc_idx);
3211 // Issue 1974 — the ONE producer of a timer's call info, computed from
3212 // the very comparison that just decided the activation is due, so the
3213 // three language surfaces read the dispatcher's answer rather than
3214 // keeping their own. Lateness is how far `elapsed_us` ran past the
3215 // period; it is never negative here, so `actual >= expected` always.
3216 let info = timer_call_info(
3217 entry.clock_source,
3218 entry.last_clock_ns,
3219 entry.elapsed_us,
3220 entry.period_us,
3221 );
3222 if traced {
3223 trace_cb_start(desc_idx);
3224 }
3225 (entry.callback)(&info);
3226 if traced {
3227 trace_cb_end(desc_idx);
3228 }
3229 if entry.oneshot {
3230 entry.fired = true;
3231 } else if entry.period_us == 0 {
3232 entry.elapsed_us = 0;
3233 } else {
3234 // Issue #505 — a stall leaves `elapsed_us` worth several
3235 // periods. `CatchUp` subtracts one period per pass, so the
3236 // backlog replays back-to-back; `Skip` drops the whole
3237 // backlog in one step and counts it, keeping the remainder
3238 // so activations stay on the original phase grid.
3239 match entry.overrun_policy {
3240 TimerOverrunPolicy::CatchUp => {
3241 entry.elapsed_us = entry.elapsed_us.saturating_sub(entry.period_us);
3242 }
3243 TimerOverrunPolicy::Skip => {
3244 let (missed, remainder) = skip_overrun(entry.elapsed_us, entry.period_us);
3245 if missed > 0 {
3246 entry.overruns = entry
3247 .overruns
3248 .saturating_add(u32::try_from(missed).unwrap_or(u32::MAX));
3249 }
3250 entry.elapsed_us = remainder;
3251 }
3252 }
3253 }
3254 Ok(true)
3255 } else {
3256 Ok(false)
3257 }
3258}
3259
3260/// Monomorphized action server dispatch function.
3261///
3262/// Polls goal acceptance, cancel handling, and result serving.
3263///
3264/// # Safety
3265/// `ptr` must point to a valid, aligned `ActionServerArenaEntry<...>`.
3266pub(crate) unsafe fn action_server_try_process<
3267 A,
3268 GoalF,
3269 CancelF,
3270 const GOAL_BUF: usize,
3271 const RESULT_BUF: usize,
3272 const FEEDBACK_BUF: usize,
3273 const MAX_GOALS: usize,
3274>(
3275 ptr: *mut u8,
3276 _delta_us: u64,
3277 desc_idx: u8,
3278) -> Result<bool, TransportError>
3279where
3280 A: RosAction,
3281 A::Goal: Clone,
3282 A::Result: Clone + Default,
3283 GoalF: FnMut(&nros_core::GoalUuid, &A::Goal) -> nros_core::GoalResponse,
3284 CancelF: FnMut(&nros_core::GoalUuid, nros_core::GoalStatusCode) -> nros_core::CancelResponse,
3285{
3286 let entry = unsafe {
3287 &mut *(ptr as *mut ActionServerArenaEntry<
3288 A,
3289 GoalF,
3290 CancelF,
3291 GOAL_BUF,
3292 RESULT_BUF,
3293 FEEDBACK_BUF,
3294 MAX_GOALS,
3295 >)
3296 };
3297 let ActionServerArenaEntry {
3298 server,
3299 goal_callback,
3300 cancel_callback,
3301 } = entry;
3302
3303 let mut did_work = false;
3304
3305 // Handle cancels first
3306 //
3307 // Phase 8 — hooked INSIDE the closure, not around `try_handle_cancel`.
3308 // The core runs this closure only when a cancel request is actually
3309 // pending; `Ok(None)` with no user code run is the COMMON outcome, so a
3310 // pair at the call boundary would report an invocation on every spin.
3311 if matches!(
3312 server.try_handle_cancel(|id, st| {
3313 trace_cb_start(desc_idx);
3314 let resp = (cancel_callback)(id, st);
3315 trace_cb_end(desc_idx);
3316 resp
3317 }),
3318 Ok(Some(_))
3319 ) {
3320 did_work = true;
3321 }
3322
3323 // Handle new goals
3324 //
3325 // Phase 8 — hooked inside the closure for the same reason. This is a
3326 // DISTINCT user callback from the cancel one above and gets its own
3327 // pair, so the two are counted separately rather than merged.
3328 if matches!(
3329 server.try_accept_goal(|id, g| {
3330 trace_cb_start(desc_idx);
3331 let resp = (goal_callback)(id, g);
3332 trace_cb_end(desc_idx);
3333 resp
3334 }),
3335 Ok(Some(_))
3336 ) {
3337 did_work = true;
3338 }
3339
3340 // Handle result requests
3341 if matches!(server.try_handle_get_result(), Ok(Some(_))) {
3342 did_work = true;
3343 }
3344
3345 Ok(did_work)
3346}
3347
3348/// Monomorphized raw action server dispatch function.
3349///
3350/// Polls goal acceptance, cancel handling, and result serving using raw bytes.
3351///
3352/// # Safety
3353/// `ptr` must point to a valid, aligned `ActionServerRawArenaEntry<...>`.
3354pub(crate) unsafe fn action_server_raw_try_process<
3355 const GOAL_BUF: usize,
3356 const RESULT_BUF: usize,
3357 const FEEDBACK_BUF: usize,
3358 const MAX_GOALS: usize,
3359>(
3360 ptr: *mut u8,
3361 _delta_us: u64,
3362 desc_idx: u8,
3363) -> Result<bool, TransportError> {
3364 let entry = unsafe {
3365 &mut *(ptr as *mut ActionServerRawArenaEntry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>)
3366 };
3367 let ActionServerRawArenaEntry {
3368 core,
3369 goal_callback,
3370 cancel_callback,
3371 accepted_callback,
3372 context,
3373 } = entry;
3374
3375 let mut did_work = false;
3376
3377 // Handle cancels first
3378 //
3379 // Phase 8 — hooked INSIDE the closure: the core runs it only when a cancel
3380 // request is actually pending, so nothing is emitted on the common
3381 // `Ok(None)` spin.
3382 if let Ok(Some(_)) = core.try_handle_cancel(|id, st| {
3383 trace_cb_start(desc_idx);
3384 let resp = unsafe { (*cancel_callback)(id, st, *context) };
3385 trace_cb_end(desc_idx);
3386 resp
3387 }) {
3388 did_work = true;
3389 }
3390
3391 // Handle new goals
3392 if let Ok(Some(raw_req)) = core.try_recv_goal_request() {
3393 let goal_data = &core.goal_buffer()[..raw_req.data_len];
3394 // Phase 8 — hooked inside the `Ok(Some(..))` arm: no goal request,
3395 // no goal callback, nothing emitted.
3396 trace_cb_start(desc_idx);
3397 let response = unsafe {
3398 (*goal_callback)(
3399 &raw_req.goal_id,
3400 goal_data.as_ptr(),
3401 raw_req.data_len,
3402 *context,
3403 )
3404 };
3405 trace_cb_end(desc_idx);
3406
3407 if response.is_accepted() {
3408 // Send the accept reply *before* running any long-running
3409 // post-accept hook so the client observes acceptance promptly.
3410 let _ = core.accept_goal(raw_req.goal_id, raw_req.sequence_number);
3411 // Phase 8 — the post-accept hook is a THIRD, separate user
3412 // callback: optional, and run only on the accepted branch, so
3413 // it carries its own pair rather than being folded into the
3414 // goal callback's span above.
3415 if let Some(post) = *accepted_callback {
3416 trace_cb_start(desc_idx);
3417 unsafe { post(&raw_req.goal_id, *context) };
3418 trace_cb_end(desc_idx);
3419 }
3420 } else {
3421 let _ = core.reject_goal(raw_req.sequence_number);
3422 }
3423 did_work = true;
3424 }
3425
3426 // Handle result requests (empty default result for raw API)
3427 if let Ok(Some(_)) = core.try_handle_get_result_raw(&[]) {
3428 did_work = true;
3429 }
3430
3431 Ok(did_work)
3432}
3433
3434/// Monomorphized raw action client dispatch function.
3435///
3436/// Polls the action client core's non-blocking methods:
3437/// 1. Goal acceptance reply (`try_recv_send_goal_reply`)
3438/// 2. Feedback (`try_recv_feedback_raw`)
3439/// 3. Result reply (`try_recv_get_result_reply`)
3440///
3441/// Invokes the corresponding callback when data is available.
3442///
3443/// # Safety
3444/// `ptr` must point to a valid, aligned `ActionClientRawArenaEntry<...>`.
3445/// RFC-0069 / issues 0418 + 0035 — **retired as a correctness mechanism.**
3446///
3447/// This sniffed whether a payload already began with a CDR encapsulation header
3448/// (`00 <id> <opts> <opts>`) and read it directly if so. That is a VALUE test,
3449/// not a framing test, and it cannot tell the two apart: a leading `uint32` of
3450/// 256 serializes little-endian as `00 01 00 00` — byte for byte the LE encap
3451/// header. A sequence of length 256 is enough.
3452///
3453/// It was harmless while only Cyclone took the false branch. 0418 stopped the
3454/// producer writing an inner header, so EVERY payload began consulting it, and
3455/// a body whose first word happened to look like a header had that word eaten
3456/// as framing — issue #35's "sequence deserialized to len 0", reached through a
3457/// payload value instead of a framing bug.
3458///
3459/// Kept only for the pre-0418 diagnostic below, never for a decode decision.
3460#[cfg(test)]
3461fn payload_has_cdr_encap(p: &[u8]) -> bool {
3462 p.len() >= 4 && p[0] == 0x00 && matches!(p[1], 0x00 | 0x01 | 0x06 | 0x07 | 0x0a | 0x0b)
3463}
3464
3465/// Deserialize an action result/feedback field payload, restoring the per-message
3466/// CDR encapsulation header the backend's typed framing may have stripped (#175).
3467/// `raw` is the field bytes at the payload offset; `top_encap` is the enclosing
3468/// message's leading 4-byte encap (always a valid header). When `raw` already
3469/// begins with an encap (zenoh/XRCE) it is read directly; when it does not
3470/// (Cyclone `dds_stream` drops the inner encap of a nested message field) the
3471/// top-level encap is spliced in front into a `CAP`-byte scratch buffer first.
3472fn read_action_field<M: nros_serdes::Deserialize, const CAP: usize>(
3473 top_encap: &[u8],
3474 raw: &[u8],
3475) -> Option<M> {
3476 // ALWAYS splice (RFC-0069 / issue 0418). Post-0418 the producer writes no
3477 // inner encapsulation header, and neither does Cyclone's `dds_stream` (it
3478 // drops the inner encap of a nested field, #175) — so the payload is field
3479 // bytes in every case and the enclosing message's encap is the right one to
3480 // read them with.
3481 //
3482 // This used to branch on `payload_has_cdr_encap(raw)`. That sniff is a value
3483 // test and gets it wrong for a body starting `00 01 00 00` (a `uint32` of
3484 // 256, e.g. a 256-element sequence), eating the leading data word — see the
3485 // retired helper above.
3486 if top_encap.len() < 4 || raw.len() + 4 > CAP {
3487 return None;
3488 }
3489 let mut buf = [0u8; CAP];
3490 buf[0..4].copy_from_slice(&top_encap[0..4]);
3491 buf[4..4 + raw.len()].copy_from_slice(raw);
3492 let mut reader = CdrReader::new_with_header(&buf[..4 + raw.len()]).ok()?;
3493 M::deserialize(&mut reader).ok()
3494}
3495
3496pub(crate) unsafe fn action_client_raw_try_process<
3497 const GOAL_BUF: usize,
3498 const RESULT_BUF: usize,
3499 const FEEDBACK_BUF: usize,
3500>(
3501 ptr: *mut u8,
3502 _delta_us: u64,
3503 desc_idx: u8,
3504) -> Result<bool, TransportError> {
3505 let entry = unsafe {
3506 &mut *(ptr as *mut ActionClientRawArenaEntry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>)
3507 };
3508 let ActionClientRawArenaEntry {
3509 core,
3510 goal_response_callback,
3511 feedback_callback,
3512 result_callback,
3513 cancel_response_callback,
3514 context,
3515 } = entry;
3516
3517 let mut did_work = false;
3518
3519 // 1. Poll goal acceptance reply
3520 // Issue 2009 — the goal is the one the reply's request was ABOUT. It used
3521 // to be rebuilt from `goal_counter`, i.e. the most recently SENT goal, so
3522 // two goals in flight swapped acceptances.
3523 if let Ok(Some((total_len, goal_id))) = core.try_recv_send_goal_reply_for() {
3524 if let Some(cb) = goal_response_callback {
3525 // Reply CDR: header (4) + accepted (u8) + stamp
3526 let accepted = crate::action_frame::goal_accepted(&core.result_buffer[..total_len]);
3527 // Phase 8 — hooked at the invocation, inside both the
3528 // `Ok(Some(..))` arm and the `Some(cb)` guard: an unregistered
3529 // callback or an empty poll emits nothing.
3530 trace_cb_start(desc_idx);
3531 unsafe { cb(&goal_id, accepted, *context) };
3532 trace_cb_end(desc_idx);
3533 }
3534 did_work = true;
3535 }
3536
3537 // 2. Poll feedback
3538 if let Ok(Some((goal_id, total_len))) = core.try_recv_feedback_raw() {
3539 if let Some(cb) = feedback_callback {
3540 // Layout `[encap 4][GoalUuid.uuid 16][fields]` — see `action_frame`.
3541 // Issue 2014 — the offset (`action_frame::FEEDBACK_PAYLOAD_OFFSET`)
3542 // and the splice are spelled once, in `action_frame`.
3543 //
3544 // #175 + RFC-0069/0418 — the payload is field bytes on every
3545 // backend now: Cyclone's `dds_stream` drops the inner encap of a
3546 // nested field, and since 0418 the producer never writes one. So
3547 // splice the enclosing message's encap unconditionally. This used
3548 // to branch on an encap SNIFF, which mis-fires on a body whose
3549 // first word is 0x00010000 (see the retired helper).
3550 let mut spliced = [0u8; FEEDBACK_BUF];
3551 if let Some(n) = crate::action_frame::splice_feedback(
3552 &core.feedback_buffer[..total_len],
3553 &mut spliced,
3554 ) {
3555 // Phase 8 — a DISTINCT callback from the goal-response one,
3556 // with its own pair. Hooked inside the guard: a short
3557 // payload runs no user code and must emit nothing.
3558 trace_cb_start(desc_idx);
3559 unsafe { cb(&goal_id, spliced.as_ptr(), n, *context) };
3560 trace_cb_end(desc_idx);
3561 }
3562 }
3563 did_work = true;
3564 }
3565
3566 // 3. Poll result reply — issue 2009: attributed to the goal its request
3567 // named, not to the most recently sent goal.
3568 if let Ok(Some((total_len, goal_id))) = core.try_recv_get_result_reply_for() {
3569 if let Some(cb) = result_callback {
3570 // Layout `[encap 4][status i8][pad → 8][fields]` — see
3571 // `action_frame`. Slicing at 5 (Phase 96.1) leaked the three pad
3572 // bytes into the payload and emptied every C++/XRCE result.
3573 // Issue 2014 — status, payload offset and splice come from
3574 // `action_frame`, the one spelling every consumer shares.
3575 //
3576 // #175 + RFC-0069/0418 — restore the result's CDR encapsulation
3577 // header. The fields arrive raw on every backend now: Cyclone sends
3578 // `GetResult_Response` as a TYPED sample whose `result` is a NESTED
3579 // field, so `dds_stream` consumes the inner encap; and since 0418
3580 // the producer never writes one. The consumer
3581 // (`CallbackCtx::message` / `ffi_deserialize`) reads with
3582 // `new_with_header`, so splice the reply's top-level encap in front
3583 // unconditionally. It used to branch on an encap SNIFF, which a
3584 // leading `int32` of 256 (`00 01 00 00`) defeats — see the retired
3585 // `payload_has_cdr_encap`.
3586 let reply = &core.result_buffer[..total_len];
3587 let mut spliced = [0u8; RESULT_BUF];
3588 if let (Some(status), Some(n)) = (
3589 crate::action_frame::result_status(reply),
3590 crate::action_frame::splice_result(reply, &mut spliced),
3591 ) {
3592 // Phase 8 — the third distinct callback on this entry, its
3593 // own pair. Inside the guard, which can skip the call entirely.
3594 trace_cb_start(desc_idx);
3595 unsafe { cb(&goal_id, status, spliced.as_ptr(), n, *context) };
3596 trace_cb_end(desc_idx);
3597 }
3598 }
3599 did_work = true;
3600 }
3601
3602 // 4. Poll cancel reply — issue 2030. Only when a callback is registered:
3603 // a surface with no callback must find the reply still there. Taking it
3604 // retires its request from `pending_cancel`, which is what frees the
3605 // slot; before this, nothing on the C callback tier took one and the
3606 // fifth `rclc_action_cancel_goal` was refused. Issue 2034 — the request's
3607 // sequence id and the whole frame go along, so C++ can hand the reply to
3608 // the future waiting on THAT request, with its `goals_canceling` list.
3609 if let Some(cb) = cancel_response_callback
3610 && let Ok(Some((total_len, seq, goal_id))) = core.try_recv_cancel_reply_seq()
3611 {
3612 let reply = &core.result_buffer[..total_len];
3613 let code = crate::action_frame::cancel_return_code(reply)
3614 .and_then(nros_core::CancelResponseCode::from_i8)
3615 .unwrap_or(nros_core::CancelResponseCode::Rejected);
3616 let n = crate::action_frame::cancel_goals_count(reply).unwrap_or(0);
3617 // Phase 8 — the fourth distinct callback on this entry, its own pair.
3618 trace_cb_start(desc_idx);
3619 unsafe {
3620 cb(
3621 &goal_id,
3622 seq,
3623 code,
3624 n,
3625 reply.as_ptr(),
3626 reply.len(),
3627 *context,
3628 )
3629 };
3630 trace_cb_end(desc_idx);
3631 did_work = true;
3632 }
3633
3634 Ok(did_work)
3635}
3636
3637/// Monomorphized raw service-client dispatch function.
3638///
3639/// Checks `reply_ready` (set by the transport waker) before calling
3640/// `take_response_raw`. This avoids blind polling on every spin tick —
3641/// the only cost per tick is an atomic load when no reply is pending.
3642///
3643/// # Safety
3644/// `ptr` must point to a valid, aligned `ServiceClientRawArenaEntry<REPLY_BUF>`.
3645pub(crate) unsafe fn service_client_raw_try_process<const REPLY_BUF: usize>(
3646 ptr: *mut u8,
3647 _delta_us: u64,
3648 desc_idx: u8,
3649) -> Result<bool, TransportError> {
3650 use core::sync::atomic::Ordering;
3651 let entry = unsafe { &mut *(ptr as *mut ServiceClientRawArenaEntry<REPLY_BUF>) };
3652
3653 if !entry.pending {
3654 return Ok(false);
3655 }
3656 // phase-492 W4 — a client registered WITHOUT a response callback is the
3657 // polling shape (`rclc_client_take_response`): its reply belongs to that
3658 // call. Taking it here dropped it, because there was no callback to hand
3659 // it to and `pending` was cleared — so a C component that sent a request
3660 // and polled from a timer never saw a single reply while the server
3661 // answered every one. Leave it in the handle for the poll.
3662 if entry.callback.is_none() && entry.request_id_callback.is_none() {
3663 return Ok(false);
3664 }
3665
3666 // Clear the waker flag if set (consumed by this check).
3667 entry.reply_ready.store(false, Ordering::Release);
3668
3669 // Issue 2009 — dispatched only if the reply names the call in flight; a
3670 // late reply to an earlier call is dropped here, not handed to this one.
3671 let ServiceClientRawArenaEntry {
3672 handle,
3673 reply_buffer,
3674 sequences,
3675 ..
3676 } = &mut *entry;
3677 match take_client_reply(handle, reply_buffer, sequences) {
3678 Ok(Some((len, seq))) => {
3679 entry.pending = false;
3680 // Issue 2136 — the request-id shape, when registered, in place of
3681 // the plain one. The id is read before anything else takes on
3682 // this handle, so it describes this reply.
3683 if let Some(cb) = entry.request_id_callback {
3684 let mut id = RawRequestId::for_response(
3685 &nros_rmw::ClientTrait::response_info(&entry.handle),
3686 seq,
3687 );
3688 trace_cb_start(desc_idx);
3689 unsafe { cb(entry.reply_buffer.as_ptr(), len, &mut id, entry.context) };
3690 trace_cb_end(desc_idx);
3691 return Ok(true);
3692 }
3693 // Phase 8 — hooked inside the `Some(cb)` arm. A client with no
3694 // callback never reaches here (see above); the not-pending and
3695 // `Ok(None)` paths return before this point too.
3696 if let Some(cb) = entry.callback {
3697 trace_cb_start(desc_idx);
3698 unsafe { cb(entry.reply_buffer.as_ptr(), len, entry.context) };
3699 trace_cb_end(desc_idx);
3700 }
3701 Ok(true)
3702 }
3703 Ok(None) => Ok(false),
3704 Err(_) => {
3705 entry.pending = false;
3706 Err(TransportError::ServiceRequestFailed)
3707 }
3708 }
3709}
3710
3711/// RFC-0041 / Phase 239.1 — F-independent prefix of a typed service-client
3712/// callback entry. `#[repr(C)]` guarantees it is the leading member of every
3713/// [`ServiceClientCallbackEntry`] regardless of the closure type `F`, so a
3714/// `ServiceClientCallback` handle can hold a `*mut` to it and send requests
3715/// (serialize → `send_request_raw` → set `pending`) without naming `F`.
3716#[repr(C)]
3717pub struct ServiceClientSendHeader<const REPLY_BUF: usize> {
3718 pub handle: session::RmwServiceClient,
3719 pub reply_buffer: [u8; REPLY_BUF],
3720 pub pending: bool,
3721 /// Set by the transport waker when a reply arrives (mirrors the raw entry).
3722 pub reply_ready: core::sync::atomic::AtomicBool,
3723 /// Issue 2009 — the id of the call in flight (mirrors the raw entry).
3724 pub sequences: ClientSequences,
3725}
3726
3727/// Typed service-client callback entry (RFC-0041, Phase 239.1). The executor
3728/// eager-drains the reply at `spin_once` and dispatches it as a deserialized
3729/// `Svc::Reply` to the user closure — the typed analogue of
3730/// [`ServiceClientRawArenaEntry`]. The send side goes through the embedded
3731/// [`ServiceClientSendHeader`] (offset 0) via a `ServiceClientCallback` handle.
3732#[repr(C)]
3733pub(crate) struct ServiceClientCallbackEntry<Svc: RosService, F, const REPLY_BUF: usize> {
3734 pub(crate) hdr: ServiceClientSendHeader<REPLY_BUF>,
3735 pub(crate) callback: F,
3736 pub(crate) _phantom: PhantomData<Svc>,
3737}
3738
3739/// Issue 2148 — what a typed callback client hands each reply to. Two shapes:
3740/// [`ReplyOnly`] (`FnMut(&Reply)`, `create_client_with_callback`) and
3741/// [`ReplyWithInfo`] (`FnMut(&Reply, &ServiceInfo)`,
3742/// `create_client_with_callback_with_info`). One dispatch serves both; the
3743/// info is read only for the shape that asks for it.
3744pub(crate) trait ClientReplyCallback<R> {
3745 /// Run the user closure. `info` reads the reply's
3746 /// [`ServiceInfo`](nros_core::ServiceInfo) from the handle that just took
3747 /// it; it is called only by a shape that wants the info.
3748 fn on_reply(&mut self, reply: &R, info: impl FnOnce() -> nros_core::ServiceInfo);
3749}
3750
3751/// Issue 2148 — the reply-only shape (`FnMut(&Reply)`).
3752pub(crate) struct ReplyOnly<F>(pub(crate) F);
3753
3754impl<R, F: FnMut(&R)> ClientReplyCallback<R> for ReplyOnly<F> {
3755 fn on_reply(&mut self, reply: &R, _info: impl FnOnce() -> nros_core::ServiceInfo) {
3756 (self.0)(reply)
3757 }
3758}
3759
3760/// Issue 2148 — the reply-plus-info shape (`FnMut(&Reply, &ServiceInfo)`),
3761/// rclrs's `(Response, ServiceInfo)` and upstream `rmw_take_response`'s
3762/// `rmw_service_info_t`, as [`Promise::take_with_info`](super::handles::Promise::take_with_info)
3763/// reports it.
3764pub(crate) struct ReplyWithInfo<F>(pub(crate) F);
3765
3766impl<R, F: FnMut(&R, &nros_core::ServiceInfo)> ClientReplyCallback<R> for ReplyWithInfo<F> {
3767 fn on_reply(&mut self, reply: &R, info: impl FnOnce() -> nros_core::ServiceInfo) {
3768 let info = info();
3769 (self.0)(reply, &info)
3770 }
3771}
3772
3773/// Monomorphized typed service-client dispatch (RFC-0041, Phase 239.1).
3774///
3775/// Mirrors [`service_client_raw_try_process`] but deserializes the reply into
3776/// `Svc::Reply` and invokes the typed closure. Single in-flight request gated by
3777/// `hdr.pending`; the reply view is dropped before return (no escape).
3778///
3779/// # Safety
3780/// `ptr` must point to a valid, aligned `ServiceClientCallbackEntry<Svc, F, REPLY_BUF>`.
3781pub(crate) unsafe fn service_client_callback_try_process<Svc, F, const REPLY_BUF: usize>(
3782 ptr: *mut u8,
3783 _delta_us: u64,
3784 desc_idx: u8,
3785) -> Result<bool, TransportError>
3786where
3787 Svc: RosService,
3788 F: ClientReplyCallback<Svc::Reply>,
3789{
3790 use core::sync::atomic::Ordering;
3791 let entry = unsafe { &mut *(ptr as *mut ServiceClientCallbackEntry<Svc, F, REPLY_BUF>) };
3792
3793 if !entry.hdr.pending {
3794 return Ok(false);
3795 }
3796 entry.hdr.reply_ready.store(false, Ordering::Release);
3797
3798 // Issue 2009 — see `service_client_raw_try_process`.
3799 let ServiceClientSendHeader {
3800 handle,
3801 reply_buffer,
3802 sequences,
3803 ..
3804 } = &mut entry.hdr;
3805 match take_client_reply(handle, reply_buffer, sequences) {
3806 Ok(Some((len, _seq))) => {
3807 entry.hdr.pending = false;
3808 let mut reader = CdrReader::new_with_header(&entry.hdr.reply_buffer[..len])
3809 .map_err(|_| TransportError::DeserializationError)?;
3810 // Fully-qualify the `Deserialize` trait (mirrors the
3811 // `DeserializeView` call above): arena.rs imports
3812 // `DeserializeView` but not `Deserialize`, so the bare
3813 // `Svc::Reply::deserialize` only resolved when a default/std feature
3814 // happened to glob it into scope — under `rmw-cffi` (embedded) it
3815 // failed E0599. The fully-qualified path resolves under every feature.
3816 let reply = <Svc::Reply as nros_serdes::Deserialize>::deserialize(&mut reader)
3817 .map_err(|_| TransportError::DeserializationError)?;
3818 // Phase 8 — hooked AFTER both deserialization `?`s. Placing the
3819 // start any earlier would let a `DeserializationError` return
3820 // between start and end and leave an unbalanced span; here the
3821 // callback is the only thing between the pair, so the measured
3822 // interval is user code and nothing else.
3823 // Issue 2148 — the info is read from the handle that matched this
3824 // reply, before anything else can take on it (as
3825 // `Promise::take_with_info` does); only the with-info shape reads it,
3826 // and that one copy of a plain struct is inside the span.
3827 let handle = &entry.hdr.handle;
3828 trace_cb_start(desc_idx);
3829 entry
3830 .callback
3831 .on_reply(&reply, || nros_rmw::ClientTrait::response_info(handle));
3832 trace_cb_end(desc_idx);
3833 Ok(true)
3834 }
3835 Ok(None) => Ok(false),
3836 Err(_) => {
3837 entry.hdr.pending = false;
3838 Err(TransportError::ServiceRequestFailed)
3839 }
3840 }
3841}
3842
3843/// Typed action-client callback entry (RFC-0041, Phase 239.2). The executor
3844/// eager-drains the three client receives (goal-response / feedback / result)
3845/// at `spin_once` and dispatches them as deserialized `A::Feedback` / `A::Result`
3846/// to typed closures — the typed analogue of [`ActionClientRawArenaEntry`]. The
3847/// send side (`send_goal` / `get_result`) goes through the embedded `core`
3848/// (offset 0) via an `ActionClientCallback` handle.
3849#[repr(C)]
3850pub(crate) struct ActionClientCallbackEntry<
3851 A: RosAction,
3852 GRespF,
3853 FbF,
3854 ResF,
3855 const GOAL_BUF: usize,
3856 const RESULT_BUF: usize,
3857 const FEEDBACK_BUF: usize,
3858> {
3859 pub(crate) core: ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
3860 /// RFC-0041 / Phase 239.5 — the feedback stream's QoS-depth buffer. The
3861 /// callback path drains `core.feedback_subscriber` directly into this ring
3862 /// (depth > 1) or triple-buffer (depth ≤ 1), so a burst of feedbacks between
3863 /// spins is buffered/reported instead of overwriting a single slot — and the
3864 /// shared `ActionClientCore` buffers (the `Promise` path) stay untouched.
3865 pub(crate) feedback_buffer: BufferStrategy,
3866 pub(crate) on_goal_response: GRespF,
3867 pub(crate) on_feedback: FbF,
3868 pub(crate) on_result: ResF,
3869 pub(crate) _phantom: PhantomData<A>,
3870}
3871
3872/// Decode one raw feedback slot (outer header + GoalUuid at [4..20] + inner-CDR
3873/// payload at `offset`) and invoke the typed feedback closure (Phase 239.5).
3874///
3875/// `desc_idx` is threaded in purely so the Phase 8 callback-trace pair can
3876/// live at the invocation itself. This function has no entry and no slot of
3877/// its own to recover an identity from, and it returns WITHOUT firing on a
3878/// short payload or a failed field read — so bracketing its two call sites
3879/// instead would report invocations that never happened.
3880#[inline]
3881fn dispatch_feedback<A, F, const FEEDBACK_BUF: usize>(
3882 data: &[u8],
3883 on_feedback: &mut F,
3884 desc_idx: u8,
3885) where
3886 A: RosAction,
3887 F: FnMut(&nros_core::GoalUuid, &A::Feedback),
3888{
3889 // Issue 2014 — offsets from `action_frame`.
3890 let (Some(goal_id), Some(fields)) = (
3891 crate::action_frame::feedback_goal_id(data),
3892 crate::action_frame::feedback_fields(data),
3893 ) else {
3894 return;
3895 };
3896 // #175 — restore the feedback's per-message encap if a typed transport
3897 // framing (Cyclone) stripped it; the message's top-level encap is `data[0..4]`.
3898 //
3899 // Phase 8 — hooked INSIDE the `if let Some(fb)`: the early `return`
3900 // above and a `None` from `read_action_field` both leave the user
3901 // closure un-run, and neither may emit a span.
3902 if let Some(fb) = read_action_field::<A::Feedback, FEEDBACK_BUF>(data, fields) {
3903 trace_cb_start(desc_idx);
3904 on_feedback(&goal_id, &fb);
3905 trace_cb_end(desc_idx);
3906 }
3907}
3908
3909/// Monomorphized typed action-client dispatch (RFC-0041, Phase 239.2). Mirrors
3910/// [`action_client_raw_try_process`] but deserializes the feedback / result
3911/// payloads into `A::Feedback` / `A::Result` and invokes the typed closures.
3912///
3913/// # Safety
3914/// `ptr` must point to a valid, aligned `ActionClientCallbackEntry<…>`.
3915#[allow(clippy::type_complexity)]
3916pub(crate) unsafe fn action_client_callback_try_process<
3917 A,
3918 GRespF,
3919 FbF,
3920 ResF,
3921 const GOAL_BUF: usize,
3922 const RESULT_BUF: usize,
3923 const FEEDBACK_BUF: usize,
3924>(
3925 ptr: *mut u8,
3926 _delta_us: u64,
3927 desc_idx: u8,
3928) -> Result<bool, TransportError>
3929where
3930 A: RosAction,
3931 GRespF: FnMut(&nros_core::GoalUuid, bool),
3932 FbF: FnMut(&nros_core::GoalUuid, &A::Feedback),
3933 ResF: FnMut(&nros_core::GoalUuid, nros_core::GoalStatusCode, &A::Result),
3934{
3935 let entry = unsafe {
3936 &mut *(ptr as *mut ActionClientCallbackEntry<
3937 A,
3938 GRespF,
3939 FbF,
3940 ResF,
3941 GOAL_BUF,
3942 RESULT_BUF,
3943 FEEDBACK_BUF,
3944 >)
3945 };
3946 let ActionClientCallbackEntry {
3947 core,
3948 feedback_buffer,
3949 on_goal_response,
3950 on_feedback,
3951 on_result,
3952 _phantom,
3953 } = entry;
3954
3955 let mut did_work = false;
3956
3957 // 1. Goal-acceptance reply (single-outstanding → gated single buffer).
3958 // Issue 2009 — the goal is the one the reply's request was ABOUT, not the
3959 // most recently sent one.
3960 if let Ok(Some((total_len, goal_id))) = core.try_recv_send_goal_reply_for() {
3961 let accepted = crate::action_frame::goal_accepted(&core.result_buffer[..total_len]);
3962 // Phase 8 — hooked inside the `Ok(Some(..))` arm; an empty poll is
3963 // the common case and runs no user code.
3964 trace_cb_start(desc_idx);
3965 on_goal_response(&goal_id, accepted);
3966 trace_cb_end(desc_idx);
3967 did_work = true;
3968 }
3969
3970 // 2. Feedback — a stream: drain `feedback_subscriber` into the QoS-depth ring
3971 // (Phase 239.5), then dispatch each slot. Each slot holds the raw feedback
3972 // message: outer CDR header (4) + GoalUuid (16) + inner-CDR payload; see the
3973 // raw dispatcher for the layout rationale (233.6).
3974 {
3975 match feedback_buffer {
3976 BufferStrategy::Triple(tb) => {
3977 let slot = tb.write_slot();
3978 if let Ok(Some(len)) = core.feedback_subscriber.take_serialized(slot) {
3979 tb.writer_publish(len);
3980 }
3981 if let Some((data, len)) = tb.reader_acquire() {
3982 // Phase 8 — the pair lives INSIDE `dispatch_feedback`
3983 // (it can return without firing); `desc_idx` is threaded
3984 // in so the leaf can name itself.
3985 dispatch_feedback::<A, _, FEEDBACK_BUF>(&data[..len], on_feedback, desc_idx);
3986 did_work = true;
3987 }
3988 }
3989 BufferStrategy::Ring(ring) => {
3990 while let Some(slot) = ring.try_push() {
3991 match core.feedback_subscriber.take_serialized(slot) {
3992 Ok(Some(len)) => ring.commit_push(len),
3993 _ => break,
3994 }
3995 }
3996 while let Some((data, len)) = ring.try_pop() {
3997 // Phase 8 — one pair per drained slot, emitted by
3998 // `dispatch_feedback` itself, so a ring draining N
3999 // messages reports N invocations rather than one.
4000 dispatch_feedback::<A, _, FEEDBACK_BUF>(&data[..len], on_feedback, desc_idx);
4001 ring.commit_pop();
4002 did_work = true;
4003 }
4004 }
4005 }
4006 }
4007
4008 // 3. Result reply — status at byte 4, payload at [8 ..] (header + status +
4009 // align pad); see the raw dispatcher (Phase 96.1).
4010 if let Ok(Some((total_len, goal_id))) = core.try_recv_get_result_reply_for() {
4011 let reply = &core.result_buffer[..total_len];
4012 // Issue 2014 — status and payload offset from `action_frame`.
4013 if let (Some(status), Some(fields)) = (
4014 crate::action_frame::result_status(reply),
4015 crate::action_frame::result_fields(reply),
4016 ) {
4017 // #175 — restore the result's per-message encap if a typed transport
4018 // framing (Cyclone) stripped it; the reply's top-level encap is
4019 // `reply[0..4]`.
4020 if let Some(res) = read_action_field::<A::Result, RESULT_BUF>(reply, fields) {
4021 // Phase 8 — inside the `Some(res)` arm: a failed
4022 // deserialize leaves `on_result` un-run and must emit
4023 // nothing. Distinct callback, its own pair.
4024 trace_cb_start(desc_idx);
4025 on_result(&goal_id, status, &res);
4026 trace_cb_end(desc_idx);
4027 }
4028 }
4029 did_work = true;
4030 }
4031
4032 Ok(did_work)
4033}
4034
4035/// Issue 2081 — how a raw service entry calls its callback: with the request
4036/// bytes alone, or with the request's id beside them.
4037pub(crate) trait RawServiceDispatch: Copy {
4038 /// Call the callback for one taken request. `handle` is the server the
4039 /// request was just taken from, so its `request_info()` describes it.
4040 ///
4041 /// # Safety
4042 /// The pointers are the dispatch's own buffers, valid for the call.
4043 #[allow(clippy::too_many_arguments)]
4044 unsafe fn invoke(
4045 self,
4046 handle: &session::RmwServiceServer,
4047 req: *const u8,
4048 req_len: usize,
4049 resp: *mut u8,
4050 resp_cap: usize,
4051 resp_len: *mut usize,
4052 context: *mut core::ffi::c_void,
4053 ) -> bool;
4054}
4055
4056impl RawServiceDispatch for RawServiceCallback {
4057 unsafe fn invoke(
4058 self,
4059 _handle: &session::RmwServiceServer,
4060 req: *const u8,
4061 req_len: usize,
4062 resp: *mut u8,
4063 resp_cap: usize,
4064 resp_len: *mut usize,
4065 context: *mut core::ffi::c_void,
4066 ) -> bool {
4067 unsafe { self(req, req_len, resp, resp_cap, resp_len, context) }
4068 }
4069}
4070
4071impl RawServiceDispatch for RawServiceRequestIdCallback {
4072 unsafe fn invoke(
4073 self,
4074 handle: &session::RmwServiceServer,
4075 req: *const u8,
4076 req_len: usize,
4077 resp: *mut u8,
4078 resp_cap: usize,
4079 resp_len: *mut usize,
4080 context: *mut core::ffi::c_void,
4081 ) -> bool {
4082 // The take that just succeeded is the one `request_info` describes.
4083 let mut id = RawRequestId::from_service_info(&nros_rmw::ServiceTrait::request_info(handle));
4084 unsafe { self(req, req_len, &mut id, resp, resp_cap, resp_len, context) }
4085 }
4086}
4087
4088/// Monomorphized raw service dispatch function.
4089///
4090/// # Safety
4091/// `ptr` must point to a valid, aligned `SrvRawEntry<REQ_BUF, REPLY_BUF, C>`.
4092pub(crate) unsafe fn srv_raw_try_process<
4093 const REQ_BUF: usize,
4094 const REPLY_BUF: usize,
4095 C: RawServiceDispatch,
4096>(
4097 ptr: *mut u8,
4098 _delta_us: u64,
4099 desc_idx: u8,
4100) -> Result<bool, TransportError> {
4101 let entry = unsafe { &mut *(ptr as *mut SrvRawEntry<REQ_BUF, REPLY_BUF, C>) };
4102 let SrvRawEntry {
4103 handle,
4104 req_buffer,
4105 reply_buffer,
4106 callback,
4107 context,
4108 capture: _,
4109 } = entry;
4110 let buf_start = req_buffer.as_ptr() as usize;
4111 let (data_offset, data_len, seq_num) = match handle.take_request(req_buffer) {
4112 Ok(Some(request)) => {
4113 let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
4114 let len = request.data.len();
4115 let seq = request.sequence_number;
4116 (offset, len, seq)
4117 }
4118 Ok(None) => return Ok(false),
4119 // issue 1088 — a request is pending and the backend has no free
4120 // reply-correlation slot. Keep the KIND: rewriting it to
4121 // `ServiceReplyFailed` made a saturated server read as a broken one in
4122 // `SpinOnceResult`, and hid the one condition the caller can act on.
4123 Err(TransportError::WouldBlock) => return Err(TransportError::WouldBlock),
4124 Err(_) => return Err(TransportError::ServiceReplyFailed),
4125 };
4126
4127 let mut resp_len: usize = 0;
4128 // Phase 8 — hooked below the `Ok(None) => return Ok(false)` arm of the
4129 // receive, so an empty poll emits nothing. The `?` on `send_response`
4130 // sits AFTER `trace_cb_end`, so no early exit can strand an open span.
4131 trace_cb_start(desc_idx);
4132 let ok = unsafe {
4133 callback.invoke(
4134 handle,
4135 req_buffer.as_ptr().add(data_offset),
4136 data_len,
4137 reply_buffer.as_mut_ptr(),
4138 REPLY_BUF,
4139 &mut resp_len,
4140 *context,
4141 )
4142 };
4143 trace_cb_end(desc_idx);
4144 if ok && resp_len > 0 {
4145 handle
4146 .send_response(seq_num, &reply_buffer[..resp_len])
4147 .map_err(|_| TransportError::ServiceReplyFailed)?;
4148 }
4149 Ok(true)
4150}
4151
4152/// Monomorphized guard condition dispatch function.
4153///
4154/// # Safety
4155/// `ptr` must point to a valid, aligned `GuardConditionEntry<F>`.
4156pub(crate) unsafe fn guard_try_process<F>(
4157 ptr: *mut u8,
4158 _delta_us: u64,
4159 desc_idx: u8,
4160) -> Result<bool, TransportError>
4161where
4162 F: FnMut(),
4163{
4164 let entry = unsafe { &mut *(ptr as *mut GuardConditionEntry<F>) };
4165 if entry.flag.swap(false, portable_atomic::Ordering::AcqRel) {
4166 // Phase 8 — hooked inside the flag-consuming branch, the same shape
4167 // as `timer_try_process`. A guard is polled on every spin and is
4168 // almost always un-triggered, so `Ok(false)` is the common outcome
4169 // and must stay silent.
4170 trace_cb_start(desc_idx);
4171 (entry.callback)();
4172 trace_cb_end(desc_idx);
4173 Ok(true)
4174 } else {
4175 Ok(false)
4176 }
4177}
4178
4179// ============================================================================
4180// Readiness check functions
4181// ============================================================================
4182
4183/// SubInfoEntry readiness.
4184///
4185/// # Safety
4186/// `ptr` must point to a valid `SubInfoEntry<M, F>`.
4187pub(crate) unsafe fn sub_info_has_data<M, F>(ptr: *const u8) -> bool {
4188 let entry = unsafe { &*(ptr as *const SubInfoEntry<M, F>) };
4189 entry.handle.has_data()
4190}
4191
4192/// SubSafetyEntry readiness.
4193///
4194/// # Safety
4195/// `ptr` must point to a valid `SubSafetyEntry<M, F>`.
4196#[cfg(feature = "safety-e2e")]
4197pub(crate) unsafe fn sub_safety_has_data<M, F>(ptr: *const u8) -> bool {
4198 let entry = unsafe { &*(ptr as *const SubSafetyEntry<M, F>) };
4199 entry.handle.has_data()
4200}
4201
4202/// Service readiness: check `has_request()` on the service handle.
4203///
4204/// # Safety
4205/// `ptr` must point to a valid `SrvEntry<Svc, F, RQ, RP>`.
4206pub(crate) unsafe fn srv_has_data<Svc: RosService, F, const RQ: usize, const RP: usize>(
4207 ptr: *const u8,
4208) -> bool {
4209 let entry = unsafe { &*(ptr as *const SrvEntry<Svc, F, RQ, RP>) };
4210 entry.handle.has_request()
4211}
4212
4213/// Raw service readiness.
4214///
4215/// # Safety
4216/// `ptr` must point to a valid `SrvRawEntry<RQ, RP, C>`.
4217pub(crate) unsafe fn srv_raw_has_data<const RQ: usize, const RP: usize, C: RawServiceDispatch>(
4218 ptr: *const u8,
4219) -> bool {
4220 let entry = unsafe { &*(ptr as *const SrvRawEntry<RQ, RP, C>) };
4221 entry.handle.has_request()
4222}
4223
4224/// Guard condition readiness: check the atomic flag.
4225///
4226/// # Safety
4227/// `ptr` must point to a valid `GuardConditionEntry<F>`.
4228pub(crate) unsafe fn guard_has_data<F>(ptr: *const u8) -> bool {
4229 let entry = unsafe { &*(ptr as *const GuardConditionEntry<F>) };
4230 entry.flag.load(portable_atomic::Ordering::Acquire)
4231}
4232
4233/// Timers and action entries are always considered ready.
4234pub(crate) unsafe fn always_ready(_ptr: *const u8) -> bool {
4235 true
4236}
4237
4238// ============================================================================
4239// LET pre-sample functions
4240// ============================================================================
4241
4242/// Pre-sample a typed subscription with MessageInfo for LET mode.
4243///
4244/// # Safety
4245/// `ptr` must point to a valid, aligned `SubInfoEntry<M, F>`.
4246pub(crate) unsafe fn sub_info_pre_sample<M, F>(ptr: *mut u8) {
4247 let entry = unsafe { &mut *(ptr as *mut SubInfoEntry<M, F>) };
4248 // For LET, we sample only the data (MessageInfo is not preserved in the snapshot)
4249 entry.sampled_len = match entry.handle.take_serialized(entry.buffer.as_mut_slice()) {
4250 Ok(Some(len)) => len,
4251 _ => 0,
4252 };
4253}
4254
4255/// Pre-sample a safety subscription for LET mode.
4256///
4257/// # Safety
4258/// `ptr` must point to a valid, aligned `SubSafetyEntry<M, F>`.
4259#[cfg(feature = "safety-e2e")]
4260pub(crate) unsafe fn sub_safety_pre_sample<M, F>(ptr: *mut u8) {
4261 let entry = unsafe { &mut *(ptr as *mut SubSafetyEntry<M, F>) };
4262 entry.sampled_len = match entry.handle.take_serialized(entry.buffer.as_mut_slice()) {
4263 Ok(Some(len)) => len,
4264 _ => 0,
4265 };
4266}
4267
4268/// No-op pre-sample for non-subscription entries (services, timers, etc.).
4269pub(crate) unsafe fn no_pre_sample(_ptr: *mut u8) {}
4270
4271// ============================================================================
4272// Monomorphized handle operation functions
4273// ============================================================================
4274
4275/// Action server: publish feedback via arena entry.
4276///
4277/// # Safety
4278/// `ptr` must point to a valid `ActionServerArenaEntry`.
4279pub(crate) unsafe fn as_publish_feedback<
4280 A,
4281 GoalF,
4282 CancelF,
4283 const GB: usize,
4284 const RB: usize,
4285 const FB: usize,
4286 const MG: usize,
4287>(
4288 ptr: *mut u8,
4289 goal_id: &nros_core::GoalUuid,
4290 feedback: &A::Feedback,
4291) -> Result<(), NodeError>
4292where
4293 A: RosAction,
4294{
4295 let entry =
4296 unsafe { &mut *(ptr as *mut ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
4297 entry.server.publish_feedback(goal_id, feedback)
4298}
4299
4300/// Action server: complete a goal via arena entry.
4301///
4302/// # Safety
4303/// `ptr` must point to a valid `ActionServerArenaEntry`.
4304pub(crate) unsafe fn as_complete_goal<
4305 A,
4306 GoalF,
4307 CancelF,
4308 const GB: usize,
4309 const RB: usize,
4310 const FB: usize,
4311 const MG: usize,
4312>(
4313 ptr: *mut u8,
4314 goal_id: &nros_core::GoalUuid,
4315 status: nros_core::GoalStatusCode,
4316 result: A::Result,
4317) -> Result<(), NodeError>
4318where
4319 A: RosAction,
4320{
4321 let entry =
4322 unsafe { &mut *(ptr as *mut ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
4323 entry.server.complete_goal(goal_id, status, result)
4324}
4325
4326/// Action server: set goal status via arena entry.
4327///
4328/// # Safety
4329/// `ptr` must point to a valid `ActionServerArenaEntry`.
4330pub(crate) unsafe fn as_set_goal_status<
4331 A,
4332 GoalF,
4333 CancelF,
4334 const GB: usize,
4335 const RB: usize,
4336 const FB: usize,
4337 const MG: usize,
4338>(
4339 ptr: *mut u8,
4340 goal_id: &nros_core::GoalUuid,
4341 status: nros_core::GoalStatusCode,
4342) where
4343 A: RosAction,
4344{
4345 let entry =
4346 unsafe { &mut *(ptr as *mut ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
4347 entry.server.set_goal_status(goal_id, status);
4348}
4349
4350/// Action server: get active goal count via arena entry.
4351///
4352/// # Safety
4353/// `ptr` must point to a valid `ActionServerArenaEntry`.
4354pub(crate) unsafe fn as_active_goal_count<
4355 A,
4356 GoalF,
4357 CancelF,
4358 const GB: usize,
4359 const RB: usize,
4360 const FB: usize,
4361 const MG: usize,
4362>(
4363 ptr: *const u8,
4364) -> usize
4365where
4366 A: RosAction,
4367{
4368 let entry =
4369 unsafe { &*(ptr as *const ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
4370 entry.server.active_goal_count()
4371}
4372
4373/// Action server: eagerly reclaim delivered results — phase-417 W4.b.
4374///
4375/// # Safety
4376/// `ptr` must point to a valid `ActionServerArenaEntry`.
4377pub(crate) unsafe fn as_expire_goals<
4378 A,
4379 GoalF,
4380 CancelF,
4381 const GB: usize,
4382 const RB: usize,
4383 const FB: usize,
4384 const MG: usize,
4385>(
4386 ptr: *mut u8,
4387) -> usize
4388where
4389 A: RosAction,
4390{
4391 let entry =
4392 unsafe { &mut *(ptr as *mut ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
4393 entry.server.expire_goals()
4394}
4395
4396/// Action server: does this server still know `goal_id`? — phase-417 W4.b.
4397///
4398/// # Safety
4399/// `ptr` must point to a valid `ActionServerArenaEntry`.
4400pub(crate) unsafe fn as_goal_exists<
4401 A,
4402 GoalF,
4403 CancelF,
4404 const GB: usize,
4405 const RB: usize,
4406 const FB: usize,
4407 const MG: usize,
4408>(
4409 ptr: *const u8,
4410 goal_id: &nros_core::GoalUuid,
4411) -> bool
4412where
4413 A: RosAction,
4414{
4415 let entry =
4416 unsafe { &*(ptr as *const ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
4417 entry.server.goal_exists(goal_id)
4418}
4419
4420/// Raw action server: publish feedback via arena entry.
4421///
4422/// # Safety
4423/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
4424pub(crate) unsafe fn as_raw_publish_feedback<
4425 const GB: usize,
4426 const RB: usize,
4427 const FB: usize,
4428 const MG: usize,
4429>(
4430 ptr: *mut u8,
4431 goal_id: &nros_core::GoalUuid,
4432 feedback_data: *const u8,
4433 feedback_len: usize,
4434) -> Result<(), NodeError> {
4435 let entry = unsafe { &mut *(ptr as *mut ActionServerRawArenaEntry<GB, RB, FB, MG>) };
4436 let feedback_cdr = unsafe { core::slice::from_raw_parts(feedback_data, feedback_len) };
4437 entry.core.publish_feedback_raw(goal_id, feedback_cdr)
4438}
4439
4440/// Raw action server: complete a goal via arena entry.
4441///
4442/// # Safety
4443/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
4444pub(crate) unsafe fn as_raw_complete_goal<
4445 const GB: usize,
4446 const RB: usize,
4447 const FB: usize,
4448 const MG: usize,
4449>(
4450 ptr: *mut u8,
4451 goal_id: &nros_core::GoalUuid,
4452 status: nros_core::GoalStatusCode,
4453 result_data: *const u8,
4454 result_len: usize,
4455) -> Result<(), NodeError> {
4456 let entry = unsafe { &mut *(ptr as *mut ActionServerRawArenaEntry<GB, RB, FB, MG>) };
4457 let result_cdr = unsafe { core::slice::from_raw_parts(result_data, result_len) };
4458 entry.core.complete_goal_raw(goal_id, status, result_cdr)
4459}
4460
4461/// Raw action server: set goal status via arena entry.
4462///
4463/// # Safety
4464/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
4465pub(crate) unsafe fn as_raw_set_goal_status<
4466 const GB: usize,
4467 const RB: usize,
4468 const FB: usize,
4469 const MG: usize,
4470>(
4471 ptr: *mut u8,
4472 goal_id: &nros_core::GoalUuid,
4473 status: nros_core::GoalStatusCode,
4474) {
4475 let entry = unsafe { &mut *(ptr as *mut ActionServerRawArenaEntry<GB, RB, FB, MG>) };
4476 entry.core.set_goal_status(goal_id, status);
4477}
4478
4479/// Raw action server: get active goal count via arena entry.
4480///
4481/// # Safety
4482/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
4483pub(crate) unsafe fn as_raw_active_goal_count<
4484 const GB: usize,
4485 const RB: usize,
4486 const FB: usize,
4487 const MG: usize,
4488>(
4489 ptr: *const u8,
4490) -> usize {
4491 let entry = unsafe { &*(ptr as *const ActionServerRawArenaEntry<GB, RB, FB, MG>) };
4492 entry.core.active_goal_count()
4493}
4494
4495/// Raw action server: eagerly reclaim delivered results — phase-417 W4.b.
4496///
4497/// # Safety
4498/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
4499pub(crate) unsafe fn as_raw_expire_goals<
4500 const GB: usize,
4501 const RB: usize,
4502 const FB: usize,
4503 const MG: usize,
4504>(
4505 ptr: *mut u8,
4506) -> usize {
4507 let entry = unsafe { &mut *(ptr as *mut ActionServerRawArenaEntry<GB, RB, FB, MG>) };
4508 entry.core.expire_completed_results()
4509}
4510
4511/// Raw action server: does this server still know `goal_id`? — phase-417 W4.b.
4512///
4513/// # Safety
4514/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
4515pub(crate) unsafe fn as_raw_goal_exists<
4516 const GB: usize,
4517 const RB: usize,
4518 const FB: usize,
4519 const MG: usize,
4520>(
4521 ptr: *const u8,
4522 goal_id: &nros_core::GoalUuid,
4523) -> bool {
4524 let entry = unsafe { &*(ptr as *const ActionServerRawArenaEntry<GB, RB, FB, MG>) };
4525 entry.core.goal_exists(goal_id)
4526}
4527
4528/// Raw action server: iterate active goals via arena entry.
4529///
4530/// # Safety
4531/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
4532pub(crate) unsafe fn as_raw_for_each_active_goal<
4533 const GB: usize,
4534 const RB: usize,
4535 const FB: usize,
4536 const MG: usize,
4537>(
4538 ptr: *const u8,
4539 f: &mut dyn FnMut(&super::action_core::RawActiveGoal),
4540) {
4541 let entry = unsafe { &*(ptr as *const ActionServerRawArenaEntry<GB, RB, FB, MG>) };
4542 for goal in entry.core.active_goals() {
4543 f(goal);
4544 }
4545}
4546
4547/// Action server: iterate active goals via arena entry.
4548///
4549/// Calls `f` for each active goal, reconstructing `ActiveGoal<A>` from
4550/// the core's `RawActiveGoal` and the parallel typed goals vec.
4551///
4552/// # Safety
4553/// `ptr` must point to a valid `ActionServerArenaEntry`.
4554pub(crate) unsafe fn as_for_each_active_goal<
4555 A,
4556 GoalF,
4557 CancelF,
4558 const GB: usize,
4559 const RB: usize,
4560 const FB: usize,
4561 const MG: usize,
4562>(
4563 ptr: *const u8,
4564 f: &mut dyn FnMut(&ActiveGoal<A>),
4565) where
4566 A: RosAction + 'static,
4567 A::Goal: Clone,
4568{
4569 let entry =
4570 unsafe { &*(ptr as *const ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
4571 for (i, raw_goal) in entry.server.core.active_goals().iter().enumerate() {
4572 let active = ActiveGoal {
4573 goal_id: raw_goal.goal_id,
4574 status: raw_goal.status,
4575 goal: entry.server.typed_goals[i].clone(),
4576 };
4577 f(&active);
4578 }
4579}
4580
4581#[cfg(test)]
4582mod borrowed_sub_tests {
4583 use nros_core::{CdrReader, CdrWriter, DeserError};
4584
4585 use super::*;
4586
4587 // Hand-written borrowed message mirroring what codegen will emit for
4588 // `{ uint32 width; uint8[] data; }` in `borrowed` mode (Phase 229.6).
4589 struct ImageView<'a> {
4590 width: u32,
4591 data: &'a [u8],
4592 }
4593
4594 impl<'a> DeserializeView<'a> for ImageView<'a> {
4595 fn deserialize_view(reader: &mut CdrReader<'a>) -> Result<Self, DeserError> {
4596 let width = reader.read_u32()?;
4597 let data = reader.read_slice_u8()?;
4598 Ok(ImageView { width, data })
4599 }
4600 }
4601
4602 // Zero-sized borrowed-family marker (codegen emits `struct ImageViewable;`).
4603 struct ImageViewable;
4604 impl ViewableMessage for ImageViewable {
4605 type View<'a> = ImageView<'a>;
4606 const TYPE_NAME: &'static str = "test_msgs::msg::dds_::Image_";
4607 const TYPE_HASH: &'static str = "borrowed-test-hash";
4608 }
4609
4610 // The borrowed view must alias the source CDR buffer (no `heapless::Vec`
4611 // copy) — the whole point of `borrowed` mode (issue 0007).
4612 #[test]
4613 fn borrowed_view_is_zero_copy_into_source_buffer() {
4614 let payload: [u8; 64] = core::array::from_fn(|i| i as u8);
4615 let mut buf = [0u8; 128];
4616 let written = {
4617 let mut w = CdrWriter::new_with_header(&mut buf).unwrap();
4618 w.write_u32(7).unwrap();
4619 w.write_sequence_len(payload.len()).unwrap();
4620 w.write_bytes(&payload).unwrap();
4621 w.position()
4622 };
4623
4624 let mut reader = CdrReader::new_with_header(&buf[..written]).unwrap();
4625 let view = ImageView::deserialize_view(&mut reader).unwrap();
4626
4627 assert_eq!(view.width, 7);
4628 assert_eq!(view.data, &payload[..]);
4629
4630 // The borrowed slice points INTO `buf`, proving zero-copy.
4631 let buf_start = buf.as_ptr() as usize;
4632 let buf_end = buf_start + buf.len();
4633 let data_ptr = view.data.as_ptr() as usize;
4634 assert!(
4635 data_ptr >= buf_start && data_ptr < buf_end,
4636 "borrowed data must alias the source buffer (zero-copy)"
4637 );
4638 }
4639
4640 // Phase 231 Wave 3 (RFC-0038) — single-copy proof. The in-place subscription
4641 // entry carries handle + callback only; the buffered entry additionally
4642 // carries the arena `BufferStrategy` (the copy-#1 staging buffer). So the
4643 // in-place entry is strictly smaller — proving the arena buffer (and copy #1)
4644 // is gone for backends that support in-place dispatch.
4645 #[test]
4646 fn inplace_entry_drops_the_arena_buffer() {
4647 type Cb = fn(&u32);
4648 assert!(
4649 core::mem::size_of::<SubInplaceEntry<u32, Cb>>()
4650 < core::mem::size_of::<SubBufferedEntry<u32, Cb>>(),
4651 "in-place entry must be smaller than the buffered entry (no arena BufferStrategy)"
4652 );
4653 }
4654
4655 // Compile-time proof that the codegen marker + GAT + a borrowed closure
4656 // satisfy exactly the bounds the executor's borrowed dispatch
4657 // (`sub_buffered_view_try_process`) and registration require.
4658 fn assert_borrowed_sub_bounds<B, F>(_callback: F)
4659 where
4660 B: ViewableMessage + 'static,
4661 F: for<'a> FnMut(&B::View<'a>) + 'static,
4662 {
4663 }
4664
4665 #[test]
4666 fn borrowed_marker_satisfies_dispatch_bounds() {
4667 assert_borrowed_sub_bounds::<ImageViewable, _>(|view: &ImageView<'_>| {
4668 let _ = view.width;
4669 let _ = view.data.len();
4670 });
4671 }
4672}
4673
4674/// RFC-0069 / issues 0418 + 0035 — the action payload envelope carries exactly
4675/// ONE CDR header, and reading it back must not eat a data word.
4676///
4677/// The RFC lists this as the acceptance item "most likely to be skipped: its
4678/// absence is why the divergence survived a redesign in the first place." These
4679/// sit at the level the corruption happens — `read_action_field`, the consumer
4680/// half of the 0418 producer/consumer pair.
4681#[cfg(test)]
4682mod action_envelope_tests {
4683 use nros_core::{CdrWriter, Deserialize, Serialize};
4684
4685 use super::*;
4686
4687 /// Three `int32`s. The FIRST is the one that matters: give it the value 256
4688 /// and the body's leading bytes are `00 01 00 00` — byte for byte the
4689 /// little-endian CDR encapsulation header.
4690 #[derive(Debug, PartialEq)]
4691 struct Body {
4692 first: i32,
4693 rest: [i32; 2],
4694 }
4695
4696 impl Serialize for Body {
4697 fn serialize(&self, w: &mut CdrWriter) -> Result<(), nros_serdes::SerError> {
4698 w.write_i32(self.first)?;
4699 for v in &self.rest {
4700 w.write_i32(*v)?;
4701 }
4702 Ok(())
4703 }
4704 }
4705
4706 impl Deserialize for Body {
4707 fn deserialize(r: &mut CdrReader) -> Result<Self, nros_serdes::DeserError> {
4708 Ok(Body {
4709 first: r.read_i32()?,
4710 rest: [r.read_i32()?, r.read_i32()?],
4711 })
4712 }
4713 }
4714
4715 const LE_ENCAP: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
4716
4717 /// Serialize the way the 0418 PRODUCER does: fields only, no inner header.
4718 fn produce_headerless(body: &Body, buf: &mut [u8]) -> usize {
4719 let mut w = CdrWriter::new(buf);
4720 body.serialize(&mut w).expect("serialize");
4721 w.position()
4722 }
4723
4724 #[test]
4725 fn headerless_payload_round_trips() {
4726 let body = Body {
4727 first: 7,
4728 rest: [1, 1],
4729 };
4730 let mut buf = [0u8; 64];
4731 let n = produce_headerless(&body, &mut buf);
4732
4733 let got = read_action_field::<Body, 64>(&LE_ENCAP, &buf[..n])
4734 .expect("consumer must decode the headerless payload");
4735 assert_eq!(got, body, "issue 0035: the reader ate a data word");
4736 }
4737
4738 /// THE hazard 0418's own doc names and did not test.
4739 ///
4740 /// `payload_has_cdr_encap` was a VALUE sniff: a leading `int32` of 256 is
4741 /// `00 01 00 00`, indistinguishable from the LE encap header. While the
4742 /// producer wrote an inner header only Cyclone took the other branch, so it
4743 /// never mattered; once 0418 made every payload headerless the sniff was
4744 /// consulted for all of them, and this body had its first word eaten as
4745 /// framing — `first` came back as `rest[0]`. Issue #35 reached through a
4746 /// payload VALUE rather than a framing bug.
4747 #[test]
4748 fn a_leading_word_that_looks_like_an_encap_is_data_not_framing() {
4749 let body = Body {
4750 first: 256,
4751 rest: [11, 12],
4752 };
4753 let mut buf = [0u8; 64];
4754 let n = produce_headerless(&body, &mut buf);
4755
4756 assert_eq!(
4757 &buf[..4],
4758 &LE_ENCAP,
4759 "precondition: an int32 of 256 IS the LE encap byte pattern"
4760 );
4761 assert!(
4762 payload_has_cdr_encap(&buf[..n]),
4763 "precondition: the retired sniff cannot tell this from a header"
4764 );
4765
4766 let got = read_action_field::<Body, 64>(&LE_ENCAP, &buf[..n]).expect("must decode");
4767 assert_eq!(
4768 got, body,
4769 "the leading word is DATA — reading it as framing is issue #35"
4770 );
4771 }
4772
4773 /// The version break RFC-0069 accepts, asserted rather than assumed.
4774 ///
4775 /// A pre-0418 peer sends `[inner header][fields]`. The consumer no longer
4776 /// sniffs, so it splices unconditionally and the inner header is read as
4777 /// data. Old and new images are wire-incompatible on action payloads — the
4778 /// RFC's "silent version skew" risk, made explicit here so the next reader
4779 /// finds it as a decision and not as a mystery.
4780 #[test]
4781 fn a_pre_0418_double_header_payload_does_not_decode_as_itself() {
4782 let body = Body {
4783 first: 5,
4784 rest: [6, 7],
4785 };
4786 let mut inner = [0u8; 64];
4787 let n = produce_headerless(&body, &mut inner);
4788 let mut withhdr = [0u8; 68];
4789 withhdr[..4].copy_from_slice(&LE_ENCAP);
4790 withhdr[4..4 + n].copy_from_slice(&inner[..n]);
4791
4792 let got = read_action_field::<Body, 68>(&LE_ENCAP, &withhdr[..4 + n]);
4793 assert_ne!(
4794 got,
4795 Some(body),
4796 "a pre-0418 payload must NOT silently appear to decode — the wire \
4797 format changed and the skew is expected to fail"
4798 );
4799 }
4800}
4801
4802/// Issue #505 — timer overrun policy. `timer_try_process` takes the
4803/// elapsed delta directly, so a stall is expressible as a single large
4804/// delta and every case here is a pure unit test.
4805#[cfg(test)]
4806mod timer_overrun_tests {
4807 use super::*;
4808
4809 /// Drive `entry` through one `try_process` pass, crediting `delta_ms`
4810 /// worth of elapsed time (the dispatcher's unit is microseconds).
4811 fn step<F: FnMut(&TimerCallInfo)>(entry: &mut TimerEntry<F>, delta_ms: u64) -> bool {
4812 // SAFETY: `entry` is a live, aligned `TimerEntry<F>`.
4813 unsafe {
4814 timer_try_process::<F>(
4815 (entry as *mut TimerEntry<F>).cast::<u8>(),
4816 delta_ms * 1000,
4817 0,
4818 )
4819 .unwrap()
4820 }
4821 }
4822
4823 /// Same, in microseconds — the dispatcher's native unit.
4824 fn step_us<F: FnMut(&TimerCallInfo)>(entry: &mut TimerEntry<F>, delta_us: u64) -> bool {
4825 // SAFETY: `entry` is a live, aligned `TimerEntry<F>`.
4826 unsafe {
4827 timer_try_process::<F>((entry as *mut TimerEntry<F>).cast::<u8>(), delta_us, 0).unwrap()
4828 }
4829 }
4830
4831 fn periodic(
4832 period_us: u64,
4833 policy: TimerOverrunPolicy,
4834 ) -> TimerEntry<impl FnMut(&TimerCallInfo)> {
4835 TimerEntry {
4836 period_us,
4837 elapsed_us: 0,
4838 overruns: 0,
4839 overruns_reported: 0,
4840 oneshot: false,
4841 fired: false,
4842 cancelled: false,
4843 overrun_policy: policy,
4844 clock_source: TimerClockSource::Steady,
4845 last_clock_ns: 0,
4846 callback: |_: &TimerCallInfo| {},
4847 }
4848 }
4849
4850 #[test]
4851 fn skip_is_the_default_policy() {
4852 assert_eq!(TimerOverrunPolicy::default(), TimerOverrunPolicy::Skip);
4853 }
4854
4855 #[test]
4856 fn skip_coalesces_a_stall_into_one_activation() {
4857 // The observed failure: a ~200 ms preemption of a 10 ms timer
4858 // replayed as a burst of activations. Under Skip the backlog
4859 // costs exactly one activation plus a counter bump.
4860 let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
4861 assert!(step(&mut t, 205));
4862 assert_eq!(t.overruns, 19, "205 ms of a 10 ms period = 20 due, 1 fired");
4863 // Nothing is owed: the next pass waits out the remaining period.
4864 assert!(!step(&mut t, 4));
4865 assert!(step(&mut t, 1));
4866 }
4867
4868 #[test]
4869 fn skip_preserves_the_phase_grid() {
4870 // 205 ms leaves a 5 ms remainder; keeping it means the next fire
4871 // lands 5 ms later, back on the original 10 ms grid, instead of
4872 // re-anchoring to the moment the stall ended.
4873 let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
4874 assert!(step(&mut t, 205));
4875 assert_eq!(t.elapsed_us, 5_000);
4876 }
4877
4878 #[test]
4879 fn catchup_replays_every_missed_period() {
4880 let mut t = periodic(10_000, TimerOverrunPolicy::CatchUp);
4881 assert!(step(&mut t, 205));
4882 // One activation per pass until the backlog drains, with no
4883 // further time credited.
4884 let mut replays = 0;
4885 while step(&mut t, 0) {
4886 replays += 1;
4887 assert!(replays < 64, "backlog must drain");
4888 }
4889 assert_eq!(replays, 19);
4890 assert_eq!(t.overruns, 0, "CatchUp loses nothing, so counts nothing");
4891 }
4892
4893 #[test]
4894 fn on_time_ticks_never_count_as_overruns() {
4895 let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
4896 for _ in 0..100 {
4897 assert!(step(&mut t, 10));
4898 }
4899 assert_eq!(t.overruns, 0);
4900 assert_eq!(t.elapsed_us, 0);
4901 }
4902
4903 #[test]
4904 fn a_single_late_period_is_one_overrun() {
4905 // Exactly two periods due = one fired, one dropped.
4906 let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
4907 assert!(step(&mut t, 20));
4908 assert_eq!(t.overruns, 1);
4909 }
4910
4911 #[test]
4912 fn oneshot_ignores_the_policy() {
4913 let mut t = TimerEntry {
4914 period_us: 10_000,
4915 elapsed_us: 0,
4916 overruns: 0,
4917 overruns_reported: 0,
4918 oneshot: true,
4919 fired: false,
4920 cancelled: false,
4921 overrun_policy: TimerOverrunPolicy::Skip,
4922 clock_source: TimerClockSource::Steady,
4923 last_clock_ns: 0,
4924 callback: |_: &TimerCallInfo| {},
4925 };
4926 assert!(step(&mut t, 205));
4927 assert_eq!(t.overruns, 0);
4928 assert!(!step(&mut t, 205), "a fired one-shot stays fired");
4929 }
4930
4931 #[test]
4932 fn sub_millisecond_periods_are_expressible() {
4933 // Issue #505 — the dispatcher's unit is microseconds, so a
4934 // 500 us period is a real period rather than the "fires every
4935 // spin" degenerate case a millisecond field forced it into.
4936 let mut t = periodic(500, TimerOverrunPolicy::Skip);
4937 // 200 us of credit is not enough...
4938 assert!(!step_us(&mut t, 200));
4939 // ...600 us total is.
4940 assert!(step_us(&mut t, 400));
4941 assert_eq!(t.overruns, 0);
4942 assert_eq!(t.elapsed_us, 100, "phase remainder survives");
4943 }
4944
4945 #[test]
4946 fn a_stalled_sub_millisecond_timer_counts_whole_periods() {
4947 let mut t = periodic(500, TimerOverrunPolicy::Skip);
4948 assert!(step_us(&mut t, 5_000)); // 10 periods due
4949 assert_eq!(t.overruns, 9);
4950 }
4951
4952 #[test]
4953 fn zero_period_does_not_divide_by_zero() {
4954 // Degenerate but reachable: `TimerDuration::from_millis(0)` or a
4955 // sub-millisecond period truncated to 0 by `as_millis`.
4956 let mut t = periodic(0, TimerOverrunPolicy::Skip);
4957 assert!(step(&mut t, 5));
4958 assert_eq!(t.overruns, 0);
4959 assert_eq!(t.elapsed_us, 0);
4960 }
4961
4962 #[test]
4963 fn overrun_count_saturates_instead_of_wrapping() {
4964 let mut t = periodic(1_000, TimerOverrunPolicy::Skip);
4965 t.overruns = u32::MAX - 1;
4966 assert!(step(&mut t, 1_000));
4967 assert_eq!(t.overruns, u32::MAX);
4968 }
4969}
4970
4971/// issue 1437 / phase-444 — the layout invariant the three
4972/// `Executor::*_handle` accessors read the arena through.
4973///
4974/// Those accessors resolve an arena entry to its transport handle from
4975/// `(EntryKind, offset)` ALONE, with no knowledge of the shape the entry was
4976/// monomorphised into — 12 subscription shapes, 2 service-server, 2
4977/// service-client, differing in buffer sizes, message types and callback
4978/// types. That works because every one of them is `#[repr(C)]` with its
4979/// handle as the FIRST field, so `arena_base + offset` is the handle pointer
4980/// for all of them.
4981///
4982/// It is an invariant nothing else states and a compiler would not catch:
4983/// prepending a field to any entry below leaves every caller compiling and
4984/// makes the accessors read the new field AS a handle. So it is measured
4985/// here, per shape, rather than asserted in a comment beside the cast.
4986#[cfg(test)]
4987mod handle_offset_tests {
4988 use super::*;
4989
4990 /// One instantiation per SHAPE. The const-generic buffer sizes are
4991 /// deliberately not the defaults — a shape whose handle offset depended
4992 /// on them would be exactly the bug this exists to catch, and equal
4993 /// numbers everywhere would hide it.
4994 macro_rules! handle_at_zero {
4995 ($($name:literal => $ty:ty),* $(,)?) => {
4996 $(assert_eq!(
4997 core::mem::offset_of!($ty, handle),
4998 0,
4999 concat!($name, " must begin with its handle — \
5000 Executor::*_handle resolves an arena entry by \
5001 (kind, offset) and casts arena_base + offset \
5002 straight to the handle type"),
5003 );)*
5004 };
5005 }
5006
5007 #[test]
5008 fn every_arena_entry_begins_with_its_handle() {
5009 type Cb = fn();
5010 handle_at_zero! {
5011 "SubInfoEntry" => SubInfoEntry<u8, Cb>,
5012 "SubBufferedEntry" => SubBufferedEntry<u8, Cb>,
5013 "SubInplaceEntry" => SubInplaceEntry<u8, Cb>,
5014 "SubBufferedRawEntry" => SubBufferedRawEntry<Cb>,
5015 "SubBufferedViewEntry" => SubBufferedViewEntry<u8, Cb>,
5016 "SubBufferedRawInfoEntry" => SubBufferedRawInfoEntry<Cb>,
5017 "SubBufferedRawInfoCEntry" => SubBufferedRawInfoCEntry,
5018 "SubBufferedRawCEntry" => SubBufferedRawCEntry,
5019 "SubBufferedTypedCEntry" => SubBufferedTypedCEntry,
5020 "SrvRawEntry" => SrvRawEntry<43, 47>,
5021 "ServiceClientRawArenaEntry" => ServiceClientRawArenaEntry<53>,
5022 "ServiceClientSendHeader" => ServiceClientSendHeader<59>,
5023 }
5024 }
5025}
5026
5027/// Issue 1632 — the Rust arena's drop log prints the size the sample needed
5028/// beside the buffer, as the C++ one has since issue 1612.
5029///
5030/// The sink keeps what was logged, so the test reads what a human would see.
5031/// The sink is process-global, so the cells serialise; the drop counter is
5032/// never touched, since other cells advance it too (issue 2082).
5033#[cfg(test)]
5034mod dropped_take_log_tests {
5035 use super::{drop_is_reported, log_dropped_take};
5036 use nros_rmw::TransportError;
5037
5038 struct CapturingSink {
5039 lines: std::sync::Mutex<std::vec::Vec<std::string::String>>,
5040 }
5041
5042 impl nros_log::LogSink for CapturingSink {
5043 fn log(&self, record: &nros_log::Record<'_>) {
5044 if record.message.contains("subscription take DROPPED") {
5045 self.lines
5046 .lock()
5047 .unwrap_or_else(std::sync::PoisonError::into_inner)
5048 .push(record.message.into());
5049 }
5050 }
5051 }
5052
5053 static CAPTURE: std::sync::OnceLock<&'static CapturingSink> = std::sync::OnceLock::new();
5054 static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
5055
5056 /// The line this call logged. The sink is process-global, so another
5057 /// cell's drop may land beside it; `cap` picks this call's line out, and
5058 /// each call site passes a buffer size no other cell uses (issue 2082).
5059 fn logged_one(err: TransportError, cap: usize, recorded: Option<usize>) -> std::string::String {
5060 let _g = SERIAL
5061 .lock()
5062 .unwrap_or_else(std::sync::PoisonError::into_inner);
5063 let sink = CAPTURE.get_or_init(|| {
5064 let sink: &'static CapturingSink =
5065 std::boxed::Box::leak(std::boxed::Box::new(CapturingSink {
5066 lines: std::sync::Mutex::new(std::vec::Vec::new()),
5067 }));
5068 assert!(nros_log::add_sink(sink), "no sink slot left");
5069 sink
5070 });
5071 let marker = std::format!("{cap}-byte buffer");
5072 let mine = || -> std::vec::Vec<std::string::String> {
5073 sink.lines
5074 .lock()
5075 .unwrap_or_else(std::sync::PoisonError::into_inner)
5076 .iter()
5077 .filter(|l| l.contains(&marker))
5078 .cloned()
5079 .collect()
5080 };
5081 sink.lines
5082 .lock()
5083 .unwrap_or_else(std::sync::PoisonError::into_inner)
5084 .clear();
5085 // A record nros_log lost to its hold queue (issue 2000) is raised
5086 // again. Always as drop number one, never through DROPPED_TAKES.
5087 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5088 let seen = nros_log::early::emit_until_delivered(
5089 || log_dropped_take(&err, cap, recorded, 0),
5090 || !mine().is_empty(),
5091 || {
5092 std::thread::yield_now();
5093 std::time::Instant::now() < deadline
5094 },
5095 );
5096 let lines = mine();
5097 assert!(seen, "no line for a {cap}-byte buffer: {lines:?}");
5098 // A re-raise can deliver the line twice; every copy is the same drop.
5099 assert!(
5100 lines.iter().all(|l| *l == lines[0]),
5101 "the copies disagree: {lines:?}"
5102 );
5103 lines[0].clone()
5104 }
5105
5106 #[test]
5107 fn the_drop_log_names_the_sample_size_beside_the_buffer() {
5108 let line = logged_one(TransportError::BufferTooSmall, 4093, Some(1000));
5109 assert!(line.contains("1000-byte sample"), "no sample size: {line}");
5110 assert!(line.contains("4093-byte buffer"), "no buffer size: {line}");
5111 // nros_log's buffer is all-or-nothing; the issue number is the tail.
5112 assert!(line.contains("(issue 0757)"), "truncated: {line}");
5113 }
5114
5115 #[test]
5116 fn an_unknown_size_and_a_staging_refusal_print_as_unknown() {
5117 let unknown = logged_one(TransportError::BufferTooSmall, 4091, None);
5118 assert!(unknown.contains("?-byte sample"), "{unknown}");
5119 // `MessageTooLarge` is the backend's staging buffer, refused before a
5120 // take measured anything: an earlier take's size must not be printed.
5121 let staging = logged_one(TransportError::MessageTooLarge, 4087, Some(1000));
5122 assert!(staging.contains("?-byte sample"), "{staging}");
5123 assert!(!staging.contains("1000"), "{staging}");
5124 }
5125
5126 #[test]
5127 fn the_first_drop_and_every_64th_are_reported() {
5128 assert!(drop_is_reported(0));
5129 assert!(!drop_is_reported(1));
5130 assert!(!drop_is_reported(63));
5131 assert!(drop_is_reported(64));
5132 assert!(!drop_is_reported(65));
5133 assert!(drop_is_reported(128));
5134 }
5135}