Skip to main content

nros/
node_runtime.rs

1//! Phase 212.M.5.a.2 — Executor-backed `NodeRuntime` /
2//! `DeclaredNodeRuntime` for nano-ros.
3//!
4//! [`MetadataRecorder`](crate::node_metadata::MetadataRecorder)
5//! (the planner sink) binds the
6//! [`Node`](crate::node::Node) /
7//! [`ExecutableNode`](crate::node::ExecutableNode)
8//! traits to a pure metadata target. This module is the missing twin:
9//! it binds the same traits to a live [`Executor`](crate::Executor) so
10//! a Node pkg can actually run — nodes, publishers,
11//! subscriptions, timers materialise as real executor handles, and
12//! every fired callback dispatches into
13//! [`ExecutableNode::on_callback`] with the right
14//! [`CallbackId`].
15//!
16//! Shape:
17//!
18//! ```ignore
19//! use nros::{Executor, ExecutorConfig};
20//! use nros::node_runtime::ExecutorNodeRuntime;
21//!
22//! let cfg = ExecutorConfig::from_env().node_name("talker_main");
23//! let executor = Executor::open(&cfg).unwrap();
24//! let mut runtime = ExecutorNodeRuntime::from_executor(executor);
25//! let _handle = runtime.register_node::<Talker>().unwrap();
26//! runtime.spin().unwrap();
27//! ```
28//!
29//! Owned-spin / board consumer: the macro-emitted `<pkg>::register(runtime)`
30//! wrapper (Phase 258, Track 2) installs each Node onto the runtime's executor
31//! through the uniform `install_node_typed` seam — the same seam the C/C++
32//! typed entries use. The codegen `run_plan(runtime)` body + `nros::main!`
33//! owned-spin loop drive one `register(runtime)?` per launch-XML `<node>`,
34//! then `runtime.spin()`. (The retired Phase 212.M.5.a four-fn-ptr BSP-baker
35//! ABI — `register_dispatch_slot` / `nros_run_components` — is gone.)
36//!
37//! ## Coverage today (Phase 212.M.5.a.2)
38//!
39//! Publishers, subscriptions, and repeating timers wire end-to-end:
40//! the live executor delivers callbacks; the bound
41//! [`ExecutableNode::on_callback`] body runs with a
42//! [`CallbackCtx`] backed by the per-component publisher resolver.
43//! Service servers / clients and action servers / clients wire
44//! end-to-end too (Phase 212.M-F.23): `create_entity` registers them on
45//! the executor with C-ABI trampolines that route inbound requests /
46//! goals into the component's `on_callback`, and the tick-time client /
47//! action surface ([`TickCtx`]) is backed by `RuntimeClientDispatch` /
48//! `RuntimeActions` over the live executor. Parameters are still a
49//! follow-up (registration succeeds; param callbacks don't fire yet).
50
51#![cfg(feature = "rmw-cffi")]
52
53extern crate alloc;
54
55use alloc::{boxed::Box, string::String, vec::Vec};
56use core::{cell::RefCell, marker::PhantomData, time::Duration};
57
58use portable_atomic::{AtomicUsize, Ordering};
59use portable_atomic_util::Arc;
60
61use crate::{
62    EmbeddedRawPublisher, Executor, GoalId, GoalStatus,
63    node::{
64        ActionExecutor, Callback, CallbackCtx, ClientDispatch, ExecutableNode, NodeContext,
65        NodeDeclError, NodeOptions, NodeResult, NodeRuntime, PublisherResolver, TickCtx,
66    },
67    node_metadata::{
68        CallbackEffectKind, CallbackId, EntityId, EntityKind, EntityMetadata, NodeId as MetaNodeId,
69    },
70};
71
72// Phase 212.N.7 closing sweep — `component_register_symbol` retired
73// (no live callers after the BSP baker + macro extern emit were
74// removed). The former re-export here is gone.
75
76// =============================================================================
77// Public types
78// =============================================================================
79
80/// Opaque handle returned by
81/// [`ExecutorNodeRuntime::register_node`].
82///
83/// `C` distinguishes handles at the type level so a caller who keeps
84/// the handle can later (post-M.5.a.3) recover a typed mut-state
85/// borrow. For today the handle is purely a witness that registration
86/// succeeded.
87pub struct RegisteredNode<C: ExecutableNode> {
88    component_idx: usize,
89    _phantom: PhantomData<fn() -> C>,
90}
91
92impl<C: ExecutableNode> RegisteredNode<C> {
93    /// Slot index of this component inside the runtime.
94    pub fn slot(&self) -> usize {
95        self.component_idx
96    }
97}
98
99/// Errors returned by the runtime entry points.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum ExecutorError {
102    /// One of the components' register / lifecycle calls failed.
103    Node(NodeDeclError),
104    /// The executor's spin loop returned an unexpected error.
105    SpinFailed,
106}
107
108impl From<NodeDeclError> for ExecutorError {
109    fn from(e: NodeDeclError) -> Self {
110        Self::Node(e)
111    }
112}
113
114// =============================================================================
115// Internal slot — type-erases the component's `State` so the runtime
116// can hold a heterogeneous vec.
117// =============================================================================
118
119trait ComponentSlot {
120    fn dispatch(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>);
121    fn tick(&mut self, ctx: &mut TickCtx<'_>);
122}
123
124struct TypedSlot<C: ExecutableNode> {
125    state: C::State,
126    _phantom: PhantomData<fn() -> C>,
127}
128
129impl<C: ExecutableNode> ComponentSlot for TypedSlot<C> {
130    fn dispatch(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>) {
131        C::on_callback(
132            &mut self.state,
133            Callback::__from_id(CallbackId::new(cb_id)),
134            ctx,
135        );
136    }
137    fn tick(&mut self, ctx: &mut TickCtx<'_>) {
138        C::tick(&mut self.state, ctx);
139    }
140}
141
142// Phase 258 (Track 2, w5) — `BspDispatchSlot` (the type-erased
143// four-fn-ptr BSP dispatch slot) is gone with the retired
144// `register_dispatch_slot` / `nros_run_components` BSP-baker path. The
145// only remaining `ComponentSlot` impl is `TypedSlot<C>` above (used by
146// `register_node` / `register_node_borrowed` / `install_node_typed`).
147
148/// Shared per-component cell. Subscription / timer closures registered
149/// against the executor hold an `Arc` clone so they can dispatch +
150/// publish back through the resolver.
151struct ComponentCell {
152    slot: RefCell<Box<dyn ComponentSlot>>,
153    publishers: RefCell<Vec<(String, EmbeddedRawPublisher)>>,
154    // Phase 212.M-F.23 — declarative service/action CLIENT + action-SERVER
155    // handles, keyed by stable entity id, resolved during tick dispatch.
156    // Mirror of the orchestration `GenClientDispatch`/`GenActionExec` arrays,
157    // but built at registration time on the single-node runtime. Service- and
158    // action-SERVER request/goal dispatch is owned by the executor (the
159    // trampolines registered in `create_entity`); only the action-server
160    // handle is kept here so the tick can complete goals / publish feedback.
161    service_clients: RefCell<Vec<(String, crate::HandleId)>>,
162    action_clients: RefCell<Vec<(String, usize)>>,
163    action_servers: RefCell<Vec<(String, crate::ActionServerRawHandle)>>,
164    callback_dispatches: AtomicUsize,
165    message_dispatches: AtomicUsize,
166    // Phase 264 W4c — raw pointer to the executor's volatile parameter store, so a
167    // subscription/timer/service/action callback can read `ctx.parameter::<T>(name)`.
168    // The callback closures + leaked trampolines hold only an `Arc<ComponentCell>` (the
169    // executor is unreachable when they fire), so the store address is threaded HERE by
170    // `apply_param_services`' post-pass (mirrors the `run_ticks` disjoint borrow). Null
171    // until param services are registered. Stable for the executor's life: the server
172    // lives in a `Box<ParamState>` and is a fixed-size array (no realloc on declare).
173    #[cfg(feature = "param-services")]
174    param_server: core::cell::Cell<*const nros_params::ParameterServer>,
175}
176
177impl ComponentCell {
178    /// Phase 264 W4c — the executor's parameter store, or `None` until
179    /// `apply_param_services` threads it in. The deref is sound: single-threaded
180    /// executor, param services mutate the server only outside callback dispatch.
181    #[cfg(feature = "param-services")]
182    fn param_server(&self) -> Option<&nros_params::ParameterServer> {
183        let ptr = self.param_server.get();
184        if ptr.is_null() {
185            None
186        } else {
187            // SAFETY: `ptr` is the address of the executor's boxed `ParameterServer`
188            // (stable for the executor's life); param services mutate it before/after
189            // dispatch (`spin.rs` pre/post), never during, so no aliasing `&mut` is live.
190            Some(unsafe { &*ptr })
191        }
192    }
193
194    fn lookup_publisher<R>(
195        &self,
196        entity_id: &str,
197        f: impl FnOnce(&EmbeddedRawPublisher) -> R,
198    ) -> Option<R> {
199        let pubs = self.publishers.borrow();
200        pubs.iter()
201            .find(|(id, _)| id == entity_id)
202            .map(|(_, p)| f(p))
203    }
204}
205
206/// `PublisherResolver` implementation backed by a `ComponentCell`.
207struct CellResolver<'a> {
208    cell: &'a ComponentCell,
209}
210
211impl PublisherResolver for CellResolver<'_> {
212    fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()> {
213        self.cell
214            .lookup_publisher(entity_id, |p| {
215                p.publish_raw(data).map_err(|_| NodeDeclError::Runtime)
216            })
217            .unwrap_or(Err(NodeDeclError::Runtime))
218    }
219}
220
221// Phase 212.M-F.23 — the `UnsupportedActions` / `UnsupportedClients` tick-side
222// stubs are retired. Real service/action client + action-server dispatch on the
223// single-node runtime lives in `RuntimeClientDispatch` / `RuntimeActions`
224// (below), wired into `run_ticks`.
225
226// =============================================================================
227// ExecutorNodeRuntime
228// =============================================================================
229
230/// Executor-backed component runtime.
231///
232/// Owns the [`Executor`] and one slot per registered component. The
233/// register / spin lifecycle:
234///
235/// 1. [`from_executor`](Self::from_executor) wraps an open
236///    [`Executor`].
237/// 2. [`register_node`](Self::register_node) builds the
238///    component's `State`, runs [`Node::register`] over an
239///    internal [`NodeRuntime`] adapter that materialises nodes /
240///    pubs / subs / timers on the real executor, and wires each
241///    subscription + timer callback to dispatch into
242///    [`ExecutableNode::on_callback`] with the right
243///    [`CallbackId`].
244/// 3. [`spin`](Self::spin) / [`spin_once`](Self::spin_once) drive the
245///    executor; between iterations every registered component's
246///    [`ExecutableNode::tick`] runs.
247pub struct ExecutorNodeRuntime {
248    executor: Executor<'static>,
249    components: Vec<Arc<ComponentCell>>,
250}
251
252impl ExecutorNodeRuntime {
253    /// Wrap an already-built [`Executor`].
254    pub fn from_executor(executor: Executor<'static>) -> Self {
255        Self {
256            executor,
257            components: Vec::new(),
258        }
259    }
260
261    /// Borrow the underlying executor.
262    pub fn executor(&self) -> &Executor<'static> {
263        &self.executor
264    }
265
266    /// Mutably borrow the underlying executor — for advanced wiring
267    /// (parameter services, custom guard conditions). Don't use during
268    /// [`spin`](Self::spin) from another thread; the runtime is
269    /// single-threaded.
270    pub fn executor_mut(&mut self) -> &mut Executor<'static> {
271        &mut self.executor
272    }
273
274    /// Number of registered components.
275    pub fn component_count(&self) -> usize {
276        self.components.len()
277    }
278
279    /// Register a [`Node`] (which must also be
280    /// [`ExecutableNode`]) into this runtime. Builds the
281    /// component's `State` (via [`ExecutableNode::init`]) and
282    /// walks [`Node::register`] over the live executor — every
283    /// declared node / pub / sub / timer materialises as a real
284    /// executor handle, and subscription + timer callbacks are wired
285    /// to dispatch into [`ExecutableNode::on_callback`].
286    pub fn register_node<C: ExecutableNode + 'static>(&mut self) -> NodeResult<RegisteredNode<C>>
287    where
288        C::State: 'static,
289    {
290        let cell = Arc::new(ComponentCell {
291            slot: RefCell::new(Box::new(TypedSlot::<C> {
292                state: C::init(),
293                _phantom: PhantomData,
294            })),
295            publishers: RefCell::new(Vec::new()),
296            service_clients: RefCell::new(Vec::new()),
297            action_clients: RefCell::new(Vec::new()),
298            action_servers: RefCell::new(Vec::new()),
299            callback_dispatches: AtomicUsize::new(0),
300            message_dispatches: AtomicUsize::new(0),
301            // W4c — set by `apply_param_services` once the store exists.
302            #[cfg(feature = "param-services")]
303            param_server: core::cell::Cell::new(core::ptr::null()),
304        });
305        let component_idx = self.components.len();
306        self.components.push(cell.clone());
307
308        let mut sink = ExecutorSink {
309            executor: &mut self.executor,
310            cell: cell.clone(),
311            nodes: Vec::new(),
312            node_identity: None, // direct API — no launch injection
313        };
314        let sink_dyn: &mut dyn NodeRuntime = &mut sink;
315        let mut context = NodeContext::new(C::NAME, sink_dyn);
316        let result = C::register(&mut context);
317        if result.is_err() {
318            // Roll back the slot push so `component_count` stays
319            // consistent with what users observe.
320            self.components.pop();
321        }
322        result?;
323
324        // W4c — capture the executor's volatile param store on the cell (if param
325        // services were registered before this call) so the node's callbacks can read
326        // `ctx.parameter::<T>(name)`. Mirrors the install-seam path.
327        #[cfg(feature = "param-services")]
328        if let Some(server) = self.executor.params() {
329            cell.param_server.set(server as *const _);
330        }
331
332        Ok(RegisteredNode {
333            component_idx,
334            _phantom: PhantomData,
335        })
336    }
337
338    // Phase 258 (Track 2, w5) — `register_dispatch_slot` (the four-fn-ptr
339    // BSP registration) is gone with the retired `register_dispatch_slot_dyn`
340    // bridge + `nros_run_components`. Owned-spin / BSP entries now register
341    // through `install_node_typed` (the uniform install seam).
342
343    /// Drive one executor iteration + a `tick` per registered
344    /// component.
345    pub fn spin_once(&mut self, timeout: Duration) -> Result<(), ExecutorError> {
346        let _result = self.executor.spin_once(timeout);
347        self.run_ticks();
348        Ok(())
349    }
350
351    /// Phase 216.B.3 / C.3 follow-up — route a signaled callback to
352    /// every registered component slot.
353    ///
354    /// The RTIC (`nros-board-rtic-stm32f4`) and Embassy
355    /// (`nros-board-embassy-stm32f4`) dispatch tasks dequeue a
356    /// [`nros_platform::SignaledCallback`] envelope from their SPSC
357    /// queue / Embassy channel and need a routing entry point that
358    /// hands the callback off to the right Node's `on_callback`
359    /// trampoline. This method is that entry point.
360    ///
361    /// # Strategy — linear scan
362    ///
363    /// Each registered slot's `dispatch_fn` is the codegen-emitted
364    /// `d()` trampoline from `nros::node!()` (see
365    /// `packages/core/nros-macros/src/lib.rs`). That trampoline calls
366    /// `<NodeTy as ExecutableNode>::on_callback`, whose body
367    /// `match`es on the callback's own tag set
368    /// (`Subscription` / `Timer` / `Service` / `Action` ids) and is a
369    /// no-op for non-matching `cb_id`s. So a linear scan across every
370    /// slot is correct — each slot self-filters and at most one
371    /// component actually acts on a given `cb_id`. A focused
372    /// `cb_id → slot` index is a separate follow-up; the trampoline's
373    /// tag dispatch already gates the real work cheaply (string
374    /// compare on statically known literals), so the linear scan is
375    /// the minimum-viable wiring that closes the conceptual gap left
376    /// by the B.3 / C.3 skeleton emits.
377    ///
378    /// # Borrow semantics
379    ///
380    /// Each `ComponentCell`'s slot lives behind a [`RefCell`]; the
381    /// per-slot dispatch takes `try_borrow_mut` and is a no-op on
382    /// re-entrancy. The runtime is single-threaded by construction
383    /// (the dispatch task owns it via `&mut self`), so the borrow
384    /// always succeeds in normal flow.
385    pub fn dispatch_callback(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>) {
386        for cell in &self.components {
387            if let Ok(mut slot) = cell.slot.try_borrow_mut() {
388                slot.dispatch(cb_id, ctx);
389            }
390        }
391    }
392
393    /// Spin until the executor's halt flag is raised. Hosted-only; on
394    /// bare-metal the BSP wraps `spin_once` in its own loop.
395    #[cfg(feature = "std")]
396    pub fn spin(&mut self) -> Result<(), ExecutorError> {
397        // 10 ms tick cadence — matches the existing executor spin
398        // budgeting (see `Executor::spin_default`); short enough that
399        // component `tick` hooks observe latency under one cycle.
400        let tick = Duration::from_millis(10);
401        while !self.executor.is_halted() {
402            let _ = self.executor.spin_once(tick);
403            self.run_ticks();
404        }
405        Ok(())
406    }
407
408    /// Halt a running [`spin`](Self::spin). Idempotent.
409    #[cfg(feature = "std")]
410    pub fn halt(&self) {
411        self.executor.halt();
412    }
413
414    fn run_ticks(&mut self) {
415        // Per-component tick — each component's resolver is its own cell.
416        // Phase 212.M-F.23: the tick reaches the executor (service-client
417        // call_raw poll, action-server complete/feedback) through a raw
418        // pointer so `&self.components` and `&mut self.executor` (disjoint
419        // fields) can be live at once.
420        let exec_ptr: *mut Executor<'static> = &mut self.executor;
421        for cell in &self.components {
422            tick_one_cell(cell.as_ref(), exec_ptr);
423        }
424    }
425}
426
427/// Phase 258 (Track 2, 2a) — drive one component cell's `tick` against the
428/// executor. The single source of truth for the per-component tick body,
429/// shared by [`ExecutorNodeRuntime::run_ticks`] (the owned-runtime path) and
430/// the executor-enrolled [`component_tick_trampoline`] (the `install` path).
431///
432/// `exec_ptr` is reached through a raw `*mut Executor<'static>` so the caller can hold
433/// the component (`&ComponentCell`) and the executor live at once — they are
434/// disjoint, and `RuntimeActions` / `RuntimeClientDispatch` reborrow `&mut`
435/// per call (see their docs).
436fn tick_one_cell(cell: &ComponentCell, exec_ptr: *mut Executor<'static>) {
437    let resolver = CellResolver { cell };
438    let service_clients = cell.service_clients.borrow();
439    let action_clients = cell.action_clients.borrow();
440    let action_servers = cell.action_servers.borrow();
441    let mut actions = RuntimeActions {
442        executor: exec_ptr,
443        handles: &action_servers,
444    };
445    let mut clients = RuntimeClientDispatch {
446        executor: exec_ptr,
447        services: &service_clients,
448        actions: &action_clients,
449    };
450    let mut ctx = TickCtx::new(&resolver, &mut actions, &mut clients);
451    // W4c — `tick` reads `ctx.parameter::<T>(name)` from the store via the cell pointer
452    // (the store is a separate `Box<ParamState>` allocation, so this does NOT alias the
453    // `&mut Executor<'static>` the action/client tick calls reborrow through `exec_ptr`).
454    #[cfg(feature = "param-services")]
455    ctx.set_param_server(cell.param_server());
456    if let Ok(mut slot) = cell.slot.try_borrow_mut() {
457        slot.tick(&mut ctx);
458    }
459}
460
461/// Phase 258 (Track 2, 2a) — executor `ComponentSlot.tick` trampoline. Casts
462/// the enrolled state back to the component cell + `exec_ctx` back to the
463/// executor and drives one tick. The layering-clean `extern "C"` shim the
464/// `nros-node` [`Executor`] calls each `spin_once` (it can't name `nros`'s
465/// [`ComponentCell`] — see [`register_node_borrowed`]'s enroll).
466///
467/// # Safety
468/// `state` must be the leaked `Arc<ComponentCell>` enrolled via
469/// [`Executor::enroll_component`] from [`register_node_borrowed`] (borrowed
470/// here, **not** consumed); `exec_ctx` must be the live `*mut Executor<'static>` the
471/// executor passes itself.
472unsafe extern "C" fn component_tick_trampoline(
473    state: *mut core::ffi::c_void,
474    exec_ctx: *mut core::ffi::c_void,
475) {
476    // SAFETY: `state` is a live, leaked `Arc<ComponentCell>` ptr (kept alive
477    // until `component_drop_trampoline`); borrow it without taking ownership.
478    let cell = unsafe { &*(state as *const ComponentCell) };
479    tick_one_cell(cell, exec_ctx as *mut Executor<'static>);
480}
481
482/// Phase 258 (Track 2, 2a) — executor `ComponentSlot.drop` trampoline.
483/// Reconstitutes + drops the leaked `Arc<ComponentCell>` enrolled by
484/// [`register_node_borrowed`]. Run exactly once on `Executor::drop`.
485///
486/// # Safety
487/// `state` must be the leaked `Arc<ComponentCell>` enrolled via
488/// [`Executor::enroll_component`], not yet reclaimed.
489unsafe extern "C" fn component_drop_trampoline(state: *mut core::ffi::c_void) {
490    // SAFETY: reclaim the one leaked Arc clone the enroll handed the executor.
491    drop(unsafe { Arc::from_raw(state as *const ComponentCell) });
492}
493
494// =============================================================================
495// Phase 212.N.7 step-3.3 — bridge to platform-side `NodeDispatchRuntime`.
496// =============================================================================
497//
498// `nros_platform::NodeDispatchRuntime` is the board-side sink: object-safe +
499// `no_std`. `BoardEntry::run` installs this `ExecutorNodeRuntime` impl on the
500// per-boot `RuntimeCtx::runtime` slot. The owned-spin entry reaches the live
501// executor through `executor_handle()` (a raw pointer crosses the layering wall
502// cleanly) and installs via `nros::install_node_typed`. Phase 258 (w5) retired
503// the old `register_dispatch_slot_dyn` four-fn-ptr bridge.
504
505impl ::nros_platform::NodeDispatchRuntime for ExecutorNodeRuntime {
506    fn spin_once(&mut self, timeout_ms: u32) -> Result<(), ()> {
507        Self::spin_once(self, Duration::from_millis(timeout_ms.into())).map_err(|_| ())
508    }
509
510    fn executor_handle(&mut self) -> *mut core::ffi::c_void {
511        // Phase 258 (Track 2, 2a) — hand the owned-spin entry a raw pointer to
512        // the executor this runtime owns, so a Node pkg's `register(runtime)`
513        // can install through `nros::install_node_typed` (same seam as the
514        // C/C++ typed entries). The pointer is valid for the runtime's life
515        // (the executor is an inline field); the install call uses it only
516        // during registration, before any concurrent spin.
517        &mut self.executor as *mut Executor<'static> as *mut core::ffi::c_void
518    }
519
520    // Phase 264 W2 — register the REP-2002 lifecycle services + drive boot
521    // autostart on the owned executor (mirrors `generate.rs::render_lifecycle_fn`).
522    // Only compiled with `lifecycle-services`; without it the trait default no-op
523    // applies, so a `[lifecycle]` block is silently inert (the Entry opts in by
524    // enabling `nros/lifecycle-services`). `nros::main!` calls this when
525    // `system.toml` declares `[lifecycle]`.
526    #[cfg(feature = "lifecycle-services")]
527    fn apply_lifecycle(&mut self, autostart: u8) -> Result<(), ()> {
528        self.executor
529            .register_lifecycle_services()
530            .map_err(|_| ())?;
531        if autostart >= 1
532            && let Some(sm) = self.executor.lifecycle_state_machine_mut()
533        {
534            // No transition callbacks registered → each transition takes the
535            // default-success path (REP-2002 skeleton), as the bake does.
536            unsafe {
537                let _ = sm.trigger_transition(crate::LifecycleTransition::Configure);
538                if autostart >= 2 {
539                    let _ = sm.trigger_transition(crate::LifecycleTransition::Activate);
540                }
541            }
542        }
543        Ok(())
544    }
545
546    // Phase 264 W4b — register the 6 ROS 2 parameter services on the owned executor
547    // + seed the volatile param store with the launch-baked `<param>` initials
548    // (mirrors `generate.rs::render_param_persistence_fn`, minus persistence). Only
549    // compiled with `param-services`; without it the trait default no-op applies, so a
550    // `[param_services]` block is silently inert (the Entry opts in by enabling
551    // `nros/param-services`). `nros::main!` calls this when `system.toml` declares
552    // `[param_services]`. Reconfigured values (via `ros2 param set`) live in RAM until
553    // the next boot — persistence is out of scope (issue 0080).
554    // W4c note: `nros::main!` emits this BEFORE the per-node `register` calls, so the
555    // store exists when each cell is created — `register_node_borrowed` / `register_node`
556    // then capture the (stable, boxed) `ParameterServer` address on the cell, letting a
557    // callback read `ctx.parameter::<T>(name)`. (The macro-path cells live in the
558    // executor's tick registry, not `self.components`, so a post-pass here wouldn't reach
559    // them — capture-at-registration does.)
560    #[cfg(feature = "param-services")]
561    fn apply_param_services(&mut self, params: &[(&str, &str)]) -> Result<(), ()> {
562        self.executor
563            .register_parameter_services()
564            .map_err(|_| ())?;
565        for (name, raw) in params {
566            self.executor
567                .declare_parameter(name, infer_param_value(raw));
568        }
569        Ok(())
570    }
571
572    fn observed_callback_counts(&self) -> (usize, usize) {
573        let direct = self
574            .components
575            .iter()
576            .fold((0, 0), |(callbacks, messages), cell| {
577                (
578                    callbacks + cell.callback_dispatches.load(Ordering::Relaxed),
579                    messages + cell.message_dispatches.load(Ordering::Relaxed),
580                )
581            });
582        // issue #140 — install-seam components (`nros::node!` →
583        // `install_node_typed*` → `register_node_borrowed`) never enter
584        // `self.components`; their cells live only as the executor's enrolled
585        // component slots (leaked `Arc<ComponentCell>`s). Without folding them
586        // the hosted spin reported callbacks=0 for every macro-baked entry
587        // (multihost robot2 et al.) while dispatch demonstrably ran. The two
588        // populations are disjoint by construction: `register_node` pushes to
589        // `components` and does not enroll; `register_node_borrowed` enrolls
590        // and does not push.
591        self.executor
592            .enrolled_component_states()
593            .fold(direct, |(callbacks, messages), state| {
594                // SAFETY: every enrolled state is a leaked `Arc<ComponentCell>`
595                // from `register_node_borrowed` (the only enroll site); the
596                // executor keeps it alive until `Executor::drop`, and we only
597                // read its atomic counters here.
598                let cell = unsafe { &*(state as *const ComponentCell) };
599                (
600                    callbacks + cell.callback_dispatches.load(Ordering::Relaxed),
601                    messages + cell.message_dispatches.load(Ordering::Relaxed),
602                )
603            })
604    }
605}
606
607// =============================================================================
608// Internal sink — bridges `NodeRuntime` declarations onto the
609// live executor.
610// =============================================================================
611
612struct ExecutorSink<'a> {
613    executor: &'a mut Executor<'static>,
614    cell: Arc<ComponentCell>,
615    /// Per-registration node mapping: stable id → executor `NodeId`.
616    nodes: Vec<(String, nros_node::executor::NodeId)>,
617    /// Phase 268 W1 — launch-injected node identity `(name, namespace)` baked by
618    /// `nros::main!` per component. When `Some`, `create_node` uses this identity
619    /// instead of the `NodeOptions` default; `None` → default stands (backward-compat).
620    node_identity: Option<(&'static str, &'static str)>,
621}
622
623impl ExecutorSink<'_> {
624    fn lookup_node(&self, stable_id: &str) -> Option<nros_node::executor::NodeId> {
625        self.nodes
626            .iter()
627            .find(|(id, _)| id == stable_id)
628            .map(|(_, n)| *n)
629    }
630}
631
632impl NodeRuntime for ExecutorSink<'_> {
633    fn create_node(&mut self, id: MetaNodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
634        if self.nodes.iter().any(|(s, _)| s.as_str() == id.as_str()) {
635            return Err(NodeDeclError::Runtime);
636        }
637        // Phase 268 W1 — launch wins over the NodeOptions default (RFC-0046).
638        // When `nros::main!` injected an identity for this component, use it;
639        // otherwise fall back to what the Node declared in its `create_node` call.
640        let (name, ns) = match self.node_identity {
641            Some((n, s)) => (n, s),
642            None => (options.name, options.namespace),
643        };
644        let node_id = self
645            .executor
646            .node_builder(name)
647            .namespace(ns)
648            .domain_id(options.domain_id)
649            .build()
650            .map_err(decl_err_from_node)?;
651        self.nodes.push((String::from(id.as_str()), node_id));
652        Ok(())
653    }
654
655    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()> {
656        // Phase 228.C tier gate: when this executor runs a specific tier
657        // (`active_groups` set by codegen), an entity whose callback group
658        // is not active on this tier is a no-op — no RMW handle, no slot.
659        // An unlabeled entity (`callback_group == None`) is wildcard-eligible
660        // and always registers; the degenerate single-tier executor leaves
661        // `active_groups == None`, so every entity registers (byte-identical
662        // to pre-228 output).
663        if let Some(group) = metadata.callback_group.as_ref()
664            && !self.executor.group_active(group.as_str())
665        {
666            return Ok(());
667        }
668        let node = self
669            .lookup_node(metadata.node_id.as_str())
670            .ok_or(NodeDeclError::Runtime)?;
671        match metadata.kind {
672            EntityKind::Publisher => {
673                let handle = self
674                    .executor
675                    .node_mut(node)
676                    .create_generic_publisher(
677                        metadata.source_name.as_str(),
678                        metadata.type_name,
679                        metadata.type_hash,
680                    )
681                    .map_err(decl_err_from_node)?;
682                let id_owned = String::from(metadata.id.as_str());
683                self.cell.publishers.borrow_mut().push((id_owned, handle));
684                Ok(())
685            }
686            EntityKind::Subscription => {
687                let cb_id = metadata
688                    .callback_id
689                    .as_ref()
690                    .ok_or(NodeDeclError::Runtime)?;
691                let cb_id_owned = String::from(cb_id.as_str());
692                let cell = self.cell.clone();
693                // Phase 250 (Wave 2b) — a `.safety()` subscription registers via
694                // the integrity-aware generic path so `CallbackCtx::integrity()`
695                // surfaces CRC + sequence gap/dup. Gated: when `safety-e2e` is off
696                // the flag is ignored and the basic path below runs.
697                #[cfg(feature = "safety-e2e")]
698                if metadata.safety {
699                    let cell_s = self.cell.clone();
700                    let cb_s = cb_id_owned.clone();
701                    self.executor
702                        .node_mut(node)
703                        .create_generic_subscription_with_integrity(
704                            metadata.source_name.as_str(),
705                            metadata.type_name,
706                            metadata.type_hash,
707                            move |payload: &[u8], status: &nros_node::IntegrityStatus| {
708                                dispatch_into_cell_with_integrity(&cell_s, &cb_s, payload, status);
709                            },
710                        )
711                        .map_err(decl_err_from_node)?;
712                    return Ok(());
713                }
714                self.executor
715                    .node_mut(node)
716                    .create_generic_subscription(
717                        metadata.source_name.as_str(),
718                        metadata.type_name,
719                        metadata.type_hash,
720                        move |payload: &[u8]| {
721                            dispatch_into_cell(&cell, &cb_id_owned, payload);
722                        },
723                    )
724                    .map_err(decl_err_from_node)?;
725                Ok(())
726            }
727            EntityKind::Timer => {
728                let cb_id = metadata
729                    .callback_id
730                    .as_ref()
731                    .ok_or(NodeDeclError::Runtime)?;
732                let cb_id_owned = String::from(cb_id.as_str());
733                let period_ms = metadata.period_ms.ok_or(NodeDeclError::Runtime)?;
734                let cell = self.cell.clone();
735                self.executor
736                    .register_timer(
737                        nros_node::TimerDuration::from_millis(period_ms),
738                        move || {
739                            dispatch_into_cell(&cell, &cb_id_owned, &[]);
740                        },
741                    )
742                    .map_err(decl_err_from_node)?;
743                Ok(())
744            }
745            // Phase 212.M-F.23 — service / action client + server dispatch on
746            // the single-node runtime. The executor-level `register_*_on`
747            // calls add an arena dispatch entry, so inbound requests / goals
748            // are serviced inside `spin_once`; the leaked `*Ctx` trampoline
749            // contexts bridge back into the component's `on_callback`. Client
750            // handles are stashed in the cell for the tick-side dispatch
751            // (`RuntimeClientDispatch` / `RuntimeActions` in `run_ticks`).
752            EntityKind::ServiceServer => {
753                let cb_id = metadata
754                    .callback_id
755                    .as_ref()
756                    .ok_or(NodeDeclError::Runtime)?;
757                let ctx = Box::into_raw(Box::new(ServiceServerCtx {
758                    cell: self.cell.clone(),
759                    callback_id: String::from(cb_id.as_str()),
760                })) as *mut core::ffi::c_void;
761                self.executor
762                    .register_service_raw_sized_on::<1024, 1024>(
763                        node,
764                        metadata.source_name.as_str(),
765                        metadata.type_name,
766                        metadata.type_hash,
767                        crate::QosSettings::services_default(),
768                        service_server_trampoline,
769                        ctx,
770                    )
771                    .map_err(decl_err_from_node)?;
772                Ok(())
773            }
774            EntityKind::ServiceClient => {
775                let hid = self
776                    .executor
777                    .register_service_client_raw_sized_on::<1024>(
778                        node,
779                        metadata.source_name.as_str(),
780                        metadata.type_name,
781                        metadata.type_hash,
782                        crate::QosSettings::services_default(),
783                        None,
784                        core::ptr::null_mut(),
785                    )
786                    .map_err(decl_err_from_node)?;
787                self.cell
788                    .service_clients
789                    .borrow_mut()
790                    .push((String::from(metadata.id.as_str()), hid));
791                Ok(())
792            }
793            EntityKind::ActionServer => {
794                let goal_cb = metadata
795                    .callback_id
796                    .as_ref()
797                    .ok_or(NodeDeclError::Runtime)?;
798                let cancel_cb = metadata
799                    .action_cancel_callback_id
800                    .as_ref()
801                    .ok_or(NodeDeclError::Runtime)?;
802                let accepted_cb = metadata
803                    .action_accepted_callback_id
804                    .as_ref()
805                    .map(|c| String::from(c.as_str()));
806                let ctx = Box::into_raw(Box::new(ActionServerCtx {
807                    cell: self.cell.clone(),
808                    goal_callback_id: String::from(goal_cb.as_str()),
809                    cancel_callback_id: String::from(cancel_cb.as_str()),
810                    accepted_callback_id: accepted_cb,
811                })) as *mut core::ffi::c_void;
812                let handle = self
813                    .executor
814                    .register_action_server_raw_sized::<1024, 1024, 1024, 4>(
815                        crate::RawActionServerSpec {
816                            node_id: Some(node),
817                            action_name: metadata.source_name.as_str(),
818                            type_name: metadata.type_name,
819                            type_hash: metadata.type_hash,
820                            qos: crate::QosSettings::services_default(),
821                            goal_callback: action_goal_trampoline,
822                            cancel_callback: action_cancel_trampoline,
823                            accepted_callback: Some(action_accepted_trampoline),
824                            context: ctx,
825                        },
826                    )
827                    .map_err(decl_err_from_node)?;
828                self.cell
829                    .action_servers
830                    .borrow_mut()
831                    .push((String::from(metadata.id.as_str()), handle));
832                Ok(())
833            }
834            EntityKind::ActionClient => {
835                // A bound `callback_id` (set by
836                // `create_action_client_with_callbacks_for_name`) delivers the
837                // terminal goal result to the component via `on_callback`; the
838                // optional `action_accepted_callback_id` slot carries the
839                // feedback callback (reused — unused on a client). The executor
840                // auto-drives accept → feedback → result during spin and invokes
841                // these trampolines. No callbacks → send-goal only.
842                let (result_callback, feedback_callback, ctx) = match metadata.callback_id.as_ref()
843                {
844                    Some(result_cb) => {
845                        let feedback_cb = metadata
846                            .action_accepted_callback_id
847                            .as_ref()
848                            .map(|c| String::from(c.as_str()));
849                        let ctx = Box::into_raw(Box::new(ActionClientCtx {
850                            cell: self.cell.clone(),
851                            result_callback_id: String::from(result_cb.as_str()),
852                            feedback_callback_id: feedback_cb.clone(),
853                        })) as *mut core::ffi::c_void;
854                        let fb = feedback_cb.map(|_| action_feedback_trampoline as _);
855                        (Some(action_result_trampoline as _), fb, ctx)
856                    }
857                    None => (None, None, core::ptr::null_mut()),
858                };
859                let handle = self
860                    .executor
861                    .register_action_client_raw_sized::<1024, 1024, 1024>(
862                        crate::RawActionClientSpec {
863                            node_id: Some(node),
864                            action_name: metadata.source_name.as_str(),
865                            type_name: metadata.type_name,
866                            type_hash: metadata.type_hash,
867                            goal_response_callback: None,
868                            feedback_callback,
869                            result_callback,
870                            context: ctx,
871                        },
872                    )
873                    .map_err(decl_err_from_node)?;
874                self.cell
875                    .action_clients
876                    .borrow_mut()
877                    .push((String::from(metadata.id.as_str()), handle.entry_index()));
878                Ok(())
879            }
880            EntityKind::Parameter => {
881                // Phase 212.M-F.23 Wave 2 — declarative parameter dispatch on
882                // the single-node runtime. The first declared parameter lazily
883                // stands up the 6 ROS 2 parameter services for this executor's
884                // node; `spin_once` drives those service servers thereafter
885                // (`#[cfg(param-services)]` block at spin.rs). The declared
886                // source default seeds the value. With `param-services` off the
887                // arm is a no-op (entity declared, no RMW handle) — byte-
888                // identical to the pre-Wave-2 behavior.
889                #[cfg(feature = "param-services")]
890                {
891                    if self.executor.params().is_none() {
892                        self.executor
893                            .register_parameter_services()
894                            .map_err(decl_err_from_node)?;
895                    }
896                    let value = param_default_to_value(metadata.parameter_default.as_ref());
897                    self.executor
898                        .declare_parameter(metadata.source_name.as_str(), value);
899                }
900                Ok(())
901            }
902        }
903    }
904
905    fn record_callback_effect(
906        &mut self,
907        _callback_id: CallbackId<'_>,
908        _kind: CallbackEffectKind,
909        _entity_id: EntityId<'_>,
910    ) -> NodeResult<()> {
911        // Planner concern only — the live runtime doesn't need the
912        // effect graph at spin time.
913        Ok(())
914    }
915}
916
917/// Lower a source-recorded [`ParameterDefault`] into the executor-facing
918/// [`nros_params::ParameterValue`] used to seed a declared parameter. Scalar
919/// defaults carry their value directly; the array variants record only the
920/// declared type (no element data at the source layer) so they seed as
921/// `NotSet` — the parameter is still declared, just without a concrete array
922/// default. A `Double` default is stored as a string at the metadata layer and
923/// parsed here (unparseable → `0.0`).
924#[cfg(feature = "param-services")]
925fn param_default_to_value(
926    default: Option<&crate::node_metadata::ParameterDefault>,
927) -> nros_params::ParameterValue {
928    use crate::node_metadata::ParameterDefault;
929    use nros_params::ParameterValue;
930    match default {
931        None => ParameterValue::NotSet,
932        Some(ParameterDefault::Bool(b)) => ParameterValue::Bool(*b),
933        Some(ParameterDefault::Integer(i)) => ParameterValue::Integer(*i),
934        Some(ParameterDefault::Double(s)) => {
935            ParameterValue::Double(s.as_str().parse::<f64>().unwrap_or(0.0))
936        }
937        Some(ParameterDefault::String(s)) => {
938            ParameterValue::from_string(s.as_str()).unwrap_or(ParameterValue::NotSet)
939        }
940        Some(
941            ParameterDefault::BoolArray
942            | ParameterDefault::IntegerArray
943            | ParameterDefault::DoubleArray
944            | ParameterDefault::StringArray,
945        ) => ParameterValue::NotSet,
946    }
947}
948
949/// Phase 264 W4b — infer a [`nros_params::ParameterValue`] from a raw launch
950/// `<param value=…/>` string. ROS 2 launch `<param>` values are untyped strings; the
951/// macro path has no per-param type attribute, so infer: `true`/`false` → `Bool`, an
952/// `i64` literal → `Integer`, an `f64` literal → `Double`, otherwise `String`. This
953/// mirrors the type a `ros2 param set` of the same literal would land on, so the baked
954/// initial and a CLI override agree on type.
955#[cfg(feature = "param-services")]
956fn infer_param_value(raw: &str) -> nros_params::ParameterValue {
957    use nros_params::ParameterValue;
958    match raw {
959        "true" => return ParameterValue::from_bool(true),
960        "false" => return ParameterValue::from_bool(false),
961        _ => {}
962    }
963    if let Ok(i) = raw.parse::<i64>() {
964        return ParameterValue::from_integer(i);
965    }
966    if let Ok(f) = raw.parse::<f64>() {
967        return ParameterValue::from_double(f);
968    }
969    ParameterValue::from_string(raw).unwrap_or(ParameterValue::NotSet)
970}
971
972fn dispatch_into_cell(cell: &Arc<ComponentCell>, cb_id: &str, payload: &[u8]) {
973    cell.callback_dispatches.fetch_add(1, Ordering::Relaxed);
974    if !payload.is_empty() {
975        cell.message_dispatches.fetch_add(1, Ordering::Relaxed);
976    }
977    let resolver = CellResolver {
978        cell: cell.as_ref(),
979    };
980    let mut ctx = CallbackCtx::new(payload, &resolver);
981    // W4c — let the callback read `ctx.parameter::<T>(name)` from the executor's store
982    // (threaded onto the cell by `apply_param_services`; `None` until then).
983    #[cfg(feature = "param-services")]
984    ctx.set_param_server(cell.param_server());
985    // If the slot is already borrowed (a re-entrant publish from a
986    // tick hook on the same cell, etc.) we drop this dispatch. In
987    // practice `try_borrow_mut` succeeds because subscription / timer
988    // callbacks run sequentially under the single-threaded executor.
989    if let Ok(mut slot) = cell.slot.try_borrow_mut() {
990        slot.dispatch(cb_id, &mut ctx);
991    }
992}
993
994/// Phase 250 (Wave 2b) — dispatch a `.safety()` subscription message into the
995/// component's `on_callback` with its E2E [`IntegrityStatus`] attached, read via
996/// `CallbackCtx::integrity()`. The integrity-aware twin of [`dispatch_into_cell`].
997#[cfg(feature = "safety-e2e")]
998fn dispatch_into_cell_with_integrity(
999    cell: &Arc<ComponentCell>,
1000    cb_id: &str,
1001    payload: &[u8],
1002    status: &nros_node::IntegrityStatus,
1003) {
1004    cell.callback_dispatches.fetch_add(1, Ordering::Relaxed);
1005    if !payload.is_empty() {
1006        cell.message_dispatches.fetch_add(1, Ordering::Relaxed);
1007    }
1008    let resolver = CellResolver {
1009        cell: cell.as_ref(),
1010    };
1011    let mut ctx = CallbackCtx::new_with_integrity(payload, &resolver, status);
1012    // W4c — param store for a `.safety()` subscription callback too.
1013    #[cfg(feature = "param-services")]
1014    ctx.set_param_server(cell.param_server());
1015    if let Ok(mut slot) = cell.slot.try_borrow_mut() {
1016        slot.dispatch(cb_id, &mut ctx);
1017    }
1018}
1019
1020// =============================================================================
1021// Phase 212.M-F.23 — service / action SERVER trampolines + tick-side client /
1022// action dispatch.
1023//
1024// The executor's raw service/action-server registration takes C-ABI fn
1025// pointers, so the runtime leaks a `*Ctx` (lives for the runtime's lifetime,
1026// like the executor) holding the owning `ComponentCell` + the declared
1027// callback ids. Each trampoline rebuilds a `CallbackCtx` and routes into the
1028// component's `on_callback`, exactly as the orchestration codegen's
1029// `svc_tramp_*` / `goal_tramp_*` do for the Entry path.
1030// =============================================================================
1031
1032/// Leaked context for a service-server arena callback.
1033struct ServiceServerCtx {
1034    cell: Arc<ComponentCell>,
1035    callback_id: String,
1036}
1037
1038/// Leaked context for an action-server arena callback (goal + cancel + the
1039/// optional accepted hook all share one).
1040struct ActionServerCtx {
1041    cell: Arc<ComponentCell>,
1042    goal_callback_id: String,
1043    cancel_callback_id: String,
1044    accepted_callback_id: Option<String>,
1045}
1046
1047/// Leaked context for an action-CLIENT result + feedback callbacks.
1048struct ActionClientCtx {
1049    cell: Arc<ComponentCell>,
1050    result_callback_id: String,
1051    feedback_callback_id: Option<String>,
1052}
1053
1054/// Action-client result callback: the executor's spin auto-drives the goal to
1055/// completion and hands the terminal result CDR here; route it into the
1056/// component's `on_callback` (read with `CallbackCtx::message`).
1057unsafe extern "C" fn action_result_trampoline(
1058    _goal_id: *const GoalId,
1059    _status: GoalStatus,
1060    result_data: *const u8,
1061    result_len: usize,
1062    ctx: *mut core::ffi::c_void,
1063) {
1064    let actx = unsafe { &*(ctx as *const ActionClientCtx) };
1065    let result_slice = unsafe { core::slice::from_raw_parts(result_data, result_len) };
1066    dispatch_into_cell(&actx.cell, &actx.result_callback_id, result_slice);
1067}
1068
1069/// Action-client feedback callback: route each feedback CDR into the
1070/// component's `on_callback` under the bound feedback callback id.
1071unsafe extern "C" fn action_feedback_trampoline(
1072    _goal_id: *const GoalId,
1073    feedback_data: *const u8,
1074    feedback_len: usize,
1075    ctx: *mut core::ffi::c_void,
1076) {
1077    let actx = unsafe { &*(ctx as *const ActionClientCtx) };
1078    let Some(cb_id) = actx.feedback_callback_id.as_ref() else {
1079        return;
1080    };
1081    let feedback_slice = unsafe { core::slice::from_raw_parts(feedback_data, feedback_len) };
1082    dispatch_into_cell(&actx.cell, cb_id, feedback_slice);
1083}
1084
1085/// Service-server request callback: deserialize-side runs in the component's
1086/// `on_callback` via `CallbackCtx::with_reply`; the executor sends the reply
1087/// from the bytes written into `resp`.
1088unsafe extern "C" fn service_server_trampoline(
1089    req: *const u8,
1090    req_len: usize,
1091    resp: *mut u8,
1092    resp_cap: usize,
1093    resp_len: *mut usize,
1094    ctx: *mut core::ffi::c_void,
1095) -> bool {
1096    let sctx = unsafe { &*(ctx as *const ServiceServerCtx) };
1097    let req_slice = unsafe { core::slice::from_raw_parts(req, req_len) };
1098    let resp_slice = unsafe { core::slice::from_raw_parts_mut(resp, resp_cap) };
1099    let mut written = 0usize;
1100    let resolver = CellResolver {
1101        cell: sctx.cell.as_ref(),
1102    };
1103    let mut cb = CallbackCtx::with_reply(req_slice, &resolver, resp_slice, &mut written);
1104    // W4c — service-server callback can read `ctx.parameter::<T>(name)` too.
1105    #[cfg(feature = "param-services")]
1106    cb.set_param_server(sctx.cell.param_server());
1107    if let Ok(mut slot) = sctx.cell.slot.try_borrow_mut() {
1108        slot.dispatch(&sctx.callback_id, &mut cb);
1109    }
1110    unsafe { *resp_len = written };
1111    true
1112}
1113
1114/// Action-server goal callback → component `on_callback` with a goal decision.
1115unsafe extern "C" fn action_goal_trampoline(
1116    _goal_id: *const GoalId,
1117    goal_data: *const u8,
1118    goal_len: usize,
1119    ctx: *mut core::ffi::c_void,
1120) -> crate::GoalResponse {
1121    let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1122    let goal_slice = unsafe { core::slice::from_raw_parts(goal_data, goal_len) };
1123    let mut resp = crate::GoalResponse::Reject;
1124    let resolver = CellResolver {
1125        cell: actx.cell.as_ref(),
1126    };
1127    let mut cb = CallbackCtx::with_goal_decision(goal_slice, &resolver, &mut resp);
1128    // W4c — action goal callback can read `ctx.parameter::<T>(name)` too.
1129    #[cfg(feature = "param-services")]
1130    cb.set_param_server(actx.cell.param_server());
1131    if let Ok(mut slot) = actx.cell.slot.try_borrow_mut() {
1132        slot.dispatch(&actx.goal_callback_id, &mut cb);
1133    }
1134    resp
1135}
1136
1137/// Action-server cancel callback → component `on_callback` with a cancel
1138/// decision. The cancel callback has no goal payload.
1139unsafe extern "C" fn action_cancel_trampoline(
1140    _goal_id: *const GoalId,
1141    _status: GoalStatus,
1142    ctx: *mut core::ffi::c_void,
1143) -> crate::CancelResponse {
1144    let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1145    let mut resp = crate::CancelResponse::Rejected;
1146    let resolver = CellResolver {
1147        cell: actx.cell.as_ref(),
1148    };
1149    let mut cb = CallbackCtx::with_cancel_decision(&[], &resolver, &mut resp);
1150    // W4c — action cancel callback can read `ctx.parameter::<T>(name)` too.
1151    #[cfg(feature = "param-services")]
1152    cb.set_param_server(actx.cell.param_server());
1153    if let Ok(mut slot) = actx.cell.slot.try_borrow_mut() {
1154        slot.dispatch(&actx.cancel_callback_id, &mut cb);
1155    }
1156    resp
1157}
1158
1159/// Action-server accepted hook → component `on_callback` (no decision, no
1160/// payload). No-op when the component didn't declare an accepted callback.
1161unsafe extern "C" fn action_accepted_trampoline(
1162    _goal_id: *const GoalId,
1163    ctx: *mut core::ffi::c_void,
1164) {
1165    let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1166    let Some(cb_id) = actx.accepted_callback_id.as_ref() else {
1167        return;
1168    };
1169    dispatch_into_cell(&actx.cell, cb_id, &[]);
1170}
1171
1172/// Tick-side service/action CLIENT dispatch — the single-node runtime's mirror
1173/// of the orchestration `GenClientDispatch`. Resolves the per-component client
1174/// handle arrays + a `*mut Executor<'static>` (the tick borrows `&components` while
1175/// needing `&mut executor`, so the executor is reached through a raw pointer,
1176/// reborrowed `&mut` per call; no aliasing — `executor` and `components` are
1177/// disjoint fields).
1178struct RuntimeClientDispatch<'a> {
1179    executor: *mut Executor<'static>,
1180    services: &'a [(String, crate::HandleId)],
1181    actions: &'a [(String, usize)],
1182}
1183
1184impl RuntimeClientDispatch<'_> {
1185    fn service(&self, entity: &str) -> NodeResult<crate::HandleId> {
1186        self.services
1187            .iter()
1188            .find(|(e, _)| e == entity)
1189            .map(|(_, h)| *h)
1190            .ok_or(NodeDeclError::Runtime)
1191    }
1192
1193    fn action_entry(&self, entity: &str) -> NodeResult<usize> {
1194        self.actions
1195            .iter()
1196            .find(|(e, _)| e == entity)
1197            .map(|(_, i)| *i)
1198            .ok_or(NodeDeclError::Runtime)
1199    }
1200}
1201
1202impl ClientDispatch for RuntimeClientDispatch<'_> {
1203    fn call_raw(
1204        &mut self,
1205        service_entity: &str,
1206        request_cdr: &[u8],
1207        response_buf: &mut [u8],
1208    ) -> NodeResult<usize> {
1209        use crate::ServiceClientTrait;
1210        let hid = self.service(service_entity)?;
1211        {
1212            let executor = unsafe { &mut *self.executor };
1213            let entry = unsafe { executor.service_client_entry_mut(hid.0) }
1214                .ok_or(NodeDeclError::Runtime)?;
1215            entry
1216                .handle
1217                .send_request_raw(request_cdr)
1218                .map_err(|_| NodeDeclError::Runtime)?;
1219        }
1220        // Bounded wait — caps total time so the tick loop stays responsive.
1221        for _ in 0..200 {
1222            let executor = unsafe { &mut *self.executor };
1223            executor.spin_once(core::time::Duration::from_millis(10));
1224            let entry = unsafe { executor.service_client_entry_mut(hid.0) }
1225                .ok_or(NodeDeclError::Runtime)?;
1226            match entry.handle.try_recv_reply_raw(response_buf) {
1227                Ok(Some(len)) => return Ok(len),
1228                Ok(None) => continue,
1229                Err(_) => return Err(NodeDeclError::Runtime),
1230            }
1231        }
1232        Err(NodeDeclError::Runtime)
1233    }
1234
1235    fn send_goal_raw(&mut self, action_entity: &str, goal_cdr: &[u8]) -> NodeResult<GoalId> {
1236        let entry_index = self.action_entry(action_entity)?;
1237        let executor = unsafe { &mut *self.executor };
1238        let core = unsafe { executor.action_client_core_mut(entry_index) }
1239            .ok_or(NodeDeclError::Runtime)?;
1240        let goal_id = core.send_goal_raw(goal_cdr).map_err(decl_err_from_node)?;
1241        // rclcpp-style: request the result immediately. The server queues the
1242        // get_result request until the goal terminates, then replies — the
1243        // executor's spin auto-delivers it to the bound result callback (the
1244        // executor never auto-sends this request, so the declarative client
1245        // must). Best-effort: a transport hiccup just means no result callback.
1246        let _ = core.send_get_result_request(&goal_id);
1247        Ok(goal_id)
1248    }
1249}
1250
1251/// Tick-side action-SERVER execution — mirror of `GenActionExec`. Lets a
1252/// component complete goals / publish feedback / enumerate active goals from
1253/// its `tick` via `TickCtx`.
1254struct RuntimeActions<'a> {
1255    executor: *mut Executor<'static>,
1256    handles: &'a [(String, crate::ActionServerRawHandle)],
1257}
1258
1259impl RuntimeActions<'_> {
1260    fn handle(&self, entity: &str) -> NodeResult<crate::ActionServerRawHandle> {
1261        self.handles
1262            .iter()
1263            .find(|(e, _)| e == entity)
1264            .map(|(_, h)| *h)
1265            .ok_or(NodeDeclError::Runtime)
1266    }
1267}
1268
1269impl ActionExecutor for RuntimeActions<'_> {
1270    fn complete_goal_raw(
1271        &mut self,
1272        action_entity: &str,
1273        goal_id: &GoalId,
1274        status: GoalStatus,
1275        result: &[u8],
1276    ) -> NodeResult<()> {
1277        let handle = self.handle(action_entity)?;
1278        let executor = unsafe { &mut *self.executor };
1279        handle.complete_goal_raw(executor, goal_id, status, result);
1280        Ok(())
1281    }
1282
1283    fn publish_feedback_raw(
1284        &mut self,
1285        action_entity: &str,
1286        goal_id: &GoalId,
1287        feedback: &[u8],
1288    ) -> NodeResult<()> {
1289        let handle = self.handle(action_entity)?;
1290        let executor = unsafe { &mut *self.executor };
1291        handle
1292            .publish_feedback_raw(executor, goal_id, feedback)
1293            .map_err(|_| NodeDeclError::Runtime)
1294    }
1295
1296    fn for_each_active_goal(
1297        &self,
1298        action_entity: &str,
1299        visit: &mut dyn FnMut(&GoalId, GoalStatus),
1300    ) {
1301        if let Ok(handle) = self.handle(action_entity) {
1302            let executor = unsafe { &*self.executor };
1303            handle.for_each_active_goal(executor, |g| visit(&g.goal_id, g.status));
1304        }
1305    }
1306}
1307
1308// Phase 258 (Track 2, w5) — the typed BSP fn-ptr aliases (`NodeRegisterFn` /
1309// `NodeInitFn` / `NodeDispatchFn` / `NodeTickFn`) are gone with the retired
1310// `register_dispatch_slot` / `nros_run_components` BSP-baker path. The
1311// macro-emitted `register(runtime)` wrapper now installs via the
1312// `install_node_typed` seam (Track 2 w4).
1313
1314/// Phase 257 (W0-B) — register an [`ExecutableNode`] `C` against a **borrowed**
1315/// executor (the shared cffi `Executor` a foreign-language typed entry hands in via
1316/// its `nros::global_handle()` / `Node::executor_handle()`), returning the live
1317/// [`ComponentCell`].
1318///
1319/// Unlike [`ExecutorNodeRuntime::register_node`] this owns neither the executor nor a
1320/// components list: the executor's per-entity callbacks hold `Arc<ComponentCell>`
1321/// clones (see [`ExecutorSink`]), so the cell stays alive for the executor's lifetime
1322/// via dispatch alone — the caller may drop the returned cell (pub/sub/timer nodes, the
1323/// W0-B target) or stash it to drive `tick` (service-client/action nodes; phase-257 D2).
1324/// The node self-creates its node (its `Node::NAME`) on the shared executor (phase-257
1325/// D7 Option C — Rust nodes in a foreign entry self-name, no entry-side qos-override).
1326/// Issue 0095 — preserve executor callback-table exhaustion through the
1327/// `NodeError → NodeDeclError` collapse so the register seam (and ultimately
1328/// the `nros::main!` entry) can name `NROS_EXECUTOR_MAX_CBS` instead of an
1329/// opaque `NodeRegister`. Every other `NodeError` stays `Runtime`.
1330fn decl_err_from_node(e: nros_node::NodeError) -> NodeDeclError {
1331    match e {
1332        nros_node::NodeError::ExecutorFull => NodeDeclError::ExecutorFull,
1333        _ => NodeDeclError::Runtime,
1334    }
1335}
1336
1337fn register_node_borrowed<'p, C: ExecutableNode + 'static>(
1338    executor: &mut Executor<'static>,
1339    params: &'p [(&'p str, &'p str)],
1340    node_identity: Option<(&'static str, &'static str)>,
1341) -> NodeResult<Arc<ComponentCell>>
1342where
1343    C::State: 'static,
1344{
1345    let cell = Arc::new(ComponentCell {
1346        slot: RefCell::new(Box::new(TypedSlot::<C> {
1347            state: C::init(),
1348            _phantom: PhantomData,
1349        })),
1350        publishers: RefCell::new(Vec::new()),
1351        service_clients: RefCell::new(Vec::new()),
1352        action_clients: RefCell::new(Vec::new()),
1353        action_servers: RefCell::new(Vec::new()),
1354        callback_dispatches: AtomicUsize::new(0),
1355        message_dispatches: AtomicUsize::new(0),
1356        // W4c — set by `apply_param_services` once the store exists (it runs after this).
1357        #[cfg(feature = "param-services")]
1358        param_server: core::cell::Cell::new(core::ptr::null()),
1359    });
1360    let mut sink = ExecutorSink {
1361        // Reborrow so `executor` stays usable for `enroll_component` after the
1362        // sink (which holds `&mut Executor<'static>`) is dropped below.
1363        executor: &mut *executor,
1364        cell: cell.clone(),
1365        nodes: Vec::new(),
1366        // Phase 268 W1 — thread the per-component identity bake (RFC-0046).
1367        node_identity,
1368    };
1369    let sink_dyn: &mut dyn NodeRuntime = &mut sink;
1370    let mut context = NodeContext::new(C::NAME, sink_dyn);
1371    // Phase 264 W4a — seed the baked launch-param initials so `register()` can read
1372    // them via `NodeContext::param`.
1373    context.set_params(params);
1374    C::register(&mut context)?;
1375
1376    // Phase 258 (Track 2, 2a) — enroll the cell in the executor's component
1377    // tick registry so `install`'d nodes tick (closes phase-257 D2: poll-only
1378    // service-client/action nodes have no callbacks keeping the cell alive AND
1379    // never ran `tick`). Leak one `Arc` clone for the executor to own; it runs
1380    // `tick` each `spin_once` and drops the clone on `Executor::drop`. Harmless
1381    // for pub/sub/timer-only nodes — their tick body is a no-op. Registry-full
1382    // (`MAX_NODES`) reclaims the clone and proceeds without a tick slot.
1383    let raw = Arc::into_raw(cell.clone()) as *mut core::ffi::c_void;
1384    // SAFETY: `raw` is a freshly-leaked `Arc<ComponentCell>`; the trampolines
1385    // match its provenance (borrow on tick, reclaim on drop).
1386    if unsafe {
1387        executor.enroll_component(raw, component_tick_trampoline, component_drop_trampoline)
1388    }
1389    .is_err()
1390    {
1391        // SAFETY: enroll rejected the slot, so the executor never took `raw`;
1392        // reclaim the leaked clone here to avoid a permanent leak.
1393        drop(unsafe { Arc::from_raw(raw as *const ComponentCell) });
1394    }
1395
1396    // W4c — capture the executor's volatile param store on the cell so this node's
1397    // callbacks can read `ctx.parameter::<T>(name)`. Non-null only when `nros::main!`
1398    // emitted `apply_param_services` BEFORE this register call (`[param_services]`
1399    // declared); otherwise the store is absent and the field stays null.
1400    #[cfg(feature = "param-services")]
1401    if let Some(server) = executor.params() {
1402        cell.param_server.set(server as *const _);
1403    }
1404
1405    Ok(cell)
1406}
1407
1408/// Phase 257 (W0-B) — C-ABI typed component install. Recovers the shared `Executor`
1409/// from the foreign typed entry's handle (`global_handle()` / `Node::executor_handle()`
1410/// = the `_opaque` `*mut Executor<'static>`; cf. nros-c `get_executor_from_ptr`) and registers
1411/// `C` on it via [`register_node_borrowed`]. The component's `ComponentCell` is kept
1412/// alive by the executor's own callback `Arc` clones (phase-257 D1), so this drops the
1413/// returned cell. Returns `0` on success, `-1` on a null handle or a registration error.
1414///
1415/// This backs the `__nros_component_<pkg>_install(node, executor, self)` symbol
1416/// `nros::node!()` emits — the uniform cross-language install seam (phase-257 D6).
1417///
1418/// # Safety
1419/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes (its
1420/// `nros::global_handle()` / a node's `executor_handle()`), valid for the call.
1421pub unsafe fn install_node_typed<C: ExecutableNode + 'static>(
1422    executor: *mut core::ffi::c_void,
1423) -> i32
1424where
1425    C::State: 'static,
1426{
1427    // SAFETY: forwarded per this fn's contract; no baked params.
1428    unsafe { install_node_typed_with_params::<C>(executor, &[]) }
1429}
1430
1431/// W4a — same as [`install_node_typed`] but seeds the node's [`NodeContext`] with the
1432/// launch-baked `<param>` initial values, so a `register`/`init`-time `ctx.param(name)`
1433/// observes the compile-time launch value (RFC-0004 §10). `params` must outlive the call.
1434///
1435/// # Safety
1436/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes, valid for the call.
1437pub unsafe fn install_node_typed_with_params<C: ExecutableNode + 'static>(
1438    executor: *mut core::ffi::c_void,
1439    params: &[(&str, &str)],
1440) -> i32
1441where
1442    C::State: 'static,
1443{
1444    // SAFETY: forwarded per this fn's contract; no identity injection.
1445    unsafe { install_node_typed_with_node_identity::<C>(executor, params, None) }
1446}
1447
1448/// Phase 268 W1 — same as [`install_node_typed_with_params`] but also injects the
1449/// launch `<node name= namespace=>` identity so `ExecutorSink::create_node` uses it
1450/// instead of the `NodeOptions` default (RFC-0046). `None` → backward-compatible
1451/// (NodeOptions stands). `nros::node!()` calls this variant to carry the identity from
1452/// `RuntimeCtx::node_identity` set by `nros::main!` per component. `params` and
1453/// `node_identity` strings must outlive the call (both are `'static` in the macro emit).
1454///
1455/// # Safety
1456/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes, valid for the call.
1457pub unsafe fn install_node_typed_with_node_identity<C: ExecutableNode + 'static>(
1458    executor: *mut core::ffi::c_void,
1459    params: &[(&str, &str)],
1460    node_identity: Option<(&'static str, &'static str)>,
1461) -> i32
1462where
1463    C::State: 'static,
1464{
1465    if executor.is_null() {
1466        return -1;
1467    }
1468    // SAFETY: per the fn contract, `executor` is the live `*mut Executor<'static>` handle.
1469    let exec: &mut Executor<'static> = unsafe { &mut *(executor as *mut Executor<'static>) };
1470    match register_node_borrowed::<C>(exec, params, node_identity) {
1471        Ok(_cell) => 0,
1472        // Issue 0095 — distinct code for executor-table exhaustion so the macro
1473        // register seam can name `NROS_EXECUTOR_MAX_CBS` instead of an opaque
1474        // `NodeRegister`. Every other failure stays the generic `-1`.
1475        Err(NodeDeclError::ExecutorFull) => -2,
1476        Err(_) => -1,
1477    }
1478}
1479
1480// Phase 258 (Track 2, w5) — `nros_run_components` (the BSP shim that registered
1481// every component via the four-fn-ptr `register_dispatch_slot` then spun) is
1482// gone. It had no callers; owned-spin / BSP entries register through the
1483// `install_node_typed` seam + drive `ExecutorNodeRuntime::spin`.
1484
1485// =============================================================================
1486// Tests
1487// =============================================================================
1488//
1489// Concrete `Executor` construction needs a real RMW backend session
1490// (with `rmw-cffi` on, `Executor::from_session` takes the cffi
1491// session). MockSession only exists when `rmw-cffi` is off — so the
1492// unit tests that exercise live timer firing live in
1493// `packages/testing/nros-tests/tests/phase212_m5a2_component_runtime.rs`
1494// gated behind the `component-runtime-test` feature (pulls
1495// `nros-rmw-zenoh`). The compile-only smoke here verifies the public
1496// types are reachable through the umbrella surface.
1497
1498#[cfg(test)]
1499mod tests {
1500    use super::*;
1501    use crate::node::Node;
1502
1503    #[test]
1504    fn handle_slot_is_observable() {
1505        // Trivial smoke — the handle type carries the slot index.
1506        let h = RegisteredNode::<DummyComp> {
1507            component_idx: 7,
1508            _phantom: PhantomData,
1509        };
1510        assert_eq!(h.slot(), 7);
1511    }
1512
1513    struct DummyComp;
1514    impl Node for DummyComp {
1515        const NAME: &'static str = "dummy";
1516        fn register(_ctx: &mut NodeContext<'_>) -> NodeResult<()> {
1517            Ok(())
1518        }
1519    }
1520    impl ExecutableNode for DummyComp {
1521        type State = ();
1522        fn init() -> Self::State {}
1523        fn on_callback(_s: &mut (), _cb: Callback<'_>, _ctx: &mut CallbackCtx<'_>) {}
1524    }
1525}