Skip to main content

nros_node/executor/
action.rs

1//! Action server and client registration on the executor and handle types.
2
3use core::marker::PhantomData;
4
5use nros_core::RosAction;
6use nros_rmw::{ActionEndpointHashes, ActionInfo, QoSProfile, ServiceInfo, Session, TopicInfo};
7
8#[allow(unused_imports)]
9use crate::rmw_type_registry::{MessageForRmw, register_type};
10
11use super::{
12    action_core::{ActionClientCore, ActionServerCore, RawActiveGoal},
13    arena::{
14        ActionClientCallbackEntry, ActionClientRawArenaEntry, ActionServerArenaEntry,
15        ActionServerRawArenaEntry, BufferStrategy, CallbackMeta, EntryKind, TraceName,
16        action_client_callback_try_process, action_client_raw_try_process,
17        action_server_raw_try_process, action_server_try_process, always_ready,
18        as_active_goal_count, as_complete_goal, as_expire_goals, as_for_each_active_goal,
19        as_goal_exists, as_publish_feedback, as_raw_active_goal_count, as_raw_complete_goal,
20        as_raw_expire_goals, as_raw_for_each_active_goal, as_raw_goal_exists,
21        as_raw_publish_feedback, as_raw_set_goal_status, as_set_goal_status, buffered_region_size,
22        drop_entry, no_pre_sample,
23    },
24    handles::{ActionServer, ActiveGoal},
25    spin::Executor,
26    spsc_ring::SpscRing,
27    triple_buffer::TripleBuffer,
28    types::{
29        HandleId, InvocationMode, NodeError, RawAcceptedCallback, RawCancelCallback,
30        RawCancelResponseCallback, RawFeedbackCallback, RawGoalCallback, RawGoalResponseCallback,
31        RawResultCallback,
32    },
33};
34
35/// phase-392 W5.b2 — how many zenoh queryables ONE action server costs.
36///
37/// An action is three services on the wire (`send_goal`, `cancel_goal`,
38/// `get_result`; the feedback and status channels are topics, not queryables),
39/// so a launch file declaring one action server is declaring THREE entries in
40/// the backend's queryable table. A consumer sizing that table from a model
41/// must multiply, and this is the multiplier.
42///
43/// Defined here rather than in the consumer for the reason issue 0827 measured:
44/// a count restated where it cannot be derived drifts from the code that
45/// decides it. `check-infra-queryable-counts` ties this to the distinct
46/// `create_service` calls below, so adding a fourth action channel fails the
47/// gate instead of silently under-sizing every image that declares an action.
48pub const ACTION_SERVER_QUERYABLES: usize = 3;
49
50/// Publishers an action SERVER creates, per action, beyond anything the author
51/// declares: the feedback topic and the status topic (`create_publisher` calls
52/// below). A consumer sizing the backend's publisher table from a declaration
53/// must add these, because an `ENTITIES action_server:...` spec declares ONE
54/// entity and costs two publisher slots.
55///
56/// Same rule as [`ACTION_SERVER_QUERYABLES`], and defined for the same reason:
57/// the number lives next to the calls that decide it, so it cannot drift from
58/// them without failing the gate that ties the two together.
59pub const ACTION_SERVER_PUBLISHERS: usize = 2;
60
61/// Subscriptions an action CLIENT creates, per action: the feedback topic.
62/// Status is polled through the result client rather than subscribed, so this
63/// is one and not two -- check the `create_subscription` calls below before
64/// changing it, not this comment.
65///
66/// The same under-sizing hazard as the two above: `ENTITIES action_client:...`
67/// is one declared entity and one subscriber slot that nothing else accounts
68/// for.
69pub const ACTION_CLIENT_SUBSCRIPTIONS: usize = 1;
70
71/// Service CLIENTS an action CLIENT creates, per action: `send_goal`,
72/// `cancel_goal` and `get_result` (the `create_client` calls below).
73///
74/// phase-412 -- the liveliness pool needs it. Every service client the session
75/// opens declares one liveliness token (`create_client` in the zenoh shim), so
76/// an `action_client` spec is one declared entity and THREE tokens; a pool
77/// sized from the raw count is short for every image with an action client.
78/// Held to the distinct `create_client(&<name>_info` channels by
79/// `check-infra-queryable-counts`, same rule as the three above.
80pub const ACTION_CLIENT_SERVICE_CLIENTS: usize = 3;
81
82// ============================================================================
83// Raw action registration specs
84// ============================================================================
85
86/// Inputs for raw (untyped) action-server registration.
87///
88/// Collapses the runtime arguments shared by the
89/// `register_action_server_raw*` family. Buffer sizes / max-goals stay
90/// as const-generic turbofish parameters on the registration methods.
91pub struct RawActionServerSpec<'a> {
92    /// `None` registers on the executor's own node; `Some(id)` routes
93    /// the server's 5 underlying handles (send_goal / cancel_goal /
94    /// get_result servers + feedback / status publishers) through the
95    /// named Node's session.
96    pub node_id: Option<super::node_record::NodeId>,
97    pub action_name: &'a str,
98    pub type_name: &'a str,
99    /// The ACTION's own hash; feeds `ActionInfo` only. The endpoints are keyed
100    /// on [`hashes`](Self::hashes).
101    pub type_hash: &'a str,
102    /// Issue 1913 — the hash each of the five endpoints is keyed on.
103    pub hashes: ActionEndpointHashes<'a>,
104    /// QoS for the action's three underlying service servers (send_goal
105    /// / cancel_goal / get_result; Phase 193.4b). The feedback + status
106    /// publishers keep their own profiles. Use
107    /// [`QoSProfile::services_default`] for the rclc-compatible default.
108    pub qos: QoSProfile,
109    pub goal_callback: RawGoalCallback,
110    pub cancel_callback: RawCancelCallback,
111    pub accepted_callback: Option<RawAcceptedCallback>,
112    pub context: *mut core::ffi::c_void,
113}
114
115/// Inputs for raw (untyped) action-client registration.
116///
117/// Collapses the runtime arguments shared by the
118/// `register_action_client_raw*` family. Buffer sizes stay as
119/// const-generic turbofish parameters on the registration methods.
120pub struct RawActionClientSpec<'a> {
121    /// `None` registers on the executor's own node; `Some(id)` routes
122    /// the client's 4 underlying handles (send_goal / cancel_goal /
123    /// get_result service clients + feedback subscriber) through the
124    /// named Node's session.
125    pub node_id: Option<super::node_record::NodeId>,
126    pub action_name: &'a str,
127    pub type_name: &'a str,
128    /// The ACTION's own hash; feeds `ActionInfo` only. The endpoints are keyed
129    /// on [`hashes`](Self::hashes).
130    pub type_hash: &'a str,
131    /// Issue 1913 — the hash each endpoint a client uses is keyed on.
132    pub hashes: ActionEndpointHashes<'a>,
133    pub goal_response_callback: Option<RawGoalResponseCallback>,
134    pub feedback_callback: Option<RawFeedbackCallback>,
135    pub result_callback: Option<RawResultCallback>,
136    /// Issue 2030 — `Some` makes the executor take this client's cancel
137    /// replies and deliver them here. `None` leaves them for the caller's own
138    /// reader. See [`RawCancelResponseCallback`].
139    pub cancel_response_callback: Option<RawCancelResponseCallback>,
140    pub context: *mut core::ffi::c_void,
141}
142
143// ============================================================================
144// Action server registration
145// ============================================================================
146
147impl<'s> Executor<'s> {
148    /// Register an action server with goal/cancel callbacks.
149    ///
150    /// The executor automatically dispatches:
151    /// - Goal acceptance via `goal_callback`
152    /// - Cancel requests via `cancel_callback`
153    /// - Result serving for completed goals
154    ///
155    /// Use the returned [`ActionServerHandle`] to publish feedback and complete goals.
156    ///
157    /// Uses default buffer sizes and max 4 concurrent goals.
158    pub fn register_action_server<A, GoalF, CancelF>(
159        &mut self,
160        action_name: &str,
161        goal_callback: GoalF,
162        cancel_callback: CancelF,
163    ) -> Result<ActionServerHandle<A>, NodeError>
164    where
165        A: RosAction + 'static,
166        A::Goal: Clone + MessageForRmw,
167        A::Result: Clone + Default + MessageForRmw,
168        A::Feedback: MessageForRmw,
169        A::SendGoalRequest: MessageForRmw,
170        A::SendGoalResponse: MessageForRmw,
171        A::GetResultRequest: MessageForRmw,
172        A::GetResultResponse: MessageForRmw,
173        A::FeedbackMessage: MessageForRmw,
174        GoalF: FnMut(&nros_core::GoalUuid, &A::Goal) -> nros_core::GoalResponse + 'static,
175        CancelF: FnMut(&nros_core::GoalUuid, nros_core::GoalStatusCode) -> nros_core::CancelResponse
176            + 'static,
177    {
178        self.register_action_server_sized::<A, GoalF, CancelF, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, 4>(
179            action_name,
180            goal_callback,
181            cancel_callback,
182        )
183    }
184
185    /// Register an action server with custom buffer sizes.
186    pub fn register_action_server_sized<
187        A,
188        GoalF,
189        CancelF,
190        const GOAL_BUF: usize,
191        const RESULT_BUF: usize,
192        const FEEDBACK_BUF: usize,
193        const MAX_GOALS: usize,
194    >(
195        &mut self,
196        action_name: &str,
197        goal_callback: GoalF,
198        cancel_callback: CancelF,
199    ) -> Result<ActionServerHandle<A>, NodeError>
200    where
201        A: RosAction + 'static,
202        A::Goal: Clone + MessageForRmw,
203        A::Result: Clone + Default + MessageForRmw,
204        A::Feedback: MessageForRmw,
205        A::SendGoalRequest: MessageForRmw,
206        A::SendGoalResponse: MessageForRmw,
207        A::GetResultRequest: MessageForRmw,
208        A::GetResultResponse: MessageForRmw,
209        A::FeedbackMessage: MessageForRmw,
210        GoalF: FnMut(&nros_core::GoalUuid, &A::Goal) -> nros_core::GoalResponse + 'static,
211        CancelF: FnMut(&nros_core::GoalUuid, nros_core::GoalStatusCode) -> nros_core::CancelResponse
212            + 'static,
213    {
214        // Phase 212.K.7.6.b + K.7.7.c — under `rmw-cyclonedds`, register
215        // the user-facing message types AND the five action-protocol
216        // envelope types with the cyclonedds runtime registry before
217        // creating the underlying service / topic entities. No-op for
218        // other RMWs. See `Node::create_action_server_sized` for the
219        // detailed rationale.
220        register_type::<A::Goal>()?;
221        register_type::<A::Result>()?;
222        register_type::<A::Feedback>()?;
223        register_type::<A::SendGoalRequest>()?;
224        register_type::<A::SendGoalResponse>()?;
225        register_type::<A::GetResultRequest>()?;
226        register_type::<A::GetResultResponse>()?;
227        register_type::<A::FeedbackMessage>()?;
228        // Phase 244 E3 (RFC-0044) — register the fixed `action_msgs` protocol
229        // types (CancelGoal_{Request,Response}, GoalStatusArray) the cancel /
230        // status plumbing serializes. The generated `impl RosAction` overrides
231        // this (default = no-op); previously every example hand-registered these
232        // three under `#[cfg(feature = "rmw-cyclonedds")]`.
233        A::register_protocol_types().map_err(|()| NodeError::ActionCreationFailed)?;
234        type Entry<
235            A,
236            GoalF,
237            CancelF,
238            const GB: usize,
239            const RB: usize,
240            const FB: usize,
241            const MG: usize,
242        > = ActionServerArenaEntry<A, GoalF, CancelF, GB, RB, FB, MG>;
243
244        let slot = self.next_entry_slot()?;
245
246        // Create the action server entities (same logic as Node::create_action_server_sized)
247        let action_info = ActionInfo::new(action_name, A::ACTION_NAME, A::ACTION_HASH);
248
249        // ROS 2 matches the action's send_goal / get_result services by their
250        // per-channel service types (`<Action>_SendGoal` / `<Action>_GetResult`)
251        // and the feedback topic by `<Action>_FeedbackMessage` — not the bare
252        // action type. Pass those so a real `rcl_action` peer discovers us.
253        let send_goal_type = super::action_core::action_service_base_type(
254            <A::SendGoalRequest as nros_core::RosMessage>::TYPE_NAME,
255            A::ACTION_NAME,
256        );
257        let get_result_type = super::action_core::action_service_base_type(
258            <A::GetResultRequest as nros_core::RosMessage>::TYPE_NAME,
259            A::ACTION_NAME,
260        );
261        let feedback_type = <A::FeedbackMessage as nros_core::RosMessage>::TYPE_NAME;
262
263        let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
264        let send_goal_info = ServiceInfo::new(
265            &send_goal_keyexpr,
266            send_goal_type,
267            A::SEND_GOAL_SERVICE_HASH,
268        )
269        .with_domain(self.domain_id);
270        let send_goal_server = self
271            .session
272            .create_service(&send_goal_info, QoSProfile::services_default())
273            .map_err(NodeError::Transport)?;
274
275        let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
276        let cancel_goal_info = ServiceInfo::new(
277            &cancel_goal_keyexpr,
278            "action_msgs::srv::dds_::CancelGoal_",
279            A::CANCEL_GOAL_SERVICE_HASH,
280        )
281        .with_domain(self.domain_id);
282        let cancel_goal_server = self
283            .session
284            .create_service(&cancel_goal_info, QoSProfile::services_default())
285            .map_err(NodeError::Transport)?;
286
287        let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
288        let get_result_info = ServiceInfo::new(
289            &get_result_keyexpr,
290            get_result_type,
291            A::GET_RESULT_SERVICE_HASH,
292        )
293        .with_domain(self.domain_id);
294        let get_result_server = self
295            .session
296            .create_service(&get_result_info, QoSProfile::services_default())
297            .map_err(NodeError::Transport)?;
298
299        let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
300        let feedback_topic = TopicInfo::new(
301            &feedback_keyexpr,
302            feedback_type,
303            <A::FeedbackMessage as nros_core::RosMessage>::TYPE_HASH,
304        )
305        .with_domain(self.domain_id);
306        let feedback_publisher = self
307            .session
308            .create_publisher(&feedback_topic, QoSProfile::QOS_PROFILE_DEFAULT)
309            .map_err(NodeError::Transport)?;
310
311        let status_keyexpr: heapless::String<256> = action_info.status_key();
312        let status_topic = TopicInfo::new(
313            &status_keyexpr,
314            "action_msgs::msg::dds_::GoalStatusArray_",
315            A::STATUS_MESSAGE_HASH,
316        )
317        .with_domain(self.domain_id);
318        let status_publisher = self
319            .session
320            .create_publisher(&status_topic, QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT)
321            .map_err(NodeError::Transport)?;
322
323        let server = ActionServer {
324            core: super::action_core::ActionServerCore {
325                send_goal_server,
326                cancel_goal_server,
327                get_result_server,
328                feedback_publisher,
329                status_publisher,
330                active_goals: heapless::Vec::new(),
331                completed_results: heapless::Vec::new(),
332                pending_get_results: heapless::Vec::new(),
333                result_slab: [0u8; RESULT_BUF],
334                result_slab_used: 0,
335                goal_buffer: [0u8; GOAL_BUF],
336                feedback_buffer: [0u8; FEEDBACK_BUF],
337                cancel_buffer: [0u8; 256],
338            },
339            typed_goals: heapless::Vec::new(),
340            completed_goals: heapless::Vec::new(),
341        };
342
343        let offset = self
344            .arena_alloc::<Entry<A, GoalF, CancelF, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>>(
345            )?;
346
347        unsafe {
348            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
349            let entry_ptr = arena_ptr.add(offset)
350                as *mut Entry<A, GoalF, CancelF, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>;
351            core::ptr::write(
352                entry_ptr,
353                Entry {
354                    server,
355                    goal_callback,
356                    cancel_callback,
357                },
358            );
359        }
360
361        let meta = CallbackMeta {
362            offset,
363            kind: EntryKind::ActionServer,
364            arena_len: super::arena::ArenaLen::UNRECORDED,
365            has_data: always_ready,
366            pre_sample: no_pre_sample,
367            invocation: InvocationMode::Always,
368            try_process: action_server_try_process::<
369                A,
370                GoalF,
371                CancelF,
372                GOAL_BUF,
373                RESULT_BUF,
374                FEEDBACK_BUF,
375                MAX_GOALS,
376            >,
377            drop_fn: drop_entry::<
378                Entry<A, GoalF, CancelF, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
379            >,
380        };
381        self.emplace_entry(slot, meta, TraceName::Text(action_name));
382
383        Ok(ActionServerHandle {
384            entry_index: slot,
385            publish_feedback_fn: as_publish_feedback::<
386                A,
387                GoalF,
388                CancelF,
389                GOAL_BUF,
390                RESULT_BUF,
391                FEEDBACK_BUF,
392                MAX_GOALS,
393            >,
394            complete_goal_fn: as_complete_goal::<
395                A,
396                GoalF,
397                CancelF,
398                GOAL_BUF,
399                RESULT_BUF,
400                FEEDBACK_BUF,
401                MAX_GOALS,
402            >,
403            set_goal_status_fn: as_set_goal_status::<
404                A,
405                GoalF,
406                CancelF,
407                GOAL_BUF,
408                RESULT_BUF,
409                FEEDBACK_BUF,
410                MAX_GOALS,
411            >,
412            active_goal_count_fn: as_active_goal_count::<
413                A,
414                GoalF,
415                CancelF,
416                GOAL_BUF,
417                RESULT_BUF,
418                FEEDBACK_BUF,
419                MAX_GOALS,
420            >,
421            goal_exists_fn: as_goal_exists::<
422                A,
423                GoalF,
424                CancelF,
425                GOAL_BUF,
426                RESULT_BUF,
427                FEEDBACK_BUF,
428                MAX_GOALS,
429            >,
430            expire_goals_fn: as_expire_goals::<
431                A,
432                GoalF,
433                CancelF,
434                GOAL_BUF,
435                RESULT_BUF,
436                FEEDBACK_BUF,
437                MAX_GOALS,
438            >,
439            for_each_active_goal_fn: as_for_each_active_goal::<
440                A,
441                GoalF,
442                CancelF,
443                GOAL_BUF,
444                RESULT_BUF,
445                FEEDBACK_BUF,
446                MAX_GOALS,
447            >,
448            _phantom: PhantomData,
449        })
450    }
451}
452
453// ============================================================================
454// Handle types for arena-registered action server
455// ============================================================================
456
457/// Handle to an action server registered in the executor's arena.
458///
459/// Returned by [`Executor::register_action_server()`]. Provides methods
460/// to interact with the server (publish feedback, complete goals) while the
461/// executor automatically handles goal acceptance, cancel requests, and
462/// result serving during [`spin_once()`](Executor::spin_once).
463#[allow(clippy::type_complexity)]
464pub struct ActionServerHandle<A: RosAction> {
465    pub(crate) entry_index: usize,
466    publish_feedback_fn:
467        unsafe fn(*mut u8, &nros_core::GoalUuid, &A::Feedback) -> Result<(), NodeError>,
468    complete_goal_fn: unsafe fn(
469        *mut u8,
470        &nros_core::GoalUuid,
471        nros_core::GoalStatusCode,
472        A::Result,
473    ) -> Result<(), NodeError>,
474    set_goal_status_fn: unsafe fn(*mut u8, &nros_core::GoalUuid, nros_core::GoalStatusCode),
475    active_goal_count_fn: unsafe fn(*const u8) -> usize,
476    goal_exists_fn: unsafe fn(*const u8, &nros_core::GoalUuid) -> bool,
477    expire_goals_fn: unsafe fn(*mut u8) -> usize,
478    for_each_active_goal_fn: unsafe fn(*const u8, &mut dyn FnMut(&ActiveGoal<A>)),
479    _phantom: PhantomData<A>,
480}
481
482impl<A: RosAction> Clone for ActionServerHandle<A> {
483    fn clone(&self) -> Self {
484        *self
485    }
486}
487
488impl<A: RosAction> Copy for ActionServerHandle<A> {}
489
490impl<A: RosAction> ActionServerHandle<A> {
491    /// Get the [`HandleId`] for this action server.
492    ///
493    /// Used with `Trigger::One` or `HandleSet` for trigger configuration.
494    pub fn handle_id(&self) -> HandleId {
495        HandleId::for_owned_slot(self.entry_index)
496    }
497
498    /// Publish feedback for an active goal.
499    ///
500    /// Serialises the feedback message and sends it to all clients
501    /// monitoring this goal. Returns an error if the handle slot has
502    /// been removed from the executor.
503    pub fn publish_feedback(
504        &self,
505        executor: &mut Executor,
506        goal_id: &nros_core::GoalUuid,
507        feedback: &A::Feedback,
508    ) -> Result<(), NodeError> {
509        let meta = executor.entries[self.entry_index]
510            .as_ref()
511            .ok_or(NodeError::BufferTooSmall)?;
512        let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
513        unsafe {
514            let data_ptr = arena_ptr.add(meta.offset);
515            (self.publish_feedback_fn)(data_ptr, goal_id, feedback)
516        }
517    }
518
519    /// Complete a goal with a terminal status and result payload.
520    ///
521    /// The goal is moved from the active set to the completed-results
522    /// slab. Clients waiting on a result will receive the response.
523    /// `status` should be one of `Succeeded`, `Aborted`, or `Canceled`.
524    ///
525    /// # Errors
526    ///
527    /// `NodeError::BufferTooSmall` when the handle slot has been removed from
528    /// the executor, or when the serialized result exceeds the server's
529    /// `RESULT_BUF` and so cannot be retained for a later `get_result` (issue
530    /// 0796 — this used to return `()` and swallow both).
531    /// Terminate `goal_id` as SUCCEEDED — phase-379 W5.
532    ///
533    /// The three terminal verbs (`succeed` / `abort` / `canceled`) name the
534    /// ACTION SPEC's terminal states, which is also what rclcpp_action's
535    /// `ServerGoalHandle` spells them: `succeed(result)`, `abort(result)`,
536    /// `canceled(result)` — and, since phase-417 W4.b, what C
537    /// (`rclc_action_canceled`) and C++ (`ActionServer<A>::canceled`) spell
538    /// them too. They take the goal ID rather than hanging off a
539    /// handle because a goal here lives in a fixed-capacity arena and is named
540    /// by its UUID — see the `divergence` row for why neither client library's
541    /// ownership model is available without an allocator.
542    ///
543    /// `complete_goal` remains the general form for a status computed at
544    /// runtime.
545    pub fn succeed(
546        &self,
547        executor: &mut Executor,
548        goal_id: &nros_core::GoalUuid,
549        result: A::Result,
550    ) -> Result<(), NodeError> {
551        self.complete_goal(
552            executor,
553            goal_id,
554            nros_core::GoalStatusCode::Succeeded,
555            result,
556        )
557    }
558
559    /// Terminate `goal_id` as ABORTED. See [`Self::succeed`].
560    pub fn abort(
561        &self,
562        executor: &mut Executor,
563        goal_id: &nros_core::GoalUuid,
564        result: A::Result,
565    ) -> Result<(), NodeError> {
566        self.complete_goal(
567            executor,
568            goal_id,
569            nros_core::GoalStatusCode::Aborted,
570            result,
571        )
572    }
573
574    /// Terminate `goal_id` as CANCELED. See [`Self::succeed`].
575    ///
576    /// phase-417 W4.b — this was `cancel`, and the argument for that spelling
577    /// lived on the `cancel` alias phase-482 W6 retired. It loses to the
578    /// drop-in claim: C spells it
579    /// `rclc_action_canceled`, rclcpp_action spells it
580    /// `ServerGoalHandle::canceled`, and Rust was the only one of the three
581    /// disagreeing — over a naming preference with no platform reason behind
582    /// it (RFC-0089: a rename is cheap, a surface our own languages disagree
583    /// about is not).
584    pub fn canceled(
585        &self,
586        executor: &mut Executor,
587        goal_id: &nros_core::GoalUuid,
588        result: A::Result,
589    ) -> Result<(), NodeError> {
590        self.complete_goal(
591            executor,
592            goal_id,
593            nros_core::GoalStatusCode::Canceled,
594            result,
595        )
596    }
597
598    pub fn complete_goal(
599        &self,
600        executor: &mut Executor,
601        goal_id: &nros_core::GoalUuid,
602        status: nros_core::GoalStatusCode,
603        result: A::Result,
604    ) -> Result<(), NodeError> {
605        let meta = executor.entries[self.entry_index]
606            .as_ref()
607            .ok_or(NodeError::BufferTooSmall)?;
608        let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
609        unsafe {
610            let data_ptr = arena_ptr.add(meta.offset);
611            (self.complete_goal_fn)(data_ptr, goal_id, status, result)
612        }
613    }
614
615    /// Update a goal's status without completing it.
616    ///
617    /// Use this to transition a goal to `Executing` or `Canceling`
618    /// while it is still active. To finish a goal, use [`complete_goal`](Self::complete_goal).
619    pub fn set_goal_status(
620        &self,
621        executor: &mut Executor,
622        goal_id: &nros_core::GoalUuid,
623        status: nros_core::GoalStatusCode,
624    ) {
625        if let Some(meta) = executor.entries[self.entry_index].as_ref() {
626            let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
627            unsafe {
628                let data_ptr = arena_ptr.add(meta.offset);
629                (self.set_goal_status_fn)(data_ptr, goal_id, status);
630            }
631        }
632    }
633
634    /// Get the number of currently active goals.
635    ///
636    /// Returns 0 if the action server handle has been removed from the executor.
637    pub fn active_goal_count(&self, executor: &Executor) -> usize {
638        match executor.entries[self.entry_index].as_ref() {
639            Some(meta) => {
640                let arena_ptr = executor.arena.as_ptr() as *const u8;
641                unsafe {
642                    let data_ptr = arena_ptr.add(meta.offset);
643                    (self.active_goal_count_fn)(data_ptr)
644                }
645            }
646            None => 0,
647        }
648    }
649
650    /// Is `goal_id` a goal this server still knows about? — phase-417 W4.b.
651    ///
652    /// rcl's `rcl_action_server_goal_exists`, and the Rust half of the ledger
653    /// row `c:action_server_goal_exists`. TRUE while the goal is active AND
654    /// while its completed result is still retained, which is a strictly wider
655    /// window than `goal_status` reports — see
656    /// [`ActionServerCore::goal_exists`](crate::executor::ffi::ActionServerCoreFfi::goal_exists).
657    ///
658    /// Returns `false` if the handle slot has been removed from the executor,
659    /// which is the same answer a caller wants there: nothing on this server
660    /// knows the goal any more.
661    pub fn goal_exists(&self, executor: &Executor, goal_id: &nros_core::GoalUuid) -> bool {
662        match executor.entries[self.entry_index].as_ref() {
663            Some(meta) => {
664                let arena_ptr = executor.arena.as_ptr() as *const u8;
665                unsafe {
666                    let data_ptr = arena_ptr.add(meta.offset);
667                    (self.goal_exists_fn)(data_ptr, goal_id)
668                }
669            }
670            None => false,
671        }
672    }
673
674    /// Eagerly reclaim every completed result whose `get_result` reply has
675    /// already been sent — phase-417 W4.b, ledger row `c:action_expire_goals`.
676    ///
677    /// Returns the number of entries reclaimed. rcl's `rcl_action_expire_goals`
678    /// is the same capability under a CLOCK: it reclaims goals whose
679    /// `result_timeout` has elapsed and reports which. Ours has no clock — the
680    /// `no_std` core takes no time source (RFC-0036) — so the trigger is the
681    /// caller, and reclamation is otherwise on demand inside `complete_goal`.
682    /// Calling this is OPTIONAL; a server that never does still runs forever.
683    pub fn expire_goals(&self, executor: &mut Executor) -> usize {
684        match executor.entries[self.entry_index].as_ref() {
685            Some(meta) => {
686                let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
687                unsafe {
688                    let data_ptr = arena_ptr.add(meta.offset);
689                    (self.expire_goals_fn)(data_ptr)
690                }
691            }
692            None => 0,
693        }
694    }
695
696    /// Iterate over all currently active goals.
697    ///
698    /// Calls `f` for each goal that has been accepted but not yet
699    /// completed. Useful for monitoring progress or canceling stale goals.
700    pub fn for_each_active_goal(&self, executor: &Executor, mut f: impl FnMut(&ActiveGoal<A>)) {
701        if let Some(meta) = executor.entries[self.entry_index].as_ref() {
702            let arena_ptr = executor.arena.as_ptr() as *const u8;
703            unsafe {
704                let data_ptr = arena_ptr.add(meta.offset);
705                (self.for_each_active_goal_fn)(data_ptr, &mut f);
706            }
707        }
708    }
709}
710
711// ============================================================================
712// Raw (untyped) action server registration
713// ============================================================================
714
715impl<'s> Executor<'s> {
716    /// Register a raw action server with raw-bytes callbacks.
717    ///
718    /// Unlike [`register_action_server()`](Executor::register_action_server), this does
719    /// not require `RosAction` — the goal/cancel callbacks receive raw CDR
720    /// bytes. This is used by the C API thin wrapper.
721    ///
722    /// `type_name` and `type_hash` identify the action type for key expression
723    /// construction and liveliness tokens.
724    #[doc(hidden)]
725    #[allow(clippy::too_many_arguments)]
726    pub(crate) fn register_action_server_raw(
727        &mut self,
728        spec: RawActionServerSpec<'_>,
729    ) -> Result<ActionServerRawHandle, NodeError> {
730        self.register_action_server_raw_sized::<{ crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, { crate::config::DEFAULT_RX_BUF_SIZE }, 4>(
731            spec,
732        )
733    }
734
735    /// Register a raw action server with custom buffer sizes.
736    ///
737    /// `spec.node_id` selects the target: `None` registers on the
738    /// executor's own node, `Some(id)` routes the server's 5 underlying
739    /// handles through the named Node's session (Phase 104.C.3.3.a).
740    /// `spec.qos` applies to the action's three underlying service
741    /// servers (send_goal / cancel_goal / get_result; Phase 193.4b); the
742    /// feedback + status publishers keep their own profiles.
743    #[doc(hidden)]
744    pub fn register_action_server_raw_sized<
745        const GOAL_BUF: usize,
746        const RESULT_BUF: usize,
747        const FEEDBACK_BUF: usize,
748        const MAX_GOALS: usize,
749    >(
750        &mut self,
751        spec: RawActionServerSpec<'_>,
752    ) -> Result<ActionServerRawHandle, NodeError> {
753        let RawActionServerSpec {
754            node_id,
755            action_name,
756            type_name,
757            type_hash,
758            hashes,
759            qos,
760            goal_callback,
761            cancel_callback,
762            accepted_callback,
763            context,
764        } = spec;
765
766        type Entry<const GB: usize, const RB: usize, const FB: usize, const MG: usize> =
767            ActionServerRawArenaEntry<GB, RB, FB, MG>;
768
769        let slot = self.next_entry_slot()?;
770
771        let action_info = ActionInfo::new(action_name, type_name, type_hash);
772        // Issue 0656 — capture the domain BEFORE the session borrow below, for
773        // the same reason `node_name`/`ns` are cloned here: the `&mut session`
774        // in the create scope would otherwise conflict with `&self`.
775        let domain_id = self.domain_id;
776        let (node_name, ns, session_idx) = match node_id {
777            Some(id) => {
778                let r = self
779                    .nodes
780                    .get(id.index())
781                    .ok_or(NodeError::InvalidSchedContextBinding)?;
782                (r.name.clone(), r.namespace.clone(), r.session_idx)
783            }
784            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
785        };
786
787        // Thread node identity through each underlying ServiceInfo /
788        // TopicInfo so the Zenoh shim declares a liveliness token for
789        // each entity. Without `with_node_name`,
790        // `declare_entity_liveliness` short-circuits and
791        // `wait_for_action_server` has nothing to find — same fix as
792        // `Node::create_action_server_sized` (commit ea5e80b4).
793        // All 5 session-create calls grouped into one scope so the
794        // mutable session borrow drops before arena alloc below.
795        let (
796            send_goal_server,
797            cancel_goal_server,
798            get_result_server,
799            feedback_publisher,
800            status_publisher,
801        ) = {
802            // phase-338 W3 — ROS 2 matches these by their PER-CHANNEL types, not
803            // the bare action type. The typed path derives them from
804            // `A::SendGoalRequest::TYPE_NAME`; the raw path has only the bare
805            // type, and advertising it here left send_goal / get_result /
806            // feedback undiscoverable, so every goal timed out.
807            let send_goal_type: heapless::String<256> =
808                super::action_core::action_channel_type(type_name, "SendGoal");
809            let get_result_type: heapless::String<256> =
810                super::action_core::action_channel_type(type_name, "GetResult");
811            let feedback_type: heapless::String<256> =
812                super::action_core::action_channel_type(type_name, "FeedbackMessage");
813
814            let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
815            let mut send_goal_info =
816                ServiceInfo::new(&send_goal_keyexpr, &send_goal_type, hashes.send_goal)
817                    .with_namespace(&ns)
818                    .with_domain(domain_id);
819            if !node_name.is_empty() {
820                send_goal_info = send_goal_info.with_node_name(&node_name);
821            }
822
823            let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
824            let mut cancel_goal_info = ServiceInfo::new(
825                &cancel_goal_keyexpr,
826                "action_msgs::srv::dds_::CancelGoal_",
827                hashes.cancel_goal,
828            )
829            .with_namespace(&ns)
830            .with_domain(domain_id);
831            if !node_name.is_empty() {
832                cancel_goal_info = cancel_goal_info.with_node_name(&node_name);
833            }
834
835            let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
836            let mut get_result_info =
837                ServiceInfo::new(&get_result_keyexpr, &get_result_type, hashes.get_result)
838                    .with_namespace(&ns)
839                    .with_domain(domain_id);
840            if !node_name.is_empty() {
841                get_result_info = get_result_info.with_node_name(&node_name);
842            }
843
844            let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
845            let mut feedback_topic =
846                TopicInfo::new(&feedback_keyexpr, &feedback_type, hashes.feedback)
847                    .with_namespace(&ns)
848                    .with_domain(domain_id);
849            if !node_name.is_empty() {
850                feedback_topic = feedback_topic.with_node_name(&node_name);
851            }
852
853            let status_keyexpr: heapless::String<256> = action_info.status_key();
854            let mut status_topic = TopicInfo::new(
855                &status_keyexpr,
856                "action_msgs::msg::dds_::GoalStatusArray_",
857                hashes.status,
858            )
859            .with_namespace(&ns)
860            .with_domain(domain_id);
861            if !node_name.is_empty() {
862                status_topic = status_topic.with_node_name(&node_name);
863            }
864
865            let session = self
866                .session_at_mut(session_idx)
867                .ok_or(NodeError::BackendMismatch)?;
868            (
869                session
870                    .create_service(&send_goal_info, qos)
871                    .map_err(NodeError::Transport)?,
872                session
873                    .create_service(&cancel_goal_info, qos)
874                    .map_err(NodeError::Transport)?,
875                session
876                    .create_service(&get_result_info, qos)
877                    .map_err(NodeError::Transport)?,
878                session
879                    .create_publisher(&feedback_topic, QoSProfile::QOS_PROFILE_DEFAULT)
880                    .map_err(NodeError::Transport)?,
881                session
882                    .create_publisher(&status_topic, QoSProfile::QOS_PROFILE_ACTION_STATUS_DEFAULT)
883                    .map_err(NodeError::Transport)?,
884            )
885        };
886
887        let core = ActionServerCore {
888            send_goal_server,
889            cancel_goal_server,
890            get_result_server,
891            feedback_publisher,
892            status_publisher,
893            active_goals: heapless::Vec::new(),
894            completed_results: heapless::Vec::new(),
895            pending_get_results: heapless::Vec::new(),
896            result_slab: [0u8; RESULT_BUF],
897            result_slab_used: 0,
898            goal_buffer: [0u8; GOAL_BUF],
899            feedback_buffer: [0u8; FEEDBACK_BUF],
900            cancel_buffer: [0u8; 256],
901        };
902
903        let offset = self.arena_alloc::<Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>>()?;
904
905        unsafe {
906            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
907            let entry_ptr =
908                arena_ptr.add(offset) as *mut Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>;
909            core::ptr::write(
910                entry_ptr,
911                Entry {
912                    core,
913                    goal_callback,
914                    cancel_callback,
915                    accepted_callback,
916                    context,
917                },
918            );
919        }
920
921        let meta = CallbackMeta {
922            offset,
923            kind: EntryKind::ActionServer,
924            arena_len: super::arena::ArenaLen::UNRECORDED,
925            has_data: always_ready,
926            pre_sample: no_pre_sample,
927            invocation: InvocationMode::Always,
928            try_process: action_server_raw_try_process::<
929                GOAL_BUF,
930                RESULT_BUF,
931                FEEDBACK_BUF,
932                MAX_GOALS,
933            >,
934            drop_fn: drop_entry::<Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>>,
935        };
936        self.emplace_entry(slot, meta, TraceName::Text(spec.action_name));
937        self.apply_node_default_sched(slot, node_id, None);
938
939        Ok(ActionServerRawHandle {
940            entry_index: slot,
941            publish_feedback_fn: as_raw_publish_feedback::<
942                GOAL_BUF,
943                RESULT_BUF,
944                FEEDBACK_BUF,
945                MAX_GOALS,
946            >,
947            complete_goal_fn: as_raw_complete_goal::<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
948            set_goal_status_fn: as_raw_set_goal_status::<
949                GOAL_BUF,
950                RESULT_BUF,
951                FEEDBACK_BUF,
952                MAX_GOALS,
953            >,
954            active_goal_count_fn: as_raw_active_goal_count::<
955                GOAL_BUF,
956                RESULT_BUF,
957                FEEDBACK_BUF,
958                MAX_GOALS,
959            >,
960            goal_exists_fn: as_raw_goal_exists::<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
961            expire_goals_fn: as_raw_expire_goals::<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>,
962            for_each_active_goal_fn: as_raw_for_each_active_goal::<
963                GOAL_BUF,
964                RESULT_BUF,
965                FEEDBACK_BUF,
966                MAX_GOALS,
967            >,
968        })
969    }
970}
971
972// ============================================================================
973// Detaching a raw action entry from the storage that owns it — issue 1496
974// ============================================================================
975
976/// The `goal_callback` a DETACHED raw action server entry carries.
977///
978/// Issue 1496 — a DETACHED entry is not a released one: detaching leaves the
979/// action server's five RMW entities advertised after the object that
980/// registered them is gone, so a `send_goal` query still reaches its entry.
981/// (`release_action_server_raw`, resolution 2, is what removes them.)
982/// What must not survive is the DISPATCH: the registered `context` is the
983/// address of the owner's storage (for the C++ tier, the `storage_` member of
984/// a destroyed `rclcpp_action::Server<A>`), so calling the real trampoline
985/// after destruction reads freed memory.
986///
987/// This stub ignores `context` entirely and rejects. A late goal therefore
988/// gets a well-formed REJECT reply instead of a dispatch into whatever now
989/// occupies the owner's storage.
990unsafe extern "C" fn detached_goal_callback(
991    _goal_id: *const nros_core::GoalUuid,
992    _goal_data: *const u8,
993    _goal_len: usize,
994    _context: *mut core::ffi::c_void,
995) -> nros_core::GoalResponse {
996    nros_core::GoalResponse::Reject
997}
998
999/// The `cancel_callback` a DETACHED raw action server entry carries.
1000///
1001/// ACCEPTS, where [`detached_goal_callback`] rejects, and the asymmetry is
1002/// deliberate: accepting a cancel runs entirely inside the arena's own
1003/// `ActionServerCore` (it moves the goal to `Canceled` and replies), touching
1004/// no user state, so a client holding a goal accepted before the server was
1005/// destroyed learns that the goal ended instead of waiting for a result nobody
1006/// will ever complete. Rejecting would leave it hanging on a server that
1007/// cannot answer. This is also what the C++ tier already does for a server
1008/// with no cancel callback installed (`cancel_callback_trampoline`).
1009unsafe extern "C" fn detached_cancel_callback(
1010    _goal_id: *const nros_core::GoalUuid,
1011    _status: nros_core::GoalStatusCode,
1012    _context: *mut core::ffi::c_void,
1013) -> nros_core::CancelResponse {
1014    nros_core::CancelResponse::Accept
1015}
1016
1017impl<'s> Executor<'s> {
1018    /// Detach a raw action SERVER's arena entry from its owner's storage —
1019    /// issue 1496.
1020    ///
1021    /// This is not a release and does not pretend to be one. The entry keeps
1022    /// its slot, its arena bytes and its five RMW entities for the executor's
1023    /// lifetime: detaching is not releasing. Resolution 2 of issue 1496 —
1024    /// [`release_action_server_raw`](Self::release_action_server_raw), over the
1025    /// arena's free list — is what gives those back; this keeps them. What it
1026    /// does is cut the one edge that outlives the owner: the registered
1027    /// `context`, and the two callbacks that dereference it.
1028    ///
1029    /// After this call the entry answers a late `send_goal` with a REJECT and a
1030    /// late `cancel_goal` with an ACCEPT (`detached_goal_callback` /
1031    /// `detached_cancel_callback`), and the post-accept hook is cleared.
1032    /// Nothing reads `context` again, so the owner's storage may die.
1033    ///
1034    /// Returns `false` when `entry_index` holds no live entry or the entry is
1035    /// not an action server; the caller should treat that as "nothing to
1036    /// detach", which is the normal state for a server that was created and
1037    /// never registered.
1038    ///
1039    /// # Safety
1040    /// `entry_index` must be the index of an entry registered by
1041    /// [`register_action_server_raw_sized`](Executor::register_action_server_raw_sized)
1042    /// with **these same const parameters**. The callbacks and the context sit
1043    /// behind `ActionServerCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>`
1044    /// in the entry, so their offsets are a function of those four numbers and
1045    /// a mismatch writes into the core's buffers instead. The `EntryKind`
1046    /// check below catches a wrong KIND, never wrong sizes — the same contract
1047    /// [`action_client_core_mut`](Executor::action_client_core_mut) and
1048    /// [`service_client_entry_mut`](Executor::service_client_entry_mut) carry.
1049    pub(crate) unsafe fn detach_action_server_raw_sized<
1050        const GOAL_BUF: usize,
1051        const RESULT_BUF: usize,
1052        const FEEDBACK_BUF: usize,
1053        const MAX_GOALS: usize,
1054    >(
1055        &mut self,
1056        entry_index: usize,
1057    ) -> bool {
1058        let Some(meta) = self.entries.get(entry_index).and_then(|e| e.as_ref()) else {
1059            return false;
1060        };
1061        if !matches!(meta.kind, EntryKind::ActionServer) {
1062            return false;
1063        }
1064        let offset = meta.offset;
1065        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
1066        unsafe {
1067            let entry = &mut *(arena_ptr.add(offset)
1068                as *mut ActionServerRawArenaEntry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF, MAX_GOALS>);
1069            entry.goal_callback = detached_goal_callback;
1070            entry.cancel_callback = detached_cancel_callback;
1071            entry.accepted_callback = None;
1072            entry.context = core::ptr::null_mut();
1073        }
1074        true
1075    }
1076
1077    /// [`detach_action_server_raw_sized`](Executor::detach_action_server_raw_sized)
1078    /// for an entry registered through the default-sized
1079    /// [`register_action_server_raw`](Executor::register_action_server_raw).
1080    ///
1081    /// Takes the handle rather than a bare index so the caller cannot name a
1082    /// subscription slot by accident: an [`ActionServerRawHandle`] is only ever
1083    /// produced by the raw registration path.
1084    ///
1085    /// # Safety
1086    /// `handle` must have come from `register_action_server_raw` — the
1087    /// DEFAULT-sized entry point. A handle from
1088    /// `register_action_server_raw_sized::<…>` with other const parameters must
1089    /// be detached through the sized call with the same ones.
1090    pub(crate) unsafe fn detach_action_server_raw(
1091        &mut self,
1092        handle: &ActionServerRawHandle,
1093    ) -> bool {
1094        unsafe {
1095            self.detach_action_server_raw_sized::<
1096                { crate::config::DEFAULT_RX_BUF_SIZE },
1097                { crate::config::DEFAULT_RX_BUF_SIZE },
1098                { crate::config::DEFAULT_RX_BUF_SIZE },
1099                4,
1100            >(handle.entry_index)
1101        }
1102    }
1103
1104    /// Re-point a default-sized raw action SERVER entry's callback `context`
1105    /// at `context` — issue 2015, the server half of
1106    /// [`retarget_action_client_raw`](Executor::retarget_action_client_raw).
1107    ///
1108    /// The C++ `rclcpp_action::Server<A>` registers its storage address as the
1109    /// entry's context. Moving the object (into an `rclcpp::Owned`, which is
1110    /// what `rclcpp_action::create_server` returns) relocated the storage and
1111    /// left the entry naming the moved-from bytes, so the first goal after the
1112    /// move was dispatched through dead memory and every later one rejected.
1113    ///
1114    /// # Safety
1115    /// As [`detach_action_server_raw`](Executor::detach_action_server_raw):
1116    /// `handle` must come from the DEFAULT-sized `register_action_server_raw`.
1117    /// `context` must be valid for as long as the entry may dispatch.
1118    pub(crate) unsafe fn retarget_action_server_raw(
1119        &mut self,
1120        handle: &ActionServerRawHandle,
1121        context: *mut core::ffi::c_void,
1122    ) -> bool {
1123        let Some(meta) = self
1124            .entries
1125            .get(handle.entry_index)
1126            .and_then(|e| e.as_ref())
1127        else {
1128            return false;
1129        };
1130        if !matches!(meta.kind, EntryKind::ActionServer) {
1131            return false;
1132        }
1133        let offset = meta.offset;
1134        unsafe {
1135            let entry = &mut *((self.arena.as_mut_ptr() as *mut u8).add(offset)
1136                as *mut ActionServerRawArenaEntry<
1137                    { crate::config::DEFAULT_RX_BUF_SIZE },
1138                    { crate::config::DEFAULT_RX_BUF_SIZE },
1139                    { crate::config::DEFAULT_RX_BUF_SIZE },
1140                    4,
1141                >);
1142            entry.context = context;
1143        }
1144        true
1145    }
1146
1147    /// Detach a raw action CLIENT's arena entry from its owner's storage —
1148    /// issue 1496, the client half.
1149    ///
1150    /// Same shape as
1151    /// [`detach_action_server_raw`](Executor::detach_action_server_raw) and one
1152    /// step simpler: the client entry's three callbacks are already
1153    /// `Option`s that `action_client_raw_try_process` guards with
1154    /// `if let Some(cb)`, so clearing them is enough to make a late reply,
1155    /// feedback sample or result a no-op rather than a call through a dead
1156    /// `context`.
1157    ///
1158    /// # Safety
1159    /// `entry_index` must be the index of an entry registered by
1160    /// [`register_action_client_raw`](Executor::register_action_client_raw) —
1161    /// the DEFAULT-sized entry point, which is the only one the C and C++ tiers
1162    /// use and the same assumption
1163    /// [`action_client_core_mut`](Executor::action_client_core_mut) makes on
1164    /// every poll.
1165    pub(crate) unsafe fn detach_action_client_raw(&mut self, entry_index: usize) -> bool {
1166        let Some(meta) = self.entries.get(entry_index).and_then(|e| e.as_ref()) else {
1167            return false;
1168        };
1169        if !matches!(meta.kind, EntryKind::ActionClient) {
1170            return false;
1171        }
1172        let offset = meta.offset;
1173        let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
1174        unsafe {
1175            let entry = &mut *(arena_ptr.add(offset)
1176                as *mut ActionClientRawArenaEntry<
1177                    { crate::config::DEFAULT_RX_BUF_SIZE },
1178                    { crate::config::DEFAULT_RX_BUF_SIZE },
1179                    { crate::config::DEFAULT_RX_BUF_SIZE },
1180                >);
1181            entry.goal_response_callback = None;
1182            entry.feedback_callback = None;
1183            entry.result_callback = None;
1184            entry.context = core::ptr::null_mut();
1185        }
1186        true
1187    }
1188
1189    /// RE-POINT a raw action client entry's callback `context` — issue 1496.
1190    ///
1191    /// The C++ client registers its own storage address as the entry's
1192    /// context; a move relocates that storage, so the entry must follow it or a
1193    /// moved-from temporary leaves it naming dead bytes. `false`, touching
1194    /// nothing, when `entry_index` is not a live action client.
1195    ///
1196    /// # Safety
1197    /// The entry must have been registered through the default-sized
1198    /// [`register_action_client_raw`](Executor::register_action_client_raw),
1199    /// and `context` must be valid for as long as the entry may dispatch.
1200    pub(crate) unsafe fn retarget_action_client_raw(
1201        &mut self,
1202        entry_index: usize,
1203        context: *mut core::ffi::c_void,
1204    ) -> bool {
1205        let Some(meta) = self.entries.get(entry_index).and_then(|e| e.as_ref()) else {
1206            return false;
1207        };
1208        if !matches!(meta.kind, EntryKind::ActionClient) {
1209            return false;
1210        }
1211        let offset = meta.offset;
1212        unsafe {
1213            let entry = &mut *((self.arena.as_mut_ptr() as *mut u8).add(offset)
1214                as *mut ActionClientRawArenaEntry<
1215                    { crate::config::DEFAULT_RX_BUF_SIZE },
1216                    { crate::config::DEFAULT_RX_BUF_SIZE },
1217                    { crate::config::DEFAULT_RX_BUF_SIZE },
1218                >);
1219            entry.context = context;
1220        }
1221        true
1222    }
1223
1224    /// RELEASE a raw action server — issue 1496's resolution 2.
1225    ///
1226    /// Where [`detach_action_server_raw_sized`](Self::detach_action_server_raw_sized)
1227    /// only cut the entry's edge to its owner, this ends the entry: it is
1228    /// dropped in place, so its three service servers and two publishers are
1229    /// destroyed and the action leaves the graph; its callback slot is freed
1230    /// (`NROS_EXECUTOR_MAX_CBS`), and its arena bytes go back to the
1231    /// executor's free list, where the next registration that fits reuses
1232    /// them. A program that creates and destroys action servers in a loop
1233    /// therefore runs forever instead of exhausting the slot table and then
1234    /// the arena.
1235    ///
1236    /// `false`, touching nothing, when `entry_index` is not a live action
1237    /// server.
1238    ///
1239    /// The const parameters are no longer load-bearing (issue 1631): the
1240    /// bytes released are the ones the registration RECORDED, so a mismatch
1241    /// cannot release the wrong length. They stay so existing callers compile.
1242    ///
1243    /// # Safety
1244    /// No [`ActionServerRawHandle`] naming the entry may be used afterwards —
1245    /// the slot is handed to the next registration.
1246    pub(crate) unsafe fn release_action_server_raw_sized<
1247        const GOAL_BUF: usize,
1248        const RESULT_BUF: usize,
1249        const FEEDBACK_BUF: usize,
1250        const MAX_GOALS: usize,
1251    >(
1252        &mut self,
1253        entry_index: usize,
1254    ) -> bool {
1255        unsafe { self.release_entry(entry_index, EntryKind::ActionServer) }
1256    }
1257
1258    /// [`release_action_server_raw_sized`](Self::release_action_server_raw_sized)
1259    /// for an entry registered through the default-sized
1260    /// [`register_action_server_raw`](Executor::register_action_server_raw).
1261    ///
1262    /// # Safety
1263    /// As the sized form; `handle` must not be used afterwards.
1264    pub(crate) unsafe fn release_action_server_raw(
1265        &mut self,
1266        handle: &ActionServerRawHandle,
1267    ) -> bool {
1268        unsafe {
1269            self.release_action_server_raw_sized::<
1270                { crate::config::DEFAULT_RX_BUF_SIZE },
1271                { crate::config::DEFAULT_RX_BUF_SIZE },
1272                { crate::config::DEFAULT_RX_BUF_SIZE },
1273                4,
1274            >(handle.entry_index)
1275        }
1276    }
1277
1278    /// RELEASE a raw action client registered through the default-sized
1279    /// [`register_action_client_raw`](Executor::register_action_client_raw) —
1280    /// issue 1496. Drops the entry (its three service clients and feedback
1281    /// subscription are destroyed), frees its callback slot and returns its
1282    /// arena bytes for reuse.
1283    ///
1284    /// # Safety
1285    /// As [`release_action_server_raw_sized`](Self::release_action_server_raw_sized),
1286    /// for the default-sized client entry.
1287    pub(crate) unsafe fn release_action_client_raw(&mut self, entry_index: usize) -> bool {
1288        unsafe {
1289            self.release_action_client_raw_sized::<
1290                { crate::config::DEFAULT_RX_BUF_SIZE },
1291                { crate::config::DEFAULT_RX_BUF_SIZE },
1292                { crate::config::DEFAULT_RX_BUF_SIZE },
1293            >(entry_index)
1294        }
1295    }
1296
1297    /// The sized form of [`release_action_client_raw`](Self::release_action_client_raw).
1298    ///
1299    /// As for the server, the const parameters are no longer load-bearing
1300    /// (issue 1631): the released length is the recorded one.
1301    ///
1302    /// # Safety
1303    /// No copy of `entry_index` may be used afterwards.
1304    pub(crate) unsafe fn release_action_client_raw_sized<
1305        const GOAL_BUF: usize,
1306        const RESULT_BUF: usize,
1307        const FEEDBACK_BUF: usize,
1308    >(
1309        &mut self,
1310        entry_index: usize,
1311    ) -> bool {
1312        unsafe { self.release_entry(entry_index, EntryKind::ActionClient) }
1313    }
1314}
1315
1316// ============================================================================
1317// Raw action server handle
1318// ============================================================================
1319
1320/// Handle to a raw (untyped) action server registered in the executor's arena.
1321///
1322/// Returned by [`ExecutorFfi::register_action_server_raw()`](crate::executor::ffi::ExecutorFfi::register_action_server_raw). Provides methods
1323/// to interact with the server using raw CDR bytes.
1324#[repr(C)]
1325#[allow(clippy::type_complexity)]
1326pub struct ActionServerRawHandle {
1327    pub(crate) entry_index: usize,
1328    publish_feedback_fn:
1329        unsafe fn(*mut u8, &nros_core::GoalUuid, *const u8, usize) -> Result<(), NodeError>,
1330    complete_goal_fn: unsafe fn(
1331        *mut u8,
1332        &nros_core::GoalUuid,
1333        nros_core::GoalStatusCode,
1334        *const u8,
1335        usize,
1336    ) -> Result<(), NodeError>,
1337    set_goal_status_fn: unsafe fn(*mut u8, &nros_core::GoalUuid, nros_core::GoalStatusCode),
1338    active_goal_count_fn: unsafe fn(*const u8) -> usize,
1339    goal_exists_fn: unsafe fn(*const u8, &nros_core::GoalUuid) -> bool,
1340    expire_goals_fn: unsafe fn(*mut u8) -> usize,
1341    for_each_active_goal_fn: unsafe fn(*const u8, &mut dyn FnMut(&RawActiveGoal)),
1342}
1343
1344impl Clone for ActionServerRawHandle {
1345    fn clone(&self) -> Self {
1346        *self
1347    }
1348}
1349
1350impl Copy for ActionServerRawHandle {}
1351
1352/// Sentinel value indicating an `ActionServerRawHandle` is not bound to an
1353/// arena entry yet. Used by Phase 87.5 to replace `Option<...>` with a
1354/// `#[repr(C)]`-compatible inline field.
1355///
1356/// Function pointers are populated with `unreachable_*` stubs that panic
1357/// if anyone is reckless enough to dispatch through an unbound handle —
1358/// callers must check `entry_index == INVALID_ENTRY_INDEX` first.
1359pub const INVALID_ENTRY_INDEX: usize = usize::MAX;
1360
1361impl ActionServerRawHandle {
1362    /// Construct a sentinel handle representing "not registered yet".
1363    ///
1364    /// All function pointers are unreachable stubs; only valid use is
1365    /// to populate `#[repr(C)]` storage that is later overwritten by a
1366    /// real handle (or queried via `is_invalid()` to skip operations).
1367    pub(crate) const fn invalid() -> Self {
1368        unsafe fn unreachable_publish_feedback(
1369            _: *mut u8,
1370            _: &nros_core::GoalUuid,
1371            _: *const u8,
1372            _: usize,
1373        ) -> Result<(), NodeError> {
1374            unreachable!("ActionServerRawHandle::publish_feedback called on invalid handle")
1375        }
1376        unsafe fn unreachable_complete_goal(
1377            _: *mut u8,
1378            _: &nros_core::GoalUuid,
1379            _: nros_core::GoalStatusCode,
1380            _: *const u8,
1381            _: usize,
1382        ) -> Result<(), NodeError> {
1383            unreachable!("ActionServerRawHandle::complete_goal called on invalid handle")
1384        }
1385        unsafe fn unreachable_set_goal_status(
1386            _: *mut u8,
1387            _: &nros_core::GoalUuid,
1388            _: nros_core::GoalStatusCode,
1389        ) {
1390            unreachable!("ActionServerRawHandle::set_goal_status called on invalid handle")
1391        }
1392        unsafe fn unreachable_active_goal_count(_: *const u8) -> usize {
1393            unreachable!("ActionServerRawHandle::active_goal_count called on invalid handle")
1394        }
1395        unsafe fn unreachable_goal_exists(_: *const u8, _: &nros_core::GoalUuid) -> bool {
1396            unreachable!("ActionServerRawHandle::goal_exists called on invalid handle")
1397        }
1398        unsafe fn unreachable_expire_goals(_: *mut u8) -> usize {
1399            unreachable!("ActionServerRawHandle::expire_goals called on invalid handle")
1400        }
1401        unsafe fn unreachable_for_each_active_goal(
1402            _: *const u8,
1403            _: &mut dyn FnMut(&RawActiveGoal),
1404        ) {
1405            unreachable!("ActionServerRawHandle::for_each_active_goal called on invalid handle")
1406        }
1407        Self {
1408            entry_index: INVALID_ENTRY_INDEX,
1409            publish_feedback_fn: unreachable_publish_feedback,
1410            complete_goal_fn: unreachable_complete_goal,
1411            set_goal_status_fn: unreachable_set_goal_status,
1412            active_goal_count_fn: unreachable_active_goal_count,
1413            goal_exists_fn: unreachable_goal_exists,
1414            expire_goals_fn: unreachable_expire_goals,
1415            for_each_active_goal_fn: unreachable_for_each_active_goal,
1416        }
1417    }
1418
1419    /// `true` if this handle is the sentinel returned by `Self::invalid()`.
1420    pub(crate) const fn is_invalid(&self) -> bool {
1421        self.entry_index == INVALID_ENTRY_INDEX
1422    }
1423}
1424
1425impl Default for ActionServerRawHandle {
1426    fn default() -> Self {
1427        Self::invalid()
1428    }
1429}
1430
1431impl ActionServerRawHandle {
1432    /// Get the [`HandleId`] for this action server.
1433    pub(crate) fn handle_id(&self) -> HandleId {
1434        HandleId::for_owned_slot(self.entry_index)
1435    }
1436
1437    /// Publish feedback with raw CDR bytes (untyped variant).
1438    ///
1439    /// Used by the C API when feedback is already serialised.
1440    pub(crate) fn publish_feedback_raw(
1441        &self,
1442        executor: &mut Executor,
1443        goal_id: &nros_core::GoalUuid,
1444        feedback_data: &[u8],
1445    ) -> Result<(), NodeError> {
1446        let meta = executor.entries[self.entry_index]
1447            .as_ref()
1448            .ok_or(NodeError::BufferTooSmall)?;
1449        let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
1450        unsafe {
1451            let data_ptr = arena_ptr.add(meta.offset);
1452            (self.publish_feedback_fn)(
1453                data_ptr,
1454                goal_id,
1455                feedback_data.as_ptr(),
1456                feedback_data.len(),
1457            )
1458        }
1459    }
1460
1461    /// Complete a goal with raw CDR result bytes (untyped variant).
1462    ///
1463    /// Moves the goal from the active set to the completed-results slab.
1464    ///
1465    /// # Errors
1466    ///
1467    /// `NodeError::BufferTooSmall` when the handle slot has been removed from
1468    /// the executor, or when `result_data` exceeds the server's `RESULT_BUF`
1469    /// and so cannot be retained for a later `get_result` (issue 0796 — this
1470    /// used to return `()` and swallow both).
1471    pub(crate) fn complete_goal_raw(
1472        &self,
1473        executor: &mut Executor,
1474        goal_id: &nros_core::GoalUuid,
1475        status: nros_core::GoalStatusCode,
1476        result_data: &[u8],
1477    ) -> Result<(), NodeError> {
1478        let meta = executor.entries[self.entry_index]
1479            .as_ref()
1480            .ok_or(NodeError::BufferTooSmall)?;
1481        let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
1482        unsafe {
1483            let data_ptr = arena_ptr.add(meta.offset);
1484            (self.complete_goal_fn)(
1485                data_ptr,
1486                goal_id,
1487                status,
1488                result_data.as_ptr(),
1489                result_data.len(),
1490            )
1491        }
1492    }
1493
1494    /// Update a goal's status without completing it.
1495    ///
1496    /// Use this to transition a goal to `Executing` or `Canceling`
1497    /// while it is still active. To finish a goal, use [`complete_goal_raw`](Self::complete_goal_raw).
1498    pub(crate) fn set_goal_status(
1499        &self,
1500        executor: &mut Executor,
1501        goal_id: &nros_core::GoalUuid,
1502        status: nros_core::GoalStatusCode,
1503    ) {
1504        if let Some(meta) = executor.entries[self.entry_index].as_ref() {
1505            let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
1506            unsafe {
1507                let data_ptr = arena_ptr.add(meta.offset);
1508                (self.set_goal_status_fn)(data_ptr, goal_id, status);
1509            }
1510        }
1511    }
1512
1513    /// Get the number of currently active goals.
1514    ///
1515    /// Returns 0 if the action server handle has been removed from the executor.
1516    pub(crate) fn active_goal_count(&self, executor: &Executor) -> usize {
1517        match executor.entries[self.entry_index].as_ref() {
1518            Some(meta) => {
1519                let arena_ptr = executor.arena.as_ptr() as *const u8;
1520                unsafe {
1521                    let data_ptr = arena_ptr.add(meta.offset);
1522                    (self.active_goal_count_fn)(data_ptr)
1523                }
1524            }
1525            None => 0,
1526        }
1527    }
1528
1529    /// Is `goal_id` a goal this server still knows about? — phase-417 W4.b.
1530    ///
1531    /// The raw twin of [`ActionServerHandle::goal_exists`], and what the C
1532    /// surface's `rclc_action_server_goal_exists` and the C++
1533    /// `Server<A>::goal_exists` both resolve to. Active OR completed-with-a-
1534    /// retained-result; `false` once the slot is gone from the executor.
1535    pub(crate) fn goal_exists(&self, executor: &Executor, goal_id: &nros_core::GoalUuid) -> bool {
1536        match executor.entries[self.entry_index].as_ref() {
1537            Some(meta) => {
1538                let arena_ptr = executor.arena.as_ptr() as *const u8;
1539                unsafe {
1540                    let data_ptr = arena_ptr.add(meta.offset);
1541                    (self.goal_exists_fn)(data_ptr, goal_id)
1542                }
1543            }
1544            None => false,
1545        }
1546    }
1547
1548    /// Eagerly reclaim delivered results — the raw twin of
1549    /// [`ActionServerHandle::expire_goals`], and what C's
1550    /// `rclc_action_expire_goals` resolves to. phase-417 W4.b.
1551    pub(crate) fn expire_goals(&self, executor: &mut Executor) -> usize {
1552        match executor.entries[self.entry_index].as_ref() {
1553            Some(meta) => {
1554                let arena_ptr = executor.arena.as_mut_ptr() as *mut u8;
1555                unsafe {
1556                    let data_ptr = arena_ptr.add(meta.offset);
1557                    (self.expire_goals_fn)(data_ptr)
1558                }
1559            }
1560            None => 0,
1561        }
1562    }
1563
1564    /// Iterate over all currently active goals (raw/untyped variant).
1565    ///
1566    /// Calls `f` for each goal that has been accepted but not yet completed.
1567    pub(crate) fn for_each_active_goal(
1568        &self,
1569        executor: &Executor,
1570        mut f: impl FnMut(&RawActiveGoal),
1571    ) {
1572        if let Some(meta) = executor.entries[self.entry_index].as_ref() {
1573            let arena_ptr = executor.arena.as_ptr() as *const u8;
1574            unsafe {
1575                let data_ptr = arena_ptr.add(meta.offset);
1576                (self.for_each_active_goal_fn)(data_ptr, &mut f);
1577            }
1578        }
1579    }
1580
1581    /// Look up the status of a single active goal by UUID.
1582    ///
1583    /// Returns `Some(status)` while the goal is still in the arena's
1584    /// `active_goals` vector. Returns `None` once the goal has been
1585    /// retired (completed + result delivered, or cancelled + acknowledged).
1586    ///
1587    /// This is the authoritative source of goal status — the C/C++ FFI
1588    /// layers call this from `rclc_action_get_goal_status` rather than
1589    /// reading a cached field on their own handle structs.
1590    pub(crate) fn goal_status(
1591        &self,
1592        executor: &Executor,
1593        goal_id: &nros_core::GoalUuid,
1594    ) -> Option<nros_core::GoalStatusCode> {
1595        let mut found = None;
1596        self.for_each_active_goal(executor, |g| {
1597            if g.goal_id.uuid == goal_id.uuid && found.is_none() {
1598                found = Some(g.status);
1599            }
1600        });
1601        found
1602    }
1603}
1604
1605// ============================================================================
1606// Action client registration
1607// ============================================================================
1608
1609impl<'s> Executor<'s> {
1610    /// Register a raw action client with the executor.
1611    ///
1612    /// Creates service clients for send_goal, cancel_goal, get_result, and a
1613    /// feedback subscriber. The executor polls these during `spin_once` and
1614    /// invokes the provided callbacks when responses/feedback arrive.
1615    ///
1616    /// # Arguments
1617    /// * `action_name` — action name (e.g., "/fibonacci")
1618    /// * `type_name` — action type (e.g., "example_interfaces::action::dds_::Fibonacci_")
1619    /// * `type_hash` — type hash (e.g., "TypeHashNotSupported")
1620    /// * `goal_response_callback` — called when goal is accepted/rejected
1621    /// * `feedback_callback` — called when feedback is received
1622    /// * `result_callback` — called when result is received
1623    /// * `context` — opaque pointer passed to all callbacks
1624    #[doc(hidden)]
1625    #[allow(clippy::too_many_arguments)]
1626    pub(crate) fn register_action_client_raw(
1627        &mut self,
1628        spec: RawActionClientSpec<'_>,
1629    ) -> Result<ActionClientRawHandle, NodeError> {
1630        self.register_action_client_raw_sized::<
1631            { crate::config::DEFAULT_RX_BUF_SIZE },
1632            { crate::config::DEFAULT_RX_BUF_SIZE },
1633            { crate::config::DEFAULT_RX_BUF_SIZE },
1634        >(spec)
1635    }
1636
1637    /// Register a raw action client with explicit buffer sizes.
1638    ///
1639    /// `spec.node_id` selects the target: `None` registers on the
1640    /// executor's own node, `Some(id)` routes the client's 4 underlying
1641    /// handles through the named Node's session (Phase 104.C.3.3.a).
1642    #[doc(hidden)]
1643    pub fn register_action_client_raw_sized<
1644        const GOAL_BUF: usize,
1645        const RESULT_BUF: usize,
1646        const FEEDBACK_BUF: usize,
1647    >(
1648        &mut self,
1649        spec: RawActionClientSpec<'_>,
1650    ) -> Result<ActionClientRawHandle, NodeError> {
1651        let RawActionClientSpec {
1652            node_id,
1653            action_name,
1654            type_name,
1655            type_hash,
1656            hashes,
1657            goal_response_callback,
1658            feedback_callback,
1659            result_callback,
1660            cancel_response_callback,
1661            context,
1662        } = spec;
1663
1664        type Entry<const GB: usize, const RB: usize, const FB: usize> =
1665            ActionClientRawArenaEntry<GB, RB, FB>;
1666
1667        let slot = self.next_entry_slot()?;
1668
1669        let action_info = ActionInfo::new(action_name, type_name, type_hash);
1670        // Issue 0656 — capture the domain BEFORE the session borrow below, for
1671        // the same reason `node_name`/`ns` are cloned here: the `&mut session`
1672        // in the create scope would otherwise conflict with `&self`.
1673        let domain_id = self.domain_id;
1674        let (node_name, ns, session_idx) = match node_id {
1675            Some(id) => {
1676                let r = self
1677                    .nodes
1678                    .get(id.index())
1679                    .ok_or(NodeError::InvalidSchedContextBinding)?;
1680                (r.name.clone(), r.namespace.clone(), r.session_idx)
1681            }
1682            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
1683        };
1684
1685        let (send_goal_client, cancel_goal_client, get_result_client, feedback_sub) = {
1686            // phase-338 W3 — per-channel types, matching the raw SERVER path.
1687            // Both raw sides used the bare action type, so nano-ros talked to
1688            // itself but was invisible to any `rcl_action` peer; fixing only one
1689            // side would have broken the self-consistent pairs instead.
1690            let send_goal_type: heapless::String<256> =
1691                super::action_core::action_channel_type(type_name, "SendGoal");
1692            let get_result_type: heapless::String<256> =
1693                super::action_core::action_channel_type(type_name, "GetResult");
1694            let feedback_type: heapless::String<256> =
1695                super::action_core::action_channel_type(type_name, "FeedbackMessage");
1696
1697            let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
1698            let mut send_goal_info =
1699                ServiceInfo::new(&send_goal_keyexpr, &send_goal_type, hashes.send_goal)
1700                    .with_namespace(&ns)
1701                    .with_domain(domain_id);
1702            if !node_name.is_empty() {
1703                send_goal_info = send_goal_info.with_node_name(&node_name);
1704            }
1705
1706            let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
1707            let mut cancel_goal_info = ServiceInfo::new(
1708                &cancel_goal_keyexpr,
1709                "action_msgs::srv::dds_::CancelGoal_",
1710                hashes.cancel_goal,
1711            )
1712            .with_namespace(&ns)
1713            .with_domain(domain_id);
1714            if !node_name.is_empty() {
1715                cancel_goal_info = cancel_goal_info.with_node_name(&node_name);
1716            }
1717
1718            let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
1719            let mut get_result_info =
1720                ServiceInfo::new(&get_result_keyexpr, &get_result_type, hashes.get_result)
1721                    .with_namespace(&ns)
1722                    .with_domain(domain_id);
1723            if !node_name.is_empty() {
1724                get_result_info = get_result_info.with_node_name(&node_name);
1725            }
1726
1727            let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
1728            let mut feedback_topic =
1729                TopicInfo::new(&feedback_keyexpr, &feedback_type, hashes.feedback)
1730                    .with_namespace(&ns)
1731                    .with_domain(domain_id);
1732            if !node_name.is_empty() {
1733                feedback_topic = feedback_topic.with_node_name(&node_name);
1734            }
1735
1736            let session = self
1737                .session_at_mut(session_idx)
1738                .ok_or(NodeError::BackendMismatch)?;
1739            // issue 0870 — these were `map_err(|_| NodeError::ActionCreationFailed)`,
1740            // discarding a `TransportError` the caller could have used. An action
1741            // client declares four entities back to back, so "one of them failed"
1742            // is the least useful thing this seam can say. `NodeError` already
1743            // carries `Transport(TransportError)` — nothing needed adding, the
1744            // error simply was not passed on. Swept across all 17 session
1745            // `create_*` sites here. The two `register_protocol_types` sites keep
1746            // `ActionCreationFailed`: their `map_err(|()| …)` has no payload, so
1747            // there the variant IS the whole truth.
1748            (
1749                session
1750                    .create_client(&send_goal_info, QoSProfile::services_default())
1751                    .map_err(|e| {
1752                        nros_log::log_error!(
1753                            nros_log::get_logger("nros_node"),
1754                            "action client: send_goal client failed: {:?}",
1755                            e
1756                        );
1757                        NodeError::Transport(e)
1758                    })?,
1759                session
1760                    .create_client(&cancel_goal_info, QoSProfile::services_default())
1761                    .map_err(|e| {
1762                        nros_log::log_error!(
1763                            nros_log::get_logger("nros_node"),
1764                            "action client: cancel_goal client failed: {:?}",
1765                            e
1766                        );
1767                        NodeError::Transport(e)
1768                    })?,
1769                session
1770                    .create_client(&get_result_info, QoSProfile::services_default())
1771                    .map_err(|e| {
1772                        nros_log::log_error!(
1773                            nros_log::get_logger("nros_node"),
1774                            "action client: get_result client failed: {:?}",
1775                            e
1776                        );
1777                        NodeError::Transport(e)
1778                    })?,
1779                session
1780                    .create_subscription(&feedback_topic, QoSProfile::BEST_EFFORT)
1781                    .map_err(|e| {
1782                        nros_log::log_error!(
1783                            nros_log::get_logger("nros_node"),
1784                            "action client: feedback subscription failed: {:?}",
1785                            e
1786                        );
1787                        NodeError::Transport(e)
1788                    })?,
1789            )
1790        };
1791
1792        let core = ActionClientCore::new(
1793            send_goal_client,
1794            cancel_goal_client,
1795            get_result_client,
1796            feedback_sub,
1797        );
1798
1799        let offset = self.arena_alloc::<Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>>()?;
1800
1801        unsafe {
1802            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
1803            let entry_ptr = arena_ptr.add(offset) as *mut Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>;
1804            core::ptr::write(
1805                entry_ptr,
1806                Entry {
1807                    core,
1808                    goal_response_callback,
1809                    feedback_callback,
1810                    result_callback,
1811                    cancel_response_callback,
1812                    context,
1813                },
1814            );
1815        }
1816
1817        let meta = CallbackMeta {
1818            offset,
1819            kind: EntryKind::ActionClient,
1820            arena_len: super::arena::ArenaLen::UNRECORDED,
1821            has_data: always_ready,
1822            pre_sample: no_pre_sample,
1823            invocation: InvocationMode::Always,
1824            try_process: action_client_raw_try_process::<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
1825            drop_fn: drop_entry::<Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>>,
1826        };
1827        self.emplace_entry(slot, meta, TraceName::Text(spec.action_name));
1828        self.apply_node_default_sched(slot, node_id, None);
1829
1830        Ok(ActionClientRawHandle { entry_index: slot })
1831    }
1832
1833    /// RFC-0041 / Phase 239.2 — register a **typed callback** action client.
1834    /// Goal-response / feedback / result are eager-drained at `spin_once` and
1835    /// dispatched as deserialized `A::Feedback` / `A::Result` to the typed
1836    /// closures. Returns the scheduling [`HandleId`] and a `*mut` to the arena
1837    /// entry's core (used to build the typed
1838    /// [`ActionClientCallback`](super::handles::ActionClientCallback)).
1839    #[allow(clippy::too_many_arguments, clippy::type_complexity)]
1840    pub(crate) fn register_action_client_callback<
1841        A,
1842        GRespF,
1843        FbF,
1844        ResF,
1845        const GOAL_BUF: usize,
1846        const RESULT_BUF: usize,
1847        const FEEDBACK_BUF: usize,
1848    >(
1849        &mut self,
1850        node_id: Option<super::node_record::NodeId>,
1851        action_name: &str,
1852        type_name: &str,
1853        type_hash: &str,
1854        feedback_depth: u16,
1855        on_goal_response: GRespF,
1856        on_feedback: FbF,
1857        on_result: ResF,
1858    ) -> Result<
1859        (
1860            HandleId,
1861            *mut ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
1862        ),
1863        NodeError,
1864    >
1865    where
1866        A: nros_core::RosAction + 'static,
1867        GRespF: FnMut(&nros_core::GoalUuid, bool) + 'static,
1868        FbF: FnMut(&nros_core::GoalUuid, &A::Feedback) + 'static,
1869        ResF: FnMut(&nros_core::GoalUuid, nros_core::GoalStatusCode, &A::Result) + 'static,
1870    {
1871        type Entry<A, G, Fb, R, const GB: usize, const RB: usize, const FB: usize> =
1872            ActionClientCallbackEntry<A, G, Fb, R, GB, RB, FB>;
1873
1874        let slot = self.next_entry_slot()?;
1875        let action_info = ActionInfo::new(action_name, type_name, type_hash);
1876        // Issue 1913 — each endpoint on its own type's hash.
1877        let hashes = ActionEndpointHashes::of::<A>();
1878        // Phase 244 E3 (RFC-0044) — register the `action_msgs` protocol types
1879        // (CancelGoal_{Request,Response}, GoalStatusArray) the client's cancel
1880        // service + status subscription serialize, before creating those
1881        // entities. Generated `impl RosAction` overrides this (default no-op);
1882        // replaces the example's hand-rolled `#[cfg(rmw-cyclonedds)]` block.
1883        A::register_protocol_types().map_err(|()| NodeError::ActionCreationFailed)?;
1884        // Issue 0656 — capture the domain BEFORE the session borrow below, for
1885        // the same reason `node_name`/`ns` are cloned here: the `&mut session`
1886        // in the create scope would otherwise conflict with `&self`.
1887        let domain_id = self.domain_id;
1888        let (node_name, ns, session_idx) = match node_id {
1889            Some(id) => {
1890                let r = self
1891                    .nodes
1892                    .get(id.index())
1893                    .ok_or(NodeError::InvalidSchedContextBinding)?;
1894                (r.name.clone(), r.namespace.clone(), r.session_idx)
1895            }
1896            None => (self.node_name.clone(), self.namespace.clone(), 0u8),
1897        };
1898
1899        let (send_goal_client, cancel_goal_client, get_result_client, feedback_sub) = {
1900            // phase-338 W3 — per-channel types, matching the raw SERVER path.
1901            // Both raw sides used the bare action type, so nano-ros talked to
1902            // itself but was invisible to any `rcl_action` peer; fixing only one
1903            // side would have broken the self-consistent pairs instead.
1904            let send_goal_type: heapless::String<256> =
1905                super::action_core::action_channel_type(type_name, "SendGoal");
1906            let get_result_type: heapless::String<256> =
1907                super::action_core::action_channel_type(type_name, "GetResult");
1908            let feedback_type: heapless::String<256> =
1909                super::action_core::action_channel_type(type_name, "FeedbackMessage");
1910
1911            let send_goal_keyexpr: heapless::String<256> = action_info.send_goal_key();
1912            let mut send_goal_info =
1913                ServiceInfo::new(&send_goal_keyexpr, &send_goal_type, hashes.send_goal)
1914                    .with_namespace(&ns)
1915                    .with_domain(domain_id);
1916            if !node_name.is_empty() {
1917                send_goal_info = send_goal_info.with_node_name(&node_name);
1918            }
1919            let cancel_goal_keyexpr: heapless::String<256> = action_info.cancel_goal_key();
1920            let mut cancel_goal_info = ServiceInfo::new(
1921                &cancel_goal_keyexpr,
1922                "action_msgs::srv::dds_::CancelGoal_",
1923                hashes.cancel_goal,
1924            )
1925            .with_namespace(&ns)
1926            .with_domain(domain_id);
1927            if !node_name.is_empty() {
1928                cancel_goal_info = cancel_goal_info.with_node_name(&node_name);
1929            }
1930            let get_result_keyexpr: heapless::String<256> = action_info.get_result_key();
1931            let mut get_result_info =
1932                ServiceInfo::new(&get_result_keyexpr, &get_result_type, hashes.get_result)
1933                    .with_namespace(&ns)
1934                    .with_domain(domain_id);
1935            if !node_name.is_empty() {
1936                get_result_info = get_result_info.with_node_name(&node_name);
1937            }
1938            let feedback_keyexpr: heapless::String<256> = action_info.feedback_key();
1939            let mut feedback_topic =
1940                TopicInfo::new(&feedback_keyexpr, &feedback_type, hashes.feedback)
1941                    .with_namespace(&ns)
1942                    .with_domain(domain_id);
1943            if !node_name.is_empty() {
1944                feedback_topic = feedback_topic.with_node_name(&node_name);
1945            }
1946            let session = self
1947                .session_at_mut(session_idx)
1948                .ok_or(NodeError::BackendMismatch)?;
1949            (
1950                session
1951                    .create_client(&send_goal_info, QoSProfile::services_default())
1952                    .map_err(NodeError::Transport)?,
1953                session
1954                    .create_client(&cancel_goal_info, QoSProfile::services_default())
1955                    .map_err(NodeError::Transport)?,
1956                session
1957                    .create_client(&get_result_info, QoSProfile::services_default())
1958                    .map_err(NodeError::Transport)?,
1959                session
1960                    .create_subscription(&feedback_topic, QoSProfile::BEST_EFFORT)
1961                    .map_err(NodeError::Transport)?,
1962            )
1963        };
1964
1965        let core = ActionClientCore::new(
1966            send_goal_client,
1967            cancel_goal_client,
1968            get_result_client,
1969            feedback_sub,
1970        );
1971
1972        // Phase 239.5 — trailing-allocate the feedback QoS-depth buffer alongside
1973        // the entry (ring for depth > 1, triple for depth ≤ 1), then drain
1974        // `core.feedback_subscriber` into it in the dispatcher.
1975        let (_slot_count, trailing_bytes) =
1976            buffered_region_size(feedback_depth as u32, FEEDBACK_BUF);
1977        let (offset, trailing_offset) = self.arena_alloc_with_trailing::<Entry<
1978            A,
1979            GRespF,
1980            FbF,
1981            ResF,
1982            GOAL_BUF,
1983            RESULT_BUF,
1984            FEEDBACK_BUF,
1985        >>(trailing_bytes)?;
1986        let buf_ptr = unsafe { (self.arena.as_mut_ptr() as *mut u8).add(trailing_offset) };
1987        let feedback_buffer = if feedback_depth <= 1 {
1988            BufferStrategy::Triple(unsafe { TripleBuffer::init(buf_ptr, FEEDBACK_BUF) })
1989        } else {
1990            BufferStrategy::Ring(unsafe {
1991                SpscRing::init(buf_ptr, FEEDBACK_BUF, feedback_depth as usize)
1992            })
1993        };
1994        let core_ptr = unsafe {
1995            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
1996            let entry_ptr = arena_ptr.add(offset)
1997                as *mut Entry<A, GRespF, FbF, ResF, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>;
1998            core::ptr::write(
1999                entry_ptr,
2000                ActionClientCallbackEntry {
2001                    core,
2002                    feedback_buffer,
2003                    on_goal_response,
2004                    on_feedback,
2005                    on_result,
2006                    _phantom: core::marker::PhantomData,
2007                },
2008            );
2009            &mut (*entry_ptr).core as *mut ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>
2010        };
2011
2012        let meta = CallbackMeta {
2013            offset,
2014            kind: EntryKind::ActionClient,
2015            arena_len: super::arena::ArenaLen::UNRECORDED,
2016            has_data: always_ready,
2017            pre_sample: no_pre_sample,
2018            invocation: InvocationMode::Always,
2019            try_process: action_client_callback_try_process::<
2020                A,
2021                GRespF,
2022                FbF,
2023                ResF,
2024                GOAL_BUF,
2025                RESULT_BUF,
2026                FEEDBACK_BUF,
2027            >,
2028            drop_fn: drop_entry::<Entry<A, GRespF, FbF, ResF, GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>>,
2029        };
2030        self.emplace_entry(slot, meta, TraceName::Text(action_name));
2031        self.apply_node_default_sched(slot, node_id, None);
2032        Ok((self.handle_for(slot), core_ptr))
2033    }
2034}
2035
2036impl<'s> Executor<'s> {
2037    /// Register an existing `ActionClientCore` with the executor for async polling.
2038    ///
2039    /// Unlike `register_action_client_raw` (which creates new transport handles),
2040    /// this takes ownership of an existing core. Use this when the core was
2041    /// already created by the C/C++ action client init.
2042    #[doc(hidden)]
2043    pub(crate) fn register_action_client_core<
2044        const GOAL_BUF: usize,
2045        const RESULT_BUF: usize,
2046        const FEEDBACK_BUF: usize,
2047    >(
2048        &mut self,
2049        core: ActionClientCore<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
2050        goal_response_callback: Option<RawGoalResponseCallback>,
2051        feedback_callback: Option<RawFeedbackCallback>,
2052        result_callback: Option<RawResultCallback>,
2053        cancel_response_callback: Option<RawCancelResponseCallback>,
2054        context: *mut core::ffi::c_void,
2055    ) -> Result<ActionClientRawHandle, NodeError> {
2056        type Entry<const GB: usize, const RB: usize, const FB: usize> =
2057            ActionClientRawArenaEntry<GB, RB, FB>;
2058
2059        let slot = self.next_entry_slot()?;
2060        let offset = self.arena_alloc::<Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>>()?;
2061
2062        unsafe {
2063            let arena_ptr = self.arena.as_mut_ptr() as *mut u8;
2064            let entry_ptr = arena_ptr.add(offset) as *mut Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>;
2065            core::ptr::write(
2066                entry_ptr,
2067                Entry {
2068                    core,
2069                    goal_response_callback,
2070                    feedback_callback,
2071                    result_callback,
2072                    cancel_response_callback,
2073                    context,
2074                },
2075            );
2076        }
2077
2078        let meta = CallbackMeta {
2079            offset,
2080            kind: EntryKind::ActionClient,
2081            arena_len: super::arena::ArenaLen::UNRECORDED,
2082            has_data: always_ready,
2083            pre_sample: no_pre_sample,
2084            invocation: InvocationMode::Always,
2085            try_process: action_client_raw_try_process::<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>,
2086            drop_fn: drop_entry::<Entry<GOAL_BUF, RESULT_BUF, FEEDBACK_BUF>>,
2087        };
2088        self.emplace_entry(slot, meta, TraceName::Slot("action_client", slot));
2089
2090        Ok(ActionClientRawHandle { entry_index: slot })
2091    }
2092}
2093
2094/// Handle returned by [`ExecutorFfi::register_action_client_raw()`](crate::executor::ffi::ExecutorFfi::register_action_client_raw).
2095///
2096/// Provides methods to send goals, request results, and cancel goals
2097/// via the executor's non-blocking path.
2098pub struct ActionClientRawHandle {
2099    entry_index: usize,
2100}
2101
2102impl ActionClientRawHandle {
2103    /// Get the entry index for this action client.
2104    pub(crate) fn entry_index(&self) -> usize {
2105        self.entry_index
2106    }
2107}
2108
2109// ============================================================================
2110// phase-417 W4.b — the three terminal verbs
2111// ============================================================================
2112
2113// Gated exactly like `executor::tests`: MockSession is the `ConcreteSession`
2114// only when no real backend is linked, and `from_session` needs `alloc`.
2115#[cfg(all(test, feature = "alloc", not(feature = "rmw-cffi")))]
2116mod terminal_verb_tests {
2117    use super::*;
2118    use core::sync::atomic::{AtomicI8, Ordering};
2119    use nros_core::{CdrReader, CdrWriter, DeserError, Deserialize, SerError, Serialize};
2120    use nros_rmw::TransportError;
2121
2122    use crate::mock::MockSession;
2123
2124    /// The status the last `complete_goal_fn` call was handed. `-1` is "no
2125    /// call yet", which is what makes "the verb never reached `complete_goal`"
2126    /// distinguishable from "it reached it with the wrong status".
2127    static LAST_STATUS: AtomicI8 = AtomicI8::new(-1);
2128
2129    #[derive(Default)]
2130    struct Unit;
2131
2132    impl Serialize for Unit {
2133        fn serialize(&self, _w: &mut CdrWriter) -> Result<(), SerError> {
2134            Ok(())
2135        }
2136    }
2137    impl Deserialize for Unit {
2138        fn deserialize(_r: &mut CdrReader) -> Result<Self, DeserError> {
2139            Ok(Self)
2140        }
2141    }
2142    impl nros_core::RosMessage for Unit {
2143        const TYPE_NAME: &'static str = "test/action/Verbs_Unit";
2144        const TYPE_HASH: &'static str = "test_hash";
2145    }
2146
2147    struct VerbAction;
2148
2149    impl RosAction for VerbAction {
2150        type Goal = Unit;
2151        type Result = Unit;
2152        type Feedback = Unit;
2153        type SendGoalRequest = Unit;
2154        type SendGoalResponse = Unit;
2155        type GetResultRequest = Unit;
2156        type GetResultResponse = Unit;
2157        type FeedbackMessage = Unit;
2158        const ACTION_NAME: &'static str = "test/action/dds_/Verbs_";
2159        const ACTION_HASH: &'static str = "test_hash";
2160    }
2161
2162    unsafe fn record_status(
2163        _data: *mut u8,
2164        _goal_id: &nros_core::GoalUuid,
2165        status: nros_core::GoalStatusCode,
2166        _result: Unit,
2167    ) -> Result<(), NodeError> {
2168        LAST_STATUS.store(status as i8, Ordering::SeqCst);
2169        Ok(())
2170    }
2171
2172    unsafe fn unused_feedback(
2173        _data: *mut u8,
2174        _goal_id: &nros_core::GoalUuid,
2175        _fb: &Unit,
2176    ) -> Result<(), NodeError> {
2177        unreachable!("the terminal-verb test never publishes feedback")
2178    }
2179
2180    unsafe fn unused_set_status(
2181        _data: *mut u8,
2182        _goal_id: &nros_core::GoalUuid,
2183        _status: nros_core::GoalStatusCode,
2184    ) {
2185        unreachable!("the terminal-verb test never sets a non-terminal status")
2186    }
2187
2188    unsafe fn unused_count(_data: *const u8) -> usize {
2189        unreachable!("the terminal-verb test never counts active goals")
2190    }
2191
2192    unsafe fn unused_for_each(_data: *const u8, _f: &mut dyn FnMut(&ActiveGoal<VerbAction>)) {
2193        unreachable!("the terminal-verb test never iterates active goals")
2194    }
2195
2196    unsafe fn unused_goal_exists(_data: *const u8, _goal_id: &nros_core::GoalUuid) -> bool {
2197        unreachable!("the terminal-verb test never asks whether a goal exists")
2198    }
2199
2200    unsafe fn unused_expire_goals(_data: *mut u8) -> usize {
2201        unreachable!("the terminal-verb test never expires goals")
2202    }
2203
2204    unsafe fn never_processes(
2205        _data: *mut u8,
2206        _delta_us: u64,
2207        _slot: u8,
2208    ) -> Result<bool, TransportError> {
2209        Ok(false)
2210    }
2211
2212    unsafe fn drops_nothing(_data: *mut u8) {}
2213
2214    fn handle() -> ActionServerHandle<VerbAction> {
2215        ActionServerHandle {
2216            entry_index: 0,
2217            publish_feedback_fn: unused_feedback,
2218            complete_goal_fn: record_status,
2219            set_goal_status_fn: unused_set_status,
2220            active_goal_count_fn: unused_count,
2221            goal_exists_fn: unused_goal_exists,
2222            expire_goals_fn: unused_expire_goals,
2223            for_each_active_goal_fn: unused_for_each,
2224            _phantom: PhantomData,
2225        }
2226    }
2227
2228    fn meta() -> CallbackMeta {
2229        CallbackMeta {
2230            offset: 0,
2231            kind: EntryKind::ActionServer,
2232            arena_len: crate::executor::arena::ArenaLen::UNRECORDED,
2233            try_process: never_processes,
2234            has_data: always_ready,
2235            pre_sample: no_pre_sample,
2236            invocation: InvocationMode::OnNewData,
2237            drop_fn: drops_nothing,
2238        }
2239    }
2240
2241    /// Each verb must reach `complete_goal` with the SPEC state it is named
2242    /// for, and `cancel` must forward to `canceled` rather than be a second
2243    /// code path (RFC-0019: the wrapper spelling may double, the behaviour may
2244    /// not).
2245    ///
2246    /// The observation point is the handle's own `complete_goal_fn`, so a verb
2247    /// that quietly did nothing fails on `-1` and a verb wired to the wrong
2248    /// status fails on the discriminant — the two ways a forwarder goes wrong.
2249    #[test]
2250    fn terminal_verbs_carry_their_spec_state_and_cancel_forwards_to_canceled() {
2251        let mut executor = Executor::from_session(MockSession::new());
2252        executor.entries[0] = Some(meta());
2253
2254        let h = handle();
2255        let goal = nros_core::GoalUuid::zero();
2256
2257        LAST_STATUS.store(-1, Ordering::SeqCst);
2258        h.succeed(&mut executor, &goal, Unit).unwrap();
2259        assert_eq!(
2260            LAST_STATUS.load(Ordering::SeqCst),
2261            nros_core::GoalStatusCode::Succeeded as i8,
2262            "succeed() must terminate the goal as SUCCEEDED"
2263        );
2264
2265        LAST_STATUS.store(-1, Ordering::SeqCst);
2266        h.abort(&mut executor, &goal, Unit).unwrap();
2267        assert_eq!(
2268            LAST_STATUS.load(Ordering::SeqCst),
2269            nros_core::GoalStatusCode::Aborted as i8,
2270            "abort() must terminate the goal as ABORTED"
2271        );
2272
2273        // The renamed verb. `canceled` is the spelling C
2274        // (`rclc_action_canceled`), C++ (`ActionServer<A>::canceled`) and
2275        // rclcpp_action (`ServerGoalHandle::canceled`) all use.
2276        LAST_STATUS.store(-1, Ordering::SeqCst);
2277        h.canceled(&mut executor, &goal, Unit).unwrap();
2278        assert_eq!(
2279            LAST_STATUS.load(Ordering::SeqCst),
2280            nros_core::GoalStatusCode::Canceled as i8,
2281            "canceled() must terminate the goal as CANCELED"
2282        );
2283
2284        // The entry was hand-built, not registered; hand it back before Drop
2285        // walks the arena.
2286        executor.entries[0] = None;
2287    }
2288}