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