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