nros/node_runtime.rs
1//! Phase 212.M.5.a.2 — Executor-backed `NodeRuntime` /
2//! `DeclaredNodeRuntime` for nano-ros.
3//!
4//! [`MetadataRecorder`](crate::node_metadata::MetadataRecorder)
5//! (the planner sink) binds the
6//! [`Node`](crate::node::Node) /
7//! [`ExecutableNode`](crate::node::ExecutableNode)
8//! traits to a pure metadata target. This module is the missing twin:
9//! it binds the same traits to a live [`Executor`](crate::Executor) so
10//! a Node pkg can actually run — nodes, publishers,
11//! subscriptions, timers materialise as real executor handles, and
12//! every fired callback dispatches into
13//! [`ExecutableNode::on_callback`] with the right
14//! [`CallbackId`].
15//!
16//! Shape:
17//!
18//! ```ignore
19//! use nros::{Executor, ExecutorConfig};
20//! use nros::node_runtime::ExecutorNodeRuntime;
21//!
22//! let cfg = ExecutorConfig::from_env().node_name("talker_main");
23//! let executor = Executor::open(&cfg).unwrap();
24//! let mut runtime = ExecutorNodeRuntime::from_executor(executor);
25//! let _handle = runtime.register_node::<Talker>().unwrap();
26//! runtime.spin().unwrap();
27//! ```
28//!
29//! Owned-spin / board consumer: the macro-emitted `<pkg>::register(runtime)`
30//! wrapper (Phase 258, Track 2) installs each Node onto the runtime's executor
31//! through the uniform `install_node_typed` seam — the same seam the C/C++
32//! typed entries use. The codegen `run_plan(runtime)` body + `nros::main!`
33//! owned-spin loop drive one `register(runtime)?` per launch-XML `<node>`,
34//! then `runtime.spin()`. (The retired Phase 212.M.5.a four-fn-ptr BSP-baker
35//! ABI — `register_dispatch_slot` / `nros_run_components` — is gone.)
36//!
37//! ## Coverage today (Phase 212.M.5.a.2)
38//!
39//! Publishers, subscriptions, and repeating timers wire end-to-end:
40//! the live executor delivers callbacks; the bound
41//! [`ExecutableNode::on_callback`] body runs with a
42//! [`CallbackCtx`] backed by the per-component publisher resolver.
43//! Service servers / clients and action servers / clients wire
44//! end-to-end too (Phase 212.M-F.23): `create_entity` registers them on
45//! the executor with C-ABI trampolines that route inbound requests /
46//! goals into the component's `on_callback`, and the tick-time client /
47//! action surface ([`TickCtx`]) is backed by `RuntimeClientDispatch` /
48//! `RuntimeActions` over the live executor. Parameters are still a
49//! follow-up (registration succeeds; param callbacks don't fire yet).
50
51#![cfg(feature = "rmw-cffi")]
52
53extern crate alloc;
54
55use alloc::{boxed::Box, string::String, vec::Vec};
56use core::{cell::RefCell, marker::PhantomData, time::Duration};
57
58use portable_atomic::{AtomicUsize, Ordering};
59use portable_atomic_util::Arc;
60
61use crate::{
62 EmbeddedRawPublisher, Executor, GoalId, GoalStatus,
63 node::{
64 ActionExecutor, Callback, CallbackCtx, ClientDispatch, ExecutableNode, NodeContext,
65 NodeDeclError, NodeOptions, NodeResult, NodeRuntime, PublisherResolver, TickCtx,
66 },
67 node_metadata::{
68 CallbackEffectKind, CallbackId, EntityId, EntityKind, EntityMetadata, NodeId as MetaNodeId,
69 },
70};
71
72// Phase 212.N.7 closing sweep — `component_register_symbol` retired
73// (no live callers after the BSP baker + macro extern emit were
74// removed). The former re-export here is gone.
75
76// =============================================================================
77// Public types
78// =============================================================================
79
80/// Opaque handle returned by
81/// [`ExecutorNodeRuntime::register_node`].
82///
83/// `C` distinguishes handles at the type level so a caller who keeps
84/// the handle can later (post-M.5.a.3) recover a typed mut-state
85/// borrow. For today the handle is purely a witness that registration
86/// succeeded.
87pub struct RegisteredNode<C: ExecutableNode> {
88 component_idx: usize,
89 _phantom: PhantomData<fn() -> C>,
90}
91
92impl<C: ExecutableNode> RegisteredNode<C> {
93 /// Slot index of this component inside the runtime.
94 pub fn slot(&self) -> usize {
95 self.component_idx
96 }
97}
98
99/// Errors returned by the runtime entry points.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum ExecutorError {
102 /// One of the components' register / lifecycle calls failed.
103 Node(NodeDeclError),
104 /// The executor's spin loop returned an unexpected error.
105 SpinFailed,
106}
107
108impl From<NodeDeclError> for ExecutorError {
109 fn from(e: NodeDeclError) -> Self {
110 Self::Node(e)
111 }
112}
113
114// =============================================================================
115// Internal slot — type-erases the component's `State` so the runtime
116// can hold a heterogeneous vec.
117// =============================================================================
118
119trait ComponentSlot {
120 fn dispatch(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>);
121 fn tick(&mut self, ctx: &mut TickCtx<'_>);
122}
123
124struct TypedSlot<C: ExecutableNode> {
125 state: C::State,
126 _phantom: PhantomData<fn() -> C>,
127}
128
129impl<C: ExecutableNode> ComponentSlot for TypedSlot<C> {
130 fn dispatch(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>) {
131 C::on_callback(
132 &mut self.state,
133 Callback::__from_id(CallbackId::new(cb_id)),
134 ctx,
135 );
136 }
137 fn tick(&mut self, ctx: &mut TickCtx<'_>) {
138 C::tick(&mut self.state, ctx);
139 }
140}
141
142// Phase 258 (Track 2, w5) — `BspDispatchSlot` (the type-erased
143// four-fn-ptr BSP dispatch slot) is gone with the retired
144// `register_dispatch_slot` / `nros_run_components` BSP-baker path. The
145// only remaining `ComponentSlot` impl is `TypedSlot<C>` above (used by
146// `register_node` / `register_node_borrowed` / `install_node_typed`).
147
148/// Shared per-component cell. Subscription / timer closures registered
149/// against the executor hold an `Arc` clone so they can dispatch +
150/// publish back through the resolver.
151struct ComponentCell {
152 slot: RefCell<Box<dyn ComponentSlot>>,
153 publishers: RefCell<Vec<(String, EmbeddedRawPublisher)>>,
154 // Phase 212.M-F.23 — declarative service/action CLIENT + action-SERVER
155 // handles, keyed by stable entity id, resolved during tick dispatch.
156 // Mirror of the orchestration `GenClientDispatch`/`GenActionExec` arrays,
157 // but built at registration time on the single-node runtime. Service- and
158 // action-SERVER request/goal dispatch is owned by the executor (the
159 // trampolines registered in `create_entity`); only the action-server
160 // handle is kept here so the tick can complete goals / publish feedback.
161 service_clients: RefCell<Vec<(String, crate::HandleId)>>,
162 action_clients: RefCell<Vec<(String, usize)>>,
163 action_servers: RefCell<Vec<(String, crate::ActionServerRawHandle)>>,
164 callback_dispatches: AtomicUsize,
165 message_dispatches: AtomicUsize,
166 // Phase 264 W4c — raw pointer to the executor's volatile parameter store, so a
167 // subscription/timer/service/action callback can read `ctx.parameter::<T>(name)`.
168 // The callback closures + leaked trampolines hold only an `Arc<ComponentCell>` (the
169 // executor is unreachable when they fire), so the store address is threaded HERE by
170 // `apply_param_services`' post-pass (mirrors the `run_ticks` disjoint borrow). Null
171 // until param services are registered. Stable for the executor's life: the server
172 // lives in a `Box<ParamState>` and is a fixed-size array (no realloc on declare).
173 #[cfg(feature = "param-services")]
174 param_server: core::cell::Cell<*const nros_params::ParameterServer>,
175}
176
177impl ComponentCell {
178 /// Phase 264 W4c — the executor's parameter store, or `None` until
179 /// `apply_param_services` threads it in. The deref is sound: single-threaded
180 /// executor, param services mutate the server only outside callback dispatch.
181 #[cfg(feature = "param-services")]
182 fn param_server(&self) -> Option<&nros_params::ParameterServer> {
183 let ptr = self.param_server.get();
184 if ptr.is_null() {
185 None
186 } else {
187 // SAFETY: `ptr` is the address of the executor's boxed `ParameterServer`
188 // (stable for the executor's life); param services mutate it before/after
189 // dispatch (`spin.rs` pre/post), never during, so no aliasing `&mut` is live.
190 Some(unsafe { &*ptr })
191 }
192 }
193
194 fn lookup_publisher<R>(
195 &self,
196 entity_id: &str,
197 f: impl FnOnce(&EmbeddedRawPublisher) -> R,
198 ) -> Option<R> {
199 let pubs = self.publishers.borrow();
200 pubs.iter()
201 .find(|(id, _)| id == entity_id)
202 .map(|(_, p)| f(p))
203 }
204}
205
206/// `PublisherResolver` implementation backed by a `ComponentCell`.
207struct CellResolver<'a> {
208 cell: &'a ComponentCell,
209}
210
211impl PublisherResolver for CellResolver<'_> {
212 fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()> {
213 self.cell
214 .lookup_publisher(entity_id, |p| {
215 p.publish_raw(data).map_err(|_| NodeDeclError::Runtime)
216 })
217 .unwrap_or(Err(NodeDeclError::Runtime))
218 }
219}
220
221// Phase 212.M-F.23 — the `UnsupportedActions` / `UnsupportedClients` tick-side
222// stubs are retired. Real service/action client + action-server dispatch on the
223// single-node runtime lives in `RuntimeClientDispatch` / `RuntimeActions`
224// (below), wired into `run_ticks`.
225
226// =============================================================================
227// ExecutorNodeRuntime
228// =============================================================================
229
230/// Executor-backed component runtime.
231///
232/// Owns the [`Executor`] and one slot per registered component. The
233/// register / spin lifecycle:
234///
235/// 1. [`from_executor`](Self::from_executor) wraps an open
236/// [`Executor`].
237/// 2. [`register_node`](Self::register_node) builds the
238/// component's `State`, runs [`Node::register`] over an
239/// internal [`NodeRuntime`] adapter that materialises nodes /
240/// pubs / subs / timers on the real executor, and wires each
241/// subscription + timer callback to dispatch into
242/// [`ExecutableNode::on_callback`] with the right
243/// [`CallbackId`].
244/// 3. [`spin`](Self::spin) / [`spin_once`](Self::spin_once) drive the
245/// executor; between iterations every registered component's
246/// [`ExecutableNode::tick`] runs.
247pub struct ExecutorNodeRuntime {
248 executor: Executor<'static>,
249 components: Vec<Arc<ComponentCell>>,
250}
251
252impl ExecutorNodeRuntime {
253 /// Wrap an already-built [`Executor`].
254 pub fn from_executor(executor: Executor<'static>) -> Self {
255 Self {
256 executor,
257 components: Vec::new(),
258 }
259 }
260
261 /// Borrow the underlying executor.
262 pub fn executor(&self) -> &Executor<'static> {
263 &self.executor
264 }
265
266 /// Mutably borrow the underlying executor — for advanced wiring
267 /// (parameter services, custom guard conditions). Don't use during
268 /// [`spin`](Self::spin) from another thread; the runtime is
269 /// single-threaded.
270 pub fn executor_mut(&mut self) -> &mut Executor<'static> {
271 &mut self.executor
272 }
273
274 /// Number of registered components.
275 pub fn component_count(&self) -> usize {
276 self.components.len()
277 }
278
279 /// Register a [`Node`] (which must also be
280 /// [`ExecutableNode`]) into this runtime. Builds the
281 /// component's `State` (via [`ExecutableNode::init`]) and
282 /// walks [`Node::register`] over the live executor — every
283 /// declared node / pub / sub / timer materialises as a real
284 /// executor handle, and subscription + timer callbacks are wired
285 /// to dispatch into [`ExecutableNode::on_callback`].
286 pub fn register_node<C: ExecutableNode + 'static>(&mut self) -> NodeResult<RegisteredNode<C>>
287 where
288 C::State: 'static,
289 {
290 let cell = Arc::new(ComponentCell {
291 slot: RefCell::new(Box::new(TypedSlot::<C> {
292 state: C::init(),
293 _phantom: PhantomData,
294 })),
295 publishers: RefCell::new(Vec::new()),
296 service_clients: RefCell::new(Vec::new()),
297 action_clients: RefCell::new(Vec::new()),
298 action_servers: RefCell::new(Vec::new()),
299 callback_dispatches: AtomicUsize::new(0),
300 message_dispatches: AtomicUsize::new(0),
301 // W4c — set by `apply_param_services` once the store exists.
302 #[cfg(feature = "param-services")]
303 param_server: core::cell::Cell::new(core::ptr::null()),
304 });
305 let component_idx = self.components.len();
306 self.components.push(cell.clone());
307
308 let mut sink = ExecutorSink {
309 executor: &mut self.executor,
310 cell: cell.clone(),
311 nodes: Vec::new(),
312 node_identity: None, // direct API — no launch injection
313 };
314 let sink_dyn: &mut dyn NodeRuntime = &mut sink;
315 let mut context = NodeContext::new(C::NAME, sink_dyn);
316 let result = C::register(&mut context);
317 if result.is_err() {
318 // Roll back the slot push so `component_count` stays
319 // consistent with what users observe.
320 self.components.pop();
321 }
322 result?;
323
324 // W4c — capture the executor's volatile param store on the cell (if param
325 // services were registered before this call) so the node's callbacks can read
326 // `ctx.parameter::<T>(name)`. Mirrors the install-seam path.
327 #[cfg(feature = "param-services")]
328 if let Some(server) = self.executor.params() {
329 cell.param_server.set(server as *const _);
330 }
331
332 Ok(RegisteredNode {
333 component_idx,
334 _phantom: PhantomData,
335 })
336 }
337
338 // Phase 258 (Track 2, w5) — `register_dispatch_slot` (the four-fn-ptr
339 // BSP registration) is gone with the retired `register_dispatch_slot_dyn`
340 // bridge + `nros_run_components`. Owned-spin / BSP entries now register
341 // through `install_node_typed` (the uniform install seam).
342
343 /// Drive one executor iteration + a `tick` per registered
344 /// component.
345 pub fn spin_once(&mut self, timeout: Duration) -> Result<(), ExecutorError> {
346 let _result = self.executor.spin_once(timeout);
347 self.run_ticks();
348 Ok(())
349 }
350
351 /// Phase 216.B.3 / C.3 follow-up — route a signaled callback to
352 /// every registered component slot.
353 ///
354 /// The RTIC (`nros-board-rtic-stm32f4`) and Embassy
355 /// (`nros-board-embassy-stm32f4`) dispatch tasks dequeue a
356 /// [`nros_platform::SignaledCallback`] envelope from their SPSC
357 /// queue / Embassy channel and need a routing entry point that
358 /// hands the callback off to the right Node's `on_callback`
359 /// trampoline. This method is that entry point.
360 ///
361 /// # Strategy — linear scan
362 ///
363 /// Each registered slot's `dispatch_fn` is the codegen-emitted
364 /// `d()` trampoline from `nros::node!()` (see
365 /// `packages/core/nros-macros/src/lib.rs`). That trampoline calls
366 /// `<NodeTy as ExecutableNode>::on_callback`, whose body
367 /// `match`es on the callback's own tag set
368 /// (`Subscription` / `Timer` / `Service` / `Action` ids) and is a
369 /// no-op for non-matching `cb_id`s. So a linear scan across every
370 /// slot is correct — each slot self-filters and at most one
371 /// component actually acts on a given `cb_id`. A focused
372 /// `cb_id → slot` index is a separate follow-up; the trampoline's
373 /// tag dispatch already gates the real work cheaply (string
374 /// compare on statically known literals), so the linear scan is
375 /// the minimum-viable wiring that closes the conceptual gap left
376 /// by the B.3 / C.3 skeleton emits.
377 ///
378 /// # Borrow semantics
379 ///
380 /// Each `ComponentCell`'s slot lives behind a [`RefCell`]; the
381 /// per-slot dispatch takes `try_borrow_mut` and is a no-op on
382 /// re-entrancy. The runtime is single-threaded by construction
383 /// (the dispatch task owns it via `&mut self`), so the borrow
384 /// always succeeds in normal flow.
385 pub fn dispatch_callback(&mut self, cb_id: &str, ctx: &mut CallbackCtx<'_>) {
386 for cell in &self.components {
387 if let Ok(mut slot) = cell.slot.try_borrow_mut() {
388 slot.dispatch(cb_id, ctx);
389 }
390 }
391 }
392
393 /// Spin until the executor's halt flag is raised. Hosted-only; on
394 /// bare-metal the BSP wraps `spin_once` in its own loop.
395 #[cfg(feature = "std")]
396 pub fn spin(&mut self) -> Result<(), ExecutorError> {
397 // 10 ms tick cadence — matches the existing executor spin
398 // budgeting (see `Executor::spin_default`); short enough that
399 // component `tick` hooks observe latency under one cycle.
400 let tick = Duration::from_millis(10);
401 while !self.executor.is_halted() {
402 let _ = self.executor.spin_once(tick);
403 self.run_ticks();
404 }
405 Ok(())
406 }
407
408 /// Halt a running [`spin`](Self::spin). Idempotent.
409 #[cfg(feature = "std")]
410 pub fn halt(&self) {
411 self.executor.halt();
412 }
413
414 fn run_ticks(&mut self) {
415 // Per-component tick — each component's resolver is its own cell.
416 // Phase 212.M-F.23: the tick reaches the executor (service-client
417 // call_raw poll, action-server complete/feedback) through a raw
418 // pointer so `&self.components` and `&mut self.executor` (disjoint
419 // fields) can be live at once.
420 let exec_ptr: *mut Executor<'static> = &mut self.executor;
421 for cell in &self.components {
422 tick_one_cell(cell.as_ref(), exec_ptr);
423 }
424 }
425}
426
427/// Phase 258 (Track 2, 2a) — drive one component cell's `tick` against the
428/// executor. The single source of truth for the per-component tick body,
429/// shared by [`ExecutorNodeRuntime::run_ticks`] (the owned-runtime path) and
430/// the executor-enrolled [`component_tick_trampoline`] (the `install` path).
431///
432/// `exec_ptr` is reached through a raw `*mut Executor<'static>` so the caller can hold
433/// the component (`&ComponentCell`) and the executor live at once — they are
434/// disjoint, and `RuntimeActions` / `RuntimeClientDispatch` reborrow `&mut`
435/// per call (see their docs).
436fn tick_one_cell(cell: &ComponentCell, exec_ptr: *mut Executor<'static>) {
437 let resolver = CellResolver { cell };
438 let service_clients = cell.service_clients.borrow();
439 let action_clients = cell.action_clients.borrow();
440 let action_servers = cell.action_servers.borrow();
441 let mut actions = RuntimeActions {
442 executor: exec_ptr,
443 handles: &action_servers,
444 };
445 let mut clients = RuntimeClientDispatch {
446 executor: exec_ptr,
447 services: &service_clients,
448 actions: &action_clients,
449 };
450 let mut ctx = TickCtx::new(&resolver, &mut actions, &mut clients);
451 // W4c — `tick` reads `ctx.parameter::<T>(name)` from the store via the cell pointer
452 // (the store is a separate `Box<ParamState>` allocation, so this does NOT alias the
453 // `&mut Executor<'static>` the action/client tick calls reborrow through `exec_ptr`).
454 #[cfg(feature = "param-services")]
455 ctx.set_param_server(cell.param_server());
456 if let Ok(mut slot) = cell.slot.try_borrow_mut() {
457 slot.tick(&mut ctx);
458 }
459}
460
461/// Phase 258 (Track 2, 2a) — executor `ComponentSlot.tick` trampoline. Casts
462/// the enrolled state back to the component cell + `exec_ctx` back to the
463/// executor and drives one tick. The layering-clean `extern "C"` shim the
464/// `nros-node` [`Executor`] calls each `spin_once` (it can't name `nros`'s
465/// [`ComponentCell`] — see [`register_node_borrowed`]'s enroll).
466///
467/// # Safety
468/// `state` must be the leaked `Arc<ComponentCell>` enrolled via
469/// [`Executor::enroll_component`] from [`register_node_borrowed`] (borrowed
470/// here, **not** consumed); `exec_ctx` must be the live `*mut Executor<'static>` the
471/// executor passes itself.
472unsafe extern "C" fn component_tick_trampoline(
473 state: *mut core::ffi::c_void,
474 exec_ctx: *mut core::ffi::c_void,
475) {
476 // SAFETY: `state` is a live, leaked `Arc<ComponentCell>` ptr (kept alive
477 // until `component_drop_trampoline`); borrow it without taking ownership.
478 let cell = unsafe { &*(state as *const ComponentCell) };
479 tick_one_cell(cell, exec_ctx as *mut Executor<'static>);
480}
481
482/// Phase 258 (Track 2, 2a) — executor `ComponentSlot.drop` trampoline.
483/// Reconstitutes + drops the leaked `Arc<ComponentCell>` enrolled by
484/// [`register_node_borrowed`]. Run exactly once on `Executor::drop`.
485///
486/// # Safety
487/// `state` must be the leaked `Arc<ComponentCell>` enrolled via
488/// [`Executor::enroll_component`], not yet reclaimed.
489unsafe extern "C" fn component_drop_trampoline(state: *mut core::ffi::c_void) {
490 // SAFETY: reclaim the one leaked Arc clone the enroll handed the executor.
491 drop(unsafe { Arc::from_raw(state as *const ComponentCell) });
492}
493
494// =============================================================================
495// Phase 212.N.7 step-3.3 — bridge to platform-side `NodeDispatchRuntime`.
496// =============================================================================
497//
498// `nros_platform::NodeDispatchRuntime` is the board-side sink: object-safe +
499// `no_std`. `BoardEntry::run` installs this `ExecutorNodeRuntime` impl on the
500// per-boot `RuntimeCtx::runtime` slot. The owned-spin entry reaches the live
501// executor through `executor_handle()` (a raw pointer crosses the layering wall
502// cleanly) and installs via `nros::install_node_typed`. Phase 258 (w5) retired
503// the old `register_dispatch_slot_dyn` four-fn-ptr bridge.
504
505impl ::nros_platform::NodeDispatchRuntime for ExecutorNodeRuntime {
506 fn spin_once(&mut self, timeout_ms: u32) -> Result<(), ()> {
507 Self::spin_once(self, Duration::from_millis(timeout_ms.into())).map_err(|_| ())
508 }
509
510 fn executor_handle(&mut self) -> *mut core::ffi::c_void {
511 // Phase 258 (Track 2, 2a) — hand the owned-spin entry a raw pointer to
512 // the executor this runtime owns, so a Node pkg's `register(runtime)`
513 // can install through `nros::install_node_typed` (same seam as the
514 // C/C++ typed entries). The pointer is valid for the runtime's life
515 // (the executor is an inline field); the install call uses it only
516 // during registration, before any concurrent spin.
517 &mut self.executor as *mut Executor<'static> as *mut core::ffi::c_void
518 }
519
520 // Phase 264 W2 — register the REP-2002 lifecycle services + drive boot
521 // autostart on the owned executor (mirrors `generate.rs::render_lifecycle_fn`).
522 // Only compiled with `lifecycle-services`; without it the trait default no-op
523 // applies, so a `[lifecycle]` block is silently inert (the Entry opts in by
524 // enabling `nros/lifecycle-services`). `nros::main!` calls this when
525 // `system.toml` declares `[lifecycle]`.
526 #[cfg(feature = "lifecycle-services")]
527 fn apply_lifecycle(&mut self, autostart: u8) -> Result<(), ()> {
528 self.executor
529 .register_lifecycle_services()
530 .map_err(|_| ())?;
531 if autostart >= 1
532 && let Some(sm) = self.executor.lifecycle_state_machine_mut()
533 {
534 // No transition callbacks registered → each transition takes the
535 // default-success path (REP-2002 skeleton), as the bake does.
536 unsafe {
537 let _ = sm.trigger_transition(crate::LifecycleTransition::Configure);
538 if autostart >= 2 {
539 let _ = sm.trigger_transition(crate::LifecycleTransition::Activate);
540 }
541 }
542 }
543 Ok(())
544 }
545
546 // Phase 264 W4b — register the 6 ROS 2 parameter services on the owned executor
547 // + seed the volatile param store with the launch-baked `<param>` initials
548 // (mirrors `generate.rs::render_param_persistence_fn`, minus persistence). Only
549 // compiled with `param-services`; without it the trait default no-op applies, so a
550 // `[param_services]` block is silently inert (the Entry opts in by enabling
551 // `nros/param-services`). `nros::main!` calls this when `system.toml` declares
552 // `[param_services]`. Reconfigured values (via `ros2 param set`) live in RAM until
553 // the next boot — persistence is out of scope (issue 0080).
554 // W4c note: `nros::main!` emits this BEFORE the per-node `register` calls, so the
555 // store exists when each cell is created — `register_node_borrowed` / `register_node`
556 // then capture the (stable, boxed) `ParameterServer` address on the cell, letting a
557 // callback read `ctx.parameter::<T>(name)`. (The macro-path cells live in the
558 // executor's tick registry, not `self.components`, so a post-pass here wouldn't reach
559 // them — capture-at-registration does.)
560 #[cfg(feature = "param-services")]
561 fn apply_param_services(&mut self, params: &[(&str, &str)]) -> Result<(), ()> {
562 self.executor
563 .register_parameter_services()
564 .map_err(|_| ())?;
565 for (name, raw) in params {
566 self.executor
567 .declare_parameter(name, infer_param_value(raw));
568 }
569 Ok(())
570 }
571
572 fn observed_callback_counts(&self) -> (usize, usize) {
573 let direct = self
574 .components
575 .iter()
576 .fold((0, 0), |(callbacks, messages), cell| {
577 (
578 callbacks + cell.callback_dispatches.load(Ordering::Relaxed),
579 messages + cell.message_dispatches.load(Ordering::Relaxed),
580 )
581 });
582 // issue #140 — install-seam components (`nros::node!` →
583 // `install_node_typed*` → `register_node_borrowed`) never enter
584 // `self.components`; their cells live only as the executor's enrolled
585 // component slots (leaked `Arc<ComponentCell>`s). Without folding them
586 // the hosted spin reported callbacks=0 for every macro-baked entry
587 // (multihost robot2 et al.) while dispatch demonstrably ran. The two
588 // populations are disjoint by construction: `register_node` pushes to
589 // `components` and does not enroll; `register_node_borrowed` enrolls
590 // and does not push.
591 self.executor
592 .enrolled_component_states()
593 .fold(direct, |(callbacks, messages), state| {
594 // SAFETY: every enrolled state is a leaked `Arc<ComponentCell>`
595 // from `register_node_borrowed` (the only enroll site); the
596 // executor keeps it alive until `Executor::drop`, and we only
597 // read its atomic counters here.
598 let cell = unsafe { &*(state as *const ComponentCell) };
599 (
600 callbacks + cell.callback_dispatches.load(Ordering::Relaxed),
601 messages + cell.message_dispatches.load(Ordering::Relaxed),
602 )
603 })
604 }
605}
606
607// =============================================================================
608// Internal sink — bridges `NodeRuntime` declarations onto the
609// live executor.
610// =============================================================================
611
612struct ExecutorSink<'a> {
613 executor: &'a mut Executor<'static>,
614 cell: Arc<ComponentCell>,
615 /// Per-registration node mapping: stable id → executor `NodeId`.
616 nodes: Vec<(String, nros_node::executor::NodeId)>,
617 /// Phase 268 W1 — launch-injected node identity `(name, namespace)` baked by
618 /// `nros::main!` per component. When `Some`, `create_node` uses this identity
619 /// instead of the `NodeOptions` default; `None` → default stands (backward-compat).
620 node_identity: Option<(&'static str, &'static str)>,
621}
622
623impl ExecutorSink<'_> {
624 fn lookup_node(&self, stable_id: &str) -> Option<nros_node::executor::NodeId> {
625 self.nodes
626 .iter()
627 .find(|(id, _)| id == stable_id)
628 .map(|(_, n)| *n)
629 }
630}
631
632impl NodeRuntime for ExecutorSink<'_> {
633 fn create_node(&mut self, id: MetaNodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
634 if self.nodes.iter().any(|(s, _)| s.as_str() == id.as_str()) {
635 return Err(NodeDeclError::Runtime);
636 }
637 // Phase 268 W1 — launch wins over the NodeOptions default (RFC-0046).
638 // When `nros::main!` injected an identity for this component, use it;
639 // otherwise fall back to what the Node declared in its `create_node` call.
640 let (name, ns) = match self.node_identity {
641 Some((n, s)) => (n, s),
642 None => (options.name, options.namespace),
643 };
644 let node_id = self
645 .executor
646 .node_builder(name)
647 .namespace(ns)
648 .domain_id(options.domain_id)
649 .build()
650 .map_err(decl_err_from_node)?;
651 self.nodes.push((String::from(id.as_str()), node_id));
652 Ok(())
653 }
654
655 fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()> {
656 // Phase 228.C tier gate: when this executor runs a specific tier
657 // (`active_groups` set by codegen), an entity whose callback group
658 // is not active on this tier is a no-op — no RMW handle, no slot.
659 // An unlabeled entity (`callback_group == None`) is wildcard-eligible
660 // and always registers; the degenerate single-tier executor leaves
661 // `active_groups == None`, so every entity registers (byte-identical
662 // to pre-228 output).
663 if let Some(group) = metadata.callback_group.as_ref()
664 && !self.executor.group_active(group.as_str())
665 {
666 return Ok(());
667 }
668 let node = self
669 .lookup_node(metadata.node_id.as_str())
670 .ok_or(NodeDeclError::Runtime)?;
671 match metadata.kind {
672 EntityKind::Publisher => {
673 let handle = self
674 .executor
675 .node_mut(node)
676 .create_generic_publisher(
677 metadata.source_name.as_str(),
678 metadata.type_name,
679 metadata.type_hash,
680 )
681 .map_err(decl_err_from_node)?;
682 let id_owned = String::from(metadata.id.as_str());
683 self.cell.publishers.borrow_mut().push((id_owned, handle));
684 Ok(())
685 }
686 EntityKind::Subscription => {
687 let cb_id = metadata
688 .callback_id
689 .as_ref()
690 .ok_or(NodeDeclError::Runtime)?;
691 let cb_id_owned = String::from(cb_id.as_str());
692 let cell = self.cell.clone();
693 // Phase 250 (Wave 2b) — a `.safety()` subscription registers via
694 // the integrity-aware generic path so `CallbackCtx::integrity()`
695 // surfaces CRC + sequence gap/dup. Gated: when `safety-e2e` is off
696 // the flag is ignored and the basic path below runs.
697 #[cfg(feature = "safety-e2e")]
698 if metadata.safety {
699 let cell_s = self.cell.clone();
700 let cb_s = cb_id_owned.clone();
701 self.executor
702 .node_mut(node)
703 .create_generic_subscription_with_integrity(
704 metadata.source_name.as_str(),
705 metadata.type_name,
706 metadata.type_hash,
707 move |payload: &[u8], status: &nros_node::IntegrityStatus| {
708 dispatch_into_cell_with_integrity(&cell_s, &cb_s, payload, status);
709 },
710 )
711 .map_err(decl_err_from_node)?;
712 return Ok(());
713 }
714 self.executor
715 .node_mut(node)
716 .create_generic_subscription(
717 metadata.source_name.as_str(),
718 metadata.type_name,
719 metadata.type_hash,
720 move |payload: &[u8]| {
721 dispatch_into_cell(&cell, &cb_id_owned, payload);
722 },
723 )
724 .map_err(decl_err_from_node)?;
725 Ok(())
726 }
727 EntityKind::Timer => {
728 let cb_id = metadata
729 .callback_id
730 .as_ref()
731 .ok_or(NodeDeclError::Runtime)?;
732 let cb_id_owned = String::from(cb_id.as_str());
733 let period_ms = metadata.period_ms.ok_or(NodeDeclError::Runtime)?;
734 let cell = self.cell.clone();
735 self.executor
736 .register_timer(
737 nros_node::TimerDuration::from_millis(period_ms),
738 move || {
739 dispatch_into_cell(&cell, &cb_id_owned, &[]);
740 },
741 )
742 .map_err(decl_err_from_node)?;
743 Ok(())
744 }
745 // Phase 212.M-F.23 — service / action client + server dispatch on
746 // the single-node runtime. The executor-level `register_*_on`
747 // calls add an arena dispatch entry, so inbound requests / goals
748 // are serviced inside `spin_once`; the leaked `*Ctx` trampoline
749 // contexts bridge back into the component's `on_callback`. Client
750 // handles are stashed in the cell for the tick-side dispatch
751 // (`RuntimeClientDispatch` / `RuntimeActions` in `run_ticks`).
752 EntityKind::ServiceServer => {
753 let cb_id = metadata
754 .callback_id
755 .as_ref()
756 .ok_or(NodeDeclError::Runtime)?;
757 let ctx = Box::into_raw(Box::new(ServiceServerCtx {
758 cell: self.cell.clone(),
759 callback_id: String::from(cb_id.as_str()),
760 })) as *mut core::ffi::c_void;
761 self.executor
762 .register_service_raw_sized_on::<1024, 1024>(
763 node,
764 metadata.source_name.as_str(),
765 metadata.type_name,
766 metadata.type_hash,
767 crate::QosSettings::services_default(),
768 service_server_trampoline,
769 ctx,
770 )
771 .map_err(decl_err_from_node)?;
772 Ok(())
773 }
774 EntityKind::ServiceClient => {
775 let hid = self
776 .executor
777 .register_service_client_raw_sized_on::<1024>(
778 node,
779 metadata.source_name.as_str(),
780 metadata.type_name,
781 metadata.type_hash,
782 crate::QosSettings::services_default(),
783 None,
784 core::ptr::null_mut(),
785 )
786 .map_err(decl_err_from_node)?;
787 self.cell
788 .service_clients
789 .borrow_mut()
790 .push((String::from(metadata.id.as_str()), hid));
791 Ok(())
792 }
793 EntityKind::ActionServer => {
794 let goal_cb = metadata
795 .callback_id
796 .as_ref()
797 .ok_or(NodeDeclError::Runtime)?;
798 let cancel_cb = metadata
799 .action_cancel_callback_id
800 .as_ref()
801 .ok_or(NodeDeclError::Runtime)?;
802 let accepted_cb = metadata
803 .action_accepted_callback_id
804 .as_ref()
805 .map(|c| String::from(c.as_str()));
806 let ctx = Box::into_raw(Box::new(ActionServerCtx {
807 cell: self.cell.clone(),
808 goal_callback_id: String::from(goal_cb.as_str()),
809 cancel_callback_id: String::from(cancel_cb.as_str()),
810 accepted_callback_id: accepted_cb,
811 })) as *mut core::ffi::c_void;
812 let handle = self
813 .executor
814 .register_action_server_raw_sized::<1024, 1024, 1024, 4>(
815 crate::RawActionServerSpec {
816 node_id: Some(node),
817 action_name: metadata.source_name.as_str(),
818 type_name: metadata.type_name,
819 type_hash: metadata.type_hash,
820 qos: crate::QosSettings::services_default(),
821 goal_callback: action_goal_trampoline,
822 cancel_callback: action_cancel_trampoline,
823 accepted_callback: Some(action_accepted_trampoline),
824 context: ctx,
825 },
826 )
827 .map_err(decl_err_from_node)?;
828 self.cell
829 .action_servers
830 .borrow_mut()
831 .push((String::from(metadata.id.as_str()), handle));
832 Ok(())
833 }
834 EntityKind::ActionClient => {
835 // A bound `callback_id` (set by
836 // `create_action_client_with_callbacks_for_name`) delivers the
837 // terminal goal result to the component via `on_callback`; the
838 // optional `action_accepted_callback_id` slot carries the
839 // feedback callback (reused — unused on a client). The executor
840 // auto-drives accept → feedback → result during spin and invokes
841 // these trampolines. No callbacks → send-goal only.
842 let (result_callback, feedback_callback, ctx) = match metadata.callback_id.as_ref()
843 {
844 Some(result_cb) => {
845 let feedback_cb = metadata
846 .action_accepted_callback_id
847 .as_ref()
848 .map(|c| String::from(c.as_str()));
849 let ctx = Box::into_raw(Box::new(ActionClientCtx {
850 cell: self.cell.clone(),
851 result_callback_id: String::from(result_cb.as_str()),
852 feedback_callback_id: feedback_cb.clone(),
853 })) as *mut core::ffi::c_void;
854 let fb = feedback_cb.map(|_| action_feedback_trampoline as _);
855 (Some(action_result_trampoline as _), fb, ctx)
856 }
857 None => (None, None, core::ptr::null_mut()),
858 };
859 let handle = self
860 .executor
861 .register_action_client_raw_sized::<1024, 1024, 1024>(
862 crate::RawActionClientSpec {
863 node_id: Some(node),
864 action_name: metadata.source_name.as_str(),
865 type_name: metadata.type_name,
866 type_hash: metadata.type_hash,
867 goal_response_callback: None,
868 feedback_callback,
869 result_callback,
870 context: ctx,
871 },
872 )
873 .map_err(decl_err_from_node)?;
874 self.cell
875 .action_clients
876 .borrow_mut()
877 .push((String::from(metadata.id.as_str()), handle.entry_index()));
878 Ok(())
879 }
880 EntityKind::Parameter => {
881 // Phase 212.M-F.23 Wave 2 — declarative parameter dispatch on
882 // the single-node runtime. The first declared parameter lazily
883 // stands up the 6 ROS 2 parameter services for this executor's
884 // node; `spin_once` drives those service servers thereafter
885 // (`#[cfg(param-services)]` block at spin.rs). The declared
886 // source default seeds the value. With `param-services` off the
887 // arm is a no-op (entity declared, no RMW handle) — byte-
888 // identical to the pre-Wave-2 behavior.
889 #[cfg(feature = "param-services")]
890 {
891 if self.executor.params().is_none() {
892 self.executor
893 .register_parameter_services()
894 .map_err(decl_err_from_node)?;
895 }
896 let value = param_default_to_value(metadata.parameter_default.as_ref());
897 self.executor
898 .declare_parameter(metadata.source_name.as_str(), value);
899 }
900 Ok(())
901 }
902 }
903 }
904
905 fn record_callback_effect(
906 &mut self,
907 _callback_id: CallbackId<'_>,
908 _kind: CallbackEffectKind,
909 _entity_id: EntityId<'_>,
910 ) -> NodeResult<()> {
911 // Planner concern only — the live runtime doesn't need the
912 // effect graph at spin time.
913 Ok(())
914 }
915}
916
917/// Lower a source-recorded [`ParameterDefault`] into the executor-facing
918/// [`nros_params::ParameterValue`] used to seed a declared parameter. Scalar
919/// defaults carry their value directly; the array variants record only the
920/// declared type (no element data at the source layer) so they seed as
921/// `NotSet` — the parameter is still declared, just without a concrete array
922/// default. A `Double` default is stored as a string at the metadata layer and
923/// parsed here (unparseable → `0.0`).
924#[cfg(feature = "param-services")]
925fn param_default_to_value(
926 default: Option<&crate::node_metadata::ParameterDefault>,
927) -> nros_params::ParameterValue {
928 use crate::node_metadata::ParameterDefault;
929 use nros_params::ParameterValue;
930 match default {
931 None => ParameterValue::NotSet,
932 Some(ParameterDefault::Bool(b)) => ParameterValue::Bool(*b),
933 Some(ParameterDefault::Integer(i)) => ParameterValue::Integer(*i),
934 Some(ParameterDefault::Double(s)) => {
935 ParameterValue::Double(s.as_str().parse::<f64>().unwrap_or(0.0))
936 }
937 Some(ParameterDefault::String(s)) => {
938 ParameterValue::from_string(s.as_str()).unwrap_or(ParameterValue::NotSet)
939 }
940 Some(
941 ParameterDefault::BoolArray
942 | ParameterDefault::IntegerArray
943 | ParameterDefault::DoubleArray
944 | ParameterDefault::StringArray,
945 ) => ParameterValue::NotSet,
946 }
947}
948
949/// Phase 264 W4b — infer a [`nros_params::ParameterValue`] from a raw launch
950/// `<param value=…/>` string. ROS 2 launch `<param>` values are untyped strings; the
951/// macro path has no per-param type attribute, so infer: `true`/`false` → `Bool`, an
952/// `i64` literal → `Integer`, an `f64` literal → `Double`, otherwise `String`. This
953/// mirrors the type a `ros2 param set` of the same literal would land on, so the baked
954/// initial and a CLI override agree on type.
955#[cfg(feature = "param-services")]
956fn infer_param_value(raw: &str) -> nros_params::ParameterValue {
957 use nros_params::ParameterValue;
958 match raw {
959 "true" => return ParameterValue::from_bool(true),
960 "false" => return ParameterValue::from_bool(false),
961 _ => {}
962 }
963 if let Ok(i) = raw.parse::<i64>() {
964 return ParameterValue::from_integer(i);
965 }
966 if let Ok(f) = raw.parse::<f64>() {
967 return ParameterValue::from_double(f);
968 }
969 ParameterValue::from_string(raw).unwrap_or(ParameterValue::NotSet)
970}
971
972fn dispatch_into_cell(cell: &Arc<ComponentCell>, cb_id: &str, payload: &[u8]) {
973 cell.callback_dispatches.fetch_add(1, Ordering::Relaxed);
974 if !payload.is_empty() {
975 cell.message_dispatches.fetch_add(1, Ordering::Relaxed);
976 }
977 let resolver = CellResolver {
978 cell: cell.as_ref(),
979 };
980 let mut ctx = CallbackCtx::new(payload, &resolver);
981 // W4c — let the callback read `ctx.parameter::<T>(name)` from the executor's store
982 // (threaded onto the cell by `apply_param_services`; `None` until then).
983 #[cfg(feature = "param-services")]
984 ctx.set_param_server(cell.param_server());
985 // If the slot is already borrowed (a re-entrant publish from a
986 // tick hook on the same cell, etc.) we drop this dispatch. In
987 // practice `try_borrow_mut` succeeds because subscription / timer
988 // callbacks run sequentially under the single-threaded executor.
989 if let Ok(mut slot) = cell.slot.try_borrow_mut() {
990 slot.dispatch(cb_id, &mut ctx);
991 }
992}
993
994/// Phase 250 (Wave 2b) — dispatch a `.safety()` subscription message into the
995/// component's `on_callback` with its E2E [`IntegrityStatus`] attached, read via
996/// `CallbackCtx::integrity()`. The integrity-aware twin of [`dispatch_into_cell`].
997#[cfg(feature = "safety-e2e")]
998fn dispatch_into_cell_with_integrity(
999 cell: &Arc<ComponentCell>,
1000 cb_id: &str,
1001 payload: &[u8],
1002 status: &nros_node::IntegrityStatus,
1003) {
1004 cell.callback_dispatches.fetch_add(1, Ordering::Relaxed);
1005 if !payload.is_empty() {
1006 cell.message_dispatches.fetch_add(1, Ordering::Relaxed);
1007 }
1008 let resolver = CellResolver {
1009 cell: cell.as_ref(),
1010 };
1011 let mut ctx = CallbackCtx::new_with_integrity(payload, &resolver, status);
1012 // W4c — param store for a `.safety()` subscription callback too.
1013 #[cfg(feature = "param-services")]
1014 ctx.set_param_server(cell.param_server());
1015 if let Ok(mut slot) = cell.slot.try_borrow_mut() {
1016 slot.dispatch(cb_id, &mut ctx);
1017 }
1018}
1019
1020// =============================================================================
1021// Phase 212.M-F.23 — service / action SERVER trampolines + tick-side client /
1022// action dispatch.
1023//
1024// The executor's raw service/action-server registration takes C-ABI fn
1025// pointers, so the runtime leaks a `*Ctx` (lives for the runtime's lifetime,
1026// like the executor) holding the owning `ComponentCell` + the declared
1027// callback ids. Each trampoline rebuilds a `CallbackCtx` and routes into the
1028// component's `on_callback`, exactly as the orchestration codegen's
1029// `svc_tramp_*` / `goal_tramp_*` do for the Entry path.
1030// =============================================================================
1031
1032/// Leaked context for a service-server arena callback.
1033struct ServiceServerCtx {
1034 cell: Arc<ComponentCell>,
1035 callback_id: String,
1036}
1037
1038/// Leaked context for an action-server arena callback (goal + cancel + the
1039/// optional accepted hook all share one).
1040struct ActionServerCtx {
1041 cell: Arc<ComponentCell>,
1042 goal_callback_id: String,
1043 cancel_callback_id: String,
1044 accepted_callback_id: Option<String>,
1045}
1046
1047/// Leaked context for an action-CLIENT result + feedback callbacks.
1048struct ActionClientCtx {
1049 cell: Arc<ComponentCell>,
1050 result_callback_id: String,
1051 feedback_callback_id: Option<String>,
1052}
1053
1054/// Action-client result callback: the executor's spin auto-drives the goal to
1055/// completion and hands the terminal result CDR here; route it into the
1056/// component's `on_callback` (read with `CallbackCtx::message`).
1057unsafe extern "C" fn action_result_trampoline(
1058 _goal_id: *const GoalId,
1059 _status: GoalStatus,
1060 result_data: *const u8,
1061 result_len: usize,
1062 ctx: *mut core::ffi::c_void,
1063) {
1064 let actx = unsafe { &*(ctx as *const ActionClientCtx) };
1065 let result_slice = unsafe { core::slice::from_raw_parts(result_data, result_len) };
1066 dispatch_into_cell(&actx.cell, &actx.result_callback_id, result_slice);
1067}
1068
1069/// Action-client feedback callback: route each feedback CDR into the
1070/// component's `on_callback` under the bound feedback callback id.
1071unsafe extern "C" fn action_feedback_trampoline(
1072 _goal_id: *const GoalId,
1073 feedback_data: *const u8,
1074 feedback_len: usize,
1075 ctx: *mut core::ffi::c_void,
1076) {
1077 let actx = unsafe { &*(ctx as *const ActionClientCtx) };
1078 let Some(cb_id) = actx.feedback_callback_id.as_ref() else {
1079 return;
1080 };
1081 let feedback_slice = unsafe { core::slice::from_raw_parts(feedback_data, feedback_len) };
1082 dispatch_into_cell(&actx.cell, cb_id, feedback_slice);
1083}
1084
1085/// Service-server request callback: deserialize-side runs in the component's
1086/// `on_callback` via `CallbackCtx::with_reply`; the executor sends the reply
1087/// from the bytes written into `resp`.
1088unsafe extern "C" fn service_server_trampoline(
1089 req: *const u8,
1090 req_len: usize,
1091 resp: *mut u8,
1092 resp_cap: usize,
1093 resp_len: *mut usize,
1094 ctx: *mut core::ffi::c_void,
1095) -> bool {
1096 let sctx = unsafe { &*(ctx as *const ServiceServerCtx) };
1097 let req_slice = unsafe { core::slice::from_raw_parts(req, req_len) };
1098 let resp_slice = unsafe { core::slice::from_raw_parts_mut(resp, resp_cap) };
1099 let mut written = 0usize;
1100 let resolver = CellResolver {
1101 cell: sctx.cell.as_ref(),
1102 };
1103 let mut cb = CallbackCtx::with_reply(req_slice, &resolver, resp_slice, &mut written);
1104 // W4c — service-server callback can read `ctx.parameter::<T>(name)` too.
1105 #[cfg(feature = "param-services")]
1106 cb.set_param_server(sctx.cell.param_server());
1107 if let Ok(mut slot) = sctx.cell.slot.try_borrow_mut() {
1108 slot.dispatch(&sctx.callback_id, &mut cb);
1109 }
1110 unsafe { *resp_len = written };
1111 true
1112}
1113
1114/// Action-server goal callback → component `on_callback` with a goal decision.
1115unsafe extern "C" fn action_goal_trampoline(
1116 _goal_id: *const GoalId,
1117 goal_data: *const u8,
1118 goal_len: usize,
1119 ctx: *mut core::ffi::c_void,
1120) -> crate::GoalResponse {
1121 let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1122 let goal_slice = unsafe { core::slice::from_raw_parts(goal_data, goal_len) };
1123 let mut resp = crate::GoalResponse::Reject;
1124 let resolver = CellResolver {
1125 cell: actx.cell.as_ref(),
1126 };
1127 let mut cb = CallbackCtx::with_goal_decision(goal_slice, &resolver, &mut resp);
1128 // W4c — action goal callback can read `ctx.parameter::<T>(name)` too.
1129 #[cfg(feature = "param-services")]
1130 cb.set_param_server(actx.cell.param_server());
1131 if let Ok(mut slot) = actx.cell.slot.try_borrow_mut() {
1132 slot.dispatch(&actx.goal_callback_id, &mut cb);
1133 }
1134 resp
1135}
1136
1137/// Action-server cancel callback → component `on_callback` with a cancel
1138/// decision. The cancel callback has no goal payload.
1139unsafe extern "C" fn action_cancel_trampoline(
1140 _goal_id: *const GoalId,
1141 _status: GoalStatus,
1142 ctx: *mut core::ffi::c_void,
1143) -> crate::CancelResponse {
1144 let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1145 let mut resp = crate::CancelResponse::Rejected;
1146 let resolver = CellResolver {
1147 cell: actx.cell.as_ref(),
1148 };
1149 let mut cb = CallbackCtx::with_cancel_decision(&[], &resolver, &mut resp);
1150 // W4c — action cancel callback can read `ctx.parameter::<T>(name)` too.
1151 #[cfg(feature = "param-services")]
1152 cb.set_param_server(actx.cell.param_server());
1153 if let Ok(mut slot) = actx.cell.slot.try_borrow_mut() {
1154 slot.dispatch(&actx.cancel_callback_id, &mut cb);
1155 }
1156 resp
1157}
1158
1159/// Action-server accepted hook → component `on_callback` (no decision, no
1160/// payload). No-op when the component didn't declare an accepted callback.
1161unsafe extern "C" fn action_accepted_trampoline(
1162 _goal_id: *const GoalId,
1163 ctx: *mut core::ffi::c_void,
1164) {
1165 let actx = unsafe { &*(ctx as *const ActionServerCtx) };
1166 let Some(cb_id) = actx.accepted_callback_id.as_ref() else {
1167 return;
1168 };
1169 dispatch_into_cell(&actx.cell, cb_id, &[]);
1170}
1171
1172/// Tick-side service/action CLIENT dispatch — the single-node runtime's mirror
1173/// of the orchestration `GenClientDispatch`. Resolves the per-component client
1174/// handle arrays + a `*mut Executor<'static>` (the tick borrows `&components` while
1175/// needing `&mut executor`, so the executor is reached through a raw pointer,
1176/// reborrowed `&mut` per call; no aliasing — `executor` and `components` are
1177/// disjoint fields).
1178struct RuntimeClientDispatch<'a> {
1179 executor: *mut Executor<'static>,
1180 services: &'a [(String, crate::HandleId)],
1181 actions: &'a [(String, usize)],
1182}
1183
1184impl RuntimeClientDispatch<'_> {
1185 fn service(&self, entity: &str) -> NodeResult<crate::HandleId> {
1186 self.services
1187 .iter()
1188 .find(|(e, _)| e == entity)
1189 .map(|(_, h)| *h)
1190 .ok_or(NodeDeclError::Runtime)
1191 }
1192
1193 fn action_entry(&self, entity: &str) -> NodeResult<usize> {
1194 self.actions
1195 .iter()
1196 .find(|(e, _)| e == entity)
1197 .map(|(_, i)| *i)
1198 .ok_or(NodeDeclError::Runtime)
1199 }
1200}
1201
1202impl ClientDispatch for RuntimeClientDispatch<'_> {
1203 fn call_raw(
1204 &mut self,
1205 service_entity: &str,
1206 request_cdr: &[u8],
1207 response_buf: &mut [u8],
1208 ) -> NodeResult<usize> {
1209 use crate::ServiceClientTrait;
1210 let hid = self.service(service_entity)?;
1211 {
1212 let executor = unsafe { &mut *self.executor };
1213 let entry = unsafe { executor.service_client_entry_mut(hid.0) }
1214 .ok_or(NodeDeclError::Runtime)?;
1215 entry
1216 .handle
1217 .send_request_raw(request_cdr)
1218 .map_err(|_| NodeDeclError::Runtime)?;
1219 }
1220 // Bounded wait — caps total time so the tick loop stays responsive.
1221 for _ in 0..200 {
1222 let executor = unsafe { &mut *self.executor };
1223 executor.spin_once(core::time::Duration::from_millis(10));
1224 let entry = unsafe { executor.service_client_entry_mut(hid.0) }
1225 .ok_or(NodeDeclError::Runtime)?;
1226 match entry.handle.try_recv_reply_raw(response_buf) {
1227 Ok(Some(len)) => return Ok(len),
1228 Ok(None) => continue,
1229 Err(_) => return Err(NodeDeclError::Runtime),
1230 }
1231 }
1232 Err(NodeDeclError::Runtime)
1233 }
1234
1235 fn send_goal_raw(&mut self, action_entity: &str, goal_cdr: &[u8]) -> NodeResult<GoalId> {
1236 let entry_index = self.action_entry(action_entity)?;
1237 let executor = unsafe { &mut *self.executor };
1238 let core = unsafe { executor.action_client_core_mut(entry_index) }
1239 .ok_or(NodeDeclError::Runtime)?;
1240 let goal_id = core.send_goal_raw(goal_cdr).map_err(decl_err_from_node)?;
1241 // rclcpp-style: request the result immediately. The server queues the
1242 // get_result request until the goal terminates, then replies — the
1243 // executor's spin auto-delivers it to the bound result callback (the
1244 // executor never auto-sends this request, so the declarative client
1245 // must). Best-effort: a transport hiccup just means no result callback.
1246 let _ = core.send_get_result_request(&goal_id);
1247 Ok(goal_id)
1248 }
1249}
1250
1251/// Tick-side action-SERVER execution — mirror of `GenActionExec`. Lets a
1252/// component complete goals / publish feedback / enumerate active goals from
1253/// its `tick` via `TickCtx`.
1254struct RuntimeActions<'a> {
1255 executor: *mut Executor<'static>,
1256 handles: &'a [(String, crate::ActionServerRawHandle)],
1257}
1258
1259impl RuntimeActions<'_> {
1260 fn handle(&self, entity: &str) -> NodeResult<crate::ActionServerRawHandle> {
1261 self.handles
1262 .iter()
1263 .find(|(e, _)| e == entity)
1264 .map(|(_, h)| *h)
1265 .ok_or(NodeDeclError::Runtime)
1266 }
1267}
1268
1269impl ActionExecutor for RuntimeActions<'_> {
1270 fn complete_goal_raw(
1271 &mut self,
1272 action_entity: &str,
1273 goal_id: &GoalId,
1274 status: GoalStatus,
1275 result: &[u8],
1276 ) -> NodeResult<()> {
1277 let handle = self.handle(action_entity)?;
1278 let executor = unsafe { &mut *self.executor };
1279 handle.complete_goal_raw(executor, goal_id, status, result);
1280 Ok(())
1281 }
1282
1283 fn publish_feedback_raw(
1284 &mut self,
1285 action_entity: &str,
1286 goal_id: &GoalId,
1287 feedback: &[u8],
1288 ) -> NodeResult<()> {
1289 let handle = self.handle(action_entity)?;
1290 let executor = unsafe { &mut *self.executor };
1291 handle
1292 .publish_feedback_raw(executor, goal_id, feedback)
1293 .map_err(|_| NodeDeclError::Runtime)
1294 }
1295
1296 fn for_each_active_goal(
1297 &self,
1298 action_entity: &str,
1299 visit: &mut dyn FnMut(&GoalId, GoalStatus),
1300 ) {
1301 if let Ok(handle) = self.handle(action_entity) {
1302 let executor = unsafe { &*self.executor };
1303 handle.for_each_active_goal(executor, |g| visit(&g.goal_id, g.status));
1304 }
1305 }
1306}
1307
1308// Phase 258 (Track 2, w5) — the typed BSP fn-ptr aliases (`NodeRegisterFn` /
1309// `NodeInitFn` / `NodeDispatchFn` / `NodeTickFn`) are gone with the retired
1310// `register_dispatch_slot` / `nros_run_components` BSP-baker path. The
1311// macro-emitted `register(runtime)` wrapper now installs via the
1312// `install_node_typed` seam (Track 2 w4).
1313
1314/// Phase 257 (W0-B) — register an [`ExecutableNode`] `C` against a **borrowed**
1315/// executor (the shared cffi `Executor` a foreign-language typed entry hands in via
1316/// its `nros::global_handle()` / `Node::executor_handle()`), returning the live
1317/// [`ComponentCell`].
1318///
1319/// Unlike [`ExecutorNodeRuntime::register_node`] this owns neither the executor nor a
1320/// components list: the executor's per-entity callbacks hold `Arc<ComponentCell>`
1321/// clones (see [`ExecutorSink`]), so the cell stays alive for the executor's lifetime
1322/// via dispatch alone — the caller may drop the returned cell (pub/sub/timer nodes, the
1323/// W0-B target) or stash it to drive `tick` (service-client/action nodes; phase-257 D2).
1324/// The node self-creates its node (its `Node::NAME`) on the shared executor (phase-257
1325/// D7 Option C — Rust nodes in a foreign entry self-name, no entry-side qos-override).
1326/// Issue 0095 — preserve executor callback-table exhaustion through the
1327/// `NodeError → NodeDeclError` collapse so the register seam (and ultimately
1328/// the `nros::main!` entry) can name `NROS_EXECUTOR_MAX_CBS` instead of an
1329/// opaque `NodeRegister`. Every other `NodeError` stays `Runtime`.
1330fn decl_err_from_node(e: nros_node::NodeError) -> NodeDeclError {
1331 match e {
1332 nros_node::NodeError::ExecutorFull => NodeDeclError::ExecutorFull,
1333 _ => NodeDeclError::Runtime,
1334 }
1335}
1336
1337fn register_node_borrowed<'p, C: ExecutableNode + 'static>(
1338 executor: &mut Executor<'static>,
1339 params: &'p [(&'p str, &'p str)],
1340 node_identity: Option<(&'static str, &'static str)>,
1341) -> NodeResult<Arc<ComponentCell>>
1342where
1343 C::State: 'static,
1344{
1345 let cell = Arc::new(ComponentCell {
1346 slot: RefCell::new(Box::new(TypedSlot::<C> {
1347 state: C::init(),
1348 _phantom: PhantomData,
1349 })),
1350 publishers: RefCell::new(Vec::new()),
1351 service_clients: RefCell::new(Vec::new()),
1352 action_clients: RefCell::new(Vec::new()),
1353 action_servers: RefCell::new(Vec::new()),
1354 callback_dispatches: AtomicUsize::new(0),
1355 message_dispatches: AtomicUsize::new(0),
1356 // W4c — set by `apply_param_services` once the store exists (it runs after this).
1357 #[cfg(feature = "param-services")]
1358 param_server: core::cell::Cell::new(core::ptr::null()),
1359 });
1360 let mut sink = ExecutorSink {
1361 // Reborrow so `executor` stays usable for `enroll_component` after the
1362 // sink (which holds `&mut Executor<'static>`) is dropped below.
1363 executor: &mut *executor,
1364 cell: cell.clone(),
1365 nodes: Vec::new(),
1366 // Phase 268 W1 — thread the per-component identity bake (RFC-0046).
1367 node_identity,
1368 };
1369 let sink_dyn: &mut dyn NodeRuntime = &mut sink;
1370 let mut context = NodeContext::new(C::NAME, sink_dyn);
1371 // Phase 264 W4a — seed the baked launch-param initials so `register()` can read
1372 // them via `NodeContext::param`.
1373 context.set_params(params);
1374 C::register(&mut context)?;
1375
1376 // Phase 258 (Track 2, 2a) — enroll the cell in the executor's component
1377 // tick registry so `install`'d nodes tick (closes phase-257 D2: poll-only
1378 // service-client/action nodes have no callbacks keeping the cell alive AND
1379 // never ran `tick`). Leak one `Arc` clone for the executor to own; it runs
1380 // `tick` each `spin_once` and drops the clone on `Executor::drop`. Harmless
1381 // for pub/sub/timer-only nodes — their tick body is a no-op. Registry-full
1382 // (`MAX_NODES`) reclaims the clone and proceeds without a tick slot.
1383 let raw = Arc::into_raw(cell.clone()) as *mut core::ffi::c_void;
1384 // SAFETY: `raw` is a freshly-leaked `Arc<ComponentCell>`; the trampolines
1385 // match its provenance (borrow on tick, reclaim on drop).
1386 if unsafe {
1387 executor.enroll_component(raw, component_tick_trampoline, component_drop_trampoline)
1388 }
1389 .is_err()
1390 {
1391 // SAFETY: enroll rejected the slot, so the executor never took `raw`;
1392 // reclaim the leaked clone here to avoid a permanent leak.
1393 drop(unsafe { Arc::from_raw(raw as *const ComponentCell) });
1394 }
1395
1396 // W4c — capture the executor's volatile param store on the cell so this node's
1397 // callbacks can read `ctx.parameter::<T>(name)`. Non-null only when `nros::main!`
1398 // emitted `apply_param_services` BEFORE this register call (`[param_services]`
1399 // declared); otherwise the store is absent and the field stays null.
1400 #[cfg(feature = "param-services")]
1401 if let Some(server) = executor.params() {
1402 cell.param_server.set(server as *const _);
1403 }
1404
1405 Ok(cell)
1406}
1407
1408/// Phase 257 (W0-B) — C-ABI typed component install. Recovers the shared `Executor`
1409/// from the foreign typed entry's handle (`global_handle()` / `Node::executor_handle()`
1410/// = the `_opaque` `*mut Executor<'static>`; cf. nros-c `get_executor_from_ptr`) and registers
1411/// `C` on it via [`register_node_borrowed`]. The component's `ComponentCell` is kept
1412/// alive by the executor's own callback `Arc` clones (phase-257 D1), so this drops the
1413/// returned cell. Returns `0` on success, `-1` on a null handle or a registration error.
1414///
1415/// This backs the `__nros_component_<pkg>_install(node, executor, self)` symbol
1416/// `nros::node!()` emits — the uniform cross-language install seam (phase-257 D6).
1417///
1418/// # Safety
1419/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes (its
1420/// `nros::global_handle()` / a node's `executor_handle()`), valid for the call.
1421pub unsafe fn install_node_typed<C: ExecutableNode + 'static>(
1422 executor: *mut core::ffi::c_void,
1423) -> i32
1424where
1425 C::State: 'static,
1426{
1427 // SAFETY: forwarded per this fn's contract; no baked params.
1428 unsafe { install_node_typed_with_params::<C>(executor, &[]) }
1429}
1430
1431/// W4a — same as [`install_node_typed`] but seeds the node's [`NodeContext`] with the
1432/// launch-baked `<param>` initial values, so a `register`/`init`-time `ctx.param(name)`
1433/// observes the compile-time launch value (RFC-0004 §10). `params` must outlive the call.
1434///
1435/// # Safety
1436/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes, valid for the call.
1437pub unsafe fn install_node_typed_with_params<C: ExecutableNode + 'static>(
1438 executor: *mut core::ffi::c_void,
1439 params: &[(&str, &str)],
1440) -> i32
1441where
1442 C::State: 'static,
1443{
1444 // SAFETY: forwarded per this fn's contract; no identity injection.
1445 unsafe { install_node_typed_with_node_identity::<C>(executor, params, None) }
1446}
1447
1448/// Phase 268 W1 — same as [`install_node_typed_with_params`] but also injects the
1449/// launch `<node name= namespace=>` identity so `ExecutorSink::create_node` uses it
1450/// instead of the `NodeOptions` default (RFC-0046). `None` → backward-compatible
1451/// (NodeOptions stands). `nros::node!()` calls this variant to carry the identity from
1452/// `RuntimeCtx::node_identity` set by `nros::main!` per component. `params` and
1453/// `node_identity` strings must outlive the call (both are `'static` in the macro emit).
1454///
1455/// # Safety
1456/// `executor` must be the live `*mut Executor<'static>` handle a typed entry passes, valid for the call.
1457pub unsafe fn install_node_typed_with_node_identity<C: ExecutableNode + 'static>(
1458 executor: *mut core::ffi::c_void,
1459 params: &[(&str, &str)],
1460 node_identity: Option<(&'static str, &'static str)>,
1461) -> i32
1462where
1463 C::State: 'static,
1464{
1465 if executor.is_null() {
1466 return -1;
1467 }
1468 // SAFETY: per the fn contract, `executor` is the live `*mut Executor<'static>` handle.
1469 let exec: &mut Executor<'static> = unsafe { &mut *(executor as *mut Executor<'static>) };
1470 match register_node_borrowed::<C>(exec, params, node_identity) {
1471 Ok(_cell) => 0,
1472 // Issue 0095 — distinct code for executor-table exhaustion so the macro
1473 // register seam can name `NROS_EXECUTOR_MAX_CBS` instead of an opaque
1474 // `NodeRegister`. Every other failure stays the generic `-1`.
1475 Err(NodeDeclError::ExecutorFull) => -2,
1476 Err(_) => -1,
1477 }
1478}
1479
1480// Phase 258 (Track 2, w5) — `nros_run_components` (the BSP shim that registered
1481// every component via the four-fn-ptr `register_dispatch_slot` then spun) is
1482// gone. It had no callers; owned-spin / BSP entries register through the
1483// `install_node_typed` seam + drive `ExecutorNodeRuntime::spin`.
1484
1485// =============================================================================
1486// Tests
1487// =============================================================================
1488//
1489// Concrete `Executor` construction needs a real RMW backend session
1490// (with `rmw-cffi` on, `Executor::from_session` takes the cffi
1491// session). MockSession only exists when `rmw-cffi` is off — so the
1492// unit tests that exercise live timer firing live in
1493// `packages/testing/nros-tests/tests/phase212_m5a2_component_runtime.rs`
1494// gated behind the `component-runtime-test` feature (pulls
1495// `nros-rmw-zenoh`). The compile-only smoke here verifies the public
1496// types are reachable through the umbrella surface.
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501 use crate::node::Node;
1502
1503 #[test]
1504 fn handle_slot_is_observable() {
1505 // Trivial smoke — the handle type carries the slot index.
1506 let h = RegisteredNode::<DummyComp> {
1507 component_idx: 7,
1508 _phantom: PhantomData,
1509 };
1510 assert_eq!(h.slot(), 7);
1511 }
1512
1513 struct DummyComp;
1514 impl Node for DummyComp {
1515 const NAME: &'static str = "dummy";
1516 fn register(_ctx: &mut NodeContext<'_>) -> NodeResult<()> {
1517 Ok(())
1518 }
1519 }
1520 impl ExecutableNode for DummyComp {
1521 type State = ();
1522 fn init() -> Self::State {}
1523 fn on_callback(_s: &mut (), _cb: Callback<'_>, _ctx: &mut CallbackCtx<'_>) {}
1524 }
1525}