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