Skip to main content

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}