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
//! Process-wide test-environment barrier owned by shell dispatch.
//!
//! `shell_dispatcher.rs` is also compiled directly by integration harnesses,
//! outside the main binary crate. Keeping the barrier under that module makes
//! shell detection self-contained while `crate::test_support` re-exports the
//! same instance to its existing environment-mutating callers in the main crate.
use std::sync::{Mutex, MutexGuard, OnceLock, TryLockError};
use std::thread::ThreadId;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Who currently counts as "inside" the process-wide env lock.
///
/// The owner is the thread holding [`TestEnvLock`]. `adopted` holds helper
/// threads that owner explicitly enrolled with [`join_env_scope`] — see that
/// function for why a worker thread of the current test must not be treated as
/// a foreign reader.
#[derive(Default)]
struct EnvScope {
/// Bumped on every acquisition, so a ticket minted by an earlier test can
/// never enroll a thread into a later test's environment.
generation: u64,
owner: Option<ThreadId>,
adopted: Vec<ThreadId>,
}
fn env_scope() -> &'static Mutex<EnvScope> {
static SCOPE: OnceLock<Mutex<EnvScope>> = OnceLock::new();
SCOPE.get_or_init(|| Mutex::new(EnvScope::default()))
}
fn lock_env_scope() -> MutexGuard<'static, EnvScope> {
match env_scope().lock() {
Ok(scope) => scope,
Err(poisoned) => poisoned.into_inner(),
}
}
fn open_env_scope() {
let mut scope = lock_env_scope();
scope.generation = scope.generation.wrapping_add(1);
scope.owner = Some(std::thread::current().id());
scope.adopted.clear();
}
fn current_thread_owns_contended_env_lock() -> bool {
let scope = lock_env_scope();
let current = std::thread::current().id();
scope.owner == Some(current) || scope.adopted.contains(¤t)
}
/// Proof that the calling thread owns a live [`lock_test_env`] scope, handed to
/// a worker thread so it can join that scope with [`join_env_scope`].
///
/// Returns `None` when the caller is not the owner, so a ticket can never be
/// minted on behalf of a test that did not seal the environment.
#[derive(Clone, Copy, Debug)]
pub(crate) struct EnvScopeTicket {
generation: u64,
}
impl EnvScopeTicket {
/// Which sealed environment this ticket authorizes. Callers that gate real
/// disk writes on a live scope key their bookkeeping by this value, so a
/// straggler from generation N can never be mistaken for work belonging to
/// generation N+1.
pub(crate) fn generation(&self) -> u64 {
self.generation
}
}
/// The generation of the env scope the calling thread is currently inside, as
/// owner or as a [`join_env_scope`]-adopted worker; `None` when the thread is a
/// foreign reader with no sealed environment of its own.
///
/// This is the authorization primitive for anything that must only touch disk
/// on behalf of a test that actually sealed `HOME`. A process-global "writes
/// are enabled" flag cannot distinguish unrelated parallel tests.
pub(crate) fn current_env_scope_generation() -> Option<u64> {
let scope = lock_env_scope();
let current = std::thread::current().id();
if scope.owner == Some(current) || scope.adopted.contains(¤t) {
Some(scope.generation)
} else {
None
}
}
pub(crate) fn env_scope_ticket() -> Option<EnvScopeTicket> {
let scope = lock_env_scope();
(scope.owner == Some(std::thread::current().id())).then_some(EnvScopeTicket {
generation: scope.generation,
})
}
/// Enroll the calling thread in the ticket's env scope for as long as the
/// returned guard lives.
///
/// [`with_test_env_lock`] stops a foreign test from resolving another test's
/// temporary `HOME`. A helper thread doing work for the sealing test must see
/// that same environment without blocking on the mutex its owner holds.
pub(crate) fn join_env_scope(ticket: Option<EnvScopeTicket>) -> Option<EnvScopeMembership> {
let ticket = ticket?;
let mut scope = lock_env_scope();
if scope.owner.is_none() || scope.generation != ticket.generation {
return None;
}
let thread = std::thread::current().id();
if !scope.adopted.contains(&thread) {
scope.adopted.push(thread);
}
Some(EnvScopeMembership {
generation: ticket.generation,
thread,
})
}
pub(crate) struct EnvScopeMembership {
generation: u64,
thread: ThreadId,
}
impl Drop for EnvScopeMembership {
fn drop(&mut self) {
let mut scope = lock_env_scope();
if scope.generation == self.generation {
scope.adopted.retain(|thread| *thread != self.thread);
}
}
}
/// Owned process-wide test-environment lock.
///
/// Clearing the owner before the underlying mutex unlocks keeps re-entrant
/// reader detection exact. Closing the scope also evicts adopted workers, so
/// enrollment cannot outlive the test that granted it.
pub(crate) struct TestEnvLock {
_guard: MutexGuard<'static, ()>,
}
impl Drop for TestEnvLock {
fn drop(&mut self) {
let mut scope = lock_env_scope();
if scope.owner == Some(std::thread::current().id()) {
scope.owner = None;
scope.adopted.clear();
}
}
}
/// Acquire the process-wide env-var mutex.
///
/// If a prior test panicked while holding the lock, recover the guard instead
/// of cascading failures across unrelated tests.
pub(crate) fn lock_test_env() -> TestEnvLock {
let guard = match env_lock().lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
open_env_scope();
TestEnvLock { _guard: guard }
}
/// Read process-global test environment while respecting [`lock_test_env`].
///
/// The owner check makes the barrier re-entrant for a test that reads its own
/// guarded override.
pub(crate) fn with_test_env_lock<T>(read: impl FnOnce() -> T) -> T {
if current_thread_owns_contended_env_lock() {
return read();
}
let _guard = lock_test_env();
read()
}
/// [`with_test_env_lock`] for readers that run inside a `LazyLock`/`OnceLock`
/// initializer: takes the barrier when it is free, and reads *without* it
/// rather than waiting when another thread holds it.
///
/// Blocking is not an option there. The initializer's own lock is held for the
/// duration, so a second thread that already owns the env barrier and then
/// touches the same lazy value deadlocks against this one: A waits for the
/// initializer, B (this thread) waits for A's barrier. Both threads wedge, and
/// libtest has no per-test timeout, so the whole test binary hangs instead of
/// failing — which is how a single wedged run burns an entire CI job.
///
/// The trade is deliberate and small: an uncontended read is still fully
/// serialized, and a contended one reads a variable mid-mutation at worst.
/// Callers must therefore only use this for values whose staleness is
/// tolerable, never to gate a disk write.
pub(crate) fn with_test_env_lock_if_uncontended<T>(read: impl FnOnce() -> T) -> T {
if current_thread_owns_contended_env_lock() {
return read();
}
match env_lock().try_lock() {
Ok(_guard) => read(),
Err(TryLockError::Poisoned(poisoned)) => {
let _guard = poisoned.into_inner();
read()
}
Err(TryLockError::WouldBlock) => read(),
}
}
pub(crate) fn current_thread_holds_test_env_lock() -> bool {
match env_lock().try_lock() {
Ok(guard) => {
drop(guard);
false
}
Err(TryLockError::Poisoned(poisoned)) => {
drop(poisoned.into_inner());
false
}
Err(TryLockError::WouldBlock) => current_thread_owns_contended_env_lock(),
}
}
#[cfg(test)]
mod tests {
use super::{lock_test_env, with_test_env_lock_if_uncontended};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// The lock-order inversion this helper exists to break.
///
/// A reader running inside a `LazyLock`/`OnceLock` initializer must not wait
/// for the env barrier: the thread holding the barrier may itself be waiting
/// on that initializer. Both threads then wedge, and libtest has no per-test
/// timeout, so the entire test binary hangs — reproduced locally as
/// `cargo test -p codewhale-tui --lib -- shell` never returning.
#[test]
fn uncontended_read_returns_while_another_thread_holds_the_barrier() {
let (held_tx, held_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel::<()>();
let holder = std::thread::spawn(move || {
let _guard = lock_test_env();
held_tx.send(()).expect("announce that the barrier is held");
// Outlive the assertion below without depending on timing.
let _ = release_rx.recv_timeout(Duration::from_secs(30));
});
held_rx
.recv_timeout(Duration::from_secs(10))
.expect("holder thread must take the barrier");
let start = Instant::now();
let value = with_test_env_lock_if_uncontended(|| 7);
let elapsed = start.elapsed();
release_tx.send(()).ok();
holder.join().expect("holder thread");
assert_eq!(value, 7, "the read still runs, just unsynchronized");
assert!(
elapsed < Duration::from_secs(1),
"a contended read must not block; waited {elapsed:?}"
);
}
}