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
53// W5-endgame (issue 0843): the DECLARATION is gated too — a bare
54// `extern crate alloc` links the alloc crate into every image and rustc then
55// demands a `#[global_allocator]` even when nothing here allocates. This line
56// is what kept the first heap-free-tier image from linking.
57#[cfg(feature = "alloc")]
58extern crate alloc;
59
60#[cfg(feature = "alloc")]
61use alloc::{boxed::Box, vec::Vec};
62#[cfg(feature = "alloc")]
63use core::time::Duration;
64use core::{
65    cell::{Ref, RefCell, UnsafeCell},
66    marker::PhantomData,
67    mem::MaybeUninit,
68};
69
70// Via nros-core's re-export rather than a new direct dependency — every graph
71// containing `nros` would otherwise move (16 leaf lockfiles, measured on W5.1).
72use nros_core::heapless;
73use portable_atomic::{AtomicUsize, Ordering};
74#[cfg(feature = "alloc")]
75use portable_atomic_util::Arc;
76
77/// phase-391 W5 — entity/callback identifier, fixed-capacity.
78///
79/// Same bound as `nros_node::names::ResolvedName` (`MAX_RESOLVED_NAME_LEN` =
80/// 128): every string these registries hold is a resolved entity id, callback
81/// id, node name or namespace, i.e. name-shaped. An id that does not fit is a
82/// REGISTRATION error, never a truncation.
83type IdStr = heapless::String<{ nros_node::names::MAX_RESOLVED_NAME_LEN }>;
84
85/// W5-endgame alloc-off — nodes one component's `register()` may create.
86/// Components create one node in every in-tree class; 4 leaves room without
87/// costing anything that outlives registration (the sink is a stack local).
88const MAX_SINK_NODES: usize = 4;
89
90// phase-391 W5 (amended by W5-endgame step 2a): the per-cell registry bound,
91// PER KIND, is the `MAX_CELL_ENTITIES` knob — now spelled as `ComponentCell`'s
92// const-parameter DEFAULTS rather than a shared `CELL_REG_CAP` const, so the
93// macro can name tighter per-class bounds while every existing spelling keeps
94// the knob-capped layout. Was `DEFAULT_MAX_METADATA_ENTITIES` (32), borrowed
95// from the metadata twin — but that figure is per-PLAN-shaped and made every
96// cell ~20 KB up front. 8 is per-component-shaped; a component declaring more
97// gets a loud registration error naming `NROS_RUNTIME_MAX_CELL_ENTITIES`.
98
99/// Owned copy of a name-shaped `&str`, or the registration error that says
100/// which knob-less bound it burst.
101fn id_str(s: &str) -> Result<IdStr, NodeDeclError> {
102    IdStr::try_from(s).map_err(|_| NodeDeclError::Runtime)
103}
104
105use crate::{
106    EmbeddedRawPublisher, Executor, GoalId, GoalStatus,
107    node::{
108        ActionExecutor, Callback, CallbackCtx, ClientDispatch, ExecutableNode, NodeContext,
109        NodeDeclError, NodeOptions, NodeResult, NodeRuntime, PublisherResolver, TickCtx,
110    },
111    node_metadata::{
112        CallbackEffectKind, CallbackId, EntityId, EntityKind, EntityMetadata, NodeId as MetaNodeId,
113    },
114};
115
116// Phase 212.N.7 closing sweep — `component_register_symbol` retired
117// (no live callers after the BSP baker + macro extern emit were
118// removed). The former re-export here is gone.
119
120// =============================================================================
121// Public types
122// =============================================================================
123
124/// Opaque handle returned by
125/// [`ExecutorNodeRuntime::register_node`].
126///
127/// `C` distinguishes handles at the type level so a caller who keeps
128/// the handle can later (post-M.5.a.3) recover a typed mut-state
129/// borrow. For today the handle is purely a witness that registration
130/// succeeded.
131pub struct RegisteredNode<C: ExecutableNode> {
132    component_idx: usize,
133    _phantom: PhantomData<fn() -> C>,
134}
135
136impl<C: ExecutableNode> RegisteredNode<C> {
137    /// Slot index of this component inside the runtime.
138    pub fn slot(&self) -> usize {
139        self.component_idx
140    }
141}
142
143/// Errors returned by the runtime entry points.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum ExecutorError {
146    /// One of the components' register / lifecycle calls failed.
147    Node(NodeDeclError),
148    /// The executor's spin loop returned an unexpected error.
149    SpinFailed,
150}
151
152impl From<NodeDeclError> for ExecutorError {
153    fn from(e: NodeDeclError) -> Self {
154        Self::Node(e)
155    }
156}
157
158// =============================================================================
159// Internal slot — type-erases the component's `State` so the runtime
160// can hold a heterogeneous vec.
161// =============================================================================
162
163trait ComponentSlot {
164    fn dispatch(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>);
165    fn tick(&mut self, ctx: &mut TickCtx<'_>);
166}
167
168struct TypedSlot<C: ExecutableNode> {
169    state: C::State,
170    _phantom: PhantomData<fn() -> C>,
171}
172
173impl<C: ExecutableNode> ComponentSlot for TypedSlot<C> {
174    fn dispatch(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>) {
175        C::on_callback(
176            &mut self.state,
177            Callback::__from_id(CallbackId::new(cb_id)),
178            ctx,
179        );
180    }
181    fn tick(&mut self, ctx: &mut TickCtx<'_>) {
182        C::tick(&mut self.state, ctx);
183    }
184}
185
186// Phase 258 (Track 2, w5) — `BspDispatchSlot` (the type-erased
187// four-fn-ptr BSP dispatch slot) is gone with the retired
188// `register_dispatch_slot` / `nros_run_components` BSP-baker path. The
189// only remaining `ComponentSlot` impl is `TypedSlot<C>` above (used by
190// `register_node` / `register_node_borrowed` / `install_node_typed`).
191
192/// phase-391 W5.3b — per-class slot storage the `nros::node!` macro emits.
193///
194/// One `static` of this type per macro expansion, so `C` is CONCRETE at the
195/// emission site and the storage is sized `size_of::<TypedSlot<C>>()` exactly —
196/// no byte-budget guessing on the FFI install path. The type is public API for
197/// the MACRO EMIT only; it never crosses the C ABI (the trampoline keeps its
198/// `(ptr, ptr, ptr) -> i32` shape), which is what keeps the const-generic
199/// parameter legal under the "no const generics on FFI-visible types" rule.
200///
201/// `N` is the per-class INSTANCE cap: the launch path bakes one identity per
202/// plan node and can name the same class twice, so this is an array, not one
203/// slot. Taking past `N` is a registration error (`take` returns `None`), the
204/// same Full shape as the executor's node table. Slots are one-shot — `next`
205/// never rewinds — so storage is never re-initialised under a stale reference.
206pub struct ComponentSlotStorage<
207    C: ExecutableNode,
208    const N: usize = { crate::config::MAX_CLASS_INSTANCES },
209    const PUBS: usize = { crate::config::MAX_CELL_ENTITIES },
210    const SVCS: usize = { crate::config::MAX_CELL_ENTITIES },
211    const ACTC: usize = { crate::config::MAX_CELL_ENTITIES },
212    const ACTS: usize = { crate::config::MAX_CELL_ENTITIES },
213    const SSRV: usize = { crate::config::MAX_CELL_ENTITIES },
214> {
215    slots: [UnsafeCell<MaybeUninit<TypedSlot<C>>>; N],
216    /// W5-endgame step 2b (issue 0857) — the per-instance CELL lives here too,
217    /// so the whole component (state + registries) is `.bss`, not heap. Handed
218    /// out pairwise with its slot by `take`; the cell's `slot` field then
219    /// borrows the sibling region, which is sound because the two arrays are
220    /// distinct `UnsafeCell`s claimed by the same monotonic index.
221    cells: [UnsafeCell<MaybeUninit<ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>>>; N],
222    next: AtomicUsize,
223}
224
225// SAFETY: `take` hands each slot out at most once (monotonic `fetch_add`), so
226// no two references to the same `UnsafeCell` contents ever coexist.
227unsafe impl<
228    C: ExecutableNode,
229    const N: usize,
230    const PUBS: usize,
231    const SVCS: usize,
232    const ACTC: usize,
233    const ACTS: usize,
234    const SSRV: usize,
235> Sync for ComponentSlotStorage<C, N, PUBS, SVCS, ACTC, ACTS, SSRV>
236{
237}
238
239impl<
240    C: ExecutableNode,
241    const N: usize,
242    const PUBS: usize,
243    const SVCS: usize,
244    const ACTC: usize,
245    const ACTS: usize,
246    const SSRV: usize,
247> ComponentSlotStorage<C, N, PUBS, SVCS, ACTC, ACTS, SSRV>
248{
249    /// Const constructor — the macro emits `static STORE: ... = ...::new();`.
250    #[allow(clippy::new_without_default)]
251    pub const fn new() -> Self {
252        Self {
253            slots: [const { UnsafeCell::new(MaybeUninit::uninit()) }; N],
254            cells: [const { UnsafeCell::new(MaybeUninit::uninit()) }; N],
255            next: AtomicUsize::new(0),
256        }
257    }
258
259    /// Hand out the next uninitialised slot, or `None` past the instance cap.
260    ///
261    /// `clippy::mut_from_ref` fires because a `&mut` derived from a `&self` is
262    /// usually unsound. It is not here, and the reason is already written down
263    /// on the `unsafe impl Sync` above: the monotonic `fetch_add` hands each
264    /// index out at most once, so no two `&mut` to one `UnsafeCell` can coexist,
265    /// and `i >= N` bounds it. Allowed rather than restructured — the lint
266    /// cannot see the atomic, and the invariant is the point of the type.
267    #[allow(clippy::mut_from_ref)]
268    fn take(
269        &'static self,
270    ) -> Option<(
271        &'static mut MaybeUninit<TypedSlot<C>>,
272        &'static mut MaybeUninit<ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>>,
273    )> {
274        let i = self.next.fetch_add(1, Ordering::Relaxed);
275        if i >= N {
276            return None;
277        }
278        // SAFETY: `i` was claimed exactly once by the fetch_add above, so these
279        // are the only references that will ever exist to `slots[i]`'s /
280        // `cells[i]`'s contents; the storage is a `static`, so `'static` is
281        // honest.
282        Some(unsafe { (&mut *self.slots[i].get(), &mut *self.cells[i].get()) })
283    }
284}
285
286/// Placement-init a [`TypedSlot<C>`] into `slot` and erase it.
287///
288/// Both registration paths end here: the FFI path with a slot from the
289/// macro-emitted [`ComponentSlotStorage`], the dynamic path with one carved
290/// from the runtime's caller-supplied backing.
291fn place_slot<C: ExecutableNode>(
292    slot: &'static mut MaybeUninit<TypedSlot<C>>,
293) -> &'static mut dyn ComponentSlot
294where
295    C::State: 'static,
296{
297    slot.write(TypedSlot::<C> {
298        state: C::init(),
299        _phantom: PhantomData,
300    })
301}
302
303/// phase-391 W5-endgame step 2b (issue 0857) — the NON-GENERIC head of every
304/// `ComponentCell<..>`, guaranteed FIRST FIELD by the cell's `#[repr(C)]`.
305///
306/// The executor's enrolled-component state is a thin pointer to a cell whose
307/// const parameters only the enrolling monomorphization knows. The tick/drop
308/// trampolines are monomorphized alongside it and cast back to the concrete
309/// type; the one consumer that iterates ALL enrolled states regardless of
310/// class — the dispatch-stats fold — casts to this header instead. Both casts
311/// are sound because the header is the first field of a `repr(C)` struct, so
312/// a cell pointer IS a header pointer.
313struct CellHeader {
314    callback_dispatches: AtomicUsize,
315    message_dispatches: AtomicUsize,
316}
317
318/// Shared per-component cell. Subscription / timer closures registered
319/// against the executor hold a [`CellHandle`] so they can dispatch +
320/// publish back through the resolver.
321///
322/// phase-391 W5-endgame step 2a (issue 0857) — generic over the four registry
323/// capacities, defaulted to the `MAX_CELL_ENTITIES` knob so every existing
324/// spelling (`ComponentCell`) keeps meaning the pool-capped layout. The
325/// per-class macro emission names tighter bounds; everything downstream of
326/// construction consumes the cell through the non-generic [`CellView`], so
327/// the const params never cross an FFI or public signature.
328#[repr(C)]
329struct ComponentCell<
330    const PUBS: usize = { crate::config::MAX_CELL_ENTITIES },
331    const SVCS: usize = { crate::config::MAX_CELL_ENTITIES },
332    const ACTC: usize = { crate::config::MAX_CELL_ENTITIES },
333    const ACTS: usize = { crate::config::MAX_CELL_ENTITIES },
334    const SSRV: usize = { crate::config::MAX_CELL_ENTITIES },
335> {
336    /// W5-endgame step 2b — MUST stay first; see [`CellHeader`].
337    header: CellHeader,
338    /// phase-391 W5.3b — BORROWED from per-class or pool storage, not boxed.
339    /// The storage is `'static` (macro-emitted static / caller backing), so the
340    /// reference outlives every closure that dispatches through it.
341    slot: RefCell<&'static mut dyn ComponentSlot>,
342    publishers: RefCell<heapless::Vec<(IdStr, EmbeddedRawPublisher), PUBS>>,
343    // Phase 212.M-F.23 — declarative service/action CLIENT + action-SERVER
344    // handles, keyed by stable entity id, resolved during tick dispatch.
345    // Mirror of the orchestration `GenClientDispatch`/`GenActionExec` arrays,
346    // but built at registration time on the single-node runtime. Service- and
347    // action-SERVER request/goal dispatch is owned by the executor (the
348    // trampolines registered in `create_entity`); only the action-server
349    // handle is kept here so the tick can complete goals / publish feedback.
350    service_clients: RefCell<heapless::Vec<(IdStr, crate::HandleId), SVCS>>,
351    action_clients: RefCell<heapless::Vec<(IdStr, usize), ACTC>>,
352    action_servers: RefCell<heapless::Vec<(IdStr, crate::ActionServerRawHandle), ACTS>>,
353    // W5-endgame ctx slabs (issue 0857) — the leaked trampoline contexts the
354    // sink used to `Box::into_raw` live INSIDE the cell, sized by the class's
355    // declared bounds, so the macro path allocates nothing. Monotonic
356    // counters; a full slab is a loud registration error. The entries die
357    // with the cell (executor drop), after which no trampoline can fire.
358    svc_ctxs: [UnsafeCell<MaybeUninit<ServiceServerCtx>>; SSRV],
359    svc_ctxs_used: core::cell::Cell<usize>,
360    act_srv_ctxs: [UnsafeCell<MaybeUninit<ActionServerCtx>>; ACTS],
361    act_srv_ctxs_used: core::cell::Cell<usize>,
362    act_cli_ctxs: [UnsafeCell<MaybeUninit<ActionClientCtx>>; ACTC],
363    act_cli_ctxs_used: core::cell::Cell<usize>,
364    // Phase 264 W4c — raw pointer to the executor's volatile parameter store, so a
365    // subscription/timer/service/action callback can read `ctx.parameter::<T>(name)`.
366    // The callback closures + leaked trampolines hold only a `CellHandle` (the
367    // executor is unreachable when they fire), so the store address is threaded HERE by
368    // `apply_param_services`' post-pass (mirrors the `run_ticks` disjoint borrow). Null
369    // until param services are registered. Stable for the executor's life: the server
370    // lives in a `Box<ParamState>`, and since phase-382 W2' its SLOTS live in
371    // caller-owned storage it merely borrows — a fixed-length table either way, so a
372    // declare never moves anything this pointer or a stored `&ParameterValue` names.
373    #[cfg(feature = "param-services")]
374    param_server: core::cell::Cell<*const nros_params::ParameterServer<'static>>,
375}
376
377impl<const PUBS: usize, const SVCS: usize, const ACTC: usize, const ACTS: usize, const SSRV: usize>
378    Drop for ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>
379{
380    fn drop(&mut self) {
381        // phase-391 W5.3b — the slot is BORROWED from one-shot storage, so the
382        // component state's destructor no longer rides a `Box` drop. Run it
383        // here instead: the cell drops exactly once (the executor's drop
384        // trampoline runs it in place on the enrolled path; the dynamic
385        // path's `Arc` drops it on runtime drop), and the
386        // storage slot is never handed out again (`take`/`next_slot` counters
387        // are monotonic), so nothing can observe the dropped bytes.
388        // W5-endgame ctx slabs — run the placed ctxs' destructors (their
389        // `CellHandle` may hold an `Arc` on the pooled arm). Monotonic
390        // counters bound the initialized prefix exactly.
391        for i in 0..self.svc_ctxs_used.get() {
392            // SAFETY: entries [0, used) were initialized by `place_service_ctx`
393            // and are dropped exactly once, here.
394            unsafe { (*self.svc_ctxs[i].get()).assume_init_drop() };
395        }
396        for i in 0..self.act_srv_ctxs_used.get() {
397            // SAFETY: as above, for `place_action_server_ctx`.
398            unsafe { (*self.act_srv_ctxs[i].get()).assume_init_drop() };
399        }
400        for i in 0..self.act_cli_ctxs_used.get() {
401            // SAFETY: as above, for `place_action_client_ctx`.
402            unsafe { (*self.act_cli_ctxs[i].get()).assume_init_drop() };
403        }
404        let slot: &mut &'static mut dyn ComponentSlot = self.slot.get_mut();
405        let p: *mut dyn ComponentSlot = &raw mut **slot;
406        // SAFETY: `p` targets storage uniquely owned by this cell (handed out
407        // at most once), reachable only through the reference being dropped
408        // with us; it is dropped at most once because the cell is.
409        unsafe { core::ptr::drop_in_place(p) };
410    }
411}
412
413impl<const PUBS: usize, const SVCS: usize, const ACTC: usize, const ACTS: usize, const SSRV: usize>
414    ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>
415{
416    /// Phase 264 W4c — the executor's parameter store, or `None` until
417    /// `apply_param_services` threads it in. The deref is sound: single-threaded
418    /// executor, param services mutate the server only outside callback dispatch.
419    #[cfg(feature = "param-services")]
420    fn param_server(&self) -> Option<&nros_params::ParameterServer<'static>> {
421        let ptr = self.param_server.get();
422        if ptr.is_null() {
423            None
424        } else {
425            // SAFETY: `ptr` is the address of the executor's boxed `ParameterServer`
426            // (stable for the executor's life); param services mutate it before/after
427            // dispatch (`spin.rs` pre/post), never during, so no aliasing `&mut` is live.
428            Some(unsafe { &*ptr })
429        }
430    }
431}
432
433/// phase-391 W5-endgame step 1 (issue 0857) — the NON-GENERIC view every
434/// dispatch/tick path consumes instead of the concrete [`ComponentCell`].
435///
436/// Why a trait and not the cell: the endgame emits a per-class cell whose
437/// registries are sized to the class's DECLARED entity counts (const generics,
438/// which the "never public to other languages" rule confines to the macro
439/// emission), while the dynamic `register_node` path keeps pool-backed cells at
440/// the knob caps. Two cell layouts, ONE dispatch implementation — this trait is
441/// the seam that makes the split affordable. `Ref<'_, [T]>` erases the
442/// `heapless::Vec` capacity; `try_with_slot_mut` erases the slot's storage
443/// shape (borrowed `&'static mut dyn` today, fused inline in the per-class
444/// static tomorrow).
445///
446/// Object-safe on purpose: the FFI trampolines carry a THIN `*mut c_void`, so
447/// each concrete cell type casts back to itself and only then widens to
448/// `&dyn CellView`.
449trait CellView {
450    /// Bump the dispatch counters (`message` = payload was non-empty).
451    fn note_dispatch(&self, message: bool);
452    /// The publisher registered under `entity_id`, if this component declared
453    /// one. Holds the registry's shared borrow for the returned `Ref`'s life.
454    fn publisher(&self, entity_id: &str) -> Option<Ref<'_, EmbeddedRawPublisher>>;
455    fn service_clients(&self) -> Ref<'_, [(IdStr, crate::HandleId)]>;
456    fn action_clients(&self) -> Ref<'_, [(IdStr, usize)]>;
457    fn action_servers(&self) -> Ref<'_, [(IdStr, crate::ActionServerRawHandle)]>;
458    /// Run `f` over the component slot unless it is already mutably borrowed
459    /// (a re-entrant dispatch on the same cell) — in which case the dispatch
460    /// is dropped, exactly as the pre-trait `try_borrow_mut` spelled it.
461    fn try_with_slot_mut(&self, f: &mut dyn FnMut(&mut dyn ComponentSlot));
462    /// The executor's parameter store, or `None` until `apply_param_services`
463    /// threads it in.
464    #[cfg(feature = "param-services")]
465    fn view_param_server(&self) -> Option<&nros_params::ParameterServer<'static>>;
466    /// The `(callbacks, messages)` dispatch counters. Only the dynamic
467    /// runtime's stats fold reads them through the trait; the macro path's
468    /// fold reads the `CellHeader` directly.
469    #[cfg(feature = "alloc")]
470    fn dispatch_counts(&self) -> (usize, usize);
471    /// Registration-time registry appends. `Err(())` = registry full — the
472    /// component declared more entities of that kind than its cell's bound,
473    /// which the caller reports loudly (never a silent drop).
474    fn push_publisher(&self, id: IdStr, handle: EmbeddedRawPublisher) -> Result<(), ()>;
475    fn push_service_client(&self, id: IdStr, handle: crate::HandleId) -> Result<(), ()>;
476    fn push_action_client(&self, id: IdStr, entry_index: usize) -> Result<(), ()>;
477    fn push_action_server(&self, id: IdStr, handle: crate::ActionServerRawHandle)
478    -> Result<(), ()>;
479    /// W5-endgame ctx slabs — place a trampoline context into the cell's slab
480    /// and return its stable address for the executor's C-ABI registration.
481    /// `Err(())` = slab full (the class declared fewer of that kind than
482    /// `register()` creates). The entry lives — and is dropped — with the cell.
483    fn place_service_ctx(&self, ctx: ServiceServerCtx) -> Result<*mut core::ffi::c_void, ()>;
484    fn place_action_server_ctx(&self, ctx: ActionServerCtx) -> Result<*mut core::ffi::c_void, ()>;
485    fn place_action_client_ctx(&self, ctx: ActionClientCtx) -> Result<*mut core::ffi::c_void, ()>;
486}
487
488impl<const PUBS: usize, const SVCS: usize, const ACTC: usize, const ACTS: usize, const SSRV: usize>
489    CellView for ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>
490{
491    fn note_dispatch(&self, message: bool) {
492        self.header
493            .callback_dispatches
494            .fetch_add(1, Ordering::Relaxed);
495        if message {
496            self.header
497                .message_dispatches
498                .fetch_add(1, Ordering::Relaxed);
499        }
500    }
501
502    #[cfg(feature = "alloc")]
503    fn dispatch_counts(&self) -> (usize, usize) {
504        (
505            self.header.callback_dispatches.load(Ordering::Relaxed),
506            self.header.message_dispatches.load(Ordering::Relaxed),
507        )
508    }
509
510    fn push_publisher(&self, id: IdStr, handle: EmbeddedRawPublisher) -> Result<(), ()> {
511        self.publishers
512            .borrow_mut()
513            .push((id, handle))
514            .map_err(|_| ())
515    }
516
517    fn push_service_client(&self, id: IdStr, handle: crate::HandleId) -> Result<(), ()> {
518        self.service_clients
519            .borrow_mut()
520            .push((id, handle))
521            .map_err(|_| ())
522    }
523
524    fn push_action_client(&self, id: IdStr, entry_index: usize) -> Result<(), ()> {
525        self.action_clients
526            .borrow_mut()
527            .push((id, entry_index))
528            .map_err(|_| ())
529    }
530
531    fn push_action_server(
532        &self,
533        id: IdStr,
534        handle: crate::ActionServerRawHandle,
535    ) -> Result<(), ()> {
536        self.action_servers
537            .borrow_mut()
538            .push((id, handle))
539            .map_err(|_| ())
540    }
541
542    fn place_service_ctx(&self, ctx: ServiceServerCtx) -> Result<*mut core::ffi::c_void, ()> {
543        let i = self.svc_ctxs_used.get();
544        if i >= SSRV {
545            return Err(());
546        }
547        self.svc_ctxs_used.set(i + 1);
548        // SAFETY: `i` is claimed exactly once (monotonic counter, single-
549        // threaded executor contract), so no other reference to this entry
550        // exists; the address is stable because the cell never moves after
551        // placement.
552        Ok(
553            unsafe { (*self.svc_ctxs[i].get()).write(ctx) as *mut ServiceServerCtx }
554                as *mut core::ffi::c_void,
555        )
556    }
557
558    fn place_action_server_ctx(&self, ctx: ActionServerCtx) -> Result<*mut core::ffi::c_void, ()> {
559        let i = self.act_srv_ctxs_used.get();
560        if i >= ACTS {
561            return Err(());
562        }
563        self.act_srv_ctxs_used.set(i + 1);
564        // SAFETY: as `place_service_ctx`.
565        Ok(
566            unsafe { (*self.act_srv_ctxs[i].get()).write(ctx) as *mut ActionServerCtx }
567                as *mut core::ffi::c_void,
568        )
569    }
570
571    fn place_action_client_ctx(&self, ctx: ActionClientCtx) -> Result<*mut core::ffi::c_void, ()> {
572        let i = self.act_cli_ctxs_used.get();
573        if i >= ACTC {
574            return Err(());
575        }
576        self.act_cli_ctxs_used.set(i + 1);
577        // SAFETY: as `place_service_ctx`.
578        Ok(
579            unsafe { (*self.act_cli_ctxs[i].get()).write(ctx) as *mut ActionClientCtx }
580                as *mut core::ffi::c_void,
581        )
582    }
583
584    fn publisher(&self, entity_id: &str) -> Option<Ref<'_, EmbeddedRawPublisher>> {
585        Ref::filter_map(self.publishers.borrow(), |pubs| {
586            pubs.iter().find(|(id, _)| id == entity_id).map(|(_, p)| p)
587        })
588        .ok()
589    }
590
591    fn service_clients(&self) -> Ref<'_, [(IdStr, crate::HandleId)]> {
592        Ref::map(self.service_clients.borrow(), |v| v.as_slice())
593    }
594
595    fn action_clients(&self) -> Ref<'_, [(IdStr, usize)]> {
596        Ref::map(self.action_clients.borrow(), |v| v.as_slice())
597    }
598
599    fn action_servers(&self) -> Ref<'_, [(IdStr, crate::ActionServerRawHandle)]> {
600        Ref::map(self.action_servers.borrow(), |v| v.as_slice())
601    }
602
603    fn try_with_slot_mut(&self, f: &mut dyn FnMut(&mut dyn ComponentSlot)) {
604        if let Ok(mut slot) = self.slot.try_borrow_mut() {
605            f(*slot);
606        }
607    }
608
609    #[cfg(feature = "param-services")]
610    fn view_param_server(&self) -> Option<&nros_params::ParameterServer<'static>> {
611        self.param_server()
612    }
613}
614
615/// phase-391 W5-endgame step 2b — the two ways a cell is held, one spelling.
616///
617/// The macro/FFI install path places its cell in per-class static storage and
618/// holds `&'static`; the dynamic `register_node` path (alloc-gated, hosted)
619/// keeps the `Arc` it always had — its cells are not 0857's embedded heap
620/// cost, and `Arc` gives them the drop-on-runtime-drop lifetime the dynamic
621/// path needs. Closures and leaked ctxs clone the HANDLE (a ref copy or an
622/// `Arc` bump) and dispatch through [`CellHandle::view`].
623#[derive(Clone)]
624enum CellHandle {
625    Static(&'static dyn CellView),
626    #[cfg(feature = "alloc")]
627    Pooled(Arc<ComponentCell>),
628}
629
630impl CellHandle {
631    fn view(&self) -> &dyn CellView {
632        match self {
633            CellHandle::Static(v) => *v,
634            #[cfg(feature = "alloc")]
635            CellHandle::Pooled(cell) => cell.as_ref(),
636        }
637    }
638}
639
640/// `PublisherResolver` implementation backed by a [`CellView`].
641struct CellResolver<'a> {
642    cell: &'a dyn CellView,
643}
644
645impl PublisherResolver for CellResolver<'_> {
646    fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()> {
647        // issue 0736 — the two arms report DIFFERENT failures. A miss on
648        // `lookup_publisher` means this component declared no publisher for the
649        // entity, so nothing ever reached the transport; a `publish_raw` error
650        // means the transport had the sample and refused it. Both used to
651        // return `Runtime`, which is why a console full of "publish FAILED"
652        // could not distinguish a wiring bug from a congested link.
653        match self.cell.publisher(entity_id) {
654            Some(p) => p.publish_raw(data).map_err(|_| NodeDeclError::Runtime),
655            None => Err(NodeDeclError::UnknownPublisher),
656        }
657    }
658}
659
660// Phase 212.M-F.23 — the `UnsupportedActions` / `UnsupportedClients` tick-side
661// stubs are retired. Real service/action client + action-server dispatch on the
662// single-node runtime lives in `RuntimeClientDispatch` / `RuntimeActions`
663// (below), wired into `run_ticks`.
664
665// =============================================================================
666// ExecutorNodeRuntime
667// =============================================================================
668
669/// Executor-backed component runtime.
670///
671/// Owns the [`Executor`] and one slot per registered component. The
672/// register / spin lifecycle:
673///
674/// 1. [`from_executor`](Self::from_executor) wraps an open
675///    [`Executor`].
676/// 2. [`register_node`](Self::register_node) builds the
677///    component's `State`, runs [`Node::register`](crate::node::Node::register) over an
678///    internal [`NodeRuntime`] adapter that materialises nodes /
679///    pubs / subs / timers on the real executor, and wires each
680///    subscription + timer callback to dispatch into
681///    [`ExecutableNode::on_callback`] with the right
682///    [`CallbackId`].
683/// 3. [`spin`](Self::spin) / [`spin_once`](Self::spin_once) drive the
684///    executor; between iterations every registered component's
685///    [`ExecutableNode::tick`] runs.
686#[cfg(feature = "alloc")]
687pub struct ExecutorNodeRuntime {
688    executor: Executor<'static>,
689    components: Vec<Arc<ComponentCell>>,
690    /// phase-391 W5.3b — slot storage for [`register_node`](Self::register_node).
691    /// Always present: the leaking constructor allocates it once, `new_in`
692    /// carves the caller's backing.
693    pool: ComponentPool,
694}
695
696/// Bump view over the runtime's slot backing. Raw pointer, not a slice, so the
697/// runtime stays movable — the SLOTS never move; they live in the backing.
698#[cfg(feature = "alloc")]
699struct ComponentPool {
700    base: *mut MaybeUninit<u8>,
701    len_bytes: usize,
702    per_slot: usize,
703    capacity: usize,
704    next: usize,
705}
706
707// SAFETY: the pool is confined to the runtime (`&mut self` on every use); the
708// raw pointer targets `'static` backing handed over at construction.
709#[cfg(feature = "alloc")]
710unsafe impl Send for ComponentPool {}
711
712#[cfg(feature = "alloc")]
713impl ComponentPool {
714    /// Next disjoint slot window, or `None` when the pool is full.
715    fn next_slot(&mut self) -> Option<&'static mut [MaybeUninit<u8>]> {
716        if self.next >= self.capacity {
717            return None;
718        }
719        let off = self.next * self.per_slot;
720        debug_assert!(off + self.per_slot <= self.len_bytes);
721        self.next += 1;
722        // SAFETY: `base` points at `'static` backing of `len_bytes`; the
723        // window is in-bounds by the checks above and DISJOINT from every
724        // earlier one because `next` only increases.
725        Some(unsafe { core::slice::from_raw_parts_mut(self.base.add(off), self.per_slot) })
726    }
727}
728
729#[cfg(feature = "alloc")]
730impl ExecutorNodeRuntime {
731    /// Wrap an already-built [`Executor`], LEAKING the slot backing.
732    ///
733    /// The convenience constructor — allocates `RuntimeSizing::DEFAULT`'s
734    /// backing once and never frees it, exactly the relationship
735    /// `Executor::from_session` has to `Executor::open_in`. Use
736    /// [`new_in`](Self::new_in) on an image that must not allocate.
737    pub fn from_executor(executor: Executor<'static>) -> Self {
738        let sizing = crate::runtime_storage::RuntimeSizing::DEFAULT;
739        let backing: &'static mut [MaybeUninit<u64>] =
740            Vec::leak(alloc::vec![MaybeUninit::uninit(); sizing.u64_len()]);
741        // SAFETY: freshly leaked — `'static`, uniquely owned, exactly
742        // `u64_len()` words.
743        unsafe { Self::new_in(executor, backing, sizing) }
744    }
745
746    /// Wrap an already-built [`Executor`] over CALLER-SUPPLIED slot storage.
747    ///
748    /// Size `backing` with [`crate::runtime_storage::RuntimeSizing::u64_len`]; a short one panics,
749    /// naming both sizes (fail-loud on every profile — a short backing is
750    /// silent corruption, the `executor::storage::carve` / issue #131 lesson).
751    ///
752    /// # Safety
753    /// `backing` must be uniquely owned by this runtime for its whole life:
754    /// slots carved from it are handed out as `&'static mut`, so aliasing it
755    /// anywhere else is undefined behaviour.
756    pub unsafe fn new_in(
757        executor: Executor<'static>,
758        backing: &'static mut [MaybeUninit<u64>],
759        sizing: crate::runtime_storage::RuntimeSizing,
760    ) -> Self {
761        let need = sizing.u64_len();
762        assert!(
763            backing.len() >= need,
764            "component pool backing too small: {} u64 words < {} required for {} slot(s) \
765             of {} bytes — size it with RuntimeSizing::u64_len()",
766            backing.len(),
767            need,
768            sizing.components,
769            sizing.slot_bytes,
770        );
771        Self {
772            executor,
773            components: Vec::new(),
774            pool: ComponentPool {
775                base: backing.as_mut_ptr() as *mut MaybeUninit<u8>,
776                len_bytes: backing.len() * 8,
777                per_slot: (sizing.slot_bytes.div_ceil(8) * 8).max(1),
778                capacity: sizing.components,
779                next: 0,
780            },
781        }
782    }
783
784    /// Borrow the underlying executor.
785    pub fn executor(&self) -> &Executor<'static> {
786        &self.executor
787    }
788
789    /// Mutably borrow the underlying executor — for advanced wiring
790    /// (parameter services, custom guard conditions). Don't use during
791    /// [`spin`](Self::spin) from another thread; the runtime is
792    /// single-threaded.
793    pub fn executor_mut(&mut self) -> &mut Executor<'static> {
794        &mut self.executor
795    }
796
797    /// RFC-0052 / phase-296 W5.4 — lower a tier's RTOS-agnostic scheduling
798    /// policy onto this executor's DEFAULT scheduling context. One `Executor`
799    /// per tier means "the tier's policy" == "this executor's default SC";
800    /// per-group/per-handle bindings still take precedence.
801    ///
802    /// **Portable** across every board — call from each board's `run_tiers`
803    /// after building the runtime. Takes the tier fields as primitives (not a
804    /// `TierSpec`) so `nros` needs no board/platform dependency; a board passes
805    /// `tier.class`, `tier.period_us`, … straight through.
806    ///
807    /// `real_time` + `budget_us` + `period_us` → [`SchedClass::Sporadic`](crate::SchedClass::Sporadic);
808    /// `best_effort` → [`SchedClass::BestEffort`](crate::SchedClass::BestEffort); `time_triggered` +
809    /// `period_us` → the cyclic dispatcher (major frame = period, window =
810    /// `budget_us` or the whole frame); `deadline_us` sets the SC deadline and
811    /// `deadline_policy` its action. A tier with no class/budget/deadline
812    /// leaves the default `Fifo` SC untouched (byte-identical pre-W3 behavior).
813    pub fn apply_tier_sched_policy(
814        &mut self,
815        class: Option<&str>,
816        period_us: Option<u64>,
817        budget_us: Option<u64>,
818        deadline_us: Option<u64>,
819        deadline_policy: Option<&str>,
820    ) {
821        use crate::SchedContext;
822        // Common backend (RFC-0052): the tier→SchedContext lowering lives ONCE
823        // in `SchedContext::from_tier_policy`, shared with the C / C++ entries
824        // (`nros_{c,cpp}_create_sched_context_from_policy`) so the mapping never
825        // drifts between languages. `None` → keep the default `Fifo` SC.
826        let Some((sc, tt_frame)) = SchedContext::from_tier_policy(
827            class,
828            period_us,
829            budget_us,
830            deadline_us,
831            deadline_policy,
832        ) else {
833            return;
834        };
835        if let Some(frame_us) = tt_frame {
836            self.executor_mut()
837                .register_time_triggered_dispatcher(frame_us);
838        }
839        self.executor_mut().set_default_sched_context(sc);
840    }
841
842    /// Number of registered components.
843    pub fn component_count(&self) -> usize {
844        self.components.len()
845    }
846
847    /// Register a [`Node`](crate::node::Node) (which must also be
848    /// [`ExecutableNode`]) into this runtime. Builds the
849    /// component's `State` (via [`ExecutableNode::init`]) and
850    /// walks [`Node::register`](crate::node::Node::register) over the live executor — every
851    /// declared node / pub / sub / timer materialises as a real
852    /// executor handle, and subscription + timer callbacks are wired
853    /// to dispatch into [`ExecutableNode::on_callback`].
854    pub fn register_node<C: ExecutableNode + 'static>(&mut self) -> NodeResult<RegisteredNode<C>>
855    where
856        C::State: 'static,
857    {
858        // phase-391 W5.3b — draw slot storage from the runtime's pool instead
859        // of boxing. Full → the executor-table-Full class (raise
860        // NROS_RUNTIME_MAX_COMPONENTS); too big for `slot_bytes` → likewise a
861        // registration error (raise NROS_RUNTIME_COMPONENT_SLOT_BYTES), never
862        // a truncation.
863        let raw = self.pool.next_slot().ok_or(NodeDeclError::ExecutorFull)?;
864        if raw.len() < core::mem::size_of::<TypedSlot<C>>()
865            || !(raw.as_ptr() as usize).is_multiple_of(core::mem::align_of::<TypedSlot<C>>())
866        {
867            return Err(NodeDeclError::Runtime);
868        }
869        // SAFETY: sized + aligned for `TypedSlot<C>` by the check above;
870        // uniquely owned (`next_slot` windows are disjoint); `'static` backing.
871        let slot_mu: &'static mut MaybeUninit<TypedSlot<C>> =
872            unsafe { &mut *(raw.as_mut_ptr() as *mut MaybeUninit<TypedSlot<C>>) };
873        let cell = Arc::new(ComponentCell {
874            slot: RefCell::new(place_slot::<C>(slot_mu)),
875            publishers: RefCell::new(heapless::Vec::new()),
876            service_clients: RefCell::new(heapless::Vec::new()),
877            action_clients: RefCell::new(heapless::Vec::new()),
878            action_servers: RefCell::new(heapless::Vec::new()),
879            svc_ctxs: [const { UnsafeCell::new(MaybeUninit::uninit()) }; _],
880            svc_ctxs_used: core::cell::Cell::new(0),
881            act_srv_ctxs: [const { UnsafeCell::new(MaybeUninit::uninit()) }; _],
882            act_srv_ctxs_used: core::cell::Cell::new(0),
883            act_cli_ctxs: [const { UnsafeCell::new(MaybeUninit::uninit()) }; _],
884            act_cli_ctxs_used: core::cell::Cell::new(0),
885            header: CellHeader {
886                callback_dispatches: AtomicUsize::new(0),
887                message_dispatches: AtomicUsize::new(0),
888            },
889            // W4c — set by `apply_param_services` once the store exists.
890            #[cfg(feature = "param-services")]
891            param_server: core::cell::Cell::new(core::ptr::null()),
892        });
893        let component_idx = self.components.len();
894        self.components.push(cell.clone());
895
896        let mut sink = ExecutorSink {
897            executor: &mut self.executor,
898            cell: CellHandle::Pooled(cell.clone()),
899            nodes: heapless::Vec::new(),
900            node_identity: None, // direct API — no launch injection
901            remaps: &[],         // direct API — no launch remaps
902            qos_overrides: &[],  // direct API — no plan overrides
903        };
904        let sink_dyn: &mut dyn NodeRuntime = &mut sink;
905        let mut context = NodeContext::new(C::NAME, sink_dyn);
906        let result = C::register(&mut context);
907        if result.is_err() {
908            // Roll back the slot push so `component_count` stays
909            // consistent with what users observe.
910            self.components.pop();
911        }
912        result?;
913
914        // W4c — capture the executor's volatile param store on the cell (if param
915        // services were registered before this call) so the node's callbacks can read
916        // `ctx.parameter::<T>(name)`. Mirrors the install-seam path.
917        #[cfg(feature = "param-services")]
918        if let Some(server) = self.executor.params() {
919            cell.param_server.set(server as *const _);
920        }
921
922        Ok(RegisteredNode {
923            component_idx,
924            _phantom: PhantomData,
925        })
926    }
927
928    // Phase 258 (Track 2, w5) — `register_dispatch_slot` (the four-fn-ptr
929    // BSP registration) is gone with the retired `register_dispatch_slot_dyn`
930    // bridge + `nros_run_components`. Owned-spin / BSP entries now register
931    // through `install_node_typed` (the uniform install seam).
932
933    /// Drive one executor iteration + a `tick` per registered
934    /// component.
935    pub fn spin_once(&mut self, timeout: Duration) -> Result<(), ExecutorError> {
936        let _result = self.executor.spin_once(timeout);
937        self.run_ticks();
938        Ok(())
939    }
940
941    /// [`Self::spin_once`], returning the executor's own per-iteration counts
942    /// instead of discarding them — issue 0572.
943    ///
944    /// `spin_once` throws away a `SpinOnceResult` that already carries exactly
945    /// what a stalled tier needs to be diagnosed: how many timers fired, how
946    /// many subscription callbacks ran, and how many errored. Without it, "the
947    /// tier's timer never fires" and "the tier's callback runs and its publish
948    /// fails" are the same observation from outside the guest — a silent topic.
949    pub fn spin_once_counted(
950        &mut self,
951        timeout: Duration,
952    ) -> Result<nros_node::SpinOnceResult, ExecutorError> {
953        let result = self.executor.spin_once(timeout);
954        self.run_ticks();
955        Ok(result)
956    }
957
958    /// Phase 216.B.3 / C.3 follow-up — route a signaled callback to
959    /// every registered component slot.
960    ///
961    /// The RTIC (`nros-board-rtic-stm32f4`) and Embassy
962    /// (`nros-board-embassy-stm32f4`) dispatch tasks dequeue a
963    /// [`nros_platform::SignaledCallback`] envelope from their SPSC
964    /// queue / Embassy channel and need a routing entry point that
965    /// hands the callback off to the right Node's `on_callback`
966    /// trampoline. This method is that entry point.
967    ///
968    /// # Strategy — linear scan
969    ///
970    /// Each registered slot's `dispatch_fn` is the codegen-emitted
971    /// `d()` trampoline from `nros::node!()` (see
972    /// `packages/core/nros-macros/src/lib.rs`). That trampoline calls
973    /// `<NodeTy as ExecutableNode>::on_callback`, whose body
974    /// `match`es on the callback's own tag set
975    /// (`Subscription` / `Timer` / `Service` / `Action` ids) and is a
976    /// no-op for non-matching `cb_id`s. So a linear scan across every
977    /// slot is correct — each slot self-filters and at most one
978    /// component actually acts on a given `cb_id`. A focused
979    /// `cb_id → slot` index is a separate follow-up; the trampoline's
980    /// tag dispatch already gates the real work cheaply (string
981    /// compare on statically known literals), so the linear scan is
982    /// the minimum-viable wiring that closes the conceptual gap left
983    /// by the B.3 / C.3 skeleton emits.
984    ///
985    /// # Borrow semantics
986    ///
987    /// Each `ComponentCell`'s slot lives behind a [`RefCell`]; the
988    /// per-slot dispatch takes `try_borrow_mut` and is a no-op on
989    /// re-entrancy. The runtime is single-threaded by construction
990    /// (the dispatch task owns it via `&mut self`), so the borrow
991    /// always succeeds in normal flow.
992    pub fn dispatch_callback(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>) {
993        for cell in &self.components {
994            if let Ok(mut slot) = cell.slot.try_borrow_mut() {
995                slot.dispatch(cb_id, ctx);
996            }
997        }
998    }
999
1000    /// Spin until the executor's halt flag is raised.
1001    ///
1002    /// phase-359 W10 — this used to be `#[cfg(feature = "std")]` and described
1003    /// itself as hosted-only, but the body is `Duration` + `spin_once` +
1004    /// `is_halted`, none of which need an OS. The gate was describing a
1005    /// CONVENTION (a BSP usually wants its own loop so it can interleave
1006    /// board work) as if it were a requirement, and a bare-metal image that
1007    /// wants exactly this loop had to hand-roll it to get it.
1008    ///
1009    /// It does need `alloc`: the halt flag is an `Arc`, so a core-only image
1010    /// has no flag to poll.
1011    #[cfg(feature = "alloc")]
1012    pub fn spin(&mut self) -> Result<(), ExecutorError> {
1013        // 10 ms tick cadence — matches the existing executor spin
1014        // budgeting (see `Executor::spin_default`); short enough that
1015        // component `tick` hooks observe latency under one cycle.
1016        let tick = Duration::from_millis(10);
1017        while !self.executor.is_halted() {
1018            let _ = self.executor.spin_once(tick);
1019            self.run_ticks();
1020        }
1021        Ok(())
1022    }
1023
1024    /// Halt a running [`spin`](Self::spin). Idempotent.
1025    #[cfg(feature = "alloc")]
1026    pub fn halt(&self) {
1027        self.executor.halt();
1028    }
1029
1030    fn run_ticks(&mut self) {
1031        // Per-component tick — each component's resolver is its own cell.
1032        // Phase 212.M-F.23: the tick reaches the executor (service-client
1033        // call_raw poll, action-server complete/feedback) through a raw
1034        // pointer so `&self.components` and `&mut self.executor` (disjoint
1035        // fields) can be live at once.
1036        let exec_ptr: *mut Executor<'static> = &mut self.executor;
1037        for cell in &self.components {
1038            tick_one_cell(cell.as_ref(), exec_ptr);
1039        }
1040    }
1041}
1042
1043/// Phase 258 (Track 2, 2a) — drive one component cell's `tick` against the
1044/// executor. The single source of truth for the per-component tick body,
1045/// shared by [`ExecutorNodeRuntime::run_ticks`] (the owned-runtime path) and
1046/// the executor-enrolled [`component_tick_trampoline`] (the `install` path).
1047///
1048/// `exec_ptr` is reached through a raw `*mut Executor<'static>` so the caller can hold
1049/// the component (`&ComponentCell`) and the executor live at once — they are
1050/// disjoint, and `RuntimeActions` / `RuntimeClientDispatch` reborrow `&mut`
1051/// per call (see their docs).
1052fn tick_one_cell(cell: &dyn CellView, exec_ptr: *mut Executor<'static>) {
1053    let resolver = CellResolver { cell };
1054    let service_clients = cell.service_clients();
1055    let action_clients = cell.action_clients();
1056    let action_servers = cell.action_servers();
1057    let mut actions = RuntimeActions {
1058        executor: exec_ptr,
1059        handles: &action_servers,
1060    };
1061    let mut clients = RuntimeClientDispatch {
1062        executor: exec_ptr,
1063        services: &service_clients,
1064        actions: &action_clients,
1065    };
1066    let mut ctx = TickCtx::new(&resolver, &mut actions, &mut clients);
1067    // W4c — `tick` reads `ctx.parameter::<T>(name)` from the store via the cell pointer
1068    // (the store is a separate `Box<ParamState>` allocation, so this does NOT alias the
1069    // `&mut Executor<'static>` the action/client tick calls reborrow through `exec_ptr`).
1070    #[cfg(feature = "param-services")]
1071    ctx.set_param_server(cell.view_param_server());
1072    cell.try_with_slot_mut(&mut |slot| slot.tick(&mut ctx));
1073}
1074
1075/// Phase 258 (Track 2, 2a) — executor `ComponentSlot.tick` trampoline. Casts
1076/// the enrolled state back to the component cell + `exec_ctx` back to the
1077/// executor and drives one tick. The layering-clean `extern "C"` shim the
1078/// `nros-node` [`Executor`] calls each `spin_once` (it can't name `nros`'s
1079/// [`ComponentCell`] — see [`register_node_borrowed`]'s enroll).
1080///
1081/// # Safety
1082/// `state` must be the placed `ComponentCell` enrolled via
1083/// [`Executor::enroll_component`] from [`register_node_borrowed`] (per-class
1084/// static storage or the alloc convenience's leaked box — live until
1085/// `component_drop_trampoline`); `exec_ctx` must be the live
1086/// `*mut Executor<'static>` the executor passes itself.
1087unsafe extern "C" fn component_tick_trampoline<
1088    const PUBS: usize,
1089    const SVCS: usize,
1090    const ACTC: usize,
1091    const ACTS: usize,
1092    const SSRV: usize,
1093>(
1094    state: *mut core::ffi::c_void,
1095    exec_ctx: *mut core::ffi::c_void,
1096) {
1097    // SAFETY: `state` is a live placed `ComponentCell` (kept in place until
1098    // the drop trampoline); this monomorphization was enrolled alongside it,
1099    // so the cast target is the cell's true type.
1100    let cell = unsafe { &*(state as *const ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>) };
1101    tick_one_cell(cell, exec_ctx as *mut Executor<'static>);
1102}
1103
1104/// Phase 258 (Track 2, 2a; W5-endgame step 2b) — executor `ComponentSlot.drop`
1105/// trampoline. Drops the placed `ComponentCell` (which drops the component
1106/// state through its borrowed slot) IN PLACE — the storage itself is a
1107/// `static` (or the alloc convenience's leaked box, whose bytes are never
1108/// freed, matching its slot's documented leak). Run exactly once on
1109/// `Executor::drop`; the storage's monotonic `take` never re-hands the
1110/// region out, so nothing can observe the dropped bytes.
1111///
1112/// # Safety
1113/// `state` must be the placed `ComponentCell` enrolled via
1114/// [`Executor::enroll_component`], not yet dropped.
1115unsafe extern "C" fn component_drop_trampoline<
1116    const PUBS: usize,
1117    const SVCS: usize,
1118    const ACTC: usize,
1119    const ACTS: usize,
1120    const SSRV: usize,
1121>(
1122    state: *mut core::ffi::c_void,
1123) {
1124    // SAFETY: enroll's contract above; dropped exactly once by the executor,
1125    // through the monomorphization enrolled with the cell.
1126    unsafe { core::ptr::drop_in_place(state as *mut ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>) };
1127}
1128
1129// =============================================================================
1130// Phase 212.N.7 step-3.3 — bridge to platform-side `NodeDispatchRuntime`.
1131// =============================================================================
1132//
1133// `nros_platform::NodeDispatchRuntime` is the board-side sink: object-safe +
1134// `no_std`. `BoardEntry::run` installs this `ExecutorNodeRuntime` impl on the
1135// per-boot `RuntimeCtx::runtime` slot. The owned-spin entry reaches the live
1136// executor through `executor_handle()` (a raw pointer crosses the layering wall
1137// cleanly) and installs via `nros::install_node_typed`. Phase 258 (w5) retired
1138// the old `register_dispatch_slot_dyn` four-fn-ptr bridge.
1139
1140#[cfg(feature = "alloc")]
1141impl ::nros_platform::NodeDispatchRuntime for ExecutorNodeRuntime {
1142    fn spin_once(&mut self, timeout_ms: u32) -> Result<(), ()> {
1143        Self::spin_once(self, Duration::from_millis(timeout_ms.into())).map_err(|_| ())
1144    }
1145
1146    fn executor_handle(&mut self) -> *mut core::ffi::c_void {
1147        // Phase 258 (Track 2, 2a) — hand the owned-spin entry a raw pointer to
1148        // the executor this runtime owns, so a Node pkg's `register(runtime)`
1149        // can install through `nros::install_node_typed` (same seam as the
1150        // C/C++ typed entries). The pointer is valid for the runtime's life
1151        // (the executor is an inline field); the install call uses it only
1152        // during registration, before any concurrent spin.
1153        &mut self.executor as *mut Executor<'static> as *mut core::ffi::c_void
1154    }
1155
1156    // Phase 264 W2 — register the REP-2002 lifecycle services + drive boot
1157    // autostart on the owned executor (mirrors `generate.rs::render_lifecycle_fn`).
1158    // Only compiled with `lifecycle-services`; without it the trait default no-op
1159    // applies, so a `[lifecycle]` block is silently inert (the Entry opts in by
1160    // enabling `nros/lifecycle-services`). `nros::main!` calls this when
1161    // `system.toml` declares `[lifecycle]`.
1162    #[cfg(feature = "lifecycle-services")]
1163    fn apply_lifecycle(&mut self, autostart: u8) -> Result<(), &'static str> {
1164        // issue 0460 — carry WHY. The caller can only say which capability
1165        // failed; without this the whole diagnostic was
1166        // `NodeRegister("lifecycle")`.
1167        self.executor
1168            .register_lifecycle_services()
1169            .map_err(|e| capability_reason(&e))?;
1170        if autostart >= 1
1171            && let Some(sm) = self.executor.lifecycle_state_machine_mut()
1172        {
1173            // No transition callbacks registered → each transition takes the
1174            // default-success path (REP-2002 skeleton), as the bake does.
1175            unsafe {
1176                let _ = sm.trigger_transition(crate::LifecycleTransition::Configure);
1177                if autostart >= 2 {
1178                    let _ = sm.trigger_transition(crate::LifecycleTransition::Activate);
1179                }
1180            }
1181        }
1182        Ok(())
1183    }
1184
1185    // Phase 264 W4b — register the 6 ROS 2 parameter services on the owned executor
1186    // + seed the volatile param store with the launch-baked `<param>` initials
1187    // (mirrors `generate.rs::render_param_persistence_fn`, minus persistence). Only
1188    // compiled with `param-services`; without it the trait default no-op applies, so a
1189    // `[param_services]` block is silently inert (the Entry opts in by enabling
1190    // `nros/param-services`). `nros::main!` calls this when `system.toml` declares
1191    // `[param_services]`. Reconfigured values (via `ros2 param set`) live in RAM until
1192    // the next boot — persistence is out of scope (issue 0080).
1193    // W4c note: `nros::main!` emits this BEFORE the per-node `register` calls, so the
1194    // store exists when each cell is created — `register_node_borrowed` / `register_node`
1195    // then capture the (stable, boxed) `ParameterServer` address on the cell, letting a
1196    // callback read `ctx.parameter::<T>(name)`. (The macro-path cells live in the
1197    // executor's tick registry, not `self.components`, so a post-pass here wouldn't reach
1198    // them — capture-at-registration does.)
1199    #[cfg(feature = "param-services")]
1200    fn apply_param_services(&mut self, params: &[(&str, &str)]) -> Result<(), &'static str> {
1201        self.executor
1202            .register_parameter_services()
1203            .map_err(|e| capability_reason(&e))?;
1204        for (name, raw) in params {
1205            self.executor
1206                .declare_parameter(name, infer_param_value(raw));
1207        }
1208        Ok(())
1209    }
1210
1211    fn observed_callback_counts(&self) -> (usize, usize) {
1212        let direct = self
1213            .components
1214            .iter()
1215            .fold((0, 0), |(callbacks, messages), cell| {
1216                let (c, m) = cell.dispatch_counts();
1217                (callbacks + c, messages + m)
1218            });
1219        // issue #140 — install-seam components (`nros::node!` →
1220        // `install_node_typed*` → `register_node_borrowed`) never enter
1221        // `self.components`; their cells live only as the executor's enrolled
1222        // component slots (leaked `Arc<ComponentCell>`s). Without folding them
1223        // the hosted spin reported callbacks=0 for every macro-baked entry
1224        // (multihost robot2 et al.) while dispatch demonstrably ran. The two
1225        // populations are disjoint by construction: `register_node` pushes to
1226        // `components` and does not enroll; `register_node_borrowed` enrolls
1227        // and does not push.
1228        self.executor
1229            .enrolled_component_states()
1230            .fold(direct, |(callbacks, messages), state| {
1231                // SAFETY: every enrolled state is a leaked `Arc<ComponentCell>`
1232                // from `register_node_borrowed` (the only enroll site); the
1233                // executor keeps it alive until `Executor::drop`, and we only
1234                // read its atomic counters here.
1235                let header = unsafe { &*(state as *const CellHeader) };
1236                (
1237                    callbacks + header.callback_dispatches.load(Ordering::Relaxed),
1238                    messages + header.message_dispatches.load(Ordering::Relaxed),
1239                )
1240            })
1241    }
1242}
1243
1244// =============================================================================
1245// Internal sink — bridges `NodeRuntime` declarations onto the
1246// live executor.
1247// =============================================================================
1248
1249struct ExecutorSink<'a> {
1250    executor: &'a mut Executor<'static>,
1251    cell: CellHandle,
1252    /// Per-registration node mapping: stable id → executor `NodeId` plus the
1253    /// EFFECTIVE `(name, namespace)` the node was created with (launch identity
1254    /// or the `NodeOptions` default) — phase-306 W3 needs it to expand
1255    /// `~`/relative entity names per node.
1256    /// W5-endgame alloc-off — heapless: a component class declares a small,
1257    /// statically bounded number of NODES (nearly always one). Full = loud
1258    /// registration error, the registries' rule.
1259    nodes: heapless::Vec<SinkNode, MAX_SINK_NODES>,
1260    /// Phase 268 W1 — launch-injected node identity `(name, namespace)` baked by
1261    /// `nros::main!` per component. When `Some`, `create_node` uses this identity
1262    /// instead of the `NodeOptions` default; `None` → default stands (backward-compat).
1263    node_identity: Option<(&'static str, &'static str)>,
1264    /// Phase 305 W3 (issue 0255) — launch `<remap from= to=/>` rules baked by
1265    /// `nros::main!` for this component. Applied in `create_entity` via the
1266    /// shared `node_metadata::resolve_name` seam (exact-FQN match, first rule
1267    /// wins). Empty → names still get ROS 2 expansion, no substitution.
1268    remaps: &'a [(&'a str, &'a str)],
1269    /// Issue #52 — the component's baked QoS-override codes, installed on each
1270    /// node this sink creates (`Executor::set_node_qos_overrides`) BEFORE the
1271    /// component declares entities, so `create_publisher`/`create_subscription`
1272    /// fold the matching ones in. Empty → nothing installed, zero cost.
1273    qos_overrides: &'static [nros_node::executor::node_record::QoSOverrideCode],
1274}
1275
1276struct SinkNode {
1277    stable_id: IdStr,
1278    node_id: nros_node::executor::NodeId,
1279    name: IdStr,
1280    namespace: IdStr,
1281}
1282
1283impl ExecutorSink<'_> {
1284    fn lookup_node(&self, stable_id: &str) -> Option<&SinkNode> {
1285        self.nodes.iter().find(|n| n.stable_id == stable_id)
1286    }
1287}
1288
1289impl NodeRuntime for ExecutorSink<'_> {
1290    fn create_node(&mut self, id: MetaNodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
1291        if self.nodes.iter().any(|n| n.stable_id == id.as_str()) {
1292            return Err(NodeDeclError::Runtime);
1293        }
1294        // Phase 268 W1 — launch wins over the NodeOptions default (RFC-0046).
1295        // When `nros::main!` injected an identity for this component, use it;
1296        // otherwise fall back to what the Node declared in its `create_node` call.
1297        let (name, ns) = match self.node_identity {
1298            Some((n, s)) => (n, s),
1299            None => (options.name, options.namespace),
1300        };
1301        let node_id = self
1302            .executor
1303            .node_builder(name)
1304            .namespace(ns)
1305            .domain_id(options.domain_id)
1306            .build()
1307            .map_err(decl_err_from_node)?;
1308        // Issue #52 — install the bake BEFORE the component declares any
1309        // entity on this node; entities created earlier could not be folded.
1310        if !self.qos_overrides.is_empty() {
1311            self.executor
1312                .set_node_qos_overrides(node_id, self.qos_overrides);
1313        }
1314        self.nodes
1315            .push(SinkNode {
1316                stable_id: id_str(id.as_str())?,
1317                node_id,
1318                name: id_str(name)?,
1319                namespace: id_str(ns)?,
1320            })
1321            .map_err(|_| NodeDeclError::Runtime)?;
1322        Ok(())
1323    }
1324
1325    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()> {
1326        // Phase 228.C tier gate: when this executor runs a specific tier
1327        // (`active_groups` set by codegen), an entity whose callback group
1328        // is not active on this tier is a no-op — no RMW handle, no slot.
1329        // An unlabeled entity (`callback_group == None`) is wildcard-eligible
1330        // and always registers; the degenerate single-tier executor leaves
1331        // `active_groups == None`, so every entity registers (byte-identical
1332        // to pre-228 output).
1333        if let Some(group) = metadata.callback_group.as_ref()
1334            && !self.executor.group_active(group.as_str())
1335        {
1336            return Ok(());
1337        }
1338        let (node, node_name, node_ns) = {
1339            let entry = self
1340                .lookup_node(metadata.node_id.as_str())
1341                .ok_or(NodeDeclError::Runtime)?;
1342            (entry.node_id, entry.name.clone(), entry.namespace.clone())
1343        };
1344        // Phase 305 W3 (issue 0255) — expand `~`/relative source names against
1345        // the owning node's identity and apply the launch remap rules (shared
1346        // `node_metadata::resolve_name` seam) before any name reaches the wire.
1347        // Timers have no wire name; parameter names are NOT remapped (matches
1348        // ROS 2 basic name remapping — param remaps are a separate rule class).
1349        let resolved_name = match metadata.kind {
1350            EntityKind::Timer | EntityKind::Parameter => None,
1351            _ => Some(
1352                crate::node_metadata::resolve_name(
1353                    metadata.source_name.as_str(),
1354                    &node_name,
1355                    &node_ns,
1356                    self.remaps.iter().copied(),
1357                )
1358                .map_err(|_| NodeDeclError::Runtime)?,
1359            ),
1360        };
1361        let entity_name = resolved_name.as_ref().map(|r| r.as_str()).unwrap_or("");
1362        match metadata.kind {
1363            EntityKind::Publisher => {
1364                // Issue 0306 — the node's DECLARED profile
1365                // (`create_publisher_for_topic_with_qos`) rides
1366                // `metadata.qos`; this used to call the default-QoS
1367                // constructor, so every declarative entity was created
1368                // `QoSProfile::default()` and a node's own QoS was silently
1369                // discarded. Plan overrides still win: they fold in below this,
1370                // inside the executor's create path.
1371                let handle = self
1372                    .executor
1373                    .node_mut(node)
1374                    .create_generic_publisher_with_qos(
1375                        entity_name,
1376                        metadata.type_name,
1377                        metadata.type_hash,
1378                        metadata.qos,
1379                    )
1380                    .map_err(decl_err_from_node)?;
1381                let id_owned = id_str(metadata.id.as_str())?;
1382                // Registry full = this component declared more entities than
1383                // CELL_REG_CAP — a registration error, never a silent drop.
1384                self.cell
1385                    .view()
1386                    .push_publisher(id_owned, handle)
1387                    .map_err(|_| NodeDeclError::Runtime)?;
1388                Ok(())
1389            }
1390            EntityKind::Subscription => {
1391                let cb_id = metadata
1392                    .callback_id
1393                    .as_ref()
1394                    .ok_or(NodeDeclError::Runtime)?;
1395                let cb_id_owned = id_str(cb_id.as_str())?;
1396                let cell = self.cell.clone();
1397                // Phase 250 (Wave 2b) — a `.safety()` subscription registers via
1398                // the integrity-aware generic path so `CallbackCtx::integrity()`
1399                // surfaces CRC + sequence gap/dup. Gated: when `safety-e2e` is off
1400                // the flag is ignored and the basic path below runs.
1401                #[cfg(feature = "safety-e2e")]
1402                if metadata.safety {
1403                    let cell_s = self.cell.clone();
1404                    let cb_s = cb_id_owned.clone();
1405                    self.executor
1406                        .node_mut(node)
1407                        .create_generic_subscription_with_integrity(
1408                            entity_name,
1409                            metadata.type_name,
1410                            metadata.type_hash,
1411                            move |payload: &[u8], status: &nros_node::IntegrityStatus| {
1412                                dispatch_into_cell_with_integrity(
1413                                    cell_s.view(),
1414                                    &cb_s,
1415                                    payload,
1416                                    status,
1417                                );
1418                            },
1419                        )
1420                        .map_err(decl_err_from_node)?;
1421                    return Ok(());
1422                }
1423                // Issue 0306 — same as the publisher branch: honour the
1424                // node's declared profile instead of defaulting it.
1425                self.executor
1426                    .node_mut(node)
1427                    .create_generic_subscription_with_qos(
1428                        entity_name,
1429                        metadata.type_name,
1430                        metadata.type_hash,
1431                        metadata.qos,
1432                        move |payload: &[u8]| {
1433                            dispatch_into_cell(cell.view(), &cb_id_owned, payload);
1434                        },
1435                    )
1436                    .map_err(decl_err_from_node)?;
1437                Ok(())
1438            }
1439            EntityKind::Timer => {
1440                let cb_id = metadata
1441                    .callback_id
1442                    .as_ref()
1443                    .ok_or(NodeDeclError::Runtime)?;
1444                let cb_id_owned = id_str(cb_id.as_str())?;
1445                // Issue #505 — prefer the microsecond field; fall back to
1446                // the millisecond one only for metadata that predates it.
1447                let period = match metadata.period_us {
1448                    Some(us) => nros_node::TimerDuration::from_micros(us),
1449                    None => nros_node::TimerDuration::from_millis(
1450                        metadata.period_ms.ok_or(NodeDeclError::Runtime)?,
1451                    ),
1452                };
1453                let cell = self.cell.clone();
1454                self.executor
1455                    .register_timer(period, move || {
1456                        dispatch_into_cell(cell.view(), &cb_id_owned, &[]);
1457                    })
1458                    .map_err(decl_err_from_node)?;
1459                Ok(())
1460            }
1461            // Phase 212.M-F.23 — service / action client + server dispatch on
1462            // the single-node runtime. The executor-level `register_*_on`
1463            // calls add an arena dispatch entry, so inbound requests / goals
1464            // are serviced inside `spin_once`; the leaked `*Ctx` trampoline
1465            // contexts bridge back into the component's `on_callback`. Client
1466            // handles are stashed in the cell for the tick-side dispatch
1467            // (`RuntimeClientDispatch` / `RuntimeActions` in `run_ticks`).
1468            EntityKind::ServiceServer => {
1469                let cb_id = metadata
1470                    .callback_id
1471                    .as_ref()
1472                    .ok_or(NodeDeclError::Runtime)?;
1473                let ctx = self
1474                    .cell
1475                    .view()
1476                    .place_service_ctx(ServiceServerCtx {
1477                        cell: self.cell.clone(),
1478                        callback_id: id_str(cb_id.as_str())?,
1479                    })
1480                    .map_err(|_| NodeDeclError::Runtime)?;
1481                self.executor
1482                    .register_service_raw_sized_on::<1024, 1024>(
1483                        node,
1484                        entity_name,
1485                        metadata.type_name,
1486                        metadata.type_hash,
1487                        crate::QoSProfile::services_default(),
1488                        service_server_trampoline,
1489                        ctx,
1490                    )
1491                    .map_err(decl_err_from_node)?;
1492                Ok(())
1493            }
1494            EntityKind::ServiceClient => {
1495                let hid = self
1496                    .executor
1497                    .register_service_client_raw_sized_on::<1024>(
1498                        node,
1499                        entity_name,
1500                        metadata.type_name,
1501                        metadata.type_hash,
1502                        crate::QoSProfile::services_default(),
1503                        None,
1504                        core::ptr::null_mut(),
1505                    )
1506                    .map_err(decl_err_from_node)?;
1507                self.cell
1508                    .view()
1509                    .push_service_client(id_str(metadata.id.as_str())?, hid)
1510                    .map_err(|_| NodeDeclError::Runtime)?;
1511                Ok(())
1512            }
1513            EntityKind::ActionServer => {
1514                let goal_cb = metadata
1515                    .callback_id
1516                    .as_ref()
1517                    .ok_or(NodeDeclError::Runtime)?;
1518                let cancel_cb = metadata
1519                    .action_cancel_callback_id
1520                    .as_ref()
1521                    .ok_or(NodeDeclError::Runtime)?;
1522                let accepted_cb = metadata
1523                    .action_accepted_callback_id
1524                    .as_ref()
1525                    .map(|c| id_str(c.as_str()))
1526                    .transpose()?;
1527                let ctx = self
1528                    .cell
1529                    .view()
1530                    .place_action_server_ctx(ActionServerCtx {
1531                        cell: self.cell.clone(),
1532                        goal_callback_id: id_str(goal_cb.as_str())?,
1533                        cancel_callback_id: id_str(cancel_cb.as_str())?,
1534                        accepted_callback_id: accepted_cb,
1535                    })
1536                    .map_err(|_| NodeDeclError::Runtime)?;
1537                let handle = self
1538                    .executor
1539                    .register_action_server_raw_sized::<1024, 1024, 1024, 4>(
1540                        crate::RawActionServerSpec {
1541                            node_id: Some(node),
1542                            action_name: entity_name,
1543                            type_name: metadata.type_name,
1544                            type_hash: metadata.type_hash,
1545                            qos: crate::QoSProfile::services_default(),
1546                            goal_callback: action_goal_trampoline,
1547                            cancel_callback: action_cancel_trampoline,
1548                            accepted_callback: Some(action_accepted_trampoline),
1549                            context: ctx,
1550                        },
1551                    )
1552                    .map_err(decl_err_from_node)?;
1553                self.cell
1554                    .view()
1555                    .push_action_server(id_str(metadata.id.as_str())?, handle)
1556                    .map_err(|_| NodeDeclError::Runtime)?;
1557                Ok(())
1558            }
1559            EntityKind::ActionClient => {
1560                // A bound `callback_id` (set by
1561                // `create_action_client_with_callbacks_for_name`) delivers the
1562                // terminal goal result to the component via `on_callback`; the
1563                // optional `action_accepted_callback_id` slot carries the
1564                // feedback callback (reused — unused on a client). The executor
1565                // auto-drives accept → feedback → result during spin and invokes
1566                // these trampolines. No callbacks → send-goal only.
1567                let (result_callback, feedback_callback, ctx) = match metadata.callback_id.as_ref()
1568                {
1569                    Some(result_cb) => {
1570                        let feedback_cb = metadata
1571                            .action_accepted_callback_id
1572                            .as_ref()
1573                            .map(|c| id_str(c.as_str()))
1574                            .transpose()?;
1575                        let ctx = self
1576                            .cell
1577                            .view()
1578                            .place_action_client_ctx(ActionClientCtx {
1579                                cell: self.cell.clone(),
1580                                result_callback_id: id_str(result_cb.as_str())?,
1581                                feedback_callback_id: feedback_cb.clone(),
1582                            })
1583                            .map_err(|_| NodeDeclError::Runtime)?;
1584                        let fb = feedback_cb.map(|_| action_feedback_trampoline as _);
1585                        (Some(action_result_trampoline as _), fb, ctx)
1586                    }
1587                    None => (None, None, core::ptr::null_mut()),
1588                };
1589                let handle = self
1590                    .executor
1591                    .register_action_client_raw_sized::<1024, 1024, 1024>(
1592                        crate::RawActionClientSpec {
1593                            node_id: Some(node),
1594                            action_name: entity_name,
1595                            type_name: metadata.type_name,
1596                            type_hash: metadata.type_hash,
1597                            goal_response_callback: None,
1598                            feedback_callback,
1599                            result_callback,
1600                            context: ctx,
1601                        },
1602                    )
1603                    .map_err(decl_err_from_node)?;
1604                self.cell
1605                    .view()
1606                    .push_action_client(id_str(metadata.id.as_str())?, handle.entry_index())
1607                    .map_err(|_| NodeDeclError::Runtime)?;
1608                Ok(())
1609            }
1610            EntityKind::Parameter => {
1611                // Phase 212.M-F.23 Wave 2 — declarative parameter dispatch on
1612                // the single-node runtime. The first declared parameter lazily
1613                // stands up the 6 ROS 2 parameter services for this executor's
1614                // node; `spin_once` drives those service servers thereafter
1615                // (`#[cfg(param-services)]` block at spin.rs). The declared
1616                // source default seeds the value. With `param-services` off the
1617                // arm is a no-op (entity declared, no RMW handle) — byte-
1618                // identical to the pre-Wave-2 behavior.
1619                #[cfg(feature = "param-services")]
1620                {
1621                    if self.executor.params().is_none() {
1622                        self.executor
1623                            .register_parameter_services()
1624                            .map_err(decl_err_from_node)?;
1625                    }
1626                    let value = param_default_to_value(metadata.parameter_default.as_ref());
1627                    self.executor
1628                        .declare_parameter(metadata.source_name.as_str(), value);
1629                }
1630                Ok(())
1631            }
1632        }
1633    }
1634
1635    fn record_callback_effect(
1636        &mut self,
1637        _callback_id: CallbackId<'_>,
1638        _kind: CallbackEffectKind,
1639        _entity_id: EntityId<'_>,
1640    ) -> NodeResult<()> {
1641        // Planner concern only — the live runtime doesn't need the
1642        // effect graph at spin time.
1643        Ok(())
1644    }
1645}
1646
1647/// Lower a source-recorded [`ParameterDefault`] into the executor-facing
1648/// [`nros_params::ParameterValue`] used to seed a declared parameter. Scalar
1649/// defaults carry their value directly; the array variants record only the
1650/// declared type (no element data at the source layer) so they seed as
1651/// `NotSet` — the parameter is still declared, just without a concrete array
1652/// default. A `Double` default is stored as a string at the metadata layer and
1653/// parsed here (unparseable → `0.0`).
1654#[cfg(feature = "param-services")]
1655fn param_default_to_value(
1656    default: Option<&crate::node_metadata::ParameterDefault>,
1657) -> nros_params::ParameterValue {
1658    use crate::node_metadata::ParameterDefault;
1659    use nros_params::ParameterValue;
1660    match default {
1661        None => ParameterValue::NotSet,
1662        Some(ParameterDefault::Bool(b)) => ParameterValue::Bool(*b),
1663        Some(ParameterDefault::Integer(i)) => ParameterValue::Integer(*i),
1664        Some(ParameterDefault::Double(s)) => {
1665            ParameterValue::Double(s.as_str().parse::<f64>().unwrap_or(0.0))
1666        }
1667        Some(ParameterDefault::String(s)) => {
1668            ParameterValue::from_string(s.as_str()).unwrap_or(ParameterValue::NotSet)
1669        }
1670        Some(
1671            ParameterDefault::BoolArray
1672            | ParameterDefault::IntegerArray
1673            | ParameterDefault::DoubleArray
1674            | ParameterDefault::StringArray,
1675        ) => ParameterValue::NotSet,
1676    }
1677}
1678
1679/// Phase 264 W4b — infer a [`nros_params::ParameterValue`] from a raw launch
1680/// `<param value=…/>` string. ROS 2 launch `<param>` values are untyped strings; the
1681/// macro path has no per-param type attribute, so infer: `true`/`false` → `Bool`, an
1682/// `i64` literal → `Integer`, an `f64` literal → `Double`, otherwise `String`. This
1683/// mirrors the type a `ros2 param set` of the same literal would land on, so the baked
1684/// initial and a CLI override agree on type.
1685#[cfg(feature = "param-services")]
1686fn infer_param_value(raw: &str) -> nros_params::ParameterValue {
1687    use nros_params::ParameterValue;
1688    match raw {
1689        "true" => return ParameterValue::from_bool(true),
1690        "false" => return ParameterValue::from_bool(false),
1691        _ => {}
1692    }
1693    if let Ok(i) = raw.parse::<i64>() {
1694        return ParameterValue::from_integer(i);
1695    }
1696    if let Ok(f) = raw.parse::<f64>() {
1697        return ParameterValue::from_double(f);
1698    }
1699    ParameterValue::from_string(raw).unwrap_or(ParameterValue::NotSet)
1700}
1701
1702fn dispatch_into_cell(cell: &dyn CellView, cb_id: &str, payload: &[u8]) {
1703    cell.note_dispatch(!payload.is_empty());
1704    let resolver = CellResolver { cell };
1705    let mut ctx = CallbackCtx::new(payload, &resolver);
1706    // W4c — let the callback read `ctx.parameter::<T>(name)` from the executor's store
1707    // (threaded onto the cell by `apply_param_services`; `None` until then).
1708    #[cfg(feature = "param-services")]
1709    ctx.set_param_server(cell.view_param_server());
1710    // If the slot is already borrowed (a re-entrant publish from a
1711    // tick hook on the same cell, etc.) the view drops this dispatch. In
1712    // practice the borrow succeeds because subscription / timer
1713    // callbacks run sequentially under the single-threaded executor.
1714    cell.try_with_slot_mut(&mut |slot| slot.dispatch(cb_id, &mut ctx));
1715}
1716
1717/// Phase 250 (Wave 2b) — dispatch a `.safety()` subscription message into the
1718/// component's `on_callback` with its E2E [`IntegrityStatus`] attached, read via
1719/// `CallbackCtx::integrity()`. The integrity-aware twin of [`dispatch_into_cell`].
1720#[cfg(feature = "safety-e2e")]
1721fn dispatch_into_cell_with_integrity(
1722    cell: &dyn CellView,
1723    cb_id: &str,
1724    payload: &[u8],
1725    status: &nros_node::IntegrityStatus,
1726) {
1727    cell.note_dispatch(!payload.is_empty());
1728    let resolver = CellResolver { cell };
1729    let mut ctx = CallbackCtx::new_with_integrity(payload, &resolver, status);
1730    // W4c — param store for a `.safety()` subscription callback too.
1731    #[cfg(feature = "param-services")]
1732    ctx.set_param_server(cell.view_param_server());
1733    cell.try_with_slot_mut(&mut |slot| slot.dispatch(cb_id, &mut ctx));
1734}
1735
1736// =============================================================================
1737// Phase 212.M-F.23 — service / action SERVER trampolines + tick-side client /
1738// action dispatch.
1739//
1740// The executor's raw service/action-server registration takes C-ABI fn
1741// pointers, so the runtime leaks a `*Ctx` (lives for the runtime's lifetime,
1742// like the executor) holding the owning `ComponentCell` + the declared
1743// callback ids. Each trampoline rebuilds a `CallbackCtx` and routes into the
1744// component's `on_callback`, exactly as the orchestration codegen's
1745// `svc_tramp_*` / `goal_tramp_*` do for the Entry path.
1746// =============================================================================
1747
1748/// Leaked context for a service-server arena callback.
1749struct ServiceServerCtx {
1750    cell: CellHandle,
1751    callback_id: IdStr,
1752}
1753
1754/// Leaked context for an action-server arena callback (goal + cancel + the
1755/// optional accepted hook all share one).
1756struct ActionServerCtx {
1757    cell: CellHandle,
1758    goal_callback_id: IdStr,
1759    cancel_callback_id: IdStr,
1760    accepted_callback_id: Option<IdStr>,
1761}
1762
1763/// Leaked context for an action-CLIENT result + feedback callbacks.
1764struct ActionClientCtx {
1765    cell: CellHandle,
1766    result_callback_id: IdStr,
1767    feedback_callback_id: Option<IdStr>,
1768}
1769
1770/// Action-client result callback: the executor's spin auto-drives the goal to
1771/// completion and hands the terminal result CDR here; route it into the
1772/// component's `on_callback` (read with `CallbackCtx::message`).
1773unsafe extern "C" fn action_result_trampoline(
1774    _goal_id: *const GoalId,
1775    _status: GoalStatus,
1776    result_data: *const u8,
1777    result_len: usize,
1778    ctx: *mut core::ffi::c_void,
1779) {
1780    let actx = unsafe { &*(ctx as *const ActionClientCtx) };
1781    let result_slice = unsafe { core::slice::from_raw_parts(result_data, result_len) };
1782    dispatch_into_cell(actx.cell.view(), &actx.result_callback_id, result_slice);
1783}
1784
1785/// Action-client feedback callback: route each feedback CDR into the
1786/// component's `on_callback` under the bound feedback callback id.
1787unsafe extern "C" fn action_feedback_trampoline(
1788    _goal_id: *const GoalId,
1789    feedback_data: *const u8,
1790    feedback_len: usize,
1791    ctx: *mut core::ffi::c_void,
1792) {
1793    let actx = unsafe { &*(ctx as *const ActionClientCtx) };
1794    let Some(cb_id) = actx.feedback_callback_id.as_ref() else {
1795        return;
1796    };
1797    let feedback_slice = unsafe { core::slice::from_raw_parts(feedback_data, feedback_len) };
1798    dispatch_into_cell(actx.cell.view(), cb_id, feedback_slice);
1799}
1800
1801/// Service-server request callback: deserialize-side runs in the component's
1802/// `on_callback` via `CallbackCtx::with_reply`; the executor sends the reply
1803/// from the bytes written into `resp`.
1804unsafe extern "C" fn service_server_trampoline(
1805    req: *const u8,
1806    req_len: usize,
1807    resp: *mut u8,
1808    resp_cap: usize,
1809    resp_len: *mut usize,
1810    ctx: *mut core::ffi::c_void,
1811) -> bool {
1812    let sctx = unsafe { &*(ctx as *const ServiceServerCtx) };
1813    let req_slice = unsafe { core::slice::from_raw_parts(req, req_len) };
1814    let resp_slice = unsafe { core::slice::from_raw_parts_mut(resp, resp_cap) };
1815    let mut written = 0usize;
1816    let view = sctx.cell.view();
1817    let resolver = CellResolver { cell: view };
1818    let mut cb = CallbackCtx::with_reply(req_slice, &resolver, resp_slice, &mut written);
1819    // W4c — service-server callback can read `ctx.parameter::<T>(name)` too.
1820    #[cfg(feature = "param-services")]
1821    cb.set_param_server(view.view_param_server());
1822    view.try_with_slot_mut(&mut |slot| slot.dispatch(&sctx.callback_id, &mut cb));
1823    unsafe { *resp_len = written };
1824    true
1825}
1826
1827/// Action-server goal callback → component `on_callback` with a goal decision.
1828unsafe extern "C" fn action_goal_trampoline(
1829    _goal_id: *const GoalId,
1830    goal_data: *const u8,
1831    goal_len: usize,
1832    ctx: *mut core::ffi::c_void,
1833) -> crate::GoalResponse {
1834    let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1835    let goal_slice = unsafe { core::slice::from_raw_parts(goal_data, goal_len) };
1836    let mut resp = crate::GoalResponse::Reject;
1837    let view = actx.cell.view();
1838    let resolver = CellResolver { cell: view };
1839    let mut cb = CallbackCtx::with_goal_decision(goal_slice, &resolver, &mut resp);
1840    // W4c — action goal callback can read `ctx.parameter::<T>(name)` too.
1841    #[cfg(feature = "param-services")]
1842    cb.set_param_server(view.view_param_server());
1843    view.try_with_slot_mut(&mut |slot| slot.dispatch(&actx.goal_callback_id, &mut cb));
1844    resp
1845}
1846
1847/// Action-server cancel callback → component `on_callback` with a cancel
1848/// decision. The cancel callback has no goal payload.
1849unsafe extern "C" fn action_cancel_trampoline(
1850    _goal_id: *const GoalId,
1851    _status: GoalStatus,
1852    ctx: *mut core::ffi::c_void,
1853) -> crate::CancelResponse {
1854    let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1855    let mut resp = crate::CancelResponse::Reject;
1856    let view = actx.cell.view();
1857    let resolver = CellResolver { cell: view };
1858    let mut cb = CallbackCtx::with_cancel_decision(&[], &resolver, &mut resp);
1859    // W4c — action cancel callback can read `ctx.parameter::<T>(name)` too.
1860    #[cfg(feature = "param-services")]
1861    cb.set_param_server(view.view_param_server());
1862    view.try_with_slot_mut(&mut |slot| slot.dispatch(&actx.cancel_callback_id, &mut cb));
1863    resp
1864}
1865
1866/// Action-server accepted hook → component `on_callback` (no decision, no
1867/// payload). No-op when the component didn't declare an accepted callback.
1868unsafe extern "C" fn action_accepted_trampoline(
1869    _goal_id: *const GoalId,
1870    ctx: *mut core::ffi::c_void,
1871) {
1872    let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1873    let Some(cb_id) = actx.accepted_callback_id.as_ref() else {
1874        return;
1875    };
1876    dispatch_into_cell(actx.cell.view(), cb_id, &[]);
1877}
1878
1879/// Tick-side service/action CLIENT dispatch — the single-node runtime's mirror
1880/// of the orchestration `GenClientDispatch`. Resolves the per-component client
1881/// handle arrays + a `*mut Executor<'static>` (the tick borrows `&components` while
1882/// needing `&mut executor`, so the executor is reached through a raw pointer,
1883/// reborrowed `&mut` per call; no aliasing — `executor` and `components` are
1884/// disjoint fields).
1885struct RuntimeClientDispatch<'a> {
1886    executor: *mut Executor<'static>,
1887    services: &'a [(IdStr, crate::HandleId)],
1888    actions: &'a [(IdStr, usize)],
1889}
1890
1891impl RuntimeClientDispatch<'_> {
1892    fn service(&self, entity: &str) -> NodeResult<crate::HandleId> {
1893        self.services
1894            .iter()
1895            .find(|(e, _)| e == entity)
1896            .map(|(_, h)| *h)
1897            .ok_or(NodeDeclError::Runtime)
1898    }
1899
1900    fn action_entry(&self, entity: &str) -> NodeResult<usize> {
1901        self.actions
1902            .iter()
1903            .find(|(e, _)| e == entity)
1904            .map(|(_, i)| *i)
1905            .ok_or(NodeDeclError::Runtime)
1906    }
1907}
1908
1909impl ClientDispatch for RuntimeClientDispatch<'_> {
1910    fn call_raw(
1911        &mut self,
1912        service_entity: &str,
1913        request_cdr: &[u8],
1914        response_buf: &mut [u8],
1915    ) -> NodeResult<usize> {
1916        use crate::ClientTrait;
1917        let hid = self.service(service_entity)?;
1918        {
1919            let executor = unsafe { &mut *self.executor };
1920            let entry = unsafe { executor.service_client_entry_mut(hid.0) }
1921                .ok_or(NodeDeclError::Runtime)?;
1922            entry
1923                .handle
1924                .send_request_raw(request_cdr)
1925                .map_err(|_| NodeDeclError::Runtime)?;
1926        }
1927        // Bounded wait — caps total time so the tick loop stays responsive.
1928        for _ in 0..200 {
1929            let executor = unsafe { &mut *self.executor };
1930            executor.spin_once(core::time::Duration::from_millis(10));
1931            let entry = unsafe { executor.service_client_entry_mut(hid.0) }
1932                .ok_or(NodeDeclError::Runtime)?;
1933            match entry.handle.take_response_raw(response_buf) {
1934                // Issue 0778 — one call in flight on this blocking path, so
1935                // the sequence id is dropped deliberately.
1936                Ok(Some((len, _seq))) => return Ok(len),
1937                Ok(None) => continue,
1938                Err(_) => return Err(NodeDeclError::Runtime),
1939            }
1940        }
1941        Err(NodeDeclError::Runtime)
1942    }
1943
1944    fn send_goal_raw(&mut self, action_entity: &str, goal_cdr: &[u8]) -> NodeResult<GoalId> {
1945        let entry_index = self.action_entry(action_entity)?;
1946        let executor = unsafe { &mut *self.executor };
1947        let core = unsafe { executor.action_client_core_mut(entry_index) }
1948            .ok_or(NodeDeclError::Runtime)?;
1949        let goal_id = core.send_goal_raw(goal_cdr).map_err(decl_err_from_node)?;
1950        // rclcpp-style: request the result immediately. The server queues the
1951        // get_result request until the goal terminates, then replies — the
1952        // executor's spin auto-delivers it to the bound result callback (the
1953        // executor never auto-sends this request, so the declarative client
1954        // must). Best-effort: a transport hiccup just means no result callback.
1955        let _ = core.send_get_result_request(&goal_id);
1956        Ok(goal_id)
1957    }
1958}
1959
1960/// Tick-side action-SERVER execution — mirror of `GenActionExec`. Lets a
1961/// component complete goals / publish feedback / enumerate active goals from
1962/// its `tick` via `TickCtx`.
1963struct RuntimeActions<'a> {
1964    executor: *mut Executor<'static>,
1965    handles: &'a [(IdStr, crate::ActionServerRawHandle)],
1966}
1967
1968impl RuntimeActions<'_> {
1969    fn handle(&self, entity: &str) -> NodeResult<crate::ActionServerRawHandle> {
1970        self.handles
1971            .iter()
1972            .find(|(e, _)| e == entity)
1973            .map(|(_, h)| *h)
1974            .ok_or(NodeDeclError::Runtime)
1975    }
1976}
1977
1978impl ActionExecutor for RuntimeActions<'_> {
1979    fn complete_goal_raw(
1980        &mut self,
1981        action_entity: &str,
1982        goal_id: &GoalId,
1983        status: GoalStatus,
1984        result: &[u8],
1985    ) -> NodeResult<()> {
1986        let handle = self.handle(action_entity)?;
1987        let executor = unsafe { &mut *self.executor };
1988        // issue 0796 — a result too large for the server's RESULT_BUF is
1989        // reported rather than silently dropped.
1990        handle
1991            .complete_goal_raw(executor, goal_id, status, result)
1992            .map_err(|_| NodeDeclError::Runtime)
1993    }
1994
1995    fn publish_feedback_raw(
1996        &mut self,
1997        action_entity: &str,
1998        goal_id: &GoalId,
1999        feedback: &[u8],
2000    ) -> NodeResult<()> {
2001        let handle = self.handle(action_entity)?;
2002        let executor = unsafe { &mut *self.executor };
2003        handle
2004            .publish_feedback_raw(executor, goal_id, feedback)
2005            .map_err(|_| NodeDeclError::Runtime)
2006    }
2007
2008    fn for_each_active_goal(
2009        &self,
2010        action_entity: &str,
2011        visit: &mut dyn FnMut(&GoalId, GoalStatus),
2012    ) {
2013        if let Ok(handle) = self.handle(action_entity) {
2014            let executor = unsafe { &*self.executor };
2015            handle.for_each_active_goal(executor, |g| visit(&g.goal_id, g.status));
2016        }
2017    }
2018}
2019
2020// Phase 258 (Track 2, w5) — the typed BSP fn-ptr aliases (`NodeRegisterFn` /
2021// `NodeInitFn` / `NodeDispatchFn` / `NodeTickFn`) are gone with the retired
2022// `register_dispatch_slot` / `nros_run_components` BSP-baker path. The
2023// macro-emitted `register(runtime)` wrapper now installs via the
2024// `install_node_typed` seam (Track 2 w4).
2025
2026/// Phase 257 (W0-B) — register an [`ExecutableNode`] `C` against a **borrowed**
2027/// executor (the shared cffi `Executor` a foreign-language typed entry hands in via
2028/// its `nros::global_handle()` / `Node::executor_handle()`), returning the live
2029/// [`ComponentCell`].
2030///
2031/// Unlike [`ExecutorNodeRuntime::register_node`] this owns neither the executor nor a
2032/// components list: the executor's per-entity callbacks hold `CellHandle`s
2033/// clones (see [`ExecutorSink`]), so the cell stays alive for the executor's lifetime
2034/// via dispatch alone — the caller may drop the returned cell (pub/sub/timer nodes, the
2035/// W0-B target) or stash it to drive `tick` (service-client/action nodes; phase-257 D2).
2036/// The node self-creates its node (its `Node::NAME`) on the shared executor (phase-257
2037/// D7 Option C — Rust nodes in a foreign entry self-name, no entry-side qos-override).
2038/// Issue 0095 — preserve executor callback-table exhaustion through the
2039/// `NodeError → NodeDeclError` collapse so the register seam (and ultimately
2040/// the `nros::main!` entry) can name `NROS_EXECUTOR_MAX_CBS` instead of an
2041/// opaque `NodeRegister`. Every other `NodeError` stays `Runtime`.
2042fn decl_err_from_node(e: nros_node::NodeError) -> NodeDeclError {
2043    // Issue 0428 — `NodeDeclError::Runtime` collapses every non-`ExecutorFull`
2044    // cause into one opaque `NodeRegister("<pkg>")`, "four collapses away from the
2045    // cause" (node.rs). That is how a Cyclone descriptor/transport failure was
2046    // mis-diagnosed twice. Surface the real variant on the error path (rare — this
2047    // only runs when a declaration is already failing), so the register seam names
2048    // WHAT failed, not just WHERE.
2049    // issue 0589 — `nros_log`, not `std::eprintln!`: std stdio is fatal on Zephyr
2050    // native_sim, and this crate cannot gate on the platform. Routing it here
2051    // also means a `no_std` image gets the diagnostic, which the old
2052    // `cfg(feature = "std")` arm never delivered.
2053    if !matches!(e, nros_node::NodeError::ExecutorFull) {
2054        nros_log::nros_error!(
2055            nros_log::get_logger("nros"),
2056            "node declaration failed — NodeError::{e:?}"
2057        );
2058    }
2059    // no_std targets get the same diagnostic through the `log` facade (every
2060    // RTOS board bridges it); without this the collapse left only an opaque
2061    // `NodeRegister("<pkg>")` on embedded — e.g. a static subscriber-pool
2062    // exhaustion (NROS_RMW_SUBSCRIBER_SLOTS) surfaced with no cause at all.
2063    #[cfg(not(feature = "std"))]
2064    if !matches!(e, nros_node::NodeError::ExecutorFull) {
2065        log::error!("nros: node declaration failed — NodeError::{e:?}");
2066    }
2067    match e {
2068        nros_node::NodeError::ExecutorFull => NodeDeclError::ExecutorFull,
2069        _ => NodeDeclError::Runtime,
2070    }
2071}
2072
2073fn register_node_borrowed<
2074    'p,
2075    C: ExecutableNode + 'static,
2076    const PUBS: usize,
2077    const SVCS: usize,
2078    const ACTC: usize,
2079    const ACTS: usize,
2080    const SSRV: usize,
2081>(
2082    executor: &mut Executor<'static>,
2083    params: &'p [(&'p str, &'p str)],
2084    node_identity: Option<(&'static str, &'static str)>,
2085    remaps: &'p [(&'p str, &'p str)],
2086    qos_overrides: &'static [nros_node::executor::node_record::QoSOverrideCode],
2087    slot_mu: &'static mut MaybeUninit<TypedSlot<C>>,
2088    cell_mu: &'static mut MaybeUninit<ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>>,
2089) -> NodeResult<&'static ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV>>
2090where
2091    C::State: 'static,
2092{
2093    // W5-endgame step 2b (issue 0857) — the cell is PLACED into caller
2094    // storage (the per-class static's paired region, or the alloc
2095    // convenience's leaked box), never `Arc`'d: 0857 measured the Arc'd cell
2096    // at ~17.5 KiB of heap per component. Closures and leaked ctxs hold a
2097    // `CellHandle::Static` copy; the executor's drop trampoline runs the
2098    // cell's destructor in place.
2099    let cell: &'static ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV> = cell_mu.write(ComponentCell {
2100        slot: RefCell::new(place_slot::<C>(slot_mu)),
2101        publishers: RefCell::new(heapless::Vec::new()),
2102        service_clients: RefCell::new(heapless::Vec::new()),
2103        action_clients: RefCell::new(heapless::Vec::new()),
2104        action_servers: RefCell::new(heapless::Vec::new()),
2105        svc_ctxs: [const { UnsafeCell::new(MaybeUninit::uninit()) }; _],
2106        svc_ctxs_used: core::cell::Cell::new(0),
2107        act_srv_ctxs: [const { UnsafeCell::new(MaybeUninit::uninit()) }; _],
2108        act_srv_ctxs_used: core::cell::Cell::new(0),
2109        act_cli_ctxs: [const { UnsafeCell::new(MaybeUninit::uninit()) }; _],
2110        act_cli_ctxs_used: core::cell::Cell::new(0),
2111        header: CellHeader {
2112            callback_dispatches: AtomicUsize::new(0),
2113            message_dispatches: AtomicUsize::new(0),
2114        },
2115        // W4c — set by `apply_param_services` once the store exists (it runs after this).
2116        #[cfg(feature = "param-services")]
2117        param_server: core::cell::Cell::new(core::ptr::null()),
2118    });
2119    let mut sink = ExecutorSink {
2120        // Reborrow so `executor` stays usable for `enroll_component` after the
2121        // sink (which holds `&mut Executor<'static>`) is dropped below.
2122        executor: &mut *executor,
2123        cell: CellHandle::Static(cell),
2124        nodes: heapless::Vec::new(),
2125        // Phase 268 W1 — thread the per-component identity bake (RFC-0046).
2126        node_identity,
2127        // Phase 305 W3 (issue 0255) — thread the per-component remap bake.
2128        remaps,
2129        // Issue #52 — thread the per-component QoS-override bake.
2130        qos_overrides,
2131    };
2132    let sink_dyn: &mut dyn NodeRuntime = &mut sink;
2133    let mut context = NodeContext::new(C::NAME, sink_dyn);
2134    // Phase 264 W4a — seed the baked launch-param initials so `register()` can read
2135    // them via `NodeContext::param`.
2136    context.set_params(params);
2137    C::register(&mut context)?;
2138
2139    // Phase 258 (Track 2, 2a) — enroll the cell in the executor's component
2140    // tick registry so `install`'d nodes tick (closes phase-257 D2: poll-only
2141    // service-client/action nodes have no callbacks keeping the cell alive AND
2142    // never ran `tick`). The executor runs `tick` each `spin_once` and runs
2143    // the cell's destructor in place on `Executor::drop`. Harmless for
2144    // pub/sub/timer-only nodes — their tick body is a no-op. Registry-full
2145    // (`MAX_NODES`) proceeds without a tick slot; the placed cell then simply
2146    // lives (and leaks its component state's destructor) with its storage,
2147    // which a monotonic `take` never re-hands out.
2148    let raw = cell as *const ComponentCell<PUBS, SVCS, ACTC, ACTS, SSRV> as *mut core::ffi::c_void;
2149    // SAFETY: `raw` is the freshly placed cell; the enrolled trampoline
2150    // monomorphizations match its exact type (borrow on tick, drop_in_place
2151    // on executor drop).
2152    let _ = unsafe {
2153        executor.enroll_component(
2154            raw,
2155            component_tick_trampoline::<PUBS, SVCS, ACTC, ACTS, SSRV>,
2156            component_drop_trampoline::<PUBS, SVCS, ACTC, ACTS, SSRV>,
2157        )
2158    };
2159
2160    // W4c — capture the executor's volatile param store on the cell so this node's
2161    // callbacks can read `ctx.parameter::<T>(name)`. Non-null only when `nros::main!`
2162    // emitted `apply_param_services` BEFORE this register call (`[param_services]`
2163    // declared); otherwise the store is absent and the field stays null.
2164    #[cfg(feature = "param-services")]
2165    if let Some(server) = executor.params() {
2166        cell.param_server.set(server as *const _);
2167    }
2168
2169    Ok(cell)
2170}
2171
2172/// Phase 257 (W0-B) — C-ABI typed component install. Recovers the shared `Executor`
2173/// from the foreign typed entry's handle (`global_handle()` / `Node::executor_handle()`
2174/// = the `_opaque` `*mut Executor<'static>`; cf. nros-c `get_executor_from_ptr`) and registers
2175/// `C` on it via `register_node_borrowed` (private helper). The component's `ComponentCell`
2176/// is PLACED in caller/leaked storage (W5-endgame step 2b) and dropped in place by the
2177/// executor on `Executor::drop`. Returns `0` on success, `-1` on a null handle or a
2178/// registration error.
2179///
2180/// This backs the `__nros_component_<pkg>_install(node, executor, self)` symbol
2181/// `nros::node!()` emits — the uniform cross-language install seam (phase-257 D6).
2182///
2183/// # Safety
2184/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes (its
2185/// `nros::global_handle()` / a node's `executor_handle()`), valid for the call.
2186#[cfg(feature = "alloc")]
2187pub unsafe fn install_node_typed<C: ExecutableNode + 'static>(
2188    executor: *mut core::ffi::c_void,
2189) -> i32
2190where
2191    C::State: 'static,
2192{
2193    // SAFETY: forwarded per this fn's contract; no baked params.
2194    unsafe { install_node_typed_with_params::<C>(executor, &[]) }
2195}
2196
2197/// W4a — same as [`install_node_typed`] but seeds the node's [`NodeContext`] with the
2198/// launch-baked `<param>` initial values, so a `register`/`init`-time `ctx.param(name)`
2199/// observes the compile-time launch value (RFC-0004 §10). `params` must outlive the call.
2200///
2201/// # Safety
2202/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes, valid for the call.
2203#[cfg(feature = "alloc")]
2204pub unsafe fn install_node_typed_with_params<C: ExecutableNode + 'static>(
2205    executor: *mut core::ffi::c_void,
2206    params: &[(&str, &str)],
2207) -> i32
2208where
2209    C::State: 'static,
2210{
2211    // SAFETY: forwarded per this fn's contract; no identity injection.
2212    unsafe { install_node_typed_with_node_identity::<C>(executor, params, None) }
2213}
2214
2215/// Phase 268 W1 — same as [`install_node_typed_with_params`] but also injects the
2216/// launch `<node name= namespace=>` identity so `ExecutorSink::create_node` uses it
2217/// instead of the `NodeOptions` default (RFC-0046). `None` → backward-compatible
2218/// (NodeOptions stands). `nros::node!()` calls this variant to carry the identity from
2219/// `RuntimeCtx::node_identity` set by `nros::main!` per component. `params` and
2220/// `node_identity` strings must outlive the call (both are `'static` in the macro emit).
2221///
2222/// # Safety
2223/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes, valid for the call.
2224#[cfg(feature = "alloc")]
2225pub unsafe fn install_node_typed_with_node_identity<C: ExecutableNode + 'static>(
2226    executor: *mut core::ffi::c_void,
2227    params: &[(&str, &str)],
2228    node_identity: Option<(&'static str, &'static str)>,
2229) -> i32
2230where
2231    C::State: 'static,
2232{
2233    // SAFETY: forwarded per this fn's contract; no remap rules.
2234    unsafe { install_node_typed_with_launch::<C>(executor, params, node_identity, &[], &[]) }
2235}
2236
2237/// Phase 305 W3 (issue 0255) — same as [`install_node_typed_with_node_identity`]
2238/// but also carries the launch `<remap from= to=/>` rules `nros::main!` baked for
2239/// this component (`RuntimeCtx::remaps`). `ExecutorSink::create_entity` applies
2240/// them (plus `~`/relative expansion) before any entity name reaches the wire.
2241/// All slices must outlive the call (`'static` promoted in the macro emit).
2242///
2243/// # Safety
2244/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes, valid for the call.
2245#[cfg(feature = "alloc")]
2246pub unsafe fn install_node_typed_with_launch<C: ExecutableNode + 'static>(
2247    executor: *mut core::ffi::c_void,
2248    params: &[(&str, &str)],
2249    node_identity: Option<(&'static str, &'static str)>,
2250    remaps: &[(&str, &str)],
2251    qos_overrides: &'static [nros_node::executor::node_record::QoSOverrideCode],
2252) -> i32
2253where
2254    C::State: 'static,
2255{
2256    if executor.is_null() {
2257        return -1;
2258    }
2259    // SAFETY: per the fn contract, `executor` is the live `*mut Executor<'static>` handle.
2260    let exec: &mut Executor<'static> = unsafe { &mut *(executor as *mut Executor<'static>) };
2261    // phase-391 W5.3b — the alloc convenience: leak exactly-sized slot storage
2262    // per call, the same relationship `from_executor` has to `new_in`. The
2263    // macro-emitted entries call the `_in` twin with their per-class static
2264    // instead, so GENERATED images do not take this branch.
2265    let slot_mu: &'static mut MaybeUninit<TypedSlot<C>> =
2266        Box::leak(Box::new(MaybeUninit::uninit()));
2267    // W5-endgame step 2b — the cell leaks the same way; its destructor still
2268    // runs (executor drop trampoline, in place), only the bytes stay.
2269    let cell_mu: &'static mut MaybeUninit<ComponentCell> =
2270        Box::leak(Box::new(MaybeUninit::uninit()));
2271    match register_node_borrowed::<C, _, _, _, _, _>(
2272        exec,
2273        params,
2274        node_identity,
2275        remaps,
2276        qos_overrides,
2277        slot_mu,
2278        cell_mu,
2279    ) {
2280        Ok(_cell) => 0,
2281        // Issue 0095 — distinct code for executor-table exhaustion so the macro
2282        // register seam can name `NROS_EXECUTOR_MAX_CBS` instead of an opaque
2283        // `NodeRegister`. Every other failure stays the generic `-1`.
2284        Err(NodeDeclError::ExecutorFull) => -2,
2285        Err(_) => -1,
2286    }
2287}
2288
2289/// phase-391 W5.3b — [`install_node_typed_with_launch`] over the CALLER's
2290/// per-class slot storage: the heap-free FFI install path. The `nros::node!`
2291/// macro emits one `static ComponentSlotStorage<C>` per expansion and calls
2292/// this twin; the C ABI trampoline above it keeps its `(ptr, ptr, ptr) -> i32`
2293/// shape, so nothing foreign changes.
2294///
2295/// Returns `-3` when the class's instance cap is exhausted — raise
2296/// `NROS_RUNTIME_MAX_CLASS_INSTANCES`. (Distinct from `-2`, the executor
2297/// callback-table Full.)
2298///
2299/// # Safety
2300/// `executor` must be the live `*mut Executor<'static>` handle a typed entry
2301/// passes, valid for the call.
2302pub unsafe fn install_node_typed_with_launch_in<
2303    C: ExecutableNode + 'static,
2304    const N: usize,
2305    const PUBS: usize,
2306    const SVCS: usize,
2307    const ACTC: usize,
2308    const ACTS: usize,
2309    const SSRV: usize,
2310>(
2311    executor: *mut core::ffi::c_void,
2312    store: &'static ComponentSlotStorage<C, N, PUBS, SVCS, ACTC, ACTS, SSRV>,
2313    params: &[(&str, &str)],
2314    node_identity: Option<(&'static str, &'static str)>,
2315    remaps: &[(&str, &str)],
2316    qos_overrides: &'static [nros_node::executor::node_record::QoSOverrideCode],
2317) -> i32
2318where
2319    C::State: 'static,
2320{
2321    if executor.is_null() {
2322        return -1;
2323    }
2324    let Some((slot_mu, cell_mu)) = store.take() else {
2325        return -3;
2326    };
2327    // SAFETY: per the fn contract, `executor` is the live handle.
2328    let exec: &mut Executor<'static> = unsafe { &mut *(executor as *mut Executor<'static>) };
2329    match register_node_borrowed::<C, _, _, _, _, _>(
2330        exec,
2331        params,
2332        node_identity,
2333        remaps,
2334        qos_overrides,
2335        slot_mu,
2336        cell_mu,
2337    ) {
2338        Ok(_cell) => 0,
2339        Err(NodeDeclError::ExecutorFull) => -2,
2340        Err(_) => -1,
2341    }
2342}
2343
2344/// phase-391 W5.3b — [`install_node_typed`] over caller storage; see
2345/// [`install_node_typed_with_launch_in`].
2346///
2347/// # Safety
2348/// As [`install_node_typed_with_launch_in`].
2349pub unsafe fn install_node_typed_in<
2350    C: ExecutableNode + 'static,
2351    const N: usize,
2352    const PUBS: usize,
2353    const SVCS: usize,
2354    const ACTC: usize,
2355    const ACTS: usize,
2356    const SSRV: usize,
2357>(
2358    executor: *mut core::ffi::c_void,
2359    store: &'static ComponentSlotStorage<C, N, PUBS, SVCS, ACTC, ACTS, SSRV>,
2360) -> i32
2361where
2362    C::State: 'static,
2363{
2364    // SAFETY: forwarded per this fn's contract.
2365    unsafe { install_node_typed_with_launch_in(executor, store, &[], None, &[], &[]) }
2366}
2367
2368// Phase 258 (Track 2, w5) — `nros_run_components` (the BSP shim that registered
2369// every component via the four-fn-ptr `register_dispatch_slot` then spun) is
2370// gone. It had no callers; owned-spin / BSP entries register through the
2371// `install_node_typed` seam + drive `ExecutorNodeRuntime::spin`.
2372
2373// =============================================================================
2374// Tests
2375// =============================================================================
2376//
2377// Concrete `Executor` construction needs a real RMW backend session
2378// (with `rmw-cffi` on, `Executor::from_session` takes the cffi
2379// session). MockSession only exists when `rmw-cffi` is off — so the
2380// unit tests that exercise live timer firing live in
2381// `packages/testing/nros-tests/tests/phase212_m5a2_component_runtime.rs`
2382// gated behind the `component-runtime-test` feature (pulls
2383// `nros-rmw-zenoh`). The compile-only smoke here verifies the public
2384// types are reachable through the umbrella surface.
2385
2386#[cfg(test)]
2387mod tests {
2388    use super::*;
2389    use crate::node::Node;
2390
2391    #[test]
2392    fn handle_slot_is_observable() {
2393        // Trivial smoke — the handle type carries the slot index.
2394        let h = RegisteredNode::<DummyComp> {
2395            component_idx: 7,
2396            _phantom: PhantomData,
2397        };
2398        assert_eq!(h.slot(), 7);
2399    }
2400
2401    struct DummyComp;
2402    impl Node for DummyComp {
2403        const NAME: &'static str = "dummy";
2404        fn register(_ctx: &mut NodeContext<'_>) -> NodeResult<()> {
2405            Ok(())
2406        }
2407    }
2408    impl ExecutableNode for DummyComp {
2409        type State = ();
2410        fn init() -> Self::State {}
2411        fn on_callback(_s: &mut (), _cb: Callback<'_>, _ctx: &mut CallbackCtx<'_>) {}
2412    }
2413}
2414
2415/// issue 0460 — map a `NodeError` to a static reason a capability failure can
2416/// carry across the `nros-platform` trait boundary (which cannot name
2417/// `NodeError`). Static strings, so this works on `no_std` targets with no
2418/// allocator and no `log` dependency in this crate.
2419///
2420/// Both matches are EXHAUSTIVE on purpose. A wildcard arm is how this issue got
2421/// its diagnostic — `NodeRegister("lifecycle")` named the capability and
2422/// nothing about the cause — and a `_ =>` here would reintroduce exactly that
2423/// for whichever variant is added next. The compile error is the point.
2424#[cfg(any(feature = "lifecycle-services", feature = "param-services"))]
2425fn capability_reason(e: &nros_node::NodeError) -> &'static str {
2426    use nros_node::NodeError;
2427    match e {
2428        NodeError::Transport(t) => capability_transport_reason(t),
2429        NodeError::NameTooLong => {
2430            "NameTooLong (the node FQN or a service name overflowed its fixed buffer)"
2431        }
2432        NodeError::ExecutorFull => {
2433            "ExecutorFull (no callback slot left — raise CONFIG_NROS_EXECUTOR_MAX_CBS; \
2434             a capability's services are not counted by the model)"
2435        }
2436        NodeError::NodeTableFull => "NodeTableFull (NROS_EXECUTOR_MAX_NODES reached)",
2437        NodeError::NotInitialized => "NotInitialized (a required subsystem was never registered)",
2438        NodeError::BackendMismatch => "BackendMismatch (the RMW is not the executor's session)",
2439        NodeError::BufferTooSmall => "BufferTooSmall",
2440        NodeError::Serialization => "Serialization",
2441        NodeError::Deserialization => "Deserialization",
2442        NodeError::ActionCreationFailed => "ActionCreationFailed",
2443        NodeError::ServiceRequestFailed => "ServiceRequestFailed",
2444        NodeError::ServiceReplyFailed => "ServiceReplyFailed",
2445        NodeError::Timeout => "Timeout",
2446        NodeError::RequestInFlight => "RequestInFlight",
2447        NodeError::NoSchedContextSlot => "NoSchedContextSlot",
2448        NodeError::InvalidSchedContextBinding => "InvalidSchedContextBinding",
2449        // issue 0790 added this variant and did not add the arm; the exhaustive
2450        // match above did exactly what its doc comment says it is for, and the
2451        // lanes that were run could not see it (`--all-targets` enables `test`,
2452        // which pulls in a different cfg path, and `cargo doc` does no
2453        // exhaustiveness checking, so the parity lane stayed green over it).
2454        NodeError::ShutdownCallbacksFull => {
2455            "ShutdownCallbacksFull (the executor's pre/on-shutdown callback table is \
2456             full — raise NROS_EXECUTOR_MAX_SHUTDOWN_CBS)"
2457        }
2458    }
2459}
2460
2461/// The `NodeError::Transport` payload, which is the half that actually names
2462/// what the RMW refused. `Backend(&'static str)` passes its own diagnostic
2463/// straight through — it is already the string the backend wanted to say.
2464///
2465/// Unlike `capability_reason` above this one keeps a wildcard, and only because
2466/// it must: `TransportError::BackendDynamic` is gated on `nros-rmw/alloc`,
2467/// which feature unification can switch on from another crate in the graph
2468/// without `nros/alloc` — so no spelling of the arm is correct in both builds
2469/// (`nros-c`'s `nros_ret_t` mapping carries the same wildcard for the same
2470/// reason). Every other variant is named, so the wildcard's text is what an
2471/// unmapped one looks like.
2472#[cfg(any(feature = "lifecycle-services", feature = "param-services"))]
2473fn capability_transport_reason(t: &nros_rmw::TransportError) -> &'static str {
2474    use nros_rmw::TransportError as T;
2475    match t {
2476        T::Backend(s) => s,
2477        T::ServiceServerCreationFailed => {
2478            "Transport::ServiceServerCreationFailed (the RMW refused to declare the queryable)"
2479        }
2480        T::ServiceClientCreationFailed => "Transport::ServiceClientCreationFailed",
2481        T::PublisherCreationFailed => "Transport::PublisherCreationFailed",
2482        T::SubscriberCreationFailed => "Transport::SubscriberCreationFailed",
2483        T::ConnectionFailed => "Transport::ConnectionFailed (no session to the router)",
2484        T::Disconnected => "Transport::Disconnected",
2485        T::TopicNameInvalid => "Transport::TopicNameInvalid",
2486        T::NodeNameNonExistent => "Transport::NodeNameNonExistent",
2487        T::IncompatibleQos => "Transport::IncompatibleQos",
2488        T::IncompatibleAbi => "Transport::IncompatibleAbi",
2489        T::InvalidConfig => "Transport::InvalidConfig",
2490        T::InvalidArgument => "Transport::InvalidArgument",
2491        T::Unsupported => "Transport::Unsupported (the backend does not implement this)",
2492        T::BadAlloc => "Transport::BadAlloc",
2493        T::PublishFailed => "Transport::PublishFailed",
2494        T::ServiceRequestFailed => "Transport::ServiceRequestFailed",
2495        T::ServiceReplyFailed => "Transport::ServiceReplyFailed",
2496        T::SerializationError => "Transport::SerializationError",
2497        T::DeserializationError => "Transport::DeserializationError",
2498        T::BufferTooSmall => "Transport::BufferTooSmall",
2499        T::MessageTooLarge => "Transport::MessageTooLarge",
2500        T::Timeout => "Transport::Timeout",
2501        T::WouldBlock => "Transport::WouldBlock",
2502        T::TooLarge => "Transport::TooLarge",
2503        T::TaskStartFailed => "Transport::TaskStartFailed",
2504        T::PollFailed => "Transport::PollFailed",
2505        T::KeepaliveFailed => "Transport::KeepaliveFailed",
2506        T::JoinFailed => "Transport::JoinFailed",
2507        T::LoanNotSupported => "Transport::LoanNotSupported",
2508        T::NoData => "Transport::NoData",
2509        _ => "Transport::<variant with no reason mapping — add it here>",
2510    }
2511}