Skip to main content

nros/
node_runtime.rs

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