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}