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