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