Skip to main content

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}