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