Skip to main content

nros_node/executor/
arena.rs

1//! Callback arena infrastructure (all pub(crate)).
2
3use core::marker::PhantomData;
4
5use nros_core::{
6    CdrReader, DeserializeView, MessageInfo, RawMessageInfo, RosAction, RosMessage, RosService,
7    ViewableMessage,
8};
9use nros_rmw::{ServiceTrait, Subscription, TransportError};
10
11use super::{
12    action_core::{ActionClientCore, ActionServerCore},
13    handles::{ActionServer, ActiveGoal},
14    spsc_ring::SpscRing,
15    triple_buffer::TripleBuffer,
16    types::{
17        InvocationMode, NodeError, RawAcceptedCallback, RawCancelCallback, RawFeedbackCallback,
18        RawGoalCallback, RawGoalResponseCallback, RawResponseCallback, RawResultCallback,
19        RawServiceCallback, RawSubscriptionCallback, RawSubscriptionInfoCallback,
20    },
21};
22use crate::session;
23
24// ============================================================================
25// Callback metadata
26// ============================================================================
27
28// ============================================================================
29// Phase 8 — callback dispatch hooks (paired stubs)
30// ============================================================================
31//
32// `docs/design/callback_tracing.rst`. The hooks bracket the LEAF callback
33// invocation rather than the `try_process` boundary, because that boundary
34// gets both granularity and truth wrong: one `try_process` for an action
35// server fires up to three distinct user callbacks, a ring-buffered
36// subscription fires the user callback once per queued message, and
37// `Ok(false)` — "ran, fired nothing", the common outcome for a timer that is
38// not yet due — would be recorded as an invocation that never happened.
39//
40// Paired stubs (the `entry_tiers.rs` idiom) so the call sites read IDENTICALLY
41// whether or not the feature is on; the `#[cfg]` lives here, once, instead of
42// wrapping every hook site.
43
44/// Emit `callback_start(handle)` immediately before invoking a user callback.
45#[cfg(feature = "trace-callbacks")]
46#[inline]
47fn trace_cb_start(desc_idx: u8) {
48    super::callback_trace::start(desc_idx);
49}
50
51#[cfg(not(feature = "trace-callbacks"))]
52#[inline]
53fn trace_cb_start(_desc_idx: u8) {}
54
55/// Emit `callback_end(handle)` immediately after a user callback returns.
56#[cfg(feature = "trace-callbacks")]
57#[inline]
58fn trace_cb_end(desc_idx: u8) {
59    super::callback_trace::end(desc_idx);
60}
61
62#[cfg(not(feature = "trace-callbacks"))]
63#[inline]
64fn trace_cb_end(_desc_idx: u8) {}
65
66/// Kind of registered callback entry.
67#[derive(Clone, Copy)]
68pub(crate) enum EntryKind {
69    Subscription,
70    Service,
71    ServiceClient,
72    Timer,
73    ActionServer,
74    ActionClient,
75    GuardCondition,
76}
77
78/// What a registration site calls the callback it is registering.
79///
80/// Phase 8 (`docs/design/callback_tracing.rst`) — the payload of the
81/// `nros_callback_register` event, resolved to a string only inside the
82/// feature-gated emitter so a build without `trace-callbacks` pays nothing
83/// for the two synthesised forms.
84///
85/// The three variants exist because the executor does NOT have a name for
86/// every entry kind:
87///
88/// * subscriptions / services / actions carry a topic or service name;
89/// * a timer carries only a period — so the period IS its identity;
90/// * a guard condition, and an arena subscription attached to an already-open
91///   `RmwSubscriber`, carry nothing at all — the slot index is the only
92///   thing that distinguishes one from another.
93///
94/// Naming them here rather than at the 25 emplace sites keeps the synthesis
95/// rules in one place; a new registration site that gets the name wrong is
96/// then a wrong ARGUMENT, not a second convention.
97// Only the feature-gated emitter READS these, so a build with the feature off
98// constructs them and never matches on them. That is the intended shape (the
99// call sites must read identically either way), not dead code to delete.
100#[cfg_attr(not(feature = "trace-callbacks"), allow(dead_code))]
101#[derive(Clone, Copy)]
102pub(crate) enum TraceName<'a> {
103    /// A real name the caller already has: topic, service, or action name.
104    Text(&'a str),
105    /// A timer, rendered `timer@<period_us>us`.
106    TimerPeriod(u64),
107    /// An entry with no name anywhere in the executor, rendered
108    /// `<label>#<slot>` — `guard#3`, `sub#7`. The slot index is exactly what
109    /// the runtime `callback_start` / `callback_end` events key on, so this
110    /// label is not a placeholder: it is the identity, spelled out.
111    Slot(&'static str, usize),
112}
113
114/// Metadata for a type-erased callback stored in the arena.
115///
116/// Each entry records where the concrete entry struct lives in the arena
117/// and carries monomorphized function pointers for dispatch and cleanup.
118#[derive(Clone, Copy)]
119pub(crate) struct CallbackMeta {
120    /// Byte offset into the arena where the concrete entry starts.
121    pub(crate) offset: usize,
122    /// What kind of entry this is (for `SpinOnceResult` counters).
123    pub(crate) kind: EntryKind,
124    /// Monomorphized dispatch: tries to receive and process one message/request.
125    /// Returns `Ok(true)` if work was done, `Ok(false)` if nothing available.
126    /// The `u64` parameter is `delta_us` (used by timer entries, ignored by others).
127    ///
128    /// The trailing `u8` is the entry's SLOT INDEX (phase 8,
129    /// `docs/design/callback_tracing.rst`). Inside a leaf the only identity
130    /// otherwise in scope is the address `arena_base + offset` — unique and
131    /// stable, but an address, and `arena_base` is not reachable from the
132    /// leaf. Threading the index in is the design's preferred fix over
133    /// publishing an `offset -> index` map: it needs no side table, it cannot
134    /// go stale if the arena moves, and attribution is exact at the point of
135    /// use. All three producers already have the index (the drain loop, the
136    /// trigger-fail timer sweep, and `os_priority::WorkItem`).
137    ///
138    /// Passed unconditionally rather than behind `feature = "trace-callbacks"`:
139    /// a cfg-dependent function-pointer signature would have to be threaded
140    /// through 21 leaf definitions as a macro. The cost when tracing is off is
141    /// one constant register argument per dispatch that the leaf ignores.
142    pub(crate) try_process: unsafe fn(*mut u8, u64, u8) -> Result<bool, TransportError>,
143    /// Monomorphized readiness check: returns true if the entry has data.
144    pub(crate) has_data: unsafe fn(*const u8) -> bool,
145    /// Monomorphized LET pre-sample: reads data from transport into the entry's
146    /// buffer without invoking the callback. No-op for non-subscription entries.
147    pub(crate) pre_sample: unsafe fn(*mut u8),
148    /// Per-callback invocation mode.
149    pub(crate) invocation: InvocationMode,
150    /// Monomorphized drop: runs destructors on the concrete entry.
151    pub(crate) drop_fn: unsafe fn(*mut u8),
152}
153
154// ============================================================================
155// Concrete entry types
156// ============================================================================
157
158/// Concrete subscription entry stored in the arena (with MessageInfo).
159#[repr(C)]
160pub(crate) struct SubInfoEntry<M, F, const RX_BUF: usize> {
161    pub(crate) handle: session::RmwSubscriber,
162    pub(crate) buffer: [u8; RX_BUF],
163    /// Length of pre-sampled LET data (0 = not sampled).
164    pub(crate) sampled_len: usize,
165    pub(crate) callback: F,
166    pub(crate) _phantom: PhantomData<M>,
167}
168
169/// Concrete subscription entry stored in the arena (with safety validation).
170#[cfg(feature = "safety-e2e")]
171#[repr(C)]
172pub(crate) struct SubSafetyEntry<M, F, const RX_BUF: usize> {
173    pub(crate) handle: session::RmwSubscriber,
174    pub(crate) buffer: [u8; RX_BUF],
175    /// Length of pre-sampled LET data (0 = not sampled).
176    pub(crate) sampled_len: usize,
177    pub(crate) callback: F,
178    pub(crate) _phantom: PhantomData<M>,
179}
180
181/// Concrete service entry stored in the arena.
182#[repr(C)]
183pub(crate) struct SrvEntry<Svc: RosService, F, const REQ_BUF: usize, const REPLY_BUF: usize> {
184    pub(crate) handle: session::RmwServiceServer,
185    pub(crate) req_buffer: [u8; REQ_BUF],
186    pub(crate) reply_buffer: [u8; REPLY_BUF],
187    pub(crate) callback: F,
188    pub(crate) _phantom: PhantomData<Svc>,
189}
190
191/// What a periodic timer does with periods it missed while its executor
192/// was blocked (issue #505).
193///
194/// A tier that loses the CPU for several periods — a long-running
195/// callback, or preemption by a higher-priority band — resumes with
196/// accumulated elapsed time worth more than one period.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum TimerOverrunPolicy {
199    /// Coalesce the backlog: fire ONCE and drop the missed periods,
200    /// counting them in `overruns`. Phase is preserved (the sub-period
201    /// remainder carries over), so activations stay aligned to the
202    /// original cadence grid instead of drifting by each stall.
203    ///
204    /// The default, matching rclcpp (which advances the next call time
205    /// past "now") and Zephyr's `k_timer` (which coalesces expiries and
206    /// reports a count).
207    #[default]
208    Skip,
209    /// Replay every missed period, one activation per `try_process`
210    /// pass, until the backlog drains. Correct for timers that count or
211    /// accumulate, where each activation is a unit of work that must
212    /// not be lost. A replay burst runs back-to-back at dispatch speed,
213    /// NOT at the declared cadence — control loops usually want `Skip`.
214    CatchUp,
215}
216
217/// Which clock advances a timer (phase-425 W4, RFC-0075-adjacent: this is the
218/// distinction rclcpp draws between `create_wall_timer` and `create_timer`).
219///
220/// The default is [`Steady`](Self::Steady) and it is the only source that costs
221/// nothing: it consumes the spin delta the executor already measured. The other
222/// two READ a clock on every poll of the timer, which is one relaxed atomic load
223/// plus whatever the platform's time call costs.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
225#[repr(u8)]
226pub enum TimerClockSource {
227    /// The executor's monotonic spin delta — a WALL timer in rclcpp's sense.
228    /// Unaffected by `/clock`: a paused simulator does not pause it, which is
229    /// exactly what a watchdog or a transport keep-alive wants.
230    #[default]
231    Steady = 0,
232    /// `ClockType::RosTime`: simulated time when a `/clock` source is active,
233    /// and system time when none is (the same fallback `rclcpp::Clock` has, so
234    /// a node built for simulation still runs standalone).
235    Ros = 1,
236    /// `ClockType::SystemTime`: the wall clock, NTP steps and all. Present
237    /// because rclrs offers it (`TimerClock::SystemTime`); a timer that must
238    /// not jump wants `Steady`.
239    System = 2,
240}
241
242impl TimerClockSource {
243    /// The `nros_core` clock this source reads, or `None` for [`Steady`](Self::Steady),
244    /// which reads no clock at all.
245    pub(crate) fn clock(self) -> Option<nros_core::clock::Clock> {
246        match self {
247            TimerClockSource::Steady => None,
248            TimerClockSource::Ros => Some(nros_core::clock::Clock::ros_time()),
249            TimerClockSource::System => Some(nros_core::clock::Clock::system()),
250        }
251    }
252
253    /// The clock's current reading in nanoseconds, or 0 for [`Steady`](Self::Steady).
254    pub(crate) fn now_ns(self) -> i64 {
255        self.clock().map(|c| c.now().to_nanos()).unwrap_or(0)
256    }
257}
258
259/// Concrete timer entry stored in the arena.
260///
261/// The first fields (up to `callback`) share layout with [`TimerHeader`],
262/// enabling type-erased access to timer state (cancel, reset, period query).
263#[repr(C)]
264pub(crate) struct TimerEntry<F> {
265    pub(crate) period_us: u64,
266    pub(crate) elapsed_us: u64,
267    /// Periods dropped by [`TimerOverrunPolicy::Skip`] (issue #505).
268    /// Saturating; never cleared by the dispatcher.
269    pub(crate) overruns: u32,
270    /// `overruns` as of the last `timer-overrun-runtime` monitor check,
271    /// so the rule reports newly dropped activations rather than the
272    /// running total. Lives here rather than in a table parallel to
273    /// `entries`, whose capacity is a runtime slice length.
274    pub(crate) overruns_reported: u32,
275    pub(crate) oneshot: bool,
276    pub(crate) fired: bool,
277    pub(crate) cancelled: bool,
278    pub(crate) overrun_policy: TimerOverrunPolicy,
279    /// Which clock advances `elapsed_us` (phase-425 W4). `Steady` consumes the
280    /// executor's spin delta; the others read their clock in `try_process`.
281    pub(crate) clock_source: TimerClockSource,
282    /// Last reading of `clock_source`, in nanoseconds. Meaningless — and never
283    /// read — while `clock_source` is `Steady`.
284    pub(crate) last_clock_ns: i64,
285    pub(crate) callback: F,
286}
287
288/// Type-erased header for timer entries.
289///
290/// Shares layout with the initial fields of `TimerEntry<F>` (both `#[repr(C)]`),
291/// so a `*mut TimerHeader` can safely read/write the timer state fields
292/// regardless of the concrete closure type `F`.
293#[repr(C)]
294pub(crate) struct TimerHeader {
295    pub(crate) period_us: u64,
296    pub(crate) elapsed_us: u64,
297    pub(crate) overruns: u32,
298    pub(crate) overruns_reported: u32,
299    pub(crate) oneshot: bool,
300    pub(crate) fired: bool,
301    pub(crate) cancelled: bool,
302    pub(crate) overrun_policy: TimerOverrunPolicy,
303    pub(crate) clock_source: TimerClockSource,
304    pub(crate) last_clock_ns: i64,
305}
306
307/// Concrete action server entry stored in the arena.
308#[repr(C)]
309pub(crate) struct ActionServerArenaEntry<
310    A: RosAction,
311    GoalF,
312    CancelF,
313    const GOAL_BUF: usize,
314    const RESULT_BUF: usize,
315    const FEEDBACK_BUF: usize,
316    const MAX_GOALS: usize,
317> {
318    pub(crate) server: ActionServer<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
319    pub(crate) goal_callback: GoalF,
320    pub(crate) cancel_callback: CancelF,
321}
322
323/// Concrete action server entry for raw (untyped) callbacks.
324///
325/// Uses [`ActionServerCore`] directly (no typed `ActionServer<A>` wrapper).
326#[repr(C)]
327pub(crate) struct ActionServerRawArenaEntry<
328    const GOAL_BUF: usize,
329    const RESULT_BUF: usize,
330    const FEEDBACK_BUF: usize,
331    const MAX_GOALS: usize,
332> {
333    pub(crate) core: ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
334    pub(crate) goal_callback: RawGoalCallback,
335    pub(crate) cancel_callback: RawCancelCallback,
336    /// Optional hook fired after the accept reply has been sent. Used by the
337    /// C API so user-supplied long-running `accepted_callback`s run *after*
338    /// the client has observed the accept instead of blocking the reply.
339    pub(crate) accepted_callback: Option<RawAcceptedCallback>,
340    pub(crate) context: *mut core::ffi::c_void,
341}
342
343/// Concrete action client entry for raw (untyped) async callbacks.
344///
345/// Contains the `ActionClientCore` plus callback function pointers for
346/// goal response, feedback, and result. The executor polls the core's
347/// non-blocking methods during `spin_once` and invokes the callbacks.
348#[repr(C)]
349pub(crate) struct ActionClientRawArenaEntry<
350    const GOAL_BUF: usize,
351    const RESULT_BUF: usize,
352    const FEEDBACK_BUF: usize,
353> {
354    pub(crate) core: ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
355    pub(crate) goal_response_callback: Option<RawGoalResponseCallback>,
356    pub(crate) feedback_callback: Option<RawFeedbackCallback>,
357    pub(crate) result_callback: Option<RawResultCallback>,
358    pub(crate) context: *mut core::ffi::c_void,
359}
360
361/// Concrete service-client entry for raw (untyped) async polling.
362///
363/// Holds the `RmwServiceClient` plus a single-shot reply buffer and a
364/// callback fn pointer. The executor dispatches via
365/// `service_client_raw_try_process` which checks `reply_ready` (set by
366/// the transport waker) before calling `take_response_raw`. This
367/// avoids busy-polling `get_check` on every spin tick.
368///
369/// Single in-flight request per entry: a second `send_request` while
370/// `pending` is still `true` is the user's responsibility to avoid (the
371/// C wrapper checks at the call site).
372#[repr(C)]
373pub struct ServiceClientRawArenaEntry<const REPLY_BUF: usize> {
374    pub handle: session::RmwServiceClient,
375    pub reply_buffer: [u8; REPLY_BUF],
376    pub pending: bool,
377    /// Set by the transport waker when a reply arrives for this slot.
378    /// Checked by `try_process` to avoid blind polling.
379    pub reply_ready: core::sync::atomic::AtomicBool,
380    pub callback: Option<RawResponseCallback>,
381    pub context: *mut core::ffi::c_void,
382}
383
384/// Concrete service entry for raw (untyped) callbacks.
385#[repr(C)]
386pub(crate) struct SrvRawEntry<const REQ_BUF: usize, const REPLY_BUF: usize> {
387    pub(crate) handle: session::RmwServiceServer,
388    pub(crate) req_buffer: [u8; REQ_BUF],
389    pub(crate) reply_buffer: [u8; REPLY_BUF],
390    pub(crate) callback: RawServiceCallback,
391    pub(crate) context: *mut core::ffi::c_void,
392}
393
394/// Concrete guard condition entry stored in the arena.
395#[repr(C)]
396pub(crate) struct GuardConditionEntry<F> {
397    pub(crate) flag: portable_atomic::AtomicBool,
398    pub(crate) callback: F,
399}
400
401// ============================================================================
402// QoS-driven buffered subscription entries (Phase 73)
403// ============================================================================
404
405/// Buffer strategy selected by QoS depth at subscription registration time.
406///
407/// The buffer data lives in a trailing region immediately after the
408/// `SubBufferedEntry` struct in the arena.
409pub(crate) enum BufferStrategy {
410    /// `KEEP_LAST(1)`: 3 slots, latest-value semantics, writer never blocks.
411    Triple(TripleBuffer),
412    /// `KEEP_LAST(N)` where N > 1: N+1 slots, FIFO ordering, bounded drops.
413    Ring(SpscRing),
414}
415
416impl BufferStrategy {
417    /// Check if new data is available.
418    pub(crate) fn has_data(&self) -> bool {
419        match self {
420            BufferStrategy::Triple(tb) => tb.has_data(),
421            BufferStrategy::Ring(ring) => ring.has_data(),
422        }
423    }
424}
425
426/// Compute the number of buffer slots and trailing region size for a given
427/// QoS depth and per-slot buffer size.
428///
429/// Returns `(slot_count, trailing_bytes)`.
430pub(crate) fn buffered_region_size(depth: u32, slot_size: usize) -> (usize, usize) {
431    if depth <= 1 {
432        // Triple buffer: 3 fixed slots
433        (
434            TripleBuffer::SLOT_COUNT,
435            TripleBuffer::SLOT_COUNT * slot_size,
436        )
437    } else {
438        let d = depth as usize;
439        (SpscRing::slot_count(d), SpscRing::region_size(d, slot_size))
440    }
441}
442
443/// phase-408 W5b — a SINGLE flat payload slot living in the arena's trailing
444/// region, sized at REGISTRATION from the caller's `rx_buffer_hint` instead of
445/// baked into the entry as `[u8; RX_BUF]`.
446///
447/// Why this and not [`BufferStrategy`], which the plain C raw path uses: a
448/// triple buffer / ring DECOUPLES the producer's slot from the consumer's, and
449/// the two entries that hold a `TrailingBuf` carry PER-SAMPLE side data beside
450/// the payload — the wire attachment for
451/// [`SubBufferedRawInfoCEntry`], the integrity status for
452/// `SubBufferedRawSafetyCEntry`. Decoupled slots cannot carry that, which is
453/// the reason both were flat in the first place (see
454/// [`SubBufferedRawInfoEntry`]'s note) and it has not changed. What made them
455/// EXPENSIVE was the const, not the flatness: `RX_BUF` is
456/// `DEFAULT_RX_BUF_SIZE` at every call site, so a subscription that knows its
457/// type's bound was still charged the image-wide default. Moving the bytes out
458/// to the trailing region spends the hint on the allocation while keeping one
459/// sample per dispatch.
460///
461/// The buffer does not own its memory — the arena does, and it outlives every
462/// entry in it.
463#[repr(C)]
464pub(crate) struct TrailingBuf {
465    ptr: *mut u8,
466    len: usize,
467}
468
469impl TrailingBuf {
470    /// Adopt `len` bytes of arena trailing region at `ptr`, zeroing them.
471    ///
472    /// The zeroing is what makes [`as_mut_slice`](Self::as_mut_slice) a safe
473    /// method: the executor arena is `&mut [MaybeUninit<u8>]`, so the region is
474    /// UNINITIALISED until someone writes it, and handing out a `&mut [u8]`
475    /// over uninit bytes is UB even if every reader stays inside the length a
476    /// receive reported. The entries this replaced held an inline
477    /// `[0u8; RX_BUF]` and were therefore initialised; one memset per
478    /// REGISTRATION (not per sample) keeps that property rather than trading it
479    /// for the allocation saving.
480    ///
481    /// # Safety
482    /// `ptr` must point to at least `len` writable bytes that stay valid for
483    /// the lifetime of this `TrailingBuf` — i.e. a region handed back by
484    /// `arena_alloc_with_trailing`, which is never reused while the entry
485    /// lives.
486    pub(crate) unsafe fn init(ptr: *mut u8, len: usize) -> Self {
487        // Safety: the caller's contract — `len` writable bytes at `ptr`.
488        unsafe { core::ptr::write_bytes(ptr, 0, len) };
489        Self { ptr, len }
490    }
491
492    /// The whole slot, for a receive to fill.
493    pub(crate) fn as_mut_slice(&mut self) -> &mut [u8] {
494        // Safety: the `init` contract — `len` writable bytes at `ptr`, valid
495        // for as long as `self`, and zeroed there so they are initialised `u8`.
496        unsafe { core::slice::from_raw_parts_mut(self.ptr, self.len) }
497    }
498
499    /// The slot's base address, for handing to a C callback.
500    pub(crate) fn as_ptr(&self) -> *const u8 {
501        self.ptr
502    }
503}
504
505/// Subscription entry with QoS-driven buffer strategy (Phase 73).
506///
507/// Unlike the legacy single-buffer pattern, this entry
508/// stores a [`BufferStrategy`] that manages a trailing buffer region
509/// allocated from the arena at registration time.
510///
511/// # Arena layout
512///
513/// ```text
514/// [SubBufferedEntry<M, F> struct][trailing: slot_count × slot_size bytes]
515///  ↑ offset                      ↑ buffer managed by BufferStrategy
516/// ```
517/// W3b.5 — a contracted subscriber's age hook: the endpoint's cell plus
518/// the epoch clock, captured at registration. `None` = uncontracted (or
519/// no epoch source / no stamp in the type) — the take path costs one
520/// `Option` branch.
521pub(crate) type AgeMon = (
522    &'static crate::executor::monitor::SubMonitorCell,
523    fn() -> u64,
524);
525
526/// W3b.5 — peek the message stamp from the raw CDR buffer and record its
527/// take-age. Compiled away per-type when `M::STAMP_OFFSET` is `None`.
528#[inline]
529pub(crate) fn observe_age<M: RosMessage>(raw: &[u8], mon: &Option<AgeMon>) {
530    if let (Some((cell, epoch)), Some(off)) = (mon, M::STAMP_OFFSET)
531        && let Some(stamp_us) = crate::executor::monitor::peek_stamp_us(raw, off)
532    {
533        cell.observe(stamp_us, epoch());
534    }
535}
536
537#[repr(C)]
538pub(crate) struct SubBufferedEntry<M, F> {
539    pub(crate) handle: session::RmwSubscriber,
540    pub(crate) buffer: BufferStrategy,
541    pub(crate) callback: F,
542    /// W3b.5 — age hook for contracted endpoints.
543    pub(crate) age_mon: Option<AgeMon>,
544    pub(crate) _phantom: PhantomData<M>,
545}
546
547/// Drain the RMW subscriber handle into the buffer strategy.
548///
549/// Calls `take_serialized()` on the subscriber handle and writes received data
550/// into the triple buffer's write slot or the SPSC ring's next push slot.
551///
552/// # Safety
553/// `entry` must be a valid mutable reference to a `SubBufferedEntry`.
554/// Issue 0757 — how many takes this process has dropped, for throttling.
555///
556/// A COUNTER, not a per-entry field, and deliberately: the arena's entry
557/// structs are sized by knob at build time (`EXECUTOR_OPAQUE_U64S`), so adding
558/// a field here would move every image's executor footprint to buy a log line.
559/// The cost is that the report cannot name the topic — see `report_dropped_take`.
560static DROPPED_TAKES: portable_atomic::AtomicU32 = portable_atomic::AtomicU32::new(0);
561
562/// issue 0900 — has the arena-headroom advisory been emitted?
563///
564/// A STATIC, for the reason [`DROPPED_TAKES`] is one: an `Executor` field would
565/// move `EXECUTOR_OPAQUE_U64S` and therefore every image's executor footprint,
566/// to buy a diagnostic. Process-scoped rather than per-executor is also the
567/// RIGHT scope here, not merely the cheap one — the number this names
568/// (`NROS_EXECUTOR_ARENA_SIZE`) is a BUILD-TIME constant, identical for every
569/// executor in the image, so saying it twice adds nothing.
570static ARENA_ADVISORY_DONE: portable_atomic::AtomicBool = portable_atomic::AtomicBool::new(false);
571
572/// Report an arena that is far larger than the entities registered in it.
573///
574/// `ARENA_SIZE` is derived by budgeting EVERY slot at the ActionClient worst
575/// case (`nros-node/build.rs`), so an image with no action client carries
576/// several times what it can use — 74,240 bytes against ~16 KiB for a
577/// pub/sub-only workload at the defaults. The arena is a BUMP allocator, so
578/// `arena_used` is the exact claimed total rather than a reservation, and the
579/// allocator has therefore always known the right answer and never said it.
580///
581/// The override exists (`NROS_EXECUTOR_ARENA_SIZE`, or
582/// `CONFIG_NROS_EXECUTOR_ARENA_SIZE` on Zephyr) and is already load-bearing:
583/// the FreeRTOS action examples pin it to 8192 and still need a 64 KB app task
584/// stack, a number someone found by hitting "Invalid mbox" and working
585/// backwards. That is the shape of issues 0271/0739 — a knob nobody can
586/// enumerate is a knob nobody sets — and this turns it from folklore into a
587/// measurement.
588///
589/// **Where the arena lives is the CALLER's choice**, and both answers are real.
590/// `Executor` holds `arena: &'s mut [MaybeUninit<u8>]` -- a borrowed slice, since
591/// phase-271 (issue 0110) moved the sized tables off build-time consts. What is
592/// inline is `ExecutorInlineStorage::backing`, which the C FFI sizes its
593/// `_opaque` from, so a stack-declared `nros_executor_t` does put the arena on
594/// the task stack -- that is the FreeRTOS "Invalid mbox" case in
595/// `docs/reference/platform-implementation-notes.md`. The C++ component entry
596/// does not take that path: `run_components` -> `nros::init` ->
597/// `Node::GlobalStorageHolder` is a `static`, so the arena is `.bss` and IS
598/// visible to `nm` and `mem-report`. Measured on mr-canhubk344: DTCM tracked
599/// ARENA_SIZE one-for-one across MAX_CBS 24 -> 36.
600///
601/// Do not size against one placement and assume the other.
602///
603/// `nros_log`, never stdio: this is reached on `no_std` targets and inside
604/// Zephyr `native_sim`, where a Rust `std` stdio call is FATAL (issue 0589).
605#[cold]
606fn report_arena_headroom(used: usize, capacity: usize) {
607    // Round the suggestion up so a small later registration still fits, and
608    // keep it a multiple of 1024 because that is the unit the knob is written
609    // in everywhere else in the tree.
610    let suggest = used.next_multiple_of(1024).max(1024);
611    // BUDGET: `nros_log`'s call-site format buffer is 256 bytes by default
612    // (`buffer-size-256`), and overflow truncates with a `…` rather than
613    // dropping the record. The first draft of this line ran ~450 bytes and was
614    // cut mid-number, so the sink received "executor arena is 74240 bytes and
615    // 32…" — every word of the explanation and NONE of the value to set. A
616    // diagnostic that explains itself past the budget delivers exactly the
617    // folklore it was written to replace. So: the actionable value FIRST, the
618    // reasoning in this comment and issue 0900, and a test that fails on `…`.
619    nros_log::nros_info!(
620        nros_log::get_logger("nros"),
621        "arena over-provisioned: set NROS_EXECUTOR_ARENA_SIZE={suggest}          (Zephyr: CONFIG_ prefix). {used}/{capacity} bytes claimed at first          spin, and the arena is INLINE ON THE TASK STACK. Later registrations          need more. issue 0900"
622    );
623}
624
625/// One-shot arena-headroom check, called on the first `spin_once`.
626///
627/// First spin, not registration end, because there is no "registration end" —
628/// an app may register lazily, and this is why the message says
629/// "at first spin" and names what it measured rather than asserting a total.
630///
631/// Hot-path cost is one relaxed load on a branch that is taken exactly once.
632pub(crate) fn maybe_report_arena_headroom(used: usize, capacity: usize) {
633    if ARENA_ADVISORY_DONE.load(portable_atomic::Ordering::Relaxed) {
634        return;
635    }
636    // Only ever set, never cleared, so a racing second spin at worst emits the
637    // line twice — cheaper than an AcqRel on every spin to prevent a duplicate
638    // advisory.
639    ARENA_ADVISORY_DONE.store(true, portable_atomic::Ordering::Relaxed);
640    if arena_is_over_provisioned(used, capacity) {
641        report_arena_headroom(used, capacity);
642    }
643}
644
645/// Say WHY a registration ran out of arena, once.
646///
647/// `NodeError::BufferTooSmall` is the same code a dozen other paths return, so
648/// on a target where a return code is all you get, exhaustion here is
649/// indistinguishable from a message that did not fit a receive buffer. This
650/// names the two knobs that actually govern it and the numbers involved.
651///
652/// The counterpart to [`report_arena_headroom`], and the reason lowering
653/// `NROS_EXECUTOR_ACTION_CLIENTS` is safe to suggest: too small fails at
654/// REGISTRATION rather than at link, so the failure has to say so itself.
655///
656/// One-shot for the same reason the advisory is — the numbers do not change
657/// between registrations, and a per-registration line on an RTOS target is a
658/// flood (issue 0371's shape). `nros_log`, never stdio (issue 0589), and inside
659/// the 256-byte format budget that truncated the first advisory.
660#[cold]
661pub(crate) fn report_arena_exhausted(want: usize, used: usize, capacity: usize) {
662    if ARENA_EXHAUSTED_REPORTED.swap(true, portable_atomic::Ordering::Relaxed) {
663        return;
664    }
665    nros_log::nros_error!(
666        nros_log::get_logger("nros"),
667        "arena exhausted: {want} more bytes needed, {used}/{capacity} in use. \
668         Raise NROS_EXECUTOR_ARENA_SIZE, or NROS_EXECUTOR_ACTION_CLIENTS if \
669         this image registers action clients. issue 0900"
670    );
671}
672
673/// One-shot latch for [`report_arena_exhausted`]. A static for the reason
674/// [`DROPPED_TAKES`] is one.
675static ARENA_EXHAUSTED_REPORTED: portable_atomic::AtomicBool =
676    portable_atomic::AtomicBool::new(false);
677
678/// Is this arena grossly larger than what registered in it?
679///
680/// Half is the threshold because the derivation's own error is a factor of
681/// ~4.5 at the defaults (74,240 bytes budgeted against ~16 KiB for a
682/// pub/sub-only image), so "under half" is unambiguous rather than a rounding
683/// artifact, and an image that genuinely uses most of its arena stays quiet.
684///
685/// A zero capacity is NOT over-provisioned: that is the sentinel bug issue 0460
686/// produced on Zephyr (a literal `0` forwarded instead of the derivation), and
687/// it fails loudly on the first registration. Calling it over-provisioned would
688/// bury a fatal misconfiguration under an advisory about wasted space.
689///
690/// Separated from [`maybe_report_arena_headroom`] so it is testable: the
691/// reporter is one-shot on a process-scoped flag, so a test that called it
692/// twice would silently check nothing the second time.
693pub(crate) const fn arena_is_over_provisioned(used: usize, capacity: usize) -> bool {
694    capacity != 0 && used.saturating_mul(2) <= capacity
695}
696
697#[cfg(test)]
698mod arena_headroom_tests {
699    use super::arena_is_over_provisioned;
700    use crate::config::{ARENA_ACTION_CLIENTS, ARENA_SIZE, DEFAULT_RX_BUF_SIZE, MAX_CBS};
701
702    /// issue 0900 — the per-kind derivation must reproduce the OLD
703    /// `max_cbs * action_client_entry + base` arithmetic byte for byte when
704    /// every slot is still budgeted at ActionClient size, which is the default.
705    ///
706    /// This is the compatibility gate: the knob exists so an image CAN shrink
707    /// its arena, not so every image silently does. A change here that moves
708    /// the default is a change to every image's stack frame.
709    #[test]
710    fn the_default_derivation_is_unchanged() {
711        if ARENA_ACTION_CLIENTS != MAX_CBS {
712            // The test build set the knob; the identity below is not the claim
713            // being made then. Fail rather than pass vacuously.
714            panic!(
715                "this test asserts the DEFAULT derivation, but \
716                 NROS_EXECUTOR_ACTION_CLIENTS was set to {ARENA_ACTION_CLIENTS} \
717                 against MAX_CBS {MAX_CBS}"
718            );
719        }
720        const ACTION_CLIENT_PER_SERVICE: usize = 4096 + 384;
721        const ACTION_CLIENT_SERVICES: usize = 3;
722        const ACTION_CLIENT_FEEDBACK_SUBS: usize = 3;
723        const ACTION_CLIENT_SUB_OVERHEAD: usize = 1536;
724        const ARENA_BASE_OVERHEAD: usize = 2048;
725        const ARENA_FLOOR: usize = 8192;
726
727        let per_entry = ACTION_CLIENT_SERVICES * ACTION_CLIENT_PER_SERVICE
728            + ACTION_CLIENT_FEEDBACK_SUBS * DEFAULT_RX_BUF_SIZE
729            + ACTION_CLIENT_SUB_OVERHEAD;
730        let want = (MAX_CBS * per_entry + ARENA_BASE_OVERHEAD).max(ARENA_FLOOR);
731        assert_eq!(
732            ARENA_SIZE, want,
733            "the per-kind derivation moved the default arena; every image's \
734             task-stack frame moves with it (issue 0900)"
735        );
736    }
737
738    /// The advisory must actually fire for the shipped defaults — otherwise W1
739    /// installed a diagnostic that is dead on the very configuration that
740    /// needs it. A timer-only executor claims 32 bytes against ARENA_SIZE.
741    #[test]
742    fn the_shipped_default_arena_trips_the_advisory() {
743        assert!(
744            arena_is_over_provisioned(32, ARENA_SIZE),
745            "a timer-only executor must trip the advisory at the shipped \
746             defaults; ARENA_SIZE is {ARENA_SIZE}"
747        );
748    }
749
750    #[test]
751    fn gross_over_provision_is_reported() {
752        // The measured shape: a talker's handful of entries against the
753        // worst-case-derived 74,240.
754        assert!(arena_is_over_provisioned(4_096, 74_240));
755        assert!(arena_is_over_provisioned(0, 74_240));
756    }
757
758    #[test]
759    fn a_well_sized_arena_stays_quiet() {
760        assert!(!arena_is_over_provisioned(60_000, 74_240));
761        // Exactly half is the boundary and IS reported; one byte more is not.
762        assert!(arena_is_over_provisioned(37_120, 74_240));
763        assert!(!arena_is_over_provisioned(37_121, 74_240));
764    }
765
766    #[test]
767    fn a_zero_capacity_arena_is_a_fault_not_headroom() {
768        // Issue 0460's sentinel bug. It fails on the first registration; an
769        // advisory about wasted space would bury that.
770        assert!(!arena_is_over_provisioned(0, 0));
771    }
772
773    #[test]
774    fn overflow_cannot_panic_the_check() {
775        // `used` is bounded by `capacity` in practice, but the doubling must
776        // not be the thing that decides that.
777        assert!(!arena_is_over_provisioned(usize::MAX, 74_240));
778    }
779}
780
781/// Say that a take was thrown away, on the first one and every 64th after.
782///
783/// Issue 0757, RFC-0052 fail-loud. `take_serialized` returns `BufferTooSmall` when
784/// a reassembled sample exceeds the subscription buffer, and this path used to
785/// discard EVERY non-OK take. At transport level cyclone has already completed
786/// and ACKed the sample by then, so the subscription looks matched and healthy
787/// from every outside probe (`ros2 topic info -v`, tshark ACKNACK analysis)
788/// while the application waits forever. That is how 13.4 KiB Autoware
789/// trajectories were silently dropped by every Zephyr image for the whole life
790/// of the lane: small degenerate samples fit the 1 KiB default, so every green
791/// marker stayed green, and attribution needed a consumer-side tshark session.
792///
793/// **What this can and cannot say.** The buffer capacity is known here and is
794/// the actionable half — it names the knob to raise
795/// (`NROS_SUBSCRIPTION_BUFFER_SIZE`, or `ZPICO_SUBSCRIBER_BUFFER_SIZE` /
796/// `ZPICO_SUBSCRIBER_LARGE_SIZE` on zenoh). The SAMPLE size is not: the C ABI
797/// contract is "non-negative = bytes produced, negative = error code"
798/// (`rmw_vtable.h`), with no required-length out-param, so the backend cannot
799/// report how big the sample was. The topic is not either: `SubBufferedEntry`
800/// carries no name and adding one changes arena sizing. Both are ABI/struct
801/// changes worth doing on their own merits, not smuggled in behind a log line.
802///
803/// `nros_log`, never stdio: this site is reached on `no_std` targets and inside
804/// Zephyr `native_sim`, where a Rust `std` stdio call is FATAL (issue 0589).
805#[cold]
806fn report_dropped_take(err: &TransportError, buf_len: usize) {
807    let n = DROPPED_TAKES.fetch_add(1, portable_atomic::Ordering::Relaxed);
808    // First, then every 64th. A 40-participant graph must not turn one
809    // misconfigured subscription into a log flood (issue 0371's shape).
810    if n != 0 && !n.is_multiple_of(64) {
811        return;
812    }
813    nros_log::nros_error!(
814        nros_log::get_logger("nros"),
815        "subscription take DROPPED ({err:?}); buffer is {buf_len} bytes. The \
816         sample was received and ACKed, then discarded — raise the subscription \
817         buffer knob if this is BufferTooSmall. Dropped {} so far (issue 0757)",
818        n + 1
819    );
820}
821
822unsafe fn drain_into_buffer<M, F>(
823    entry: &mut SubBufferedEntry<M, F>,
824) -> Result<(), TransportError> {
825    match &entry.buffer {
826        BufferStrategy::Triple(tb) => {
827            let slot = tb.write_slot();
828            let cap = slot.len();
829            if let Some(len) = entry.handle.take_serialized(slot).inspect_err(|e| {
830                report_dropped_take(e, cap);
831            })? {
832                tb.writer_publish(len);
833            }
834        }
835        BufferStrategy::Ring(ring) => {
836            while let Some(slot) = ring.try_push() {
837                let cap = slot.len();
838                match entry.handle.take_serialized(slot).inspect_err(|e| {
839                    report_dropped_take(e, cap);
840                })? {
841                    Some(len) => ring.commit_push(len),
842                    None => break,
843                }
844            }
845        }
846    }
847    Ok(())
848}
849
850/// Monomorphized dispatch for buffered subscriptions.
851///
852/// First drains the RMW subscriber into the buffer strategy (triple buffer
853/// or SPSC ring), then dispatches from the buffer to the user callback.
854///
855/// # Safety
856/// `ptr` must point to a valid, aligned `SubBufferedEntry<M, F>`.
857pub(crate) unsafe fn sub_buffered_try_process<M, F>(
858    ptr: *mut u8,
859    _delta_us: u64,
860    desc_idx: u8,
861) -> Result<bool, TransportError>
862where
863    M: RosMessage,
864    F: FnMut(&M),
865{
866    let entry = unsafe { &mut *(ptr as *mut SubBufferedEntry<M, F>) };
867
868    // Phase 1: drain RMW subscriber → buffer strategy
869    // Issue 0757 — let a transport error OUT, exactly as issue 0737 does for
870    // the C copy below. Anything already buffered from an earlier spin is still
871    // dispatched on the next call.
872    unsafe { drain_into_buffer(entry)? };
873
874    // Phase 2: dispatch from buffer → user callback
875    match &entry.buffer {
876        // Phase 8 — hooked. Triple-buffered: at most one invocation per
877        // `try_process`, and none at all when `reader_acquire` is empty.
878        // The two deserialization `?`s sit BEFORE the start hook, so a
879        // malformed sample returns early without opening a span.
880        BufferStrategy::Triple(tb) => match tb.reader_acquire() {
881            Some((data, len)) => {
882                observe_age::<M>(&data[..len], &entry.age_mon);
883                let mut reader = CdrReader::new_with_header(&data[..len])
884                    .map_err(|_| TransportError::DeserializationError)?;
885                let msg = M::deserialize(&mut reader)
886                    .map_err(|_| TransportError::DeserializationError)?;
887                trace_cb_start(desc_idx);
888                (entry.callback)(&msg);
889                trace_cb_end(desc_idx);
890                Ok(true)
891            }
892            None => Ok(false),
893        },
894        // Phase 8 — hooked INSIDE the loop: a ring drains N queued messages
895        // per `try_process` and fires the callback once per message, so N
896        // messages must produce N spans, not one.
897        BufferStrategy::Ring(ring) => {
898            let mut did_work = false;
899            while let Some((data, len)) = ring.try_pop() {
900                observe_age::<M>(&data[..len], &entry.age_mon);
901                let mut reader = CdrReader::new_with_header(&data[..len])
902                    .map_err(|_| TransportError::DeserializationError)?;
903                let msg = M::deserialize(&mut reader)
904                    .map_err(|_| TransportError::DeserializationError)?;
905                trace_cb_start(desc_idx);
906                (entry.callback)(&msg);
907                trace_cb_end(desc_idx);
908                ring.commit_pop();
909                did_work = true;
910            }
911            Ok(did_work)
912        }
913    }
914}
915
916/// Readiness check for buffered subscriptions.
917///
918/// Checks the RMW subscriber handle first (new data available from transport),
919/// then the buffer strategy (data already drained into triple buffer/ring).
920///
921/// # Safety
922/// `ptr` must point to a valid `SubBufferedEntry<M, F>`.
923pub(crate) unsafe fn sub_buffered_has_data<M, F>(ptr: *const u8) -> bool {
924    let entry = unsafe { &*(ptr as *const SubBufferedEntry<M, F>) };
925    // Check RMW handle first (data may be in static buffer, not yet drained)
926    entry.handle.has_data() || entry.buffer.has_data()
927}
928
929// ============================================================================
930// In-place typed subscription (Phase 231 Wave 0.2 — RFC-0038)
931// ============================================================================
932
933/// In-place typed subscription entry — **no arena buffer**.
934///
935/// Unlike [`SubBufferedEntry`], this carries no trailing `BufferStrategy`: the
936/// callback deserializes directly from the backend's borrowed receive slot via
937/// [`Subscriber::process_raw_in_place`], so copy #1 (ring → arena) and the arena
938/// buffer are both gone. Selected at registration when the backend advertises
939/// `supports_process_in_place()`.
940#[repr(C)]
941pub(crate) struct SubInplaceEntry<M, F> {
942    pub(crate) handle: session::RmwSubscriber,
943    pub(crate) callback: F,
944    /// W3b.5 — age hook for contracted endpoints.
945    pub(crate) age_mon: Option<AgeMon>,
946    pub(crate) _phantom: PhantomData<M>,
947}
948
949/// Monomorphized in-place dispatch for typed subscriptions.
950///
951/// Drains all pending messages from the backend, deserializing + invoking the
952/// callback directly from each borrowed slot. Returns `Ok(true)` if any message
953/// was dispatched.
954///
955/// # Safety
956/// `ptr` must point to a valid, aligned `SubInplaceEntry<M, F>`.
957pub(crate) unsafe fn sub_inplace_try_process<M, F>(
958    ptr: *mut u8,
959    _delta_us: u64,
960    desc_idx: u8,
961) -> Result<bool, TransportError>
962where
963    M: RosMessage,
964    F: FnMut(&M),
965{
966    let entry = unsafe { &mut *(ptr as *mut SubInplaceEntry<M, F>) };
967    // Split-borrow the handle and callback (disjoint fields).
968    let SubInplaceEntry {
969        handle,
970        callback,
971        age_mon,
972        ..
973    } = entry;
974    let mut did_work = false;
975    loop {
976        let mut deser_err = false;
977        // Phase 8 — hooked inside the `Ok(msg)` arm of the borrow closure,
978        // which the drain loop re-enters once per pending message: N
979        // messages therefore produce N spans. The pair is fully contained
980        // in the closure, so the `?` on `process_raw_in_place` below can
981        // never fire between a start and its end. Deserialization failures
982        // take the `deser_err` arms and emit nothing.
983        let processed = handle.process_raw_in_place(|raw| {
984            observe_age::<M>(raw, age_mon);
985            match CdrReader::new_with_header(raw) {
986                Ok(mut reader) => match M::deserialize(&mut reader) {
987                    Ok(msg) => {
988                        trace_cb_start(desc_idx);
989                        (callback)(&msg);
990                        trace_cb_end(desc_idx);
991                    }
992                    Err(_) => deser_err = true,
993                },
994                Err(_) => deser_err = true,
995            }
996        })?;
997        if deser_err {
998            return Err(TransportError::DeserializationError);
999        }
1000        if processed {
1001            did_work = true;
1002        } else {
1003            break;
1004        }
1005    }
1006    Ok(did_work)
1007}
1008
1009/// Readiness check for in-place typed subscriptions.
1010///
1011/// # Safety
1012/// `ptr` must point to a valid `SubInplaceEntry<M, F>`.
1013pub(crate) unsafe fn sub_inplace_has_data<M, F>(ptr: *const u8) -> bool {
1014    let entry = unsafe { &*(ptr as *const SubInplaceEntry<M, F>) };
1015    entry.handle.has_data()
1016}
1017
1018// ============================================================================
1019// Zero-copy raw buffered subscription (Phase 73.10)
1020// ============================================================================
1021
1022/// Buffered subscription entry for zero-copy raw callbacks.
1023///
1024/// The callback receives `&[u8]` (CDR data) borrowing directly from the
1025/// triple buffer's read slot or SPSC ring's pop slot. For borrowed message
1026/// types (e.g., `Image<'a>`), the callback calls `deserialize_view()`
1027/// on the data, giving the message a lifetime tied to the callback scope.
1028#[repr(C)]
1029pub(crate) struct SubBufferedRawEntry<F> {
1030    pub(crate) handle: session::RmwSubscriber,
1031    pub(crate) buffer: BufferStrategy,
1032    pub(crate) callback: F,
1033}
1034
1035/// Drain helper for raw buffered entries.
1036///
1037/// Issue 0757 — the THIRD copy of this drain, and it had the same swallow as
1038/// the typed one above. Fixed the way issue 0737 fixed the C copy
1039/// (`drain_into_buffer_raw_c`): let the error OUT so `spin_once` counts it,
1040/// rather than inventing a second remedy for one defect.
1041unsafe fn drain_into_buffer_raw<F>(
1042    entry: &mut SubBufferedRawEntry<F>,
1043) -> Result<(), TransportError> {
1044    match &entry.buffer {
1045        BufferStrategy::Triple(tb) => {
1046            let slot = tb.write_slot();
1047            let cap = slot.len();
1048            if let Some(len) = entry.handle.take_serialized(slot).inspect_err(|e| {
1049                report_dropped_take(e, cap);
1050            })? {
1051                tb.writer_publish(len);
1052            }
1053        }
1054        BufferStrategy::Ring(ring) => {
1055            while let Some(slot) = ring.try_push() {
1056                let cap = slot.len();
1057                match entry.handle.take_serialized(slot).inspect_err(|e| {
1058                    report_dropped_take(e, cap);
1059                })? {
1060                    Some(len) => ring.commit_push(len),
1061                    None => break,
1062                }
1063            }
1064        }
1065    }
1066    Ok(())
1067}
1068
1069/// Dispatch for zero-copy raw buffered subscriptions.
1070///
1071/// Drains the RMW handle into the buffer, then passes the raw CDR slice
1072/// to the callback. The callback borrows from the buffer slot — no copy.
1073///
1074/// # Safety
1075/// `ptr` must point to a valid, aligned `SubBufferedRawEntry<F>`.
1076pub(crate) unsafe fn sub_buffered_raw_try_process<F>(
1077    ptr: *mut u8,
1078    _delta_us: u64,
1079    desc_idx: u8,
1080) -> Result<bool, TransportError>
1081where
1082    F: FnMut(&[u8]),
1083{
1084    let entry = unsafe { &mut *(ptr as *mut SubBufferedRawEntry<F>) };
1085
1086    // Issue 0757 — see `drain_into_buffer`; the error reaches `spin_once`'s
1087    // `subscription_errors` instead of vanishing.
1088    unsafe { drain_into_buffer_raw(entry)? };
1089
1090    match &entry.buffer {
1091        // Phase 8 — hooked. Triple-buffered: one invocation per
1092        // `try_process` at most, none when `reader_acquire` is empty.
1093        BufferStrategy::Triple(tb) => match tb.reader_acquire() {
1094            Some((data, len)) => {
1095                trace_cb_start(desc_idx);
1096                (entry.callback)(&data[..len]);
1097                trace_cb_end(desc_idx);
1098                Ok(true)
1099            }
1100            None => Ok(false),
1101        },
1102        // Phase 8 — hooked INSIDE the loop: one span per drained message,
1103        // not one per `try_process`.
1104        BufferStrategy::Ring(ring) => {
1105            let mut did_work = false;
1106            while let Some((data, len)) = ring.try_pop() {
1107                trace_cb_start(desc_idx);
1108                (entry.callback)(&data[..len]);
1109                trace_cb_end(desc_idx);
1110                ring.commit_pop();
1111                did_work = true;
1112            }
1113            Ok(did_work)
1114        }
1115    }
1116}
1117
1118/// Readiness check for raw buffered subscriptions.
1119///
1120/// # Safety
1121/// `ptr` must point to a valid `SubBufferedRawEntry<F>`.
1122pub(crate) unsafe fn sub_buffered_raw_has_data<F>(ptr: *const u8) -> bool {
1123    let entry = unsafe { &*(ptr as *const SubBufferedRawEntry<F>) };
1124    entry.handle.has_data() || entry.buffer.has_data()
1125}
1126
1127// ============================================================================
1128// Borrowed (zero-copy) buffered subscription (Phase 229.6, issue 0007)
1129// ============================================================================
1130
1131/// Buffered subscription entry for borrowed (zero-copy) message callbacks.
1132///
1133/// The callback receives `&B::View<'a>` — a lifetime-carrying message whose
1134/// unbounded sequence/string fields borrow directly from the triple buffer's
1135/// read slot (no arena copy, no `heapless::Vec` copy). The view is materialised
1136/// per dispatch via [`DeserializeView`] and dropped before the slot is
1137/// released, so the borrow never outlives the buffer.
1138///
1139/// **Triple-buffer only.** A borrowed view must reference exactly one
1140/// well-defined slot for the duration of the callback; an SPSC ring (depth > 1)
1141/// holds several samples in flight with no single such slot. Registration
1142/// rejects `qos.depth > 1` for borrowed subscriptions, so `buffer` is always
1143/// [`BufferStrategy::Triple`] here.
1144#[repr(C)]
1145pub(crate) struct SubBufferedViewEntry<B, F> {
1146    pub(crate) handle: session::RmwSubscriber,
1147    pub(crate) buffer: BufferStrategy,
1148    pub(crate) callback: F,
1149    pub(crate) _phantom: PhantomData<B>,
1150}
1151
1152/// Dispatch for borrowed (zero-copy) buffered subscriptions.
1153///
1154/// Drains the RMW handle into the triple buffer, then materialises a borrowed
1155/// `B::View<'_>` over the read slot and hands it to the callback. The view
1156/// borrows the slot; it is dropped at the end of the callback, before the next
1157/// dispatch can publish over the slot.
1158///
1159/// # Safety
1160/// `ptr` must point to a valid, aligned `SubBufferedViewEntry<B, F>`.
1161pub(crate) unsafe fn sub_buffered_view_try_process<B, F>(
1162    ptr: *mut u8,
1163    _delta_us: u64,
1164    desc_idx: u8,
1165) -> Result<bool, TransportError>
1166where
1167    B: ViewableMessage,
1168    F: for<'a> FnMut(&B::View<'a>),
1169{
1170    let entry = unsafe { &mut *(ptr as *mut SubBufferedViewEntry<B, F>) };
1171
1172    // Borrowed subscriptions are triple-buffer only (enforced at registration).
1173    let tb = match &entry.buffer {
1174        BufferStrategy::Triple(tb) => tb,
1175        // Unreachable: registration rejects depth > 1. Treat as no work.
1176        BufferStrategy::Ring(_) => return Ok(false),
1177    };
1178
1179    // Phase 1: drain RMW subscriber → triple buffer write slot.
1180    //
1181    // Issue 0757 — the FOURTH copy of this drain (the borrowed/zero-copy path)
1182    // and it swallowed non-OK takes like the others. Same remedy as issue 0737's
1183    // C copy: report the actionable size, then let the error out.
1184    {
1185        let slot = tb.write_slot();
1186        let cap = slot.len();
1187        if let Some(len) = entry.handle.take_serialized(slot).inspect_err(|e| {
1188            report_dropped_take(e, cap);
1189        })? {
1190            tb.writer_publish(len);
1191        }
1192    }
1193
1194    // Phase 2: borrow the read slot and deserialize a view over it (no copy).
1195    //
1196    // Phase 8 — hooked. Exactly ONE site: the `BufferStrategy::Ring(_)` arm
1197    // above returns `Ok(false)` without ever reaching a callback (borrowed
1198    // subscriptions are triple-buffer only, enforced at registration), so it
1199    // gets no pair. Both `?`s land before the start hook.
1200    match tb.reader_acquire() {
1201        Some((data, len)) => {
1202            let mut reader = CdrReader::new_with_header(&data[..len])
1203                .map_err(|_| TransportError::DeserializationError)?;
1204            let msg = <B::View<'_> as DeserializeView>::deserialize_view(&mut reader)
1205                .map_err(|_| TransportError::DeserializationError)?;
1206            trace_cb_start(desc_idx);
1207            (entry.callback)(&msg);
1208            trace_cb_end(desc_idx);
1209            Ok(true)
1210        }
1211        None => Ok(false),
1212    }
1213}
1214
1215/// Readiness check for borrowed buffered subscriptions.
1216///
1217/// # Safety
1218/// `ptr` must point to a valid `SubBufferedViewEntry<B, F>`.
1219pub(crate) unsafe fn sub_buffered_view_has_data<B, F>(ptr: *const u8) -> bool {
1220    let entry = unsafe { &*(ptr as *const SubBufferedViewEntry<B, F>) };
1221    entry.handle.has_data() || entry.buffer.has_data()
1222}
1223
1224// ============================================================================
1225// Raw buffered subscription with attachment / MessageInfo (Phase 189.M1)
1226// ============================================================================
1227
1228/// Staging cap for a raw subscription's wire attachment (`bridge_origin`
1229/// tags and similar are small). Attachment bytes longer than this are
1230/// truncated by the backend's `take_serialized_with_attachment`.
1231pub(crate) const RAW_INFO_ATT_CAP: usize = 256;
1232
1233/// Raw buffered subscription entry that surfaces the sample's wire
1234/// attachment as a [`RawMessageInfo`] to the callback
1235/// (`FnMut(&[u8], &RawMessageInfo)`).
1236///
1237/// Unlike [`SubBufferedRawEntry`] (Triple/Ring `BufferStrategy`), this
1238/// uses a flat inline payload buffer + a flat attachment buffer so the
1239/// attachment travels with its message — the decoupled producer/consumer
1240/// slots of a triple/ring buffer cannot carry per-message side data.
1241/// One sample per dispatch (mirrors [`SubInfoEntry`]).
1242#[repr(C)]
1243pub(crate) struct SubBufferedRawInfoEntry<F, const RX_BUF: usize> {
1244    pub(crate) handle: session::RmwSubscriber,
1245    pub(crate) buffer: [u8; RX_BUF],
1246    pub(crate) att: [u8; RAW_INFO_ATT_CAP],
1247    pub(crate) callback: F,
1248}
1249
1250/// Dispatch for raw buffered subscriptions with attachment.
1251///
1252/// # Safety
1253/// `ptr` must point to a valid, aligned `SubBufferedRawInfoEntry<F, RX_BUF>`.
1254pub(crate) unsafe fn sub_buffered_raw_info_try_process<F, const RX_BUF: usize>(
1255    ptr: *mut u8,
1256    _delta_us: u64,
1257    desc_idx: u8,
1258) -> Result<bool, TransportError>
1259where
1260    F: FnMut(&[u8], &RawMessageInfo),
1261{
1262    let entry = unsafe { &mut *(ptr as *mut SubBufferedRawInfoEntry<F, RX_BUF>) };
1263    match entry
1264        .handle
1265        .take_serialized_with_attachment(&mut entry.buffer, &mut entry.att)
1266    {
1267        // Phase 8 — hooked. One sample per dispatch (flat inline buffer, no
1268        // ring), so exactly one pair. `Ok(None)` and `Err(_)` never reach a
1269        // callback and stay bare.
1270        Ok(Some((len, att_len))) => {
1271            let info = RawMessageInfo::new(&entry.att[..att_len]);
1272            trace_cb_start(desc_idx);
1273            (entry.callback)(&entry.buffer[..len], &info);
1274            trace_cb_end(desc_idx);
1275            Ok(true)
1276        }
1277        Ok(None) => Ok(false),
1278        Err(_) => Err(TransportError::DeserializationError),
1279    }
1280}
1281
1282/// Readiness check for raw buffered subscriptions with attachment.
1283///
1284/// # Safety
1285/// `ptr` must point to a valid `SubBufferedRawInfoEntry<F, RX_BUF>`.
1286pub(crate) unsafe fn sub_buffered_raw_info_has_data<F, const RX_BUF: usize>(
1287    ptr: *const u8,
1288) -> bool {
1289    let entry = unsafe { &*(ptr as *const SubBufferedRawInfoEntry<F, RX_BUF>) };
1290    entry.handle.has_data()
1291}
1292
1293/// C-style (fn-ptr + context) raw buffered subscription with attachment
1294/// (Phase 189.M3.4 — the C analog of [`SubBufferedRawInfoEntry`]). Flat
1295/// payload + attachment buffers, one sample per dispatch.
1296///
1297/// phase-408 W5b — the payload slot is a [`TrailingBuf`] in the arena's
1298/// trailing region rather than an inline `[u8; RX_BUF]`, so the registering
1299/// caller's `rx_buffer_hint` sizes the ALLOCATION and not just the backend's
1300/// payload size class. The attachment stays inline and fixed
1301/// ([`RAW_INFO_ATT_CAP`]): it is small, and it is the thing that has to travel
1302/// with its message.
1303#[repr(C)]
1304pub(crate) struct SubBufferedRawInfoCEntry {
1305    pub(crate) handle: session::RmwSubscriber,
1306    pub(crate) buffer: TrailingBuf,
1307    pub(crate) att: [u8; RAW_INFO_ATT_CAP],
1308    pub(crate) callback: RawSubscriptionInfoCallback,
1309    pub(crate) context: *mut core::ffi::c_void,
1310}
1311
1312/// Dispatch for the C-style raw buffered subscription with attachment.
1313///
1314/// # Safety
1315/// `ptr` must point to a valid, aligned `SubBufferedRawInfoCEntry`.
1316pub(crate) unsafe fn sub_buffered_raw_info_c_try_process(
1317    ptr: *mut u8,
1318    _delta_us: u64,
1319    desc_idx: u8,
1320) -> Result<bool, TransportError> {
1321    let entry = unsafe { &mut *(ptr as *mut SubBufferedRawInfoCEntry) };
1322    let payload = entry.buffer.as_mut_slice();
1323    match entry
1324        .handle
1325        .take_serialized_with_attachment(payload, &mut entry.att)
1326    {
1327        // Phase 8 — hooked. One sample per dispatch; the pair brackets the
1328        // whole `unsafe` FFI call, which is the user callback itself.
1329        Ok(Some((len, att_len))) => {
1330            trace_cb_start(desc_idx);
1331            unsafe {
1332                (entry.callback)(
1333                    entry.buffer.as_ptr(),
1334                    len,
1335                    entry.att.as_ptr(),
1336                    att_len,
1337                    entry.context,
1338                )
1339            };
1340            trace_cb_end(desc_idx);
1341            Ok(true)
1342        }
1343        Ok(None) => Ok(false),
1344        Err(_) => Err(TransportError::DeserializationError),
1345    }
1346}
1347
1348/// Readiness check for the C-style raw buffered subscription with attachment.
1349///
1350/// # Safety
1351/// `ptr` must point to a valid `SubBufferedRawInfoCEntry`.
1352pub(crate) unsafe fn sub_buffered_raw_info_c_has_data(ptr: *const u8) -> bool {
1353    let entry = unsafe { &*(ptr as *const SubBufferedRawInfoCEntry) };
1354    entry.handle.has_data()
1355}
1356
1357/// Phase 250 (Wave 2) — generic (type-erased) raw buffered subscription that
1358/// surfaces E2E [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC + sequence
1359/// gap/dup) alongside the raw CDR bytes (`FnMut(&[u8], &IntegrityStatus)`).
1360///
1361/// The type-erased analog of [`SubSafetyEntry`]: the validator lives in the
1362/// `RmwSubscriber` and `take_validated` produces the status, so no typed
1363/// `M` is needed (the declarative `Node` path is generic). Flat inline payload
1364/// buffer; one sample per dispatch.
1365#[cfg(feature = "safety-e2e")]
1366#[repr(C)]
1367pub(crate) struct SubBufferedRawSafetyEntry<F, const RX_BUF: usize> {
1368    pub(crate) handle: session::RmwSubscriber,
1369    pub(crate) buffer: [u8; RX_BUF],
1370    pub(crate) callback: F,
1371}
1372
1373/// Dispatch for the generic raw safety subscription: validate-receive into the
1374/// buffer, then pass the raw slice + status to the callback.
1375///
1376/// # Safety
1377/// `ptr` must point to a valid, aligned `SubBufferedRawSafetyEntry<F, RX_BUF>`.
1378#[cfg(feature = "safety-e2e")]
1379pub(crate) unsafe fn sub_buffered_raw_safety_try_process<F, const RX_BUF: usize>(
1380    ptr: *mut u8,
1381    _delta_us: u64,
1382    desc_idx: u8,
1383) -> Result<bool, TransportError>
1384where
1385    F: FnMut(&[u8], &nros_rmw::IntegrityStatus),
1386{
1387    let entry = unsafe { &mut *(ptr as *mut SubBufferedRawSafetyEntry<F, RX_BUF>) };
1388    match entry.handle.take_validated(&mut entry.buffer) {
1389        // Phase 8 — hooked. One sample per dispatch. A failed validation
1390        // still delivers the sample WITH its status, so this arm is the only
1391        // callback path; `Ok(None)` / `Err(_)` fire nothing.
1392        Ok(Some((len, status))) => {
1393            trace_cb_start(desc_idx);
1394            (entry.callback)(&entry.buffer[..len], &status);
1395            trace_cb_end(desc_idx);
1396            Ok(true)
1397        }
1398        Ok(None) => Ok(false),
1399        Err(_) => Err(TransportError::DeserializationError),
1400    }
1401}
1402
1403/// Readiness check for the generic raw safety subscription.
1404///
1405/// # Safety
1406/// `ptr` must point to a valid `SubBufferedRawSafetyEntry<F, RX_BUF>`.
1407#[cfg(feature = "safety-e2e")]
1408pub(crate) unsafe fn sub_buffered_raw_safety_has_data<F, const RX_BUF: usize>(
1409    ptr: *const u8,
1410) -> bool {
1411    let entry = unsafe { &*(ptr as *const SubBufferedRawSafetyEntry<F, RX_BUF>) };
1412    entry.handle.has_data()
1413}
1414
1415/// Phase 269 W3 — the C analog of [`SubBufferedRawSafetyEntry`]: same flat inline
1416/// payload buffer + `take_validated` dispatch, but the callback is a plain
1417/// C function pointer (`RawSubscriptionSafetyCallback`) that receives the integrity
1418/// scalars alongside the CDR bytes.
1419///
1420/// phase-408 W5b — the payload slot is a [`TrailingBuf`] sized from the
1421/// registering caller's `rx_buffer_hint`, so this entry is no longer generic at
1422/// all: the `RX_BUF` const it was monomorphised over only ever arrived as
1423/// `DEFAULT_RX_BUF_SIZE`, and the status a validated sample carries is
1424/// per-sample side data, which is why the slot stays flat rather than becoming
1425/// a [`BufferStrategy`].
1426#[cfg(feature = "safety-e2e")]
1427#[repr(C)]
1428pub(crate) struct SubBufferedRawSafetyCEntry {
1429    pub(crate) handle: session::RmwSubscriber,
1430    pub(crate) buffer: TrailingBuf,
1431    pub(crate) callback: super::types::RawSubscriptionSafetyCallback,
1432    pub(crate) context: *mut core::ffi::c_void,
1433}
1434
1435/// Dispatch for the C-style raw validated subscription: validate-receive into the
1436/// buffer, then pass the raw slice + unpacked integrity scalars to the callback.
1437///
1438/// # Safety
1439/// `ptr` must point to a valid, aligned `SubBufferedRawSafetyCEntry`.
1440#[cfg(feature = "safety-e2e")]
1441pub(crate) unsafe fn sub_buffered_raw_safety_c_try_process(
1442    ptr: *mut u8,
1443    _delta_us: u64,
1444    desc_idx: u8,
1445) -> Result<bool, TransportError> {
1446    let entry = unsafe { &mut *(ptr as *mut SubBufferedRawSafetyCEntry) };
1447    let payload = entry.buffer.as_mut_slice();
1448    match entry.handle.take_validated(payload) {
1449        // Phase 8 — hooked. One sample per dispatch. The `crc_valid` unpack
1450        // is executor bookkeeping, not user code, so it stays OUTSIDE the
1451        // span; the pair brackets only the FFI call.
1452        Ok(Some((len, status))) => {
1453            let crc_valid: i8 = match status.crc_valid {
1454                Some(true) => 1,
1455                Some(false) => 0,
1456                None => -1,
1457            };
1458            trace_cb_start(desc_idx);
1459            unsafe {
1460                (entry.callback)(
1461                    entry.buffer.as_ptr(),
1462                    len,
1463                    status.gap,
1464                    status.duplicate,
1465                    crc_valid,
1466                    entry.context,
1467                )
1468            };
1469            trace_cb_end(desc_idx);
1470            Ok(true)
1471        }
1472        Ok(None) => Ok(false),
1473        Err(_) => Err(TransportError::DeserializationError),
1474    }
1475}
1476
1477/// Readiness check for the C-style raw validated subscription.
1478///
1479/// # Safety
1480/// `ptr` must point to a valid `SubBufferedRawSafetyCEntry`.
1481#[cfg(feature = "safety-e2e")]
1482pub(crate) unsafe fn sub_buffered_raw_safety_c_has_data(ptr: *const u8) -> bool {
1483    let entry = unsafe { &*(ptr as *const SubBufferedRawSafetyCEntry) };
1484    entry.handle.has_data()
1485}
1486
1487/// Buffered subscription entry for C-style raw callbacks (function pointer + context).
1488///
1489/// Same as `SubBufferedRawEntry` but uses `RawSubscriptionCallback` instead of
1490/// a Rust closure. Used by the C API and by `register_subscription_raw_*` methods.
1491#[repr(C)]
1492pub(crate) struct SubBufferedRawCEntry {
1493    pub(crate) handle: session::RmwSubscriber,
1494    pub(crate) buffer: BufferStrategy,
1495    pub(crate) callback: RawSubscriptionCallback,
1496    pub(crate) context: *mut core::ffi::c_void,
1497}
1498
1499/// Drain helper for C-style raw buffered entries.
1500/// Issue 0737 — a transport ERROR is not "no data", and conflating them
1501/// destroys the sample without a trace.
1502///
1503/// Both arms used to read `if let Ok(Some(len)) = … else { break }`, which
1504/// treats `Err(_)` exactly like `Ok(None)`. The backend has ALREADY consumed
1505/// the sample by the time it reports the error, so the message is gone and the
1506/// only observable is that nothing arrived — indistinguishable from a publisher
1507/// that never published. 0737 spent two hosts' investigations inside that
1508/// ambiguity while the executor's own `alive — … 0 error(s)` line reported
1509/// health, because the error never reached the counter that prints it.
1510///
1511/// Now it propagates: `spin_once` maps an `Err` from `try_process` to
1512/// `subscription_errors`, so the count stops lying and the failure has a name.
1513unsafe fn drain_into_buffer_raw_c(entry: &mut SubBufferedRawCEntry) -> Result<(), TransportError> {
1514    match &entry.buffer {
1515        BufferStrategy::Triple(tb) => {
1516            let slot = tb.write_slot();
1517            if let Some(len) = entry.handle.take_serialized(slot)? {
1518                tb.writer_publish(len);
1519            }
1520        }
1521        BufferStrategy::Ring(ring) => {
1522            while let Some(slot) = ring.try_push() {
1523                match entry.handle.take_serialized(slot)? {
1524                    Some(len) => ring.commit_push(len),
1525                    None => break,
1526                }
1527            }
1528        }
1529    }
1530    Ok(())
1531}
1532
1533/// Dispatch for C-style raw buffered subscriptions.
1534///
1535/// # Safety
1536/// `ptr` must point to a valid, aligned `SubBufferedRawCEntry`.
1537pub(crate) unsafe fn sub_buffered_raw_c_try_process(
1538    ptr: *mut u8,
1539    _delta_us: u64,
1540    desc_idx: u8,
1541) -> Result<bool, TransportError> {
1542    let entry = unsafe { &mut *(ptr as *mut SubBufferedRawCEntry) };
1543
1544    // Issue 0737 — drain first, and let a transport error OUT. Anything already
1545    // buffered from an earlier spin is still dispatched below on the next call.
1546    unsafe { drain_into_buffer_raw_c(entry)? };
1547
1548    match &entry.buffer {
1549        // Phase 8 — hooked. Triple-buffered: at most one invocation per
1550        // `try_process`, so exactly one start/end pair, and none at all when
1551        // `reader_acquire` comes back empty.
1552        BufferStrategy::Triple(tb) => match tb.reader_acquire() {
1553            Some((data, len)) => {
1554                trace_cb_start(desc_idx);
1555                unsafe { (entry.callback)(data.as_ptr(), len, entry.context) };
1556                trace_cb_end(desc_idx);
1557                Ok(true)
1558            }
1559            None => Ok(false),
1560        },
1561        // Phase 8 — hooked INSIDE the loop, deliberately. A ring drains N
1562        // queued messages per `try_process` and fires the user callback once
1563        // per message; a pair outside the loop would report N invocations as
1564        // one, which is the granularity failure that ruled out hooking the
1565        // `try_process` boundary in the first place.
1566        BufferStrategy::Ring(ring) => {
1567            let mut did_work = false;
1568            while let Some((data, len)) = ring.try_pop() {
1569                trace_cb_start(desc_idx);
1570                unsafe { (entry.callback)(data.as_ptr(), len, entry.context) };
1571                trace_cb_end(desc_idx);
1572                ring.commit_pop();
1573                did_work = true;
1574            }
1575            Ok(did_work)
1576        }
1577    }
1578}
1579
1580/// Readiness check for C-style raw buffered subscriptions.
1581///
1582/// # Safety
1583/// `ptr` must point to a valid `SubBufferedRawCEntry`.
1584pub(crate) unsafe fn sub_buffered_raw_c_has_data(ptr: *const u8) -> bool {
1585    let entry = unsafe { &*(ptr as *const SubBufferedRawCEntry) };
1586    entry.handle.has_data() || entry.buffer.has_data()
1587}
1588
1589// ============================================================================
1590// Dispatch functions
1591// ============================================================================
1592
1593/// Monomorphized subscription dispatch function (with MessageInfo).
1594///
1595/// # Safety
1596/// `ptr` must point to a valid, aligned `SubInfoEntry<M, F, RX_BUF>`.
1597pub(crate) unsafe fn sub_info_try_process<M, F, const RX_BUF: usize>(
1598    ptr: *mut u8,
1599    _delta_us: u64,
1600    desc_idx: u8,
1601) -> Result<bool, TransportError>
1602where
1603    M: RosMessage,
1604    F: FnMut(&M, Option<&MessageInfo>),
1605{
1606    let entry = unsafe { &mut *(ptr as *mut SubInfoEntry<M, F, RX_BUF>) };
1607
1608    // LET mode: use pre-sampled data if available (no MessageInfo in snapshot)
1609    //
1610    // Phase 8 — hooked. This is a SECOND callback site, mutually exclusive
1611    // with the receive path below: LET mode dispatches the snapshot and
1612    // returns, so a dispatch emits one pair from here OR one from there,
1613    // never both.
1614    if entry.sampled_len > 0 {
1615        let len = entry.sampled_len;
1616        entry.sampled_len = 0;
1617        let mut reader = CdrReader::new_with_header(&entry.buffer[..len])
1618            .map_err(|_| TransportError::DeserializationError)?;
1619        let msg = M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
1620        trace_cb_start(desc_idx);
1621        (entry.callback)(&msg, None);
1622        trace_cb_end(desc_idx);
1623        return Ok(true);
1624    }
1625
1626    match entry.handle.take_serialized_with_info(&mut entry.buffer) {
1627        // Phase 8 — hooked. One sample per dispatch; `Ok(None)` / `Err(_)`
1628        // fire nothing, and both `?`s precede the start hook.
1629        Ok(Some((len, info))) => {
1630            let mut reader = CdrReader::new_with_header(&entry.buffer[..len])
1631                .map_err(|_| TransportError::DeserializationError)?;
1632            let msg =
1633                M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
1634            trace_cb_start(desc_idx);
1635            (entry.callback)(&msg, info.as_ref());
1636            trace_cb_end(desc_idx);
1637            Ok(true)
1638        }
1639        Ok(None) => Ok(false),
1640        Err(_) => Err(TransportError::DeserializationError),
1641    }
1642}
1643
1644/// Monomorphized subscription dispatch function (with safety validation).
1645///
1646/// # Safety
1647/// `ptr` must point to a valid, aligned `SubSafetyEntry<M, F, RX_BUF>`.
1648#[cfg(feature = "safety-e2e")]
1649pub(crate) unsafe fn sub_safety_try_process<M, F, const RX_BUF: usize>(
1650    ptr: *mut u8,
1651    _delta_us: u64,
1652    desc_idx: u8,
1653) -> Result<bool, TransportError>
1654where
1655    M: RosMessage,
1656    F: FnMut(&M, &nros_rmw::IntegrityStatus),
1657{
1658    let entry = unsafe { &mut *(ptr as *mut SubSafetyEntry<M, F, RX_BUF>) };
1659
1660    // LET mode: use pre-sampled data (no IntegrityStatus in snapshot)
1661    //
1662    // Phase 8 — hooked. A SECOND callback site, mutually exclusive with the
1663    // validated-receive path below (this one `return`s). The synthetic
1664    // all-clear `IntegrityStatus` is built inside the span because it is an
1665    // argument expression, not user code — it is three constant stores.
1666    if entry.sampled_len > 0 {
1667        let len = entry.sampled_len;
1668        entry.sampled_len = 0;
1669        let mut reader = CdrReader::new_with_header(&entry.buffer[..len])
1670            .map_err(|_| TransportError::DeserializationError)?;
1671        let msg = M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
1672        trace_cb_start(desc_idx);
1673        (entry.callback)(
1674            &msg,
1675            &nros_rmw::IntegrityStatus {
1676                gap: 0,
1677                duplicate: false,
1678                crc_valid: None,
1679            },
1680        );
1681        trace_cb_end(desc_idx);
1682        return Ok(true);
1683    }
1684
1685    match entry.handle.take_validated(&mut entry.buffer) {
1686        // Phase 8 — hooked. One sample per dispatch; both `?`s precede the
1687        // start hook, and `Ok(None)` / `Err(_)` fire nothing.
1688        Ok(Some((len, status))) => {
1689            let mut reader = CdrReader::new_with_header(&entry.buffer[..len])
1690                .map_err(|_| TransportError::DeserializationError)?;
1691            let msg =
1692                M::deserialize(&mut reader).map_err(|_| TransportError::DeserializationError)?;
1693            trace_cb_start(desc_idx);
1694            (entry.callback)(&msg, &status);
1695            trace_cb_end(desc_idx);
1696            Ok(true)
1697        }
1698        Ok(None) => Ok(false),
1699        Err(_) => Err(TransportError::DeserializationError),
1700    }
1701}
1702
1703/// Monomorphized service dispatch function.
1704///
1705/// # Safety
1706/// `ptr` must point to a valid, aligned `SrvEntry<Svc, F, REQ_BUF, REPLY_BUF>`.
1707pub(crate) unsafe fn srv_try_process<Svc, F, const REQ_BUF: usize, const REPLY_BUF: usize>(
1708    ptr: *mut u8,
1709    _delta_us: u64,
1710    desc_idx: u8,
1711) -> Result<bool, TransportError>
1712where
1713    Svc: RosService,
1714    F: FnMut(&Svc::Request) -> Svc::Reply,
1715{
1716    let entry = unsafe { &mut *(ptr as *mut SrvEntry<Svc, F, REQ_BUF, REPLY_BUF>) };
1717    // Split borrow: destructure entry to avoid aliasing issues
1718    let SrvEntry {
1719        handle,
1720        req_buffer,
1721        reply_buffer,
1722        callback,
1723        ..
1724    } = entry;
1725    handle
1726        // Phase 8 — hooked INSIDE the closure `handle_request` invokes, not
1727        // around `handle_request` itself. The closure runs once per request
1728        // actually received, so a spin with nothing pending emits nothing;
1729        // bracketing the outer call would have timed the receive/reply
1730        // machinery and fired on every empty poll.
1731        .handle_request::<Svc>(req_buffer, reply_buffer, |req| {
1732            trace_cb_start(desc_idx);
1733            let reply = (callback)(req);
1734            trace_cb_end(desc_idx);
1735            reply
1736        })
1737        .map_err(|_| TransportError::ServiceReplyFailed)
1738}
1739
1740/// Monomorphized drop function for arena entries.
1741///
1742/// # Safety
1743/// `ptr` must point to a valid, aligned `T` that has not been dropped.
1744pub(crate) unsafe fn drop_entry<T>(ptr: *mut u8) {
1745    unsafe { core::ptr::drop_in_place(ptr as *mut T) };
1746}
1747
1748/// Monomorphized timer dispatch function.
1749///
1750/// # Safety
1751/// `ptr` must point to a valid, aligned `TimerEntry<F>`.
1752pub(crate) unsafe fn timer_try_process<F>(
1753    ptr: *mut u8,
1754    delta_us: u64,
1755    desc_idx: u8,
1756) -> Result<bool, TransportError>
1757where
1758    F: FnMut(),
1759{
1760    let entry = unsafe { &mut *(ptr as *mut TimerEntry<F>) };
1761
1762    // Cancelled or one-shot already fired
1763    if entry.cancelled || (entry.oneshot && entry.fired) {
1764        return Ok(false);
1765    }
1766
1767    // phase-425 W4 — which clock advanced, and by how much. `Steady` is the
1768    // executor's spin delta, unchanged and free. The other two READ their clock
1769    // and diff against the last reading, which is what makes a paused simulator
1770    // pause the timer: `/clock` stops advancing, so the delta is zero.
1771    let delta_us = match entry.clock_source {
1772        TimerClockSource::Steady => delta_us,
1773        source => {
1774            let now_ns = source.now_ns();
1775            let step_ns = now_ns - entry.last_clock_ns;
1776            entry.last_clock_ns = now_ns;
1777            if step_ns < 0 {
1778                // A BACKWARDS jump — a bag looping, a simulator reset, an NTP
1779                // step. Restart the period rather than stalling the timer for
1780                // the length of the jump, which is what accumulating a negative
1781                // delta would amount to. rclcpp gets here through a jump
1782                // callback; we need the behaviour, not (yet) the callback
1783                // surface (`c:clock_add_jump_callback` stays declined).
1784                entry.elapsed_us = 0;
1785                return Ok(false);
1786            }
1787            // A FORWARD jump is deliberately NOT special-cased: it lands in
1788            // `elapsed_us` as a backlog, and the overrun policy below is the
1789            // documented mechanism for deciding whether a backlog replays
1790            // (`CatchUp`) or coalesces (`Skip`).
1791            (step_ns as u64) / 1_000
1792        }
1793    };
1794
1795    entry.elapsed_us = entry.elapsed_us.saturating_add(delta_us);
1796
1797    if entry.elapsed_us >= entry.period_us {
1798        // Phase 8 — hooked. Inside the due-check, so a timer polled on every
1799        // spin and not yet due emits nothing: `Ok(false)` is the COMMON
1800        // outcome here, and it is exactly the over-reporting that a hook at
1801        // the `try_process` boundary would have produced.
1802        trace_cb_start(desc_idx);
1803        (entry.callback)();
1804        trace_cb_end(desc_idx);
1805        if entry.oneshot {
1806            entry.fired = true;
1807        } else if entry.period_us == 0 {
1808            entry.elapsed_us = 0;
1809        } else {
1810            // Issue #505 — a stall leaves `elapsed_us` worth several
1811            // periods. `CatchUp` subtracts one period per pass, so the
1812            // backlog replays back-to-back; `Skip` drops the whole
1813            // backlog in one step and counts it, keeping the remainder
1814            // so activations stay on the original phase grid.
1815            match entry.overrun_policy {
1816                TimerOverrunPolicy::CatchUp => {
1817                    entry.elapsed_us = entry.elapsed_us.saturating_sub(entry.period_us);
1818                }
1819                TimerOverrunPolicy::Skip => {
1820                    let missed = entry.elapsed_us / entry.period_us - 1;
1821                    if missed > 0 {
1822                        entry.overruns = entry
1823                            .overruns
1824                            .saturating_add(u32::try_from(missed).unwrap_or(u32::MAX));
1825                    }
1826                    entry.elapsed_us %= entry.period_us;
1827                }
1828            }
1829        }
1830        Ok(true)
1831    } else {
1832        Ok(false)
1833    }
1834}
1835
1836/// Monomorphized action server dispatch function.
1837///
1838/// Polls goal acceptance, cancel handling, and result serving.
1839///
1840/// # Safety
1841/// `ptr` must point to a valid, aligned `ActionServerArenaEntry<...>`.
1842pub(crate) unsafe fn action_server_try_process<
1843    A,
1844    GoalF,
1845    CancelF,
1846    const GOAL_BUF: usize,
1847    const RESULT_BUF: usize,
1848    const FEEDBACK_BUF: usize,
1849    const MAX_GOALS: usize,
1850>(
1851    ptr: *mut u8,
1852    _delta_us: u64,
1853    desc_idx: u8,
1854) -> Result<bool, TransportError>
1855where
1856    A: RosAction,
1857    A::Goal: Clone,
1858    A::Result: Clone + Default,
1859    GoalF: FnMut(&nros_core::GoalId, &A::Goal) -> nros_core::GoalResponse,
1860    CancelF: FnMut(&nros_core::GoalId, nros_core::GoalStatus) -> nros_core::CancelResponse,
1861{
1862    let entry = unsafe {
1863        &mut *(ptr as *mut ActionServerArenaEntry<
1864            A,
1865            GoalF,
1866            CancelF,
1867            GOAL_BUF,
1868            RESULT_BUF,
1869            FEEDBACK_BUF,
1870            MAX_GOALS,
1871        >)
1872    };
1873    let ActionServerArenaEntry {
1874        server,
1875        goal_callback,
1876        cancel_callback,
1877    } = entry;
1878
1879    let mut did_work = false;
1880
1881    // Handle cancels first
1882    //
1883    // Phase 8 — hooked INSIDE the closure, not around `try_handle_cancel`.
1884    // The core runs this closure only when a cancel request is actually
1885    // pending; `Ok(None)` with no user code run is the COMMON outcome, so a
1886    // pair at the call boundary would report an invocation on every spin.
1887    if matches!(
1888        server.try_handle_cancel(|id, st| {
1889            trace_cb_start(desc_idx);
1890            let resp = (cancel_callback)(id, st);
1891            trace_cb_end(desc_idx);
1892            resp
1893        }),
1894        Ok(Some(_))
1895    ) {
1896        did_work = true;
1897    }
1898
1899    // Handle new goals
1900    //
1901    // Phase 8 — hooked inside the closure for the same reason. This is a
1902    // DISTINCT user callback from the cancel one above and gets its own
1903    // pair, so the two are counted separately rather than merged.
1904    if matches!(
1905        server.try_accept_goal(|id, g| {
1906            trace_cb_start(desc_idx);
1907            let resp = (goal_callback)(id, g);
1908            trace_cb_end(desc_idx);
1909            resp
1910        }),
1911        Ok(Some(_))
1912    ) {
1913        did_work = true;
1914    }
1915
1916    // Handle result requests
1917    if matches!(server.try_handle_get_result(), Ok(Some(_))) {
1918        did_work = true;
1919    }
1920
1921    Ok(did_work)
1922}
1923
1924/// Monomorphized raw action server dispatch function.
1925///
1926/// Polls goal acceptance, cancel handling, and result serving using raw bytes.
1927///
1928/// # Safety
1929/// `ptr` must point to a valid, aligned `ActionServerRawArenaEntry<...>`.
1930pub(crate) unsafe fn action_server_raw_try_process<
1931    const GOAL_BUF: usize,
1932    const RESULT_BUF: usize,
1933    const FEEDBACK_BUF: usize,
1934    const MAX_GOALS: usize,
1935>(
1936    ptr: *mut u8,
1937    _delta_us: u64,
1938    desc_idx: u8,
1939) -> Result<bool, TransportError> {
1940    let entry = unsafe {
1941        &mut *(ptr as *mut ActionServerRawArenaEntry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>)
1942    };
1943    let ActionServerRawArenaEntry {
1944        core,
1945        goal_callback,
1946        cancel_callback,
1947        accepted_callback,
1948        context,
1949    } = entry;
1950
1951    let mut did_work = false;
1952
1953    // Handle cancels first
1954    //
1955    // Phase 8 — hooked INSIDE the closure: the core runs it only when a cancel
1956    // request is actually pending, so nothing is emitted on the common
1957    // `Ok(None)` spin.
1958    if let Ok(Some(_)) = core.try_handle_cancel(|id, st| {
1959        trace_cb_start(desc_idx);
1960        let resp = unsafe { (*cancel_callback)(id, st, *context) };
1961        trace_cb_end(desc_idx);
1962        resp
1963    }) {
1964        did_work = true;
1965    }
1966
1967    // Handle new goals
1968    if let Ok(Some(raw_req)) = core.try_recv_goal_request() {
1969        let goal_data = &core.goal_buffer()[..raw_req.data_len];
1970        // Phase 8 — hooked inside the `Ok(Some(..))` arm: no goal request,
1971        // no goal callback, nothing emitted.
1972        trace_cb_start(desc_idx);
1973        let response = unsafe {
1974            (*goal_callback)(
1975                &raw_req.goal_id,
1976                goal_data.as_ptr(),
1977                raw_req.data_len,
1978                *context,
1979            )
1980        };
1981        trace_cb_end(desc_idx);
1982
1983        if response.is_accepted() {
1984            // Send the accept reply *before* running any long-running
1985            // post-accept hook so the client observes acceptance promptly.
1986            let _ = core.accept_goal(raw_req.goal_id, raw_req.sequence_number);
1987            // Phase 8 — the post-accept hook is a THIRD, separate user
1988            // callback: optional, and run only on the accepted branch, so
1989            // it carries its own pair rather than being folded into the
1990            // goal callback's span above.
1991            if let Some(post) = *accepted_callback {
1992                trace_cb_start(desc_idx);
1993                unsafe { post(&raw_req.goal_id, *context) };
1994                trace_cb_end(desc_idx);
1995            }
1996        } else {
1997            let _ = core.reject_goal(raw_req.sequence_number);
1998        }
1999        did_work = true;
2000    }
2001
2002    // Handle result requests (empty default result for raw API)
2003    if let Ok(Some(_)) = core.try_handle_get_result_raw(&[]) {
2004        did_work = true;
2005    }
2006
2007    Ok(did_work)
2008}
2009
2010/// Monomorphized raw action client dispatch function.
2011///
2012/// Polls the action client core's non-blocking methods:
2013/// 1. Goal acceptance reply (`try_recv_send_goal_reply`)
2014/// 2. Feedback (`try_recv_feedback_raw`)
2015/// 3. Result reply (`try_recv_get_result_reply`)
2016///
2017/// Invokes the corresponding callback when data is available.
2018///
2019/// # Safety
2020/// `ptr` must point to a valid, aligned `ActionClientRawArenaEntry<...>`.
2021/// RFC-0069 / issues 0418 + 0035 — **retired as a correctness mechanism.**
2022///
2023/// This sniffed whether a payload already began with a CDR encapsulation header
2024/// (`00 <id> <opts> <opts>`) and read it directly if so. That is a VALUE test,
2025/// not a framing test, and it cannot tell the two apart: a leading `uint32` of
2026/// 256 serializes little-endian as `00 01 00 00` — byte for byte the LE encap
2027/// header. A sequence of length 256 is enough.
2028///
2029/// It was harmless while only Cyclone took the false branch. 0418 stopped the
2030/// producer writing an inner header, so EVERY payload began consulting it, and
2031/// a body whose first word happened to look like a header had that word eaten
2032/// as framing — issue #35's "sequence deserialized to len 0", reached through a
2033/// payload value instead of a framing bug.
2034///
2035/// Kept only for the pre-0418 diagnostic below, never for a decode decision.
2036#[cfg(test)]
2037fn payload_has_cdr_encap(p: &[u8]) -> bool {
2038    p.len() >= 4 && p[0] == 0x00 && matches!(p[1], 0x00 | 0x01 | 0x06 | 0x07 | 0x0a | 0x0b)
2039}
2040
2041/// Deserialize an action result/feedback field payload, restoring the per-message
2042/// CDR encapsulation header the backend's typed framing may have stripped (#175).
2043/// `raw` is the field bytes at the payload offset; `top_encap` is the enclosing
2044/// message's leading 4-byte encap (always a valid header). When `raw` already
2045/// begins with an encap (zenoh/XRCE) it is read directly; when it does not
2046/// (Cyclone `dds_stream` drops the inner encap of a nested message field) the
2047/// top-level encap is spliced in front into a `CAP`-byte scratch buffer first.
2048fn read_action_field<M: nros_serdes::Deserialize, const CAP: usize>(
2049    top_encap: &[u8],
2050    raw: &[u8],
2051) -> Option<M> {
2052    // ALWAYS splice (RFC-0069 / issue 0418). Post-0418 the producer writes no
2053    // inner encapsulation header, and neither does Cyclone's `dds_stream` (it
2054    // drops the inner encap of a nested field, #175) — so the payload is field
2055    // bytes in every case and the enclosing message's encap is the right one to
2056    // read them with.
2057    //
2058    // This used to branch on `payload_has_cdr_encap(raw)`. That sniff is a value
2059    // test and gets it wrong for a body starting `00 01 00 00` (a `uint32` of
2060    // 256, e.g. a 256-element sequence), eating the leading data word — see the
2061    // retired helper above.
2062    if top_encap.len() < 4 || raw.len() + 4 > CAP {
2063        return None;
2064    }
2065    let mut buf = [0u8; CAP];
2066    buf[0..4].copy_from_slice(&top_encap[0..4]);
2067    buf[4..4 + raw.len()].copy_from_slice(raw);
2068    let mut reader = CdrReader::new_with_header(&buf[..4 + raw.len()]).ok()?;
2069    M::deserialize(&mut reader).ok()
2070}
2071
2072pub(crate) unsafe fn action_client_raw_try_process<
2073    const GOAL_BUF: usize,
2074    const RESULT_BUF: usize,
2075    const FEEDBACK_BUF: usize,
2076>(
2077    ptr: *mut u8,
2078    _delta_us: u64,
2079    desc_idx: u8,
2080) -> Result<bool, TransportError> {
2081    let entry = unsafe {
2082        &mut *(ptr as *mut ActionClientRawArenaEntry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>)
2083    };
2084    let ActionClientRawArenaEntry {
2085        core,
2086        goal_response_callback,
2087        feedback_callback,
2088        result_callback,
2089        context,
2090    } = entry;
2091
2092    let mut did_work = false;
2093
2094    // 1. Poll goal acceptance reply
2095    if let Ok(Some(total_len)) = core.try_recv_send_goal_reply() {
2096        if let Some(cb) = goal_response_callback {
2097            // Reply CDR: header (4) + accepted (u8) + stamp
2098            let accepted = total_len >= 5 && core.result_buffer[4] != 0;
2099            // Extract GoalId from the last sent goal
2100            let goal_id = nros_core::GoalId {
2101                uuid: {
2102                    let mut uuid = [0u8; 16];
2103                    let counter = core.goal_counter.to_le_bytes();
2104                    uuid[..8].copy_from_slice(&counter);
2105                    uuid
2106                },
2107            };
2108            // Phase 8 — hooked at the invocation, inside both the
2109            // `Ok(Some(..))` arm and the `Some(cb)` guard: an unregistered
2110            // callback or an empty poll emits nothing.
2111            trace_cb_start(desc_idx);
2112            unsafe { cb(&goal_id, accepted, *context) };
2113            trace_cb_end(desc_idx);
2114        }
2115        did_work = true;
2116    }
2117
2118    // 2. Poll feedback
2119    if let Ok(Some((goal_id, total_len))) = core.try_recv_feedback_raw() {
2120        if let Some(cb) = feedback_callback {
2121            // Feedback buffer layout from `publish_feedback_raw` in
2122            // `action_core.rs`:
2123            //   bytes 0..4   outer CDR header (`new_with_header`)
2124            //   bytes 4..20  GoalId.uuid (16 bytes, fixed `uint8[16]`,
2125            //                no length prefix — ROS 2
2126            //                `unique_identifier_msgs/UUID`; see
2127            //                `action_core::write_goal_id`)
2128            //   bytes 20..   payload — exactly the bytes the caller
2129            //                of `publish_feedback_raw` handed in
2130            //                (typed serializers like `ffi_serialize`
2131            //                write a CDR header at the front).
2132            //
2133            // 233.6: the GoalId carries NO sequence-length prefix (it did
2134            // pre-233.6, which made the offset `4 + 4 + 16`; that framing
2135            // self-matched nano-ros peers but a real `rcl_action` peer
2136            // rejects the extra 4 bytes).
2137            const FEEDBACK_PAYLOAD_OFFSET: usize = 4 + 16;
2138            if total_len > FEEDBACK_PAYLOAD_OFFSET {
2139                // #175 + RFC-0069/0418 — the payload is field bytes on every
2140                // backend now: Cyclone's `dds_stream` drops the inner encap of a
2141                // nested field, and since 0418 the producer never writes one. So
2142                // splice the enclosing message's encap unconditionally. This used
2143                // to branch on an encap SNIFF, which mis-fires on a body whose
2144                // first word is 0x00010000 (see the retired helper).
2145                let raw = &core.feedback_buffer[FEEDBACK_PAYLOAD_OFFSET..total_len];
2146                let mut spliced = [0u8; FEEDBACK_BUF];
2147                let n = raw.len().min(FEEDBACK_BUF - 4);
2148                spliced[0..4].copy_from_slice(&core.feedback_buffer[0..4]);
2149                spliced[4..4 + n].copy_from_slice(&raw[..n]);
2150                // Phase 8 — a DISTINCT callback from the goal-response one,
2151                // with its own pair. Hooked inside the
2152                // `total_len > FEEDBACK_PAYLOAD_OFFSET` guard: a short
2153                // payload runs no user code and must emit nothing.
2154                trace_cb_start(desc_idx);
2155                unsafe { cb(&goal_id, spliced.as_ptr(), 4 + n, *context) };
2156                trace_cb_end(desc_idx);
2157            }
2158        }
2159        did_work = true;
2160    }
2161
2162    // 3. Poll result reply
2163    if let Ok(Some(total_len)) = core.try_recv_get_result_reply() {
2164        if let Some(cb) = result_callback {
2165            // Reply layout from `try_handle_get_result_raw` in
2166            // `action_core.rs`:
2167            //   bytes 0..4   outer CDR header (`new_with_header`)
2168            //   byte  4      status (i8)
2169            //   bytes 5..8   align(4) pad
2170            //   bytes 8..    payload — exactly the bytes the caller
2171            //                of `complete_goal_raw` handed in (typed
2172            //                serializers like `ffi_serialize` write
2173            //                a CDR header at the front, which is
2174            //                why the alignment pad above is sized
2175            //                to land the payload at a 4-byte boundary).
2176            //
2177            // The trampoline forwards `payload` to the C/C++
2178            // callback verbatim — the cpp wrapper expects to see
2179            // the inner CDR header that `ffi_serialize` wrote.
2180            // Earlier code used `result_offset = 5` and skipped
2181            // only the status byte; that leaked the 3 alignment
2182            // pad bytes into the payload prefix and blew up
2183            // `ffi_deserialize`, surfacing as an empty result on
2184            // the cpp/xrce action client (Phase 96.1 follow-up).
2185            const RESULT_PAYLOAD_OFFSET: usize = 8;
2186            if total_len >= RESULT_PAYLOAD_OFFSET {
2187                let status_byte = core.result_buffer[4];
2188                let status = match status_byte {
2189                    4 => nros_core::GoalStatus::Succeeded,
2190                    5 => nros_core::GoalStatus::Canceled,
2191                    6 => nros_core::GoalStatus::Aborted,
2192                    _ => nros_core::GoalStatus::Unknown,
2193                };
2194                // Extract GoalId from the last sent goal
2195                let goal_id = nros_core::GoalId {
2196                    uuid: {
2197                        let mut uuid = [0u8; 16];
2198                        let counter = core.goal_counter.to_le_bytes();
2199                        uuid[..8].copy_from_slice(&counter);
2200                        uuid
2201                    },
2202                };
2203                // #175 + RFC-0069/0418 — restore the result's CDR encapsulation
2204                // header. The fields arrive raw at `RESULT_PAYLOAD_OFFSET` on
2205                // every backend now: Cyclone sends `GetResult_Response` as a
2206                // TYPED sample whose `result` is a NESTED field, so `dds_stream`
2207                // consumes the inner encap; and since 0418 the producer never
2208                // writes one. The consumer (`CallbackCtx::message` /
2209                // `ffi_deserialize`) reads with `new_with_header`, so splice the
2210                // reply's top-level encap (`result_buffer[0..4]`, always valid)
2211                // in front unconditionally.
2212                //
2213                // This used to branch on an encap SNIFF, justified by the claim
2214                // that "a raw CDR field never begins with an encoding
2215                // identifier". That claim is false — a leading `int32` of 256 is
2216                // `00 01 00 00` — and once 0418 made every payload header-less
2217                // the sniff decided every decode, so such a body had its first
2218                // word eaten as framing: the very corruption this comment warns
2219                // about, caused by the guard against it. See the retired
2220                // `payload_has_cdr_encap`.
2221                let raw = &core.result_buffer[RESULT_PAYLOAD_OFFSET..total_len];
2222                let mut spliced = [0u8; RESULT_BUF];
2223                let n = raw.len().min(RESULT_BUF - 4);
2224                spliced[0..4].copy_from_slice(&core.result_buffer[0..4]);
2225                spliced[4..4 + n].copy_from_slice(&raw[..n]);
2226                // Phase 8 — the third distinct callback on this entry, its
2227                // own pair. Inside the `total_len >= RESULT_PAYLOAD_OFFSET`
2228                // guard, which can skip the call entirely.
2229                trace_cb_start(desc_idx);
2230                unsafe { cb(&goal_id, status, spliced.as_ptr(), 4 + n, *context) };
2231                trace_cb_end(desc_idx);
2232            }
2233        }
2234        did_work = true;
2235    }
2236
2237    Ok(did_work)
2238}
2239
2240/// Monomorphized raw service-client dispatch function.
2241///
2242/// Checks `reply_ready` (set by the transport waker) before calling
2243/// `take_response_raw`. This avoids blind polling on every spin tick —
2244/// the only cost per tick is an atomic load when no reply is pending.
2245///
2246/// # Safety
2247/// `ptr` must point to a valid, aligned `ServiceClientRawArenaEntry<REPLY_BUF>`.
2248pub(crate) unsafe fn service_client_raw_try_process<const REPLY_BUF: usize>(
2249    ptr: *mut u8,
2250    _delta_us: u64,
2251    desc_idx: u8,
2252) -> Result<bool, TransportError> {
2253    use core::sync::atomic::Ordering;
2254    use nros_rmw::ClientTrait;
2255    let entry = unsafe { &mut *(ptr as *mut ServiceClientRawArenaEntry<REPLY_BUF>) };
2256
2257    if !entry.pending {
2258        return Ok(false);
2259    }
2260
2261    // Clear the waker flag if set (consumed by this check).
2262    entry.reply_ready.store(false, Ordering::Release);
2263
2264    // Issue 0778 — one call per arena entry today, so the sequence id is
2265    // discarded rather than absent. Correlating here means keying entries by
2266    // it, which is the follow-up that issue tracks.
2267    match entry.handle.take_response_raw(&mut entry.reply_buffer) {
2268        Ok(Some((len, _seq))) => {
2269            entry.pending = false;
2270            // Phase 8 — hooked inside the `Some(cb)` arm. The callback is
2271            // optional here, so a client registered without one completes
2272            // the reply and emits nothing; the not-pending and `Ok(None)`
2273            // paths return before this point.
2274            if let Some(cb) = entry.callback {
2275                trace_cb_start(desc_idx);
2276                unsafe { cb(entry.reply_buffer.as_ptr(), len, entry.context) };
2277                trace_cb_end(desc_idx);
2278            }
2279            Ok(true)
2280        }
2281        Ok(None) => Ok(false),
2282        Err(_) => {
2283            entry.pending = false;
2284            Err(TransportError::ServiceRequestFailed)
2285        }
2286    }
2287}
2288
2289/// RFC-0041 / Phase 239.1 — F-independent prefix of a typed service-client
2290/// callback entry. `#[repr(C)]` guarantees it is the leading member of every
2291/// [`ServiceClientCallbackEntry`] regardless of the closure type `F`, so a
2292/// `ServiceClientCallback` handle can hold a `*mut` to it and send requests
2293/// (serialize → `send_request_raw` → set `pending`) without naming `F`.
2294#[repr(C)]
2295pub struct ServiceClientSendHeader<const REPLY_BUF: usize> {
2296    pub handle: session::RmwServiceClient,
2297    pub reply_buffer: [u8; REPLY_BUF],
2298    pub pending: bool,
2299    /// Set by the transport waker when a reply arrives (mirrors the raw entry).
2300    pub reply_ready: core::sync::atomic::AtomicBool,
2301}
2302
2303/// Typed service-client callback entry (RFC-0041, Phase 239.1). The executor
2304/// eager-drains the reply at `spin_once` and dispatches it as a deserialized
2305/// `Svc::Reply` to the user closure — the typed analogue of
2306/// [`ServiceClientRawArenaEntry`]. The send side goes through the embedded
2307/// [`ServiceClientSendHeader`] (offset 0) via a `ServiceClientCallback` handle.
2308#[repr(C)]
2309pub(crate) struct ServiceClientCallbackEntry<Svc: RosService, F, const REPLY_BUF: usize> {
2310    pub(crate) hdr: ServiceClientSendHeader<REPLY_BUF>,
2311    pub(crate) callback: F,
2312    pub(crate) _phantom: PhantomData<Svc>,
2313}
2314
2315/// Monomorphized typed service-client dispatch (RFC-0041, Phase 239.1).
2316///
2317/// Mirrors [`service_client_raw_try_process`] but deserializes the reply into
2318/// `Svc::Reply` and invokes the typed closure. Single in-flight request gated by
2319/// `hdr.pending`; the reply view is dropped before return (no escape).
2320///
2321/// # Safety
2322/// `ptr` must point to a valid, aligned `ServiceClientCallbackEntry<Svc, F, REPLY_BUF>`.
2323pub(crate) unsafe fn service_client_callback_try_process<Svc, F, const REPLY_BUF: usize>(
2324    ptr: *mut u8,
2325    _delta_us: u64,
2326    desc_idx: u8,
2327) -> Result<bool, TransportError>
2328where
2329    Svc: RosService,
2330    F: FnMut(&Svc::Reply),
2331{
2332    use core::sync::atomic::Ordering;
2333    use nros_rmw::ClientTrait;
2334    let entry = unsafe { &mut *(ptr as *mut ServiceClientCallbackEntry<Svc, F, REPLY_BUF>) };
2335
2336    if !entry.hdr.pending {
2337        return Ok(false);
2338    }
2339    entry.hdr.reply_ready.store(false, Ordering::Release);
2340
2341    match entry
2342        .hdr
2343        .handle
2344        .take_response_raw(&mut entry.hdr.reply_buffer)
2345    {
2346        Ok(Some((len, _seq))) => {
2347            entry.hdr.pending = false;
2348            let mut reader = CdrReader::new_with_header(&entry.hdr.reply_buffer[..len])
2349                .map_err(|_| TransportError::DeserializationError)?;
2350            // Fully-qualify the `Deserialize` trait (mirrors the
2351            // `DeserializeView` call above): arena.rs imports
2352            // `DeserializeView` but not `Deserialize`, so the bare
2353            // `Svc::Reply::deserialize` only resolved when a default/std feature
2354            // happened to glob it into scope — under `rmw-cffi` (embedded) it
2355            // failed E0599. The fully-qualified path resolves under every feature.
2356            let reply = <Svc::Reply as nros_serdes::Deserialize>::deserialize(&mut reader)
2357                .map_err(|_| TransportError::DeserializationError)?;
2358            // Phase 8 — hooked AFTER both deserialization `?`s. Placing the
2359            // start any earlier would let a `DeserializationError` return
2360            // between start and end and leave an unbalanced span; here the
2361            // callback is the only thing between the pair, so the measured
2362            // interval is user code and nothing else.
2363            trace_cb_start(desc_idx);
2364            (entry.callback)(&reply);
2365            trace_cb_end(desc_idx);
2366            Ok(true)
2367        }
2368        Ok(None) => Ok(false),
2369        Err(_) => {
2370            entry.hdr.pending = false;
2371            Err(TransportError::ServiceRequestFailed)
2372        }
2373    }
2374}
2375
2376/// Typed action-client callback entry (RFC-0041, Phase 239.2). The executor
2377/// eager-drains the three client receives (goal-response / feedback / result)
2378/// at `spin_once` and dispatches them as deserialized `A::Feedback` / `A::Result`
2379/// to typed closures — the typed analogue of [`ActionClientRawArenaEntry`]. The
2380/// send side (`send_goal` / `get_result`) goes through the embedded `core`
2381/// (offset 0) via an `ActionClientCallback` handle.
2382#[repr(C)]
2383pub(crate) struct ActionClientCallbackEntry<
2384    A: RosAction,
2385    GRespF,
2386    FbF,
2387    ResF,
2388    const GOAL_BUF: usize,
2389    const RESULT_BUF: usize,
2390    const FEEDBACK_BUF: usize,
2391> {
2392    pub(crate) core: ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
2393    /// RFC-0041 / Phase 239.5 — the feedback stream's QoS-depth buffer. The
2394    /// callback path drains `core.feedback_subscriber` directly into this ring
2395    /// (depth > 1) or triple-buffer (depth ≤ 1), so a burst of feedbacks between
2396    /// spins is buffered/reported instead of overwriting a single slot — and the
2397    /// shared `ActionClientCore` buffers (the `Promise` path) stay untouched.
2398    pub(crate) feedback_buffer: BufferStrategy,
2399    pub(crate) on_goal_response: GRespF,
2400    pub(crate) on_feedback: FbF,
2401    pub(crate) on_result: ResF,
2402    pub(crate) _phantom: PhantomData<A>,
2403}
2404
2405/// Reconstruct a `GoalId` from the core's monotonically increasing counter
2406/// (mirrors `action_client_raw_try_process`).
2407#[inline]
2408fn goal_id_from_counter(counter: u64) -> nros_core::GoalId {
2409    let mut uuid = [0u8; 16];
2410    uuid[..8].copy_from_slice(&counter.to_le_bytes());
2411    nros_core::GoalId { uuid }
2412}
2413
2414/// Decode one raw feedback slot (outer header + GoalId at [4..20] + inner-CDR
2415/// payload at `offset`) and invoke the typed feedback closure (Phase 239.5).
2416///
2417/// `desc_idx` is threaded in purely so the Phase 8 callback-trace pair can
2418/// live at the invocation itself. This function has no entry and no slot of
2419/// its own to recover an identity from, and it returns WITHOUT firing on a
2420/// short payload or a failed field read — so bracketing its two call sites
2421/// instead would report invocations that never happened.
2422#[inline]
2423fn dispatch_feedback<A, F, const FEEDBACK_BUF: usize>(
2424    data: &[u8],
2425    offset: usize,
2426    on_feedback: &mut F,
2427    desc_idx: u8,
2428) where
2429    A: RosAction,
2430    F: FnMut(&nros_core::GoalId, &A::Feedback),
2431{
2432    if data.len() <= offset {
2433        return;
2434    }
2435    let mut uuid = [0u8; 16];
2436    uuid.copy_from_slice(&data[4..20]);
2437    let goal_id = nros_core::GoalId { uuid };
2438    // #175 — restore the feedback's per-message encap if a typed transport
2439    // framing (Cyclone) stripped it; the message's top-level encap is `data[0..4]`.
2440    //
2441    // Phase 8 — hooked INSIDE the `if let Some(fb)`: the early `return`
2442    // above and a `None` from `read_action_field` both leave the user
2443    // closure un-run, and neither may emit a span.
2444    if let Some(fb) = read_action_field::<A::Feedback, FEEDBACK_BUF>(&data[0..4], &data[offset..]) {
2445        trace_cb_start(desc_idx);
2446        on_feedback(&goal_id, &fb);
2447        trace_cb_end(desc_idx);
2448    }
2449}
2450
2451/// Monomorphized typed action-client dispatch (RFC-0041, Phase 239.2). Mirrors
2452/// [`action_client_raw_try_process`] but deserializes the feedback / result
2453/// payloads into `A::Feedback` / `A::Result` and invokes the typed closures.
2454///
2455/// # Safety
2456/// `ptr` must point to a valid, aligned `ActionClientCallbackEntry<…>`.
2457#[allow(clippy::type_complexity)]
2458pub(crate) unsafe fn action_client_callback_try_process<
2459    A,
2460    GRespF,
2461    FbF,
2462    ResF,
2463    const GOAL_BUF: usize,
2464    const RESULT_BUF: usize,
2465    const FEEDBACK_BUF: usize,
2466>(
2467    ptr: *mut u8,
2468    _delta_us: u64,
2469    desc_idx: u8,
2470) -> Result<bool, TransportError>
2471where
2472    A: RosAction,
2473    GRespF: FnMut(&nros_core::GoalId, bool),
2474    FbF: FnMut(&nros_core::GoalId, &A::Feedback),
2475    ResF: FnMut(&nros_core::GoalId, nros_core::GoalStatus, &A::Result),
2476{
2477    let entry = unsafe {
2478        &mut *(ptr as *mut ActionClientCallbackEntry<
2479            A,
2480            GRespF,
2481            FbF,
2482            ResF,
2483            GOAL_BUF,
2484            RESULT_BUF,
2485            FEEDBACK_BUF,
2486        >)
2487    };
2488    let ActionClientCallbackEntry {
2489        core,
2490        feedback_buffer,
2491        on_goal_response,
2492        on_feedback,
2493        on_result,
2494        _phantom,
2495    } = entry;
2496
2497    let mut did_work = false;
2498
2499    // 1. Goal-acceptance reply (single-outstanding → gated single buffer).
2500    if let Ok(Some(total_len)) = core.try_recv_send_goal_reply() {
2501        let accepted = total_len >= 5 && core.result_buffer[4] != 0;
2502        let goal_id = goal_id_from_counter(core.goal_counter);
2503        // Phase 8 — hooked inside the `Ok(Some(..))` arm; an empty poll is
2504        // the common case and runs no user code.
2505        trace_cb_start(desc_idx);
2506        on_goal_response(&goal_id, accepted);
2507        trace_cb_end(desc_idx);
2508        did_work = true;
2509    }
2510
2511    // 2. Feedback — a stream: drain `feedback_subscriber` into the QoS-depth ring
2512    //    (Phase 239.5), then dispatch each slot. Each slot holds the raw feedback
2513    //    message: outer CDR header (4) + GoalId (16) + inner-CDR payload; see the
2514    //    raw dispatcher for the layout rationale (233.6).
2515    {
2516        const FEEDBACK_PAYLOAD_OFFSET: usize = 4 + 16;
2517        match feedback_buffer {
2518            BufferStrategy::Triple(tb) => {
2519                let slot = tb.write_slot();
2520                if let Ok(Some(len)) = core.feedback_subscriber.take_serialized(slot) {
2521                    tb.writer_publish(len);
2522                }
2523                if let Some((data, len)) = tb.reader_acquire() {
2524                    // Phase 8 — the pair lives INSIDE `dispatch_feedback`
2525                    // (it can return without firing); `desc_idx` is threaded
2526                    // in so the leaf can name itself.
2527                    dispatch_feedback::<A, _, FEEDBACK_BUF>(
2528                        &data[..len],
2529                        FEEDBACK_PAYLOAD_OFFSET,
2530                        on_feedback,
2531                        desc_idx,
2532                    );
2533                    did_work = true;
2534                }
2535            }
2536            BufferStrategy::Ring(ring) => {
2537                while let Some(slot) = ring.try_push() {
2538                    match core.feedback_subscriber.take_serialized(slot) {
2539                        Ok(Some(len)) => ring.commit_push(len),
2540                        _ => break,
2541                    }
2542                }
2543                while let Some((data, len)) = ring.try_pop() {
2544                    // Phase 8 — one pair per drained slot, emitted by
2545                    // `dispatch_feedback` itself, so a ring draining N
2546                    // messages reports N invocations rather than one.
2547                    dispatch_feedback::<A, _, FEEDBACK_BUF>(
2548                        &data[..len],
2549                        FEEDBACK_PAYLOAD_OFFSET,
2550                        on_feedback,
2551                        desc_idx,
2552                    );
2553                    ring.commit_pop();
2554                    did_work = true;
2555                }
2556            }
2557        }
2558    }
2559
2560    // 3. Result reply — status at byte 4, payload at [8 ..] (header + status +
2561    //    align pad); see the raw dispatcher (Phase 96.1).
2562    if let Ok(Some(total_len)) = core.try_recv_get_result_reply() {
2563        const RESULT_PAYLOAD_OFFSET: usize = 8;
2564        if total_len >= RESULT_PAYLOAD_OFFSET {
2565            let status = match core.result_buffer[4] {
2566                4 => nros_core::GoalStatus::Succeeded,
2567                5 => nros_core::GoalStatus::Canceled,
2568                6 => nros_core::GoalStatus::Aborted,
2569                _ => nros_core::GoalStatus::Unknown,
2570            };
2571            let goal_id = goal_id_from_counter(core.goal_counter);
2572            // #175 — restore the result's per-message encap if a typed transport
2573            // framing (Cyclone) stripped it; the reply's top-level encap is
2574            // `result_buffer[0..4]`.
2575            if let Some(res) = read_action_field::<A::Result, RESULT_BUF>(
2576                &core.result_buffer[0..4],
2577                &core.result_buffer[RESULT_PAYLOAD_OFFSET..total_len],
2578            ) {
2579                // Phase 8 — inside the `Some(res)` arm: a failed
2580                // deserialize leaves `on_result` un-run and must emit
2581                // nothing. Distinct callback, its own pair.
2582                trace_cb_start(desc_idx);
2583                on_result(&goal_id, status, &res);
2584                trace_cb_end(desc_idx);
2585            }
2586        }
2587        did_work = true;
2588    }
2589
2590    Ok(did_work)
2591}
2592
2593/// Monomorphized raw service dispatch function.
2594///
2595/// # Safety
2596/// `ptr` must point to a valid, aligned `SrvRawEntry<REQ_BUF, REPLY_BUF>`.
2597pub(crate) unsafe fn srv_raw_try_process<const REQ_BUF: usize, const REPLY_BUF: usize>(
2598    ptr: *mut u8,
2599    _delta_us: u64,
2600    desc_idx: u8,
2601) -> Result<bool, TransportError> {
2602    let entry = unsafe { &mut *(ptr as *mut SrvRawEntry<REQ_BUF, REPLY_BUF>) };
2603    let SrvRawEntry {
2604        handle,
2605        req_buffer,
2606        reply_buffer,
2607        callback,
2608        context,
2609    } = entry;
2610    let buf_start = req_buffer.as_ptr() as usize;
2611    let (data_offset, data_len, seq_num) = match handle.take_request(req_buffer) {
2612        Ok(Some(request)) => {
2613            let offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
2614            let len = request.data.len();
2615            let seq = request.sequence_number;
2616            (offset, len, seq)
2617        }
2618        Ok(None) => return Ok(false),
2619        Err(_) => return Err(TransportError::ServiceReplyFailed),
2620    };
2621
2622    let mut resp_len: usize = 0;
2623    // Phase 8 — hooked below the `Ok(None) => return Ok(false)` arm of the
2624    // receive, so an empty poll emits nothing. The `?` on `send_response`
2625    // sits AFTER `trace_cb_end`, so no early exit can strand an open span.
2626    trace_cb_start(desc_idx);
2627    let ok = unsafe {
2628        (*callback)(
2629            req_buffer.as_ptr().add(data_offset),
2630            data_len,
2631            reply_buffer.as_mut_ptr(),
2632            REPLY_BUF,
2633            &mut resp_len,
2634            *context,
2635        )
2636    };
2637    trace_cb_end(desc_idx);
2638    if ok && resp_len > 0 {
2639        handle
2640            .send_response(seq_num, &reply_buffer[..resp_len])
2641            .map_err(|_| TransportError::ServiceReplyFailed)?;
2642    }
2643    Ok(true)
2644}
2645
2646/// Monomorphized guard condition dispatch function.
2647///
2648/// # Safety
2649/// `ptr` must point to a valid, aligned `GuardConditionEntry<F>`.
2650pub(crate) unsafe fn guard_try_process<F>(
2651    ptr: *mut u8,
2652    _delta_us: u64,
2653    desc_idx: u8,
2654) -> Result<bool, TransportError>
2655where
2656    F: FnMut(),
2657{
2658    let entry = unsafe { &mut *(ptr as *mut GuardConditionEntry<F>) };
2659    if entry.flag.swap(false, portable_atomic::Ordering::AcqRel) {
2660        // Phase 8 — hooked inside the flag-consuming branch, the same shape
2661        // as `timer_try_process`. A guard is polled on every spin and is
2662        // almost always un-triggered, so `Ok(false)` is the common outcome
2663        // and must stay silent.
2664        trace_cb_start(desc_idx);
2665        (entry.callback)();
2666        trace_cb_end(desc_idx);
2667        Ok(true)
2668    } else {
2669        Ok(false)
2670    }
2671}
2672
2673// ============================================================================
2674// Readiness check functions
2675// ============================================================================
2676
2677/// SubInfoEntry readiness.
2678///
2679/// # Safety
2680/// `ptr` must point to a valid `SubInfoEntry<M, F, RX_BUF>`.
2681pub(crate) unsafe fn sub_info_has_data<M, F, const RX_BUF: usize>(ptr: *const u8) -> bool {
2682    let entry = unsafe { &*(ptr as *const SubInfoEntry<M, F, RX_BUF>) };
2683    entry.handle.has_data()
2684}
2685
2686/// SubSafetyEntry readiness.
2687///
2688/// # Safety
2689/// `ptr` must point to a valid `SubSafetyEntry<M, F, RX_BUF>`.
2690#[cfg(feature = "safety-e2e")]
2691pub(crate) unsafe fn sub_safety_has_data<M, F, const RX_BUF: usize>(ptr: *const u8) -> bool {
2692    let entry = unsafe { &*(ptr as *const SubSafetyEntry<M, F, RX_BUF>) };
2693    entry.handle.has_data()
2694}
2695
2696/// Service readiness: check `has_request()` on the service handle.
2697///
2698/// # Safety
2699/// `ptr` must point to a valid `SrvEntry<Svc, F, RQ, RP>`.
2700pub(crate) unsafe fn srv_has_data<Svc: RosService, F, const RQ: usize, const RP: usize>(
2701    ptr: *const u8,
2702) -> bool {
2703    let entry = unsafe { &*(ptr as *const SrvEntry<Svc, F, RQ, RP>) };
2704    entry.handle.has_request()
2705}
2706
2707/// Raw service readiness.
2708///
2709/// # Safety
2710/// `ptr` must point to a valid `SrvRawEntry<RQ, RP>`.
2711pub(crate) unsafe fn srv_raw_has_data<const RQ: usize, const RP: usize>(ptr: *const u8) -> bool {
2712    let entry = unsafe { &*(ptr as *const SrvRawEntry<RQ, RP>) };
2713    entry.handle.has_request()
2714}
2715
2716/// Guard condition readiness: check the atomic flag.
2717///
2718/// # Safety
2719/// `ptr` must point to a valid `GuardConditionEntry<F>`.
2720pub(crate) unsafe fn guard_has_data<F>(ptr: *const u8) -> bool {
2721    let entry = unsafe { &*(ptr as *const GuardConditionEntry<F>) };
2722    entry.flag.load(portable_atomic::Ordering::Acquire)
2723}
2724
2725/// Timers and action entries are always considered ready.
2726pub(crate) unsafe fn always_ready(_ptr: *const u8) -> bool {
2727    true
2728}
2729
2730// ============================================================================
2731// LET pre-sample functions
2732// ============================================================================
2733
2734/// Pre-sample a typed subscription with MessageInfo for LET mode.
2735///
2736/// # Safety
2737/// `ptr` must point to a valid, aligned `SubInfoEntry<M, F, RX_BUF>`.
2738pub(crate) unsafe fn sub_info_pre_sample<M, F, const RX_BUF: usize>(ptr: *mut u8) {
2739    let entry = unsafe { &mut *(ptr as *mut SubInfoEntry<M, F, RX_BUF>) };
2740    // For LET, we sample only the data (MessageInfo is not preserved in the snapshot)
2741    entry.sampled_len = match entry.handle.take_serialized(&mut entry.buffer) {
2742        Ok(Some(len)) => len,
2743        _ => 0,
2744    };
2745}
2746
2747/// Pre-sample a safety subscription for LET mode.
2748///
2749/// # Safety
2750/// `ptr` must point to a valid, aligned `SubSafetyEntry<M, F, RX_BUF>`.
2751#[cfg(feature = "safety-e2e")]
2752pub(crate) unsafe fn sub_safety_pre_sample<M, F, const RX_BUF: usize>(ptr: *mut u8) {
2753    let entry = unsafe { &mut *(ptr as *mut SubSafetyEntry<M, F, RX_BUF>) };
2754    entry.sampled_len = match entry.handle.take_serialized(&mut entry.buffer) {
2755        Ok(Some(len)) => len,
2756        _ => 0,
2757    };
2758}
2759
2760/// No-op pre-sample for non-subscription entries (services, timers, etc.).
2761pub(crate) unsafe fn no_pre_sample(_ptr: *mut u8) {}
2762
2763// ============================================================================
2764// Monomorphized handle operation functions
2765// ============================================================================
2766
2767/// Action server: publish feedback via arena entry.
2768///
2769/// # Safety
2770/// `ptr` must point to a valid `ActionServerArenaEntry`.
2771pub(crate) unsafe fn as_publish_feedback<
2772    A,
2773    GoalF,
2774    CancelF,
2775    const GB: usize,
2776    const RB: usize,
2777    const FB: usize,
2778    const MG: usize,
2779>(
2780    ptr: *mut u8,
2781    goal_id: &nros_core::GoalId,
2782    feedback: &A::Feedback,
2783) -> Result<(), NodeError>
2784where
2785    A: RosAction,
2786{
2787    let entry =
2788        unsafe { &mut *(ptr as *mut ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
2789    entry.server.publish_feedback(goal_id, feedback)
2790}
2791
2792/// Action server: complete a goal via arena entry.
2793///
2794/// # Safety
2795/// `ptr` must point to a valid `ActionServerArenaEntry`.
2796pub(crate) unsafe fn as_complete_goal<
2797    A,
2798    GoalF,
2799    CancelF,
2800    const GB: usize,
2801    const RB: usize,
2802    const FB: usize,
2803    const MG: usize,
2804>(
2805    ptr: *mut u8,
2806    goal_id: &nros_core::GoalId,
2807    status: nros_core::GoalStatus,
2808    result: A::Result,
2809) -> Result<(), NodeError>
2810where
2811    A: RosAction,
2812{
2813    let entry =
2814        unsafe { &mut *(ptr as *mut ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
2815    entry.server.complete_goal(goal_id, status, result)
2816}
2817
2818/// Action server: set goal status via arena entry.
2819///
2820/// # Safety
2821/// `ptr` must point to a valid `ActionServerArenaEntry`.
2822pub(crate) unsafe fn as_set_goal_status<
2823    A,
2824    GoalF,
2825    CancelF,
2826    const GB: usize,
2827    const RB: usize,
2828    const FB: usize,
2829    const MG: usize,
2830>(
2831    ptr: *mut u8,
2832    goal_id: &nros_core::GoalId,
2833    status: nros_core::GoalStatus,
2834) where
2835    A: RosAction,
2836{
2837    let entry =
2838        unsafe { &mut *(ptr as *mut ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
2839    entry.server.set_goal_status(goal_id, status);
2840}
2841
2842/// Action server: get active goal count via arena entry.
2843///
2844/// # Safety
2845/// `ptr` must point to a valid `ActionServerArenaEntry`.
2846pub(crate) unsafe fn as_active_goal_count<
2847    A,
2848    GoalF,
2849    CancelF,
2850    const GB: usize,
2851    const RB: usize,
2852    const FB: usize,
2853    const MG: usize,
2854>(
2855    ptr: *const u8,
2856) -> usize
2857where
2858    A: RosAction,
2859{
2860    let entry =
2861        unsafe { &*(ptr as *const ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
2862    entry.server.active_goal_count()
2863}
2864
2865/// Raw action server: publish feedback via arena entry.
2866///
2867/// # Safety
2868/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
2869pub(crate) unsafe fn as_raw_publish_feedback<
2870    const GB: usize,
2871    const RB: usize,
2872    const FB: usize,
2873    const MG: usize,
2874>(
2875    ptr: *mut u8,
2876    goal_id: &nros_core::GoalId,
2877    feedback_data: *const u8,
2878    feedback_len: usize,
2879) -> Result<(), NodeError> {
2880    let entry = unsafe { &mut *(ptr as *mut ActionServerRawArenaEntry<GB, RB, FB, MG>) };
2881    let feedback_cdr = unsafe { core::slice::from_raw_parts(feedback_data, feedback_len) };
2882    entry.core.publish_feedback_raw(goal_id, feedback_cdr)
2883}
2884
2885/// Raw action server: complete a goal via arena entry.
2886///
2887/// # Safety
2888/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
2889pub(crate) unsafe fn as_raw_complete_goal<
2890    const GB: usize,
2891    const RB: usize,
2892    const FB: usize,
2893    const MG: usize,
2894>(
2895    ptr: *mut u8,
2896    goal_id: &nros_core::GoalId,
2897    status: nros_core::GoalStatus,
2898    result_data: *const u8,
2899    result_len: usize,
2900) -> Result<(), NodeError> {
2901    let entry = unsafe { &mut *(ptr as *mut ActionServerRawArenaEntry<GB, RB, FB, MG>) };
2902    let result_cdr = unsafe { core::slice::from_raw_parts(result_data, result_len) };
2903    entry.core.complete_goal_raw(goal_id, status, result_cdr)
2904}
2905
2906/// Raw action server: set goal status via arena entry.
2907///
2908/// # Safety
2909/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
2910pub(crate) unsafe fn as_raw_set_goal_status<
2911    const GB: usize,
2912    const RB: usize,
2913    const FB: usize,
2914    const MG: usize,
2915>(
2916    ptr: *mut u8,
2917    goal_id: &nros_core::GoalId,
2918    status: nros_core::GoalStatus,
2919) {
2920    let entry = unsafe { &mut *(ptr as *mut ActionServerRawArenaEntry<GB, RB, FB, MG>) };
2921    entry.core.set_goal_status(goal_id, status);
2922}
2923
2924/// Raw action server: get active goal count via arena entry.
2925///
2926/// # Safety
2927/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
2928pub(crate) unsafe fn as_raw_active_goal_count<
2929    const GB: usize,
2930    const RB: usize,
2931    const FB: usize,
2932    const MG: usize,
2933>(
2934    ptr: *const u8,
2935) -> usize {
2936    let entry = unsafe { &*(ptr as *const ActionServerRawArenaEntry<GB, RB, FB, MG>) };
2937    entry.core.active_goal_count()
2938}
2939
2940/// Raw action server: iterate active goals via arena entry.
2941///
2942/// # Safety
2943/// `ptr` must point to a valid `ActionServerRawArenaEntry`.
2944pub(crate) unsafe fn as_raw_for_each_active_goal<
2945    const GB: usize,
2946    const RB: usize,
2947    const FB: usize,
2948    const MG: usize,
2949>(
2950    ptr: *const u8,
2951    f: &mut dyn FnMut(&super::action_core::RawActiveGoal),
2952) {
2953    let entry = unsafe { &*(ptr as *const ActionServerRawArenaEntry<GB, RB, FB, MG>) };
2954    for goal in entry.core.active_goals() {
2955        f(goal);
2956    }
2957}
2958
2959/// Action server: iterate active goals via arena entry.
2960///
2961/// Calls `f` for each active goal, reconstructing `ActiveGoal<A>` from
2962/// the core's `RawActiveGoal` and the parallel typed goals vec.
2963///
2964/// # Safety
2965/// `ptr` must point to a valid `ActionServerArenaEntry`.
2966pub(crate) unsafe fn as_for_each_active_goal<
2967    A,
2968    GoalF,
2969    CancelF,
2970    const GB: usize,
2971    const RB: usize,
2972    const FB: usize,
2973    const MG: usize,
2974>(
2975    ptr: *const u8,
2976    f: &mut dyn FnMut(&ActiveGoal<A>),
2977) where
2978    A: RosAction + 'static,
2979    A::Goal: Clone,
2980{
2981    let entry =
2982        unsafe { &*(ptr as *const ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>) };
2983    for (i, raw_goal) in entry.server.core.active_goals().iter().enumerate() {
2984        let active = ActiveGoal {
2985            goal_id: raw_goal.goal_id,
2986            status: raw_goal.status,
2987            goal: entry.server.typed_goals[i].clone(),
2988        };
2989        f(&active);
2990    }
2991}
2992
2993#[cfg(test)]
2994mod borrowed_sub_tests {
2995    use nros_core::{CdrReader, CdrWriter, DeserError};
2996
2997    use super::*;
2998
2999    // Hand-written borrowed message mirroring what codegen will emit for
3000    // `{ uint32 width; uint8[] data; }` in `borrowed` mode (Phase 229.6).
3001    struct ImageView<'a> {
3002        width: u32,
3003        data: &'a [u8],
3004    }
3005
3006    impl<'a> DeserializeView<'a> for ImageView<'a> {
3007        fn deserialize_view(reader: &mut CdrReader<'a>) -> Result<Self, DeserError> {
3008            let width = reader.read_u32()?;
3009            let data = reader.read_slice_u8()?;
3010            Ok(ImageView { width, data })
3011        }
3012    }
3013
3014    // Zero-sized borrowed-family marker (codegen emits `struct ImageViewable;`).
3015    struct ImageViewable;
3016    impl ViewableMessage for ImageViewable {
3017        type View<'a> = ImageView<'a>;
3018        const TYPE_NAME: &'static str = "test_msgs::msg::dds_::Image_";
3019        const TYPE_HASH: &'static str = "borrowed-test-hash";
3020    }
3021
3022    // The borrowed view must alias the source CDR buffer (no `heapless::Vec`
3023    // copy) — the whole point of `borrowed` mode (issue 0007).
3024    #[test]
3025    fn borrowed_view_is_zero_copy_into_source_buffer() {
3026        let payload: [u8; 64] = core::array::from_fn(|i| i as u8);
3027        let mut buf = [0u8; 128];
3028        let written = {
3029            let mut w = CdrWriter::new_with_header(&mut buf).unwrap();
3030            w.write_u32(7).unwrap();
3031            w.write_sequence_len(payload.len()).unwrap();
3032            w.write_bytes(&payload).unwrap();
3033            w.position()
3034        };
3035
3036        let mut reader = CdrReader::new_with_header(&buf[..written]).unwrap();
3037        let view = ImageView::deserialize_view(&mut reader).unwrap();
3038
3039        assert_eq!(view.width, 7);
3040        assert_eq!(view.data, &payload[..]);
3041
3042        // The borrowed slice points INTO `buf`, proving zero-copy.
3043        let buf_start = buf.as_ptr() as usize;
3044        let buf_end = buf_start + buf.len();
3045        let data_ptr = view.data.as_ptr() as usize;
3046        assert!(
3047            data_ptr >= buf_start && data_ptr < buf_end,
3048            "borrowed data must alias the source buffer (zero-copy)"
3049        );
3050    }
3051
3052    // Phase 231 Wave 3 (RFC-0038) — single-copy proof. The in-place subscription
3053    // entry carries handle + callback only; the buffered entry additionally
3054    // carries the arena `BufferStrategy` (the copy-#1 staging buffer). So the
3055    // in-place entry is strictly smaller — proving the arena buffer (and copy #1)
3056    // is gone for backends that support in-place dispatch.
3057    #[test]
3058    fn inplace_entry_drops_the_arena_buffer() {
3059        type Cb = fn(&u32);
3060        assert!(
3061            core::mem::size_of::<SubInplaceEntry<u32, Cb>>()
3062                < core::mem::size_of::<SubBufferedEntry<u32, Cb>>(),
3063            "in-place entry must be smaller than the buffered entry (no arena BufferStrategy)"
3064        );
3065    }
3066
3067    // Compile-time proof that the codegen marker + GAT + a borrowed closure
3068    // satisfy exactly the bounds the executor's borrowed dispatch
3069    // (`sub_buffered_view_try_process`) and registration require.
3070    fn assert_borrowed_sub_bounds<B, F>(_callback: F)
3071    where
3072        B: ViewableMessage + 'static,
3073        F: for<'a> FnMut(&B::View<'a>) + 'static,
3074    {
3075    }
3076
3077    #[test]
3078    fn borrowed_marker_satisfies_dispatch_bounds() {
3079        assert_borrowed_sub_bounds::<ImageViewable, _>(|view: &ImageView<'_>| {
3080            let _ = view.width;
3081            let _ = view.data.len();
3082        });
3083    }
3084}
3085
3086/// RFC-0069 / issues 0418 + 0035 — the action payload envelope carries exactly
3087/// ONE CDR header, and reading it back must not eat a data word.
3088///
3089/// The RFC lists this as the acceptance item "most likely to be skipped: its
3090/// absence is why the divergence survived a redesign in the first place." These
3091/// sit at the level the corruption happens — `read_action_field`, the consumer
3092/// half of the 0418 producer/consumer pair.
3093#[cfg(test)]
3094mod action_envelope_tests {
3095    use nros_core::{CdrWriter, Deserialize, Serialize};
3096
3097    use super::*;
3098
3099    /// Three `int32`s. The FIRST is the one that matters: give it the value 256
3100    /// and the body's leading bytes are `00 01 00 00` — byte for byte the
3101    /// little-endian CDR encapsulation header.
3102    #[derive(Debug, PartialEq)]
3103    struct Body {
3104        first: i32,
3105        rest: [i32; 2],
3106    }
3107
3108    impl Serialize for Body {
3109        fn serialize(&self, w: &mut CdrWriter) -> Result<(), nros_serdes::SerError> {
3110            w.write_i32(self.first)?;
3111            for v in &self.rest {
3112                w.write_i32(*v)?;
3113            }
3114            Ok(())
3115        }
3116    }
3117
3118    impl Deserialize for Body {
3119        fn deserialize(r: &mut CdrReader) -> Result<Self, nros_serdes::DeserError> {
3120            Ok(Body {
3121                first: r.read_i32()?,
3122                rest: [r.read_i32()?, r.read_i32()?],
3123            })
3124        }
3125    }
3126
3127    const LE_ENCAP: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
3128
3129    /// Serialize the way the 0418 PRODUCER does: fields only, no inner header.
3130    fn produce_headerless(body: &Body, buf: &mut [u8]) -> usize {
3131        let mut w = CdrWriter::new(buf);
3132        body.serialize(&mut w).expect("serialize");
3133        w.position()
3134    }
3135
3136    #[test]
3137    fn headerless_payload_round_trips() {
3138        let body = Body {
3139            first: 7,
3140            rest: [1, 1],
3141        };
3142        let mut buf = [0u8; 64];
3143        let n = produce_headerless(&body, &mut buf);
3144
3145        let got = read_action_field::<Body, 64>(&LE_ENCAP, &buf[..n])
3146            .expect("consumer must decode the headerless payload");
3147        assert_eq!(got, body, "issue 0035: the reader ate a data word");
3148    }
3149
3150    /// THE hazard 0418's own doc names and did not test.
3151    ///
3152    /// `payload_has_cdr_encap` was a VALUE sniff: a leading `int32` of 256 is
3153    /// `00 01 00 00`, indistinguishable from the LE encap header. While the
3154    /// producer wrote an inner header only Cyclone took the other branch, so it
3155    /// never mattered; once 0418 made every payload headerless the sniff was
3156    /// consulted for all of them, and this body had its first word eaten as
3157    /// framing — `first` came back as `rest[0]`. Issue #35 reached through a
3158    /// payload VALUE rather than a framing bug.
3159    #[test]
3160    fn a_leading_word_that_looks_like_an_encap_is_data_not_framing() {
3161        let body = Body {
3162            first: 256,
3163            rest: [11, 12],
3164        };
3165        let mut buf = [0u8; 64];
3166        let n = produce_headerless(&body, &mut buf);
3167
3168        assert_eq!(
3169            &buf[..4],
3170            &LE_ENCAP,
3171            "precondition: an int32 of 256 IS the LE encap byte pattern"
3172        );
3173        assert!(
3174            payload_has_cdr_encap(&buf[..n]),
3175            "precondition: the retired sniff cannot tell this from a header"
3176        );
3177
3178        let got = read_action_field::<Body, 64>(&LE_ENCAP, &buf[..n]).expect("must decode");
3179        assert_eq!(
3180            got, body,
3181            "the leading word is DATA — reading it as framing is issue #35"
3182        );
3183    }
3184
3185    /// The version break RFC-0069 accepts, asserted rather than assumed.
3186    ///
3187    /// A pre-0418 peer sends `[inner header][fields]`. The consumer no longer
3188    /// sniffs, so it splices unconditionally and the inner header is read as
3189    /// data. Old and new images are wire-incompatible on action payloads — the
3190    /// RFC's "silent version skew" risk, made explicit here so the next reader
3191    /// finds it as a decision and not as a mystery.
3192    #[test]
3193    fn a_pre_0418_double_header_payload_does_not_decode_as_itself() {
3194        let body = Body {
3195            first: 5,
3196            rest: [6, 7],
3197        };
3198        let mut inner = [0u8; 64];
3199        let n = produce_headerless(&body, &mut inner);
3200        let mut withhdr = [0u8; 68];
3201        withhdr[..4].copy_from_slice(&LE_ENCAP);
3202        withhdr[4..4 + n].copy_from_slice(&inner[..n]);
3203
3204        let got = read_action_field::<Body, 68>(&LE_ENCAP, &withhdr[..4 + n]);
3205        assert_ne!(
3206            got,
3207            Some(body),
3208            "a pre-0418 payload must NOT silently appear to decode — the wire \
3209             format changed and the skew is expected to fail"
3210        );
3211    }
3212}
3213
3214/// Issue #505 — timer overrun policy. `timer_try_process` takes the
3215/// elapsed delta directly, so a stall is expressible as a single large
3216/// delta and every case here is a pure unit test.
3217#[cfg(test)]
3218mod timer_overrun_tests {
3219    use super::*;
3220
3221    /// Drive `entry` through one `try_process` pass, crediting `delta_ms`
3222    /// worth of elapsed time (the dispatcher's unit is microseconds).
3223    fn step<F: FnMut()>(entry: &mut TimerEntry<F>, delta_ms: u64) -> bool {
3224        // SAFETY: `entry` is a live, aligned `TimerEntry<F>`.
3225        unsafe {
3226            timer_try_process::<F>(
3227                (entry as *mut TimerEntry<F>).cast::<u8>(),
3228                delta_ms * 1000,
3229                0,
3230            )
3231            .unwrap()
3232        }
3233    }
3234
3235    /// Same, in microseconds — the dispatcher's native unit.
3236    fn step_us<F: FnMut()>(entry: &mut TimerEntry<F>, delta_us: u64) -> bool {
3237        // SAFETY: `entry` is a live, aligned `TimerEntry<F>`.
3238        unsafe {
3239            timer_try_process::<F>((entry as *mut TimerEntry<F>).cast::<u8>(), delta_us, 0).unwrap()
3240        }
3241    }
3242
3243    fn periodic(period_us: u64, policy: TimerOverrunPolicy) -> TimerEntry<impl FnMut()> {
3244        TimerEntry {
3245            period_us,
3246            elapsed_us: 0,
3247            overruns: 0,
3248            overruns_reported: 0,
3249            oneshot: false,
3250            fired: false,
3251            cancelled: false,
3252            overrun_policy: policy,
3253            clock_source: TimerClockSource::Steady,
3254            last_clock_ns: 0,
3255            callback: || {},
3256        }
3257    }
3258
3259    #[test]
3260    fn skip_is_the_default_policy() {
3261        assert_eq!(TimerOverrunPolicy::default(), TimerOverrunPolicy::Skip);
3262    }
3263
3264    #[test]
3265    fn skip_coalesces_a_stall_into_one_activation() {
3266        // The observed failure: a ~200 ms preemption of a 10 ms timer
3267        // replayed as a burst of activations. Under Skip the backlog
3268        // costs exactly one activation plus a counter bump.
3269        let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
3270        assert!(step(&mut t, 205));
3271        assert_eq!(t.overruns, 19, "205 ms of a 10 ms period = 20 due, 1 fired");
3272        // Nothing is owed: the next pass waits out the remaining period.
3273        assert!(!step(&mut t, 4));
3274        assert!(step(&mut t, 1));
3275    }
3276
3277    #[test]
3278    fn skip_preserves_the_phase_grid() {
3279        // 205 ms leaves a 5 ms remainder; keeping it means the next fire
3280        // lands 5 ms later, back on the original 10 ms grid, instead of
3281        // re-anchoring to the moment the stall ended.
3282        let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
3283        assert!(step(&mut t, 205));
3284        assert_eq!(t.elapsed_us, 5_000);
3285    }
3286
3287    #[test]
3288    fn catchup_replays_every_missed_period() {
3289        let mut t = periodic(10_000, TimerOverrunPolicy::CatchUp);
3290        assert!(step(&mut t, 205));
3291        // One activation per pass until the backlog drains, with no
3292        // further time credited.
3293        let mut replays = 0;
3294        while step(&mut t, 0) {
3295            replays += 1;
3296            assert!(replays < 64, "backlog must drain");
3297        }
3298        assert_eq!(replays, 19);
3299        assert_eq!(t.overruns, 0, "CatchUp loses nothing, so counts nothing");
3300    }
3301
3302    #[test]
3303    fn on_time_ticks_never_count_as_overruns() {
3304        let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
3305        for _ in 0..100 {
3306            assert!(step(&mut t, 10));
3307        }
3308        assert_eq!(t.overruns, 0);
3309        assert_eq!(t.elapsed_us, 0);
3310    }
3311
3312    #[test]
3313    fn a_single_late_period_is_one_overrun() {
3314        // Exactly two periods due = one fired, one dropped.
3315        let mut t = periodic(10_000, TimerOverrunPolicy::Skip);
3316        assert!(step(&mut t, 20));
3317        assert_eq!(t.overruns, 1);
3318    }
3319
3320    #[test]
3321    fn oneshot_ignores_the_policy() {
3322        let mut t = TimerEntry {
3323            period_us: 10_000,
3324            elapsed_us: 0,
3325            overruns: 0,
3326            overruns_reported: 0,
3327            oneshot: true,
3328            fired: false,
3329            cancelled: false,
3330            overrun_policy: TimerOverrunPolicy::Skip,
3331            clock_source: TimerClockSource::Steady,
3332            last_clock_ns: 0,
3333            callback: || {},
3334        };
3335        assert!(step(&mut t, 205));
3336        assert_eq!(t.overruns, 0);
3337        assert!(!step(&mut t, 205), "a fired one-shot stays fired");
3338    }
3339
3340    #[test]
3341    fn sub_millisecond_periods_are_expressible() {
3342        // Issue #505 — the dispatcher's unit is microseconds, so a
3343        // 500 us period is a real period rather than the "fires every
3344        // spin" degenerate case a millisecond field forced it into.
3345        let mut t = periodic(500, TimerOverrunPolicy::Skip);
3346        // 200 us of credit is not enough...
3347        assert!(!step_us(&mut t, 200));
3348        // ...600 us total is.
3349        assert!(step_us(&mut t, 400));
3350        assert_eq!(t.overruns, 0);
3351        assert_eq!(t.elapsed_us, 100, "phase remainder survives");
3352    }
3353
3354    #[test]
3355    fn a_stalled_sub_millisecond_timer_counts_whole_periods() {
3356        let mut t = periodic(500, TimerOverrunPolicy::Skip);
3357        assert!(step_us(&mut t, 5_000)); // 10 periods due
3358        assert_eq!(t.overruns, 9);
3359    }
3360
3361    #[test]
3362    fn zero_period_does_not_divide_by_zero() {
3363        // Degenerate but reachable: `TimerDuration::from_millis(0)` or a
3364        // sub-millisecond period truncated to 0 by `as_millis`.
3365        let mut t = periodic(0, TimerOverrunPolicy::Skip);
3366        assert!(step(&mut t, 5));
3367        assert_eq!(t.overruns, 0);
3368        assert_eq!(t.elapsed_us, 0);
3369    }
3370
3371    #[test]
3372    fn overrun_count_saturates_instead_of_wrapping() {
3373        let mut t = periodic(1_000, TimerOverrunPolicy::Skip);
3374        t.overruns = u32::MAX - 1;
3375        assert!(step(&mut t, 1_000));
3376        assert_eq!(t.overruns, u32::MAX);
3377    }
3378}