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