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