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