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