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