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