Skip to main content

nros_node/executor/
action_core.rs

1//! Type-agnostic action protocol core types.
2//!
3//! [`ActionServerCore`] and [`ActionClientCore`] handle the raw-bytes
4//! action protocol (GoalId framing, status publishing, result slab)
5//! without requiring `RosAction` type parameters. The typed
6//! [`ActionServer`](super::handles::ActionServer) and
7//! [`ActionClient`](super::handles::ActionClient) wrap these cores
8//! and add serialization/deserialization at the boundary.
9
10use nros_core::{CdrReader, CdrWriter, GoalId, GoalInfo, GoalStatus, GoalStatusStamped, Serialize};
11use nros_rmw::{ClientTrait, Publisher, ServiceTrait, Subscription, TransportError};
12
13use super::types::NodeError;
14use crate::session;
15
16/// Scratch buffer for a decoded CancelGoal request. Cancel payloads are
17/// tiny (a `GoalId` + a `builtin_interfaces/Time` stamp), so a fixed
18/// 256-byte buffer covers them without a const-generic parameter like the
19/// goal/result/feedback slabs.
20pub(crate) const CANCEL_BUF: usize = 256;
21
22/// DDS type name of an action's `send_goal` / `get_result` service.
23///
24/// ROS 2 names the action's two services by their per-channel service types —
25/// `<Action>_SendGoal` / `<Action>_GetResult` — **not** the bare action type
26/// (`<Action>`). A real `rcl_action` peer matches on those, so advertising the
27/// bare action type leaves our send_goal/get_result services undiscovered and
28/// every goal times out.
29///
30/// The codegen emits each channel's *request* envelope type name in DDS form,
31/// e.g. `example_interfaces::action::dds_::Fibonacci_SendGoal_Request_`. The
32/// service layer (`xrce_dds_request_type` / the Zenoh shim) extends a base type
33/// ending in `_` with `Request_` / `Response_`, so the base we must pass is the
34/// request type name with its trailing `Request_` stripped:
35/// `…Fibonacci_SendGoal_Request_` → `…Fibonacci_SendGoal_`. Falls back to the
36/// bare action type if the request name has an unexpected shape.
37pub(crate) fn action_service_base_type<'a>(
38    request_type_name: &'a str,
39    fallback_action_type: &'a str,
40) -> &'a str {
41    request_type_name
42        .strip_suffix("Request_")
43        .unwrap_or(fallback_action_type)
44}
45
46/// The per-channel DDS type name for a RAW-registered action server.
47///
48/// The typed path derives these from `A::SendGoalRequest::TYPE_NAME` via
49/// [`action_service_base_type`]. The raw path (`register_action_server_raw*`)
50/// has only the BARE action type, so it must construct them — and until
51/// phase-338 W3 it did not, advertising `…Fibonacci_` on send_goal / get_result
52/// / feedback where ROS 2 expects `…Fibonacci_SendGoal_`,
53/// `…Fibonacci_GetResult_` and `…Fibonacci_FeedbackMessage_`. The type name is
54/// baked into the keyexpr, so a client's query never matched the server's
55/// queryable and every goal timed out with `Transport(Timeout)` — exactly the
56/// failure [`action_service_base_type`]'s own doc warns about, on the other
57/// registration path.
58///
59/// `action_type` is DDS-form and ends in `_` (`…::dds_::Fibonacci_`); the
60/// result replaces that suffix with `_<Channel>_`. Returns the bare type
61/// unchanged if it has an unexpected shape, matching the typed path's
62/// fallback.
63pub fn action_channel_type<const N: usize>(
64    action_type: &str,
65    channel: &str,
66) -> heapless::String<N> {
67    let mut out: heapless::String<N> = heapless::String::new();
68    let base = action_type.strip_suffix('_').unwrap_or(action_type);
69    if out.push_str(base).is_err()
70        || out.push('_').is_err()
71        || out.push_str(channel).is_err()
72        || out.push('_').is_err()
73    {
74        let mut fallback: heapless::String<N> = heapless::String::new();
75        let _ = fallback.push_str(action_type);
76        return fallback;
77    }
78    out
79}
80
81/// Scratch buffer for serializing a `GoalStatusArray` before publishing it
82/// on the status topic. 512 bytes holds the CDR header plus a status entry
83/// (`GoalInfo` + status enum) for every concurrently-tracked goal.
84const STATUS_ARRAY_BUF: usize = 512;
85
86// ============================================================================
87// Supporting types
88// ============================================================================
89
90/// Goal tracked by the core — only GoalId + status, no typed data.
91#[derive(Clone, Copy)]
92pub struct RawActiveGoal {
93    /// Goal ID.
94    pub goal_id: GoalId,
95    /// Current status.
96    pub status: GoalStatus,
97}
98
99/// A `get_result` request held until its goal terminates (Phase 237).
100///
101/// `sequence_number` is the service-backend reply-correlation token; the backend
102/// must be able to `send_response(sequence_number, …)` after the handler returned
103/// (Cyclone native; XRCE/Zenoh via the Phase 237 seq-keyed reply tables).
104#[derive(Clone, Copy)]
105pub struct PendingGetResult {
106    /// Goal whose terminal result the requester is waiting for.
107    pub goal_id: GoalId,
108    /// Backend reply-correlation token for the deferred `send_response`.
109    pub sequence_number: i64,
110}
111
112/// Completed goal result metadata — indexes into the result slab.
113///
114/// Entries are kept in **completion order**, which is also **increasing
115/// `offset` order**: results are appended at `result_slab_used` and the slab is
116/// compacted in place whenever an entry is reclaimed. Every reclamation path
117/// preserves that ordering (`heapless::Vec::remove` / `retain`, never
118/// `swap_remove`) because [`ActionServerCore::compact_result_slab`] moves
119/// survivors *down* and would clobber a not-yet-moved entry otherwise.
120#[derive(Clone, Copy)]
121pub struct CompletedResultEntry {
122    /// Unique identifier for the completed goal.
123    pub goal_id: GoalId,
124    /// Terminal status of the goal.
125    pub status: GoalStatus,
126    /// Byte offset into the result slab.
127    pub offset: usize,
128    /// Length of the serialised result in bytes.
129    pub len: usize,
130    /// `true` once a `get_result` reply carrying this result has been sent —
131    /// either the deferred flush in
132    /// [`ActionServerCore::complete_goal_raw`] or the immediate reply in
133    /// [`ActionServerCore::try_handle_get_result_raw`].
134    ///
135    /// Issue 0796: this is the reclamation priority. A delivered result has
136    /// served its purpose (rcl would let its `result_timeout` retire it), so it
137    /// is evicted before any result nobody has fetched yet.
138    pub delivered: bool,
139}
140
141/// Phase 122.3.c.6.d — information about a peeked cancel-goal
142/// request. Returned by
143/// [`ActionServerCore::try_recv_cancel_request`].
144pub struct PendingCancelRequest {
145    /// The goal_id named in the cancel request.
146    pub goal_id: GoalId,
147    /// Service sequence number — pass back to
148    /// [`ActionServerCore::send_cancel_reply`].
149    pub sequence_number: i64,
150    /// Snapshot of the goal's current status at peek time
151    /// (`GoalStatus::Unknown` if no matching active goal).
152    pub current_status: GoalStatus,
153}
154
155/// Information about a received goal request.
156pub struct RawGoalRequest {
157    /// The parsed goal ID.
158    pub goal_id: GoalId,
159    /// Sequence number for the service reply.
160    pub sequence_number: i64,
161    /// Offset into the goal buffer where the CDR payload begins.
162    /// Backends may prepend a sequence-number header (DDS) or place
163    /// the payload at offset 0 (zenoh).
164    pub data_offset: usize,
165    /// Length of valid CDR data starting at `data_offset`.
166    pub data_len: usize,
167}
168
169// ============================================================================
170// GoalId CDR helpers
171// ============================================================================
172
173/// Read a GoalId from a CDR reader as a fixed `uint8[16]` array.
174///
175/// ROS 2 actions carry the goal id as `unique_identifier_msgs/UUID`, whose
176/// single field is a **fixed-size** `uint8[16]` array — CDR fixed arrays have
177/// **no** length prefix. We must read exactly 16 bytes with no leading count,
178/// matching `unique_identifier_msgs::msg::UUID::deserialize`. (The pre-233.6
179/// framing wrote a `u32(16)` sequence prefix, which self-matched nano-ros peers
180/// but added 4 bytes a real `rcl_action` peer rejects.)
181fn read_goal_id(reader: &mut CdrReader<'_>) -> Result<GoalId, NodeError> {
182    let mut goal_id = GoalId::default();
183    for byte in &mut goal_id.uuid {
184        *byte = reader
185            .read_u8()
186            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
187    }
188    Ok(goal_id)
189}
190
191/// Write a GoalId into a CDR writer as a fixed `uint8[16]` array (no length
192/// prefix) — see [`read_goal_id`] for why the prefix must be absent.
193fn write_goal_id(writer: &mut CdrWriter<'_>, goal_id: &GoalId) -> Result<(), NodeError> {
194    for b in &goal_id.uuid {
195        writer.write_u8(*b).map_err(|_| NodeError::Serialization)?;
196    }
197    Ok(())
198}
199
200// ============================================================================
201// ActionServerCore
202// ============================================================================
203
204/// Type-agnostic action server core handling the raw-bytes protocol.
205///
206/// Manages active goal tracking (GoalId + status), completed result storage
207/// in a fixed-size slab, and all CDR framing for the action protocol.
208///
209/// The typed [`ActionServer`](super::handles::ActionServer) wraps this
210/// and adds `A::Goal` / `A::Feedback` / `A::Result` (de)serialization.
211pub struct ActionServerCore<
212    const GOAL_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
213    const RESULT_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
214    const FEEDBACK_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
215    const MAX_GOALS: usize = 4,
216> {
217    pub(crate) send_goal_server: session::RmwServiceServer,
218    pub(crate) cancel_goal_server: session::RmwServiceServer,
219    pub(crate) get_result_server: session::RmwServiceServer,
220    pub(crate) feedback_publisher: session::RmwPublisher,
221    pub(crate) status_publisher: session::RmwPublisher,
222    pub(crate) active_goals: heapless::Vec<RawActiveGoal, MAX_GOALS>,
223    pub(crate) completed_results: heapless::Vec<CompletedResultEntry, MAX_GOALS>,
224    /// `get_result` requests that arrived while their goal was still active
225    /// (Phase 237). `rclcpp_action` sends `get_result` immediately after
226    /// acceptance and expects the reply only once the goal terminates, so we
227    /// hold the request's correlation token (`sequence_number`) here and flush
228    /// it in [`Self::complete_goal_raw`]. Deferral relies on the service
229    /// backend honoring `send_response(seq)` after the handler returns — the
230    /// seq-keyed reply contract (Cyclone native; XRCE/Zenoh per Phase 237).
231    pub(crate) pending_get_results: heapless::Vec<PendingGetResult, MAX_GOALS>,
232    /// Slab storage for completed result CDR bytes.
233    ///
234    /// A bump region whose survivors are compacted on reclamation — see
235    /// [`CompletedResultEntry`] and
236    /// [`reserve_result_space`](Self::reserve_result_space).
237    pub(crate) result_slab: [u8; RESULT_BUF],
238    /// Bytes of [`result_slab`](Self::result_slab) currently held by the
239    /// entries in `completed_results`. Compaction keeps the live bytes in
240    /// `[0, result_slab_used)` with no holes, so this is exactly the sum of the
241    /// entries' `len`.
242    pub(crate) result_slab_used: usize,
243    pub(crate) goal_buffer: [u8; GOAL_BUF],
244    pub(crate) feedback_buffer: [u8; FEEDBACK_BUF],
245    pub(crate) cancel_buffer: [u8; CANCEL_BUF],
246}
247
248impl<
249    const GOAL_BUF: usize,
250    const RESULT_BUF: usize,
251    const FEEDBACK_BUF: usize,
252    const MAX_GOALS: usize,
253> ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>
254{
255    /// Phase 122.3.c.6.b — construct an `ActionServerCore` from the
256    /// 5 already-built transport channels. Caller (typically the C
257    /// API's `nros_action_server_init_polling`) owns wiring the
258    /// channels via the session's `create_*` methods.
259    pub fn from_channels(
260        send_goal_server: session::RmwServiceServer,
261        cancel_goal_server: session::RmwServiceServer,
262        get_result_server: session::RmwServiceServer,
263        feedback_publisher: session::RmwPublisher,
264        status_publisher: session::RmwPublisher,
265    ) -> Self {
266        Self {
267            send_goal_server,
268            cancel_goal_server,
269            get_result_server,
270            feedback_publisher,
271            status_publisher,
272            active_goals: heapless::Vec::new(),
273            completed_results: heapless::Vec::new(),
274            pending_get_results: heapless::Vec::new(),
275            result_slab: [0u8; RESULT_BUF],
276            result_slab_used: 0,
277            goal_buffer: [0u8; GOAL_BUF],
278            feedback_buffer: [0u8; FEEDBACK_BUF],
279            cancel_buffer: [0u8; CANCEL_BUF],
280        }
281    }
282
283    /// Try to receive a goal request from the send_goal service.
284    ///
285    /// Returns the parsed GoalId, sequence number, and data length.
286    /// The full CDR data (including GoalId) remains in `goal_buffer`.
287    pub fn try_recv_goal_request(&mut self) -> Result<Option<RawGoalRequest>, NodeError> {
288        // Capture buf base ptr before borrowing through `take_request`
289        // so we can recover the data offset after the borrow ends.
290        // DDS-style backends place a sequence-number prefix before the
291        // CDR payload; reading the buffer from offset 0 unconditionally
292        // would feed the prefix bytes to the deserializer.
293        let buf_start = self.goal_buffer.as_ptr() as usize;
294        // Phase 120: NoData (no pending request) is the steady-state
295        // expected condition — collapse it to `Ok(None)` instead of
296        // surfacing as ServiceRequestFailed. Any other transport
297        // error remains ServiceRequestFailed.
298        let request = match self.send_goal_server.take_request(&mut self.goal_buffer) {
299            Ok(opt) => opt,
300            Err(TransportError::NoData) => return Ok(None),
301            Err(_) => return Err(NodeError::Transport(TransportError::ServiceRequestFailed)),
302        };
303
304        let request = match request {
305            Some(r) => r,
306            None => return Ok(None),
307        };
308
309        let data_offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
310        let data_len = request.data.len();
311        let sequence_number = request.sequence_number;
312        #[allow(clippy::drop_non_drop)]
313        drop(request);
314
315        let mut reader =
316            CdrReader::new_with_header(&self.goal_buffer[data_offset..data_offset + data_len])
317                .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
318
319        let goal_id = read_goal_id(&mut reader)?;
320
321        Ok(Some(RawGoalRequest {
322            goal_id,
323            sequence_number,
324            data_offset,
325            data_len,
326        }))
327    }
328
329    /// Get a reference to the goal buffer (valid after `try_recv_goal_request`).
330    pub fn goal_buffer(&self) -> &[u8] {
331        &self.goal_buffer
332    }
333
334    /// Accept a goal: records it, sends the acceptance reply, publishes status.
335    ///
336    /// # Ordering
337    ///
338    /// The goal is recorded in `active_goals` **before** the reply goes out,
339    /// and the recording is rolled back if the reply fails. Issue 0322: this
340    /// used to reply first and then `let _ = self.active_goals.push(...)`, so
341    /// once `MAX_GOALS` (default 4) were active, a 5th `send_goal` was
342    /// answered `accepted=true` and then dropped — no execution, no feedback,
343    /// no result, no terminal status. An rclcpp/rclpy client that saw
344    /// `accepted=true` waited on its result future forever.
345    ///
346    /// A full table is now answered truthfully with `accepted=false` via
347    /// [`Self::reject_goal`], which is a contract the client already handles.
348    pub fn accept_goal(&mut self, goal_id: GoalId, seq: i64) -> Result<(), NodeError> {
349        // Serialize first: a serialization failure here must leave no trace,
350        // and nothing below depends on the table.
351        let mut writer =
352            crate::tx_writer(&mut self.cancel_buffer).map_err(|_| NodeError::BufferTooSmall)?;
353        writer.write_u8(1).map_err(|_| NodeError::Serialization)?;
354        writer.write_i32(0).map_err(|_| NodeError::Serialization)?;
355        writer.write_u32(0).map_err(|_| NodeError::Serialization)?;
356        let reply_len = writer.position();
357
358        // Capacity is decided BEFORE anything reaches the wire, so a full
359        // table becomes an honest rejection rather than a lie.
360        if self
361            .active_goals
362            .push(RawActiveGoal {
363                goal_id,
364                status: GoalStatus::Accepted,
365            })
366            .is_err()
367        {
368            return self.reject_goal(seq);
369        }
370
371        if self
372            .send_goal_server
373            .send_response(seq, &self.cancel_buffer[..reply_len])
374            .is_err()
375        {
376            // The client never learned it was accepted, so un-record it —
377            // otherwise the slot leaks and lowers the effective capacity for
378            // every later goal. `pop` removes the entry pushed just above:
379            // `&mut self` means nothing else can have touched the table.
380            self.active_goals.pop();
381            return Err(NodeError::ServiceReplyFailed);
382        }
383
384        // Past this point the acceptance is on the wire and irreversible.
385        //
386        // The status-array publish is therefore NOT propagated: both C and C++
387        // callers collapse `Err` to a generic error code
388        // (`nros-c/src/action/server.rs`, `nros-cpp/src/action.rs`), so
389        // returning one here would report "accept failed" for a goal that IS
390        // accepted and running — inviting the caller to reject or retry it.
391        // The client already holds `accepted=true` and will still receive the
392        // result; a missed status sample is degraded, not broken. Issue 0322
393        // proposed propagating this; see that issue for why it is deliberately
394        // not done.
395        let _status = self.publish_status_array();
396        Ok(())
397    }
398
399    /// Reject a goal: sends the rejection reply.
400    pub fn reject_goal(&mut self, seq: i64) -> Result<(), NodeError> {
401        // Serialize response: accepted=false + stamp
402        let mut writer =
403            crate::tx_writer(&mut self.cancel_buffer).map_err(|_| NodeError::BufferTooSmall)?;
404        writer.write_u8(0).map_err(|_| NodeError::Serialization)?;
405        writer.write_i32(0).map_err(|_| NodeError::Serialization)?;
406        writer.write_u32(0).map_err(|_| NodeError::Serialization)?;
407        let reply_len = writer.position();
408
409        self.send_goal_server
410            .send_response(seq, &self.cancel_buffer[..reply_len])
411            .map_err(|_| NodeError::ServiceReplyFailed)
412    }
413
414    /// Publish feedback with raw CDR bytes.
415    ///
416    /// Writes GoalId framing + raw feedback bytes into the feedback buffer
417    /// and publishes.
418    pub fn publish_feedback_raw(
419        &mut self,
420        goal_id: &GoalId,
421        feedback_cdr: &[u8],
422    ) -> Result<(), NodeError> {
423        // GoalId framing (4 + 16 = 20 bytes) + feedback_cdr must fit in FEEDBACK_BUF
424        let needed = 4 + 20 + feedback_cdr.len(); // CDR header + GoalId + feedback
425        if needed > FEEDBACK_BUF {
426            return Err(NodeError::BufferTooSmall);
427        }
428
429        let mut writer =
430            crate::tx_writer(&mut self.feedback_buffer).map_err(|_| NodeError::BufferTooSmall)?;
431
432        write_goal_id(&mut writer, goal_id)?;
433
434        // Copy raw feedback bytes directly after GoalId
435        let pos = writer.position();
436        if pos + feedback_cdr.len() > FEEDBACK_BUF {
437            return Err(NodeError::BufferTooSmall);
438        }
439        self.feedback_buffer[pos..pos + feedback_cdr.len()].copy_from_slice(feedback_cdr);
440        let len = pos + feedback_cdr.len();
441
442        self.feedback_publisher
443            .publish_raw(&self.feedback_buffer[..len])
444            .map_err(|_| NodeError::Transport(TransportError::PublishFailed))
445    }
446
447    /// Set a goal's status and publish the updated GoalStatusArray.
448    pub fn set_goal_status(&mut self, goal_id: &GoalId, status: GoalStatus) {
449        for goal in &mut self.active_goals {
450            if goal.goal_id.uuid == goal_id.uuid {
451                goal.status = status;
452                break;
453            }
454        }
455        let _ = self.publish_status_array();
456    }
457
458    /// Compact the result slab: walk the retained entries in offset order,
459    /// move each survivor down to the first free byte, and rewrite its offset.
460    ///
461    /// Issue 0796. `completed_results` is ordered by `offset` (see
462    /// [`CompletedResultEntry`]), so `write <= entry.offset` at every step and
463    /// the `copy_within` can never clobber an entry that has not moved yet.
464    /// Bounded work: at most `MAX_GOALS` (default 4) moves totalling at most
465    /// `RESULT_BUF` bytes, and only on a reclamation.
466    fn compact_result_slab(&mut self) {
467        let mut write = 0usize;
468        for entry in self.completed_results.iter_mut() {
469            debug_assert!(
470                write <= entry.offset,
471                "completed_results must stay in increasing-offset order"
472            );
473            if entry.offset != write {
474                self.result_slab
475                    .copy_within(entry.offset..entry.offset + entry.len, write);
476                entry.offset = write;
477            }
478            write += entry.len;
479        }
480        self.result_slab_used = write;
481    }
482
483    /// Drop the retained result for `goal_id`, if any, and compact.
484    /// Returns `true` when an entry was dropped.
485    fn drop_completed_result(&mut self, goal_id: &GoalId) -> bool {
486        match self
487            .completed_results
488            .iter()
489            .position(|e| e.goal_id.uuid == goal_id.uuid)
490        {
491            Some(idx) => {
492                self.completed_results.remove(idx);
493                self.compact_result_slab();
494                true
495            }
496            None => false,
497        }
498    }
499
500    /// Reclaim exactly one completed result. Returns `false` when there is
501    /// nothing left to reclaim.
502    ///
503    /// **Eviction policy** (issue 0796). rcl keeps a terminated goal's result
504    /// until it has been collected *and* a per-goal `result_timeout` elapses,
505    /// then `rcl_action_expire_goals()` reclaims it. We have no clock in the
506    /// core — every RTOS port spells one differently and the core is `no_std`
507    /// with no time source threaded through it — so reclamation is **on demand
508    /// and priority-ordered** instead of timed:
509    ///
510    /// 1. the oldest **delivered** result (a client already has these bytes;
511    ///    this is the case rcl's timeout is really for), else
512    /// 2. the oldest result overall.
513    ///
514    /// Rule 1 means a fetched result never pins storage. Rule 2 means a client
515    /// that asks for nothing cannot wedge the server: its stale result is
516    /// displaced by newer ones rather than blocking every later goal. A goal
517    /// evicted under rule 2 whose client asks *later* is answered
518    /// `GoalStatus::Unknown` with the default result by
519    /// [`try_handle_get_result_raw`](Self::try_handle_get_result_raw) — degraded,
520    /// but an answer, where the pre-0796 code left the requester hanging
521    /// forever.
522    fn evict_one_completed_result(&mut self) -> bool {
523        if self.completed_results.is_empty() {
524            return false;
525        }
526        let idx = self
527            .completed_results
528            .iter()
529            .position(|e| e.delivered)
530            .unwrap_or(0);
531        self.completed_results.remove(idx);
532        self.compact_result_slab();
533        true
534    }
535
536    /// Make room for one more entry of `needed` bytes, reclaiming completed
537    /// results as required.
538    ///
539    /// `Err(NodeError::BufferTooSmall)` means the result can never be retained
540    /// because it exceeds `RESULT_BUF` outright — an empty slab would not hold
541    /// it either, so the caller must raise the action server's `RESULT_BUF`.
542    /// That is the ONLY failure: a full slab or a full entry table is
543    /// reclaimed, not refused.
544    fn reserve_result_space(&mut self, needed: usize) -> Result<(), NodeError> {
545        if needed > RESULT_BUF {
546            return Err(NodeError::BufferTooSmall);
547        }
548        loop {
549            if self.completed_results.len() < MAX_GOALS
550                && RESULT_BUF - self.result_slab_used >= needed
551            {
552                return Ok(());
553            }
554            if !self.evict_one_completed_result() {
555                // Unreachable while `needed <= RESULT_BUF` and `MAX_GOALS >= 1`,
556                // but the loop must not spin on a degenerate instantiation.
557                return Err(NodeError::BufferTooSmall);
558            }
559        }
560    }
561
562    /// Mark `goal_id`'s retained result as fetched, making it the first
563    /// candidate for reclamation.
564    fn mark_result_delivered(&mut self, goal_id: &GoalId) {
565        for entry in self.completed_results.iter_mut() {
566            if entry.goal_id.uuid == goal_id.uuid {
567                entry.delivered = true;
568                break;
569            }
570        }
571    }
572
573    /// Reclaim every completed result whose `get_result` reply has already been
574    /// sent — nano-ros's analogue of `rcl_action_expire_goals()`. Returns the
575    /// number of entries reclaimed.
576    ///
577    /// Calling this is **optional**: [`complete_goal_raw`](Self::complete_goal_raw)
578    /// reclaims on demand, so a server that never calls it still runs forever.
579    /// It exists for a server that would rather return the memory eagerly (e.g.
580    /// before a long idle period) than at the next completion.
581    pub fn expire_completed_results(&mut self) -> usize {
582        let before = self.completed_results.len();
583        self.completed_results.retain(|e| !e.delivered);
584        let removed = before - self.completed_results.len();
585        if removed > 0 {
586            self.compact_result_slab();
587        }
588        removed
589    }
590
591    /// Number of completed results currently retained.
592    pub fn completed_result_count(&self) -> usize {
593        self.completed_results.len()
594    }
595
596    /// Bytes of the result slab currently held by retained results.
597    pub fn result_slab_used(&self) -> usize {
598        self.result_slab_used
599    }
600
601    /// `true` while `goal_id`'s completed result is still retained (i.e. a
602    /// `get_result` for it would be answered from the slab rather than as
603    /// `Unknown`).
604    pub fn has_completed_result(&self, goal_id: &GoalId) -> bool {
605        self.completed_results
606            .iter()
607            .any(|e| e.goal_id.uuid == goal_id.uuid)
608    }
609
610    /// Complete a goal: remove from active, store raw result CDR in slab,
611    /// publish status.
612    ///
613    /// # Errors
614    ///
615    /// `NodeError::BufferTooSmall` when `result_cdr` is larger than
616    /// `RESULT_BUF` and therefore cannot be retained for a later `get_result`.
617    /// Any client already waiting on `~/_action/get_result` is still answered
618    /// (straight from `result_cdr`), and the terminal status is still
619    /// published — but a *later* `get_result` for this goal gets
620    /// `GoalStatus::Unknown`. Raise the server's `RESULT_BUF`.
621    ///
622    /// Issue 0796: this returned `()`, so the pre-fix slab exhaustion — the
623    /// server silently dropping every result once the bump allocator hit
624    /// `RESULT_BUF`, and silently stranding every waiting requester with it —
625    /// was invisible to the caller. A full slab is no longer a failure at all
626    /// (it is reclaimed), and the one remaining failure is reported.
627    pub fn complete_goal_raw(
628        &mut self,
629        goal_id: &GoalId,
630        status: GoalStatus,
631        result_cdr: &[u8],
632    ) -> Result<(), NodeError> {
633        // Remove from active goals
634        if let Some(pos) = self
635            .active_goals
636            .iter()
637            .position(|g| g.goal_id.uuid == goal_id.uuid)
638        {
639            self.active_goals.swap_remove(pos);
640        }
641
642        // Re-completing a goal replaces its retained result rather than
643        // stacking a second copy of it in the slab.
644        self.drop_completed_result(goal_id);
645
646        // Store result CDR in the slab, reclaiming older results if needed.
647        let stored = match self.reserve_result_space(result_cdr.len()) {
648            Ok(()) => {
649                let offset = self.result_slab_used;
650                let end = offset + result_cdr.len();
651                self.result_slab[offset..end].copy_from_slice(result_cdr);
652                self.result_slab_used = end;
653                // `reserve_result_space` guaranteed a free entry slot.
654                let pushed = self
655                    .completed_results
656                    .push(CompletedResultEntry {
657                        goal_id: *goal_id,
658                        status,
659                        offset,
660                        len: result_cdr.len(),
661                        delivered: false,
662                    })
663                    .is_ok();
664                debug_assert!(pushed, "reserve_result_space must leave an entry slot");
665                Some((offset, result_cdr.len()))
666            }
667            Err(_) => None,
668        };
669
670        // Phase 237 — flush any get_result requests that arrived while this goal
671        // was still active. Reply to each held requester via its retained
672        // `sequence_number`.
673        //
674        // Issue 0796: this used to be skipped entirely when the result could not
675        // be stored, so an oversized (or, pre-fix, merely unlucky) result left
676        // an `rclcpp_action` client waiting on its result future forever. The
677        // waiter is now answered from `result_cdr` directly when the slab could
678        // not take it — the bytes are right here; only the *retention* failed.
679        let mut delivered_any = false;
680        let mut i = 0;
681        while i < self.pending_get_results.len() {
682            if self.pending_get_results[i].goal_id.uuid == goal_id.uuid {
683                let seq = self.pending_get_results[i].sequence_number;
684                // swap_remove moves the last entry into slot `i`; re-check `i`.
685                let _ = self.pending_get_results.swap_remove(i);
686                let sent = match stored {
687                    Some((offset, len)) => {
688                        self.reply_get_result_from_slab(seq, status, offset, len)
689                    }
690                    None => self.reply_get_result_bytes(seq, status, result_cdr),
691                };
692                delivered_any |= sent.is_ok();
693            } else {
694                i += 1;
695            }
696        }
697        if delivered_any {
698            self.mark_result_delivered(goal_id);
699        }
700
701        let _ = self.publish_status_array();
702
703        if stored.is_some() {
704            Ok(())
705        } else {
706            Err(NodeError::BufferTooSmall)
707        }
708    }
709
710    /// Phase 122.3.c.6.e — register a `Waker` that fires when a new
711    /// send_goal request arrives. Event-driven action servers
712    /// register here in place of polling `try_recv_goal_request` on
713    /// a timer.
714    pub fn register_goal_waker(&self, waker: &core::task::Waker) {
715        use nros_rmw::ServiceTrait;
716        self.send_goal_server.register_waker(waker);
717    }
718
719    /// Phase 122.3.c.6.e — register a `Waker` that fires when a
720    /// cancel-goal request arrives.
721    pub fn register_cancel_waker(&self, waker: &core::task::Waker) {
722        use nros_rmw::ServiceTrait;
723        self.cancel_goal_server.register_waker(waker);
724    }
725
726    /// Phase 122.3.c.6.e — register a `Waker` that fires when a
727    /// get_result query arrives.
728    pub fn register_get_result_waker(&self, waker: &core::task::Waker) {
729        use nros_rmw::ServiceTrait;
730        self.get_result_server.register_waker(waker);
731    }
732
733    /// Phase 122.3.c.6.d — peek a pending cancel-goal request without
734    /// generating a reply. Returns the goal_id named in the request,
735    /// the matching service sequence number (use it with
736    /// [`send_cancel_reply`](Self::send_cancel_reply)), and the
737    /// goal's current status (`GoalStatus::Unknown` if no such
738    /// active goal). Returns `Ok(None)` when no cancel request is
739    /// pending.
740    ///
741    /// Used by L1 polling-mode action servers (nros-c / nros-cpp C
742    /// FFI) that want to drive cancel-decision policy without
743    /// passing a Rust closure across the C ABI. See the matching
744    /// [`send_cancel_reply`](Self::send_cancel_reply) for the reply
745    /// side. The high-level closure-based
746    /// [`try_handle_cancel`](Self::try_handle_cancel) keeps working
747    /// and now delegates to this pair.
748    pub fn try_recv_cancel_request(&mut self) -> Result<Option<PendingCancelRequest>, NodeError> {
749        let buf_start = self.cancel_buffer.as_ptr() as usize;
750        let request = match self
751            .cancel_goal_server
752            .take_request(&mut self.cancel_buffer)
753        {
754            Ok(Some(r)) => r,
755            Ok(None) | Err(TransportError::NoData) => return Ok(None),
756            Err(_) => return Err(NodeError::Transport(TransportError::ServiceRequestFailed)),
757        };
758
759        let data_offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
760        let data_len = request.data.len();
761        let sequence_number = request.sequence_number;
762        #[allow(clippy::drop_non_drop)]
763        drop(request);
764
765        let mut reader =
766            CdrReader::new_with_header(&self.cancel_buffer[data_offset..data_offset + data_len])
767                .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
768
769        let goal_id = read_goal_id(&mut reader)?;
770        let current_status = self.find_goal_status(&goal_id);
771
772        Ok(Some(PendingCancelRequest {
773            goal_id,
774            sequence_number,
775            current_status,
776        }))
777    }
778
779    /// Phase 122.3.c.6.d — send the reply to a previously-peeked
780    /// cancel-goal request. `sequence_number` must match the value
781    /// returned by [`try_recv_cancel_request`](Self::try_recv_cancel_request).
782    ///
783    /// `return_code` is the overall RPC status (`CancelReturnCode::Ok`
784    /// = at least one cancel honoured; other variants = whole-request
785    /// failure). `accepted` lists the goals that transition to
786    /// `Canceling`; this function flips their stored status before
787    /// publishing the status array.
788    ///
789    /// Issue 0796 — the parameter is a [`nros_core::CancelReturnCode`], not
790    /// the per-goal [`nros_core::CancelResponse`] a cancel callback returns.
791    /// The two were one type and their discriminants overlap with opposite
792    /// meanings (`Reject`/`Ok` are both 0), so this signature is what stops a
793    /// per-goal answer being written into the RPC field.
794    pub fn send_cancel_reply(
795        &mut self,
796        sequence_number: i64,
797        return_code: nros_core::CancelReturnCode,
798        accepted: &[GoalId],
799    ) -> Result<(), NodeError> {
800        for id in accepted {
801            self.set_goal_status(id, GoalStatus::Canceling);
802        }
803
804        let mut writer =
805            crate::tx_writer(&mut self.goal_buffer).map_err(|_| NodeError::BufferTooSmall)?;
806        writer
807            .write_i8(return_code as i8)
808            .map_err(|_| NodeError::Serialization)?;
809        let count = u32::try_from(accepted.len()).unwrap_or(u32::MAX);
810        writer
811            .write_u32(count)
812            .map_err(|_| NodeError::Serialization)?;
813        for id in accepted {
814            write_goal_id(&mut writer, id)?;
815            // GoalInfo.stamp — zero timestamp.
816            writer.write_i32(0).map_err(|_| NodeError::Serialization)?;
817            writer.write_u32(0).map_err(|_| NodeError::Serialization)?;
818        }
819        let reply_len = writer.position();
820
821        self.cancel_goal_server
822            .send_response(sequence_number, &self.goal_buffer[..reply_len])
823            .map_err(|_| NodeError::ServiceReplyFailed)?;
824
825        if !accepted.is_empty() {
826            let _ = self.publish_status_array();
827        }
828
829        Ok(())
830    }
831
832    /// Try to handle a cancel_goal request (type-agnostic).
833    pub fn try_handle_cancel(
834        &mut self,
835        cancel_handler: impl FnOnce(&GoalId, GoalStatus) -> nros_core::CancelResponse,
836    ) -> Result<Option<(GoalId, nros_core::CancelResponse)>, NodeError> {
837        // (issue 0796) the handler answers about ONE goal; the reply below
838        // carries the RPC-level return code. They are different types with
839        // overlapping discriminants, so the translation is explicit.
840        let buf_start = self.cancel_buffer.as_ptr() as usize;
841        // Phase 120: NoData == steady-state idle; map to Ok(None).
842        let request = match self
843            .cancel_goal_server
844            .take_request(&mut self.cancel_buffer)
845        {
846            Ok(Some(r)) => r,
847            Ok(None) | Err(TransportError::NoData) => return Ok(None),
848            Err(_) => return Err(NodeError::Transport(TransportError::ServiceRequestFailed)),
849        };
850
851        let data_offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
852        let data_len = request.data.len();
853        let sequence_number = request.sequence_number;
854        #[allow(clippy::drop_non_drop)]
855        drop(request);
856
857        let mut reader =
858            CdrReader::new_with_header(&self.cancel_buffer[data_offset..data_offset + data_len])
859                .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
860
861        let goal_id = read_goal_id(&mut reader)?;
862
863        let current_status = self.find_goal_status(&goal_id);
864        let response = cancel_handler(&goal_id, current_status);
865
866        let accepted = response == nros_core::CancelResponse::Accept;
867        if accepted {
868            self.set_goal_status(&goal_id, GoalStatus::Canceling);
869        }
870
871        // Serialize response: return_code (i8) + goals_canceling (sequence of GoalInfo)
872        let return_code = if accepted {
873            nros_core::CancelReturnCode::Ok
874        } else {
875            nros_core::CancelReturnCode::Rejected
876        };
877        let mut writer =
878            crate::tx_writer(&mut self.goal_buffer).map_err(|_| NodeError::BufferTooSmall)?;
879        writer
880            .write_i8(return_code as i8)
881            .map_err(|_| NodeError::Serialization)?;
882
883        let num_canceling = if accepted { 1u32 } else { 0u32 };
884        writer
885            .write_u32(num_canceling)
886            .map_err(|_| NodeError::Serialization)?;
887        if accepted {
888            write_goal_id(&mut writer, &goal_id)?;
889            writer.write_i32(0).map_err(|_| NodeError::Serialization)?;
890            writer.write_u32(0).map_err(|_| NodeError::Serialization)?;
891        }
892        let reply_len = writer.position();
893
894        self.cancel_goal_server
895            .send_response(sequence_number, &self.goal_buffer[..reply_len])
896            .map_err(|_| NodeError::ServiceReplyFailed)?;
897
898        Ok(Some((goal_id, response)))
899    }
900
901    /// Try to handle a get_result request using raw bytes.
902    ///
903    /// For completed goals, sends the stored raw result CDR from the slab.
904    /// For active/unknown goals, sends the provided `default_result_cdr` bytes.
905    ///
906    /// `default_result_cdr` should contain serialized result data (without CDR
907    /// header or status byte) — typically `A::Result::default()` serialized.
908    pub fn try_handle_get_result_raw(
909        &mut self,
910        default_result_cdr: &[u8],
911    ) -> Result<Option<GoalId>, NodeError> {
912        let buf_start = self.goal_buffer.as_ptr() as usize;
913        // Phase 120: NoData == steady-state idle; map to Ok(None).
914        let request = match self.get_result_server.take_request(&mut self.goal_buffer) {
915            Ok(Some(r)) => r,
916            Ok(None) | Err(TransportError::NoData) => return Ok(None),
917            Err(_) => return Err(NodeError::Transport(TransportError::ServiceRequestFailed)),
918        };
919
920        let data_offset = (request.data.as_ptr() as usize).saturating_sub(buf_start);
921        let data_len = request.data.len();
922        let sequence_number = request.sequence_number;
923        #[allow(clippy::drop_non_drop)]
924        drop(request);
925
926        let mut reader =
927            CdrReader::new_with_header(&self.goal_buffer[data_offset..data_offset + data_len])
928                .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
929
930        let goal_id = read_goal_id(&mut reader)?;
931
932        // Look up in completed results
933        let completed = self
934            .completed_results
935            .iter()
936            .find(|c| c.goal_id.uuid == goal_id.uuid);
937
938        if let Some(entry) = completed {
939            // Completed: send status + stored result CDR from the slab.
940            let (off, len, status) = (entry.offset, entry.len, entry.status);
941            self.reply_get_result_from_slab(sequence_number, status, off, len)?;
942            // Issue 0796 — the client now holds these bytes, so this entry
943            // becomes the first candidate for reclamation.
944            self.mark_result_delivered(&goal_id);
945        } else if self
946            .active_goals
947            .iter()
948            .any(|g| g.goal_id.uuid == goal_id.uuid)
949        {
950            // Active goal → DEFER (Phase 237). `rclcpp_action` sends get_result
951            // right after acceptance and expects the reply only once the goal
952            // terminates; replying now with a non-terminal status makes the
953            // client treat an unfinished goal as done. Hold the request's
954            // correlation token; `complete_goal_raw` flushes it. The backend
955            // retains the reply token keyed by `sequence_number`.
956            if self
957                .pending_get_results
958                .push(PendingGetResult {
959                    goal_id,
960                    sequence_number,
961                })
962                .is_err()
963            {
964                // Table full — fail loud rather than silently strand the
965                // requester (caller surfaces it; the request is not re-queued).
966                return Err(NodeError::BufferTooSmall);
967            }
968        } else {
969            // Unknown goal → reply immediately with UNKNOWN + default result.
970            // Issue 0796: a goal whose result was reclaimed (evicted by a newer
971            // completion) lands here too. That is deliberate — an honest
972            // `Unknown` beats an unanswered query.
973            self.reply_get_result_bytes(sequence_number, GoalStatus::Unknown, default_result_cdr)?;
974        }
975
976        Ok(Some(goal_id))
977    }
978
979    /// Write the `get_result` reply prologue — `[CDR header][status i8][pad to
980    /// 4]` — into `goal_buffer` and return the offset the result bytes go at.
981    ///
982    /// The align-to-4 matters: the result CDR starts with a `u32` sequence
983    /// length the reader will `align(4)` to.
984    fn write_get_result_header(&mut self, status: GoalStatus) -> Result<usize, NodeError> {
985        let mut writer =
986            crate::tx_writer(&mut self.goal_buffer).map_err(|_| NodeError::BufferTooSmall)?;
987        writer
988            .write_i8(status as i8)
989            .map_err(|_| NodeError::Serialization)?;
990        writer.align(4).map_err(|_| NodeError::Serialization)?;
991        Ok(writer.position())
992    }
993
994    /// Build + send a `get_result` reply — `[status i8][align(4)][result CDR]`
995    /// — copying the result bytes from the slab. Shared by the immediate
996    /// completed-goal path and the deferred flush in `complete_goal_raw`
997    /// (Phase 237). `goal_buffer` and `result_slab` are disjoint fields, so the
998    /// header build (into `goal_buffer`) and the slab copy don't alias.
999    fn reply_get_result_from_slab(
1000        &mut self,
1001        sequence_number: i64,
1002        status: GoalStatus,
1003        slab_offset: usize,
1004        slab_len: usize,
1005    ) -> Result<(), NodeError> {
1006        let pos = self.write_get_result_header(status)?;
1007        if pos + slab_len > GOAL_BUF {
1008            return Err(NodeError::BufferTooSmall);
1009        }
1010        self.goal_buffer[pos..pos + slab_len]
1011            .copy_from_slice(&self.result_slab[slab_offset..slab_offset + slab_len]);
1012        let reply_len = pos + slab_len;
1013        self.get_result_server
1014            .send_response(sequence_number, &self.goal_buffer[..reply_len])
1015            .map_err(|_| NodeError::ServiceReplyFailed)
1016    }
1017
1018    /// Same reply, from a caller-owned slice instead of the slab.
1019    ///
1020    /// Used for the `Unknown` reply and — issue 0796 — for a waiter whose
1021    /// result was too large to retain: the bytes exist in the caller's buffer
1022    /// even when the slab could not take a copy. `bytes` must NOT alias
1023    /// `self` (the slab path is [`reply_get_result_from_slab`]).
1024    fn reply_get_result_bytes(
1025        &mut self,
1026        sequence_number: i64,
1027        status: GoalStatus,
1028        bytes: &[u8],
1029    ) -> Result<(), NodeError> {
1030        let pos = self.write_get_result_header(status)?;
1031        if pos + bytes.len() > GOAL_BUF {
1032            return Err(NodeError::BufferTooSmall);
1033        }
1034        self.goal_buffer[pos..pos + bytes.len()].copy_from_slice(bytes);
1035        let reply_len = pos + bytes.len();
1036        self.get_result_server
1037            .send_response(sequence_number, &self.goal_buffer[..reply_len])
1038            .map_err(|_| NodeError::ServiceReplyFailed)
1039    }
1040
1041    /// Get the number of active goals.
1042    pub fn active_goal_count(&self) -> usize {
1043        self.active_goals.len()
1044    }
1045
1046    /// Get a reference to all active goals.
1047    pub fn active_goals(&self) -> &[RawActiveGoal] {
1048        &self.active_goals
1049    }
1050
1051    /// Find the status of a goal (active or unknown).
1052    pub fn find_goal_status(&self, goal_id: &GoalId) -> GoalStatus {
1053        self.active_goals
1054            .iter()
1055            .find(|g| g.goal_id.uuid == goal_id.uuid)
1056            .map(|g| g.status)
1057            .unwrap_or(GoalStatus::Unknown)
1058    }
1059
1060    /// Publish the current GoalStatusArray on the status topic.
1061    pub fn publish_status_array(&self) -> Result<(), NodeError> {
1062        let mut buf = [0u8; STATUS_ARRAY_BUF];
1063        let mut writer = crate::tx_writer(&mut buf).map_err(|_| NodeError::BufferTooSmall)?;
1064
1065        writer
1066            .write_u32(self.active_goals.len() as u32)
1067            .map_err(|_| NodeError::Serialization)?;
1068
1069        for goal in &self.active_goals {
1070            let stamped = GoalStatusStamped::new(GoalInfo::with_id(goal.goal_id), goal.status);
1071            stamped
1072                .serialize(&mut writer)
1073                .map_err(|_| NodeError::Serialization)?;
1074        }
1075
1076        let len = writer.position();
1077        self.status_publisher
1078            .publish_raw(&buf[..len])
1079            .map_err(|_| NodeError::Transport(TransportError::PublishFailed))
1080    }
1081}
1082
1083// ============================================================================
1084// ActionClientCore
1085// ============================================================================
1086
1087/// Type-agnostic action client core handling the raw-bytes protocol.
1088///
1089/// The typed [`ActionClient`](super::handles::ActionClient) wraps this
1090/// and adds serialization/deserialization at the boundary.
1091pub struct ActionClientCore<
1092    const GOAL_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1093    const RESULT_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1094    const FEEDBACK_BUF: usize = { crate::config::DEFAULT_RX_BUF_SIZE },
1095> {
1096    pub(crate) send_goal_client: session::RmwServiceClient,
1097    pub(crate) cancel_goal_client: session::RmwServiceClient,
1098    pub(crate) get_result_client: session::RmwServiceClient,
1099    pub(crate) feedback_subscriber: session::RmwSubscriber,
1100    pub(crate) goal_buffer: [u8; GOAL_BUF],
1101    pub(crate) result_buffer: [u8; RESULT_BUF],
1102    pub(crate) feedback_buffer: [u8; FEEDBACK_BUF],
1103    pub(crate) goal_counter: u64,
1104    /// Phase 84.D3: per-sub-client in-flight flags. Each of the three
1105    /// sub-clients (send_goal / cancel / get_result) is an independent
1106    /// request/reply channel and tracks its own "unconsumed reply"
1107    /// state. Cleared by `Promise::take` on success.
1108    pub(crate) in_flight_send_goal: bool,
1109    pub(crate) in_flight_cancel: bool,
1110    pub(crate) in_flight_get_result: bool,
1111}
1112
1113impl<const GOAL_BUF: usize, const RESULT_BUF: usize, const FEEDBACK_BUF: usize>
1114    ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
1115{
1116    /// Begin a server-discovery probe on the underlying `send_goal`
1117    /// service client. Used by the C action-client wrapper
1118    /// (`nros_action_client_wait_for_action_server`) to keep
1119    /// `send_goal_client` private while still exposing the discovery
1120    /// surface from `ClientTrait`.
1121    pub fn start_server_discovery(
1122        &mut self,
1123        timeout_ms: u32,
1124    ) -> Result<(), nros_rmw::TransportError> {
1125        use nros_rmw::ClientTrait;
1126        self.send_goal_client.start_server_discovery(timeout_ms)
1127    }
1128
1129    /// Poll the in-flight server-discovery probe started by
1130    /// [`start_server_discovery`](Self::start_server_discovery).
1131    pub fn poll_server_discovery(&mut self) -> Result<Option<bool>, nros_rmw::TransportError> {
1132        use nros_rmw::ClientTrait;
1133        self.send_goal_client.poll_server_discovery()
1134    }
1135
1136    /// Latched "is server visible" snapshot. See
1137    /// `ActionClient::action_server_is_ready` for the semantic.
1138    ///
1139    /// phase-379 W6 / issue 1008 — `Err` (the backend cannot answer) reports
1140    /// NOT ready. The deleted `is_server_ready` defaulted to `true`, so an
1141    /// image whose backend has no discovery claimed the server was up.
1142    /// Answering `false` makes a caller wait; answering `true` makes it send
1143    /// into the void.
1144    pub fn is_server_ready(&self) -> bool {
1145        use nros_rmw::ClientTrait;
1146        matches!(self.send_goal_client.service_is_ready(), Ok(true))
1147    }
1148
1149    /// Create a new action client core from the raw transport handles.
1150    pub fn new(
1151        send_goal_client: session::RmwServiceClient,
1152        cancel_goal_client: session::RmwServiceClient,
1153        get_result_client: session::RmwServiceClient,
1154        feedback_subscriber: session::RmwSubscriber,
1155    ) -> Self {
1156        Self {
1157            send_goal_client,
1158            cancel_goal_client,
1159            get_result_client,
1160            feedback_subscriber,
1161            goal_buffer: [0u8; GOAL_BUF],
1162            result_buffer: [0u8; RESULT_BUF],
1163            feedback_buffer: [0u8; FEEDBACK_BUF],
1164            goal_counter: 0,
1165            in_flight_send_goal: false,
1166            in_flight_cancel: false,
1167            in_flight_get_result: false,
1168        }
1169    }
1170
1171    /// Send a goal with raw CDR bytes. Returns the generated GoalId.
1172    ///
1173    /// The `goal_cdr` bytes are the serialized goal data (without GoalId framing).
1174    /// This writes GoalId + goal_cdr into the goal buffer and sends the request.
1175    ///
1176    /// After calling, use `send_goal_client` and `result_buffer` to construct
1177    /// a Promise for the acceptance reply.
1178    pub fn send_goal_raw(&mut self, goal_cdr: &[u8]) -> Result<GoalId, NodeError> {
1179        self.goal_counter += 1;
1180        let mut goal_id = GoalId::default();
1181        let counter_bytes = self.goal_counter.to_le_bytes();
1182        goal_id.uuid[..8].copy_from_slice(&counter_bytes);
1183
1184        let mut writer =
1185            crate::tx_writer(&mut self.goal_buffer).map_err(|_| NodeError::BufferTooSmall)?;
1186
1187        write_goal_id(&mut writer, &goal_id)?;
1188
1189        // Copy raw goal CDR bytes after GoalId
1190        let pos = writer.position();
1191        if pos + goal_cdr.len() > GOAL_BUF {
1192            return Err(NodeError::BufferTooSmall);
1193        }
1194        self.goal_buffer[pos..pos + goal_cdr.len()].copy_from_slice(goal_cdr);
1195        let req_len = pos + goal_cdr.len();
1196
1197        self.send_goal_client
1198            .send_request_raw(&self.goal_buffer[..req_len])
1199            .map(|_seq| ())
1200            .map_err(|_| NodeError::ServiceRequestFailed)?;
1201
1202        Ok(goal_id)
1203    }
1204
1205    /// Try to receive feedback (non-blocking, raw bytes).
1206    ///
1207    /// Returns the GoalId and total data length. The full CDR data
1208    /// (including GoalId) is in `feedback_buffer`.
1209    pub fn try_recv_feedback_raw(&mut self) -> Result<Option<(GoalId, usize)>, NodeError> {
1210        let data = self
1211            .feedback_subscriber
1212            .take_serialized(&mut self.feedback_buffer)
1213            .map_err(NodeError::Transport)?;
1214
1215        let len = match data {
1216            Some(len) => len,
1217            None => return Ok(None),
1218        };
1219
1220        let mut reader = CdrReader::new_with_header(&self.feedback_buffer[..len])
1221            .map_err(|_| NodeError::Transport(TransportError::DeserializationError))?;
1222
1223        let goal_id = read_goal_id(&mut reader)?;
1224
1225        Ok(Some((goal_id, len)))
1226    }
1227
1228    /// Cancel a goal (non-blocking). Sends the cancel request.
1229    ///
1230    /// After calling, use `cancel_goal_client` and `result_buffer` to construct
1231    /// a Promise for the cancel response.
1232    pub fn send_cancel_request(&mut self, goal_id: &GoalId) -> Result<(), NodeError> {
1233        let mut writer =
1234            crate::tx_writer(&mut self.goal_buffer).map_err(|_| NodeError::BufferTooSmall)?;
1235
1236        write_goal_id(&mut writer, goal_id)?;
1237        writer.write_i32(0).map_err(|_| NodeError::Serialization)?;
1238        writer.write_u32(0).map_err(|_| NodeError::Serialization)?;
1239
1240        let req_len = writer.position();
1241
1242        self.cancel_goal_client
1243            .send_request_raw(&self.goal_buffer[..req_len])
1244            .map(|_seq| ())
1245            .map_err(|_| NodeError::ServiceRequestFailed)
1246    }
1247
1248    /// Send a get_result request.
1249    ///
1250    /// After calling, use `get_result_client` and `result_buffer` to construct
1251    /// a Promise for the result response.
1252    pub fn send_get_result_request(&mut self, goal_id: &GoalId) -> Result<(), NodeError> {
1253        let mut writer =
1254            crate::tx_writer(&mut self.goal_buffer).map_err(|_| NodeError::BufferTooSmall)?;
1255
1256        write_goal_id(&mut writer, goal_id)?;
1257
1258        let req_len = writer.position();
1259
1260        self.get_result_client
1261            .send_request_raw(&self.goal_buffer[..req_len])
1262            .map(|_seq| ())
1263            .map_err(|_| NodeError::ServiceRequestFailed)
1264    }
1265
1266    /// Phase 122.3.c.6.e — register a `Waker` that fires when the
1267    /// send_goal RPC reply lands.
1268    pub fn register_goal_response_waker(&self, waker: &core::task::Waker) {
1269        use nros_rmw::ClientTrait;
1270        self.send_goal_client.register_waker(waker);
1271    }
1272
1273    /// Phase 122.3.c.6.e — register a `Waker` for cancel-RPC replies.
1274    pub fn register_cancel_response_waker(&self, waker: &core::task::Waker) {
1275        use nros_rmw::ClientTrait;
1276        self.cancel_goal_client.register_waker(waker);
1277    }
1278
1279    /// Phase 122.3.c.6.e — register a `Waker` for get_result replies.
1280    pub fn register_result_waker(&self, waker: &core::task::Waker) {
1281        use nros_rmw::ClientTrait;
1282        self.get_result_client.register_waker(waker);
1283    }
1284
1285    /// Phase 122.3.c.6.e — register a `Waker` for feedback messages.
1286    pub fn register_feedback_waker(&self, waker: &core::task::Waker) {
1287        use nros_rmw::Subscription;
1288        self.feedback_subscriber.register_waker(waker);
1289    }
1290
1291    /// Phase 122.3.c.6.c — poll for a cancel reply (non-blocking,
1292    /// raw bytes). Returns `Ok(Some(len))` when a reply landed; the
1293    /// CDR payload is in `result_buffer_ref()[..len]`. Reply layout
1294    /// is action_msgs/srv/CancelGoal_Response wire CDR.
1295    pub fn try_recv_cancel_reply(&mut self) -> Result<Option<usize>, NodeError> {
1296        match self
1297            .cancel_goal_client
1298            .take_response_raw(&mut self.result_buffer)
1299        {
1300            // Issue 0778 — the sequence id is discarded HERE, not missing:
1301            // an action's cancel / get_result client keeps one call in flight
1302            // at a time, so the id adds nothing the caller can use yet. It is
1303            // available the moment that changes.
1304            Ok(opt) => Ok(opt.map(|(len, _seq)| len)),
1305            Err(TransportError::NoData) => Ok(None),
1306            Err(_) => Err(NodeError::Transport(TransportError::DeserializationError)),
1307        }
1308    }
1309
1310    /// Poll for a get_result reply (non-blocking, raw bytes).
1311    ///
1312    /// Returns `Ok(Some(total_len))` if a reply arrived (data in result buffer),
1313    /// `Ok(None)` if no reply yet.
1314    ///
1315    /// After receiving, use [`result_buffer_ref()`](Self::result_buffer_ref)
1316    /// to access the raw CDR data. The layout is: CDR header (4) + status
1317    /// byte (1) + result data.
1318    pub fn try_recv_get_result_reply(&mut self) -> Result<Option<usize>, NodeError> {
1319        // Phase 120: NoData == steady-state polling; map to Ok(None).
1320        match self
1321            .get_result_client
1322            .take_response_raw(&mut self.result_buffer)
1323        {
1324            // Issue 0778 — the sequence id is discarded HERE, not missing:
1325            // an action's cancel / get_result client keeps one call in flight
1326            // at a time, so the id adds nothing the caller can use yet. It is
1327            // available the moment that changes.
1328            Ok(opt) => Ok(opt.map(|(len, _seq)| len)),
1329            Err(TransportError::NoData) => Ok(None),
1330            Err(_) => Err(NodeError::Transport(TransportError::DeserializationError)),
1331        }
1332    }
1333
1334    /// Poll for the send_goal acceptance reply (non-blocking, raw bytes).
1335    ///
1336    /// Returns `Ok(Some(total_len))` if a reply arrived (data in result buffer),
1337    /// `Ok(None)` if no reply yet.
1338    ///
1339    /// The reply CDR contains: header (4) + accepted (u8) + stamp (i32 + u32).
1340    pub fn try_recv_send_goal_reply(&mut self) -> Result<Option<usize>, NodeError> {
1341        // Phase 120: NoData == steady-state polling; map to Ok(None).
1342        match self
1343            .send_goal_client
1344            .take_response_raw(&mut self.result_buffer)
1345        {
1346            // Issue 0778 — the sequence id is discarded HERE, not missing:
1347            // an action's cancel / get_result client keeps one call in flight
1348            // at a time, so the id adds nothing the caller can use yet. It is
1349            // available the moment that changes.
1350            Ok(opt) => Ok(opt.map(|(len, _seq)| len)),
1351            Err(TransportError::NoData) => Ok(None),
1352            Err(_) => Err(NodeError::Transport(TransportError::DeserializationError)),
1353        }
1354    }
1355
1356    /// Read-only access to the result buffer (after polling a reply).
1357    pub fn result_buffer_ref(&self) -> &[u8] {
1358        &self.result_buffer
1359    }
1360
1361    /// Read-only access to the feedback buffer (after receiving feedback).
1362    pub fn feedback_buffer_ref(&self) -> &[u8] {
1363        &self.feedback_buffer
1364    }
1365
1366    /// Get the current goal counter (used to reconstruct the last goal ID).
1367    pub fn goal_counter(&self) -> u64 {
1368        self.goal_counter
1369    }
1370}