Skip to main content

nros_platform/board/
runtime.rs

1//! [`RuntimeCtx`] — Phase 212.N.1.
2//!
3//! Per-invocation runtime context handed to `BoardEntry::run`'s
4//! `setup` callback. Carries the overlay knobs the codegen
5//! `run_plan(runtime)` body reads:
6//!
7//! - **params** — `(key, value)` pairs from launch XML
8//!   `<param name="…" value="…"/>` or `--ros-args -p k:=v`.
9//! - **remaps** — `(from, to)` topic/service renames.
10//! - **env** — environment-style key/value pairs (POSIX `getenv`
11//!   shape) accessible from no_std boards via this struct rather
12//!   than a `libc::getenv` call.
13//! - **runtime** — `&mut dyn NodeDispatchRuntime` sink the
14//!   codegen-emitted `run_plan(runtime)` body forwards each Node
15//!   pkg's `register(runtime)` call into (Phase 212.N.7 step-3.2).
16//!   Populated by each `BoardEntry::run` impl after opening its
17//!   executor; defaults to a no-op sink when constructed via
18//!   [`RuntimeCtx::with_runtime`].
19//!
20//! ## no_std-safe shape
21//!
22//! Slice-of-tuples kept on the boot stack. No allocation, no
23//! `core::collections`. Codegen owns the storage and passes a
24//! `&mut RuntimeCtx<'_>` whose backing slices live in `static`s.
25//!
26//! Hosted boards (POSIX) may instead build a longer-lived owned
27//! variant on the heap; the trait surface is slice-based so
28//! both shapes work.
29
30use super::dispatch::DispatchStrategy;
31
32/// Layer-clean substitute for `nros::node_metadata::CallbackId` +
33/// `nros::node::CallbackCtx` at the [`NodeDispatchRuntime`] boundary
34/// (Phase 216.A.2). `nros-platform` sits below `nros` in the dep
35/// graph, so the trait surface cannot reference those types
36/// directly. The `nros`-side runtime impl wraps a real
37/// `(CallbackId, &mut CallbackCtx)` pair into this opaque shape
38/// before invoking [`NodeDispatchRuntime::signal_callback`]; the
39/// concrete dispatcher casts `ctx_ptr` back to
40/// `&mut nros::CallbackCtx<'_>` at the call site.
41///
42/// `#[repr(C)]` keeps the layout stable across the (same-language
43/// today, FFI-shaped tomorrow) `nros-platform` ↔ `nros` boundary.
44#[repr(C)]
45pub struct SignaledCallback<'a> {
46    /// Stable identifier string carried by `nros::CallbackId(&'a str)`.
47    pub cb_id: &'a str,
48
49    /// Erased pointer to the `nros::CallbackCtx<'_>` the dispatcher
50    /// will drive. The `nros`-side `NodeDispatchRuntime` impl casts
51    /// back to `&mut nros::CallbackCtx<'_>` before invoking the
52    /// component body.
53    pub ctx_ptr: *mut core::ffi::c_void,
54}
55
56// Phase 258 (Track 2, w5) — the opaque per-Node `extern "Rust" fn()` aliases
57// (`NodeRegisterFn` / `NodeInitFn` / `NodeDispatchFn` / `NodeTickFn`) are gone.
58// They anchored the retired `register_dispatch_slot_dyn` four-fn-ptr bridge
59// (owned-spin declarative register), which the install seam replaced — Rust
60// owned-spin now registers via `RuntimeCtx::runtime.executor_handle()` +
61// `nros::install_node_typed` like the C/C++ typed entries.
62
63/// Node runtime sink the codegen-emitted `run_plan(runtime)`
64/// body talks to (Phase 212.N.7 step-3.1).
65///
66/// Object-safe + `no_std`. The concrete impl
67/// (`ExecutorNodeRuntime` in `nros`) owns the live executor;
68/// `BoardEntry::run` installs it on the per-boot
69/// [`RuntimeCtx::runtime`] slot before invoking the user `setup`
70/// closure.
71///
72/// Phase 214.K.1 — renamed from `NodeRuntime` to disambiguate from
73/// the user-facing `nros::NodeRuntime` metadata-sink trait in
74/// `packages/api/nros/src/node.rs:112`. The two traits live at
75/// different layers (board-side dispatch sink vs user-side metadata
76/// declaration sink) and the previous shared name forced explicit
77/// `nros_platform::` / `nros::` qualification at every use site +
78/// produced confusing `impl NodeRuntime for X` ambiguity. The
79/// phase-214.K.1 `NodeRuntime` deprecation alias was removed in phase-313
80/// W1 (issue #0243); use `NodeDispatchRuntime` directly.
81pub trait NodeDispatchRuntime {
82    /// Drive the underlying executor for at most `timeout_ms`
83    /// milliseconds. `Ok(())` on a clean spin (including timeout);
84    /// `Err(())` if the executor surfaces a spin error.
85    ///
86    /// `Result<_, ()>` is deliberate: the board entry-point callers
87    /// (`nros-board-{freertos,nuttx,threadx}` spin loops) only
88    /// `{:?}`-print the error and `B::exit_failure()` — a typed enum
89    /// would carry no extra info across the trait boundary, since the
90    /// underlying `ExecutorError` from `nros::node_runtime` is mapped
91    /// to `()` at the impl site (`impl NodeDispatchRuntime for
92    /// ExecutorNodeRuntime`). `#[allow]` keeps the surface narrow.
93    #[allow(clippy::result_unit_err)]
94    fn spin_once(&mut self, timeout_ms: u32) -> Result<(), ()>;
95
96    /// Phase 264 W2 — register the REP-2002 lifecycle services on the underlying
97    /// executor + drive boot autostart. `autostart`: 0 = none, 1 = configure,
98    /// 2 = configure+activate. Default no-op (non-executor runtimes, or `nros`
99    /// built without `lifecycle-services`); the `ExecutorNodeRuntime` impl in
100    /// `nros` does the real work behind that feature. `nros::main!` calls this
101    /// (via [`RuntimeCtx::apply_lifecycle`]) when `system.toml` declares
102    /// `[lifecycle]`, mirroring the bake's `generate.rs::render_lifecycle_fn`.
103    /// issue 0460 — the `Err` carries a STATIC REASON, not `()`.
104    ///
105    /// A capability that fails to register is reported by the caller as
106    /// `RuntimeError::NodeRegister("lifecycle")`, which names WHICH capability
107    /// and nothing about WHY. On Zephyr that opaque string was the entire
108    /// diagnostic for three dead entries: the image printed nothing after
109    /// "Network ready" and the cause could only be guessed at. `&'static str`
110    /// rather than a typed error because this trait lives below `nros-node` and
111    /// cannot name `NodeError`.
112    fn apply_lifecycle(&mut self, autostart: u8) -> Result<(), &'static str> {
113        let _ = autostart;
114        Ok(())
115    }
116
117    /// Phase 264 W4b — register the 6 ROS 2 parameter services on the underlying
118    /// executor + seed a volatile RAM param store with the launch-baked `<param>`
119    /// initials (`params` is the aggregate `(name, value)` slice, value as the raw
120    /// launch string — the impl infers the `ParameterValue` type). After this,
121    /// `ros2 param list/get/set` works against the running node; reconfigured values
122    /// live in RAM until the next boot (RFC-0004 §10; persistence is out of scope,
123    /// issue 0080). Default no-op (non-executor runtimes, or `nros` built without
124    /// `param-services`); the `ExecutorNodeRuntime` impl in `nros` does the real work
125    /// behind that feature. `nros::main!` calls this (via
126    /// [`RuntimeCtx::apply_param_services`]) when `system.toml` declares
127    /// `[param_services]`, mirroring the bake's `generate.rs::render_param_persistence_fn`.
128    /// issue 0460 — the `Err` carries a STATIC REASON, not `()`.
129    ///
130    /// A capability that fails to register is reported by the caller as
131    /// `RuntimeError::NodeRegister("lifecycle")`, which names WHICH capability
132    /// and nothing about WHY. On Zephyr that opaque string was the entire
133    /// diagnostic for three dead entries: the image printed nothing after
134    /// "Network ready" and the cause could only be guessed at. `&'static str`
135    /// rather than a typed error because this trait lives below `nros-node` and
136    /// cannot name `NodeError`.
137    fn apply_param_services(&mut self, params: &[(&str, &str)]) -> Result<(), &'static str> {
138        let _ = params;
139        Ok(())
140    }
141
142    /// Phase 258 (Track 2, 2a) — raw `*mut Executor` (as `void*`) for the
143    /// owned-spin entry, so a Node pkg's `register(runtime)` wrapper can call
144    /// the uniform `__nros_component_<pkg>_install(.., executor, ..)` seam
145    /// (`nros::install_node_typed`) instead of the retired opaque-fn-ptr
146    /// `register_dispatch_slot_dyn` bridge. A pointer crosses the
147    /// `nros-platform` → `nros` layering wall cleanly (the concrete
148    /// `ExecutorNodeRuntime` lives in `nros`; this trait can't name it).
149    ///
150    /// Default `null` — sinks without a live executor (e.g.
151    /// [`NullNodeRuntime`], framework-dispatch-only runtimes) report no
152    /// handle; the install path treats null as a registration error.
153    fn executor_handle(&mut self) -> *mut core::ffi::c_void {
154        core::ptr::null_mut()
155    }
156
157    /// Observability counters from hosted/runtime tests.
158    ///
159    /// Returns `(all_callbacks, message_callbacks)`. Implementations
160    /// that cannot observe callback dispatch keep the default zeros.
161    fn observed_callback_counts(&self) -> (usize, usize) {
162        (0, 0)
163    }
164
165    /// Hand a signaled callback to the framework-side dispatcher
166    /// (Phase 216.A.2). Only meaningful for `DispatchStrategy::Deferred`
167    /// (RTIC / Embassy) runtimes — `Inline` runtimes drive callbacks
168    /// directly from `spin_once` and never call this. The default panic
169    /// surfaces the mis-wire loudly rather than silently dropping the
170    /// callback signal.
171    fn signal_callback(&mut self, _cb: SignaledCallback<'_>) {
172        panic!("signal_callback not implemented for Inline runtime");
173    }
174
175    /// Declare how this runtime delivers callbacks (Phase 216.A.2).
176    /// `nros check` (Phase 216.D.1) cross-validates each Node pkg's
177    /// `Node::DISPATCH` against this value. Defaults to `Inline` so
178    /// every existing impl reports the historical behavior unchanged.
179    fn dispatch_strategy(&self) -> DispatchStrategy {
180        DispatchStrategy::Inline
181    }
182}
183
184/// No-op [`NodeDispatchRuntime`] for tests / placeholders. Every call
185/// returns `Err(())` so callers that depend on a populated runtime
186/// fail loud rather than silently no-op.
187///
188/// `BoardEntry::run` impls replace this with a real
189/// `ExecutorNodeRuntime`-backed sink before invoking the user
190/// `setup` closure.
191#[derive(Debug, Default)]
192pub struct NullNodeRuntime;
193
194impl NodeDispatchRuntime for NullNodeRuntime {
195    fn spin_once(&mut self, _timeout_ms: u32) -> Result<(), ()> {
196        Err(())
197    }
198}
199
200/// Runtime context handed to `BoardEntry::run(setup)`.
201///
202/// All three overlay slices may be empty. A board's launch overlay
203/// typically populates `params` + `remaps`; `env` is rarely set on
204/// embedded.
205pub struct RuntimeCtx<'a> {
206    /// `<param name=… value=…/>` from launch XML, or
207    /// `-p name:=value` CLI overrides.
208    pub params: &'a [(&'a str, &'a str)],
209
210    /// Topic / service / action remaps: `(from, to)`, set per component by
211    /// `nros::main!` from the model's launch `<remap>` rules (phase-306 W3,
212    /// issue 0255). `nros::node!`'s `register` forwards them into
213    /// `install_node_typed_with_launch`, where entity creation expands
214    /// `~`/relative names and substitutes matching rules (exact-FQN match,
215    /// first rule wins).
216    pub remaps: &'a [(&'a str, &'a str)],
217
218    /// Environment-style key/value pairs (mostly POSIX). Empty on
219    /// embedded boards.
220    pub env: &'a [(&'a str, &'a str)],
221
222    /// Phase 268 W1 — launch-injected node identity `(name, namespace)` for
223    /// the NEXT `register()` call, set by `nros::main!` per component before
224    /// each `<pkg>::register(runtime)?` call. `None` → the node's own
225    /// `create_node(NodeOptions::new("…"))` default stands (backward-compatible).
226    /// Reset to `None` after every register in the self-bringup arm so a prior
227    /// component's identity never leaks into the next (RFC-0046).
228    pub node_identity: Option<(&'static str, &'static str)>,
229
230    /// Issue #52 — per-node QoS overrides for the NEXT `register()` call, baked
231    /// by `nros::main!` from the model's
232    /// `qos_overrides.<topic>.<role>.<policy>` params. Same reset discipline as
233    /// `params`/`remaps`: `&[]` when the node has none, so a prior component's
234    /// table never leaks into the next.
235    ///
236    /// Primitive `(topic, role, policy, value)` codes — the SAME wire form the
237    /// C and C++ ABIs use — because `nros-platform` sits below `nros-rmw` in the
238    /// layer graph and a typed `QoSOverride` field here would invert it. The
239    /// register seam installs them via `Executor::set_node_qos_overrides`, which
240    /// decodes at entity-create time.
241    pub qos_overrides: &'static [(&'static str, u8, u8, u32)],
242
243    /// Node runtime sink. `BoardEntry::run` populates this with
244    /// the live `ExecutorNodeRuntime`-backed impl before invoking
245    /// the user `setup` closure. The codegen-emitted
246    /// `run_plan(runtime)` body calls `<pkg>::register(runtime)` once per Node
247    /// pkg, which installs through `runtime.executor_handle()` +
248    /// `nros::install_node_typed` (Phase 258, Track 2).
249    ///
250    /// Defaults to a [`NullNodeRuntime`] when the context is
251    /// built via [`RuntimeCtx::with_runtime`]. That sink errors
252    /// every call so test fixtures that forget to wire a real runtime
253    /// fail loud.
254    pub runtime: &'a mut dyn NodeDispatchRuntime,
255}
256
257impl core::fmt::Debug for RuntimeCtx<'_> {
258    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
259        f.debug_struct("RuntimeCtx")
260            .field("params", &self.params)
261            .field("remaps", &self.remaps)
262            .field("qos_overrides", &self.qos_overrides)
263            .field("env", &self.env)
264            .field("node_identity", &self.node_identity)
265            .field("runtime", &"<dyn NodeDispatchRuntime>")
266            .finish()
267    }
268}
269
270impl<'a> RuntimeCtx<'a> {
271    /// Build a [`RuntimeCtx`] with no params / remaps / env and the
272    /// given runtime sink. The common shape `BoardEntry::run`
273    /// constructs after opening its executor.
274    ///
275    /// For test fixtures that don't need a populated runtime, pass a
276    /// `&mut NullNodeRuntime` — every call against the sink
277    /// returns `Err(())`, surfacing the missing wiring.
278    pub fn with_runtime(runtime: &'a mut dyn NodeDispatchRuntime) -> Self {
279        Self {
280            params: &[],
281            remaps: &[],
282            env: &[],
283            node_identity: None,
284            qos_overrides: &[],
285            runtime,
286        }
287    }
288
289    /// Build a [`RuntimeCtx`] with explicit overlay slices + runtime
290    /// sink (Phase 212.N.7 step-3.2).
291    pub fn new(
292        runtime: &'a mut dyn NodeDispatchRuntime,
293        params: &'a [(&'a str, &'a str)],
294        remaps: &'a [(&'a str, &'a str)],
295        env: &'a [(&'a str, &'a str)],
296    ) -> Self {
297        Self {
298            params,
299            remaps,
300            env,
301            node_identity: None,
302            qos_overrides: &[],
303            runtime,
304        }
305    }
306
307    /// Phase 264 W2 — register lifecycle services + drive autostart on the runtime
308    /// sink (forwards to [`NodeDispatchRuntime::apply_lifecycle`]). `nros::main!`
309    /// emits this after the per-node `register` calls when `system.toml` declares
310    /// `[lifecycle]`. No-op unless `nros` is built with `lifecycle-services`.
311    pub fn apply_lifecycle(&mut self, autostart: u8) -> Result<(), &'static str> {
312        self.runtime.apply_lifecycle(autostart)
313    }
314
315    /// Phase 264 W4b — register the ROS 2 parameter services + seed the volatile
316    /// param store from the launch-baked `<param>` initials (forwards to
317    /// [`NodeDispatchRuntime::apply_param_services`]). `nros::main!` emits this after
318    /// the per-node `register` calls when `system.toml` declares `[param_services]`.
319    /// No-op unless `nros` is built with `param-services`.
320    pub fn apply_param_services(&mut self, params: &[(&str, &str)]) -> Result<(), &'static str> {
321        self.runtime.apply_param_services(params)
322    }
323
324    /// Lookup a param by name; first match wins. Linear scan
325    /// because the slice is typically small (≤ a dozen entries).
326    pub fn param(&self, name: &str) -> Option<&'a str> {
327        self.params
328            .iter()
329            .find(|(k, _)| *k == name)
330            .map(|(_, v)| *v)
331    }
332
333    /// Lookup a remap by the original (`from`) name; returns the
334    /// rewritten name when remapped, else `None`.
335    pub fn remap(&self, from: &str) -> Option<&'a str> {
336        self.remaps
337            .iter()
338            .find(|(k, _)| *k == from)
339            .map(|(_, v)| *v)
340    }
341
342    /// Lookup an env entry by name.
343    pub fn env_var(&self, name: &str) -> Option<&'a str> {
344        self.env.iter().find(|(k, _)| *k == name).map(|(_, v)| *v)
345    }
346}
347
348/// Error returned by the codegen-emitted `run_plan(runtime)` body
349/// (Phase 212.N.4) and by Node pkg `register(runtime)` wrappers
350/// (Phase 212.N.7 step-2).
351///
352/// `no_std`-safe — variants are string-typed so embedded Entry pkgs
353/// don't need to pull `thiserror`/`anyhow` to print. The
354/// out-of-tree `nros-build` codegen library re-exports this type so
355/// emitted code references `::nros_platform::RuntimeError`, NOT
356/// `::nros_build::RuntimeError` — the embedded Entry pkg's runtime
357/// path then doesn't need `nros-build` as a runtime dep (build-dep
358/// only).
359#[derive(Debug)]
360#[non_exhaustive]
361pub enum RuntimeError {
362    /// A node's `register(runtime)` call failed. The string carries the
363    /// node pkg name.
364    ///
365    /// Phase 212.N.12 hard-renamed the legacy `ComponentRegister` variant
366    /// to `NodeRegister` to match the rclcpp_components / ROS 2 launch.xml
367    /// `<node pkg=…>` convention.
368    NodeRegister(&'static str),
369
370    /// issue 0460 — a CAPABILITY (`[lifecycle]`, `[param_services]`) failed to
371    /// register, with the reason.
372    ///
373    /// Distinct from [`NodeRegister`](Self::NodeRegister) because reusing it
374    /// forced the capability name to be the whole message: three Zephyr entries
375    /// died reporting `NodeRegister("lifecycle")`, which says WHICH capability
376    /// and nothing about WHY, and the image printed nothing else. Two static
377    /// strings rather than a log call, because not every entry crate depends on
378    /// `log` — the first attempt emitted `::log::error!` from the macro and
379    /// broke `native_rust_qos_entry` with `cannot find 'log' in the crate root`.
380    Capability {
381        /// `"lifecycle"` / `"param_services"`.
382        name: &'static str,
383        /// Why registration failed, from the runtime that attempted it.
384        reason: &'static str,
385    },
386
387    /// Issue 0095 — a node's `register` failed specifically because the
388    /// executor's fixed callback-entry table (`NROS_EXECUTOR_MAX_CBS`) is full.
389    /// Distinct from the opaque [`NodeRegister`](Self::NodeRegister) so the user
390    /// sees the actionable knob. The string carries the node pkg name.
391    ExecutorFull(&'static str),
392
393    /// The hosted Entry spin loop failed or did not observe the
394    /// requested runtime condition before its bounded test deadline.
395    Spin,
396}
397
398impl core::fmt::Display for RuntimeError {
399    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
400        match self {
401            Self::NodeRegister(msg) => write!(f, "node register failed: {msg}"),
402            Self::ExecutorFull(msg) => write!(
403                f,
404                "node '{msg}' register failed: executor callback table full — \
405                 raise NROS_EXECUTOR_MAX_CBS (build-time env, default 4)"
406            ),
407            Self::Capability { name, reason } => write!(
408                f,
409                "capability '[{name}]' failed to register: {reason} — the system \
410                 declares it, so the entry cannot run without it"
411            ),
412            Self::Spin => write!(f, "entry spin failed"),
413        }
414    }
415}