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}