nros/env.rs
1//! The hosted edge of configuration: the process environment.
2//!
3//! issue 0687 / phase-359 W10. Every env var nano-ros honours is read HERE and
4//! nowhere else. The core (`nros-node`) takes values — `ExecutorConfig::
5//! resolve_with` accepts an [`EnvRung`](nros_node::EnvRung) of already-resolved fields — so it
6//! needs no `std`, no `env` capability, and no stub on the five ports that have
7//! no environment to read.
8//!
9//! That is the difference between this and the ABI ports W10 made for the
10//! clock, sleep, tasks and the log sink: those name capabilities every RTOS
11//! HAS. A process environment is not one. Modelling it as an ABI entry would
12//! have put `return 0` in five ports and permanent surface in the header, for a
13//! facility that exists on one platform family.
14//!
15//! What lives here:
16//!
17//! * [`ExecutorConfigEnvExt::from_env`] — the constructor that used to be an
18//! inherent method on `ExecutorConfig`. Bring the trait into scope (the
19//! prelude has it) and the call site spelling is unchanged.
20//! * [`resolve_hosted`](crate::env::resolve_hosted) / [`try_resolve_hosted`](crate::env::try_resolve_hosted) — RFC-0045 precedence model A
21//! with the environment rung on top. These replace the old
22//! `ExecutorConfig::resolve(baked, hosted_env: bool)`, whose flag was a
23//! compile-time constant at every call site in the tree: `true` at exactly
24//! one board plus the two FFI entries, `false` everywhere else.
25//! * [`rmw_selector`] — the one `$NROS_RMW` reader (issue 0687's first half).
26
27use alloc::{
28 boxed::Box,
29 string::{String, ToString},
30};
31use nros_node::{BootConfig, BootConfigError, EnvRung, ExecutorConfig, RMW_SELECTOR_CAP};
32use nros_rmw::SessionMode;
33
34/// A frozen snapshot of the environment, and the backing store for the
35/// `&'static str` fields a resolved [`ExecutorConfig`] hands out.
36struct EnvCache {
37 locator: String,
38 mode: SessionMode,
39 /// RFC-0045 model A — `NROS_NODE_NAME` env rung (issue #206 parity).
40 node_name: String,
41 /// issue 0687 — `$NROS_RMW`, through [`rmw_selector`] so the snapshot and
42 /// the live reader cannot disagree about what "unset" means.
43 rmw: Option<String>,
44}
45
46// `std::sync::OnceLock`, and it stays that way deliberately. The portable
47// `nros_rmw::sync::Mutex` would cost a `spin` edge to remove a `std::` path
48// from a module whose next line calls `std::env::var` — this whole file is
49// compiled only under `env`, which REQUIRES `std` because a process
50// environment is a `std` facility. Porting it would move the census number
51// without changing what the build links, which is the definition of gaming the
52// ratchet. (`metadata_mode`'s mutex was the opposite case and did move: there
53// the lock was the ONLY std thing, so porting it made the capability
54// `alloc`-only.)
55static ENV_CACHE: std::sync::OnceLock<EnvCache> = std::sync::OnceLock::new();
56
57/// The process-global env cache — and why tests do not share it.
58///
59/// Issue 0607 — resolution reads env PRESENCE live but env VALUES from here,
60/// and a `OnceLock` freezes at whichever caller touched it first. Under
61/// `cargo test` every unit test shares one process, so a later test's
62/// `EnvGuard` moves what `std::env::var` reports while the cached VALUE stays
63/// behind. The two then disagree:
64///
65/// ```text
66/// assertion `left == right` failed
67/// left: ""
68/// right: "tcp/env:7447"
69/// ```
70///
71/// It failed ~1 run in 5, never single-threaded, and a mutex cannot help: a
72/// mutex serialises MUTATION, and this is a stale READ of a cache already
73/// populated. Two tests had been weakened to tolerate it.
74///
75/// So tests rebuild per call — live env is the only coherent answer when the
76/// env is what they are varying. Production keeps the `OnceLock`: nothing there
77/// mutates the environment, so freezing it once is both correct and the point.
78fn env_cache() -> &'static EnvCache {
79 fn build() -> EnvCache {
80 // Prefer NROS_LOCATOR / NROS_SESSION_MODE; accept legacy ZENOH_*
81 // names with a deprecation warning.
82 let locator = std::env::var("NROS_LOCATOR")
83 .or_else(|_| {
84 std::env::var("ZENOH_LOCATOR").inspect(|_| {
85 nros_log::nros_warn!(
86 nros_log::get_logger("nros"),
87 "ZENOH_LOCATOR is deprecated; use NROS_LOCATOR instead"
88 );
89 })
90 })
91 // Issue 0330 — unset env leaves the locator EMPTY (= absent); the
92 // active backend substitutes its own default.
93 .unwrap_or_default();
94 let mode_str = std::env::var("NROS_SESSION_MODE")
95 .or_else(|_| {
96 std::env::var("ZENOH_MODE").inspect(|_| {
97 nros_log::nros_warn!(
98 nros_log::get_logger("nros"),
99 "ZENOH_MODE is deprecated; use NROS_SESSION_MODE instead"
100 );
101 })
102 })
103 .ok();
104 let mode = match mode_str.as_deref() {
105 Some("peer") => SessionMode::Peer,
106 _ => SessionMode::Client,
107 };
108 let node_name = std::env::var("NROS_NODE_NAME").unwrap_or_default();
109 EnvCache {
110 locator,
111 mode,
112 node_name,
113 rmw: rmw_selector().map(|s| s.as_str().to_string()),
114 }
115 }
116
117 if cfg!(test) {
118 Box::leak(Box::new(build()))
119 } else {
120 ENV_CACHE.get_or_init(build)
121 }
122}
123
124/// **The** answer to "which RMW backend did the user select" — one reader, one
125/// semantic, for every consumer.
126///
127/// phase-359 W10 / issue 0687. This variable had FOUR readers with THREE
128/// semantics: `Executor::open` read it as raw OS bytes, `nros`'s
129/// `open_session` as a UTF-8 string filtered for empty, `nros-c`'s entry as a
130/// string passed through EVEN WHEN EMPTY, and `nros::init` as a string with an
131/// `RMW_IMPLEMENTATION` fallback the other three did not have. "Which backend
132/// did the user ask for" had four answers in one process.
133///
134/// Two decisions are baked in, and both are deliberate:
135///
136/// * **`$NROS_RMW` only.** `$RMW_IMPLEMENTATION` is NOT folded in, though it
137/// was tempting and one reader did it. The two carry different vocabularies:
138/// this selector is matched against the cffi registry's canonical names
139/// (`zenoh`, `dds`, `cyclonedds`), while `RMW_IMPLEMENTATION` holds ROS names
140/// (`rmw_cyclonedds_cpp`). Feeding a ROS name to `resolve_backend` yields
141/// `Unknown` — an ERROR — where today it is ignored and the unique-backend
142/// path runs. Unifying them without a mapping would convert "ignored" into
143/// "fails to start". [`crate::init`](fn@crate::init) keeps its fallback for the `Context.rmw`
144/// HINT, which is a different quantity.
145/// * **Empty or non-UTF-8 means unset.** A name that is not UTF-8 cannot match
146/// a registry entry, so treating it as absent is what the caller wants; the
147/// old raw-bytes reader would have reported `Unknown` instead.
148///
149/// The return is `heapless::String` rather than `String` because
150/// `RMW_SELECTOR_CAP` is a real bound, not a guess: it is the capacity of
151/// `Executor::primary_rmw_name`, so a longer value cannot name a registry slot
152/// and is reported as unset rather than truncated into a different backend's
153/// name.
154pub fn rmw_selector() -> Option<nros_core::heapless::String<RMW_SELECTOR_CAP>> {
155 let raw = std::env::var_os("NROS_RMW")?;
156 let s = raw.to_str()?;
157 if s.is_empty() {
158 return None;
159 }
160 nros_core::heapless::String::try_from(s).ok()
161}
162
163/// The environment as an [`EnvRung`], for [`ExecutorConfig::resolve_with`].
164///
165/// Presence is read LIVE (so a test that sets a var sees its effect) while
166/// values come from the frozen [`env_cache`] — that split is what gives the
167/// resolved config `&'static str` fields without leaking per call.
168fn env_rung() -> Result<EnvRung<'static>, BootConfigError> {
169 let cache = env_cache();
170
171 let locator_present =
172 std::env::var("NROS_LOCATOR").is_ok() || std::env::var("ZENOH_LOCATOR").is_ok();
173 let domain_id = match std::env::var("ROS_DOMAIN_ID") {
174 Ok(s) if !s.is_empty() => {
175 // #206 — malformed is an ERROR, not a silent skip. The parse lives
176 // here rather than in the core because only this side has the text.
177 Some(
178 s.trim()
179 .parse::<u32>()
180 .map_err(|_| BootConfigError::DomainIdParse)?,
181 )
182 }
183 _ => None,
184 };
185 let node_name_present = std::env::var("NROS_NODE_NAME")
186 .map(|s| !s.is_empty())
187 .unwrap_or(false);
188
189 Ok(EnvRung {
190 locator: locator_present.then_some(cache.locator.as_str()),
191 domain_id,
192 // Session mode has no baked rung to fall through to, so the cache's
193 // default (`Client`) is always the answer — it is stated rather than
194 // conditional for exactly that reason.
195 mode: Some(cache.mode),
196 node_name: node_name_present.then_some(cache.node_name.as_str()),
197 rmw: cache.rmw.as_deref(),
198 })
199}
200
201/// RFC-0045 precedence model A with the environment on top:
202/// `env (var set) > baked > compiled default`.
203///
204/// Fails loud on invalid identity input (repo rule: a bad domain id at boot is
205/// a configuration error, never a silent domain-0 node). FFI shims that need a
206/// return code call [`try_resolve_hosted`].
207pub fn resolve_hosted<'a>(baked: BootConfig<'a>) -> ExecutorConfig<'a> {
208 match try_resolve_hosted(baked) {
209 Ok(cfg) => cfg,
210 Err(e) => panic!("nros boot-config resolution failed: {e}"),
211 }
212}
213
214/// Fallible [`resolve_hosted`] — returns [`BootConfigError`] instead of
215/// panicking, so the C / C++ FFI shims can surface a return code.
216///
217/// - `ROS_DOMAIN_ID` set but non-numeric → `DomainIdParse` (the pre-#206 C++
218/// header silently collapsed this to domain 0; the resolver silently ignored
219/// it — both were wrong).
220/// - any resolved domain id > `DOMAIN_ID_MAX` → `DomainIdRange`, INCLUDING a
221/// baked one (the DDS backend would only fail later).
222pub fn try_resolve_hosted<'a>(
223 baked: BootConfig<'a>,
224) -> Result<ExecutorConfig<'a>, BootConfigError> {
225 ExecutorConfig::try_resolve_with(baked, Some(env_rung()?))
226}
227
228/// `ExecutorConfig::from_env()`, as an extension trait.
229///
230/// issue 0687 — this was an inherent method on `ExecutorConfig`, which is
231/// defined in `nros-node`; an inherent method cannot be moved to another crate
232/// and keep its spelling, and the spelling is what ~26 call sites (most of them
233/// user-facing native examples) are written against. A trait keeps
234/// `ExecutorConfig::from_env()` working wherever it is in scope — the
235/// [`prelude`](crate::prelude) carries it, so a consumer that already writes
236/// `use nros::prelude::*` changes nothing at all.
237pub trait ExecutorConfigEnvExt {
238 /// Create a configuration from environment variables.
239 ///
240 /// Reads:
241 /// - `NROS_LOCATOR` — Middleware locator. Unset ⇒ empty (issue 0330: the
242 /// active RMW backend applies its own default; e.g. zenoh dials
243 /// `nros_rmw_zenoh::DEFAULT_LOCATOR`). Legacy name `ZENOH_LOCATOR` is
244 /// accepted with a deprecation warning.
245 /// - `ROS_DOMAIN_ID` — ROS 2 domain ID (default: `0`).
246 /// - `NROS_SESSION_MODE` — `"client"` or `"peer"` (default: `"client"`).
247 /// Legacy name `ZENOH_MODE` is accepted with a deprecation warning.
248 /// - `NROS_RMW` — the backend selector, through [`rmw_selector`]. issue
249 /// 0687: `Executor::open` used to read this itself; it now takes
250 /// `ExecutorConfig::rmw`, so a config built here still selects the
251 /// backend the user named.
252 ///
253 /// String values are cached in a process-global `OnceLock` on the first
254 /// call and reused for the process lifetime — repeated calls do NOT
255 /// accrete memory, and the returned `&'static str` fields point into that
256 /// cache. Presence and the domain id are read live, so a caller that
257 /// changes the environment between calls sees the change.
258 ///
259 /// **Panics** on a malformed or out-of-range `$ROS_DOMAIN_ID`, like
260 /// [`resolve_hosted`] and the C / C++ entries. A boot identity that cannot
261 /// be resolved is a configuration error; the alternative is a node silently
262 /// running on domain 0, which is what issue #206 removed everywhere else.
263 fn from_env() -> ExecutorConfig<'static>;
264}
265
266impl ExecutorConfigEnvExt for ExecutorConfig<'static> {
267 fn from_env() -> ExecutorConfig<'static> {
268 // issue 0687 — `from_env` IS `resolve_hosted` with nothing baked, and
269 // saying so in code rather than in a test is the point. The two used to
270 // be parallel implementations pinned together by an assertion
271 // (`noop_resolve_matches_from_env`), and they had already drifted where
272 // the assertion did not look: this one read `$ROS_DOMAIN_ID` through a
273 // second, silent parse that turned a malformed or out-of-range value
274 // into domain 0. Now a bad domain fails loud here exactly as it does
275 // for `resolve`, `nros-c` and `nros-cpp` — the #206 rule, finally
276 // uniform across all four.
277 //
278 // The one field that does NOT come from the rung is `node_name`:
279 // `from_env` has never honoured `$NROS_NODE_NAME` (its doc lists what
280 // it reads), and callers chain `.node_name(..)` immediately. Changing
281 // that is a decision, not a cleanup.
282 ExecutorConfig {
283 node_name: "node",
284 ..resolve_hosted(BootConfig::default())
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::sync::{Mutex, OnceLock};
293
294 /// Process-wide mutex that serialises all env-touching tests.
295 ///
296 /// `cargo test` runs `#[test]`s in parallel within a single binary by
297 /// default. Tests that mutate `NROS_LOCATOR` / `ROS_DOMAIN_ID` must hold
298 /// this lock for the duration to avoid races with each other. (`cargo
299 /// nextest` runs each test in its own process so the lock is always
300 /// uncontended, but taking it is still correct.)
301 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
302 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
303 // Poison-TOLERANT, deliberately: three tests here assert that a bad
304 // `$ROS_DOMAIN_ID` panics, and a panic while this guard is alive
305 // poisons the mutex — which would then fail every LATER test in the
306 // module for a reason that has nothing to do with what it asserts
307 // (measured: 3 intended failures became 8). The data behind the lock is
308 // `()`; there is no invariant a panic could have broken.
309 LOCK.get_or_init(|| Mutex::new(()))
310 .lock()
311 .unwrap_or_else(|e| e.into_inner())
312 }
313
314 /// RAII guard that saves and restores a single env var.
315 struct EnvGuard {
316 key: &'static str,
317 prev: Option<std::ffi::OsString>,
318 }
319
320 impl EnvGuard {
321 fn set(key: &'static str, value: &str) -> Self {
322 let prev = std::env::var_os(key);
323 // SAFETY: serialised via env_lock().
324 unsafe { std::env::set_var(key, value) };
325 Self { key, prev }
326 }
327
328 fn unset(key: &'static str) -> Self {
329 let prev = std::env::var_os(key);
330 // SAFETY: serialised via env_lock().
331 unsafe { std::env::remove_var(key) };
332 Self { key, prev }
333 }
334 }
335
336 impl Drop for EnvGuard {
337 fn drop(&mut self) {
338 // SAFETY: serialised via env_lock().
339 unsafe {
340 match &self.prev {
341 Some(v) => std::env::set_var(self.key, v),
342 None => std::env::remove_var(self.key),
343 }
344 }
345 }
346 }
347
348 /// `resolve_hosted(BootConfig::default())` must be field-for-field
349 /// identical to `from_env()`, regardless of what env vars are set.
350 #[test]
351 fn noop_resolve_matches_from_env() {
352 let _g = env_lock();
353
354 let resolved = resolve_hosted(BootConfig::default());
355 let env_cfg = ExecutorConfig::from_env();
356
357 assert_eq!(resolved.locator, env_cfg.locator);
358 assert_eq!(resolved.mode, env_cfg.mode);
359 assert_eq!(resolved.domain_id, env_cfg.domain_id);
360 assert_eq!(resolved.node_name, env_cfg.node_name);
361 assert_eq!(resolved.namespace, env_cfg.namespace);
362 assert_eq!(resolved.rmw, env_cfg.rmw);
363 }
364
365 #[test]
366 fn env_overrides_baked_on_hosted() {
367 let _g = env_lock();
368 let _e = EnvGuard::set("NROS_LOCATOR", "tcp/env:7447");
369
370 let baked = BootConfig {
371 locator: Some("tcp/baked:9999"),
372 ..Default::default()
373 };
374 let resolved = resolve_hosted(baked);
375
376 // Issue 0607 — the exact env string is observable: tests read live env
377 // rather than a process-global cache that whichever test ran first had
378 // already frozen. This was an `assert_ne!` against the baked value for
379 // exactly that reason.
380 assert_eq!(
381 resolved.locator, "tcp/env:7447",
382 "env locator must win over baked, with its own value"
383 );
384 }
385
386 #[test]
387 fn baked_used_when_env_unset_on_hosted() {
388 let _g = env_lock();
389 let _e1 = EnvGuard::unset("NROS_LOCATOR");
390 let _e2 = EnvGuard::unset("ZENOH_LOCATOR");
391
392 let baked = BootConfig {
393 locator: Some("tcp/baked-only:8888"),
394 ..Default::default()
395 };
396 let resolved = resolve_hosted(baked);
397
398 assert_eq!(
399 resolved.locator, "tcp/baked-only:8888",
400 "baked locator must be used when env var is absent"
401 );
402 }
403
404 #[test]
405 fn per_field_independence_baked_name_env_locator() {
406 let _g = env_lock();
407 let _e = EnvGuard::set("NROS_LOCATOR", "tcp/env:7447");
408 let _n = EnvGuard::unset("NROS_NODE_NAME");
409
410 let baked = BootConfig {
411 node_name: Some("my_talker"),
412 ..Default::default()
413 };
414 let resolved = resolve_hosted(baked);
415
416 assert_eq!(resolved.locator, "tcp/env:7447");
417 assert_eq!(
418 resolved.node_name, "my_talker",
419 "baked node_name must apply even when locator comes from env"
420 );
421 }
422
423 #[test]
424 fn try_resolve_malformed_domain_env_errors() {
425 let _l = env_lock();
426 let _g = EnvGuard::set("ROS_DOMAIN_ID", "not-a-number");
427 let err = match try_resolve_hosted(BootConfig::default()) {
428 Err(e) => e,
429 Ok(_) => panic!("expected DomainIdParse error"),
430 };
431 assert_eq!(err, BootConfigError::DomainIdParse);
432 }
433
434 #[test]
435 fn try_resolve_domain_env_over_max_errors() {
436 let _l = env_lock();
437 let _g = EnvGuard::set("ROS_DOMAIN_ID", "233");
438 let err = match try_resolve_hosted(BootConfig::default()) {
439 Err(e) => e,
440 Ok(_) => panic!("expected DomainIdRange error"),
441 };
442 assert_eq!(err, BootConfigError::DomainIdRange);
443 }
444
445 #[test]
446 fn try_resolve_node_name_env_rung() {
447 let _l = env_lock();
448 let _g = EnvGuard::set("NROS_NODE_NAME", "env_node");
449 let baked = BootConfig {
450 node_name: Some("baked_node"),
451 ..BootConfig::default()
452 };
453 let cfg = match try_resolve_hosted(baked) {
454 Ok(c) => c,
455 Err(e) => panic!("resolve failed: {e}"),
456 };
457 assert_eq!(
458 cfg.node_name, "env_node",
459 "env rung must override baked with its own value"
460 );
461 }
462
463 /// issue 0687 — the selector reaches the config, which is how
464 /// `Executor::open` still honours `$NROS_RMW` now that it does not read the
465 /// environment itself. Both halves are asserted: a set value arrives, and
466 /// an EMPTY value means unset rather than "a backend named the empty
467 /// string" (which is what `nros-c` used to pass through).
468 #[test]
469 fn selector_reaches_the_config() {
470 let _l = env_lock();
471
472 let _g = EnvGuard::set("NROS_RMW", "cyclonedds");
473 assert_eq!(rmw_selector().as_deref(), Some("cyclonedds"));
474 assert_eq!(
475 ExecutorConfig::from_env().rmw,
476 Some("cyclonedds"),
477 "from_env must carry the selector"
478 );
479 assert_eq!(
480 resolve_hosted(BootConfig::default()).rmw,
481 Some("cyclonedds")
482 );
483
484 let _g = EnvGuard::set("NROS_RMW", "");
485 assert_eq!(rmw_selector(), None, "empty means unset");
486 assert_eq!(ExecutorConfig::from_env().rmw, None);
487 }
488
489 /// issue 0687 — `from_env` and `resolve_hosted(default)` are the same
490 /// resolution, and this asserts the fields the type does not force.
491 #[test]
492 fn from_env_is_resolve_hosted_with_nothing_baked() {
493 let _l = env_lock();
494 let _g = EnvGuard::set("NROS_LOCATOR", "tcp/agree:7447");
495 let _d = EnvGuard::set("ROS_DOMAIN_ID", "42");
496
497 let a = ExecutorConfig::from_env();
498 let b = resolve_hosted(BootConfig::default());
499 assert_eq!(a.locator, b.locator);
500 assert_eq!(a.domain_id, b.domain_id);
501 assert_eq!(a.mode, b.mode);
502 assert_eq!(a.rmw, b.rmw);
503 assert_eq!(a.namespace, b.namespace);
504 // The one deliberate divergence: `from_env` does not take the node name
505 // from the environment, and never has.
506 assert_eq!(a.node_name, "node");
507 }
508
509 /// A malformed `$ROS_DOMAIN_ID` must fail LOUD here, as it does for
510 /// `resolve_hosted` and the C / C++ entries. It used to resolve to domain 0
511 /// silently — the #206 defect, surviving on this one path.
512 #[test]
513 #[should_panic(expected = "boot-config resolution failed")]
514 fn from_env_rejects_a_malformed_domain() {
515 let _l = env_lock();
516 let _g = EnvGuard::set("ROS_DOMAIN_ID", "not-a-number");
517 let _ = ExecutorConfig::from_env();
518 }
519
520 /// …and an out-of-range one, which the same path used to coerce to 0.
521 #[test]
522 #[should_panic(expected = "boot-config resolution failed")]
523 fn from_env_rejects_an_out_of_range_domain() {
524 let _l = env_lock();
525 let _g = EnvGuard::set("ROS_DOMAIN_ID", "300");
526 let _ = ExecutorConfig::from_env();
527 }
528
529 /// A selector longer than the executor's identity capacity cannot name a
530 /// registry slot, so it is unset rather than truncated into some other
531 /// backend's name.
532 #[test]
533 fn overlong_selector_is_unset_not_truncated() {
534 let _l = env_lock();
535 let long = "x".repeat(RMW_SELECTOR_CAP + 1);
536 let _g = EnvGuard::set("NROS_RMW", &long);
537 assert_eq!(rmw_selector(), None);
538 }
539}