nros/init.rs
1//! Phase 212.L.5 — top-level init API; phase-427 W9 — on every target.
2//!
3//! [`Context`] is WHERE this image is connected — locator, domain, RMW,
4//! session mode, and the source those came from (RFC-0089 "Context and
5//! `init`, settled"). One per process or image; a resolved value, not an
6//! entity on the graph. A `Node` is a named participant with its own
7//! entities, and one image holds several of those on one `Context`
8//! (RFC-0047).
9//!
10//! ## Where the values come from
11//!
12//! * **Hosted** (feature `env`): the process environment (`ROS_DOMAIN_ID`,
13//! `NROS_LOCATOR`, `NROS_SESSION_MODE`, `NROS_RMW` / `RMW_IMPLEMENTATION`)
14//! through the one hosted resolver, `crate::env::try_resolve_hosted`. A
15//! launcher projects per-node values into that environment before exec.
16//! * **Freestanding** (no `env`): there is no process and no environment; the
17//! same values are BAKED at compile time — `option_env!("NROS_LOCATOR")` and
18//! `option_env!("NROS_DOMAIN_ID")`, exported into this crate's build by
19//! `nros_zephyr_build::bake_nros_config()` from Kconfig (`build.rs`), or
20//! present in the process environment of the build for the other RTOS lanes
21//! (the board crates read the same two names the same way). The semantics
22//! survive with the source moved from run time to build time: "the
23//! environment this image was built for or launched in".
24//!
25//! The `env` feature gates ONLY the process-environment reader and the
26//! argv/launch entry points; `Context`, [`ContextSource`], [`InitError`],
27//! [`InitOptions`] and [`Context::config`] compile on every target. The floor
28//! for `Context` itself is `alloc`: `locator` and `rmw` are owned `String`s
29//! so the value outlives whatever transient produced it (an env cache, a
30//! parsed launch file, a harness-supplied override).
31//!
32//! ## The rclrs family
33//!
34//! [`Context::default_from_env`], [`Context::from_env`] and (hosted only)
35//! [`Context::new`] are the `rclrs::Context` constructors under the same
36//! names; [`InitOptions`] is rclrs's, with the one option it has. [`init()`] is
37//! the C++-symmetric anchor (`rclcpp::init`) and equals `default_from_env()`.
38//!
39//! Two patterns are supported:
40//!
41//! 1. **Node pkg** — register via the [`nros::node!`](crate::node!)
42//! macro (Phase 172 W.3); the generated runtime owns the spin loop and
43//! builds its `Context` through [`Context::default_from_env`].
44//! 2. **Application pkg** — call [`init()`] / [`Context::default_from_env`]
45//! (or [`init_with_args`] / [`Context::new`] for argv-style entry) and
46//! drive your own spin. The returned [`Context`] carries the domain id,
47//! locator and RMW choice from the environment — see "Where launch content
48//! comes from" below.
49//!
50//! To actually open a session, call [`Context::create_executor`] (`alloc`) or
51//! [`Context::create_executor_in`] (caller-supplied backing, nothing leaked),
52//! then name each node with `Executor::create_node`. The older spelling —
53//! materialise an [`crate::ExecutorConfig`] via [`Context::config`] and pass it
54//! to `Executor::open` — still works and is what a single-node entry wants;
55//! phase-427 W10 added the pair because it cannot express the second node.
56//!
57//! ## Where launch content comes from
58//!
59//! Nothing here reads a launch file at run time. A launch tree reaches an
60//! image at BUILD time: `nros sync` resolves it into the image's SystemModel,
61//! and the generated entry (`nros::main!(launch = …)`,
62//! `nano_ros_entry(… LAUNCH …)`) installs its node identities, remaps and
63//! parameters. What is left for init to read is the environment
64//! (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
65//! `RMW_IMPLEMENTATION`) and, through [`init_with_args`] / [`Context::new`],
66//! per-process `--ros-args`. (Issue 1917 retired the runtime-overlay
67//! placeholder and, with issue 1931, the `init_with_launch*` entry points
68//! named after it.)
69
70#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
71use core::mem::MaybeUninit;
72
73#[cfg(feature = "alloc")]
74use nros_node::DOMAIN_ID_MAX;
75#[cfg(feature = "alloc")]
76use nros_node::ExecutorConfig;
77// phase-427 W10 — the open failure a `create_executor*` call can carry back.
78// Ungated: `NodeError` lives in `nros_node::executor::types`, which is compiled
79// on every target, and [`InitError`] is on every target too (W9).
80use nros_node::NodeError;
81#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
82use nros_node::{Executor, ExecutorSizing};
83#[cfg(feature = "alloc")]
84use nros_rmw::SessionMode;
85
86/// Errors returned by the init API.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum InitError {
89 /// A launch-derived env var (`ROS_DOMAIN_ID`, etc.) failed to parse —
90 /// or, freestanding, the baked `NROS_DOMAIN_ID` is not a decimal integer.
91 EnvParseFailed,
92 /// A domain id — from the environment, the bake, or an
93 /// [`InitOptions::with_domain_id`] override — exceeds
94 /// [`crate::DOMAIN_ID_MAX`]. Rejected here rather than
95 /// handed to a backend that can only fail later (the #206 rule).
96 DomainIdOutOfRange,
97 /// phase-484 W8c — a RETIRED environment variable is set (`ZENOH_LOCATOR`,
98 /// `ZENOH_MODE`). Refused rather than ignored: an ignored locator dials
99 /// the default router, which reads as "no delivery".
100 RetiredEnvVar {
101 /// The retired name the environment still sets.
102 name: &'static str,
103 /// The canonical name to set instead.
104 replacement: &'static str,
105 },
106 /// phase-427 W10 — [`Context::create_executor`] /
107 /// [`Context::create_executor_in`] resolved the identity and the RMW
108 /// session open then failed.
109 ///
110 /// The backend's own [`NodeError`] is CARRIED, not flattened into a flag:
111 /// `Transport(InvalidConfig)` (no backend registered, or more than one and
112 /// no selector) and `Transport(ConnectionFailed)` (the router is not there)
113 /// are different problems with different fixes, and issue 0465 is what
114 /// collapsing them cost the last time.
115 ExecutorOpenFailed(NodeError),
116 /// phase-427 W10 — [`Context::create_executor_in`] was handed a backing
117 /// smaller than the executor's tables need.
118 ///
119 /// Refused rather than accepted: `Executor::open_in` is `unsafe` precisely
120 /// because a short backing is undefined behaviour, so the safe wrapper
121 /// checks the length and names both numbers. `needed` is
122 /// `ExecutorSizing::DEFAULT.u64_len()` for this build — it moves with
123 /// `NROS_EXECUTOR_MAX_CBS` / `_MAX_SC` / `_ARENA_SIZE` / `_MAX_NODES`, so a
124 /// caller sizing a `static` from a literal will hear about it here rather
125 /// than corrupting memory later.
126 BackingTooSmall {
127 /// `u64` words the executor's tables need.
128 needed: usize,
129 /// `u64` words the caller supplied.
130 given: usize,
131 },
132}
133
134impl core::fmt::Display for InitError {
135 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
136 match self {
137 InitError::EnvParseFailed => f.write_str("env var parse failed"),
138 InitError::DomainIdOutOfRange => f.write_str("domain id out of range"),
139 InitError::RetiredEnvVar { name, replacement } => write!(
140 f,
141 "{name} is retired and no longer read; set {replacement} instead (unset {name})"
142 ),
143 InitError::ExecutorOpenFailed(e) => {
144 write!(f, "RMW session open failed: {e:?}")
145 }
146 InitError::BackingTooSmall { needed, given } => write!(
147 f,
148 "executor backing too small: {given} u64 words supplied, {needed} needed"
149 ),
150 }
151 }
152}
153
154// `core::error::Error` — `std::error::Error` is a re-export of it since Rust
155// 1.81, so this is the same trait, and it needs no feature: phase-427 W9 took
156// this module out from behind `env`.
157impl core::error::Error for InitError {}
158
159/// Phase 212.L.5 — resolved init context.
160///
161/// Returned by every `init*` entry point and every `Context::*` constructor.
162/// Carries the fields the user needs to construct an [`ExecutorConfig`] and
163/// open a session.
164///
165/// Fields are owned (`String`) so the `Context` can outlive transient parents
166/// (env caches, parsed launch files, a harness-supplied locator). That is why
167/// the type's feature floor is `alloc`.
168///
169/// ADOPT-BOUNDED against `rclrs::Context`, which OWNS the rcl context. This is
170/// the resolved identity as a plain value, so:
171/// - There is no `ok()`: the value has no shutdown state; liveness is the
172/// executor's.
173/// - `domain_id` is a public field, not a `domain_id()` method.
174/// - Executors are opened FROM it: [`create_executor`](Self::create_executor)
175/// leaks an executor-lifetime backing (needs `alloc`), and
176/// [`create_executor_in`](Self::create_executor_in) takes caller storage.
177#[cfg(feature = "alloc")]
178#[derive(Debug, Clone)]
179pub struct Context {
180 /// ROS 2 domain ID (`ROS_DOMAIN_ID`, default 0; baked `NROS_DOMAIN_ID`
181 /// freestanding).
182 pub domain_id: u32,
183 /// Middleware locator (`NROS_LOCATOR`; baked
184 /// `NROS_LOCATOR` freestanding). Empty means ABSENT: `nros` is
185 /// RMW-agnostic, so the linked backend substitutes its own default
186 /// (issue 0330).
187 pub locator: alloc::string::String,
188 /// Session mode (`NROS_SESSION_MODE`, default `Client`).
189 pub mode: SessionMode,
190 /// RMW implementation hint (`RMW_IMPLEMENTATION` / `NROS_RMW`).
191 ///
192 /// Empty when neither var is set, and always empty freestanding (an image
193 /// links exactly one backend). The runtime uses this to pick a primary
194 /// backend when multiple are linked; see `crate::internals::open_session`.
195 pub rmw: alloc::string::String,
196 /// Source of this context — useful for diagnostics + tests.
197 pub source: ContextSource,
198 /// The `-r`/`--remap` rules `init_with_args` / `Context::new` parsed
199 /// out of `--ros-args`, in argv order. Installed as the FALLBACK remap
200 /// tier of every executor this context creates
201 /// (`Context::create_executor`, `Context::create_executor_in`).
202 ///
203 /// rcl keeps its global arguments on the context for the same reason: the
204 /// context is what the process was started with, and every node created
205 /// from it inherits them. Private, because nothing outside the parse
206 /// should write a rule here — and a rule that bypassed the parse would
207 /// bypass its refusals too.
208 #[cfg_attr(not(feature = "env"), allow(dead_code))]
209 ros_args: alloc::vec::Vec<ArgvRemap>,
210 /// phase-482 W5 — the `-p` / `--params-file` overrides, in argv order,
211 /// installed in every executor this context creates and applied when a
212 /// node declares the parameter (`Executor::install_argv_params`). Empty
213 /// unless `nros` has `param-services`; without it the parse refuses them.
214 #[cfg_attr(not(feature = "env"), allow(dead_code))]
215 ros_params: alloc::vec::Vec<ArgvParam>,
216}
217
218/// One parameter override, owned — from `-p [node:]name:=value` or a
219/// `--params-file` entry.
220#[cfg(feature = "alloc")]
221#[derive(Debug, Clone, PartialEq, Eq)]
222#[cfg_attr(not(feature = "env"), allow(dead_code))]
223struct ArgvParam {
224 node: Option<alloc::string::String>,
225 name: alloc::string::String,
226 value: alloc::string::String,
227}
228
229/// One `-r [node:]from:=to` rule, owned so a [`Context`] can outlive the
230/// argument vector it came from.
231#[cfg(feature = "alloc")]
232#[derive(Debug, Clone, PartialEq, Eq)]
233#[cfg_attr(not(feature = "env"), allow(dead_code))]
234struct ArgvRemap {
235 node: Option<alloc::string::String>,
236 from: alloc::string::String,
237 to: alloc::string::String,
238}
239
240/// Where the [`Context`] came from. Diagnostics only.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum ContextSource {
243 /// Built from env vars by [`init()`] / [`init_with_args`] /
244 /// [`Context::new`], or by [`Context::default_from_env`] /
245 /// [`Context::from_env`] on a hosted build.
246 Env,
247 /// phase-427 W9 — built from the constants baked into this crate at
248 /// compile time (`NROS_LOCATOR`, `NROS_DOMAIN_ID`) by [`Context::baked`],
249 /// which is what [`Context::default_from_env`] / [`Context::from_env`]
250 /// resolve to on a freestanding build. The entry macros
251 /// (`nros::main!`'s Zephyr arm, `nros::zephyr_component_main!`) produce
252 /// this one.
253 Baked,
254}
255
256/// phase-427 W9 — `rclrs::InitOptions`, with the one option it has.
257///
258/// Consumed by [`Context::from_env`] and [`Context::new`]; `None` for the
259/// domain keeps whatever the environment (hosted) or the bake (freestanding)
260/// resolved.
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
262pub struct InitOptions {
263 domain_id: Option<u32>,
264}
265
266impl InitOptions {
267 /// Default options: nothing overridden.
268 pub const fn new() -> Self {
269 Self { domain_id: None }
270 }
271
272 /// Override the domain id (`Some`) or keep the resolved one (`None`).
273 ///
274 /// `usize` because that is rclrs's signature. A value above `u32::MAX`
275 /// cannot be a domain id at all; it saturates here and the constructor
276 /// that consumes the options then rejects it as
277 /// [`InitError::DomainIdOutOfRange`], which is the loud place — a builder
278 /// method has no `Result` to carry the refusal in.
279 pub const fn with_domain_id(mut self, domain_id: Option<usize>) -> Self {
280 self.domain_id = match domain_id {
281 Some(d) if d > u32::MAX as usize => Some(u32::MAX),
282 Some(d) => Some(d as u32),
283 None => None,
284 };
285 self
286 }
287
288 /// The in-place twin of [`with_domain_id`](Self::with_domain_id) — rclrs
289 /// has both, and a ported `opts.set_domain_id(Some(7))` compiles as
290 /// written. Same saturation.
291 pub fn set_domain_id(&mut self, domain_id: Option<usize>) {
292 *self = self.with_domain_id(domain_id);
293 }
294
295 /// The domain override, if any.
296 ///
297 /// `Option<usize>`, which is rclrs's signature and the same reason
298 /// [`with_domain_id`](Self::with_domain_id) takes one. phase-467: this
299 /// returned `Option<u32>` — the STORED width — so the two setters spoke
300 /// rclrs and the getter spoke the field, and a ported
301 /// `let d: Option<usize> = opts.domain_id();` failed on a type nobody
302 /// chose. The value is still stored as a `u32` and `with_domain_id`'s
303 /// saturation still applies, so a round trip through `u32::MAX + 1`
304 /// reports `u32::MAX`; the refusal stays on the consuming constructor
305 /// ([`InitError::DomainIdOutOfRange`]), which is where a `Result` exists
306 /// to carry it.
307 pub const fn domain_id(&self) -> Option<usize> {
308 match self.domain_id {
309 Some(d) => Some(d as usize),
310 None => None,
311 }
312 }
313}
314
315#[cfg(feature = "alloc")]
316/// The range check every constructor applies to a domain id, whatever its
317/// source (env, bake, override): the same bound `try_resolve_hosted` enforces
318/// ("INCLUDING a baked one"), so a freestanding image cannot boot on a domain
319/// a hosted process would have refused.
320fn check_domain_id(domain_id: u32) -> Result<u32, InitError> {
321 if domain_id > DOMAIN_ID_MAX {
322 Err(InitError::DomainIdOutOfRange)
323 } else {
324 Ok(domain_id)
325 }
326}
327
328#[cfg(feature = "alloc")]
329impl Context {
330 /// `rclrs::Context::domain_id()` — the resolved ROS domain.
331 ///
332 /// `usize`, because that is rclrs's return type (0.7.0,
333 /// `rclrs/src/context.rs`) and this name exists so a ported
334 /// `ctx.domain_id()` compiles. The field stays `pub` and stays `u32`:
335 /// `Context` is a plain value with nothing to hide, `u32` is the width
336 /// every producer and consumer of a domain in this tree speaks
337 /// (`BoardConfig::domain_id`, `ExecutorConfig::domain_id`,
338 /// `NodeHandle::domain_id`, the `NROS_DOMAIN_ID` bake), and fields and
339 /// methods are in different namespaces in Rust, so the two coexist. The
340 /// widening is lossless on every target here — `usize` is at least 32
341 /// bits on all of them, down to `thumbv7m-none-eabi`.
342 ///
343 /// phase-467 row `rust:Context::domain_id`. The row proposed `-> u32`
344 /// once and that would have been a second, quieter port break; it then
345 /// argued `usize` from consistency with [`InitOptions`], which was only
346 /// two thirds true — the setters took `usize`, the getter returned
347 /// `Option<u32>`. Both getters speak `usize` now, so the argument is the
348 /// one this crate can actually make: it is upstream's type.
349 #[must_use]
350 pub const fn domain_id(&self) -> usize {
351 self.domain_id as usize
352 }
353
354 /// Materialise an [`ExecutorConfig`] for a node with the given name.
355 ///
356 /// The returned config borrows from `self`, so callers usually do:
357 ///
358 /// ```ignore
359 /// let ctx = nros::Context::default_from_env()?;
360 /// let cfg = ctx.config("talker");
361 /// let mut executor = nros::Executor::open(&cfg)?;
362 /// ```
363 ///
364 /// phase-427 W10 — this is the shape where the executor IS the node it was
365 /// configured with, and it stays: an entry that opens exactly one node
366 /// wants exactly this. What it cannot express is the second node, which is
367 /// why `Context::create_executor()` + `Executor::create_node(name)` now sit
368 /// beside it — the session takes no name, and each node takes its own.
369 // phase-427 W10 — the one legitimate remaining use of the deprecated
370 // builder spelling: this method IS the executor-is-the-node shape, so it
371 // asks for it deliberately rather than by not having heard.
372 ///
373 /// # Refused when the context carries `--ros-args` remaps
374 ///
375 /// An [`ExecutorConfig`] has nowhere to put them, so the executor
376 /// `Executor::open` builds from it would never see them — the silent drop
377 /// `init_with_args` exists to prevent. Such a context PANICS here,
378 /// naming `Context::create_executor`, which installs them.
379 #[allow(deprecated)] // the deliberate use named above (phase-427 W10)
380 pub fn config<'a>(&'a self, node_name: &'a str) -> ExecutorConfig<'a> {
381 #[cfg(feature = "env")]
382 if !self.ros_args.is_empty() || !self.ros_params.is_empty() {
383 refuse_ros_args(&RosArgsRefusal::UnreachableExecutor);
384 }
385 ExecutorConfig::new(self.locator.as_str())
386 .node_name(node_name)
387 .domain_id(self.domain_id)
388 .mode(self.mode)
389 }
390
391 /// `rclrs::Context::default_from_env()` — the environment, default
392 /// options.
393 ///
394 /// Hosted (feature `env`) this reads the process environment and is
395 /// exactly [`init()`]; freestanding it is [`Context::baked`], the build
396 /// environment this image was compiled for. RFC-0089 "Context and `init`,
397 /// settled": the semantics survive with the source moved from run time to
398 /// build time, which is the bounded half of this adoption.
399 pub fn default_from_env() -> Result<Context, InitError> {
400 #[cfg(feature = "env")]
401 {
402 init()
403 }
404 #[cfg(not(feature = "env"))]
405 {
406 Self::baked()
407 }
408 }
409
410 /// `rclrs::Context::from_env(options)` — [`default_from_env`](Self::default_from_env)
411 /// with the [`InitOptions`] applied on top: a
412 /// `Some` domain replaces the resolved one, `None` keeps it.
413 ///
414 /// Bounded: [`InitOptions`] carries rclrs's one option, `domain_id`, and no
415 /// other, so an option rclrs adds later is a compile error here. A domain above
416 /// `DOMAIN_ID_MAX` is [`InitError::DomainIdOutOfRange`]. Freestanding, the base
417 /// the options override is the BAKED context ([`Context::baked`]), not a
418 /// process environment.
419 pub fn from_env(options: InitOptions) -> Result<Context, InitError> {
420 let mut ctx = Self::default_from_env()?;
421 if let Some(domain_id) = options.domain_id {
422 ctx.domain_id = check_domain_id(domain_id)?;
423 }
424 Ok(ctx)
425 }
426
427 /// `rclrs::Context::new(args, options)` — the argv constructor. HOSTED
428 /// ONLY: a freestanding image has no argv, so the name is absent there and
429 /// the compiler says so.
430 ///
431 /// **What it does with the arguments:** the same as [`init_with_args`] —
432 /// `-r`/`--remap` rules inside `--ros-args` are parsed onto the returned
433 /// context and every other ROS argument is REFUSED LOUDLY at the call
434 /// ([`REFUSE_INIT_ARGS`]). Arguments outside a `--ros-args` scope are the
435 /// program's own and are left alone.
436 ///
437 /// `S: AsRef<str>` is a superset of rclrs's `Item = String`, so
438 /// `Context::new(std::env::args(), InitOptions::new())` compiles as
439 /// written.
440 #[cfg(feature = "env")]
441 pub fn new<I, S>(args: I, options: InitOptions) -> Result<Context, InitError>
442 where
443 I: IntoIterator<Item = S>,
444 S: AsRef<str>,
445 {
446 let (ros_args, ros_params) = parse_ros_args_or_refuse(args);
447 let mut context = Self::from_env(options)?;
448 context.ros_args = ros_args;
449 context.ros_params = ros_params;
450 Ok(context)
451 }
452
453 /// phase-427 W9 — the freestanding constructor: the constants baked into
454 /// this crate at compile time.
455 ///
456 /// * `NROS_LOCATOR` — the middleware locator. Unset or empty ⇒ empty ⇒
457 /// the linked backend's own default (issue 0330; for zenoh-pico that is
458 /// multicast scouting, which native_sim NSOS cannot satisfy — hence the
459 /// bake).
460 /// * `NROS_DOMAIN_ID` — the domain. Unset ⇒ 0; non-numeric ⇒
461 /// [`InitError::EnvParseFailed`]; above `DOMAIN_ID_MAX` ⇒
462 /// [`InitError::DomainIdOutOfRange`].
463 ///
464 /// Both names reach this crate's build the same way they reach the board
465 /// crates: on Zephyr, `build.rs` re-exports `CONFIG_NROS_ZENOH_LOCATOR` /
466 /// `CONFIG_NROS_DOMAIN_ID` from `$DOTCONFIG` (`nros_zephyr_build::
467 /// bake_nros_config`, the issue-0460 channel); on the other RTOS lanes the
468 /// fixture build exports them into the process environment. This is the
469 /// ONE `option_env!` site for the pair — the entry macros used to carry
470 /// their own copies (and `nros::main!`'s Zephyr arm had lost the domain
471 /// half, issue 0161's class), which is what "one source of the baked
472 /// shape" replaces.
473 ///
474 /// Mode is `Client` and `rmw` is empty: an image links exactly one
475 /// backend and nothing bakes a session mode today.
476 ///
477 /// Hosted builds have this too — it answers "what was baked", which a
478 /// hosted process rarely wants; [`Context::default_from_env`] is the
479 /// constructor that picks the right source for the build.
480 pub fn baked() -> Result<Context, InitError> {
481 Self::from_baked(option_env!("NROS_LOCATOR"), option_env!("NROS_DOMAIN_ID"))
482 }
483
484 /// The parse behind [`Context::baked`], with the two constants injected
485 /// so a unit test can exercise every arm without a build environment.
486 /// Public because the macros in `nros-macros` expand in another crate;
487 /// not API.
488 #[doc(hidden)]
489 pub fn from_baked(
490 baked_locator: Option<&str>,
491 baked_domain_id: Option<&str>,
492 ) -> Result<Context, InitError> {
493 let domain_id = match baked_domain_id {
494 Some(d) => check_domain_id(d.trim().parse().map_err(|_| InitError::EnvParseFailed)?)?,
495 None => 0,
496 };
497 Ok(Context {
498 domain_id,
499 locator: alloc::string::String::from(baked_locator.unwrap_or("")),
500 mode: SessionMode::Client,
501 rmw: alloc::string::String::new(),
502 source: ContextSource::Baked,
503 ros_args: alloc::vec::Vec::new(),
504 ros_params: alloc::vec::Vec::new(),
505 })
506 }
507
508 /// Replace the locator with one handed in at run time, when there is one.
509 ///
510 /// #166 / phase-286 W1 — native_sim test parallelism: the harness launches
511 /// an image with `-testargs --nros-locator=<loc>` and a per-test router on
512 /// that ephemeral port, and `nros_runtime_locator_override()`
513 /// (`nros-platform-zephyr`, argv-backed) hands it back here. `None` or an
514 /// empty string keeps the baked value, so on real hardware — where the hook
515 /// returns NULL — the bake stands. The entry macros call this after
516 /// [`Context::default_from_env`]; a hand-written entry may too.
517 pub fn with_locator_override(mut self, locator: Option<&str>) -> Self {
518 if let Some(loc) = locator {
519 if !loc.is_empty() {
520 self.locator = alloc::string::String::from(loc);
521 }
522 }
523 self
524 }
525
526 /// phase-427 W10 — the config the executor's SESSION opens with: this
527 /// context's locator, domain and mode, and no node name.
528 ///
529 /// The counterpart of [`config`](Self::config), which is for the older
530 /// shape where the executor IS the node it was configured with. Here the
531 /// session carries no ROS identity of its own — `Executor::create_node`
532 /// supplies that, once per node, and one executor holds several
533 /// (RFC-0047). What lands in `ExecutorConfig::node_name` is therefore
534 /// whatever [`ExecutorConfig::new`] defaults to; it names the transport
535 /// session for diagnostics, not a participant on the graph.
536 #[cfg(feature = "rmw-cffi")]
537 fn session_config(&self) -> ExecutorConfig<'_> {
538 ExecutorConfig::new(self.locator.as_str())
539 .domain_id(self.domain_id)
540 .mode(self.mode)
541 }
542}
543
544/// phase-427 W10 — `rclrs::Context::create_executor`, and the ours-only
545/// no-leak twin beside it.
546///
547/// Both are gated on `rmw-cffi`, because an executor without the vtable
548/// runtime has no session to open; `create_executor` is additionally gated on
549/// `alloc`, because it is the one that leaks a default-sized backing.
550#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
551impl Context {
552 /// `rclrs::Context::create_executor()` — open the session this context
553 /// resolved and hand back the [`Executor`] that owns it.
554 ///
555 /// This is the entry a ported rclrs `main` lands on: upstream writes
556 /// `context.create_basic_executor()` (or `create_executor(runtime)`) and
557 /// gets an `Executor` back; ours returns a `Result`, so the port is a `?`.
558 /// The runtime argument has no counterpart here and is not accepted — one
559 /// executor per RTOS task is the model (RFC-0002), and there is no runtime
560 /// to choose between.
561 ///
562 /// The node name is NOT taken here. Name nodes with
563 /// [`Executor::create_node`], which is where several named nodes on one
564 /// session already live (RFC-0047).
565 ///
566 /// # Available on `alloc` only
567 ///
568 /// It leaks an executor-lifetime backing at
569 /// [`ExecutorSizing::DEFAULT`] — the reason it is `Executor<'static>` and
570 /// the reason it needs an allocator. A target without one calls
571 /// [`create_executor_in`](Self::create_executor_in) with its own
572 /// `static`, which is the same open with the storage supplied rather than
573 /// leaked. (Today [`Context`] itself has an `alloc` floor — its `locator`
574 /// and `rmw` are owned `String`s — so the pair moves together; if that
575 /// floor ever drops, this is the half that stays behind.)
576 ///
577 /// ```ignore
578 /// let context = nros::Context::default_from_env()?;
579 /// let mut executor = context.create_executor()?;
580 /// let mut node = executor.create_node("talker")?;
581 /// ```
582 ///
583 /// The `-r` rules `init_with_args` / `Context::new` parsed are
584 /// installed in the new executor as its fallback remap tier.
585 pub fn create_executor(&self) -> Result<Executor<'static>, InitError> {
586 let mut executor =
587 Executor::open(&self.session_config()).map_err(InitError::ExecutorOpenFailed)?;
588 self.install_ros_args(&mut executor);
589 Ok(executor)
590 }
591
592 /// Put this context's `--ros-args` remaps into `executor`'s fallback tier.
593 /// A rule that does not fit is REFUSED rather than dropped.
594 fn install_ros_args(&self, executor: &mut Executor<'_>) {
595 #[cfg(feature = "env")]
596 {
597 let rules = self.ros_args.iter().map(|r| nros_node::ros_args::RemapArg {
598 node: r.node.as_deref(),
599 from: r.from.as_str(),
600 to: r.to.as_str(),
601 });
602 if let Err(rule) = nros_node::ros_args::install_argv_remaps(executor, rules) {
603 refuse_ros_args(&RosArgsRefusal::DoesNotFit {
604 node: rule.node,
605 from: rule.from,
606 to: rule.to,
607 });
608 }
609 #[cfg(feature = "param-store")]
610 if !self.ros_params.is_empty() {
611 let params = self
612 .ros_params
613 .iter()
614 .map(|p| nros_node::ros_args::ParamArg {
615 node: p.node.as_deref(),
616 name: p.name.as_str(),
617 value: p.value.as_str(),
618 });
619 if executor.install_argv_params(params).is_err() {
620 refuse_ros_args(&RosArgsRefusal::NoParamStore);
621 }
622 }
623 }
624 #[cfg(not(feature = "env"))]
625 let _ = executor;
626 }
627
628 /// OURS — [`create_executor`](Self::create_executor) with the storage
629 /// supplied by the caller instead of leaked.
630 ///
631 /// The freestanding form. An RTOS or bare-metal entry owns a
632 /// `static mut [MaybeUninit<u64>; N]` (or the array the `nros::main!`
633 /// macro sizes for it) and hands it in; nothing is allocated, and the
634 /// returned executor borrows the backing for `'b` rather than living
635 /// forever. rclrs has no counterpart — it allocates.
636 ///
637 /// `backing` must hold at least `ExecutorSizing::DEFAULT.u64_len()` words.
638 /// A shorter slice is [`InitError::BackingTooSmall`] naming both numbers,
639 /// never undefined behaviour: this is the safe wrapper around the `unsafe`
640 /// `Executor::open_in`, and the length check is what makes it safe. Size
641 /// the array with `ExecutorSizing::DEFAULT.u64_len()` rather than a
642 /// literal, since the default moves with `NROS_EXECUTOR_MAX_CBS` and its
643 /// siblings.
644 ///
645 /// ```ignore
646 /// static mut BACKING: [MaybeUninit<u64>; N] = [MaybeUninit::uninit(); N];
647 /// let context = nros::Context::default_from_env()?;
648 /// // SAFETY: single-threaded entry, taken once.
649 /// let mut executor = context.create_executor_in(unsafe { &mut *&raw mut BACKING })?;
650 /// let mut node = executor.create_node("talker")?;
651 /// ```
652 pub fn create_executor_in<'b>(
653 &self,
654 backing: &'b mut [MaybeUninit<u64>],
655 ) -> Result<Executor<'b>, InitError> {
656 let sizing = ExecutorSizing::DEFAULT;
657 let needed = sizing.u64_len();
658 let given = backing.len();
659 if given < needed {
660 return Err(InitError::BackingTooSmall { needed, given });
661 }
662 // SAFETY: `backing` is at least `sizing.u64_len()` words (checked
663 // directly above), is `u64`-aligned by its element type, is uniquely
664 // borrowed for `'b`, and the returned `Executor<'b>` is the only thing
665 // that can reach it for that lifetime.
666 let mut executor = unsafe { Executor::open_in(&self.session_config(), backing, sizing) }
667 .map_err(InitError::ExecutorOpenFailed)?;
668 self.install_ros_args(&mut executor);
669 Ok(executor)
670 }
671}
672
673#[cfg(feature = "env")]
674fn read_env_context(source: ContextSource) -> Result<Context, InitError> {
675 // issue 0687 — through the ONE resolver, not a third parse of the same four
676 // variables. This function had its own copy, and the copies had drifted:
677 // it did not see the legacy `ZENOH_LOCATOR`/`ZENOH_MODE` spellings (now
678 // retired and refused by the shared reader), and it range-checked nothing, so
679 // `ROS_DOMAIN_ID=300` reached a backend that could only fail later.
680 //
681 // Issue 0330 — no backend default for the locator: `nros` is RMW-agnostic,
682 // so unset env leaves it EMPTY (= absent) and the linked backend
683 // substitutes its own (zenoh: `nros_rmw_zenoh::DEFAULT_LOCATOR`; xrce: its
684 // agent default; cyclonedds ignores the locator entirely). That is what
685 // `resolve_hosted` does with an empty `BootConfig` too.
686 let resolved =
687 crate::env::try_resolve_hosted(nros_node::BootConfig::default()).map_err(|e| match e {
688 nros_node::BootConfigError::DomainIdRange => InitError::DomainIdOutOfRange,
689 nros_node::BootConfigError::DomainIdParse => InitError::EnvParseFailed,
690 nros_node::BootConfigError::RetiredEnvVar { name, replacement } => {
691 InitError::RetiredEnvVar { name, replacement }
692 }
693 })?;
694 let locator = alloc::string::String::from(resolved.locator);
695 let domain_id = resolved.domain_id;
696 let mode = resolved.mode;
697 // issue 0687 — the `$NROS_RMW` half comes from the shared selector; the
698 // `RMW_IMPLEMENTATION` fallback stays HERE and only here. `Context.rmw` is
699 // a ROS-vocabulary HINT (`rmw_cyclonedds_cpp`), not the cffi registry
700 // selector (`cyclonedds`) — folding the two together would hand a ROS name
701 // to `resolve_backend`, which answers `Unknown` and fails the open.
702 let rmw = crate::rmw_selector()
703 .map(|s| alloc::string::String::from(s.as_str()))
704 .or_else(|| std::env::var("RMW_IMPLEMENTATION").ok())
705 .unwrap_or_default();
706 Ok(Context {
707 domain_id,
708 locator,
709 mode,
710 rmw,
711 source,
712 ros_args: alloc::vec::Vec::new(),
713 ros_params: alloc::vec::Vec::new(),
714 })
715}
716
717/// Pattern 3 — raw init, launch file ignored. The C++-symmetric anchor:
718/// `rclcpp::init()` in the Rust spelling, and EQUAL to
719/// [`Context::default_from_env`] on a hosted build (RFC-0089 "Context and
720/// `init`, settled" — each language follows its own upstream; rclrs spells
721/// this one on the type, rclcpp as a free function, and both are here).
722///
723/// Reads env vars (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
724/// `NROS_RMW` / `RMW_IMPLEMENTATION`) and returns a [`Context`]. The
725/// caller owns the spin loop — typically `Executor::open(&ctx.config(name))`
726/// followed by `spin` or a hand-rolled `spin_once` loop.
727#[cfg(feature = "env")]
728pub fn init() -> Result<Context, InitError> {
729 read_env_context(ContextSource::Env)
730}
731
732/// The refusal [`init_with_args`] and [`Context::new`] emit for a ROS
733/// argument nano-ros does not honour. Panics carry this text followed by the
734/// refused argument. `NROS_RCLCPP_REFUSE_INIT_ARGV` is the C++ twin
735/// (`packages/api/nros-cpp/include/rclcpp/logging.hpp`), and since phase-467 Row 11's
736/// C++ half it states the same honoured and refused sets, from the same parser.
737#[cfg(feature = "env")]
738pub const REFUSE_INIT_ARGS: &str = "nros::init_with_args / nros::Context::new was given a --ros-args \
739argument nano-ros cannot honour (RFC-0089, phase-417 W3.b). Proceeding would DISCARD it -- the \
740'compiles and differs' the rule forbids. HONOURED inside --ros-args ... --: -r / --remap \
741[node:]from:=to, applied as the FALLBACK beneath any remap the launch file projected for the same \
742name (RFC-0046; rcl's local-before-global), through Context::create_executor / \
743create_executor_in; and, in an image with a parameter store, -p / --param [node:]name:=value and \
744--params-file <path>, applied when a node declares the parameter (RFC-0015 section 9). REFUSED: \
745node-identity remaps (__node, __name, __ns), -e / --enclave, the log flags, any token that is not a \
746ROS flag, and -p / --params-file in an image without a parameter store. A nros sync image does not read argv at all: its launch \
747remaps and parameters are projected into the GENERATED ENTRY at BUILD time.";
748
749/// Does `args` open a `--ros-args` scope at all?
750///
751/// No longer the refusal's predicate — since the `-r` parse landed, a vector
752/// with `--ros-args` is parsed, and only what it cannot honour is refused
753/// (`nros_node::ros_args::parse_ros_args`). Kept because "will this argv be
754/// read?" is still a question a caller can ask before handing it over.
755///
756/// Exact match only. `--ros-args-extra` is not the flag, and a prefix match
757/// would claim an argument nano-ros never reads — pinned by a unit test.
758#[cfg(feature = "env")]
759pub fn args_have_ros_args<I, S>(args: I) -> bool
760where
761 I: IntoIterator<Item = S>,
762 S: AsRef<str>,
763{
764 args.into_iter().any(|a| a.as_ref() == "--ros-args")
765}
766
767/// Why a `--ros-args` vector, or a context carrying one, was refused.
768#[cfg(feature = "env")]
769enum RosArgsRefusal<'a> {
770 /// The parse itself refused an argument.
771 Parse(nros_node::ros_args::RosArgsError<'a>),
772 /// A parsed rule did not fit the executor's remap table.
773 #[cfg(feature = "rmw-cffi")]
774 DoesNotFit {
775 node: Option<&'a str>,
776 from: &'a str,
777 to: &'a str,
778 },
779 /// [`Context::config`] was asked for a config that cannot carry the rules.
780 UnreachableExecutor,
781 /// phase-482 W5 — `--params-file <path>` could not be read or parsed.
782 #[cfg(feature = "param-store")]
783 ParamsFile {
784 path: alloc::string::String,
785 why: alloc::string::String,
786 },
787 /// phase-482 W5 — the parameter store could not be allocated (issue 1706).
788 #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
789 NoParamStore,
790}
791
792#[cfg(feature = "env")]
793impl core::fmt::Display for RosArgsRefusal<'_> {
794 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
795 match self {
796 Self::Parse(e) => e.fmt(f),
797 #[cfg(feature = "rmw-cffi")]
798 Self::DoesNotFit { node, from, to } => {
799 let node = node.map(|n| alloc::format!("{n}:")).unwrap_or_default();
800 write!(
801 f,
802 "`-r {node}{from}:={to}` does not fit the executor's remap table (MAX_REMAPS rules, \
803 shared with launch remaps; names up to {} bytes)",
804 nros_node::names::MAX_RESOLVED_NAME_LEN
805 )
806 }
807 Self::UnreachableExecutor => f.write_str(
808 "Context::config() cannot carry this context's --ros-args remaps or parameter \
809 overrides into the executor Executor::open builds from it; use \
810 Context::create_executor() or create_executor_in(), which install them",
811 ),
812 #[cfg(feature = "param-store")]
813 Self::ParamsFile { path, why } => write!(f, "`--params-file {path}`: {why}"),
814 #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
815 Self::NoParamStore => f.write_str(
816 "the parameter store could not be allocated, so the parameter overrides have \
817 nowhere to go",
818 ),
819 }
820 }
821}
822
823/// The one refusal site: logged through `nros_log` (never `std::println!`,
824/// issue 0589), then a panic carrying [`REFUSE_INIT_ARGS`] and the reason —
825/// the shape `rclcpp::init(argc, argv)` has in `nros.hpp`, and the shape
826/// rclcpp's own `UnknownROSArgsError` has.
827#[cfg(feature = "env")]
828fn refuse_ros_args(why: &RosArgsRefusal<'_>) -> ! {
829 nros_log::log_error!(
830 nros_log::get_logger("nros"),
831 "{}\n refused: {}",
832 REFUSE_INIT_ARGS,
833 why
834 );
835 panic!("{REFUSE_INIT_ARGS}\n refused: {why}");
836}
837
838/// Parse `args` (rcl's grammar — `nros_node::ros_args`) into owned rules, or
839/// refuse. Shared by [`init_with_args`] and [`Context::new`], so the two stay
840/// one behaviour.
841#[cfg(feature = "env")]
842fn parse_ros_args_or_refuse<I, S>(
843 args: I,
844) -> (alloc::vec::Vec<ArgvRemap>, alloc::vec::Vec<ArgvParam>)
845where
846 I: IntoIterator<Item = S>,
847 S: AsRef<str>,
848{
849 use alloc::string::String;
850 let owned: alloc::vec::Vec<String> =
851 args.into_iter().map(|a| String::from(a.as_ref())).collect();
852 let mut rules = alloc::vec::Vec::new();
853 #[cfg_attr(not(feature = "param-store"), allow(unused_mut))]
854 let mut params: alloc::vec::Vec<ArgvParam> = alloc::vec::Vec::new();
855 let remap = |r: nros_node::ros_args::RemapArg<'_>| ArgvRemap {
856 node: r.node.map(String::from),
857 from: String::from(r.from),
858 to: String::from(r.to),
859 };
860 // phase-482 W5 — with a parameter store, `-p` and `--params-file` are
861 // honoured, each file's entries landing where its flag stood (so a later
862 // `-p` overrides it and a later file overrides an earlier `-p`). Without
863 // one, the remap-only parse refuses them by name.
864 #[cfg(feature = "param-store")]
865 let parsed = {
866 let mut events = alloc::vec::Vec::new();
867 let r = nros_node::ros_args::parse_ros_args_events(owned.iter().map(|a| a.as_str()), |a| {
868 events.push(a)
869 });
870 if r.is_ok() {
871 for a in events {
872 match a {
873 nros_node::ros_args::RosArg::Remap(r) => rules.push(remap(r)),
874 nros_node::ros_args::RosArg::Param(p) => params.push(ArgvParam {
875 node: p.node.map(String::from),
876 name: String::from(p.name),
877 value: String::from(p.value),
878 }),
879 nros_node::ros_args::RosArg::ParamsFile(path) => {
880 let text = match std::fs::read_to_string(path) {
881 Ok(t) => t,
882 Err(e) => refuse_ros_args(&RosArgsRefusal::ParamsFile {
883 path: String::from(path),
884 why: alloc::format!("cannot be read: {e}"),
885 }),
886 };
887 if let Err(e) = nros_node::ros_args::parse_params_yaml(&text, |p| {
888 params.push(ArgvParam {
889 node: p.node.map(String::from),
890 name: String::from(p.name),
891 value: String::from(p.value),
892 })
893 }) {
894 refuse_ros_args(&RosArgsRefusal::ParamsFile {
895 path: String::from(path),
896 why: alloc::format!("{e}"),
897 });
898 }
899 }
900 }
901 }
902 }
903 r
904 };
905 #[cfg(not(feature = "param-store"))]
906 let parsed = nros_node::ros_args::parse_ros_args(owned.iter().map(|a| a.as_str()), |r| {
907 rules.push(remap(r))
908 });
909 if let Err(e) = parsed {
910 refuse_ros_args(&RosArgsRefusal::Parse(e));
911 }
912 (rules, params)
913}
914
915/// Pattern 3 — like [`init()`] but takes the process arguments, the way
916/// `rclcpp::init(argc, argv)` / rclrs's `Context::new(args, ..)` do. The
917/// free-function twin of [`Context::new`] with default options.
918///
919/// # What it does with the arguments
920///
921/// It parses `--ros-args` with rcl's grammar (Humble `rcl/arguments.h`): ROS
922/// arguments live between `--ros-args` and `--` (or the end), several scopes
923/// may appear, and everything outside them is the program's own and is left
924/// alone.
925///
926/// * **`-r` / `--remap [node:]from:=to` — honoured.** The rules ride on the
927/// returned [`Context`] and are installed in every executor it creates
928/// (`Context::create_executor` / `Context::create_executor_in`) as the
929/// FALLBACK remap tier: a launch-projected rule for the same name wins
930/// (RFC-0046), exactly as rcl checks a node's local arguments before the
931/// process's global ones. They reach entities on every road — the handle
932/// `Executor::create_node` returns, `nros::node!` components, and the C and
933/// C++ APIs.
934/// * **`-p` / `--param [node:]name:=value` and `--params-file <path>` —
935/// honoured when `nros` has `param-services`** (phase-482 W5, RFC-0015 §9's
936/// POSIX row). They ride on the [`Context`] in argv order and are applied
937/// when a node DECLARES the parameter: the last matching override wins, its
938/// text typed by the declared default, and a `node:` override reaches only
939/// the node of that name. The envelope, stated here because it is the API:
940/// a parameter file is read with `std::fs` and holds scalars only (arrays,
941/// anchors and block scalars are refused, with their line), a node key
942/// matches by NAME not namespace, and an override that does not parse as
943/// the declared type is logged and the default kept.
944/// * **Everything else inside a scope — refused, loudly**: node-identity
945/// remaps, enclaves, log flags, unknown tokens, and `-p` / `--params-file`
946/// in an image with no parameter store. Logged through `nros_log`, then a panic
947/// carrying [`REFUSE_INIT_ARGS`] and the argument. rclcpp refuses unknown
948/// ROS arguments too (`UnknownROSArgsError`).
949///
950/// # Where the rules do NOT reach
951///
952/// [`Context::config`] refuses a context that carries rules, because an
953/// `ExecutorConfig` cannot carry them. Entities created through the
954/// executor's own `add_*` registration methods or a `node_mut` context are
955/// not remapped either: those are also where the C ABI and the component
956/// sink hand their ALREADY-RESOLVED names, so remapping there would apply a
957/// rule twice.
958#[cfg(feature = "env")]
959pub fn init_with_args<I, S>(args: I) -> Result<Context, InitError>
960where
961 I: IntoIterator<Item = S>,
962 S: AsRef<str>,
963{
964 let (ros_args, ros_params) = parse_ros_args_or_refuse(args);
965 let mut context = init()?;
966 context.ros_args = ros_args;
967 context.ros_params = ros_params;
968 Ok(context)
969}
970
971#[cfg(all(test, feature = "env"))]
972mod ros_args_refusal_tests {
973 use super::*;
974
975 #[test]
976 fn predicate_matches_the_flag_exactly() {
977 let _env = crate::env::test_env_lock();
978 assert!(args_have_ros_args(["node", "--ros-args", "-p", "x:=1"]));
979 assert!(args_have_ros_args(["--ros-args"]));
980 // A prefix match would refuse an argument nano-ros never drops.
981 assert!(!args_have_ros_args(["--ros-args-extra"]));
982 assert!(!args_have_ros_args(["--ros-arg"]));
983 assert!(!args_have_ros_args(["node", "--verbose"]));
984 assert!(!args_have_ros_args(core::iter::empty::<&str>()));
985 }
986
987 fn remap(node: Option<&str>, from: &str, to: &str) -> ArgvRemap {
988 ArgvRemap {
989 node: node.map(alloc::string::String::from),
990 from: alloc::string::String::from(from),
991 to: alloc::string::String::from(to),
992 }
993 }
994
995 #[test]
996 fn remap_rules_ride_on_the_context_in_argv_order() {
997 let _env = crate::env::test_env_lock();
998 let ctx = init_with_args([
999 "/usr/bin/talker",
1000 "--ros-args",
1001 "-r",
1002 "chatter:=/other",
1003 "--remap",
1004 "talker:~/out:=/wire",
1005 "--",
1006 "positional",
1007 ])
1008 .expect("init");
1009 assert_eq!(
1010 ctx.ros_args,
1011 [
1012 remap(None, "chatter", "/other"),
1013 remap(Some("talker"), "~/out", "/wire")
1014 ]
1015 );
1016 }
1017
1018 #[test]
1019 fn an_empty_scope_is_not_a_refusal() {
1020 let _env = crate::env::test_env_lock();
1021 assert!(
1022 init_with_args(["--ros-args"])
1023 .expect("init")
1024 .ros_args
1025 .is_empty()
1026 );
1027 }
1028
1029 /// Without a parameter store there is nowhere to put an override, so the
1030 /// parse refuses it by name rather than dropping it.
1031 #[cfg(not(feature = "param-store"))]
1032 #[test]
1033 #[should_panic(expected = "refused: `-p`")]
1034 fn parameter_overrides_are_still_refused_by_name() {
1035 let _env = crate::env::test_env_lock();
1036 let _ = init_with_args(["/usr/bin/talker", "--ros-args", "-r", "a:=b", "-p", "x:=1"]);
1037 }
1038
1039 #[cfg(feature = "param-store")]
1040 fn param(node: Option<&str>, name: &str, value: &str) -> ArgvParam {
1041 ArgvParam {
1042 node: node.map(alloc::string::String::from),
1043 name: alloc::string::String::from(name),
1044 value: alloc::string::String::from(value),
1045 }
1046 }
1047
1048 /// phase-482 W5 — `-p` and a `--params-file` ride on the context in argv
1049 /// order, the file's entries where its flag stood, beside the remaps.
1050 #[cfg(feature = "param-store")]
1051 #[test]
1052 fn parameter_overrides_ride_on_the_context_in_argv_order() {
1053 let _env = crate::env::test_env_lock();
1054 let path =
1055 std::env::temp_dir().join(alloc::format!("nros-w5-params-{}.yaml", std::process::id()));
1056 std::fs::write(
1057 &path,
1058 "/**:\n ros__parameters:\n rate: 30\n/talker:\n ros__parameters:\n gains:\n kp: 1.5\n",
1059 )
1060 .unwrap();
1061 let path_s = path.to_str().unwrap();
1062 let ctx = init_with_args([
1063 "/usr/bin/talker",
1064 "--ros-args",
1065 "-p",
1066 "rate:=10",
1067 "--params-file",
1068 path_s,
1069 "-r",
1070 "chatter:=/other",
1071 "-p",
1072 "talker:label:=hi",
1073 ])
1074 .expect("init");
1075 let _ = std::fs::remove_file(&path);
1076 assert_eq!(
1077 ctx.ros_params,
1078 [
1079 param(None, "rate", "10"),
1080 param(None, "rate", "30"),
1081 param(Some("talker"), "gains.kp", "1.5"),
1082 param(Some("talker"), "label", "hi"),
1083 ]
1084 );
1085 assert_eq!(ctx.ros_args, [remap(None, "chatter", "/other")]);
1086 }
1087
1088 /// An unreadable parameter file is refused, naming the path — never a
1089 /// silently empty set of overrides.
1090 #[cfg(feature = "param-store")]
1091 #[test]
1092 #[should_panic(expected = "--params-file /nonexistent/nros-w5.yaml")]
1093 fn an_unreadable_params_file_is_refused_by_path() {
1094 let _env = crate::env::test_env_lock();
1095 let _ = init_with_args(["--ros-args", "--params-file", "/nonexistent/nros-w5.yaml"]);
1096 }
1097
1098 #[test]
1099 #[should_panic(expected = "remaps a node's identity")]
1100 fn identity_remaps_are_refused() {
1101 let _env = crate::env::test_env_lock();
1102 let _ = init_with_args(["--ros-args", "-r", "__ns:=/robot"]);
1103 }
1104
1105 #[test]
1106 #[should_panic(expected = "Context::config() cannot carry")]
1107 fn config_refuses_a_context_that_carries_remaps() {
1108 let _env = crate::env::test_env_lock();
1109 let ctx = init_with_args(["--ros-args", "-r", "chatter:=/other"]).expect("init");
1110 let _ = ctx.config("talker");
1111 }
1112
1113 #[test]
1114 fn config_is_unaffected_without_remaps() {
1115 let _env = crate::env::test_env_lock();
1116 let ctx = init_with_args(["/usr/bin/talker", "--verbose"]).expect("init");
1117 assert_eq!(ctx.config("talker").node_name, "talker");
1118 }
1119
1120 #[test]
1121 fn plain_args_pass_through_to_init() {
1122 let _env = crate::env::test_env_lock();
1123 // Same answer as `init()` — the arguments are not consulted, and no
1124 // refusal fires. Both read the same environment, so compare the
1125 // resolved knobs rather than asserting a particular value.
1126 let via_args = init_with_args(["/usr/bin/talker", "--verbose", "positional"]);
1127 let plain = init();
1128 match (via_args, plain) {
1129 (Ok(a), Ok(b)) => {
1130 assert_eq!(a.domain_id, b.domain_id);
1131 assert_eq!(a.locator, b.locator);
1132 assert_eq!(a.rmw, b.rmw);
1133 }
1134 (Err(a), Err(b)) => assert_eq!(a, b),
1135 (a, b) => panic!("init_with_args and init disagree: {a:?} vs {b:?}"),
1136 }
1137 }
1138
1139 // phase-427 W9 — the rclrs family on a hosted build.
1140
1141 fn same_identity(a: &Context, b: &Context) {
1142 assert_eq!(a.domain_id, b.domain_id);
1143 assert_eq!(a.locator, b.locator);
1144 assert_eq!(a.mode, b.mode);
1145 assert_eq!(a.rmw, b.rmw);
1146 assert_eq!(a.source, b.source);
1147 }
1148
1149 #[test]
1150 fn default_from_env_is_init() {
1151 let _env = crate::env::test_env_lock();
1152 match (Context::default_from_env(), init()) {
1153 (Ok(a), Ok(b)) => {
1154 same_identity(&a, &b);
1155 assert_eq!(a.source, ContextSource::Env);
1156 }
1157 (Err(a), Err(b)) => assert_eq!(a, b),
1158 (a, b) => panic!("default_from_env and init disagree: {a:?} vs {b:?}"),
1159 }
1160 }
1161
1162 #[test]
1163 fn from_env_honours_the_domain_override() {
1164 let _env = crate::env::test_env_lock();
1165 let env = match init() {
1166 Ok(c) => c,
1167 Err(e) => panic!("init() failed on the test host: {e:?}"),
1168 };
1169 // A value the environment is not carrying, so the override is visible
1170 // whatever `ROS_DOMAIN_ID` says.
1171 let wanted = if env.domain_id == 7 { 9 } else { 7 };
1172 let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(wanted as usize)))
1173 .expect("from_env with a valid override");
1174 assert_eq!(ctx.domain_id, wanted);
1175 // Everything else is untouched.
1176 assert_eq!(ctx.locator, env.locator);
1177 assert_eq!(ctx.rmw, env.rmw);
1178 assert_eq!(ctx.source, ContextSource::Env);
1179 }
1180
1181 #[test]
1182 fn from_env_with_no_override_keeps_the_env_value() {
1183 let _env = crate::env::test_env_lock();
1184 match (
1185 Context::from_env(InitOptions::new().with_domain_id(None)),
1186 init(),
1187 ) {
1188 (Ok(a), Ok(b)) => same_identity(&a, &b),
1189 (Err(a), Err(b)) => assert_eq!(a, b),
1190 (a, b) => panic!("from_env(None) and init disagree: {a:?} vs {b:?}"),
1191 }
1192 }
1193
1194 #[test]
1195 fn from_env_rejects_an_out_of_range_override() {
1196 let _env = crate::env::test_env_lock();
1197 if init().is_err() {
1198 // The environment itself is invalid; the override is never reached.
1199 return;
1200 }
1201 assert_eq!(
1202 Context::from_env(InitOptions::new().with_domain_id(Some(DOMAIN_ID_MAX as usize + 1)))
1203 .map(|c| c.domain_id),
1204 Err(InitError::DomainIdOutOfRange)
1205 );
1206 // A value no `u32` can hold saturates in the builder and is refused
1207 // here, not truncated into a plausible small number.
1208 assert_eq!(
1209 Context::from_env(InitOptions::new().with_domain_id(Some(usize::MAX)))
1210 .map(|c| c.domain_id),
1211 Err(InitError::DomainIdOutOfRange)
1212 );
1213 }
1214
1215 #[test]
1216 #[should_panic(expected = "refused: `--enclave`")]
1217 fn context_new_refuses_what_it_cannot_honour() {
1218 let _env = crate::env::test_env_lock();
1219 let _ = Context::new(
1220 [
1221 "/usr/bin/talker",
1222 "--ros-args",
1223 "-r",
1224 "chatter:=/other",
1225 "--enclave",
1226 "/e",
1227 ],
1228 InitOptions::new(),
1229 );
1230 }
1231
1232 #[test]
1233 fn context_new_parses_exactly_like_init_with_args() {
1234 let _env = crate::env::test_env_lock();
1235 let argv = ["/usr/bin/talker", "--ros-args", "-r", "chatter:=/other"];
1236 let a = Context::new(argv, InitOptions::new()).expect("Context::new");
1237 let b = init_with_args(argv).expect("init_with_args");
1238 assert_eq!(a.ros_args, b.ros_args);
1239 assert_eq!(a.ros_args, [remap(None, "chatter", "/other")]);
1240 }
1241
1242 #[test]
1243 fn context_new_passes_plain_args_through() {
1244 let _env = crate::env::test_env_lock();
1245 let opts = InitOptions::new().with_domain_id(Some(3));
1246 match (
1247 Context::new(["/usr/bin/talker", "--verbose", "positional"], opts),
1248 Context::from_env(opts),
1249 ) {
1250 (Ok(a), Ok(b)) => {
1251 same_identity(&a, &b);
1252 assert_eq!(a.domain_id, 3);
1253 }
1254 (Err(a), Err(b)) => assert_eq!(a, b),
1255 (a, b) => panic!("Context::new and from_env disagree: {a:?} vs {b:?}"),
1256 }
1257 }
1258
1259 #[test]
1260 fn context_new_accepts_rclrs_argv_shape() {
1261 let _env = crate::env::test_env_lock();
1262 // `impl IntoIterator<Item = String>` is what rclrs takes; ours must
1263 // accept the same call unchanged.
1264 let args: alloc::vec::Vec<alloc::string::String> =
1265 alloc::vec![alloc::string::String::from("talker")];
1266 let _ = Context::new(args, InitOptions::default());
1267 }
1268}
1269
1270/// phase-427 W9 — the freestanding constructor's parse, with the constants
1271/// injected. Runs on the `alloc` floor (`cargo test -p nros --features alloc
1272/// --lib`); nothing here reads the environment.
1273#[cfg(all(test, feature = "alloc"))]
1274mod baked_tests {
1275 use super::*;
1276
1277 #[test]
1278 fn nothing_baked_is_domain_zero_and_an_absent_locator() {
1279 let ctx = Context::from_baked(None, None).expect("nothing baked is valid");
1280 assert_eq!(ctx.domain_id, 0);
1281 assert_eq!(ctx.locator, "");
1282 assert_eq!(ctx.mode, SessionMode::Client);
1283 assert_eq!(ctx.rmw, "");
1284 assert_eq!(ctx.source, ContextSource::Baked);
1285 }
1286
1287 #[test]
1288 fn baked_values_are_carried() {
1289 let ctx = Context::from_baked(Some("tcp/127.0.0.1:7456"), Some("42")).expect("valid bake");
1290 assert_eq!(ctx.locator, "tcp/127.0.0.1:7456");
1291 assert_eq!(ctx.domain_id, 42);
1292 assert_eq!(ctx.source, ContextSource::Baked);
1293 // The config it materialises is the one the macros used to assemble
1294 // by hand: locator, domain, node name, client mode.
1295 let cfg = ctx.config("talker");
1296 assert_eq!(cfg.locator, "tcp/127.0.0.1:7456");
1297 assert_eq!(cfg.domain_id, 42);
1298 assert_eq!(cfg.node_name, "talker");
1299 assert_eq!(cfg.mode, SessionMode::Client);
1300 }
1301
1302 #[test]
1303 fn an_empty_baked_locator_is_absent() {
1304 let ctx = Context::from_baked(Some(""), None).expect("empty is absent, not an error");
1305 assert_eq!(ctx.locator, "");
1306 }
1307
1308 #[test]
1309 fn a_non_numeric_baked_domain_is_refused() {
1310 assert_eq!(
1311 Context::from_baked(None, Some("seven")).map(|c| c.domain_id),
1312 Err(InitError::EnvParseFailed)
1313 );
1314 assert_eq!(
1315 Context::from_baked(None, Some("-1")).map(|c| c.domain_id),
1316 Err(InitError::EnvParseFailed)
1317 );
1318 }
1319
1320 #[test]
1321 fn an_out_of_range_baked_domain_is_refused() {
1322 // The bound `try_resolve_hosted` applies, so an image cannot be built
1323 // onto a domain a hosted process would refuse to boot on.
1324 assert_eq!(
1325 Context::from_baked(None, Some("300")).map(|c| c.domain_id),
1326 Err(InitError::DomainIdOutOfRange)
1327 );
1328 assert_eq!(
1329 Context::from_baked(None, Some("232")).map(|c| c.domain_id),
1330 Ok(DOMAIN_ID_MAX)
1331 );
1332 }
1333
1334 #[test]
1335 fn locator_override_applies_only_when_present_and_non_empty() {
1336 let baked = || Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("1")).unwrap();
1337 assert_eq!(
1338 baked()
1339 .with_locator_override(Some("tcp/127.0.0.1:7456"))
1340 .locator,
1341 "tcp/127.0.0.1:7456"
1342 );
1343 assert_eq!(
1344 baked().with_locator_override(None).locator,
1345 "tcp/10.0.2.2:7447"
1346 );
1347 assert_eq!(
1348 baked().with_locator_override(Some("")).locator,
1349 "tcp/10.0.2.2:7447"
1350 );
1351 // The override touches nothing else.
1352 let ctx = baked().with_locator_override(Some("tcp/127.0.0.1:7456"));
1353 assert_eq!(ctx.domain_id, 1);
1354 assert_eq!(ctx.source, ContextSource::Baked);
1355 }
1356
1357 /// phase-467 row `rust:Context::domain_id`. Two claims, and the second is
1358 /// the one worth a test: the accessor answers the field, and it answers it
1359 /// as a `usize` — rclrs's type — so a ported `let d: usize =
1360 /// ctx.domain_id();` compiles. The annotation is the assertion; without it
1361 /// an integer literal would infer whatever the accessor returns and the
1362 /// test would pass against a `u32` too.
1363 #[test]
1364 fn context_domain_id_reads_the_field_as_rclrs_types_it() {
1365 let ctx = Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("7")).unwrap();
1366 let d: usize = ctx.domain_id();
1367 assert_eq!(d, 7);
1368 assert_eq!(ctx.domain_id(), ctx.domain_id as usize);
1369 // The field did not move: `Context` is a plain struct and both
1370 // spellings reach the same byte.
1371 assert_eq!(ctx.domain_id, 7u32);
1372 }
1373
1374 #[test]
1375 fn baked_reads_this_crates_build_environment() {
1376 // The values depend on the build; the SHAPE does not. Whatever was
1377 // baked, the source is `Baked`, and a parse failure would have come
1378 // from a bake this test cannot control — report it rather than hide it.
1379 match Context::baked() {
1380 Ok(ctx) => assert_eq!(ctx.source, ContextSource::Baked),
1381 Err(e) => panic!(
1382 "NROS_DOMAIN_ID was baked into this test build with a value the constructor refuses: {e:?}"
1383 ),
1384 }
1385 }
1386
1387 #[test]
1388 fn init_options_default_overrides_nothing() {
1389 assert_eq!(InitOptions::new(), InitOptions::default());
1390 assert_eq!(InitOptions::new().domain_id(), None);
1391 assert_eq!(
1392 InitOptions::new().with_domain_id(Some(5)).domain_id(),
1393 Some(5)
1394 );
1395 assert_eq!(
1396 InitOptions::new()
1397 .with_domain_id(Some(5))
1398 .with_domain_id(None)
1399 .domain_id(),
1400 None
1401 );
1402 // Saturates rather than truncates: `u32::MAX + 1` must not read as 0.
1403 // Reported back as a `usize`, which is the getter's type since
1404 // phase-467 — the saturation is in the STORED `u32` and the getter
1405 // only widens it, so this is the one place the two widths are both
1406 // visible in one line.
1407 assert_eq!(
1408 InitOptions::new()
1409 .with_domain_id(Some(u32::MAX as usize + 1))
1410 .domain_id(),
1411 Some(u32::MAX as usize)
1412 );
1413 // The in-place spelling is the same operation.
1414 let mut opts = InitOptions::new();
1415 opts.set_domain_id(Some(9));
1416 assert_eq!(opts, InitOptions::new().with_domain_id(Some(9)));
1417 opts.set_domain_id(None);
1418 assert_eq!(opts, InitOptions::new());
1419 }
1420
1421 /// phase-427 W10 — the two variants `create_executor*` can produce say
1422 /// WHICH thing went wrong, in words. `alloc` only: the errors are values
1423 /// and need no RMW to construct, which is the point of testing them here
1424 /// rather than beside a session.
1425 #[test]
1426 fn the_executor_errors_say_what_happened() {
1427 use alloc::string::ToString as _;
1428
1429 let too_small = InitError::BackingTooSmall {
1430 needed: 4096,
1431 given: 8,
1432 }
1433 .to_string();
1434 // Both numbers, because "too small" without them tells a caller
1435 // nothing they can act on.
1436 assert!(too_small.contains("4096"), "{too_small}");
1437 assert!(too_small.contains('8'), "{too_small}");
1438
1439 // The backend's own error survives the wrapping — issue 0465's rule:
1440 // an exhausted table and a missing router must not read alike.
1441 let open_failed = InitError::ExecutorOpenFailed(NodeError::NodeTableFull).to_string();
1442 assert!(open_failed.contains("NodeTableFull"), "{open_failed}");
1443 assert_ne!(
1444 open_failed,
1445 InitError::ExecutorOpenFailed(NodeError::NameTooLong).to_string()
1446 );
1447 }
1448
1449 #[cfg(not(feature = "env"))]
1450 #[test]
1451 fn freestanding_default_from_env_is_the_bake() {
1452 // Without `env` there is no process environment to read; the family
1453 // resolves to the bake, and the override still lands on top of it.
1454 let ctx = Context::default_from_env().expect("bake");
1455 assert_eq!(ctx.source, ContextSource::Baked);
1456 let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(11))).expect("bake");
1457 assert_eq!(ctx.domain_id, 11);
1458 assert_eq!(ctx.source, ContextSource::Baked);
1459 }
1460}