Skip to main content

nros_node/executor/
handles.rs

1//! Entity wrapper types for the embedded executor.
2
3use core::marker::PhantomData;
4
5use nros_core::{CdrReader, CdrWriter, Deserialize, RosAction, RosMessage, RosService, Serialize};
6use nros_rmw::{Publisher, ServiceClientTrait, ServiceServerTrait, Subscriber, TransportError};
7
8use crate::session;
9
10use super::types::{DEFAULT_TX_BUF, NodeError};
11
12/// Default polling interval (ms) for sync wait loops.
13const DEFAULT_SPIN_INTERVAL_MS: u64 = 10;
14
15/// Check whether the given budget has been exhausted.
16///
17/// `std` builds measure wall-clock against `Instant::now()`; `no_std`
18/// builds count iterations and exhaust after `max_iterations` calls.
19///
20/// **Phase 89.8**: the plain `max_iters` approach is insufficient on
21/// multi-threaded zpico backends (POSIX/Zephyr/NuttX). There,
22/// `executor.spin_once(10ms)` waits on a condvar that zenoh-pico's
23/// background tasks signal on any inbound frame (keep-alives,
24/// discovery gossip, interest messages). Each signal returns the
25/// spin well before the 10 ms budget, so a nominal
26/// `1000 × 10 ms = 10 s` iteration count collapses to milliseconds
27/// of real time and the wait returns `Timeout` long before the
28/// awaited reply can arrive.
29///
30/// Same class of bug 89.2 fixed in `nros-c`'s blocking service call
31/// and 89.3 fixed in `nros-cpp`'s action-client helpers. The
32/// maintainer explicitly flagged this `Promise::wait` / `wait_next`
33/// path in the 89.2 commit: *"Promise::wait in nros-node has the
34/// same structural bug but currently passes all tests. Left on
35/// max_spins until a test surfaces it."* The NuttX Rust action
36/// E2E is that test.
37struct WaitBudget {
38    #[cfg(feature = "std")]
39    deadline: std::time::Instant,
40    // Pure no_std fallback: iteration-count budget (no wall clock).
41    #[cfg(not(feature = "std"))]
42    remaining: u64,
43}
44
45impl WaitBudget {
46    fn new(_max_iterations: u64, _timeout: core::time::Duration) -> Self {
47        #[cfg(feature = "std")]
48        {
49            Self {
50                deadline: std::time::Instant::now() + _timeout,
51            }
52        }
53        #[cfg(not(feature = "std"))]
54        {
55            Self {
56                remaining: _max_iterations,
57            }
58        }
59    }
60
61    fn tick(&mut self) -> bool {
62        #[cfg(feature = "std")]
63        {
64            std::time::Instant::now() < self.deadline
65        }
66        #[cfg(not(feature = "std"))]
67        {
68            if self.remaining == 0 {
69                false
70            } else {
71                self.remaining -= 1;
72                true
73            }
74        }
75    }
76}
77
78/// UUID byte count in a ROS 2 GoalId.
79///
80/// CDR encoding: a fixed `uint8[16]` array — ROS 2 `unique_identifier_msgs/UUID`
81/// — with **no** length prefix (fixed arrays are unprefixed in CDR).
82const GOAL_UUID_SIZE: usize = 16;
83
84// ============================================================================
85// EmbeddedPublisher
86// ============================================================================
87
88/// Typed publisher handle.
89///
90/// Two methods, both byte-oriented at the wire:
91///
92/// - [`publish`](Self::publish) / [`publish_with_buffer`](Self::publish_with_buffer)
93///   — accept `&M: RosMessage`, CDR-encode into a stack buffer, then
94///   call [`Publisher::publish_raw`](nros_rmw::Publisher::publish_raw).
95/// - [`publish_raw`](Self::publish_raw) — accepts pre-encoded CDR bytes
96///   for callers that already produced the wire payload.
97///
98/// **No typed `loan()` exists.** Loan/borrow live exclusively on
99/// [`EmbeddedRawPublisher`] / [`RawSubscription`]. `try_loan(len)`
100/// requires the byte length up front, which CDR ser/de can only
101/// discover after encoding — the two APIs are incompatible by
102/// construction. See `docs/design/0010-zero-copy-raw-api.md` decision D7.
103pub struct EmbeddedPublisher<M> {
104    pub(crate) handle: session::RmwPublisher,
105    /// Phase 108 — registered event closures kept alive for the
106    /// publisher's lifetime; freed in `Drop`.
107    pub(crate) event_regs: EventRegs,
108    /// RFC-0052 W3b.4 — contracted endpoint's publish counter (`None`
109    /// for uncontracted publishers; one relaxed atomic bump when set).
110    pub(crate) monitor: Option<&'static crate::executor::monitor::PubMonitorCell>,
111    pub(crate) _phantom: PhantomData<M>,
112}
113
114impl<M> Drop for EmbeddedPublisher<M> {
115    fn drop(&mut self) {
116        drop_event_regs(&mut self.event_regs);
117    }
118}
119
120impl<M: RosMessage> EmbeddedPublisher<M> {
121    /// Publish a message using the default buffer size.
122    pub fn publish(&self, msg: &M) -> Result<(), NodeError> {
123        self.publish_with_buffer::<DEFAULT_TX_BUF>(msg)
124    }
125
126    /// Publish a message with a custom buffer size.
127    pub fn publish_with_buffer<const BUF: usize>(&self, msg: &M) -> Result<(), NodeError> {
128        let mut buffer = [0u8; BUF];
129        let mut writer =
130            CdrWriter::new_with_header(&mut buffer).map_err(|_| NodeError::BufferTooSmall)?;
131        msg.serialize(&mut writer)
132            .map_err(|_| NodeError::Serialization)?;
133        let len = writer.position();
134        self.bump_monitor();
135        self.handle
136            .publish_raw(&buffer[..len])
137            .map_err(|_| NodeError::Transport(TransportError::PublishFailed))
138    }
139
140    /// RFC-0052 W3b.4 — one relaxed bump per publish on contracted
141    /// endpoints; a predictable no-op otherwise.
142    #[inline]
143    fn bump_monitor(&self) {
144        if let Some(cell) = self.monitor {
145            cell.count
146                .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
147        }
148    }
149
150    /// Publish raw CDR-encoded data (must include CDR header).
151    pub fn publish_raw(&self, data: &[u8]) -> Result<(), NodeError> {
152        self.bump_monitor();
153        self.handle
154            .publish_raw(data)
155            .map_err(|_| NodeError::Transport(TransportError::PublishFailed))
156    }
157
158    /// Phase 124.E.1 — streamed publish. Use a closure-driven writer
159    /// to serialise straight into the backend's outbound buffer
160    /// without a per-publisher staging copy. Saves on RAM-constrained
161    /// nodes that publish multi-KB payloads.
162    ///
163    /// The `writer` closure receives a [`StreamWriter`] mutable
164    /// reference and uses [`StreamWriter::write`] / [`extend`] /
165    /// [`reserved_len`] to fill the slot in chunks. The total
166    /// payload length must be declared up-front via
167    /// [`StreamWriter::reserve_total`]; the backend allocates that
168    /// many bytes in its outbound buffer before any chunks land.
169    ///
170    /// Backends without a native stream slot fall through to a
171    /// stack-allocated staging buffer (capped at 4 KiB) + a single
172    /// `publish_raw` — same observable result, just no zero-staging
173    /// win for the big-message case.
174    pub fn publish_streamed<F>(&self, total_len: usize, writer: F) -> Result<(), NodeError>
175    where
176        F: FnMut(&mut [u8]) -> usize,
177    {
178        // Wrap the closure in a `*mut c_void` so it survives the
179        // crossing into the C callback contract the vtable expects.
180        // The closure is consumed by reference, so the lifetime is
181        // bounded by this function's frame — no escape.
182        use nros_rmw::Publisher;
183        struct Ctx<W> {
184            writer: W,
185            total: usize,
186        }
187        unsafe extern "C" fn size_cb<W>(
188            out_total_len: *mut usize,
189            user_ctx: *mut core::ffi::c_void,
190        ) {
191            unsafe {
192                let ctx = &*(user_ctx as *const Ctx<W>);
193                *out_total_len = ctx.total;
194            }
195        }
196        unsafe extern "C" fn chunk_cb<W: FnMut(&mut [u8]) -> usize>(
197            out_buf: *mut u8,
198            cap: usize,
199            out_written: *mut usize,
200            user_ctx: *mut core::ffi::c_void,
201        ) {
202            unsafe {
203                let ctx = &mut *(user_ctx as *mut Ctx<W>);
204                let slot = core::slice::from_raw_parts_mut(out_buf, cap);
205                let n = (ctx.writer)(slot);
206                *out_written = n;
207            }
208        }
209        let mut ctx = Ctx {
210            writer,
211            total: total_len,
212        };
213        let ctx_ptr = &mut ctx as *mut Ctx<F> as *mut core::ffi::c_void;
214        // SAFETY: `ctx` lives until this call returns, and both callbacks
215        // only cast `user_ctx` back to that stack-local `Ctx<F>`.
216        unsafe {
217            self.handle
218                .publish_streamed(size_cb::<F>, chunk_cb::<F>, ctx_ptr)
219        }
220        .map_err(NodeError::Transport)
221    }
222
223    /// Phase 108.B — manually assert this publisher's liveliness.
224    /// Required for publishers configured with
225    /// [`QosLivelinessPolicy::ManualByTopic`] /
226    /// [`QosLivelinessPolicy::ManualByNode`]. No-op for AUTOMATIC /
227    /// NONE kinds. Returns `Err(Unsupported)` if the backend doesn't
228    /// implement manual liveliness.
229    pub fn assert_liveliness(&self) -> Result<(), NodeError> {
230        use nros_rmw::Publisher as _;
231        self.handle
232            .assert_liveliness()
233            .map_err(NodeError::Transport)
234    }
235
236    // ====================================================================
237    // Phase 108 — status events
238    // ====================================================================
239    //
240    // Publisher-side: `LivelinessLost` and `OfferedDeadlineMissed`.
241    // Returns `NodeError::Transport(TransportError::Unsupported)` if
242    // the active backend doesn't generate the event for this entity.
243
244    /// `true` if the active backend can fire the named event for this
245    /// publisher.
246    #[cfg(feature = "alloc")]
247    pub fn supports_event(&self, kind: nros_rmw::EventKind) -> bool {
248        use nros_rmw::Publisher as _;
249        self.handle.supports_event(kind)
250    }
251
252    /// Register a callback for `LivelinessLost`. Fires when this
253    /// publisher misses its own liveliness assertion deadline.
254    #[cfg(feature = "alloc")]
255    pub fn on_liveliness_lost<F>(&mut self, cb: F) -> Result<(), NodeError>
256    where
257        F: FnMut(nros_rmw::CountStatus) + Send + 'static,
258    {
259        register_pub_event::<F, _>(
260            &mut self.handle,
261            &mut self.event_regs,
262            nros_rmw::EventKind::LivelinessLost,
263            0,
264            cb,
265            |payload, f| {
266                if let nros_rmw::EventPayload::LivelinessLost(s) = payload {
267                    f(*s);
268                }
269            },
270        )
271    }
272
273    /// Register a callback for `OfferedDeadlineMissed`. Fires when
274    /// this publisher promised `deadline` and falls behind.
275    #[cfg(feature = "alloc")]
276    pub fn on_offered_deadline_missed<F>(
277        &mut self,
278        deadline: core::time::Duration,
279        cb: F,
280    ) -> Result<(), NodeError>
281    where
282        F: FnMut(nros_rmw::CountStatus) + Send + 'static,
283    {
284        register_pub_event::<F, _>(
285            &mut self.handle,
286            &mut self.event_regs,
287            nros_rmw::EventKind::OfferedDeadlineMissed,
288            deadline.as_millis().min(u32::MAX as u128) as u32,
289            cb,
290            |payload, f| {
291                if let nros_rmw::EventPayload::OfferedDeadlineMissed(s) = payload {
292                    f(*s);
293                }
294            },
295        )
296    }
297}
298
299/// Cap on registered event callbacks per entity. Subscribers can hold
300/// up to 3 (LivelinessChanged + RequestedDeadlineMissed + MessageLost);
301/// publishers up to 2 (LivelinessLost + OfferedDeadlineMissed). One vec
302/// type fits both — extra slots are unused on publishers.
303#[cfg(feature = "alloc")]
304pub(crate) const MAX_EVENTS_PER_ENTITY: usize = 3;
305
306/// One row of the per-entity event-callback registry. Stores enough to
307/// type-erase the boxed closure for `Drop`-time deallocation.
308#[cfg(feature = "alloc")]
309#[derive(Clone, Copy)]
310pub(crate) struct EventReg {
311    /// `Box::into_raw`-derived pointer; valid for the entity's lifetime.
312    pub(crate) ctx: *mut core::ffi::c_void,
313    /// Type-erased destructor. Calls `Box::from_raw` w/ the originating
314    /// monomorphic type, dropping the closure + freeing the heap slot.
315    pub(crate) drop_fn: unsafe fn(*mut core::ffi::c_void),
316}
317
318#[cfg(feature = "alloc")]
319pub(crate) type EventRegs = heapless::Vec<EventReg, MAX_EVENTS_PER_ENTITY>;
320
321/// Empty placeholder for no-alloc builds — keeps struct layout stable
322/// across feature combinations without paying any space.
323#[cfg(not(feature = "alloc"))]
324#[derive(Default, Clone, Copy)]
325pub(crate) struct EventRegs;
326
327/// Empty initial value for the `event_regs` field. Selected at compile
328/// time so call sites are uniform across feature combinations
329/// (`heapless::Vec::new()` for `alloc`; unit-struct constructor
330/// otherwise — clippy's `default_constructed_unit_structs` lint
331/// rejects the `EventRegs::default()` form on the unit-struct branch).
332#[cfg(feature = "alloc")]
333#[inline]
334pub(crate) fn empty_event_regs() -> EventRegs {
335    heapless::Vec::new()
336}
337#[cfg(not(feature = "alloc"))]
338#[inline]
339pub(crate) fn empty_event_regs() -> EventRegs {
340    EventRegs
341}
342
343#[cfg(feature = "alloc")]
344pub(crate) fn drop_event_regs(regs: &mut EventRegs) {
345    while let Some(reg) = regs.pop() {
346        // SAFETY: `reg.ctx` was obtained from `Box::into_raw` of the
347        // monomorphic type that `reg.drop_fn` knows about. Each reg is
348        // visited exactly once because we drain via `pop`.
349        unsafe { (reg.drop_fn)(reg.ctx) };
350    }
351}
352
353#[cfg(not(feature = "alloc"))]
354#[inline]
355pub(crate) fn drop_event_regs(_regs: &mut EventRegs) {}
356
357#[cfg(feature = "alloc")]
358fn register_pub_event<F, D>(
359    handle: &mut session::RmwPublisher,
360    regs: &mut EventRegs,
361    kind: nros_rmw::EventKind,
362    deadline_ms: u32,
363    user_cb: F,
364    dispatch: D,
365) -> Result<(), NodeError>
366where
367    F: FnMut(nros_rmw::CountStatus) + Send + 'static,
368    D: Fn(nros_rmw::EventPayload<'_>, &mut F) + 'static,
369{
370    use nros_rmw::Publisher as _;
371    if regs.is_full() {
372        return Err(NodeError::Transport(TransportError::Unsupported));
373    }
374    let state = alloc::boxed::Box::new(EventClosureState { user_cb, dispatch });
375    let user_ctx = alloc::boxed::Box::into_raw(state) as *mut core::ffi::c_void;
376    // SAFETY: trampoline downcasts `user_ctx` back to the boxed
377    // EventClosureState. Box ownership is recorded in `regs`; entity
378    // Drop walks the registry and frees via `drop_event_state::<F, D>`.
379    let res = unsafe {
380        handle.register_event_callback(kind, deadline_ms, event_trampoline::<F, D>, user_ctx)
381    };
382    match res {
383        Ok(()) => {
384            // is_full check above guarantees push() succeeds.
385            let _ = regs.push(EventReg {
386                ctx: user_ctx,
387                drop_fn: drop_event_state::<F, D>,
388            });
389            Ok(())
390        }
391        Err(e) => {
392            // SAFETY: backend rejected the registration; reclaim the
393            // box we just leaked into raw form.
394            unsafe {
395                drop(alloc::boxed::Box::from_raw(
396                    user_ctx as *mut EventClosureState<F, D>,
397                ));
398            }
399            Err(NodeError::Transport(e))
400        }
401    }
402}
403
404#[cfg(feature = "alloc")]
405struct EventClosureState<F, D> {
406    user_cb: F,
407    dispatch: D,
408}
409
410#[cfg(feature = "alloc")]
411unsafe extern "C" fn event_trampoline<F, D>(
412    kind: nros_rmw::EventKind,
413    payload_ptr: *const core::ffi::c_void,
414    user_ctx: *mut core::ffi::c_void,
415) where
416    F: FnMut(nros_rmw::CountStatus) + Send + 'static,
417    D: Fn(nros_rmw::EventPayload<'_>, &mut F) + 'static,
418{
419    let state = unsafe { &mut *(user_ctx as *mut EventClosureState<F, D>) };
420    let payload = unsafe { nros_rmw::payload_from_raw(kind, payload_ptr) };
421    (state.dispatch)(payload, &mut state.user_cb);
422}
423
424#[cfg(feature = "alloc")]
425unsafe fn drop_event_state<F, D>(ctx: *mut core::ffi::c_void)
426where
427    F: FnMut(nros_rmw::CountStatus) + Send + 'static,
428    D: Fn(nros_rmw::EventPayload<'_>, &mut F) + 'static,
429{
430    // SAFETY: caller guarantees `ctx` was obtained from
431    // `Box::into_raw::<EventClosureState<F, D>>` and not yet freed.
432    unsafe {
433        drop(alloc::boxed::Box::from_raw(
434            ctx as *mut EventClosureState<F, D>,
435        ));
436    }
437}
438
439#[cfg(feature = "alloc")]
440fn register_sub_event_count<F, D>(
441    handle: &mut session::RmwSubscriber,
442    regs: &mut EventRegs,
443    kind: nros_rmw::EventKind,
444    deadline_ms: u32,
445    user_cb: F,
446    dispatch: D,
447) -> Result<(), NodeError>
448where
449    F: FnMut(nros_rmw::CountStatus) + Send + 'static,
450    D: Fn(nros_rmw::EventPayload<'_>, &mut F) + 'static,
451{
452    use nros_rmw::Subscriber as _;
453    if regs.is_full() {
454        return Err(NodeError::Transport(TransportError::Unsupported));
455    }
456    let state = alloc::boxed::Box::new(EventClosureState { user_cb, dispatch });
457    let user_ctx = alloc::boxed::Box::into_raw(state) as *mut core::ffi::c_void;
458    let res = unsafe {
459        handle.register_event_callback(kind, deadline_ms, event_trampoline::<F, D>, user_ctx)
460    };
461    match res {
462        Ok(()) => {
463            let _ = regs.push(EventReg {
464                ctx: user_ctx,
465                drop_fn: drop_event_state::<F, D>,
466            });
467            Ok(())
468        }
469        Err(e) => {
470            unsafe {
471                drop(alloc::boxed::Box::from_raw(
472                    user_ctx as *mut EventClosureState<F, D>,
473                ));
474            }
475            Err(NodeError::Transport(e))
476        }
477    }
478}
479
480#[cfg(feature = "alloc")]
481fn register_sub_event_liveliness<F>(
482    handle: &mut session::RmwSubscriber,
483    regs: &mut EventRegs,
484    user_cb: F,
485) -> Result<(), NodeError>
486where
487    F: FnMut(nros_rmw::LivelinessChangedStatus) + Send + 'static,
488{
489    use nros_rmw::Subscriber as _;
490    if regs.is_full() {
491        return Err(NodeError::Transport(TransportError::Unsupported));
492    }
493    let state = alloc::boxed::Box::new(LivelinessClosureState { user_cb });
494    let user_ctx = alloc::boxed::Box::into_raw(state) as *mut core::ffi::c_void;
495    let res = unsafe {
496        handle.register_event_callback(
497            nros_rmw::EventKind::LivelinessChanged,
498            0,
499            liveliness_trampoline::<F>,
500            user_ctx,
501        )
502    };
503    match res {
504        Ok(()) => {
505            let _ = regs.push(EventReg {
506                ctx: user_ctx,
507                drop_fn: drop_liveliness_state::<F>,
508            });
509            Ok(())
510        }
511        Err(e) => {
512            unsafe {
513                drop(alloc::boxed::Box::from_raw(
514                    user_ctx as *mut LivelinessClosureState<F>,
515                ));
516            }
517            Err(NodeError::Transport(e))
518        }
519    }
520}
521
522#[cfg(feature = "alloc")]
523unsafe fn drop_liveliness_state<F>(ctx: *mut core::ffi::c_void)
524where
525    F: FnMut(nros_rmw::LivelinessChangedStatus) + Send + 'static,
526{
527    unsafe {
528        drop(alloc::boxed::Box::from_raw(
529            ctx as *mut LivelinessClosureState<F>,
530        ));
531    }
532}
533
534#[cfg(feature = "alloc")]
535struct LivelinessClosureState<F> {
536    user_cb: F,
537}
538
539#[cfg(feature = "alloc")]
540unsafe extern "C" fn liveliness_trampoline<F>(
541    kind: nros_rmw::EventKind,
542    payload_ptr: *const core::ffi::c_void,
543    user_ctx: *mut core::ffi::c_void,
544) where
545    F: FnMut(nros_rmw::LivelinessChangedStatus) + Send + 'static,
546{
547    let state = unsafe { &mut *(user_ctx as *mut LivelinessClosureState<F>) };
548    let payload = unsafe { nros_rmw::payload_from_raw(kind, payload_ptr) };
549    if let nros_rmw::EventPayload::LivelinessChanged(s) = payload {
550        (state.user_cb)(*s);
551    }
552}
553
554// ============================================================================
555// EmbeddedRawPublisher — typeless publisher for non-ROS message wire formats
556// ============================================================================
557
558/// Default size of each per-publisher arena slot, in bytes.
559pub const DEFAULT_LOAN_BUF: usize = 1024;
560
561use core::cell::UnsafeCell;
562// portable-atomic AtomicBool — resolves to native on targets that support it,
563// software fallback on those that don't (e.g. some Xtensa ESP32 SoCs). Use
564// portable-atomic's Ordering too so the type sees the matching trait
565// implementation across all targets.
566use portable_atomic::{AtomicBool, Ordering};
567
568/// Typeless publisher handle. Use when the wire format is not ROS CDR
569/// (e.g. PX4 uORB raw POD bytes, custom binary protocols).
570///
571/// Two publish paths:
572///
573/// - [`publish_raw`](Self::publish_raw): user supplies a `&[u8]`, backend
574///   memcpys into its outbound buffer. One copy.
575/// - [`try_loan`](Self::try_loan): backend (or per-publisher arena fallback)
576///   hands user a `&mut [u8]` slice. User writes in place. [`PublishLoan::commit`]
577///   triggers the wire write. Zero-copy on backends with native lending
578///   (Phase 99: zenoh-pico `unstable-zenoh-api`, XRCE-DDS); single-memcpy
579///   fallback on backends without (uORB).
580///
581/// The const-generic `TX_BUF` sizes the inline arena slot (default
582/// [`DEFAULT_LOAN_BUF`]). Loans larger than `TX_BUF` return
583/// `LoanError::TooLarge`.
584pub struct EmbeddedRawPublisher<const TX_BUF: usize = DEFAULT_LOAN_BUF> {
585    pub(crate) handle: session::RmwPublisher,
586    /// Single-slot arena: writable buffer + busy flag. SLOTS=1 in v1
587    /// (concurrent loans on the same publisher return WouldBlock).
588    /// Unused when the `rmw-lending` feature is on — `try_loan`
589    /// dispatches to the backend's `SlotLending` instead.
590    #[allow(dead_code)]
591    pub(crate) arena: TxArena<TX_BUF>,
592    /// Phase 108 — registered event closures.
593    pub(crate) event_regs: EventRegs,
594}
595
596impl<const TX_BUF: usize> Drop for EmbeddedRawPublisher<TX_BUF> {
597    fn drop(&mut self) {
598        drop_event_regs(&mut self.event_regs);
599    }
600}
601
602/// Single-slot per-publisher arena. Concurrent `try_loan` calls on the
603/// same publisher race on the busy flag; loser gets `WouldBlock`.
604///
605/// `waker` lets `loan().await` register a waker before returning
606/// `Pending`; `release()` wakes it so the next `try_loan` succeeds
607/// without polling the executor loop. Phase 99.H' — replaces the
608/// earlier `wake_by_ref + Pending` busy yield with an event-driven
609/// wake, and gives `LoanFuture::Drop` a place to release a pending
610/// reservation cleanly.
611#[allow(dead_code)]
612pub(crate) struct TxArena<const TX_BUF: usize> {
613    busy: AtomicBool,
614    buf: UnsafeCell<[u8; TX_BUF]>,
615    waker: atomic_waker::AtomicWaker,
616}
617
618// SAFETY: Sync-ness of the arena is enforced by the `busy` flag — only
619// the thread that won the CAS may access `buf`, and only until commit/
620// discard releases the slot.
621unsafe impl<const TX_BUF: usize> Sync for TxArena<TX_BUF> {}
622
623#[allow(dead_code)] // unused when `rmw-lending` is on
624impl<const TX_BUF: usize> TxArena<TX_BUF> {
625    pub(crate) const fn new() -> Self {
626        Self {
627            busy: AtomicBool::new(false),
628            buf: UnsafeCell::new([0u8; TX_BUF]),
629            waker: atomic_waker::AtomicWaker::new(),
630        }
631    }
632
633    /// Try to claim the arena slot. Returns a raw pointer + len pair on
634    /// success; caller wraps it in a `PublishLoan`. Returns `false` if
635    /// the slot is already loaned.
636    ///
637    /// `&self` returning `&mut` is sound because the `busy` flag
638    /// gates exclusivity at runtime — the CAS in this function is
639    /// the only writer, and `release()` is only callable through the
640    /// loan's `Drop`.
641    #[allow(clippy::mut_from_ref)]
642    fn try_claim(&self, len: usize) -> Result<&mut [u8], LoanError> {
643        if len > TX_BUF {
644            return Err(LoanError::TooLarge);
645        }
646        if self
647            .busy
648            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
649            .is_err()
650        {
651            return Err(LoanError::WouldBlock);
652        }
653        // SAFETY: we just won the busy CAS; we hold exclusive access
654        // until release(). Lifetime is tied to `&self` for the loan.
655        let buf_ref: &mut [u8; TX_BUF] = unsafe { &mut *self.buf.get() };
656        Ok(&mut buf_ref[..len])
657    }
658
659    fn release(&self) {
660        self.busy.store(false, Ordering::Release);
661        // Wake any pending `LoanFuture` waiting on this arena. Cheap
662        // no-op if no one is waiting.
663        self.waker.wake();
664    }
665}
666
667impl<const TX_BUF: usize> EmbeddedRawPublisher<TX_BUF> {
668    /// Construct an [`EmbeddedRawPublisher`] from a backend-allocated
669    /// `RmwPublisher` handle. Public so external extension crates
670    /// (e.g. `nros-px4` for typed uORB wrappers) can wrap a handle
671    /// they obtained directly from the active session via
672    /// [`crate::Node::session_mut`] + a backend-specific create method.
673    ///
674    /// Most users should not call this — use [`crate::Node::create_publisher`]
675    /// or [`crate::Node::create_publisher_raw`] instead.
676    pub fn new(handle: session::RmwPublisher) -> Self {
677        Self {
678            handle,
679            arena: TxArena::new(),
680            event_regs: empty_event_regs(),
681        }
682    }
683
684    /// Phase 108.A — `true` if the active backend can fire the named
685    /// event for this raw publisher.
686    #[cfg(feature = "alloc")]
687    pub fn supports_event(&self, kind: nros_rmw::EventKind) -> bool {
688        use nros_rmw::Publisher as _;
689        self.handle.supports_event(kind)
690    }
691
692    /// Publish a pre-encoded byte slice. The byte format depends entirely
693    /// on the active RMW backend:
694    ///
695    /// - **zenoh / XRCE-DDS / DDS**: CDR-encoded payload including the
696    ///   4-byte CDR header.
697    /// - **uORB**: raw POD struct bytes (no header). Length must equal
698    ///   `size_of::<T::Msg>()` for the registered topic.
699    pub fn publish_raw(&self, data: &[u8]) -> Result<(), NodeError> {
700        self.handle
701            .publish_raw(data)
702            .map_err(|_| NodeError::Transport(TransportError::PublishFailed))
703    }
704
705    /// Phase 128.F.4 — raw publish with a wire-level attachment block.
706    ///
707    /// `attachment` rides alongside the payload on backends that
708    /// natively support it (zenoh-pico, Cyclone DDS). Backends without
709    /// native support silently discard `attachment` and fall back to
710    /// the regular [`publish_raw`](Self::publish_raw) path — the
711    /// default `Publisher::publish_raw_with_attachment` body in
712    /// `nros-rmw` does this delegation.
713    ///
714    /// Primary use case: cross-RMW bridges stamp the source backend's
715    /// RMW name as `bridge_origin` so a paired return bridge can drop
716    /// echoed frames deterministically.
717    pub fn publish_raw_with_attachment(
718        &self,
719        data: &[u8],
720        attachment: &[u8],
721    ) -> Result<(), NodeError> {
722        self.handle
723            .publish_raw_with_attachment(data, attachment)
724            .map_err(|_| NodeError::Transport(TransportError::PublishFailed))
725    }
726
727    /// Phase 108.B — manually assert this publisher's liveliness.
728    /// Required for `QosLivelinessPolicy::ManualByTopic` /
729    /// `ManualByNode`. No-op for AUTOMATIC / NONE.
730    pub fn assert_liveliness(&self) -> Result<(), NodeError> {
731        use nros_rmw::Publisher as _;
732        self.handle
733            .assert_liveliness()
734            .map_err(NodeError::Transport)
735    }
736
737    /// Reserve a writable slot of `len` bytes. Caller writes into the
738    /// returned [`PublishLoan`] and calls [`PublishLoan::commit`] to
739    /// publish. Never blocks; returns [`LoanError::WouldBlock`] when the
740    /// slot is already in use (arena fallback) or the backend's outbound
741    /// stream is full (lending path), and [`LoanError::TooLarge`] when
742    /// `len` exceeds the publisher's slot capacity.
743    ///
744    /// With the `rmw-lending` cargo feature on, this dispatches to the
745    /// active backend's [`SlotLending::try_lend_slot`](nros_rmw::SlotLending::try_lend_slot)
746    /// — zero-copy on backends that natively lend (zenoh-pico via
747    /// `z_bytes_from_static_buf`, XRCE-DDS via `uxr_prepare_output_stream`).
748    /// Without `rmw-lending`, the arena fallback is used: caller fills a
749    /// per-publisher inline slot, [`commit`](PublishLoan::commit) calls
750    /// the backend's `publish_raw` (single memcpy into the backend's
751    /// outbound buffer, same as `publish_raw` directly).
752    #[cfg(not(feature = "rmw-lending"))]
753    pub fn try_loan(&self, len: usize) -> Result<PublishLoan<'_, TX_BUF>, LoanError> {
754        let slice = self.arena.try_claim(len)?;
755        Ok(PublishLoan {
756            publisher: self,
757            slice,
758            committed: false,
759        })
760    }
761
762    /// `rmw-lending` variant — see the no-lending [`try_loan`] for the docs.
763    #[cfg(feature = "rmw-lending")]
764    pub fn try_loan(&self, len: usize) -> Result<PublishLoan<'_, TX_BUF>, LoanError> {
765        use nros_rmw::SlotLending;
766        match self.handle.try_lend_slot(len) {
767            Ok(Some(slot)) => Ok(PublishLoan {
768                publisher: self,
769                backend_slot: Some(slot),
770                committed: false,
771            }),
772            Ok(None) => Err(LoanError::WouldBlock),
773            Err(e) => Err(LoanError::Backend(e)),
774        }
775    }
776
777    /// Sync blocking loan with timeout. Spins the executor until the
778    /// arena slot is free or `timeout` elapses.
779    ///
780    /// Useful when you publish from a sync context that owns the
781    /// executor and want to block on a busy arena (rare — single-slot
782    /// arena means contention only when concurrent task tries the same
783    /// publisher, in which case the offending other task should have
784    /// committed promptly).
785    pub fn loan_with_timeout(
786        &self,
787        len: usize,
788        executor: &mut super::Executor,
789        timeout: core::time::Duration,
790    ) -> Result<PublishLoan<'_, TX_BUF>, LoanError> {
791        if len > TX_BUF {
792            return Err(LoanError::TooLarge);
793        }
794        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
795        let timeout_ms = timeout.as_millis().min(u64::MAX as u128) as u64;
796        let max_spins = (timeout_ms / DEFAULT_SPIN_INTERVAL_MS).max(1);
797        let mut budget = WaitBudget::new(max_spins, timeout);
798        loop {
799            match self.try_loan(len) {
800                Ok(loan) => return Ok(loan),
801                Err(LoanError::WouldBlock) => {
802                    executor.spin_once(spin_interval);
803                    if !budget.tick() {
804                        return Err(LoanError::WouldBlock);
805                    }
806                }
807                Err(other) => return Err(other),
808            }
809        }
810    }
811
812    /// Async-await on a free loan slot. Returns the loan as soon as
813    /// the arena's busy flag clears (no-lending path) or as soon as
814    /// the backend's outbound stream has room (lending path).
815    ///
816    /// Phase 99.H': cancellation-safe Future. Registers the task's
817    /// waker on the arena's [`AtomicWaker`] before checking
818    /// [`try_loan`]; another task's `commit` / `discard` calls
819    /// [`TxArena::release`] which wakes us. Dropping the future before
820    /// it resolves removes nothing from any wait queue (single-slot
821    /// AtomicWaker semantics: only the latest registration matters)
822    /// and explicitly wakes another waiter so the next task in line
823    /// gets a poll. No `PublishLoan` is materialised on cancel paths.
824    pub fn loan(&self, len: usize) -> LoanFuture<'_, TX_BUF> {
825        LoanFuture {
826            publisher: self,
827            len,
828            registered: false,
829        }
830    }
831}
832
833/// Future returned by [`EmbeddedRawPublisher::loan`]. Phase 99.H'
834/// cancellation-safe variant: if dropped before resolving, it wakes
835/// the next pending waiter so the busy-flag-clear signal isn't lost
836/// to the cancelled task.
837#[must_use = "futures do nothing unless polled"]
838pub struct LoanFuture<'a, const TX_BUF: usize> {
839    publisher: &'a EmbeddedRawPublisher<TX_BUF>,
840    len: usize,
841    /// Set on the first `Pending` return so `Drop` knows whether a
842    /// waker was registered (and thus another waiter may need a wake).
843    registered: bool,
844}
845
846impl<'a, const TX_BUF: usize> core::future::Future for LoanFuture<'a, TX_BUF> {
847    type Output = Result<PublishLoan<'a, TX_BUF>, LoanError>;
848
849    fn poll(
850        self: core::pin::Pin<&mut Self>,
851        cx: &mut core::task::Context<'_>,
852    ) -> core::task::Poll<Self::Output> {
853        // SAFETY: LoanFuture is `Unpin` for all practical purposes —
854        // it holds only `&publisher`, `len`, and a bool. Move out of
855        // Pin for the body.
856        let this = self.get_mut();
857
858        // Register-then-check: closes the race where another task's
859        // `release` fires between `try_loan` returning WouldBlock and
860        // the waker landing. The arena's AtomicWaker stores the
861        // latest waker; we update it on every poll so a `select!` /
862        // re-poll under a different waker observes the right one.
863        loan_register_waker(this.publisher, cx.waker());
864        this.registered = true;
865
866        match this.publisher.try_loan(this.len) {
867            Ok(loan) => core::task::Poll::Ready(Ok(loan)),
868            Err(LoanError::WouldBlock) => core::task::Poll::Pending,
869            Err(other) => core::task::Poll::Ready(Err(other)),
870        }
871    }
872}
873
874impl<'a, const TX_BUF: usize> Drop for LoanFuture<'a, TX_BUF> {
875    fn drop(&mut self) {
876        // If we registered a waker but never resolved, the busy flag
877        // may have just cleared and we'd swallow the wake. Forward it
878        // to the next waiter so the line keeps moving. Cheap no-op
879        // when no one else is waiting.
880        if self.registered {
881            loan_wake_next(self.publisher);
882        }
883    }
884}
885
886// Indirection so the `rmw-lending` build (which has no arena) can
887// stub these. With `rmw-lending` on, the lending Future variant uses
888// the executor's drive_io spin to drain the backend stream — there's
889// no arena-level wake source, so the helpers degrade to a self-wake.
890#[cfg(not(feature = "rmw-lending"))]
891fn loan_register_waker<const TX_BUF: usize>(
892    pub_: &EmbeddedRawPublisher<TX_BUF>,
893    waker: &core::task::Waker,
894) {
895    pub_.arena.waker.register(waker);
896}
897
898#[cfg(not(feature = "rmw-lending"))]
899fn loan_wake_next<const TX_BUF: usize>(pub_: &EmbeddedRawPublisher<TX_BUF>) {
900    pub_.arena.waker.wake();
901}
902
903#[cfg(feature = "rmw-lending")]
904fn loan_register_waker<const TX_BUF: usize>(
905    _pub_: &EmbeddedRawPublisher<TX_BUF>,
906    waker: &core::task::Waker,
907) {
908    // No arena wake source under lending; self-wake so the runtime
909    // re-polls after the next executor tick (which drains the
910    // backend's outbound stream via `drive_io`).
911    waker.wake_by_ref();
912}
913
914#[cfg(feature = "rmw-lending")]
915fn loan_wake_next<const TX_BUF: usize>(_pub_: &EmbeddedRawPublisher<TX_BUF>) {
916    // No-op: no AtomicWaker on the arena under the lending build.
917}
918
919/// Error type for [`EmbeddedRawPublisher::try_loan`].
920#[derive(Debug, Clone, PartialEq, Eq)]
921pub enum LoanError {
922    /// Requested length exceeds the publisher's arena slot capacity.
923    TooLarge,
924    /// Arena slot already in use; another publish is in flight on this
925    /// publisher. Retry after the other loan commits or discards.
926    WouldBlock,
927    /// Backend rejected the publish at commit time.
928    Backend(TransportError),
929}
930
931impl From<TransportError> for LoanError {
932    fn from(e: TransportError) -> Self {
933        LoanError::Backend(e)
934    }
935}
936
937/// Writable loan into a [`EmbeddedRawPublisher`]'s slot.
938///
939/// User fills `as_mut()` then calls [`commit`](Self::commit) to publish,
940/// or [`discard`](Self::discard) to release the slot without publishing.
941/// Dropping without either silently discards (slot freed); a
942/// `#[must_use]` warning catches accidental drops at compile time.
943///
944/// Two backings, selected at compile time by the `rmw-lending` feature:
945///
946/// - **Arena (default)**: per-publisher inline `[u8; TX_BUF]` slot. On
947///   commit, `publish_raw` memcpys into the backend's outbound buffer.
948/// - **Backend lending (`rmw-lending`)**: slot owned by the backend
949///   (zenoh-pico's static buffer aliased via `z_bytes_from_static_buf`,
950///   XRCE's `ucdrBuffer` reservation). True zero-copy publish.
951#[must_use = "PublishLoan must be committed or discarded; dropping silently rolls back"]
952#[cfg(not(feature = "rmw-lending"))]
953pub struct PublishLoan<'a, const TX_BUF: usize> {
954    publisher: &'a EmbeddedRawPublisher<TX_BUF>,
955    slice: &'a mut [u8],
956    committed: bool,
957}
958
959#[must_use = "PublishLoan must be committed or discarded; dropping silently rolls back"]
960#[cfg(feature = "rmw-lending")]
961pub struct PublishLoan<'a, const TX_BUF: usize> {
962    publisher: &'a EmbeddedRawPublisher<TX_BUF>,
963    /// `Option` so `commit` can move the slot out via `take()` without
964    /// triggering Drop's release path. Always `Some(_)` until `commit`
965    /// or `discard` runs.
966    backend_slot: Option<<session::RmwPublisher as nros_rmw::SlotLending>::Slot<'a>>,
967    committed: bool,
968}
969
970#[cfg(not(feature = "rmw-lending"))]
971impl<'a, const TX_BUF: usize> PublishLoan<'a, TX_BUF> {
972    /// Mutable view into the loaned bytes. Caller writes message data here.
973    #[allow(clippy::should_implement_trait)]
974    pub fn as_mut(&mut self) -> &mut [u8] {
975        self.slice
976    }
977
978    /// Commit the loan: hand the bytes to the backend's `publish_raw`,
979    /// then release the arena slot. Returns the backend's publish error
980    /// if any (slot is released regardless).
981    pub fn commit(mut self) -> Result<(), LoanError> {
982        let res = self
983            .publisher
984            .handle
985            .publish_raw(self.slice)
986            .map_err(|_| LoanError::Backend(TransportError::PublishFailed));
987        self.committed = true;
988        // Drop runs and releases the slot.
989        res
990    }
991
992    /// Discard the loan without publishing. Equivalent to dropping, but
993    /// explicit (no #[must_use] warning).
994    pub fn discard(mut self) {
995        self.committed = true; // Suppress Drop's "discard" log if any.
996        drop(self);
997    }
998}
999
1000#[cfg(feature = "rmw-lending")]
1001impl<'a, const TX_BUF: usize> PublishLoan<'a, TX_BUF> {
1002    /// Mutable view into the backend-lent bytes.
1003    #[allow(clippy::should_implement_trait)]
1004    pub fn as_mut(&mut self) -> &mut [u8] {
1005        // SAFETY-invariant: `backend_slot` is `Some` for the whole life of
1006        // a `PublishLoan` — only `commit`/`discard` take it, and both
1007        // consume `self` by value, so no `&mut self` method can observe
1008        // `None`.
1009        self.backend_slot
1010            .as_mut()
1011            .expect("PublishLoan slot already consumed")
1012            .as_mut()
1013    }
1014
1015    /// Commit the loan: hand the slot to the backend's `commit_slot` for
1016    /// flushing. The slot's bytes are written to the wire without an
1017    /// extra user-side memcpy.
1018    pub fn commit(mut self) -> Result<(), LoanError> {
1019        use nros_rmw::SlotLending;
1020        // SAFETY-invariant: first and only `take` — `commit` consumes
1021        // `self`, so the slot is still `Some` here.
1022        let slot = self
1023            .backend_slot
1024            .take()
1025            .expect("PublishLoan slot already consumed");
1026        self.committed = true;
1027        self.publisher
1028            .handle
1029            .commit_slot(slot)
1030            .map_err(LoanError::Backend)
1031    }
1032
1033    /// Discard the loan without publishing. The backend-owned slot is
1034    /// released by its own Drop when this `PublishLoan` is dropped.
1035    pub fn discard(mut self) {
1036        self.committed = true;
1037        // backend_slot's Option<Slot> drops here, releasing the slot.
1038        drop(self.backend_slot.take());
1039    }
1040}
1041
1042#[cfg(not(feature = "rmw-lending"))]
1043impl<'a, const TX_BUF: usize> Drop for PublishLoan<'a, TX_BUF> {
1044    fn drop(&mut self) {
1045        // Slot always returned to the free pool; whether the bytes were
1046        // actually published is encoded in `committed`. Future telemetry
1047        // hook could log uncommitted drops in debug builds.
1048        self.publisher.arena.release();
1049    }
1050}
1051
1052// rmw-lending variant relies on the backend's `Slot` Drop impl to release
1053// the underlying buffer/stream slot. No explicit nros-side Drop needed.
1054
1055// ============================================================================
1056// Subscription
1057// ============================================================================
1058
1059/// Typed subscription handle with internal receive buffer.
1060///
1061/// Two methods, both byte-oriented at the wire:
1062///
1063/// - [`try_recv`](Self::try_recv) / [`recv`](Self::recv) — pull bytes
1064///   from the backend, CDR-decode into `M: RosMessage`, hand back
1065///   ownership of the typed message.
1066/// - [`try_recv_raw`](Self::try_recv_raw) — copy bytes into the
1067///   subscription's internal buffer and return the length, leaving CDR
1068///   decoding to the caller.
1069///
1070/// **No typed `borrow()` exists.** Borrow lives exclusively on
1071/// [`RawSubscription`]. `RecvView` is `&[u8]` semantics; CDR decoding
1072/// into a typed `M` requires owning the bytes (or running the decoder
1073/// in place), which the borrow contract doesn't fit. See
1074/// `docs/design/0010-zero-copy-raw-api.md` decision D7.
1075pub struct Subscription<M, const RX_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE }> {
1076    pub(crate) handle: session::RmwSubscriber,
1077    pub(crate) buffer: [u8; RX_BUF],
1078    /// Phase 108 — registered event closures.
1079    pub(crate) event_regs: EventRegs,
1080    /// W3b.5 — contracted-endpoint age hook (cell + epoch clock);
1081    /// `None` = uncontracted.
1082    pub(crate) age_mon: Option<crate::executor::arena::AgeMon>,
1083    pub(crate) _phantom: PhantomData<M>,
1084}
1085
1086impl<M, const RX_BUF: usize> Drop for Subscription<M, RX_BUF> {
1087    fn drop(&mut self) {
1088        drop_event_regs(&mut self.event_regs);
1089    }
1090}
1091
1092impl<M: RosMessage, const RX_BUF: usize> Subscription<M, RX_BUF> {
1093    /// Try to receive a typed message (non-blocking).
1094    pub fn try_recv(&mut self) -> Result<Option<M>, NodeError> {
1095        match self
1096            .handle
1097            .try_recv_raw(&mut self.buffer)
1098            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?
1099        {
1100            Some(len) => {
1101                // W3b.5 — record take-age for contracted endpoints.
1102                crate::executor::arena::observe_age::<M>(&self.buffer[..len], &self.age_mon);
1103                let mut reader = CdrReader::new_with_header(&self.buffer[..len])
1104                    .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
1105                let msg = M::deserialize(&mut reader)
1106                    .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
1107                Ok(Some(msg))
1108            }
1109            None => Ok(None),
1110        }
1111    }
1112
1113    /// Try to receive raw CDR-encoded data (non-blocking).
1114    pub fn try_recv_raw(&mut self) -> Result<Option<usize>, NodeError> {
1115        self.handle
1116            .try_recv_raw(&mut self.buffer)
1117            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))
1118    }
1119
1120    /// Get the receive buffer (valid after `try_recv_raw`).
1121    pub fn buffer(&self) -> &[u8] {
1122        &self.buffer
1123    }
1124
1125    // ====================================================================
1126    // Phase 108 — status events
1127    // ====================================================================
1128    //
1129    // Subscriber-side: `LivelinessChanged`, `RequestedDeadlineMissed`,
1130    // `MessageLost`. Returns
1131    // `NodeError::Transport(TransportError::Unsupported)` if the
1132    // active backend doesn't generate the event for this entity.
1133
1134    /// `true` if the active backend can fire the named event for this
1135    /// subscriber.
1136    #[cfg(feature = "alloc")]
1137    pub fn supports_event(&self, kind: nros_rmw::EventKind) -> bool {
1138        use nros_rmw::Subscriber as _;
1139        self.handle.supports_event(kind)
1140    }
1141
1142    /// Register a callback for `LivelinessChanged`. Fires when a
1143    /// tracked publisher's liveliness state changes.
1144    #[cfg(feature = "alloc")]
1145    pub fn on_liveliness_changed<F>(&mut self, cb: F) -> Result<(), NodeError>
1146    where
1147        F: FnMut(nros_rmw::LivelinessChangedStatus) + Send + 'static,
1148    {
1149        register_sub_event_liveliness::<F>(&mut self.handle, &mut self.event_regs, cb)
1150    }
1151
1152    /// Register a callback for `RequestedDeadlineMissed`. Fires when
1153    /// an expected sample doesn't arrive within `deadline`.
1154    #[cfg(feature = "alloc")]
1155    pub fn on_requested_deadline_missed<F>(
1156        &mut self,
1157        deadline: core::time::Duration,
1158        cb: F,
1159    ) -> Result<(), NodeError>
1160    where
1161        F: FnMut(nros_rmw::CountStatus) + Send + 'static,
1162    {
1163        register_sub_event_count::<F, _>(
1164            &mut self.handle,
1165            &mut self.event_regs,
1166            nros_rmw::EventKind::RequestedDeadlineMissed,
1167            deadline.as_millis().min(u32::MAX as u128) as u32,
1168            cb,
1169            |payload, f| {
1170                if let nros_rmw::EventPayload::RequestedDeadlineMissed(s) = payload {
1171                    f(*s);
1172                }
1173            },
1174        )
1175    }
1176
1177    /// Register a callback for `MessageLost`. Fires when the backend
1178    /// drops a sample (overflow, etc.).
1179    #[cfg(feature = "alloc")]
1180    pub fn on_message_lost<F>(&mut self, cb: F) -> Result<(), NodeError>
1181    where
1182        F: FnMut(nros_rmw::CountStatus) + Send + 'static,
1183    {
1184        register_sub_event_count::<F, _>(
1185            &mut self.handle,
1186            &mut self.event_regs,
1187            nros_rmw::EventKind::MessageLost,
1188            0,
1189            cb,
1190            |payload, f| {
1191                if let nros_rmw::EventPayload::MessageLost(s) = payload {
1192                    f(*s);
1193                }
1194            },
1195        )
1196    }
1197
1198    /// Check if data is available without consuming it.
1199    pub fn has_data(&self) -> bool {
1200        self.handle.has_data()
1201    }
1202
1203    /// Process the received message in-place without copying.
1204    pub fn process_in_place(&mut self, f: impl FnOnce(&M)) -> Result<bool, NodeError> {
1205        let mut deser_err = false;
1206        let processed = self
1207            .handle
1208            .process_raw_in_place(|raw| {
1209                match CdrReader::new_with_header(raw).and_then(|mut r| M::deserialize(&mut r)) {
1210                    Ok(msg) => f(&msg),
1211                    Err(_) => deser_err = true,
1212                }
1213            })
1214            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
1215
1216        if deser_err {
1217            return Err(NodeError::Transport(TransportError::DeserializationError));
1218        }
1219        Ok(processed)
1220    }
1221
1222    /// Async: wait for the next message (no `futures` dependency needed).
1223    ///
1224    /// Requires a background task running `executor.spin_async()` to drive
1225    /// I/O. Returns `Ok(msg)` on the next received message, or `Err` if the
1226    /// transport reports an error.
1227    ///
1228    /// When the `stream` feature is enabled, prefer `StreamExt::next()` /
1229    /// `TryStreamExt::try_next()` for combinator support.
1230    ///
1231    /// # Example
1232    ///
1233    /// ```ignore
1234    /// let mut sub = node.create_subscription::<Int32>("/topic")?;
1235    /// loop {
1236    ///     let msg = sub.recv().await?;
1237    ///     /* handle msg */
1238    /// }
1239    /// ```
1240    pub async fn recv(&mut self) -> Result<M, NodeError> {
1241        core::future::poll_fn(|cx| {
1242            // Register the waker FIRST, then check for data. This ordering
1243            // closes the race window where a subscriber callback fires
1244            // between `try_recv` returning `None` and the waker being
1245            // registered — the wake would otherwise be delivered to the
1246            // previous waker (or nowhere) and the task would hang.
1247            self.handle.register_waker(cx.waker());
1248            match self.try_recv() {
1249                Ok(Some(msg)) => core::task::Poll::Ready(Ok(msg)),
1250                Ok(None) => core::task::Poll::Pending,
1251                Err(e) => core::task::Poll::Ready(Err(e)),
1252            }
1253        })
1254        .await
1255    }
1256
1257    /// Sync: wait for the next message, spinning the executor.
1258    ///
1259    /// Returns `Ok(Some(msg))` if a message arrives within `timeout_ms`,
1260    /// or `Ok(None)` on timeout. Unlike [`Promise::wait()`], timeout is
1261    /// not an error — the caller typically retries in a loop.
1262    ///
1263    /// # Example
1264    ///
1265    /// ```ignore
1266    /// while let Some(msg) = sub.wait_next(&mut executor, core::time::Duration::from_millis(1000))? {
1267    ///     /* handle msg */
1268    /// }
1269    /// ```
1270    pub fn wait_next(
1271        &mut self,
1272        executor: &mut super::Executor,
1273        timeout: core::time::Duration,
1274    ) -> Result<Option<M>, NodeError> {
1275        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
1276        let timeout_ms = timeout.as_millis().min(u64::MAX as u128) as u64;
1277        let max_spins = (timeout_ms / DEFAULT_SPIN_INTERVAL_MS).max(1);
1278        let mut budget = WaitBudget::new(max_spins, timeout);
1279        loop {
1280            executor.spin_once(spin_interval);
1281            if let Some(msg) = self.try_recv()? {
1282                return Ok(Some(msg));
1283            }
1284            if !budget.tick() {
1285                return Ok(None);
1286            }
1287        }
1288    }
1289}
1290
1291// ============================================================================
1292// RawSubscription — typeless subscription for non-ROS message wire formats
1293// ============================================================================
1294
1295/// Typeless subscription handle. Counterpart of [`EmbeddedRawPublisher`].
1296///
1297/// The user owns the decoding step: call [`try_recv_raw`](Self::try_recv_raw)
1298/// to fill an internal buffer with bytes whose format depends on the active
1299/// RMW backend, then interpret them however is appropriate (memcpy, custom
1300/// parser, …).
1301pub struct RawSubscription<const RX_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE }> {
1302    pub(crate) handle: session::RmwSubscriber,
1303    pub(crate) buffer: [u8; RX_BUF],
1304    /// Phase 108 — registered event closures.
1305    pub(crate) event_regs: EventRegs,
1306}
1307
1308impl<const RX_BUF: usize> Drop for RawSubscription<RX_BUF> {
1309    fn drop(&mut self) {
1310        drop_event_regs(&mut self.event_regs);
1311    }
1312}
1313
1314impl<const RX_BUF: usize> RawSubscription<RX_BUF> {
1315    /// Construct a [`RawSubscription`] from a backend-allocated
1316    /// `RmwSubscriber` handle. Public so external extension crates
1317    /// (e.g. `nros-px4` for typed uORB wrappers) can wrap a handle
1318    /// they obtained directly from the active session via
1319    /// [`crate::Node::session_mut`] + a backend-specific create method.
1320    ///
1321    /// Most users should not call this — use
1322    /// [`crate::Node::create_subscription`] or
1323    /// [`crate::Node::create_subscription_raw`] instead.
1324    pub fn new(handle: session::RmwSubscriber) -> Self {
1325        Self {
1326            handle,
1327            buffer: [0u8; RX_BUF],
1328            event_regs: empty_event_regs(),
1329        }
1330    }
1331
1332    /// Try to receive raw bytes (non-blocking). Returns `Ok(Some(len))`
1333    /// with the message length on success; the bytes live in
1334    /// [`buffer`](Self::buffer) until the next call.
1335    pub fn try_recv_raw(&mut self) -> Result<Option<usize>, NodeError> {
1336        self.handle
1337            .try_recv_raw(&mut self.buffer)
1338            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))
1339    }
1340
1341    /// Phase 128.F.4 — raw receive that also surfaces the incoming
1342    /// sample's wire-level attachment block.
1343    ///
1344    /// Returns `Ok(Some((payload_len, attachment_len)))`. The payload
1345    /// lives in [`buffer`](Self::buffer); the attachment is written
1346    /// into caller-supplied `att_buf`. `attachment_len == 0` means
1347    /// the incoming sample carried no attachment.
1348    ///
1349    /// Backends without native attachment support delegate to
1350    /// [`try_recv_raw`](Self::try_recv_raw) and always report
1351    /// `attachment_len == 0` (default `Subscriber` trait body in
1352    /// `nros-rmw`).
1353    pub fn try_recv_raw_with_attachment(
1354        &mut self,
1355        att_buf: &mut [u8],
1356    ) -> Result<Option<(usize, usize)>, NodeError> {
1357        self.handle
1358            .try_recv_raw_with_attachment(&mut self.buffer, att_buf)
1359            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))
1360    }
1361
1362    /// Phase 252 / issue 0073 — raw receive that also returns the E2E
1363    /// [`IntegrityStatus`](nros_rmw::IntegrityStatus) (CRC + sequence gap/dup) for
1364    /// the C/C++ `nros_subscription_try_recv_validated` path. The validator lives
1365    /// in the backend handle (`try_recv_validated`), so no typed message is needed;
1366    /// the payload lives in [`buffer`](Self::buffer). `crc_valid == None` when the
1367    /// wire sample carried no CRC (e.g. a publisher built without `safety-e2e`).
1368    #[cfg(feature = "safety-e2e")]
1369    pub fn try_recv_validated(
1370        &mut self,
1371    ) -> Result<Option<(usize, nros_rmw::IntegrityStatus)>, NodeError> {
1372        use nros_rmw::Subscriber;
1373        self.handle
1374            .try_recv_validated(&mut self.buffer)
1375            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))
1376    }
1377
1378    /// Phase 124.D.1 — burst-take. Drain up to `max_msgs` queued
1379    /// samples into the caller-supplied contiguous block in one
1380    /// call, with the i-th sample at
1381    /// `buf[i * per_msg_cap .. i * per_msg_cap + out_lens[i]]`.
1382    /// Returns the number of messages delivered.
1383    ///
1384    /// Backends without a native batch take inherit the
1385    /// `Subscriber::try_recv_sequence` default body which loop-drives
1386    /// `try_recv_raw` — same shape, same observable result; the
1387    /// batched API just lets sensor loops commit to the call shape
1388    /// regardless of backend support.
1389    pub fn try_recv_sequence(
1390        &mut self,
1391        buf: &mut [u8],
1392        per_msg_cap: usize,
1393        max_msgs: usize,
1394        out_lens: &mut [usize],
1395    ) -> Result<usize, NodeError> {
1396        use nros_rmw::Subscriber;
1397        self.handle
1398            .try_recv_sequence(buf, per_msg_cap, max_msgs, out_lens)
1399            .map_err(NodeError::Transport)
1400    }
1401
1402    /// Phase 122.3.c.6.e — register a `Waker` that fires when a new
1403    /// message arrives. Mirror of the existing service-server /
1404    /// service-client wake plumbing. No-op on backends that don't
1405    /// support waking — caller falls back to polling.
1406    pub fn register_waker(&self, waker: &core::task::Waker) {
1407        use nros_rmw::Subscriber;
1408        self.handle.register_waker(waker);
1409    }
1410
1411    /// Phase 108.A — `true` if the active backend can fire the named
1412    /// event for this raw subscription.
1413    #[cfg(feature = "alloc")]
1414    pub fn supports_event(&self, kind: nros_rmw::EventKind) -> bool {
1415        use nros_rmw::Subscriber as _;
1416        self.handle.supports_event(kind)
1417    }
1418
1419    /// Phase 108.A — register a callback for `LivelinessChanged`.
1420    #[cfg(feature = "alloc")]
1421    pub fn on_liveliness_changed<F>(&mut self, cb: F) -> Result<(), NodeError>
1422    where
1423        F: FnMut(nros_rmw::LivelinessChangedStatus) + Send + 'static,
1424    {
1425        register_sub_event_liveliness::<F>(&mut self.handle, &mut self.event_regs, cb)
1426    }
1427
1428    /// Phase 108.A — register a callback for `RequestedDeadlineMissed`.
1429    #[cfg(feature = "alloc")]
1430    pub fn on_requested_deadline_missed<F>(
1431        &mut self,
1432        deadline: core::time::Duration,
1433        cb: F,
1434    ) -> Result<(), NodeError>
1435    where
1436        F: FnMut(nros_rmw::CountStatus) + Send + 'static,
1437    {
1438        register_sub_event_count::<F, _>(
1439            &mut self.handle,
1440            &mut self.event_regs,
1441            nros_rmw::EventKind::RequestedDeadlineMissed,
1442            deadline.as_millis().min(u32::MAX as u128) as u32,
1443            cb,
1444            |payload, f| {
1445                if let nros_rmw::EventPayload::RequestedDeadlineMissed(s) = payload {
1446                    f(*s);
1447                }
1448            },
1449        )
1450    }
1451
1452    /// Phase 108.A — register a callback for `MessageLost`.
1453    #[cfg(feature = "alloc")]
1454    pub fn on_message_lost<F>(&mut self, cb: F) -> Result<(), NodeError>
1455    where
1456        F: FnMut(nros_rmw::CountStatus) + Send + 'static,
1457    {
1458        register_sub_event_count::<F, _>(
1459            &mut self.handle,
1460            &mut self.event_regs,
1461            nros_rmw::EventKind::MessageLost,
1462            0,
1463            cb,
1464            |payload, f| {
1465                if let nros_rmw::EventPayload::MessageLost(s) = payload {
1466                    f(*s);
1467                }
1468            },
1469        )
1470    }
1471
1472    /// Get the receive buffer (valid after [`try_recv_raw`](Self::try_recv_raw)).
1473    pub fn buffer(&self) -> &[u8] {
1474        &self.buffer
1475    }
1476
1477    /// Check if data is available without consuming it.
1478    pub fn has_data(&self) -> bool {
1479        self.handle.has_data()
1480    }
1481
1482    /// Try to borrow the next available message in place. Returns
1483    /// `Ok(None)` if no message is ready; never blocks.
1484    ///
1485    /// The returned [`RecvView`] borrows the subscriber's internal
1486    /// receive buffer. Lifetime is tied to `&mut self` — only one view
1487    /// can be live at a time, and the next `try_borrow` / `try_recv_raw`
1488    /// call invalidates the previous view's bytes.
1489    ///
1490    /// View is `!Send + !Sync` to discourage holding it across `.await`
1491    /// or thread boundaries (would block subsequent receives on the
1492    /// same subscriber).
1493    #[cfg(not(feature = "rmw-lending"))]
1494    pub fn try_borrow(&mut self) -> Result<Option<RecvView<'_>>, NodeError> {
1495        match self.try_recv_raw()? {
1496            Some(len) => Ok(Some(RecvView {
1497                bytes: &self.buffer[..len],
1498                _marker: core::marker::PhantomData,
1499            })),
1500            None => Ok(None),
1501        }
1502    }
1503
1504    /// `rmw-lending` variant — dispatches to the backend's
1505    /// [`SlotBorrowing::try_borrow`](nros_rmw::SlotBorrowing::try_borrow)
1506    /// for true zero-copy receive (zenoh-pico's static buffer borrowed
1507    /// directly via `z_bytes_get_contiguous_view`, XRCE's slot borrowed
1508    /// in place). The bytes never touch `self.buffer`.
1509    #[cfg(feature = "rmw-lending")]
1510    pub fn try_borrow(&mut self) -> Result<Option<RecvView<'_>>, NodeError> {
1511        use nros_rmw::SlotBorrowing;
1512        match self.handle.try_borrow() {
1513            Ok(Some(view)) => Ok(Some(RecvView {
1514                view: Some(view),
1515                _marker: core::marker::PhantomData,
1516            })),
1517            Ok(None) => Ok(None),
1518            Err(e) => Err(NodeError::Transport(e)),
1519        }
1520    }
1521
1522    /// Async-await on the next message, returning a [`RecvView`].
1523    /// Mirrors the `Subscription::recv` pattern but typeless.
1524    ///
1525    /// Backend wake source: `Subscriber::register_waker`. Same race-
1526    /// safe register-then-check ordering as `Subscription::recv`.
1527    pub async fn borrow(&mut self) -> Result<RecvView<'_>, NodeError> {
1528        // Wait for `has_data` to flip true via the backend's
1529        // AtomicWaker, *without* holding any borrow that `try_borrow`
1530        // would need afterwards. Borrow `&self.handle` immutably
1531        // inside poll_fn so the borrow checker can prove `&mut self`
1532        // is free by the time we return Ok(view) below.
1533        //
1534        // Phase 99.H' cancellation safety: there is no reservation
1535        // taken inside poll. Dropping the future before it resolves
1536        // simply abandons whatever waker registration the backend
1537        // accepted; the next call to `borrow().await` (or
1538        // `try_borrow`) re-registers. No leaked state.
1539        {
1540            let handle = &self.handle;
1541            core::future::poll_fn(|cx| {
1542                // Register-then-check: closes the race where a backend
1543                // callback fires between has_data returning false and
1544                // the waker landing.
1545                handle.register_waker(cx.waker());
1546                if handle.has_data() {
1547                    core::task::Poll::Ready(())
1548                } else {
1549                    core::task::Poll::Pending
1550                }
1551            })
1552            .await;
1553        }
1554        // has_data was true at some point; in the single-threaded
1555        // executor there's no other reader, so try_borrow returns Some.
1556        // A spurious wake (very unlikely on shipping backends) returns
1557        // WouldBlock and the caller can retry.
1558        match self.try_borrow()? {
1559            Some(view) => Ok(view),
1560            None => Err(NodeError::Transport(TransportError::WouldBlock)),
1561        }
1562    }
1563
1564    /// Sync blocking borrow with timeout. Spins the executor until a
1565    /// message is available or `timeout` elapses.
1566    ///
1567    /// Returns `Ok(Some(view))` on success, `Ok(None)` on timeout.
1568    /// The view's lifetime is tied to `&mut self`.
1569    pub fn borrow_with_timeout(
1570        &mut self,
1571        executor: &mut super::Executor,
1572        timeout: core::time::Duration,
1573    ) -> Result<Option<RecvView<'_>>, NodeError> {
1574        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
1575        let timeout_ms = timeout.as_millis().min(u64::MAX as u128) as u64;
1576        let max_spins = (timeout_ms / DEFAULT_SPIN_INTERVAL_MS).max(1);
1577        let mut budget = WaitBudget::new(max_spins, timeout);
1578        loop {
1579            executor.spin_once(spin_interval);
1580            if self.has_data() {
1581                return self.try_borrow();
1582            }
1583            if !budget.tick() {
1584                return Ok(None);
1585            }
1586        }
1587    }
1588}
1589
1590// ============================================================================
1591// RawServiceServer / RawServiceClient (Phase 122.3.c — L1 polling, typeless)
1592// ============================================================================
1593
1594/// Typeless service-server handle. L1 counterpart of
1595/// [`EmbeddedServiceServer`] for callers that own their own scheduler
1596/// (RTIC, embassy, FreeRTOS-task-per-entity) and the C / C++ FFI
1597/// shims.
1598///
1599/// Holds the transport handle plus an inline request buffer. The
1600/// caller polls [`try_recv_request_raw`](Self::try_recv_request_raw)
1601/// and sends replies via [`send_reply_raw`](Self::send_reply_raw)
1602/// with raw CDR bytes.
1603pub struct RawServiceServer<
1604    const REQ_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1605    const RESP_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1606> {
1607    pub(crate) handle: session::RmwServiceServer,
1608    pub(crate) req_buffer: [u8; REQ_BUF],
1609    pub(crate) _phantom_resp: PhantomData<[u8; RESP_BUF]>,
1610}
1611
1612impl<const REQ_BUF: usize, const RESP_BUF: usize> RawServiceServer<REQ_BUF, RESP_BUF> {
1613    /// Phase 122.3.c.6.e — register a `Waker` that fires when a new
1614    /// request arrives. Mirror of the existing subscriber /
1615    /// service-client wake plumbing. No-op on backends that don't
1616    /// support waking — caller falls back to polling.
1617    pub fn register_waker(&self, waker: &core::task::Waker) {
1618        use nros_rmw::ServiceServerTrait;
1619        self.handle.register_waker(waker);
1620    }
1621
1622    /// Construct a [`RawServiceServer`] from a backend-allocated
1623    /// `RmwServiceServer` handle. Public so external crates and the
1624    /// C / C++ FFI shims can wrap a handle obtained directly from
1625    /// [`crate::Node::session_mut`].
1626    pub fn new(handle: session::RmwServiceServer) -> Self {
1627        Self {
1628            handle,
1629            req_buffer: [0u8; REQ_BUF],
1630            _phantom_resp: PhantomData,
1631        }
1632    }
1633
1634    /// Try to receive a service request (non-blocking).
1635    ///
1636    /// Returns `Ok(Some((len, sequence_number)))` when a request is
1637    /// available — the raw CDR bytes live in
1638    /// [`req_buffer`](Self::req_buffer) at `&req_buffer()[..len]`
1639    /// until the next call. The sequence number is required by
1640    /// [`send_reply_raw`](Self::send_reply_raw).
1641    pub fn try_recv_request_raw(&mut self) -> Result<Option<(usize, i64)>, NodeError> {
1642        match self.handle.try_recv_request(&mut self.req_buffer) {
1643            Ok(Some(req)) => Ok(Some((req.data.len(), req.sequence_number))),
1644            Ok(None) => Ok(None),
1645            Err(_) => Err(NodeError::Transport(TransportError::ServiceRequestFailed)),
1646        }
1647    }
1648
1649    /// Borrow the inline request buffer. Valid after a successful
1650    /// [`try_recv_request_raw`](Self::try_recv_request_raw) call.
1651    pub fn req_buffer(&self) -> &[u8] {
1652        &self.req_buffer
1653    }
1654
1655    /// Send a reply with raw CDR bytes. `sequence_number` must match
1656    /// the value returned by the most recent
1657    /// [`try_recv_request_raw`](Self::try_recv_request_raw).
1658    pub fn send_reply_raw(&mut self, sequence_number: i64, data: &[u8]) -> Result<(), NodeError> {
1659        self.handle
1660            .send_reply(sequence_number, data)
1661            .map_err(|_| NodeError::ServiceReplyFailed)
1662    }
1663}
1664
1665/// Typeless service-client handle. L1 counterpart of
1666/// [`EmbeddedServiceClient`] for the same audience as
1667/// [`RawServiceServer`].
1668pub struct RawServiceClient<
1669    const REQ_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1670    const REPLY_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1671> {
1672    pub(crate) handle: session::RmwServiceClient,
1673    pub(crate) reply_buffer: [u8; REPLY_BUF],
1674    pub(crate) _phantom_req: PhantomData<[u8; REQ_BUF]>,
1675}
1676
1677impl<const REQ_BUF: usize, const REPLY_BUF: usize> RawServiceClient<REQ_BUF, REPLY_BUF> {
1678    /// Phase 122.3.c.6.e — register a `Waker` that fires when the
1679    /// reply to a previously-sent request lands.
1680    pub fn register_waker(&self, waker: &core::task::Waker) {
1681        use nros_rmw::ServiceClientTrait;
1682        self.handle.register_waker(waker);
1683    }
1684
1685    /// Construct from a backend-allocated handle. Same audience as
1686    /// [`RawServiceServer::new`].
1687    pub fn new(handle: session::RmwServiceClient) -> Self {
1688        Self {
1689            handle,
1690            reply_buffer: [0u8; REPLY_BUF],
1691            _phantom_req: PhantomData,
1692        }
1693    }
1694
1695    /// Send a raw CDR request. Non-blocking; the reply arrives via
1696    /// [`try_recv_reply_raw`](Self::try_recv_reply_raw).
1697    pub fn send_request_raw(&mut self, request: &[u8]) -> Result<(), NodeError> {
1698        self.handle
1699            .send_request_raw(request)
1700            .map_err(|_| NodeError::ServiceRequestFailed)
1701    }
1702
1703    /// Phase 124.G.3 — graph-aware "is the matching server up?"
1704    /// probe. Mirrors [`Client::server_available`] for the raw API.
1705    pub fn server_available(&self) -> Result<bool, NodeError> {
1706        use nros_rmw::ServiceClientTrait;
1707        self.handle.server_available().map_err(NodeError::Transport)
1708    }
1709
1710    /// Try to receive a reply (non-blocking). Returns
1711    /// `Ok(Some(len))` with the reply length on success; bytes
1712    /// live in [`reply_buffer`](Self::reply_buffer) until the next
1713    /// call.
1714    pub fn try_recv_reply_raw(&mut self) -> Result<Option<usize>, NodeError> {
1715        self.handle
1716            .try_recv_reply_raw(&mut self.reply_buffer)
1717            .map_err(|_| NodeError::Transport(TransportError::ServiceRequestFailed))
1718    }
1719
1720    /// Borrow the inline reply buffer. Valid after a successful
1721    /// [`try_recv_reply_raw`](Self::try_recv_reply_raw) call.
1722    pub fn reply_buffer(&self) -> &[u8] {
1723        &self.reply_buffer
1724    }
1725}
1726
1727/// Read-only view into a [`RawSubscription`]'s receive buffer.
1728///
1729/// `!Send + !Sync`: cannot cross `.await` or threads. Drop releases
1730/// any backend lock + lets the next message advance.
1731///
1732/// Two backings, selected at compile time by the `rmw-lending` feature:
1733/// the no-lending variant points at `RawSubscription::buffer` (filled by
1734/// `try_recv_raw`'s memcpy); the lending variant holds the backend's
1735/// own [`SlotBorrowing::View`](nros_rmw::SlotBorrowing::View) — zero
1736/// copies on the receive path, with the backend's Drop taking care of
1737/// releasing the buffer lock.
1738#[cfg(not(feature = "rmw-lending"))]
1739pub struct RecvView<'a> {
1740    bytes: &'a [u8],
1741    _marker: core::marker::PhantomData<*const ()>,
1742}
1743
1744#[cfg(feature = "rmw-lending")]
1745pub struct RecvView<'a> {
1746    /// `Option` for symmetry with `PublishLoan::backend_slot`. Always
1747    /// `Some(_)` until the view is dropped.
1748    view: Option<<session::RmwSubscriber as nros_rmw::SlotBorrowing>::View<'a>>,
1749    _marker: core::marker::PhantomData<*const ()>,
1750}
1751
1752#[cfg(not(feature = "rmw-lending"))]
1753impl<'a> core::ops::Deref for RecvView<'a> {
1754    type Target = [u8];
1755    fn deref(&self) -> &[u8] {
1756        self.bytes
1757    }
1758}
1759
1760#[cfg(feature = "rmw-lending")]
1761impl<'a> core::ops::Deref for RecvView<'a> {
1762    type Target = [u8];
1763    fn deref(&self) -> &[u8] {
1764        // SAFETY-invariant: `view` is `Some` for the whole life of a
1765        // `RecvView` — only `Drop` takes it, after which no method (incl.
1766        // this `deref`) is reachable.
1767        self.view
1768            .as_ref()
1769            .expect("RecvView accessed after drop")
1770            .as_ref()
1771    }
1772}
1773
1774#[cfg(not(feature = "rmw-lending"))]
1775impl<'a> AsRef<[u8]> for RecvView<'a> {
1776    fn as_ref(&self) -> &[u8] {
1777        self.bytes
1778    }
1779}
1780
1781#[cfg(feature = "rmw-lending")]
1782impl<'a> AsRef<[u8]> for RecvView<'a> {
1783    fn as_ref(&self) -> &[u8] {
1784        // SAFETY-invariant: `view` is `Some` until `Drop`; see the `Deref`
1785        // impl above.
1786        self.view
1787            .as_ref()
1788            .expect("RecvView accessed after drop")
1789            .as_ref()
1790    }
1791}
1792
1793#[cfg(feature = "stream")]
1794impl<M: RosMessage + Unpin, const RX_BUF: usize> futures_core::Stream for Subscription<M, RX_BUF> {
1795    type Item = Result<M, NodeError>;
1796
1797    fn poll_next(
1798        self: core::pin::Pin<&mut Self>,
1799        cx: &mut core::task::Context<'_>,
1800    ) -> core::task::Poll<Option<Self::Item>> {
1801        let this = self.get_mut();
1802        // Register-then-check: see Subscription::recv for rationale.
1803        this.handle.register_waker(cx.waker());
1804        match this.try_recv() {
1805            Ok(Some(msg)) => core::task::Poll::Ready(Some(Ok(msg))),
1806            Ok(None) => core::task::Poll::Pending,
1807            Err(e) => core::task::Poll::Ready(Some(Err(e))),
1808        }
1809    }
1810}
1811
1812// ============================================================================
1813// EmbeddedServiceServer
1814// ============================================================================
1815
1816/// Typed service server handle with internal buffers.
1817pub struct EmbeddedServiceServer<
1818    Svc: RosService,
1819    const REQ_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1820    const REPLY_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1821> {
1822    pub(crate) handle: session::RmwServiceServer,
1823    pub(crate) req_buffer: [u8; REQ_BUF],
1824    pub(crate) reply_buffer: [u8; REPLY_BUF],
1825    pub(crate) _phantom: PhantomData<Svc>,
1826}
1827
1828impl<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>
1829    EmbeddedServiceServer<Svc, REQ_BUF, REPLY_BUF>
1830{
1831    /// Handle an incoming service request.
1832    ///
1833    /// Returns `Ok(true)` if a request was handled, `Ok(false)` if none available.
1834    pub fn handle_request(
1835        &mut self,
1836        handler: impl FnOnce(&Svc::Request) -> Svc::Reply,
1837    ) -> Result<bool, NodeError> {
1838        self.handle
1839            .handle_request::<Svc>(&mut self.req_buffer, &mut self.reply_buffer, handler)
1840            .map_err(|_| NodeError::ServiceReplyFailed)
1841    }
1842
1843    /// Handle a request with a heap-allocated reply (for large response types).
1844    ///
1845    /// Used by parameter services and lifecycle services (large response structs
1846    /// that overflow the stack). Returns `Ok(true)` if a request was handled,
1847    /// `Ok(false)` if none available.
1848    #[cfg(any(feature = "param-services", feature = "lifecycle-services"))]
1849    pub fn handle_request_boxed(
1850        &mut self,
1851        handler: impl FnOnce(&Svc::Request) -> alloc::boxed::Box<Svc::Reply>,
1852    ) -> Result<bool, NodeError> {
1853        self.handle
1854            .handle_request_boxed::<Svc>(&mut self.req_buffer, &mut self.reply_buffer, handler)
1855            .map_err(|_| NodeError::ServiceReplyFailed)
1856    }
1857
1858    /// Check if a request is available.
1859    pub fn has_request(&self) -> bool {
1860        self.handle.has_request()
1861    }
1862}
1863
1864// ============================================================================
1865// ServiceClientCallback (RFC-0041, Phase 239.1)
1866// ============================================================================
1867
1868/// Send handle for a **callback-based** typed service client.
1869///
1870/// Returned by `create_client_with_callback`: the reply is delivered to the
1871/// registered closure at `spin_once` (no `Promise` poll). This handle only
1872/// **sends** — it holds a `*mut` to the arena entry's
1873/// [`ServiceClientSendHeader`](super::arena::ServiceClientSendHeader) (pinned in
1874/// the executor arena, like a guard-condition flag), so a single outstanding
1875/// request is gated by `hdr.pending`.
1876///
1877/// # Safety / lifetime
1878/// Valid only while the owning executor lives (the arena backs the header). Do
1879/// not use after the executor is dropped.
1880pub struct ServiceClientCallback<
1881    Svc: RosService,
1882    const REQ_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1883    const REPLY_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1884> {
1885    hdr: *mut super::arena::ServiceClientSendHeader<REPLY_BUF>,
1886    _phantom: PhantomData<Svc>,
1887}
1888
1889impl<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>
1890    ServiceClientCallback<Svc, REQ_BUF, REPLY_BUF>
1891{
1892    /// Wrap an arena-resident send header. `hdr` must point at a live
1893    /// `ServiceClientCallbackEntry`'s header for the executor's lifetime.
1894    pub(crate) fn new(hdr: *mut super::arena::ServiceClientSendHeader<REPLY_BUF>) -> Self {
1895        Self {
1896            hdr,
1897            _phantom: PhantomData,
1898        }
1899    }
1900
1901    /// Send a typed request. The reply is delivered to the registered callback
1902    /// at a later `spin_once`. Returns `RequestInFlight` if a prior request has
1903    /// not yet been answered (single outstanding request).
1904    pub fn call(&mut self, request: &Svc::Request) -> Result<(), NodeError> {
1905        let hdr = unsafe { &mut *self.hdr };
1906        if hdr.pending {
1907            return Err(NodeError::RequestInFlight);
1908        }
1909        let mut buf = [0u8; REQ_BUF];
1910        let mut writer =
1911            CdrWriter::new_with_header(&mut buf).map_err(|_| NodeError::BufferTooSmall)?;
1912        request
1913            .serialize(&mut writer)
1914            .map_err(|_| NodeError::Serialization)?;
1915        let req_len = writer.position();
1916        hdr.handle
1917            .send_request_raw(&buf[..req_len])
1918            .map_err(|_| NodeError::ServiceRequestFailed)?;
1919        hdr.pending = true;
1920        Ok(())
1921    }
1922}
1923
1924// ============================================================================
1925// ActionClientCallback (RFC-0041, Phase 239.2)
1926// ============================================================================
1927
1928/// Send handle for a **callback-based** typed action client.
1929///
1930/// Returned by `create_action_client_with_callbacks`: goal-response, feedback,
1931/// and result are delivered to the registered closures at `spin_once` (no
1932/// `Promise` poll). This handle only **sends** — it holds a `*mut` to the arena
1933/// entry's [`ActionClientCore`](super::action_core::ActionClientCore) (offset 0,
1934/// pinned in the executor arena, like a guard-condition flag).
1935///
1936/// # Safety / lifetime
1937/// Valid only while the owning executor lives.
1938pub struct ActionClientCallback<
1939    A: RosAction,
1940    const GOAL_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1941    const RESULT_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1942    const FEEDBACK_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1943> {
1944    core: *mut super::action_core::ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
1945    _phantom: PhantomData<A>,
1946}
1947
1948impl<A: RosAction, const GOAL_BUF: usize, const RESULT_BUF: usize, const FEEDBACK_BUF: usize>
1949    ActionClientCallback<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
1950{
1951    /// Wrap an arena-resident core. `core` must point at a live
1952    /// `ActionClientCallbackEntry`'s core for the executor's lifetime.
1953    pub(crate) fn new(
1954        core: *mut super::action_core::ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
1955    ) -> Self {
1956        Self {
1957            core,
1958            _phantom: PhantomData,
1959        }
1960    }
1961
1962    /// Send a typed goal. Returns its `GoalId`; acceptance arrives via the
1963    /// registered goal-response callback.
1964    pub fn send_goal(&mut self, goal: &A::Goal) -> Result<nros_core::GoalId, NodeError> {
1965        let core = unsafe { &mut *self.core };
1966        let mut buf = [0u8; GOAL_BUF];
1967        let mut writer =
1968            CdrWriter::new_with_header(&mut buf).map_err(|_| NodeError::BufferTooSmall)?;
1969        goal.serialize(&mut writer)
1970            .map_err(|_| NodeError::Serialization)?;
1971        let len = writer.position();
1972        core.send_goal_raw(&buf[..len])
1973    }
1974
1975    /// Request the result for `goal_id`; the result arrives via the registered
1976    /// result callback.
1977    pub fn get_result(&mut self, goal_id: &nros_core::GoalId) -> Result<(), NodeError> {
1978        let core = unsafe { &mut *self.core };
1979        core.send_get_result_request(goal_id)
1980    }
1981}
1982
1983// ============================================================================
1984// EmbeddedServiceClient
1985// ============================================================================
1986
1987/// Typed service client handle with internal buffers.
1988pub struct EmbeddedServiceClient<
1989    Svc: RosService,
1990    const REQ_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1991    const REPLY_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1992> {
1993    pub(crate) handle: session::RmwServiceClient,
1994    pub(crate) req_buffer: [u8; REQ_BUF],
1995    pub(crate) reply_buffer: [u8; REPLY_BUF],
1996    /// Phase 84.D3: set after a successful `send_request`, cleared on a
1997    /// successful `Promise::try_recv`. Guards against "drop Promise
1998    /// without awaiting, then `call()` again" which would otherwise
1999    /// deliver the stale reply to the new caller.
2000    pub(crate) in_flight: bool,
2001    pub(crate) _phantom: PhantomData<Svc>,
2002}
2003
2004impl<Svc: RosService, const REQ_BUF: usize, const REPLY_BUF: usize>
2005    EmbeddedServiceClient<Svc, REQ_BUF, REPLY_BUF>
2006{
2007    /// Call the service (non-blocking). Returns a [`Promise`] that can be polled.
2008    ///
2009    /// Use with `Executor::spin_once()` to drive I/O while waiting:
2010    ///
2011    /// ```ignore
2012    /// let mut promise = client.call(&request)?;
2013    /// loop {
2014    ///     executor.spin_once(core::time::Duration::from_millis(10));
2015    ///     if let Some(reply) = promise.try_recv()? {
2016    ///         break;
2017    ///     }
2018    /// }
2019    /// ```
2020    ///
2021    /// # Errors
2022    ///
2023    /// Returns [`NodeError::RequestInFlight`] if a previous call's reply
2024    /// has not been received. This prevents the old hazard where dropping
2025    /// a [`Promise`] without awaiting its reply left the stale reply
2026    /// queued to land on the next [`call`](Self::call). Resolve by
2027    /// polling the existing promise to completion or calling
2028    /// [`reset_in_flight`](Self::reset_in_flight).
2029    pub fn call(&mut self, request: &Svc::Request) -> Result<Promise<'_, Svc::Reply>, NodeError> {
2030        if self.in_flight {
2031            return Err(NodeError::RequestInFlight);
2032        }
2033
2034        // Serialize request into req_buffer
2035        let mut writer = CdrWriter::new_with_header(&mut self.req_buffer)
2036            .map_err(|_| NodeError::BufferTooSmall)?;
2037        request
2038            .serialize(&mut writer)
2039            .map_err(|_| NodeError::Serialization)?;
2040        let req_len = writer.position();
2041
2042        // Send the request (non-blocking)
2043        self.handle
2044            .send_request_raw(&self.req_buffer[..req_len])
2045            .map_err(|_| NodeError::ServiceRequestFailed)?;
2046
2047        self.in_flight = true;
2048
2049        Ok(Promise {
2050            handle: &mut self.handle,
2051            reply_buffer: &mut self.reply_buffer,
2052            parse: cdr_deserialize_reply::<Svc>,
2053            in_flight_flag: &mut self.in_flight,
2054        })
2055    }
2056
2057    /// Explicitly clear the in-flight flag (Phase 84.D3).
2058    ///
2059    /// Call this if a previous [`Promise`] was dropped without completing
2060    /// and you want to abandon the pending reply. The next
2061    /// [`call`](Self::call) will proceed but may still observe the stale
2062    /// reply if one is in the transport's queue — callers that need strict
2063    /// correctness should drain / ignore one extra `try_recv` first.
2064    pub fn reset_in_flight(&mut self) {
2065        self.in_flight = false;
2066    }
2067
2068    /// Block until at least one matching service server is discoverable on
2069    /// the network, or `timeout` elapses.
2070    ///
2071    /// Returns `Ok(true)` if a matching server reported back inside the
2072    /// budget; `Ok(false)` on timeout (no server visible). Mirrors
2073    /// `rclcpp::ClientBase::wait_for_service` and
2074    /// `rclpy.client.Client.wait_for_service`.
2075    ///
2076    /// On the Zenoh backend this issues a `z_liveliness_get` against the
2077    /// matching server's wildcarded liveliness keyexpr; the executor is
2078    /// spun cooperatively while the query is in flight so other
2079    /// subscribers / timers continue to make progress. Backends without
2080    /// liveliness discovery answer `Ok(true)` immediately (default trait
2081    /// impl in `nros-rmw`), so the call is a no-op cost when discovery
2082    /// isn't supported.
2083    ///
2084    /// Recommended usage — gate the first `call()` on this:
2085    ///
2086    /// ```ignore
2087    /// let mut client = node.create_client::<AddTwoInts>("/add_two_ints")?;
2088    /// if !client.wait_for_service(&mut executor, Duration::from_secs(5))? {
2089    ///     return Err(NodeError::Timeout);
2090    /// }
2091    /// let mut promise = client.call(&request)?;
2092    /// ```
2093    ///
2094    /// Once the server is observed, the result is latched: subsequent
2095    /// `service_is_ready` checks return `true` without another round
2096    /// trip. This matches `rclcpp`'s snapshot semantic — discovery isn't
2097    /// re-proven on every call.
2098    pub fn wait_for_service(
2099        &mut self,
2100        executor: &mut super::Executor,
2101        timeout: core::time::Duration,
2102    ) -> Result<bool, NodeError> {
2103        // Already proven once — don't re-query.
2104        if self.handle.is_server_ready() {
2105            return Ok(true);
2106        }
2107        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
2108        let max_spins = (timeout.as_millis() as u64 / DEFAULT_SPIN_INTERVAL_MS).max(1);
2109        let mut budget = WaitBudget::new(max_spins, timeout);
2110        // Per-query budget. A liveliness_get is a single-shot probe of the
2111        // router's current token list; if the server hasn't declared its
2112        // token yet when our query arrives, the router replies "no
2113        // matching tokens" and the query terminates. We loop, re-issuing
2114        // shorter probes until either a matching token is observed or the
2115        // outer wall-clock budget expires (issue #224 — shared cadence).
2116        const PROBE_TIMEOUT_MS: u32 = crate::SERVER_DISCOVERY_PROBE_TIMEOUT_MS;
2117        loop {
2118            self.handle
2119                .start_server_discovery(PROBE_TIMEOUT_MS)
2120                .map_err(|_| NodeError::ServiceRequestFailed)?;
2121            // Drain this probe to completion (token reply or empty FINAL).
2122            loop {
2123                executor.spin_once(spin_interval);
2124                match self
2125                    .handle
2126                    .poll_server_discovery()
2127                    .map_err(|_| NodeError::ServiceRequestFailed)?
2128                {
2129                    Some(true) => return Ok(true),
2130                    Some(false) => break, // probe finished empty — re-issue
2131                    None => {}            // still in flight
2132                }
2133                if !budget.tick() {
2134                    return Ok(false);
2135                }
2136            }
2137            if !budget.tick() {
2138                return Ok(false);
2139            }
2140        }
2141    }
2142
2143    /// Snapshot whether a matching service server is currently visible.
2144    ///
2145    /// Non-blocking. Matches `rclcpp::ClientBase::service_is_ready` and
2146    /// `rclpy.client.Client.service_is_ready`. Backends without liveliness
2147    /// discovery return `true` (assume always reachable).
2148    pub fn service_is_ready(&self) -> bool {
2149        self.handle.is_server_ready()
2150    }
2151
2152    /// Phase 124.C.3 — graph-aware server-availability probe.
2153    ///
2154    /// Returns `Ok(true)` / `Ok(false)` when the backend can answer
2155    /// (zenoh queryable interest, DDS built-in topic reader), or
2156    /// `Err(NodeError::Transport(Unsupported))` when it can't (XRCE
2157    /// agent without participant enumeration). Distinct from
2158    /// [`service_is_ready`](Self::service_is_ready) — that one
2159    /// collapses "no" and "don't know" into the same `false`.
2160    ///
2161    /// Used to gate the first `call_raw` so a startup-ordering race
2162    /// (client opens before server's discovery announcement lands)
2163    /// doesn't surface as a request-side timeout.
2164    pub fn server_available(&self) -> Result<bool, NodeError> {
2165        use nros_rmw::ServiceClientTrait;
2166        self.handle.server_available().map_err(NodeError::Transport)
2167    }
2168}
2169
2170// ============================================================================
2171// Promise
2172// ============================================================================
2173
2174/// A pending reply from a non-blocking service or action call.
2175///
2176/// Poll with [`try_recv()`](Promise::try_recv) to check for the reply.
2177/// Implements [`Future`](core::future::Future) for use with async executors.
2178pub struct Promise<'a, T> {
2179    pub(crate) handle: &'a mut session::RmwServiceClient,
2180    pub(crate) reply_buffer: &'a mut [u8],
2181    pub(crate) parse: fn(&[u8]) -> Result<T, NodeError>,
2182    /// Phase 84.D3: cleared on a successful `try_recv` so the client's
2183    /// next `call()` can proceed. If the `Promise` is dropped before the
2184    /// reply is consumed, the flag stays set — forcing the user to
2185    /// explicitly acknowledge the abandoned call via
2186    /// `reset_in_flight()`.
2187    pub(crate) in_flight_flag: &'a mut bool,
2188}
2189
2190impl<T> Promise<'_, T> {
2191    /// Try to receive the reply (non-blocking).
2192    ///
2193    /// Returns `Ok(Some(reply))` if the reply has arrived,
2194    /// `Ok(None)` if still pending.
2195    pub fn try_recv(&mut self) -> Result<Option<T>, NodeError> {
2196        // Phase 120: NoData (no reply yet) is the steady-state polling
2197        // condition — map to Ok(None) instead of ServiceRequestFailed.
2198        match match self.handle.try_recv_reply_raw(self.reply_buffer) {
2199            Ok(opt) => opt,
2200            Err(TransportError::NoData) => return Ok(None),
2201            Err(e) => return Err(NodeError::Transport(e)),
2202        } {
2203            Some(len) => {
2204                let reply = (self.parse)(&self.reply_buffer[..len])?;
2205                // Reply consumed — allow the client to issue another call.
2206                *self.in_flight_flag = false;
2207                Ok(Some(reply))
2208            }
2209            None => Ok(None),
2210        }
2211    }
2212}
2213
2214impl<T> Promise<'_, T> {
2215    /// Block until the reply arrives, spinning the executor.
2216    ///
2217    /// Internally calls `executor.spin_once()` in a loop until the reply
2218    /// arrives or `timeout_ms` is exhausted. This is equivalent to the
2219    /// manual spin+poll loop pattern but more ergonomic for simple use cases.
2220    ///
2221    /// No borrow conflict: `executor` and `self` (which borrows the standalone
2222    /// client) are disjoint objects.
2223    ///
2224    /// # Errors
2225    ///
2226    /// Returns [`NodeError::Timeout`] if the reply does not arrive within
2227    /// `timeout_ms` milliseconds.
2228    pub fn wait(
2229        &mut self,
2230        executor: &mut super::Executor,
2231        timeout: core::time::Duration,
2232    ) -> Result<T, NodeError> {
2233        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
2234        let timeout_ms = timeout.as_millis().min(u64::MAX as u128) as u64;
2235        let max_spins = (timeout_ms / DEFAULT_SPIN_INTERVAL_MS).max(1);
2236        let mut budget = WaitBudget::new(max_spins, timeout);
2237        // Always spin at least once so a zero-timeout still polls.
2238        loop {
2239            executor.spin_once(spin_interval);
2240            if let Some(result) = self.try_recv()? {
2241                return Ok(result);
2242            }
2243            if !budget.tick() {
2244                return Err(NodeError::Timeout);
2245            }
2246        }
2247    }
2248}
2249
2250impl<T> core::future::Future for Promise<'_, T> {
2251    type Output = Result<T, NodeError>;
2252
2253    fn poll(
2254        self: core::pin::Pin<&mut Self>,
2255        cx: &mut core::task::Context<'_>,
2256    ) -> core::task::Poll<Self::Output> {
2257        let this = self.get_mut();
2258        // Register-then-check (closes the race where a reply lands
2259        // between try_recv returning None and the waker registering).
2260        this.handle.register_waker(cx.waker());
2261        match this.try_recv() {
2262            Ok(Some(reply)) => core::task::Poll::Ready(Ok(reply)),
2263            Ok(None) => core::task::Poll::Pending,
2264            Err(e) => core::task::Poll::Ready(Err(e)),
2265        }
2266    }
2267}
2268
2269impl<T> Promise<'_, T> {
2270    /// Async poll-until-ready helper for environments where the
2271    /// backend's `register_waker` path can't deliver a wake.
2272    ///
2273    /// The plain `.await` (via the `Future` impl above) parks the
2274    /// caller until the backend's listener fires the stored Waker.
2275    /// That works on `std` builds where the backend has a
2276    /// background-thread pool actively polling its listener tasks,
2277    /// but it deadlocks on the `nostd-runtime` DDS path (and any
2278    /// other cooperative backend whose listener future only runs
2279    /// when something actively drives the runtime). The 160.B.1
2280    /// trace pinned this to DDS's nostd runtime: listener
2281    /// futures only advance inside `runtime.block_on(...)`, and the
2282    /// parked `.await` consumer never issues such a call.
2283    ///
2284    /// `poll_until_ready(yield_fn)` instead actively polls
2285    /// `try_recv()` on each turn and awaits the caller-supplied
2286    /// yield future between attempts. The yield gives the executor
2287    /// a chance to run other ready tasks (typically a `spin_task`
2288    /// that drives the backend runtime via `executor.spin_once()`),
2289    /// which in turn pumps the listener future. On the `std` path
2290    /// this devolves to a fast poll-then-yield loop with no
2291    /// correctness penalty; on `nostd-runtime` it's the only shape
2292    /// that completes.
2293    ///
2294    /// # Example
2295    ///
2296    /// ```ignore
2297    /// let reply = client
2298    ///     .call(&req)?
2299    ///     .poll_until_ready(|| embassy_time::Timer::after_millis(5))
2300    ///     .await?;
2301    /// ```
2302    pub async fn poll_until_ready<F, Fut>(&mut self, mut yield_fn: F) -> Result<T, NodeError>
2303    where
2304        F: FnMut() -> Fut,
2305        Fut: core::future::Future<Output = ()>,
2306    {
2307        loop {
2308            match self.try_recv()? {
2309                Some(reply) => return Ok(reply),
2310                None => yield_fn().await,
2311            }
2312        }
2313    }
2314}
2315
2316/// Deserialize a CDR-encoded service reply.
2317fn cdr_deserialize_reply<Svc: RosService>(data: &[u8]) -> Result<Svc::Reply, NodeError> {
2318    let mut reader = CdrReader::new_with_header(data).map_err(|_| NodeError::Deserialization)?;
2319    Svc::Reply::deserialize(&mut reader).map_err(|_| NodeError::Deserialization)
2320}
2321
2322// ============================================================================
2323// Action types
2324// ============================================================================
2325
2326/// Active goal tracking for action server.
2327#[derive(Clone)]
2328pub struct ActiveGoal<A: RosAction> {
2329    /// Goal ID.
2330    pub goal_id: nros_core::GoalId,
2331    /// Current status.
2332    pub status: nros_core::GoalStatus,
2333    /// The goal data.
2334    pub goal: A::Goal,
2335}
2336
2337/// Completed goal with result.
2338pub struct CompletedGoal<A: RosAction> {
2339    /// Goal ID.
2340    pub goal_id: nros_core::GoalId,
2341    /// Final status.
2342    pub status: nros_core::GoalStatus,
2343    /// The result data.
2344    pub result: A::Result,
2345}
2346
2347// ============================================================================
2348// ActionServer
2349// ============================================================================
2350
2351/// Typed action server with goal state management.
2352///
2353/// Wraps [`ActionServerCore`](super::action_core::ActionServerCore) for
2354/// raw-bytes protocol handling, adding typed goal/feedback/result
2355/// serialization at the boundary.
2356pub struct ActionServer<
2357    A: RosAction,
2358    const GOAL_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2359    const RESULT_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2360    const FEEDBACK_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2361    const MAX_GOALS: usize = 4,
2362> {
2363    pub(crate) core:
2364        super::action_core::ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
2365    /// Typed goal data parallel to `core.active_goals`.
2366    pub(crate) typed_goals: heapless::Vec<A::Goal, MAX_GOALS>,
2367    /// Completed goals with typed results.
2368    pub(crate) completed_goals: heapless::Vec<CompletedGoal<A>, MAX_GOALS>,
2369}
2370
2371impl<
2372    A: RosAction,
2373    const GOAL_BUF: usize,
2374    const RESULT_BUF: usize,
2375    const FEEDBACK_BUF: usize,
2376    const MAX_GOALS: usize,
2377> ActionServer<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>
2378{
2379    /// Try to accept a new goal.
2380    ///
2381    /// Checks for incoming send_goal requests. If one is available, calls the
2382    /// handler to decide acceptance. Returns the goal ID if accepted.
2383    pub fn try_accept_goal(
2384        &mut self,
2385        goal_handler: impl FnOnce(&nros_core::GoalId, &A::Goal) -> nros_core::GoalResponse,
2386    ) -> Result<Option<nros_core::GoalId>, NodeError>
2387    where
2388        A::Goal: Clone,
2389    {
2390        let raw_req = self.core.try_recv_goal_request()?;
2391        let raw_req = match raw_req {
2392            Some(r) => r,
2393            None => return Ok(None),
2394        };
2395
2396        // Deserialize the goal from the buffer at the offset captured
2397        // by the core (DDS prepends an 8-byte seq prefix; zenoh uses 0).
2398        let buf = self.core.goal_buffer();
2399        let start = raw_req.data_offset;
2400        let end = start + raw_req.data_len;
2401        let mut reader = CdrReader::new_with_header(&buf[start..end])
2402            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
2403        // Skip past the GoalId — a fixed `uint8[16]` UUID, no length prefix
2404        // (ROS 2 `unique_identifier_msgs/UUID`; see action_core::read_goal_id).
2405        for _ in 0..GOAL_UUID_SIZE {
2406            let _ = reader.read_u8();
2407        }
2408        let goal = A::Goal::deserialize(&mut reader)
2409            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
2410
2411        let response = goal_handler(&raw_req.goal_id, &goal);
2412        let accepted = response.is_accepted();
2413
2414        if accepted {
2415            self.core
2416                .accept_goal(raw_req.goal_id, raw_req.sequence_number)?;
2417            let _ = self.typed_goals.push(goal);
2418            Ok(Some(raw_req.goal_id))
2419        } else {
2420            self.core.reject_goal(raw_req.sequence_number)?;
2421            Ok(None)
2422        }
2423    }
2424
2425    /// Publish feedback for a goal.
2426    pub fn publish_feedback(
2427        &mut self,
2428        goal_id: &nros_core::GoalId,
2429        feedback: &A::Feedback,
2430    ) -> Result<(), NodeError> {
2431        // Serialize feedback into a temp buffer (without CDR header or GoalId)
2432        let mut tmp = [0u8; FEEDBACK_BUF];
2433        let mut writer = CdrWriter::new(&mut tmp);
2434        feedback
2435            .serialize(&mut writer)
2436            .map_err(|_| NodeError::Serialization)?;
2437        let feedback_len = writer.position();
2438
2439        self.core
2440            .publish_feedback_raw(goal_id, &tmp[..feedback_len])
2441    }
2442
2443    /// Set a goal's status.
2444    ///
2445    /// Also publishes the updated `GoalStatusArray` on the status topic.
2446    pub fn set_goal_status(&mut self, goal_id: &nros_core::GoalId, status: nros_core::GoalStatus) {
2447        self.core.set_goal_status(goal_id, status);
2448    }
2449
2450    /// Complete a goal and store the result.
2451    ///
2452    /// Also publishes the updated `GoalStatusArray` on the status topic.
2453    pub fn complete_goal(
2454        &mut self,
2455        goal_id: &nros_core::GoalId,
2456        status: nros_core::GoalStatus,
2457        result: A::Result,
2458    ) {
2459        // Serialize result for the core slab
2460        let mut tmp = [0u8; RESULT_BUF];
2461        let mut writer = CdrWriter::new(&mut tmp);
2462        let result_len = match result.serialize(&mut writer) {
2463            Ok(()) => writer.position(),
2464            Err(_) => 0,
2465        };
2466
2467        // Remove typed goal parallel to core's active_goals removal
2468        if let Some(pos) = self
2469            .core
2470            .active_goals()
2471            .iter()
2472            .position(|g| g.goal_id.uuid == goal_id.uuid)
2473        {
2474            self.typed_goals.swap_remove(pos);
2475        }
2476
2477        self.core
2478            .complete_goal_raw(goal_id, status, &tmp[..result_len]);
2479
2480        let _ = self.completed_goals.push(CompletedGoal {
2481            goal_id: *goal_id,
2482            status,
2483            result,
2484        });
2485    }
2486
2487    /// Try to handle a cancel_goal request.
2488    pub fn try_handle_cancel(
2489        &mut self,
2490        cancel_handler: impl FnOnce(
2491            &nros_core::GoalId,
2492            nros_core::GoalStatus,
2493        ) -> nros_core::CancelResponse,
2494    ) -> Result<Option<(nros_core::GoalId, nros_core::CancelResponse)>, NodeError> {
2495        self.core.try_handle_cancel(cancel_handler)
2496    }
2497
2498    /// Try to handle a get_result request.
2499    pub fn try_handle_get_result(&mut self) -> Result<Option<nros_core::GoalId>, NodeError>
2500    where
2501        A::Result: Clone + Default,
2502    {
2503        // Serialize default result for non-completed goals
2504        let mut default_buf = [0u8; RESULT_BUF];
2505        let mut writer = CdrWriter::new(&mut default_buf);
2506        let default_len = match A::Result::default().serialize(&mut writer) {
2507            Ok(()) => writer.position(),
2508            Err(_) => 0,
2509        };
2510
2511        self.core
2512            .try_handle_get_result_raw(&default_buf[..default_len])
2513    }
2514
2515    /// Drain all pending server-side work in one call.
2516    ///
2517    /// Calls `try_accept_goal`, `try_handle_cancel`, and
2518    /// `try_handle_get_result` in sequence. Invoke this on every
2519    /// `spin_once` iteration in manual-poll code — otherwise clients
2520    /// will hang on `get_result` because `create_action_server()`
2521    /// servers are not arena-registered.
2522    ///
2523    /// The two callbacks may be called zero or one times per `poll()`:
2524    ///   * `on_goal` fires when a new goal arrives.
2525    ///   * `on_cancel` fires when a cancel request arrives.
2526    ///
2527    /// Get-result requests are drained unconditionally (no callback
2528    /// needed — the result is pulled from the goal's stored state).
2529    ///
2530    /// # Example
2531    /// ```ignore
2532    /// let mut server = node.create_action_server::<Fibonacci>("/fibonacci")?;
2533    /// loop {
2534    ///     executor.spin_once(Duration::from_millis(10));
2535    ///     server.poll(
2536    ///         |id, goal| {
2537    ///             /* accept or reject based on `goal` */
2538    ///             GoalResponse::AcceptAndExecute
2539    ///         },
2540    ///         |_id, _status| CancelResponse::Accept,
2541    ///     )?;
2542    /// }
2543    /// ```
2544    pub fn poll<GF, CF>(&mut self, mut on_goal: GF, mut on_cancel: CF) -> Result<(), NodeError>
2545    where
2546        GF: FnMut(&nros_core::GoalId, &A::Goal) -> nros_core::GoalResponse,
2547        CF: FnMut(&nros_core::GoalId, nros_core::GoalStatus) -> nros_core::CancelResponse,
2548        A::Goal: Clone,
2549        A::Result: Clone + Default,
2550    {
2551        let _ = self.try_accept_goal(|id, goal| on_goal(id, goal))?;
2552        let _ = self.try_handle_cancel(|id, status| on_cancel(id, status))?;
2553        let _ = self.try_handle_get_result()?;
2554        Ok(())
2555    }
2556
2557    /// Get a reference to an active goal.
2558    pub fn get_goal(&self, goal_id: &nros_core::GoalId) -> Option<ActiveGoal<A>>
2559    where
2560        A::Goal: Clone,
2561    {
2562        self.core
2563            .active_goals()
2564            .iter()
2565            .enumerate()
2566            .find(|(_, g)| g.goal_id.uuid == goal_id.uuid)
2567            .map(|(i, raw)| ActiveGoal {
2568                goal_id: raw.goal_id,
2569                status: raw.status,
2570                goal: self.typed_goals[i].clone(),
2571            })
2572    }
2573
2574    /// Get the number of active goals.
2575    pub fn active_goal_count(&self) -> usize {
2576        self.core.active_goal_count()
2577    }
2578}
2579
2580// ============================================================================
2581// ActionClient
2582// ============================================================================
2583
2584/// Typed action client handle.
2585///
2586/// Wraps [`ActionClientCore`](super::action_core::ActionClientCore) for
2587/// raw-bytes protocol handling, adding typed goal/feedback/result
2588/// serialization at the boundary.
2589pub struct ActionClient<
2590    A: RosAction,
2591    const GOAL_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2592    const RESULT_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2593    const FEEDBACK_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2594> {
2595    pub(crate) core: super::action_core::ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
2596    pub(crate) _phantom: PhantomData<A>,
2597}
2598
2599impl<A: RosAction, const GOAL_BUF: usize, const RESULT_BUF: usize, const FEEDBACK_BUF: usize>
2600    ActionClient<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
2601{
2602    /// Send a goal (non-blocking). Returns the goal ID and a [`Promise`] for acceptance.
2603    ///
2604    /// The promise resolves to `true` if accepted, `false` if rejected.
2605    pub fn send_goal(
2606        &mut self,
2607        goal: &A::Goal,
2608    ) -> Result<(nros_core::GoalId, Promise<'_, bool>), NodeError> {
2609        if self.core.in_flight_send_goal {
2610            return Err(NodeError::RequestInFlight);
2611        }
2612
2613        // Serialize goal into a temp buffer (without CDR header or GoalId)
2614        let mut tmp = [0u8; GOAL_BUF];
2615        let mut writer = CdrWriter::new(&mut tmp);
2616        goal.serialize(&mut writer)
2617            .map_err(|_| NodeError::Serialization)?;
2618        let goal_len = writer.position();
2619
2620        let goal_id = self.core.send_goal_raw(&tmp[..goal_len])?;
2621        self.core.in_flight_send_goal = true;
2622
2623        Ok((
2624            goal_id,
2625            Promise {
2626                handle: &mut self.core.send_goal_client,
2627                reply_buffer: &mut self.core.result_buffer,
2628                parse: parse_goal_accepted,
2629                in_flight_flag: &mut self.core.in_flight_send_goal,
2630            },
2631        ))
2632    }
2633
2634    /// Try to receive feedback (non-blocking).
2635    pub fn try_recv_feedback(
2636        &mut self,
2637    ) -> Result<Option<(nros_core::GoalId, A::Feedback)>, NodeError> {
2638        let (goal_id, len) = match self.core.try_recv_feedback_raw()? {
2639            Some(v) => v,
2640            None => return Ok(None),
2641        };
2642
2643        // Deserialize feedback from the core's feedback buffer (after GoalId)
2644        let mut reader = CdrReader::new_with_header(&self.core.feedback_buffer[..len])
2645            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
2646        // Skip GoalId — a fixed `uint8[16]` UUID, no length prefix
2647        // (ROS 2 `unique_identifier_msgs/UUID`; see action_core::read_goal_id).
2648        for _ in 0..GOAL_UUID_SIZE {
2649            let _ = reader.read_u8();
2650        }
2651
2652        let feedback = A::Feedback::deserialize(&mut reader)
2653            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
2654
2655        Ok(Some((goal_id, feedback)))
2656    }
2657
2658    /// Cancel a goal (non-blocking). Returns a [`Promise`] for the cancel response.
2659    pub fn cancel_goal(
2660        &mut self,
2661        goal_id: &nros_core::GoalId,
2662    ) -> Result<Promise<'_, nros_core::CancelResponse>, NodeError> {
2663        if self.core.in_flight_cancel {
2664            return Err(NodeError::RequestInFlight);
2665        }
2666        self.core.send_cancel_request(goal_id)?;
2667        self.core.in_flight_cancel = true;
2668
2669        Ok(Promise {
2670            handle: &mut self.core.cancel_goal_client,
2671            reply_buffer: &mut self.core.result_buffer,
2672            parse: parse_cancel_response,
2673            in_flight_flag: &mut self.core.in_flight_cancel,
2674        })
2675    }
2676
2677    /// Get the result of a completed goal (non-blocking). Returns a [`Promise`].
2678    pub fn get_result(
2679        &mut self,
2680        goal_id: &nros_core::GoalId,
2681    ) -> Result<Promise<'_, (nros_core::GoalStatus, A::Result)>, NodeError> {
2682        if self.core.in_flight_get_result {
2683            return Err(NodeError::RequestInFlight);
2684        }
2685        self.core.send_get_result_request(goal_id)?;
2686        self.core.in_flight_get_result = true;
2687
2688        Ok(Promise {
2689            handle: &mut self.core.get_result_client,
2690            reply_buffer: &mut self.core.result_buffer,
2691            parse: parse_result_response::<A>,
2692            in_flight_flag: &mut self.core.in_flight_get_result,
2693        })
2694    }
2695
2696    /// Explicitly clear the "send_goal reply in flight" flag (Phase 84.D3).
2697    pub fn reset_send_goal_in_flight(&mut self) {
2698        self.core.in_flight_send_goal = false;
2699    }
2700
2701    /// Block until the action server's send-goal queryable is discoverable
2702    /// on the network, or `timeout` elapses.
2703    ///
2704    /// Returns `Ok(true)` on discovery, `Ok(false)` on timeout. Mirrors
2705    /// `rclcpp_action::Client::wait_for_action_server`.
2706    ///
2707    /// Implementation: probes the action's `send_goal` service-server
2708    /// liveliness keyexpr via the same primitive as
2709    /// [`Client::wait_for_service`]. Once that service is reachable the
2710    /// remaining four action entities (cancel queryable + feedback /
2711    /// status / result publishers) are also reachable in practice — they
2712    /// were declared by the same server in one batch.
2713    pub fn wait_for_action_server(
2714        &mut self,
2715        executor: &mut super::Executor,
2716        timeout: core::time::Duration,
2717    ) -> Result<bool, NodeError> {
2718        if self.core.send_goal_client.is_server_ready() {
2719            return Ok(true);
2720        }
2721        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
2722        let max_spins = (timeout.as_millis() as u64 / DEFAULT_SPIN_INTERVAL_MS).max(1);
2723        let mut budget = WaitBudget::new(max_spins, timeout);
2724        // See `Client::wait_for_service` for the re-probe rationale: a
2725        // single liveliness_get samples the router's current token list
2726        // and terminates; we loop with shorter per-probe timeouts so the
2727        // outer budget covers servers that come up after we start
2728        // waiting.
2729        const PROBE_TIMEOUT_MS: u32 = crate::SERVER_DISCOVERY_PROBE_TIMEOUT_MS; // issue #224
2730        loop {
2731            self.core
2732                .send_goal_client
2733                .start_server_discovery(PROBE_TIMEOUT_MS)
2734                .map_err(|_| NodeError::ServiceRequestFailed)?;
2735            loop {
2736                executor.spin_once(spin_interval);
2737                match self
2738                    .core
2739                    .send_goal_client
2740                    .poll_server_discovery()
2741                    .map_err(|_| NodeError::ServiceRequestFailed)?
2742                {
2743                    Some(true) => return Ok(true),
2744                    Some(false) => break,
2745                    None => {}
2746                }
2747                if !budget.tick() {
2748                    return Ok(false);
2749                }
2750            }
2751            if !budget.tick() {
2752                return Ok(false);
2753            }
2754        }
2755    }
2756
2757    /// Snapshot whether the action server is currently visible.
2758    /// Mirrors `rclcpp_action::Client::action_server_is_ready`.
2759    pub fn action_server_is_ready(&self) -> bool {
2760        self.core.send_goal_client.is_server_ready()
2761    }
2762
2763    /// Explicitly clear the "cancel reply in flight" flag (Phase 84.D3).
2764    pub fn reset_cancel_in_flight(&mut self) {
2765        self.core.in_flight_cancel = false;
2766    }
2767
2768    /// Explicitly clear the "get_result reply in flight" flag (Phase 84.D3).
2769    pub fn reset_get_result_in_flight(&mut self) {
2770        self.core.in_flight_get_result = false;
2771    }
2772
2773    /// Create a feedback stream (receives feedback for all goals).
2774    ///
2775    /// The stream borrows `&mut self` exclusively. Drop it before calling
2776    /// `get_result()` or `cancel_goal()`.
2777    pub fn feedback_stream(&mut self) -> FeedbackStream<'_, A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF> {
2778        FeedbackStream { client: self }
2779    }
2780
2781    /// Create a goal-filtered feedback stream.
2782    ///
2783    /// Only yields feedback for the given `goal_id`, returning `A::Feedback`
2784    /// directly (without the `GoalId` wrapper).
2785    pub fn feedback_stream_for(
2786        &mut self,
2787        goal_id: nros_core::GoalId,
2788    ) -> GoalFeedbackStream<'_, A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF> {
2789        GoalFeedbackStream {
2790            client: self,
2791            goal_id,
2792        }
2793    }
2794}
2795
2796// ============================================================================
2797// FeedbackStream
2798// ============================================================================
2799
2800/// A stream of feedback messages from an action server.
2801///
2802/// Created by [`ActionClient::feedback_stream()`]. Receives feedback for
2803/// all active goals. The stream never self-terminates — use combinators
2804/// like `take_while` or `break` to stop.
2805///
2806/// Three access modes:
2807/// - **Async (`Stream`)**: Enable the `stream` feature for
2808///   `futures_core::Stream` + `StreamExt` combinators
2809/// - **Async (no deps)**: Use `next()` in
2810///   `while let` loops (always available)
2811/// - **Sync**: Use [`wait_next()`](FeedbackStream::wait_next) which
2812///   drives the executor internally
2813pub struct FeedbackStream<
2814    'a,
2815    A: RosAction,
2816    const GOAL_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2817    const RESULT_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2818    const FEEDBACK_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2819> {
2820    client: &'a mut ActionClient<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
2821}
2822
2823impl<A: RosAction, const GOAL_BUF: usize, const RESULT_BUF: usize, const FEEDBACK_BUF: usize>
2824    FeedbackStream<'_, A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
2825{
2826    /// Async: wait for the next feedback message (no `futures` dependency needed).
2827    ///
2828    /// Requires a background task running `executor.spin_async()` to drive
2829    /// I/O. Returns `None` only on error.
2830    ///
2831    /// When the `stream` feature is enabled, prefer `StreamExt::next()` or
2832    /// `TryStreamExt::try_next()` for combinator support.
2833    ///
2834    /// # Example
2835    ///
2836    /// ```ignore
2837    /// let mut stream = client.feedback_stream();
2838    /// while let Some(result) = stream.recv().await {
2839    ///     let (goal_id, feedback) = result?;
2840    ///     // process feedback...
2841    /// }
2842    /// ```
2843    pub async fn recv(&mut self) -> Option<Result<(nros_core::GoalId, A::Feedback), NodeError>> {
2844        core::future::poll_fn(|cx| {
2845            // Register-then-check (closes the AtomicWaker race).
2846            self.client
2847                .core
2848                .feedback_subscriber
2849                .register_waker(cx.waker());
2850            match self.client.try_recv_feedback() {
2851                Ok(Some(item)) => core::task::Poll::Ready(Some(Ok(item))),
2852                Ok(None) => core::task::Poll::Pending,
2853                Err(e) => core::task::Poll::Ready(Some(Err(e))),
2854            }
2855        })
2856        .await
2857    }
2858
2859    /// Sync: wait for the next feedback message, spinning the executor.
2860    ///
2861    /// Returns `Ok(Some(feedback))` if a message arrives within `timeout_ms`,
2862    /// or `Ok(None)` on timeout. Unlike [`Promise::wait()`], timeout is not
2863    /// an error — the caller typically retries in a loop.
2864    pub fn wait_next(
2865        &mut self,
2866        executor: &mut super::Executor,
2867        timeout: core::time::Duration,
2868    ) -> Result<Option<(nros_core::GoalId, A::Feedback)>, NodeError> {
2869        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
2870        let timeout_ms = timeout.as_millis().min(u64::MAX as u128) as u64;
2871        let max_spins = (timeout_ms / DEFAULT_SPIN_INTERVAL_MS).max(1);
2872        let mut budget = WaitBudget::new(max_spins, timeout);
2873        loop {
2874            executor.spin_once(spin_interval);
2875            if let Some(item) = self.client.try_recv_feedback()? {
2876                return Ok(Some(item));
2877            }
2878            if !budget.tick() {
2879                return Ok(None);
2880            }
2881        }
2882    }
2883}
2884
2885#[cfg(feature = "stream")]
2886impl<A: RosAction, const GOAL_BUF: usize, const RESULT_BUF: usize, const FEEDBACK_BUF: usize>
2887    futures_core::Stream for FeedbackStream<'_, A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
2888{
2889    type Item = Result<(nros_core::GoalId, A::Feedback), NodeError>;
2890
2891    fn poll_next(
2892        self: core::pin::Pin<&mut Self>,
2893        cx: &mut core::task::Context<'_>,
2894    ) -> core::task::Poll<Option<Self::Item>> {
2895        let this = self.get_mut();
2896        // Register-then-check (closes the AtomicWaker race).
2897        this.client
2898            .core
2899            .feedback_subscriber
2900            .register_waker(cx.waker());
2901        match this.client.try_recv_feedback() {
2902            Ok(Some(item)) => core::task::Poll::Ready(Some(Ok(item))),
2903            Ok(None) => core::task::Poll::Pending,
2904            Err(e) => core::task::Poll::Ready(Some(Err(e))),
2905        }
2906    }
2907}
2908
2909// ============================================================================
2910// GoalFeedbackStream
2911// ============================================================================
2912
2913/// A goal-filtered feedback stream.
2914///
2915/// Created by [`ActionClient::feedback_stream_for()`]. Only yields feedback
2916/// messages matching the specified goal ID.
2917pub struct GoalFeedbackStream<
2918    'a,
2919    A: RosAction,
2920    const GOAL_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2921    const RESULT_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2922    const FEEDBACK_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
2923> {
2924    client: &'a mut ActionClient<A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
2925    goal_id: nros_core::GoalId,
2926}
2927
2928impl<A: RosAction, const GOAL_BUF: usize, const RESULT_BUF: usize, const FEEDBACK_BUF: usize>
2929    GoalFeedbackStream<'_, A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
2930{
2931    /// Async: wait for the next feedback message for this goal (no `futures` dependency needed).
2932    ///
2933    /// When the `stream` feature is enabled, prefer `StreamExt::next()` or
2934    /// `TryStreamExt::try_next()` for combinator support.
2935    pub async fn recv(&mut self) -> Option<Result<A::Feedback, NodeError>> {
2936        core::future::poll_fn(|cx| {
2937            // Register-then-check (closes the AtomicWaker race). The
2938            // waker is registered once for both the "no data" and
2939            // "wrong goal" branches that fall through to Pending.
2940            self.client
2941                .core
2942                .feedback_subscriber
2943                .register_waker(cx.waker());
2944            match self.client.try_recv_feedback() {
2945                Ok(Some((id, feedback))) if id.uuid == self.goal_id.uuid => {
2946                    core::task::Poll::Ready(Some(Ok(feedback)))
2947                }
2948                // Feedback for a different goal — keep waiting.
2949                Ok(Some(_)) => core::task::Poll::Pending,
2950                Ok(None) => core::task::Poll::Pending,
2951                Err(e) => core::task::Poll::Ready(Some(Err(e))),
2952            }
2953        })
2954        .await
2955    }
2956
2957    /// Sync: wait for the next feedback message for this goal, spinning the executor.
2958    pub fn wait_next(
2959        &mut self,
2960        executor: &mut super::Executor,
2961        timeout: core::time::Duration,
2962    ) -> Result<Option<A::Feedback>, NodeError> {
2963        let spin_interval = core::time::Duration::from_millis(DEFAULT_SPIN_INTERVAL_MS);
2964        let timeout_ms = timeout.as_millis().min(u64::MAX as u128) as u64;
2965        let max_spins = (timeout_ms / DEFAULT_SPIN_INTERVAL_MS).max(1);
2966        let mut budget = WaitBudget::new(max_spins, timeout);
2967        loop {
2968            executor.spin_once(spin_interval);
2969            if let Some((id, feedback)) = self.client.try_recv_feedback()?
2970                && id.uuid == self.goal_id.uuid
2971            {
2972                return Ok(Some(feedback));
2973            }
2974            if !budget.tick() {
2975                return Ok(None);
2976            }
2977        }
2978    }
2979}
2980
2981#[cfg(feature = "stream")]
2982impl<A: RosAction, const GOAL_BUF: usize, const RESULT_BUF: usize, const FEEDBACK_BUF: usize>
2983    futures_core::Stream for GoalFeedbackStream<'_, A, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
2984{
2985    type Item = Result<A::Feedback, NodeError>;
2986
2987    fn poll_next(
2988        self: core::pin::Pin<&mut Self>,
2989        cx: &mut core::task::Context<'_>,
2990    ) -> core::task::Poll<Option<Self::Item>> {
2991        let this = self.get_mut();
2992        // Register-then-check (closes the AtomicWaker race).
2993        this.client
2994            .core
2995            .feedback_subscriber
2996            .register_waker(cx.waker());
2997        match this.client.try_recv_feedback() {
2998            Ok(Some((id, feedback))) if id.uuid == this.goal_id.uuid => {
2999                core::task::Poll::Ready(Some(Ok(feedback)))
3000            }
3001            Ok(Some(_)) => core::task::Poll::Pending,
3002            Ok(None) => core::task::Poll::Pending,
3003            Err(e) => core::task::Poll::Ready(Some(Err(e))),
3004        }
3005    }
3006}
3007
3008/// Parse a goal acceptance response (bool).
3009///
3010/// Issue #223 — CDR read failures PROPAGATE: a truncated/corrupt frame used
3011/// to collapse to `accepted = false` via `unwrap_or(0)`, silently reporting
3012/// "goal rejected" for a wire error. Same rule in the two parsers below.
3013fn parse_goal_accepted(data: &[u8]) -> Result<bool, NodeError> {
3014    let mut reader =
3015        CdrReader::new_with_header(data).map_err(|_| NodeError::ServiceRequestFailed)?;
3016    let accepted = reader
3017        .read_u8()
3018        .map_err(|_| NodeError::ServiceRequestFailed)?
3019        != 0;
3020    Ok(accepted)
3021}
3022
3023/// Parse a cancel response (issue #223 — read errors propagate; an
3024/// out-of-range enum value is still mapped through `from_i8`'s default,
3025/// which is a PROTOCOL value question, not a truncation).
3026fn parse_cancel_response(data: &[u8]) -> Result<nros_core::CancelResponse, NodeError> {
3027    let mut reader =
3028        CdrReader::new_with_header(data).map_err(|_| NodeError::ServiceRequestFailed)?;
3029    let return_code = reader
3030        .read_i8()
3031        .map_err(|_| NodeError::ServiceRequestFailed)?;
3032    Ok(nros_core::CancelResponse::from_i8(return_code).unwrap_or_default())
3033}
3034
3035/// Parse an action result response (status + result; issue #223 — read
3036/// errors propagate).
3037fn parse_result_response<A: RosAction>(
3038    data: &[u8],
3039) -> Result<(nros_core::GoalStatus, A::Result), NodeError> {
3040    let mut reader =
3041        CdrReader::new_with_header(data).map_err(|_| NodeError::ServiceRequestFailed)?;
3042    let status_code = reader
3043        .read_i8()
3044        .map_err(|_| NodeError::ServiceRequestFailed)?;
3045    let status = nros_core::GoalStatus::from_i8(status_code).unwrap_or_default();
3046    let result =
3047        A::Result::deserialize(&mut reader).map_err(|_| NodeError::ServiceRequestFailed)?;
3048    Ok((status, result))
3049}
3050
3051#[cfg(test)]
3052mod parse_response_tests {
3053    // Issue #223 — truncated action-response frames must ERROR, not collapse
3054    // to plausible defaults ("goal rejected" / CancelResponse::default()).
3055    use super::{parse_cancel_response, parse_goal_accepted};
3056
3057    /// A valid 4-byte CDR encapsulation header with NO payload — the
3058    /// truncation case the pre-#223 parsers silently defaulted on.
3059    const HEADER_ONLY: &[u8] = &[0x00, 0x01, 0x00, 0x00];
3060
3061    #[test]
3062    fn truncated_goal_accepted_errors() {
3063        assert!(parse_goal_accepted(HEADER_ONLY).is_err());
3064    }
3065
3066    #[test]
3067    fn truncated_cancel_response_errors() {
3068        assert!(parse_cancel_response(HEADER_ONLY).is_err());
3069    }
3070
3071    #[test]
3072    fn valid_goal_accepted_still_parses() {
3073        let frame = [0x00, 0x01, 0x00, 0x00, 0x01];
3074        assert!(parse_goal_accepted(&frame).unwrap());
3075        let frame0 = [0x00, 0x01, 0x00, 0x00, 0x00];
3076        assert!(!parse_goal_accepted(&frame0).unwrap());
3077    }
3078}