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