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