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 /// Issue 2051 (RFC-0109 D9) — drop the baked locator when the image has
527 /// session properties.
528 ///
529 /// With an RMW file the file is the wiring, and every other road bakes no
530 /// locator beside it (`nros::main!`, the C/C++ boot blob). A Zephyr image
531 /// still bakes its Kconfig locator (`CONFIG_NROS_ZENOH_LOCATOR`, default
532 /// `tcp/127.0.0.1:7447`), and the zenoh shim dials a supplied locator
533 /// BEFORE the file's `connect` list: a router on that port would win, and
534 /// the file's would be a fallback. Called before
535 /// [`with_locator_override`](Self::with_locator_override), so a run-time
536 /// locator still wins. Public for the entry macro; not API.
537 #[doc(hidden)]
538 pub fn yield_locator_to_session_properties(
539 mut self,
540 properties: &[nros_node::SessionProperty],
541 ) -> Self {
542 if !properties.is_empty() {
543 self.locator.clear();
544 }
545 self
546 }
547
548 /// phase-427 W10 — the config the executor's SESSION opens with: this
549 /// context's locator, domain and mode, and no node name.
550 ///
551 /// The counterpart of [`config`](Self::config), which is for the older
552 /// shape where the executor IS the node it was configured with. Here the
553 /// session carries no ROS identity of its own — `Executor::create_node`
554 /// supplies that, once per node, and one executor holds several
555 /// (RFC-0047). What lands in `ExecutorConfig::node_name` is therefore
556 /// whatever [`ExecutorConfig::new`] defaults to; it names the transport
557 /// session for diagnostics, not a participant on the graph.
558 #[cfg(feature = "rmw-cffi")]
559 fn session_config(&self) -> ExecutorConfig<'_> {
560 ExecutorConfig::new(self.locator.as_str())
561 .domain_id(self.domain_id)
562 .mode(self.mode)
563 }
564}
565
566/// phase-427 W10 — `rclrs::Context::create_executor`, and the ours-only
567/// no-leak twin beside it.
568///
569/// Both are gated on `rmw-cffi`, because an executor without the vtable
570/// runtime has no session to open; `create_executor` is additionally gated on
571/// `alloc`, because it is the one that leaks a default-sized backing.
572#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
573impl Context {
574 /// `rclrs::Context::create_executor()` — open the session this context
575 /// resolved and hand back the [`Executor`] that owns it.
576 ///
577 /// This is the entry a ported rclrs `main` lands on: upstream writes
578 /// `context.create_basic_executor()` (or `create_executor(runtime)`) and
579 /// gets an `Executor` back; ours returns a `Result`, so the port is a `?`.
580 /// The runtime argument has no counterpart here and is not accepted — one
581 /// executor per RTOS task is the model (RFC-0002), and there is no runtime
582 /// to choose between.
583 ///
584 /// The node name is NOT taken here. Name nodes with
585 /// [`Executor::create_node`], which is where several named nodes on one
586 /// session already live (RFC-0047).
587 ///
588 /// # Available on `alloc` only
589 ///
590 /// It leaks an executor-lifetime backing at
591 /// [`ExecutorSizing::DEFAULT`] — the reason it is `Executor<'static>` and
592 /// the reason it needs an allocator. A target without one calls
593 /// [`create_executor_in`](Self::create_executor_in) with its own
594 /// `static`, which is the same open with the storage supplied rather than
595 /// leaked. (Today [`Context`] itself has an `alloc` floor — its `locator`
596 /// and `rmw` are owned `String`s — so the pair moves together; if that
597 /// floor ever drops, this is the half that stays behind.)
598 ///
599 /// ```ignore
600 /// let context = nros::Context::default_from_env()?;
601 /// let mut executor = context.create_executor()?;
602 /// let mut node = executor.create_node("talker")?;
603 /// ```
604 ///
605 /// The `-r` rules `init_with_args` / `Context::new` parsed are
606 /// installed in the new executor as its fallback remap tier.
607 pub fn create_executor(&self) -> Result<Executor<'static>, InitError> {
608 let mut executor =
609 Executor::open(&self.session_config()).map_err(InitError::ExecutorOpenFailed)?;
610 self.install_ros_args(&mut executor);
611 Ok(executor)
612 }
613
614 /// Put this context's `--ros-args` remaps into `executor`'s fallback tier.
615 /// A rule that does not fit is REFUSED rather than dropped.
616 fn install_ros_args(&self, executor: &mut Executor<'_>) {
617 #[cfg(feature = "env")]
618 {
619 let rules = self.ros_args.iter().map(|r| nros_node::ros_args::RemapArg {
620 node: r.node.as_deref(),
621 from: r.from.as_str(),
622 to: r.to.as_str(),
623 });
624 if let Err(rule) = nros_node::ros_args::install_argv_remaps(executor, rules) {
625 refuse_ros_args(&RosArgsRefusal::DoesNotFit {
626 node: rule.node,
627 from: rule.from,
628 to: rule.to,
629 });
630 }
631 #[cfg(feature = "param-store")]
632 if !self.ros_params.is_empty() {
633 let params = self
634 .ros_params
635 .iter()
636 .map(|p| nros_node::ros_args::ParamArg {
637 node: p.node.as_deref(),
638 name: p.name.as_str(),
639 value: p.value.as_str(),
640 });
641 if executor.install_argv_params(params).is_err() {
642 refuse_ros_args(&RosArgsRefusal::NoParamStore);
643 }
644 }
645 }
646 #[cfg(not(feature = "env"))]
647 let _ = executor;
648 }
649
650 /// OURS — [`create_executor`](Self::create_executor) with the storage
651 /// supplied by the caller instead of leaked.
652 ///
653 /// The freestanding form. An RTOS or bare-metal entry owns a
654 /// `static mut [MaybeUninit<u64>; N]` (or the array the `nros::main!`
655 /// macro sizes for it) and hands it in; nothing is allocated, and the
656 /// returned executor borrows the backing for `'b` rather than living
657 /// forever. rclrs has no counterpart — it allocates.
658 ///
659 /// `backing` must hold at least `ExecutorSizing::DEFAULT.u64_len()` words.
660 /// A shorter slice is [`InitError::BackingTooSmall`] naming both numbers,
661 /// never undefined behaviour: this is the safe wrapper around the `unsafe`
662 /// `Executor::open_in`, and the length check is what makes it safe. Size
663 /// the array with `ExecutorSizing::DEFAULT.u64_len()` rather than a
664 /// literal, since the default moves with `NROS_EXECUTOR_MAX_CBS` and its
665 /// siblings.
666 ///
667 /// ```ignore
668 /// static mut BACKING: [MaybeUninit<u64>; N] = [MaybeUninit::uninit(); N];
669 /// let context = nros::Context::default_from_env()?;
670 /// // SAFETY: single-threaded entry, taken once.
671 /// let mut executor = context.create_executor_in(unsafe { &mut *&raw mut BACKING })?;
672 /// let mut node = executor.create_node("talker")?;
673 /// ```
674 pub fn create_executor_in<'b>(
675 &self,
676 backing: &'b mut [MaybeUninit<u64>],
677 ) -> Result<Executor<'b>, InitError> {
678 let sizing = ExecutorSizing::DEFAULT;
679 let needed = sizing.u64_len();
680 let given = backing.len();
681 if given < needed {
682 return Err(InitError::BackingTooSmall { needed, given });
683 }
684 // SAFETY: `backing` is at least `sizing.u64_len()` words (checked
685 // directly above), is `u64`-aligned by its element type, is uniquely
686 // borrowed for `'b`, and the returned `Executor<'b>` is the only thing
687 // that can reach it for that lifetime.
688 let mut executor = unsafe { Executor::open_in(&self.session_config(), backing, sizing) }
689 .map_err(InitError::ExecutorOpenFailed)?;
690 self.install_ros_args(&mut executor);
691 Ok(executor)
692 }
693}
694
695#[cfg(feature = "env")]
696fn read_env_context(source: ContextSource) -> Result<Context, InitError> {
697 // issue 0687 — through the ONE resolver, not a third parse of the same four
698 // variables. This function had its own copy, and the copies had drifted:
699 // it did not see the legacy `ZENOH_LOCATOR`/`ZENOH_MODE` spellings (now
700 // retired and refused by the shared reader), and it range-checked nothing, so
701 // `ROS_DOMAIN_ID=300` reached a backend that could only fail later.
702 //
703 // Issue 0330 — no backend default for the locator: `nros` is RMW-agnostic,
704 // so unset env leaves it EMPTY (= absent) and the linked backend
705 // substitutes its own (zenoh: `nros_rmw_zenoh::DEFAULT_LOCATOR`; xrce: its
706 // agent default; cyclonedds ignores the locator entirely). That is what
707 // `resolve_hosted` does with an empty `BootConfig` too.
708 let resolved =
709 crate::env::try_resolve_hosted(nros_node::BootConfig::default()).map_err(|e| match e {
710 nros_node::BootConfigError::DomainIdRange => InitError::DomainIdOutOfRange,
711 nros_node::BootConfigError::DomainIdParse => InitError::EnvParseFailed,
712 nros_node::BootConfigError::RetiredEnvVar { name, replacement } => {
713 InitError::RetiredEnvVar { name, replacement }
714 }
715 })?;
716 let locator = alloc::string::String::from(resolved.locator);
717 let domain_id = resolved.domain_id;
718 let mode = resolved.mode;
719 // issue 0687 — the `$NROS_RMW` half comes from the shared selector; the
720 // `RMW_IMPLEMENTATION` fallback stays HERE and only here. `Context.rmw` is
721 // a ROS-vocabulary HINT (`rmw_cyclonedds_cpp`), not the cffi registry
722 // selector (`cyclonedds`) — folding the two together would hand a ROS name
723 // to `resolve_backend`, which answers `Unknown` and fails the open.
724 let rmw = crate::rmw_selector()
725 .map(|s| alloc::string::String::from(s.as_str()))
726 .or_else(|| crate::env::var("RMW_IMPLEMENTATION").map(alloc::string::String::from))
727 .unwrap_or_default();
728 Ok(Context {
729 domain_id,
730 locator,
731 mode,
732 rmw,
733 source,
734 ros_args: alloc::vec::Vec::new(),
735 ros_params: alloc::vec::Vec::new(),
736 })
737}
738
739/// Pattern 3 — raw init, launch file ignored. The C++-symmetric anchor:
740/// `rclcpp::init()` in the Rust spelling, and EQUAL to
741/// [`Context::default_from_env`] on a hosted build (RFC-0089 "Context and
742/// `init`, settled" — each language follows its own upstream; rclrs spells
743/// this one on the type, rclcpp as a free function, and both are here).
744///
745/// Reads env vars (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
746/// `NROS_RMW` / `RMW_IMPLEMENTATION`) and returns a [`Context`]. The
747/// caller owns the spin loop — typically `Executor::open(&ctx.config(name))`
748/// followed by `spin` or a hand-rolled `spin_once` loop.
749#[cfg(feature = "env")]
750pub fn init() -> Result<Context, InitError> {
751 read_env_context(ContextSource::Env)
752}
753
754/// The refusal [`init_with_args`] and [`Context::new`] emit for a ROS
755/// argument nano-ros does not honour. Panics carry this text followed by the
756/// refused argument. `NROS_RCLCPP_REFUSE_INIT_ARGV` is the C++ twin
757/// (`packages/api/nros-cpp/include/rclcpp/logging.hpp`), and since phase-467 Row 11's
758/// C++ half it states the same honoured and refused sets, from the same parser.
759#[cfg(feature = "env")]
760pub const REFUSE_INIT_ARGS: &str = "nros::init_with_args / nros::Context::new was given a --ros-args \
761argument nano-ros cannot honour (RFC-0089, phase-417 W3.b). Proceeding would DISCARD it -- the \
762'compiles and differs' the rule forbids. HONOURED inside --ros-args ... --: -r / --remap \
763[node:]from:=to, applied as the FALLBACK beneath any remap the launch file projected for the same \
764name (RFC-0046; rcl's local-before-global), through Context::create_executor / \
765create_executor_in; and, in an image with a parameter store, -p / --param [node:]name:=value and \
766--params-file <path>, applied when a node declares the parameter (RFC-0015 section 9). REFUSED: \
767node-identity remaps (__node, __name, __ns), -e / --enclave, the log flags, any token that is not a \
768ROS flag, and -p / --params-file in an image without a parameter store. A nros sync image does not read argv at all: its launch \
769remaps and parameters are projected into the GENERATED ENTRY at BUILD time.";
770
771/// Does `args` open a `--ros-args` scope at all?
772///
773/// No longer the refusal's predicate — since the `-r` parse landed, a vector
774/// with `--ros-args` is parsed, and only what it cannot honour is refused
775/// (`nros_node::ros_args::parse_ros_args`). Kept because "will this argv be
776/// read?" is still a question a caller can ask before handing it over.
777///
778/// Exact match only. `--ros-args-extra` is not the flag, and a prefix match
779/// would claim an argument nano-ros never reads — pinned by a unit test.
780#[cfg(feature = "env")]
781pub fn args_have_ros_args<I, S>(args: I) -> bool
782where
783 I: IntoIterator<Item = S>,
784 S: AsRef<str>,
785{
786 args.into_iter().any(|a| a.as_ref() == "--ros-args")
787}
788
789/// Why a `--ros-args` vector, or a context carrying one, was refused.
790#[cfg(feature = "env")]
791enum RosArgsRefusal<'a> {
792 /// The parse itself refused an argument.
793 Parse(nros_node::ros_args::RosArgsError<'a>),
794 /// A parsed rule did not fit the executor's remap table.
795 #[cfg(feature = "rmw-cffi")]
796 DoesNotFit {
797 node: Option<&'a str>,
798 from: &'a str,
799 to: &'a str,
800 },
801 /// [`Context::config`] was asked for a config that cannot carry the rules.
802 UnreachableExecutor,
803 /// phase-482 W5 — `--params-file <path>` could not be read or parsed.
804 #[cfg(feature = "param-store")]
805 ParamsFile {
806 path: alloc::string::String,
807 why: alloc::string::String,
808 },
809 /// phase-482 W5 — the parameter store could not be allocated (issue 1706).
810 #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
811 NoParamStore,
812}
813
814#[cfg(feature = "env")]
815impl core::fmt::Display for RosArgsRefusal<'_> {
816 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
817 match self {
818 Self::Parse(e) => e.fmt(f),
819 #[cfg(feature = "rmw-cffi")]
820 Self::DoesNotFit { node, from, to } => {
821 let node = node.map(|n| alloc::format!("{n}:")).unwrap_or_default();
822 write!(
823 f,
824 "`-r {node}{from}:={to}` does not fit the executor's remap table (MAX_REMAPS rules, \
825 shared with launch remaps; names up to {} bytes)",
826 nros_node::names::MAX_RESOLVED_NAME_LEN
827 )
828 }
829 Self::UnreachableExecutor => f.write_str(
830 "Context::config() cannot carry this context's --ros-args remaps or parameter \
831 overrides into the executor Executor::open builds from it; use \
832 Context::create_executor() or create_executor_in(), which install them",
833 ),
834 #[cfg(feature = "param-store")]
835 Self::ParamsFile { path, why } => write!(f, "`--params-file {path}`: {why}"),
836 #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
837 Self::NoParamStore => f.write_str(
838 "the parameter store could not be allocated, so the parameter overrides have \
839 nowhere to go",
840 ),
841 }
842 }
843}
844
845/// The one refusal site: logged through `nros_log` (never `std::println!`,
846/// issue 0589), then a panic carrying [`REFUSE_INIT_ARGS`] and the reason —
847/// the shape `rclcpp::init(argc, argv)` has in `nros.hpp`, and the shape
848/// rclcpp's own `UnknownROSArgsError` has.
849#[cfg(feature = "env")]
850fn refuse_ros_args(why: &RosArgsRefusal<'_>) -> ! {
851 nros_log::log_error!(
852 nros_log::get_logger("nros"),
853 "{}\n refused: {}",
854 REFUSE_INIT_ARGS,
855 why
856 );
857 panic!("{REFUSE_INIT_ARGS}\n refused: {why}");
858}
859
860/// Parse `args` (rcl's grammar — `nros_node::ros_args`) into owned rules, or
861/// refuse. Shared by [`init_with_args`] and [`Context::new`], so the two stay
862/// one behaviour.
863#[cfg(feature = "env")]
864fn parse_ros_args_or_refuse<I, S>(
865 args: I,
866) -> (alloc::vec::Vec<ArgvRemap>, alloc::vec::Vec<ArgvParam>)
867where
868 I: IntoIterator<Item = S>,
869 S: AsRef<str>,
870{
871 use alloc::string::String;
872 let owned: alloc::vec::Vec<String> =
873 args.into_iter().map(|a| String::from(a.as_ref())).collect();
874 let mut rules = alloc::vec::Vec::new();
875 #[cfg_attr(not(feature = "param-store"), allow(unused_mut))]
876 let mut params: alloc::vec::Vec<ArgvParam> = alloc::vec::Vec::new();
877 let remap = |r: nros_node::ros_args::RemapArg<'_>| ArgvRemap {
878 node: r.node.map(String::from),
879 from: String::from(r.from),
880 to: String::from(r.to),
881 };
882 // phase-482 W5 — with a parameter store, `-p` and `--params-file` are
883 // honoured, each file's entries landing where its flag stood (so a later
884 // `-p` overrides it and a later file overrides an earlier `-p`). Without
885 // one, the remap-only parse refuses them by name.
886 #[cfg(feature = "param-store")]
887 let parsed = {
888 let mut events = alloc::vec::Vec::new();
889 let r = nros_node::ros_args::parse_ros_args_events(owned.iter().map(|a| a.as_str()), |a| {
890 events.push(a)
891 });
892 if r.is_ok() {
893 for a in events {
894 match a {
895 nros_node::ros_args::RosArg::Remap(r) => rules.push(remap(r)),
896 nros_node::ros_args::RosArg::Param(p) => params.push(ArgvParam {
897 node: p.node.map(String::from),
898 name: String::from(p.name),
899 value: String::from(p.value),
900 }),
901 nros_node::ros_args::RosArg::ParamsFile(path) => {
902 // phase-359 W10 stage B — the C library's read, beside
903 // `getenv`, so the hosted entry needs no `std`.
904 let text = match crate::env::read_file(path) {
905 Ok(t) => t,
906 Err(why) => refuse_ros_args(&RosArgsRefusal::ParamsFile {
907 path: String::from(path),
908 why: String::from(why),
909 }),
910 };
911 if let Err(e) = nros_node::ros_args::parse_params_yaml(&text, |p| {
912 params.push(ArgvParam {
913 node: p.node.map(String::from),
914 name: String::from(p.name),
915 value: String::from(p.value),
916 })
917 }) {
918 refuse_ros_args(&RosArgsRefusal::ParamsFile {
919 path: String::from(path),
920 why: alloc::format!("{e}"),
921 });
922 }
923 }
924 }
925 }
926 }
927 r
928 };
929 #[cfg(not(feature = "param-store"))]
930 let parsed = nros_node::ros_args::parse_ros_args(owned.iter().map(|a| a.as_str()), |r| {
931 rules.push(remap(r))
932 });
933 if let Err(e) = parsed {
934 refuse_ros_args(&RosArgsRefusal::Parse(e));
935 }
936 (rules, params)
937}
938
939/// Pattern 3 — like [`init()`] but takes the process arguments, the way
940/// `rclcpp::init(argc, argv)` / rclrs's `Context::new(args, ..)` do. The
941/// free-function twin of [`Context::new`] with default options.
942///
943/// # What it does with the arguments
944///
945/// It parses `--ros-args` with rcl's grammar (Humble `rcl/arguments.h`): ROS
946/// arguments live between `--ros-args` and `--` (or the end), several scopes
947/// may appear, and everything outside them is the program's own and is left
948/// alone.
949///
950/// * **`-r` / `--remap [node:]from:=to` — honoured.** The rules ride on the
951/// returned [`Context`] and are installed in every executor it creates
952/// (`Context::create_executor` / `Context::create_executor_in`) as the
953/// FALLBACK remap tier: a launch-projected rule for the same name wins
954/// (RFC-0046), exactly as rcl checks a node's local arguments before the
955/// process's global ones. They reach entities on every road — the handle
956/// `Executor::create_node` returns, `nros::node!` components, and the C and
957/// C++ APIs.
958/// * **`-p` / `--param [node:]name:=value` and `--params-file <path>` —
959/// honoured when `nros` has `param-services`** (phase-482 W5, RFC-0015 §9's
960/// POSIX row). They ride on the [`Context`] in argv order and are applied
961/// when a node DECLARES the parameter: the last matching override wins, its
962/// text typed by the declared default, and a `node:` override reaches only
963/// the node of that name. The envelope, stated here because it is the API:
964/// a parameter file is read with `std::fs` and holds scalars only (arrays,
965/// anchors and block scalars are refused, with their line), a node key
966/// matches by NAME not namespace, and an override that does not parse as
967/// the declared type is logged and the default kept.
968/// * **Everything else inside a scope — refused, loudly**: node-identity
969/// remaps, enclaves, log flags, unknown tokens, and `-p` / `--params-file`
970/// in an image with no parameter store. Logged through `nros_log`, then a panic
971/// carrying [`REFUSE_INIT_ARGS`] and the argument. rclcpp refuses unknown
972/// ROS arguments too (`UnknownROSArgsError`).
973///
974/// # Where the rules do NOT reach
975///
976/// [`Context::config`] refuses a context that carries rules, because an
977/// `ExecutorConfig` cannot carry them. Entities created through the
978/// executor's own `add_*` registration methods or a `node_mut` context are
979/// not remapped either: those are also where the C ABI and the component
980/// sink hand their ALREADY-RESOLVED names, so remapping there would apply a
981/// rule twice.
982#[cfg(feature = "env")]
983pub fn init_with_args<I, S>(args: I) -> Result<Context, InitError>
984where
985 I: IntoIterator<Item = S>,
986 S: AsRef<str>,
987{
988 let (ros_args, ros_params) = parse_ros_args_or_refuse(args);
989 let mut context = init()?;
990 context.ros_args = ros_args;
991 context.ros_params = ros_params;
992 Ok(context)
993}
994
995#[cfg(all(test, feature = "env"))]
996mod ros_args_refusal_tests {
997 use super::*;
998
999 #[test]
1000 fn predicate_matches_the_flag_exactly() {
1001 let _env = crate::env::test_env_lock();
1002 assert!(args_have_ros_args(["node", "--ros-args", "-p", "x:=1"]));
1003 assert!(args_have_ros_args(["--ros-args"]));
1004 // A prefix match would refuse an argument nano-ros never drops.
1005 assert!(!args_have_ros_args(["--ros-args-extra"]));
1006 assert!(!args_have_ros_args(["--ros-arg"]));
1007 assert!(!args_have_ros_args(["node", "--verbose"]));
1008 assert!(!args_have_ros_args(core::iter::empty::<&str>()));
1009 }
1010
1011 fn remap(node: Option<&str>, from: &str, to: &str) -> ArgvRemap {
1012 ArgvRemap {
1013 node: node.map(alloc::string::String::from),
1014 from: alloc::string::String::from(from),
1015 to: alloc::string::String::from(to),
1016 }
1017 }
1018
1019 #[test]
1020 fn remap_rules_ride_on_the_context_in_argv_order() {
1021 let _env = crate::env::test_env_lock();
1022 let ctx = init_with_args([
1023 "/usr/bin/talker",
1024 "--ros-args",
1025 "-r",
1026 "chatter:=/other",
1027 "--remap",
1028 "talker:~/out:=/wire",
1029 "--",
1030 "positional",
1031 ])
1032 .expect("init");
1033 assert_eq!(
1034 ctx.ros_args,
1035 [
1036 remap(None, "chatter", "/other"),
1037 remap(Some("talker"), "~/out", "/wire")
1038 ]
1039 );
1040 }
1041
1042 #[test]
1043 fn an_empty_scope_is_not_a_refusal() {
1044 let _env = crate::env::test_env_lock();
1045 assert!(
1046 init_with_args(["--ros-args"])
1047 .expect("init")
1048 .ros_args
1049 .is_empty()
1050 );
1051 }
1052
1053 /// Without a parameter store there is nowhere to put an override, so the
1054 /// parse refuses it by name rather than dropping it.
1055 #[cfg(not(feature = "param-store"))]
1056 #[test]
1057 #[should_panic(expected = "refused: `-p`")]
1058 fn parameter_overrides_are_still_refused_by_name() {
1059 let _env = crate::env::test_env_lock();
1060 let _ = init_with_args(["/usr/bin/talker", "--ros-args", "-r", "a:=b", "-p", "x:=1"]);
1061 }
1062
1063 #[cfg(feature = "param-store")]
1064 fn param(node: Option<&str>, name: &str, value: &str) -> ArgvParam {
1065 ArgvParam {
1066 node: node.map(alloc::string::String::from),
1067 name: alloc::string::String::from(name),
1068 value: alloc::string::String::from(value),
1069 }
1070 }
1071
1072 /// phase-482 W5 — `-p` and a `--params-file` ride on the context in argv
1073 /// order, the file's entries where its flag stood, beside the remaps.
1074 #[cfg(feature = "param-store")]
1075 #[test]
1076 fn parameter_overrides_ride_on_the_context_in_argv_order() {
1077 let _env = crate::env::test_env_lock();
1078 let path =
1079 std::env::temp_dir().join(alloc::format!("nros-w5-params-{}.yaml", std::process::id()));
1080 std::fs::write(
1081 &path,
1082 "/**:\n ros__parameters:\n rate: 30\n/talker:\n ros__parameters:\n gains:\n kp: 1.5\n",
1083 )
1084 .unwrap();
1085 let path_s = path.to_str().unwrap();
1086 let ctx = init_with_args([
1087 "/usr/bin/talker",
1088 "--ros-args",
1089 "-p",
1090 "rate:=10",
1091 "--params-file",
1092 path_s,
1093 "-r",
1094 "chatter:=/other",
1095 "-p",
1096 "talker:label:=hi",
1097 ])
1098 .expect("init");
1099 let _ = std::fs::remove_file(&path);
1100 assert_eq!(
1101 ctx.ros_params,
1102 [
1103 param(None, "rate", "10"),
1104 param(None, "rate", "30"),
1105 param(Some("talker"), "gains.kp", "1.5"),
1106 param(Some("talker"), "label", "hi"),
1107 ]
1108 );
1109 assert_eq!(ctx.ros_args, [remap(None, "chatter", "/other")]);
1110 }
1111
1112 /// An unreadable parameter file is refused, naming the path — never a
1113 /// silently empty set of overrides.
1114 #[cfg(feature = "param-store")]
1115 #[test]
1116 #[should_panic(expected = "--params-file /nonexistent/nros-w5.yaml")]
1117 fn an_unreadable_params_file_is_refused_by_path() {
1118 let _env = crate::env::test_env_lock();
1119 let _ = init_with_args(["--ros-args", "--params-file", "/nonexistent/nros-w5.yaml"]);
1120 }
1121
1122 #[test]
1123 #[should_panic(expected = "remaps a node's identity")]
1124 fn identity_remaps_are_refused() {
1125 let _env = crate::env::test_env_lock();
1126 let _ = init_with_args(["--ros-args", "-r", "__ns:=/robot"]);
1127 }
1128
1129 #[test]
1130 #[should_panic(expected = "Context::config() cannot carry")]
1131 fn config_refuses_a_context_that_carries_remaps() {
1132 let _env = crate::env::test_env_lock();
1133 let ctx = init_with_args(["--ros-args", "-r", "chatter:=/other"]).expect("init");
1134 let _ = ctx.config("talker");
1135 }
1136
1137 #[test]
1138 fn config_is_unaffected_without_remaps() {
1139 let _env = crate::env::test_env_lock();
1140 let ctx = init_with_args(["/usr/bin/talker", "--verbose"]).expect("init");
1141 assert_eq!(ctx.config("talker").node_name, "talker");
1142 }
1143
1144 #[test]
1145 fn plain_args_pass_through_to_init() {
1146 let _env = crate::env::test_env_lock();
1147 // Same answer as `init()` — the arguments are not consulted, and no
1148 // refusal fires. Both read the same environment, so compare the
1149 // resolved knobs rather than asserting a particular value.
1150 let via_args = init_with_args(["/usr/bin/talker", "--verbose", "positional"]);
1151 let plain = init();
1152 match (via_args, plain) {
1153 (Ok(a), Ok(b)) => {
1154 assert_eq!(a.domain_id, b.domain_id);
1155 assert_eq!(a.locator, b.locator);
1156 assert_eq!(a.rmw, b.rmw);
1157 }
1158 (Err(a), Err(b)) => assert_eq!(a, b),
1159 (a, b) => panic!("init_with_args and init disagree: {a:?} vs {b:?}"),
1160 }
1161 }
1162
1163 // phase-427 W9 — the rclrs family on a hosted build.
1164
1165 fn same_identity(a: &Context, b: &Context) {
1166 assert_eq!(a.domain_id, b.domain_id);
1167 assert_eq!(a.locator, b.locator);
1168 assert_eq!(a.mode, b.mode);
1169 assert_eq!(a.rmw, b.rmw);
1170 assert_eq!(a.source, b.source);
1171 }
1172
1173 #[test]
1174 fn default_from_env_is_init() {
1175 let _env = crate::env::test_env_lock();
1176 match (Context::default_from_env(), init()) {
1177 (Ok(a), Ok(b)) => {
1178 same_identity(&a, &b);
1179 assert_eq!(a.source, ContextSource::Env);
1180 }
1181 (Err(a), Err(b)) => assert_eq!(a, b),
1182 (a, b) => panic!("default_from_env and init disagree: {a:?} vs {b:?}"),
1183 }
1184 }
1185
1186 #[test]
1187 fn from_env_honours_the_domain_override() {
1188 let _env = crate::env::test_env_lock();
1189 let env = match init() {
1190 Ok(c) => c,
1191 Err(e) => panic!("init() failed on the test host: {e:?}"),
1192 };
1193 // A value the environment is not carrying, so the override is visible
1194 // whatever `ROS_DOMAIN_ID` says.
1195 let wanted = if env.domain_id == 7 { 9 } else { 7 };
1196 let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(wanted as usize)))
1197 .expect("from_env with a valid override");
1198 assert_eq!(ctx.domain_id, wanted);
1199 // Everything else is untouched.
1200 assert_eq!(ctx.locator, env.locator);
1201 assert_eq!(ctx.rmw, env.rmw);
1202 assert_eq!(ctx.source, ContextSource::Env);
1203 }
1204
1205 #[test]
1206 fn from_env_with_no_override_keeps_the_env_value() {
1207 let _env = crate::env::test_env_lock();
1208 match (
1209 Context::from_env(InitOptions::new().with_domain_id(None)),
1210 init(),
1211 ) {
1212 (Ok(a), Ok(b)) => same_identity(&a, &b),
1213 (Err(a), Err(b)) => assert_eq!(a, b),
1214 (a, b) => panic!("from_env(None) and init disagree: {a:?} vs {b:?}"),
1215 }
1216 }
1217
1218 #[test]
1219 fn from_env_rejects_an_out_of_range_override() {
1220 let _env = crate::env::test_env_lock();
1221 if init().is_err() {
1222 // The environment itself is invalid; the override is never reached.
1223 return;
1224 }
1225 assert_eq!(
1226 Context::from_env(InitOptions::new().with_domain_id(Some(DOMAIN_ID_MAX as usize + 1)))
1227 .map(|c| c.domain_id),
1228 Err(InitError::DomainIdOutOfRange)
1229 );
1230 // A value no `u32` can hold saturates in the builder and is refused
1231 // here, not truncated into a plausible small number.
1232 assert_eq!(
1233 Context::from_env(InitOptions::new().with_domain_id(Some(usize::MAX)))
1234 .map(|c| c.domain_id),
1235 Err(InitError::DomainIdOutOfRange)
1236 );
1237 }
1238
1239 #[test]
1240 #[should_panic(expected = "refused: `--enclave`")]
1241 fn context_new_refuses_what_it_cannot_honour() {
1242 let _env = crate::env::test_env_lock();
1243 let _ = Context::new(
1244 [
1245 "/usr/bin/talker",
1246 "--ros-args",
1247 "-r",
1248 "chatter:=/other",
1249 "--enclave",
1250 "/e",
1251 ],
1252 InitOptions::new(),
1253 );
1254 }
1255
1256 #[test]
1257 fn context_new_parses_exactly_like_init_with_args() {
1258 let _env = crate::env::test_env_lock();
1259 let argv = ["/usr/bin/talker", "--ros-args", "-r", "chatter:=/other"];
1260 let a = Context::new(argv, InitOptions::new()).expect("Context::new");
1261 let b = init_with_args(argv).expect("init_with_args");
1262 assert_eq!(a.ros_args, b.ros_args);
1263 assert_eq!(a.ros_args, [remap(None, "chatter", "/other")]);
1264 }
1265
1266 #[test]
1267 fn context_new_passes_plain_args_through() {
1268 let _env = crate::env::test_env_lock();
1269 let opts = InitOptions::new().with_domain_id(Some(3));
1270 match (
1271 Context::new(["/usr/bin/talker", "--verbose", "positional"], opts),
1272 Context::from_env(opts),
1273 ) {
1274 (Ok(a), Ok(b)) => {
1275 same_identity(&a, &b);
1276 assert_eq!(a.domain_id, 3);
1277 }
1278 (Err(a), Err(b)) => assert_eq!(a, b),
1279 (a, b) => panic!("Context::new and from_env disagree: {a:?} vs {b:?}"),
1280 }
1281 }
1282
1283 #[test]
1284 fn context_new_accepts_rclrs_argv_shape() {
1285 let _env = crate::env::test_env_lock();
1286 // `impl IntoIterator<Item = String>` is what rclrs takes; ours must
1287 // accept the same call unchanged.
1288 let args: alloc::vec::Vec<alloc::string::String> =
1289 alloc::vec![alloc::string::String::from("talker")];
1290 let _ = Context::new(args, InitOptions::default());
1291 }
1292}
1293
1294/// phase-427 W9 — the freestanding constructor's parse, with the constants
1295/// injected. Runs on the `alloc` floor (`cargo test -p nros --features alloc
1296/// --lib`); nothing here reads the environment.
1297#[cfg(all(test, feature = "alloc"))]
1298mod baked_tests {
1299 use super::*;
1300
1301 #[test]
1302 fn nothing_baked_is_domain_zero_and_an_absent_locator() {
1303 let ctx = Context::from_baked(None, None).expect("nothing baked is valid");
1304 assert_eq!(ctx.domain_id, 0);
1305 assert_eq!(ctx.locator, "");
1306 assert_eq!(ctx.mode, SessionMode::Client);
1307 assert_eq!(ctx.rmw, "");
1308 assert_eq!(ctx.source, ContextSource::Baked);
1309 }
1310
1311 #[test]
1312 fn baked_values_are_carried() {
1313 let ctx = Context::from_baked(Some("tcp/127.0.0.1:7456"), Some("42")).expect("valid bake");
1314 assert_eq!(ctx.locator, "tcp/127.0.0.1:7456");
1315 assert_eq!(ctx.domain_id, 42);
1316 assert_eq!(ctx.source, ContextSource::Baked);
1317 // The config it materialises is the one the macros used to assemble
1318 // by hand: locator, domain, node name, client mode.
1319 let cfg = ctx.config("talker");
1320 assert_eq!(cfg.locator, "tcp/127.0.0.1:7456");
1321 assert_eq!(cfg.domain_id, 42);
1322 assert_eq!(cfg.node_name, "talker");
1323 assert_eq!(cfg.mode, SessionMode::Client);
1324 }
1325
1326 #[test]
1327 fn an_empty_baked_locator_is_absent() {
1328 let ctx = Context::from_baked(Some(""), None).expect("empty is absent, not an error");
1329 assert_eq!(ctx.locator, "");
1330 }
1331
1332 #[test]
1333 fn a_non_numeric_baked_domain_is_refused() {
1334 assert_eq!(
1335 Context::from_baked(None, Some("seven")).map(|c| c.domain_id),
1336 Err(InitError::EnvParseFailed)
1337 );
1338 assert_eq!(
1339 Context::from_baked(None, Some("-1")).map(|c| c.domain_id),
1340 Err(InitError::EnvParseFailed)
1341 );
1342 }
1343
1344 #[test]
1345 fn an_out_of_range_baked_domain_is_refused() {
1346 // The bound `try_resolve_hosted` applies, so an image cannot be built
1347 // onto a domain a hosted process would refuse to boot on.
1348 assert_eq!(
1349 Context::from_baked(None, Some("300")).map(|c| c.domain_id),
1350 Err(InitError::DomainIdOutOfRange)
1351 );
1352 assert_eq!(
1353 Context::from_baked(None, Some("232")).map(|c| c.domain_id),
1354 Ok(DOMAIN_ID_MAX)
1355 );
1356 }
1357
1358 #[test]
1359 fn locator_override_applies_only_when_present_and_non_empty() {
1360 let baked = || Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("1")).unwrap();
1361 assert_eq!(
1362 baked()
1363 .with_locator_override(Some("tcp/127.0.0.1:7456"))
1364 .locator,
1365 "tcp/127.0.0.1:7456"
1366 );
1367 assert_eq!(
1368 baked().with_locator_override(None).locator,
1369 "tcp/10.0.2.2:7447"
1370 );
1371 assert_eq!(
1372 baked().with_locator_override(Some("")).locator,
1373 "tcp/10.0.2.2:7447"
1374 );
1375 // The override touches nothing else.
1376 let ctx = baked().with_locator_override(Some("tcp/127.0.0.1:7456"));
1377 assert_eq!(ctx.domain_id, 1);
1378 assert_eq!(ctx.source, ContextSource::Baked);
1379 }
1380
1381 #[test]
1382 fn baked_session_properties_take_the_locators_place() {
1383 let baked = || Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("1")).unwrap();
1384 // Issue 2051 — a run-time locator, applied after, still wins.
1385 let connect = [nros_node::SessionProperty::new(
1386 c"connect",
1387 c"tcp/10.0.0.9:7499",
1388 )];
1389 assert_eq!(
1390 baked()
1391 .yield_locator_to_session_properties(&connect)
1392 .locator,
1393 ""
1394 );
1395 assert_eq!(
1396 baked().yield_locator_to_session_properties(&[]).locator,
1397 baked().locator
1398 );
1399 assert_eq!(
1400 baked()
1401 .yield_locator_to_session_properties(&connect)
1402 .with_locator_override(Some("tcp/127.0.0.1:7456"))
1403 .locator,
1404 "tcp/127.0.0.1:7456"
1405 );
1406 }
1407
1408 /// phase-467 row `rust:Context::domain_id`. Two claims, and the second is
1409 /// the one worth a test: the accessor answers the field, and it answers it
1410 /// as a `usize` — rclrs's type — so a ported `let d: usize =
1411 /// ctx.domain_id();` compiles. The annotation is the assertion; without it
1412 /// an integer literal would infer whatever the accessor returns and the
1413 /// test would pass against a `u32` too.
1414 #[test]
1415 fn context_domain_id_reads_the_field_as_rclrs_types_it() {
1416 let ctx = Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("7")).unwrap();
1417 let d: usize = ctx.domain_id();
1418 assert_eq!(d, 7);
1419 assert_eq!(ctx.domain_id(), ctx.domain_id as usize);
1420 // The field did not move: `Context` is a plain struct and both
1421 // spellings reach the same byte.
1422 assert_eq!(ctx.domain_id, 7u32);
1423 }
1424
1425 #[test]
1426 fn baked_reads_this_crates_build_environment() {
1427 // The values depend on the build; the SHAPE does not. Whatever was
1428 // baked, the source is `Baked`, and a parse failure would have come
1429 // from a bake this test cannot control — report it rather than hide it.
1430 match Context::baked() {
1431 Ok(ctx) => assert_eq!(ctx.source, ContextSource::Baked),
1432 Err(e) => panic!(
1433 "NROS_DOMAIN_ID was baked into this test build with a value the constructor refuses: {e:?}"
1434 ),
1435 }
1436 }
1437
1438 #[test]
1439 fn init_options_default_overrides_nothing() {
1440 assert_eq!(InitOptions::new(), InitOptions::default());
1441 assert_eq!(InitOptions::new().domain_id(), None);
1442 assert_eq!(
1443 InitOptions::new().with_domain_id(Some(5)).domain_id(),
1444 Some(5)
1445 );
1446 assert_eq!(
1447 InitOptions::new()
1448 .with_domain_id(Some(5))
1449 .with_domain_id(None)
1450 .domain_id(),
1451 None
1452 );
1453 // Saturates rather than truncates: `u32::MAX + 1` must not read as 0.
1454 // Reported back as a `usize`, which is the getter's type since
1455 // phase-467 — the saturation is in the STORED `u32` and the getter
1456 // only widens it, so this is the one place the two widths are both
1457 // visible in one line.
1458 assert_eq!(
1459 InitOptions::new()
1460 .with_domain_id(Some(u32::MAX as usize + 1))
1461 .domain_id(),
1462 Some(u32::MAX as usize)
1463 );
1464 // The in-place spelling is the same operation.
1465 let mut opts = InitOptions::new();
1466 opts.set_domain_id(Some(9));
1467 assert_eq!(opts, InitOptions::new().with_domain_id(Some(9)));
1468 opts.set_domain_id(None);
1469 assert_eq!(opts, InitOptions::new());
1470 }
1471
1472 /// phase-427 W10 — the two variants `create_executor*` can produce say
1473 /// WHICH thing went wrong, in words. `alloc` only: the errors are values
1474 /// and need no RMW to construct, which is the point of testing them here
1475 /// rather than beside a session.
1476 #[test]
1477 fn the_executor_errors_say_what_happened() {
1478 use alloc::string::ToString as _;
1479
1480 let too_small = InitError::BackingTooSmall {
1481 needed: 4096,
1482 given: 8,
1483 }
1484 .to_string();
1485 // Both numbers, because "too small" without them tells a caller
1486 // nothing they can act on.
1487 assert!(too_small.contains("4096"), "{too_small}");
1488 assert!(too_small.contains('8'), "{too_small}");
1489
1490 // The backend's own error survives the wrapping — issue 0465's rule:
1491 // an exhausted table and a missing router must not read alike.
1492 let open_failed = InitError::ExecutorOpenFailed(NodeError::NodeTableFull).to_string();
1493 assert!(open_failed.contains("NodeTableFull"), "{open_failed}");
1494 assert_ne!(
1495 open_failed,
1496 InitError::ExecutorOpenFailed(NodeError::NameTooLong).to_string()
1497 );
1498 }
1499
1500 #[cfg(not(feature = "env"))]
1501 #[test]
1502 fn freestanding_default_from_env_is_the_bake() {
1503 // Without `env` there is no process environment to read; the family
1504 // resolves to the bake, and the override still lands on top of it.
1505 let ctx = Context::default_from_env().expect("bake");
1506 assert_eq!(ctx.source, ContextSource::Baked);
1507 let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(11))).expect("bake");
1508 assert_eq!(ctx.domain_id, 11);
1509 assert_eq!(ctx.source, ContextSource::Baked);
1510 }
1511}