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