use std::{
env,
ffi::{OsStr, OsString},
panic,
sync::{TryLockError, mpsc},
thread,
time::{Duration, Instant},
};
use rstest::rstest;
use serial_test::serial;
use super::{
ScopedEnv,
THREAD_STATE,
state::{ENV_LOCK, ThreadState},
};
#[cfg(feature = "cluster-unit-tests")]
use crate::test_support::capture_info_logs;
mod corruption;
mod thread_helpers;
mod validation;
use corruption::{
CorruptionCase,
apply_invalid_scope_exit,
drop_guards_in_order,
drop_guards_out_of_order,
no_corruption,
run_scoped_env_corruption_test,
setup_nested_guards,
setup_single_guard,
};
use thread_helpers::{
ReleaseOnDrop,
RestoreEnv,
ThreadAChannels,
ThreadBChannels,
join_guard_thread,
spawn_inner_guard_thread,
spawn_outer_guard_thread,
};
#[test]
#[serial]
fn recovers_from_poisoned_lock() {
assert!(
panic::catch_unwind(|| {
let _guard =
ScopedEnv::apply(&[(String::from("POISON_TEST"), Some(String::from("one")))]);
panic!("intentional panic to poison the mutex");
})
.is_err()
);
let guard = ScopedEnv::apply(&[(String::from("POISON_TEST"), Some(String::from("two")))]);
assert_eq!(env::var("POISON_TEST").as_deref(), Ok("two"));
drop(guard);
assert!(env::var("POISON_TEST").is_err());
}
#[test]
#[serial]
fn allows_reentrant_scopes() {
let outer = ScopedEnv::apply(&[(String::from("NESTED_TEST"), Some(String::from("outer")))]);
assert_eq!(env::var("NESTED_TEST").as_deref(), Ok("outer"));
{
let inner = ScopedEnv::apply(&[(String::from("NESTED_TEST"), Some(String::from("inner")))]);
assert_eq!(env::var("NESTED_TEST").as_deref(), Ok("inner"));
drop(inner);
}
assert_eq!(env::var("NESTED_TEST").as_deref(), Ok("outer"));
drop(outer);
assert!(env::var("NESTED_TEST").is_err());
}
#[test]
#[serial]
fn keeps_lock_until_last_scope_drops() {
let outer = ScopedEnv::apply(&[(String::from("SCOPE_TEST"), Some(String::from("outer")))]);
let inner = ScopedEnv::apply(&[(String::from("SCOPE_TEST"), Some(String::from("inner")))]);
drop(outer);
assert_eq!(env::var("SCOPE_TEST").as_deref(), Ok("inner"));
assert!(
ENV_LOCK.try_lock().is_err(),
"mutex must remain held by inner guard"
);
let third = ScopedEnv::apply(&[(String::from("SCOPE_TEST"), Some(String::from("third")))]);
assert_eq!(env::var("SCOPE_TEST").as_deref(), Ok("third"));
drop(third);
assert_eq!(env::var("SCOPE_TEST").as_deref(), Ok("inner"));
drop(inner);
let free = ENV_LOCK.try_lock();
assert!(free.is_ok(), "mutex should release after final scope drops");
drop(free);
assert!(env::var("SCOPE_TEST").is_err());
}
fn scenario_failure(message: impl Into<String>) -> Box<dyn std::error::Error> {
Box::<dyn std::error::Error>::from(message.into())
}
#[test]
#[serial]
fn serializes_env_across_threads() -> Result<(), Box<dyn std::error::Error>> {
let key = "THREAD_SCOPE_TEST";
let restore_env = RestoreEnv {
key: String::from(key),
original: env::var_os(key),
};
set_env_var_locked(OsStr::new(key), OsStr::new("pre-existing"));
let (ready_tx, ready_rx) = mpsc::channel();
let (proceed_tx, proceed_rx) = mpsc::channel();
let (start_tx, start_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let (attempt_tx, attempt_rx) = mpsc::channel();
let (acquired_tx, acquired_rx) = mpsc::channel();
let (done_a_tx, done_a_rx) = mpsc::channel();
let (done_b_tx, done_b_rx) = mpsc::channel();
let deadlock_timeout = Duration::from_secs(30);
let thread_a = spawn_outer_guard_thread(
String::from(key),
ThreadAChannels {
proceed_rx,
ready_tx,
release_rx,
done_tx: done_a_tx,
},
);
let thread_b = spawn_inner_guard_thread(
String::from(key),
ThreadBChannels {
start_rx,
attempt_tx,
acquired_tx,
done_tx: done_b_tx,
},
);
ready_rx.recv_timeout(deadlock_timeout)?;
proceed_tx.send(())?;
let release_guard = ReleaseOnDrop {
sender: Some(release_tx),
};
start_tx.send(())?;
attempt_rx.recv_timeout(deadlock_timeout)?;
if acquired_rx.try_recv().is_ok() {
return Err(scenario_failure(
"second thread must block while the outer guard holds the lock",
));
}
drop(release_guard);
let value = acquired_rx.recv_timeout(deadlock_timeout)?;
if value.as_deref() != Some("two") {
return Err(scenario_failure(format!(
"second thread observed {value:?} after release, expected Some(\"two\")"
)));
}
done_a_rx.recv_timeout(deadlock_timeout)?;
done_b_rx.recv_timeout(deadlock_timeout)?;
join_guard_thread(thread_a)?;
join_guard_thread(thread_b)?;
let restored = env::var(key);
if restored.as_deref() != Ok("pre-existing") {
return Err(scenario_failure(format!(
"environment left as {restored:?}, expected the pre-existing value"
)));
}
assert_env_lock_released();
drop(restore_env);
Ok(())
}
#[test]
#[serial]
fn thread_state_recovers_from_invalid_index() {
let key = OsString::from("THREAD_STATE_INVALID_INDEX");
let original = env::var_os(&key);
let mut state = ThreadState::new();
let index = state.enter_scope(vec![(key.clone(), Some(OsString::from("value")))]);
assert_eq!(env::var_os(&key), Some(OsString::from("value")));
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
state.exit_scope(index + 1);
}));
assert!(result.is_ok(), "invalid scope exit should not panic");
assert_eq!(env::var_os(&key), original);
assert_eq!(state.depth(), 0);
assert!(state.is_stack_empty());
assert!(!state.has_lock());
assert_env_lock_released();
}
#[rstest]
#[case::corrupt_exit(CorruptionCase {
test_name: "CORRUPT_EXIT",
setup_guards: setup_single_guard,
corrupt_state: apply_invalid_scope_exit,
drop_guards: drop_guards_in_order,
drop_message: "dropping guard after corruption should not panic",
})]
#[case::invalid_index_nested(CorruptionCase {
test_name: "INVALID_INDEX_NESTED",
setup_guards: setup_nested_guards,
corrupt_state: apply_invalid_scope_exit,
drop_guards: drop_guards_in_order,
drop_message: "dropping guards after invalid scope exit should not panic",
})]
#[case::out_of_order_drop(CorruptionCase {
test_name: "OUT_OF_ORDER_DROP",
setup_guards: setup_nested_guards,
corrupt_state: no_corruption,
drop_guards: drop_guards_out_of_order,
drop_message: "dropping outer guard out of order should not panic",
})]
#[serial]
fn scoped_env_recovers_from_corrupt_exit(#[case] case: CorruptionCase) {
run_scoped_env_corruption_test(case.test_name, |key| {
let original = env::var_os(key);
let guards = (case.setup_guards)(key);
let restored = (case.corrupt_state)();
if restored {
assert_eq!(env::var_os(key), original);
assert_thread_state_reset();
}
let drop_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
(case.drop_guards)(guards);
}));
assert!(drop_result.is_ok(), "{}", case.drop_message);
assert_eq!(env::var_os(key), original);
assert_thread_state_reset();
assert_env_lock_released();
});
}
fn set_env_var_locked(key: &OsStr, value: &OsStr) {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
set_env_var_unlocked(key, value);
}
fn set_env_var_unlocked(key: &OsStr, value: &OsStr) {
unsafe {
env::set_var(key, value);
}
}
fn remove_env_var_unlocked(key: &OsStr) {
unsafe {
env::remove_var(key);
}
}
fn assert_thread_state_reset() {
THREAD_STATE.with(|cell| {
let state = cell.borrow();
assert_eq!(state.depth(), 0);
assert!(state.is_stack_empty());
assert!(!state.has_lock());
});
}
fn assert_env_lock_released() {
let deadline = Instant::now() + Duration::from_secs(1);
loop {
match ENV_LOCK.try_lock() {
Ok(guard) => {
drop(guard);
return;
}
Err(TryLockError::Poisoned(guard)) => {
drop(guard);
ENV_LOCK.clear_poison();
return;
}
Err(TryLockError::WouldBlock) => {}
}
assert!(Instant::now() < deadline, "ENV_LOCK should be released");
thread::yield_now();
}
}
#[cfg(feature = "cluster-unit-tests")]
#[test]
#[serial]
fn logs_application_and_restoration() {
let (logs, ()) = capture_info_logs(|| {
let guard = ScopedEnv::apply(&[
(String::from("OBS_ENV_APPLY"), Some(String::from("one"))),
(String::from("OBS_ENV_CLEAR"), None),
]);
drop(guard);
});
assert!(
logs.iter()
.any(|line| line.contains("applied scoped environment variables")),
"expected application log entry, got {logs:?}"
);
assert!(
logs.iter().any(|line| line.contains("OBS_ENV_APPLY=set")),
"expected set entry, got {logs:?}"
);
assert!(
logs.iter().any(|line| line.contains("OBS_ENV_CLEAR=unset")),
"expected unset entry, got {logs:?}"
);
assert!(
logs.iter()
.any(|line| line.contains("restoring scoped environment variables")),
"expected restoration log, got {logs:?}"
);
}