Skip to main content

mur_common/
test_env.rs

1//! Serialized, panic-safe access to process-global environment variables.
2//!
3//! Test-only in purpose, compiled always: `mur-agent-runtime`'s tests need it
4//! and a `#[cfg(test)]` item in this crate is invisible to them. It is ~60
5//! lines of dead code in a release binary.
6//!
7//! # Why one lock for every variable
8//!
9//! `setenv(3)` may reallocate the `environ` array, so a concurrent `getenv`
10//! anywhere in the process — including inside libc or a dependency, for a
11//! variable this test has never heard of — can read freed memory. That is why
12//! Rust 2024 made `std::env::set_var` `unsafe`. The hazard is the array, not
13//! the name, so a per-variable lock (which this replaces) gives false comfort:
14//! it orders writers of `MUR_HOME` against each other and does nothing about
15//! the reader of `PATH` two threads over.
16//!
17//! # What wrapping the MutexGuard costs
18//!
19//! `clippy::await_holding_lock` fires on a bare `std::sync::MutexGuard` held
20//! across an `.await`; it does not see one inside a struct, so it is silent
21//! here. The hazard it warns about — a task blocking the thread its holder
22//! needs to resume on — does not arise for `#[tokio::test]`, which gives each
23//! test its own current-thread runtime on its own thread. Holding this guard
24//! across an await in production code would be a different matter, and the
25//! lint would not tell you.
26//!
27//! # Why a guard rather than a save/restore pair
28//!
29//! The pattern this replaces saved the prior value, set the variable, ran the
30//! test, then restored — with the restore *after* the assertions. A failing
31//! assertion panics past it, so the variable outlives the `TempDir` it points
32//! at, and a `std::sync::Mutex` held across that panic is poisoned for every
33//! test after it. One failed assertion became a file of failures that named
34//! the lock instead of the bug. Restoring in `Drop` is what makes the restore
35//! actually run; tolerating poison is what keeps the cascade from starting.
36
37use std::cell::Cell;
38use std::ffi::{OsStr, OsString};
39use std::sync::{Mutex, MutexGuard};
40
41static ENV_LOCK: Mutex<()> = Mutex::new(());
42
43thread_local! {
44    /// How many guards this thread holds. Only the outermost takes the lock.
45    static DEPTH: Cell<usize> = const { Cell::new(0) };
46}
47
48/// Holds the process's environment lock and restores every variable it
49/// touched when dropped — including back to *absent*.
50pub struct EnvGuard {
51    /// `None` when this guard is nested inside another on the same thread —
52    /// the outer one already holds the lock.
53    _lock: Option<MutexGuard<'static, ()>>,
54    saved: Vec<(OsString, Option<OsString>)>,
55}
56
57impl EnvGuard {
58    /// Take the lock without changing anything — for a test that only needs
59    /// to be alone with the environment, or that will `set_var` later.
60    pub fn hold() -> Self {
61        // Re-entrant on purpose. A test that holds a guard and calls a helper
62        // that takes its own is the natural thing to write — `with_test_home`
63        // is exactly that shape — and `std::sync::Mutex` is not re-entrant, so
64        // without this the second acquisition deadlocks the thread. It did:
65        // four `mcp_add` tests hung until CI's timeout killed them.
66        //
67        // Nesting keeps the invariant. One thread is inside the section at a
68        // time, and each guard restores its own variables when its own scope
69        // ends, which is what a reader expects from a scoped guard.
70        let lock = if DEPTH.get() == 0 {
71            // Poison means some earlier test panicked while holding this. That
72            // is a fact about that test, not about this one, and the values it
73            // set were restored by its own `Drop` before the poison was set.
74            Some(ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()))
75        } else {
76            None
77        };
78        DEPTH.set(DEPTH.get() + 1);
79        Self {
80            _lock: lock,
81            saved: Vec::new(),
82        }
83    }
84
85    /// Take the lock and set these variables.
86    pub fn set<K, V>(vars: impl IntoIterator<Item = (K, V)>) -> Self
87    where
88        K: AsRef<OsStr>,
89        V: AsRef<OsStr>,
90    {
91        let mut g = Self::hold();
92        for (k, v) in vars {
93            g.set_var(k, v);
94        }
95        g
96    }
97
98    /// Take the lock and remove these variables.
99    pub fn unset<K: AsRef<OsStr>>(vars: impl IntoIterator<Item = K>) -> Self {
100        let mut g = Self::hold();
101        for k in vars {
102            g.unset_var(k);
103        }
104        g
105    }
106
107    /// Set one variable inside this guard's critical section.
108    pub fn set_var<K: AsRef<OsStr>, V: AsRef<OsStr>>(&mut self, key: K, value: V) -> &mut Self {
109        self.remember(key.as_ref());
110        // SAFETY: `ENV_LOCK` is held, and it is the only lock any environment
111        // mutation in this workspace takes, so no other test thread is in
112        // `setenv`/`getenv` on our behalf. Restored in `Drop`.
113        unsafe { std::env::set_var(key.as_ref(), value.as_ref()) };
114        self
115    }
116
117    /// Remove one variable inside this guard's critical section.
118    pub fn unset_var<K: AsRef<OsStr>>(&mut self, key: K) -> &mut Self {
119        self.remember(key.as_ref());
120        // SAFETY: as `set_var` above.
121        unsafe { std::env::remove_var(key.as_ref()) };
122        self
123    }
124
125    /// Restore this variable when the guard drops, without changing it now.
126    ///
127    /// For a variable the test does not set but the code under test does. Some
128    /// production paths use the environment as a hidden parameter and do not
129    /// put it back — `deep_research::provision` sets `MUR_HOME` for the
130    /// helpers it calls and says so in its own `# Concurrency` note. A test
131    /// calling that leaks the value to every later test in the process, and no
132    /// lock can help: the leak is not a race. Run the suite with
133    /// `--test-threads=1` and it still happens, which is how this was found.
134    pub fn track_var<K: AsRef<OsStr>>(&mut self, key: K) -> &mut Self {
135        self.remember(key.as_ref());
136        self
137    }
138
139    /// Record the value to restore — the value from BEFORE this guard, so a
140    /// variable set twice still ends up where it started.
141    fn remember(&mut self, key: &OsStr) {
142        if self.saved.iter().any(|(k, _)| k == key) {
143            return;
144        }
145        self.saved.push((key.to_os_string(), std::env::var_os(key)));
146    }
147}
148
149impl Drop for EnvGuard {
150    fn drop(&mut self) {
151        DEPTH.set(DEPTH.get().saturating_sub(1));
152        for (key, prior) in self.saved.drain(..) {
153            // SAFETY: the lock is still held — it is dropped after this.
154            unsafe {
155                match prior {
156                    Some(v) => std::env::set_var(&key, v),
157                    None => std::env::remove_var(&key),
158                }
159            }
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    const K: &str = "MUR_TEST_ENV_GUARD";
169
170    #[test]
171    fn a_variable_absent_before_is_absent_after() {
172        {
173            let _g = EnvGuard::set([(K, "x")]);
174            assert_eq!(std::env::var(K).as_deref(), Ok("x"));
175        }
176        assert!(
177            std::env::var_os(K).is_none(),
178            "absent must restore to absent"
179        );
180    }
181
182    #[test]
183    fn setting_twice_restores_to_the_original_not_the_middle() {
184        let k = "MUR_TEST_ENV_GUARD_TWICE";
185        {
186            let mut g = EnvGuard::set([(k, "first")]);
187            g.set_var(k, "second");
188            assert_eq!(std::env::var(k).as_deref(), Ok("second"));
189        }
190        assert!(std::env::var_os(k).is_none());
191    }
192
193    #[test]
194    fn a_panic_still_restores() {
195        // The defect this type exists for. The pattern it replaces restored
196        // AFTER the assertions, so a failing one skipped the restore and left
197        // the variable pointing at a TempDir that was about to be deleted.
198        let k = "MUR_TEST_ENV_GUARD_PANIC";
199        // `r.is_err()` alone is not enough: if the guard itself panicked on
200        // acquisition the variable was never set, and the assertion below
201        // would pass without Drop doing anything. This flag says we reached
202        // the panic with the variable actually set — the first draft of this
203        // test passed under exactly the break it exists to catch.
204        static REACHED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
205        let r = std::panic::catch_unwind(|| {
206            let _g = EnvGuard::set([(k, "leaked?")]);
207            assert_eq!(std::env::var(k).as_deref(), Ok("leaked?"));
208            REACHED.store(true, std::sync::atomic::Ordering::SeqCst);
209            panic!("a failing assertion");
210        });
211        assert!(r.is_err(), "the panic must actually have happened");
212        assert!(
213            REACHED.load(std::sync::atomic::Ordering::SeqCst),
214            "the variable must have been set before the panic, or this proves nothing"
215        );
216        assert!(
217            std::env::var_os(k).is_none(),
218            "Drop runs on unwind; a save/restore pair does not"
219        );
220    }
221
222    #[test]
223    fn a_nested_guard_does_not_deadlock_and_unwinds_inside_out() {
224        // A test holding a guard and calling a helper that takes its own is
225        // the natural shape (`with_test_home`). Against a plain `Mutex` the
226        // inner acquisition hangs the thread — four `mcp_add` tests did
227        // exactly that until CI's timeout killed them, which reads as a
228        // mysteriously slow test rather than a lock bug.
229        let k = "MUR_TEST_ENV_GUARD_NEST";
230        let mut outer = EnvGuard::set([(k, "outer")]);
231        {
232            // Reaching this line at all is most of the assertion.
233            let mut inner = EnvGuard::set([(k, "inner")]);
234            assert_eq!(std::env::var(k).as_deref(), Ok("inner"));
235            inner.set_var(k, "inner-again");
236        }
237        // The inner guard restored what IT found, so the outer value is back
238        // and the outer guard is still in charge.
239        assert_eq!(std::env::var(k).as_deref(), Ok("outer"));
240        outer.set_var(k, "outer-again");
241        drop(outer);
242        assert!(std::env::var_os(k).is_none());
243    }
244
245    #[test]
246    fn a_poisoned_lock_does_not_cascade() {
247        // `.lock().unwrap()` on a Mutex poisoned by any earlier panicking test
248        // fails every test after it, naming the lock instead of the bug.
249        let k = "MUR_TEST_ENV_GUARD_POISON";
250        let _ = std::panic::catch_unwind(|| {
251            let _g = EnvGuard::hold();
252            panic!("poison the lock");
253        });
254        let _g = EnvGuard::set([(k, "still works")]);
255        assert_eq!(std::env::var(k).as_deref(), Ok("still works"));
256    }
257
258    #[test]
259    fn unset_restores_a_value_that_was_there() {
260        let k = "MUR_TEST_ENV_GUARD_UNSET";
261        // SAFETY: single-threaded setup for this test's own fixture, and the
262        // guard below takes the lock before anything else touches it.
263        unsafe { std::env::set_var(k, "original") };
264        {
265            let _g = EnvGuard::unset([k]);
266            assert!(std::env::var_os(k).is_none());
267        }
268        assert_eq!(std::env::var(k).as_deref(), Ok("original"));
269        unsafe { std::env::remove_var(k) };
270    }
271}
272
273/// Every environment mutation in the converted crates goes through [`EnvGuard`].
274///
275/// A ratchet. Every crate in `GUARDED` is converted and cannot regress; a
276/// crate absent from that list is not covered and this test says nothing
277/// about it. Both lists matter — an earlier version scanned only `src`, so it
278/// passed while 73 mutations sat under `tests/`, and "converted" was true
279/// only of the directory it happened to look at.
280///
281/// It lives in one place rather than one copy per crate because a
282/// `#[cfg(test)]` item here is NOT compiled into a crate that depends on
283/// `mur-common` — per-crate copies would each silently cover only themselves.
284///
285/// Exemptions are named individually with a reason of one kind: "runs before
286/// any thread exists". Not "uniquely named variable" (the hazard is the
287/// `environ` array, not the name) and not "nextest isolates tests" (true of
288/// CI, false of the `cargo test` that CLAUDE.md documents) — those were the
289/// two justifications this replaced.
290#[cfg(test)]
291#[test]
292fn converted_crates_never_mutate_the_environment_directly() {
293    const GUARDED: &[&str] = &[
294        "mur-common",
295        "mur-agent-runtime",
296        "mur-core",
297        "mur-research-gateway",
298        "mur-gui-core",
299        "mur-mcp-server",
300        "mur-daemon",
301    ];
302    /// Scanned in each guarded crate. `src` alone was the gap that hid 73
303    /// sites under `tests/` from the first two passes: the ratchet passed,
304    /// and "this crate is converted" was true only of the part it looked at.
305    const DIRS: &[&str] = &["src", "tests", "benches", "examples"];
306    /// Mutation that happens before any thread could observe it.
307    const ALLOWED: &[(&str, &str)] = &[
308        (
309            "mur-common/src/test_env.rs",
310            "the guard's own implementation, which holds the lock",
311        ),
312        (
313            "mur-agent-runtime/src/supervisor.rs",
314            "argv0 name stash at startup, before tokio spawns",
315        ),
316        (
317            "mur-core/src/cmd/deep_research/ask.rs",
318            "run id published to the loop this single-shot CLI spawns",
319        ),
320        (
321            "mur-core/src/cmd/deep_research/provision.rs",
322            "MUR_HOME as a hidden parameter to cmd_create/cmd_mcp_add — the \
323             function's own `# Concurrency` note calls it CLI-only and NOT \
324             concurrency-safe, and carries a TODO to parameterize those \
325             helpers instead. Weaker than the others: not 'before threads \
326             exist', only 'no thread does this today'",
327        ),
328        (
329            "mur-core/src/cmd/deep_research/setup.rs",
330            "same hidden-parameter pattern as provision.rs, same TODO",
331        ),
332    ];
333    let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
334        .parent()
335        .expect("crate dir has a parent")
336        .to_path_buf();
337    let mut offenders = Vec::new();
338    for c in GUARDED {
339        assert!(
340            workspace.join(c).join("src").is_dir(),
341            "guarded crate {c} not found — the list is stale"
342        );
343    }
344    let mut stack: Vec<std::path::PathBuf> = Vec::new();
345    for c in GUARDED {
346        for d in DIRS {
347            let dir = workspace.join(c).join(d);
348            if dir.is_dir() {
349                stack.push(dir);
350            }
351        }
352    }
353    while let Some(d) = stack.pop() {
354        let Ok(entries) = std::fs::read_dir(&d) else {
355            continue;
356        };
357        for e in entries.flatten() {
358            let path = e.path();
359            if path.is_dir() {
360                stack.push(path);
361                continue;
362            }
363            if path.extension().is_none_or(|x| x != "rs") {
364                continue;
365            }
366            let rel = path
367                .strip_prefix(&workspace)
368                .unwrap_or(&path)
369                .to_string_lossy()
370                .replace('\\', "/");
371            if ALLOWED.iter().any(|(f, _)| rel == *f) {
372                continue;
373            }
374            let Ok(body) = std::fs::read_to_string(&path) else {
375                continue;
376            };
377            for (i, line) in body.lines().enumerate() {
378                // A comment that mentions `env::set_var` is prose, not a
379                // mutation — `monitor/adapters/github_actions.rs` explains
380                // why its variable is set and would otherwise be reported.
381                if line.trim_start().starts_with("//") {
382                    continue;
383                }
384                if line.contains("env::set_var") || line.contains("env::remove_var") {
385                    offenders.push(format!("{rel}:{}", i + 1));
386                }
387            }
388        }
389    }
390    assert!(
391        offenders.is_empty(),
392        "use mur_common::test_env::EnvGuard — it serializes the mutation and \
393         restores it on unwind, which a set/restore pair does not: {offenders:?}"
394    );
395}