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