diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 406d3a6ac..f3309413e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,23 +39,16 @@ jobs: # regressions are still caught before release, just not on every PR push. os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest", "windows-latest"]') || fromJSON('["ubuntu-latest", "macos-latest", "windows-latest"]') }} runs-on: ${{ matrix.os }} - # --test-threads=1 (env-race legacy, v3.3.5) serializes ~70 binaries that - # now include heavy property/fuzz/benchmark suites (#291, #493, #551) — - # well beyond 90 min on hosted runners. Parallelization is tracked - # separately; until then the gate gets the time it actually needs. - # - # Tried switching to cargo-nextest (rust/.config/nextest.toml already - # exists and documents the same env-race for 3 known binaries via a - # `serial-state` test-group). Reverted: nextest's default parallelism - # (test-threads = -4) surfaced several more tests with hidden wall-clock - # timing assumptions (e.g. core::cache::tests::hebbian_eviction_bonus_is_wired - # depends on two calls landing in the same real-time 500ms window) that a - # `max-threads=1` test-group does NOT fix — that only serializes tests - # *within* the group against each other, it doesn't protect them from - # being scheduling-delayed by the many *other* tests still running in - # parallel elsewhere on the same runner. Revisit once those tests are - # made robust to scheduling jitter (or the burst window is made - # injectable for tests) rather than papering over it with test-groups. + # Tests run with libtest's default thread parallelism. The old + # `--test-threads=1` (env-race legacy, v3.3.5) serialized ~70 binaries that + # now include heavy property/fuzz/benchmark suites (#291, #493, #551) and + # dominated the wall clock. What made the flag necessary is fixed at the + # source instead: every env-mutating test serializes on the reentrant + # `data_dir::test_env_lock()`, and the one test with a real-time window + # (`hebbian_eviction_bonus_is_wired`) takes an injected window rather than + # depending on scheduling. cargo-nextest was tried first (#1206) and + # reverted — 33-80% slower here, since its process-per-test model re-pays + # this crate's heavy static-link cost for every test. timeout-minutes: 180 env: # ~50 integration-test binaries each statically link the full lib @@ -145,26 +138,31 @@ jobs: if [[ "${{ runner.os }}" == "Windows" ]]; then JEMALLOC_OVERRIDE="${{ steps.jemalloc.outputs.jemalloc-override }}" \ CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-L ${{ steps.jemalloc.outputs.dummy-dir }}" \ - "$CARGO" test --target x86_64-pc-windows-gnu --all-features -- --test-threads=1 + "$CARGO" test --target x86_64-pc-windows-gnu --all-features elif [[ "${{ runner.os }}" == "Linux" ]]; then # `mold -run` redirects child linker invocations to mold without # touching RUSTFLAGS (which is pinned to `-Dwarnings` job-wide and # would override any .cargo/config.toml rustflags). - mold -run "$CARGO" test --all-features -- --test-threads=1 + mold -run "$CARGO" test --all-features else - "$CARGO" test --all-features -- --test-threads=1 + "$CARGO" test --all-features fi - # #581: lock in the parallel BM25 (incremental) rebuild win against silent - # regression. Timing is only meaningful on a quiet, single-threaded runner, - # so the gate is env-opt-in (no-op in the matrix above) and runs once here. - - name: BM25 build-time regression gate (#581) + # Wall-clock gates: #581's parallel BM25 rebuild win, the context-kernel + # micro-benchmarks and the performance-stress suite. Timing is only + # meaningful on a quiet, single-threaded runner, so every one of them is + # env-opt-in (no-op in the matrix above, which runs multi-threaded) and + # they run serialized here instead. + - name: Wall-clock regression gates (#581) if: runner.os == 'Linux' working-directory: rust shell: bash env: LEAN_CTX_PERF_GATE: "1" - run: cargo test --all-features --lib -- --test-threads=1 parallel_incremental_rebuild_perf_gate --nocapture + run: | + cargo test --all-features --lib -- --test-threads=1 parallel_incremental_rebuild_perf_gate --nocapture + cargo test --all-features --lib -- --test-threads=1 context_kernel::perf_benchmark + cargo test --all-features --test main -- --test-threads=1 performance_stress clippy: name: Clippy diff --git a/rust/LOCK_ORDERING.md b/rust/LOCK_ORDERING.md index 47c3d9747..1b7b8b76e 100644 --- a/rust/LOCK_ORDERING.md +++ b/rust/LOCK_ORDERING.md @@ -101,12 +101,12 @@ All `std::sync::Mutex` unless noted otherwise. | # | Lock | File | Purpose | |---|------|------|---------| -| E1 | `ENV_LOCK` | `dashboard/mod.rs:537` | Serialize env-var access in dashboard tests | | E2 | `ENV_LOCK` | `core/dense_backend.rs:412` | Serialize env-var access in dense-backend tests | | E3 | `ENV_LOCK` | `core/workspace_config.rs:101` | Serialize env-var access in workspace-config tests | | E4 | `LOCK` | `core/data_dir.rs:50` | Serialize data-dir creation | | E5 | `LOCK` | `core/tokens.rs:190` | Serialize tokenizer tests | | E6 | `LOCK` | `core/tokenizer_translation_driver.rs:248` | Serialize tokenizer-translation tests | +| E7 | `TEST_ENV_MUTEX` | `core/data_dir.rs:161` | `OnceLock>` reentrant test env lock — serialize env-mutating tests across threads (`std::env::set_var` is not thread-safe); same thread may re-acquire without deadlocking | --- diff --git a/rust/src/cli/dispatch/mod.rs b/rust/src/cli/dispatch/mod.rs index 4fb0eca6b..c9cd581dc 100644 --- a/rust/src/cli/dispatch/mod.rs +++ b/rust/src/cli/dispatch/mod.rs @@ -1065,6 +1065,7 @@ mod tests { #[test] #[serial] fn worker_threads_default_clamps_low() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS"); assert_eq!(resolve_worker_threads(1), 1); } @@ -1072,6 +1073,7 @@ mod tests { #[test] #[serial] fn worker_threads_default_clamps_high() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS"); assert_eq!(resolve_worker_threads(32), 4); } @@ -1079,6 +1081,7 @@ mod tests { #[test] #[serial] fn worker_threads_default_passthrough() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS"); assert_eq!(resolve_worker_threads(3), 3); } @@ -1086,6 +1089,7 @@ mod tests { #[test] #[serial] fn worker_threads_env_override() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("LEAN_CTX_WORKER_THREADS", "12"); assert_eq!(resolve_worker_threads(2), 12); crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS"); @@ -1094,6 +1098,7 @@ mod tests { #[test] #[serial] fn worker_threads_env_invalid_falls_back() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("LEAN_CTX_WORKER_THREADS", "not_a_number"); assert_eq!(resolve_worker_threads(3), 3); crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS"); diff --git a/rust/src/core/addons/bootstrap.rs b/rust/src/core/addons/bootstrap.rs index 69861eff3..5b024f94f 100644 --- a/rust/src/core/addons/bootstrap.rs +++ b/rust/src/core/addons/bootstrap.rs @@ -630,6 +630,7 @@ mod tests { #[test] #[cfg(unix)] fn ensure_installed_runs_manager_then_verifies() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -665,6 +666,7 @@ mod tests { #[test] #[cfg(unix)] fn ensure_installed_propagates_manager_failure() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -703,6 +705,7 @@ mod tests { #[test] #[cfg(unix)] fn ensure_installed_preflights_a_missing_manager() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/rust/src/core/addons/env_scrub.rs b/rust/src/core/addons/env_scrub.rs index 33f413921..118d8d427 100644 --- a/rust/src/core/addons/env_scrub.rs +++ b/rust/src/core/addons/env_scrub.rs @@ -61,6 +61,7 @@ mod tests { #[test] fn declared_capabilities_scrub_host_secret() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("LEAN_CTX_ADDON_TEST_SECRET", "top-secret"); let caps = AddonCapabilities::default(); let mut cmd = Command::new("true"); @@ -75,6 +76,7 @@ mod tests { #[test] fn declared_env_name_passes_through() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("LEAN_CTX_ADDON_TEST_ALLOWED", "visible"); let caps = AddonCapabilities { network: NetworkAccess::Full, diff --git a/rust/src/core/budget_tracker.rs b/rust/src/core/budget_tracker.rs index e8657d077..85f594fe3 100644 --- a/rust/src/core/budget_tracker.rs +++ b/rust/src/core/budget_tracker.rs @@ -376,6 +376,7 @@ mod tests { #[test] fn cost_cap_blocks_when_exceeded() { + let _env_lock = crate::core::data_dir::test_env_lock(); let t = BudgetTracker::new(); t.record_cost_usd(6.0); // SAFETY: single-threaded test — no concurrent env access. diff --git a/rust/src/core/cache/mod.rs b/rust/src/core/cache/mod.rs index 2aebb53a4..4701a388b 100644 --- a/rust/src/core/cache/mod.rs +++ b/rust/src/core/cache/mod.rs @@ -6,8 +6,6 @@ pub use entry::*; pub use session::*; pub use validation::*; -#[cfg(test)] -use crate::core::tokens::count_tokens; #[cfg(test)] use entry::resolve_cache_max_tokens; #[cfg(test)] diff --git a/rust/src/core/cache/session.rs b/rust/src/core/cache/session.rs index dbad73bdf..7b7a862bf 100644 --- a/rust/src/core/cache/session.rs +++ b/rust/src/core/cache/session.rs @@ -57,6 +57,15 @@ impl SessionCache { self.co_access.end_burst(); } + /// Widen the co-access burst window. Test-only: lets a test keep several + /// `store()` calls in one burst without depending on them landing inside + /// the real-time 500ms window (a scheduling-jitter flake under parallel + /// test execution). + #[cfg(test)] + pub fn set_co_access_burst_window(&mut self, window: std::time::Duration) { + self.co_access.set_burst_window(window); + } + /// Per-entry Hebbian eviction bonus (#3): each cached entry that is /// co-accessed with the recently-active working set earns a positive bonus /// that is added to its RRF score, so clustered files survive eviction diff --git a/rust/src/core/cache/tests.rs b/rust/src/core/cache/tests.rs index 17b8bb782..e74e8cd64 100644 --- a/rust/src/core/cache/tests.rs +++ b/rust/src/core/cache/tests.rs @@ -180,13 +180,14 @@ fn hebbian_eviction_bonus_is_wired() { // #3: files read together build a Hebbian association via store()'s // recording, and that association must feed the eviction bonus. // - // Warm up tiktoken first: the very first count_tokens() in the process - // lazily loads the BPE tables (can exceed the 500ms co-access burst - // window). store() calls count_tokens() internally, so without warming - // up, the two store() calls below straddle that window and never - // associate — a flaky-empty bonus. Warming up keeps them in one burst. - let _ = count_tokens("warmup"); + // Widen the burst window so the two store() calls below always land in + // one burst — independent of real time. Previously this test warmed up + // tiktoken and hoped the calls stayed inside the real 500ms window; that + // still flaked under parallel test execution when scheduling jitter + // delayed one store() past the window. An injected window removes the + // wall-clock dependency entirely. let mut cache = SessionCache::new(); + cache.set_co_access_burst_window(std::time::Duration::from_secs(3600)); cache.store("/a.rs", "fn a() {}"); cache.store("/b.rs", "fn b() {}"); cache.flush_co_access(); // commit the burst → association (a,b) forms @@ -271,6 +272,7 @@ fn cache_budget_resolver_precedence() { #[test] fn evict_if_needed_removes_lowest_score() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("LEAN_CTX_CACHE_MAX_TOKENS", "50"); let mut cache = SessionCache::new(); let big_content = "a]".repeat(30); // ~30 tokens diff --git a/rust/src/core/content_cache.rs b/rust/src/core/content_cache.rs index a9afad179..8060cd0ac 100644 --- a/rust/src/core/content_cache.rs +++ b/rust/src/core/content_cache.rs @@ -404,6 +404,7 @@ mod tests { #[test] fn disabled_via_zero_budget_is_passthrough() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _g = TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/rust/src/core/context_kernel/config_bridge.rs b/rust/src/core/context_kernel/config_bridge.rs index e194cf0f4..f81e42504 100644 --- a/rust/src/core/context_kernel/config_bridge.rs +++ b/rust/src/core/context_kernel/config_bridge.rs @@ -154,7 +154,7 @@ mod tests { fn setup() -> ( std::sync::MutexGuard<'static, ()>, - std::sync::MutexGuard<'static, ()>, + crate::core::data_dir::TestEnvGuard, EnvGuard, ) { let kernel = kernel_config::KERNEL_TEST_LOCK @@ -174,7 +174,7 @@ mod tests { #[test] fn env_overrides_default() { - let _guards = setup(); + let _guards = setup(); // holds test_env_lock for the test's lifetime crate::test_env::set_var("LEAN_CTX_KERNEL_ENABLED", "false"); let (features, source) = effective_config(); assert!(!features.enabled); @@ -183,7 +183,7 @@ mod tests { #[test] fn apply_updates_global() { - let _guards = setup(); + let _guards = setup(); // holds test_env_lock for the test's lifetime crate::test_env::set_var("LEAN_CTX_KERNEL_DEDUP", "false"); apply_config(); assert!(!kernel_config::features().content_dedup); @@ -191,7 +191,7 @@ mod tests { #[test] fn report_lists_env_vars() { - let _guards = setup(); + let _guards = setup(); // holds test_env_lock for the test's lifetime crate::test_env::set_var("LEAN_CTX_KERNEL_MAX_BUDGET", "42"); assert_eq!( config_report().env_vars_detected, diff --git a/rust/src/core/context_kernel/perf_benchmark.rs b/rust/src/core/context_kernel/perf_benchmark.rs index ee480a570..958a88cb6 100644 --- a/rust/src/core/context_kernel/perf_benchmark.rs +++ b/rust/src/core/context_kernel/perf_benchmark.rs @@ -9,6 +9,14 @@ mod tests { }; use crate::tools::{search_hook, search_kernel}; + /// Wall-clock budgets below only hold on a quiet, single-threaded runner. + /// The suite runs multi-threaded, so they are opt-in: CI's perf-gate step + /// sets `LEAN_CTX_PERF_GATE=1` and runs them serialized. The work itself + /// (and every correctness assertion) still runs on every invocation. + fn timing_enforced() -> bool { + std::env::var("LEAN_CTX_PERF_GATE").as_deref() == Ok("1") + } + fn isolated() -> MutexGuard<'static, ()> { let guard = kernel_config::KERNEL_TEST_LOCK .lock() @@ -41,7 +49,7 @@ mod tests { let content = format!("unique content {index}"); assert!(ctx_read_dedup::try_dedup("bench.rs", &content).is_none()); } - assert!(started.elapsed() < Duration::from_millis(500)); + assert!(!timing_enforced() || started.elapsed() < Duration::from_millis(500)); } #[test] @@ -51,7 +59,7 @@ mod tests { for _ in 0..10_000 { evidence_hook::record_tool_call("ctx_read", 100, 25); } - assert!(started.elapsed() < Duration::from_millis(200)); + assert!(!timing_enforced() || started.elapsed() < Duration::from_millis(200)); assert_eq!(evidence_hook::evidence_report().tool_calls, 10_000); } @@ -62,7 +70,7 @@ mod tests { for _ in 0..100 { std::hint::black_box(health::kernel_health()); } - assert!(started.elapsed() < Duration::from_millis(50)); + assert!(!timing_enforced() || started.elapsed() < Duration::from_millis(50)); } #[test] @@ -76,7 +84,7 @@ mod tests { search_kernel::record_search(&format!("query-{index}"), 5, 100); } let summary = search_kernel::search_summary(); - assert!(started.elapsed() < Duration::from_millis(200)); + assert!(!timing_enforced() || started.elapsed() < Duration::from_millis(200)); assert_eq!(summary.total_searches, 600); assert_eq!(summary.unique_queries, 500); assert_eq!(summary.repeated_queries, 100); @@ -97,7 +105,7 @@ mod tests { }; token_total += std::hint::black_box(envelope).input_tokens; } - assert!(started.elapsed() < Duration::from_millis(50)); + assert!(!timing_enforced() || started.elapsed() < Duration::from_millis(50)); assert_eq!(token_total, 12_497_500); } @@ -138,6 +146,6 @@ mod tests { "cursor", )); } - assert!(started.elapsed() < Duration::from_millis(500)); + assert!(!timing_enforced() || started.elapsed() < Duration::from_millis(500)); } } diff --git a/rust/src/core/data_dir.rs b/rust/src/core/data_dir.rs index 6d37b6128..7951760de 100644 --- a/rust/src/core/data_dir.rs +++ b/rust/src/core/data_dir.rs @@ -158,13 +158,54 @@ pub(crate) fn ensure_dir_permissions(path: &std::path::Path) { #[cfg(not(unix))] pub(crate) fn ensure_dir_permissions(_path: &std::path::Path) {} -pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { - use std::sync::{Mutex, OnceLock}; - static LOCK: OnceLock> = OnceLock::new(); - let mutex = LOCK.get_or_init(|| Mutex::new(())); - mutex - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) +static TEST_ENV_MUTEX: std::sync::OnceLock> = std::sync::OnceLock::new(); + +thread_local! { + /// Re-entrancy depth for the current thread. >0 means this thread already + /// holds the lock, so a nested acquire is a no-op bump instead of a + /// self-deadlock. + static TEST_ENV_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The real guard, held only while depth transitions 0→…→0. + static TEST_ENV_HELD: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Reentrant test env lock. Serializes env-mutating tests across threads (the +/// point — `std::env::set_var` is not thread-safe) while letting the SAME +/// thread re-acquire without deadlocking. A test and a `setup()` helper (or +/// `isolated_data_dir`) can both take it; only the outermost acquisition holds +/// the underlying mutex, nested ones just bump a per-thread depth. Other +/// threads still block, so cross-test serialization is unchanged. +pub fn test_env_lock() -> TestEnvGuard { + let mutex = TEST_ENV_MUTEX.get_or_init(|| std::sync::Mutex::new(())); + TEST_ENV_DEPTH.with(|depth| { + if depth.get() == 0 { + let guard = mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + TEST_ENV_HELD.with(|held| *held.borrow_mut() = Some(guard)); + } + depth.set(depth.get() + 1); + }); + TestEnvGuard { _private: () } +} + +/// RAII guard for [`test_env_lock`]. Dropping the outermost one releases the +/// underlying mutex; nested guards just decrement the depth. +pub struct TestEnvGuard { + _private: (), +} + +impl Drop for TestEnvGuard { + fn drop(&mut self) { + TEST_ENV_DEPTH.with(|depth| { + let next = depth.get().saturating_sub(1); + depth.set(next); + if next == 0 { + TEST_ENV_HELD.with(|held| *held.borrow_mut() = None); + } + }); + } } /// RAII data-dir isolation for tests (GL #556): holds `test_env_lock` for @@ -177,7 +218,7 @@ pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { #[cfg(test)] pub struct IsolatedDataDir { tmp: tempfile::TempDir, - _guard: std::sync::MutexGuard<'static, ()>, + _guard: TestEnvGuard, } #[cfg(test)] diff --git a/rust/src/core/energy.rs b/rust/src/core/energy.rs index 115a08055..53ac1215e 100644 --- a/rust/src/core/energy.rs +++ b/rust/src/core/energy.rs @@ -157,6 +157,7 @@ mod tests { #[test] fn grid_intensity_default_when_no_override() { + let _env_lock = crate::core::data_dir::test_env_lock(); // The override is read from the environment; without it we use the constant. // (Env mutation is covered indirectly; here we assert the documented default.) crate::test_env::remove_var("LEAN_CTX_GRID_CO2_G_PER_KWH"); diff --git a/rust/src/core/firewall.rs b/rust/src/core/firewall.rs index df8015124..0ecca3a70 100644 --- a/rust/src/core/firewall.rs +++ b/rust/src/core/firewall.rs @@ -144,6 +144,7 @@ mod tests { #[test] fn should_firewall_respects_tool_and_threshold() { + let _env_lock = crate::core::data_dir::test_env_lock(); let mut cfg = Config::default(); cfg.archive.enabled = true; cfg.archive.ephemeral = true; diff --git a/rust/src/core/graph_provider.rs b/rust/src/core/graph_provider.rs index 30d28a061..5fb6509da 100644 --- a/rust/src/core/graph_provider.rs +++ b/rust/src/core/graph_provider.rs @@ -604,8 +604,8 @@ fn log_source_selection( fn trigger_lazy_graph_build(project_root: &str) { // Unit tests rewrite the process-global `LEAN_CTX_DATA_DIR` per test (each uses // its own tempdir). A detached, fire-and-forget build thread reads that global - // mid-flight and runs concurrently with the otherwise-serial (`--test-threads=1`) - // test bodies — the one source of graph-state concurrency in the suite, and the + // mid-flight and runs concurrently with test bodies that are otherwise + // serialized on `test_env_lock` — a source of graph-state concurrency, and the // root of an intermittent macOS-only flake where a freshly-built index appeared // empty to the asserting test. `open_or_build` has a synchronous fallback that // fully covers tests, so skip the background build under `cfg!(test)`. Production diff --git a/rust/src/core/hebbian_cache.rs b/rust/src/core/hebbian_cache.rs index 95380a540..d2beb83d6 100644 --- a/rust/src/core/hebbian_cache.rs +++ b/rust/src/core/hebbian_cache.rs @@ -8,7 +8,7 @@ //! lowest-value entries evicted), High T = stochastic (prevents thrashing). use std::collections::HashMap; -use std::time::Instant; +use std::time::{Duration, Instant}; /// Maximum number of co-access pairs tracked (prevents unbounded growth). const MAX_ASSOCIATIONS: usize = 10_000; @@ -26,6 +26,11 @@ pub struct CoAccessMatrix { /// Current access burst (files read in the same tool-call window) current_burst: Vec, burst_start: Instant, + /// How long a burst stays open. Real tool calls read their files within a + /// few ms, so 500ms in production. Injectable so tests can widen it and stop + /// depending on two `store()` calls landing in the same real-time window — + /// a scheduling-jitter flake once tests run in parallel. + burst_window: Duration, } impl Default for CoAccessMatrix { @@ -41,16 +46,24 @@ impl CoAccessMatrix { timestamps: HashMap::with_capacity(256), current_burst: Vec::with_capacity(8), burst_start: Instant::now(), + burst_window: Duration::from_millis(500), } } + /// Override the burst window. Test-only: production always uses the 500ms + /// default set in `new`. + #[cfg(test)] + pub fn set_burst_window(&mut self, window: Duration) { + self.burst_window = window; + self.burst_start = Instant::now(); + } + /// Record a file access. If within the burst window (500ms), strengthens /// associations with other files in the same burst. pub fn record_access(&mut self, path_hash: u64) { let now = Instant::now(); - let burst_window = std::time::Duration::from_millis(500); - if now.duration_since(self.burst_start) > burst_window { + if now.duration_since(self.burst_start) > self.burst_window { self.flush_burst(); self.burst_start = now; } diff --git a/rust/src/core/home.rs b/rust/src/core/home.rs index 8d81598e2..e9d8f8986 100644 --- a/rust/src/core/home.rs +++ b/rust/src/core/home.rs @@ -148,6 +148,7 @@ mod tests { #[test] fn resolve_codex_dir_respects_env_var() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = env_lock(); crate::test_env::set_var("CODEX_HOME", "/tmp/custom-codex"); crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV); @@ -159,6 +160,7 @@ mod tests { #[test] fn resolve_codex_dir_ignores_empty_env() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = env_lock(); crate::test_env::set_var("CODEX_HOME", " "); crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV); @@ -171,6 +173,7 @@ mod tests { #[test] fn resolve_codex_dir_falls_back_to_home() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = env_lock(); crate::test_env::remove_var("CODEX_HOME"); crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV); @@ -182,6 +185,7 @@ mod tests { #[test] fn explicit_profile_selects_overlay_for_writes_and_layers() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = env_lock(); let dir = tempfile::tempdir().unwrap(); crate::test_env::set_var("CODEX_HOME", dir.path()); @@ -200,6 +204,7 @@ mod tests { #[test] fn legacy_profile_env_selects_overlay() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = env_lock(); let dir = tempfile::tempdir().unwrap(); crate::test_env::set_var("CODEX_HOME", dir.path()); @@ -215,6 +220,7 @@ mod tests { #[test] fn sole_overlay_is_inferred_but_ambiguous_overlays_are_not() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = env_lock(); let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("cat.config.toml"), "").unwrap(); @@ -234,6 +240,7 @@ mod tests { #[test] fn invalid_profile_names_cannot_escape_codex_home() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = env_lock(); let dir = tempfile::tempdir().unwrap(); crate::test_env::set_var("CODEX_HOME", dir.path()); diff --git a/rust/src/core/mcp_catalog/adapters/compression.rs b/rust/src/core/mcp_catalog/adapters/compression.rs index 4c4092f54..63a4dc5bb 100644 --- a/rust/src/core/mcp_catalog/adapters/compression.rs +++ b/rust/src/core/mcp_catalog/adapters/compression.rs @@ -149,6 +149,7 @@ mod tests { #[test] fn compress_is_graceful_when_gateway_disabled() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("LEAN_CTX_GATEWAY"); // No gateway configured in the test env → input returned unchanged. let c = GatewayCompressor::new("nonexistent-server"); diff --git a/rust/src/core/ocla/builtin/response_optimizer.rs b/rust/src/core/ocla/builtin/response_optimizer.rs index 6710561dd..22c15cc83 100644 --- a/rust/src/core/ocla/builtin/response_optimizer.rs +++ b/rust/src/core/ocla/builtin/response_optimizer.rs @@ -80,17 +80,22 @@ mod tests { use super::*; use crate::core::ocla::types::OclaRequestContext; - fn req(original: u64, target: u64) -> ResponseOptimizationRequest { + /// `tag` keys the request to one test. The optimizer keeps a process-wide + /// per-session cache, so two tests sharing a session id + response ref see + /// each other's entries: whichever ran second got a cache hit and zero + /// delivered tokens, which only showed up once tests ran in parallel and + /// the order stopped being fixed. + fn req(tag: &str, original: u64, target: u64) -> ResponseOptimizationRequest { ResponseOptimizationRequest { context: OclaRequestContext { request_id: "r1".into(), - session_id: "s1".into(), + session_id: format!("s1-{tag}"), agent_id: "agent-test".into(), content_ref: "ref:test".into(), tenant_id: None, trace_id: "tr-unit".into(), }, - response_ref: "resp:abc".into(), + response_ref: format!("resp:{tag}"), original_tokens: original, target_tokens: target, } @@ -98,22 +103,34 @@ mod tests { #[test] fn optimization_caps_at_target() { + // optimize_response appends a `proxy_response_optimizer` event to the + // savings ledger. Without an isolated data dir that write lands in + // whatever LEAN_CTX_DATA_DIR currently points at — under parallel tests + // that is another test's isolated dir, whose ledger assertions then see + // a foreign event. + let _iso = crate::core::data_dir::isolated_data_dir(); let opt = BuiltinResponseOptimizer::new(); - let result = opt.optimize_response(req(1000, 400)).unwrap(); + let result = opt.optimize_response(req("caps", 1000, 400)).unwrap(); assert_eq!(result.delivered_tokens, 400); } #[test] fn preserves_response_ref() { + // See `optimization_caps_at_target`: keeps this test's ledger write out + // of another test's isolated data dir. + let _iso = crate::core::data_dir::isolated_data_dir(); let opt = BuiltinResponseOptimizer::new(); - let result = opt.optimize_response(req(500, 300)).unwrap(); - assert_eq!(result.response_ref, "resp:abc"); + let result = opt.optimize_response(req("preserves", 500, 300)).unwrap(); + assert_eq!(result.response_ref, "resp:preserves"); } #[test] fn registry_path_reports_cache_as_zero_delivery() { + // See `optimization_caps_at_target`: keeps this test's ledger write out + // of another test's isolated data dir. + let _iso = crate::core::data_dir::isolated_data_dir(); let registry = crate::core::ocla::registry::OclaRegistry::with_builtins(); - let mut request = req(1000, 400); + let mut request = req("registry", 1000, 400); request.context.session_id = "registry-response-optimizer".into(); request.response_ref = "resp:registry-response-optimizer".into(); let first = registry @@ -131,7 +148,7 @@ mod tests { #[test] fn duplicate_delivery_uses_target_ratio() { - let request = req(1000, 250); + let request = req("duplicate", 1000, 250); let decision = crate::proxy::response_optimizer::OptimizationDecision { cache_hit: false, is_duplicate: true, diff --git a/rust/src/core/ort_environment.rs b/rust/src/core/ort_environment.rs index c918dcb7d..db5069e05 100644 --- a/rust/src/core/ort_environment.rs +++ b/rust/src/core/ort_environment.rs @@ -345,6 +345,7 @@ mod tests { #[test] fn resolve_dylib_env_var_takes_precedence() { + let _env_lock = crate::core::data_dir::test_env_lock(); // Set ORT_DYLIB_PATH to a known file (/tmp is guaranteed to exist, // but the file itself won't — this should still error with a clear // message about the file not existing). @@ -367,6 +368,7 @@ mod tests { #[test] fn homebrew_prefix_lib_is_searched() { + let _env_lock = crate::core::data_dir::test_env_lock(); // A dylib under $HOMEBREW_PREFIX/lib is discovered (covers Homebrew on // any platform / custom prefix, incl. Linuxbrew). See issue #544. let tmp = std::env::temp_dir().join(format!("lc-ort-hb-{}", std::process::id())); @@ -392,6 +394,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn nix_per_user_lib_discovers_file_under_base() { + let _env_lock = crate::core::data_dir::test_env_lock(); let tmp = std::env::temp_dir().join(format!("lc-nix-pu-{}", std::process::id())); let name = "libonnxruntime-test-marker.so"; let user = "testuser"; @@ -410,6 +413,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn nix_per_user_lib_rejects_traversal_in_user() { + let _env_lock = crate::core::data_dir::test_env_lock(); let tmp = std::env::temp_dir().join(format!("lc-nix-trv-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); diff --git a/rust/src/core/pathjail.rs b/rust/src/core/pathjail.rs index b7ad5dbe8..2c4391a79 100644 --- a/rust/src/core/pathjail.rs +++ b/rust/src/core/pathjail.rs @@ -989,6 +989,7 @@ mod tests { // literally and never matched. #[test] fn expand_user_path_expands_tilde_and_vars() { + let _env_lock = crate::core::data_dir::test_env_lock(); let home = dirs::home_dir().expect("home dir"); let home_s = home.to_string_lossy().to_string(); @@ -1009,6 +1010,7 @@ mod tests { #[test] fn expand_user_path_leaves_unset_vars_verbatim() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR"); let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code"); assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code")); diff --git a/rust/src/core/persona.rs b/rust/src/core/persona.rs index 4c2b5562f..dd4ac3e16 100644 --- a/rust/src/core/persona.rs +++ b/rust/src/core/persona.rs @@ -538,6 +538,7 @@ intent_taxonomy = ["prospect", "qualify", "enrich"] #[test] fn loader_reads_persona_file_and_selection_picks_it() { + let _env_lock = crate::core::data_dir::test_env_lock(); let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("research.toml"), diff --git a/rust/src/core/plugins/sandbox.rs b/rust/src/core/plugins/sandbox.rs index 510264e71..466acbc82 100644 --- a/rust/src/core/plugins/sandbox.rs +++ b/rust/src/core/plugins/sandbox.rs @@ -224,6 +224,7 @@ mod tests { #[cfg(unix)] #[test] fn scrubbed_env_hides_host_secret_but_keeps_path() { + let _env_lock = crate::core::data_dir::test_env_lock(); use std::time::Duration; // A secret in the host env must NOT reach a scrubbed child. crate::test_env::set_var("LEAN_CTX_TEST_SECRET", "top-secret"); @@ -249,6 +250,7 @@ mod tests { #[cfg(unix)] #[test] fn passthrough_env_exposes_host_var() { + let _env_lock = crate::core::data_dir::test_env_lock(); use std::time::Duration; crate::test_env::set_var("LEAN_CTX_TEST_PASSTHRU", "visible"); let out = crate::core::plugins::executor::run_subprocess( diff --git a/rust/src/core/providers/github.rs b/rust/src/core/providers/github.rs index 11c536571..a95d26a39 100644 --- a/rust/src/core/providers/github.rs +++ b/rust/src/core/providers/github.rs @@ -408,6 +408,7 @@ mod tests { #[test] fn provider_unavailable_without_token() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("GITHUB_TOKEN"); crate::test_env::remove_var("GH_TOKEN"); crate::test_env::remove_var("LEAN_CTX_GITHUB_TOKEN"); diff --git a/rust/src/core/providers/jira.rs b/rust/src/core/providers/jira.rs index 5ce790e52..026851f60 100644 --- a/rust/src/core/providers/jira.rs +++ b/rust/src/core/providers/jira.rs @@ -477,6 +477,7 @@ mod tests { #[test] fn jira_provider_is_unavailable_without_env() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -498,6 +499,7 @@ mod tests { #[test] fn deployment_defaults_to_cloud() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -514,6 +516,7 @@ mod tests { #[test] fn deployment_server_variants() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -532,6 +535,7 @@ mod tests { #[test] fn oauth_is_selected_when_forced() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _guard = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/rust/src/core/providers/postgres.rs b/rust/src/core/providers/postgres.rs index ae69f50b0..e8fdd0e83 100644 --- a/rust/src/core/providers/postgres.rs +++ b/rust/src/core/providers/postgres.rs @@ -172,6 +172,7 @@ mod tests { #[test] fn postgres_provider_unavailable_without_env() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("DATABASE_URL"); crate::test_env::remove_var("PGDATABASE"); diff --git a/rust/src/core/savings_autopush.rs b/rust/src/core/savings_autopush.rs index a45955f6f..7c81890bc 100644 --- a/rust/src/core/savings_autopush.rs +++ b/rust/src/core/savings_autopush.rs @@ -118,6 +118,7 @@ mod tests { #[test] fn enabled_when_fully_configured() { + let _env_lock = crate::core::data_dir::test_env_lock(); // Guard against a CI env that sets the token override. let prior = std::env::var("LEAN_CTX_TEAM_TOKEN").ok(); crate::test_env::remove_var("LEAN_CTX_TEAM_TOKEN"); diff --git a/rust/src/core/theme.rs b/rust/src/core/theme.rs index 8c1cd514b..ddf782136 100644 --- a/rust/src/core/theme.rs +++ b/rust/src/core/theme.rs @@ -782,6 +782,7 @@ mod tests { #[test] fn border_line_width() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("NO_COLOR", "1"); let theme = preset_default(); let line = theme.border_line(10); @@ -791,6 +792,7 @@ mod tests { #[test] fn box_top_bottom_symmetric() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("NO_COLOR", "1"); let theme = preset_default(); let top = theme.box_top(20); diff --git a/rust/src/core/tokenizer_translation_driver.rs b/rust/src/core/tokenizer_translation_driver.rs index 4c9a4cfaf..91ace5d04 100644 --- a/rust/src/core/tokenizer_translation_driver.rs +++ b/rust/src/core/tokenizer_translation_driver.rs @@ -253,6 +253,7 @@ mod tests { #[test] fn ruleset_disabled_is_legacy() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _lock = env_lock(); crate::test_env::remove_var("LEAN_CTX_MODEL"); let cfg = TranslationConfig { @@ -298,6 +299,7 @@ mod tests { #[test] fn translation_skips_json_outputs() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _lock = env_lock(); crate::test_env::set_var("LEAN_CTX_MODEL", "gpt-5.4"); let cfg = TranslationConfig { diff --git a/rust/src/dashboard/routes/leaderboard.rs b/rust/src/dashboard/routes/leaderboard.rs index ee363b473..49536b92a 100644 --- a/rust/src/dashboard/routes/leaderboard.rs +++ b/rust/src/dashboard/routes/leaderboard.rs @@ -296,6 +296,7 @@ mod tests { /// immediately). #[test] fn board_proxy_is_wired_and_degrades_to_502() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("LEAN_CTX_API_URL", "http://127.0.0.1:1"); let res = handle("/api/leaderboard", "", "GET", ""); crate::test_env::remove_var("LEAN_CTX_API_URL"); diff --git a/rust/src/dashboard/routes/settings.rs b/rust/src/dashboard/routes/settings.rs index afcefdd35..766efd723 100644 --- a/rust/src/dashboard/routes/settings.rs +++ b/rust/src/dashboard/routes/settings.rs @@ -316,6 +316,7 @@ mod tests { /// does not snap back to "Power". #[test] fn tool_profile_value_distinguishes_lean_from_power() { + let _env_lock = crate::core::data_dir::test_env_lock(); // Avoid env interference from the host running the suite. crate::test_env::remove_var("LEAN_CTX_TOOL_PROFILE"); diff --git a/rust/src/dashboard/tests.rs b/rust/src/dashboard/tests.rs index 85d8f9f70..50bd8ce0c 100644 --- a/rust/src/dashboard/tests.rs +++ b/rust/src/dashboard/tests.rs @@ -5,11 +5,9 @@ use super::routes::helpers::{detect_project_root_for_dashboard, normalize_dashbo use super::*; use tempfile::tempdir; -static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - #[test] fn dashboard_project_root_honors_general_env_override() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); let td = tempdir().expect("tempdir"); let root = td.path().join("project"); std::fs::create_dir_all(&root).expect("mkdir"); @@ -23,7 +21,7 @@ fn dashboard_project_root_honors_general_env_override() { #[test] fn dashboard_project_root_ignores_broken_ancestor_gitfile() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); let td = tempdir().expect("tempdir"); let workspace = td.path().join("workspace"); let root = workspace.join("project"); @@ -41,7 +39,7 @@ fn dashboard_project_root_ignores_broken_ancestor_gitfile() { #[test] fn dashboard_project_root_honors_resolvable_ancestor_gitfile() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); let td = tempdir().expect("tempdir"); let checkout = td.path().join("checkout"); let root = checkout.join("nested"); @@ -88,9 +86,7 @@ fn open_mode_flag_parses_all_variants() { #[test] fn open_mode_env_is_used_when_no_flag() { - let _guard = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "none"); assert_eq!(resolve_open_mode(None), DashboardOpen::None); crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "vscode"); @@ -133,9 +129,6 @@ fn api_path_detection() { #[test] fn api_session_exposes_unmodified_session_stats() { - let _guard = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let _iso = crate::core::data_dir::isolated_data_dir(); let (status, _content_type, body) = @@ -149,9 +142,6 @@ fn api_session_exposes_unmodified_session_stats() { #[test] fn context_endpoints_pair_proxy_model_with_its_window() { - let _guard = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let iso = crate::core::data_dir::isolated_data_dir(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -267,9 +257,6 @@ fn normalize_dashboard_demo_path_strips_dot_slash_prefix() { fn api_context_overlay_evict_removes_ledger_entry() { // #715: the dashboard Evict must remove the ledger entry (pressure // drops), resolving basenames against absolute canonical entries. - let _guard = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let _iso = crate::core::data_dir::isolated_data_dir(); let mut ledger = crate::core::context_ledger::ContextLedger::with_window_size(100_000); @@ -367,7 +354,7 @@ fn api_procedures_returns_json() { #[test] fn api_compression_demo_heals_moved_file_paths() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); let td = tempdir().expect("tempdir"); let root = td.path(); std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir"); @@ -404,7 +391,7 @@ fn api_compression_demo_heals_moved_file_paths() { #[test] fn resolve_token_uses_env_var_verbatim() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic"); let (token, src) = resolve_requested_token(None); crate::test_env::remove_var(HTTP_TOKEN_ENV); @@ -417,7 +404,7 @@ fn resolve_token_uses_env_var_verbatim() { #[test] fn resolve_token_trims_env_var() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var(HTTP_TOKEN_ENV, " lctx_padded "); let (token, src) = resolve_requested_token(None); crate::test_env::remove_var(HTTP_TOKEN_ENV); @@ -427,7 +414,7 @@ fn resolve_token_trims_env_var() { #[test] fn resolve_token_falls_back_to_random_when_unset() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var(HTTP_TOKEN_ENV); let (token, src) = resolve_requested_token(None); assert!(token.is_none(), "unset env requests no fixed token"); @@ -446,7 +433,7 @@ fn resolve_token_falls_back_to_random_when_unset() { #[test] fn resolve_token_ignores_empty_env() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var(HTTP_TOKEN_ENV, " "); let (token, src) = resolve_requested_token(None); crate::test_env::remove_var(HTTP_TOKEN_ENV); @@ -459,9 +446,9 @@ fn resolve_token_ignores_empty_env() { #[test] fn resolve_token_flag_overrides_env() { + let _env_lock = crate::core::data_dir::test_env_lock(); // #377: --auth-token must win over LEAN_CTX_HTTP_TOKEN so it survives // environments that strip/fail to inherit the env var. - let _g = ENV_LOCK.lock().expect("env lock"); crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv"); let (token, src) = resolve_requested_token(Some("lctx_fromflag")); crate::test_env::remove_var(HTTP_TOKEN_ENV); @@ -471,7 +458,7 @@ fn resolve_token_flag_overrides_env() { #[test] fn resolve_token_uses_flag_when_env_unset() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var(HTTP_TOKEN_ENV); let (token, src) = resolve_requested_token(Some(" lctx_flag_padded ")); assert_eq!(src, "--auth-token"); @@ -480,7 +467,7 @@ fn resolve_token_uses_flag_when_env_unset() { #[test] fn resolve_token_empty_flag_falls_back_to_env() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv"); let (token, src) = resolve_requested_token(Some(" ")); crate::test_env::remove_var(HTTP_TOKEN_ENV); @@ -501,7 +488,7 @@ fn parse_human_bool_accepts_common_forms() { #[test] fn build_allowed_hosts_covers_loopback_and_bound_host() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var(ALLOWED_HOSTS_ENV); let allowed = build_allowed_hosts("0.0.0.0", 3333); assert!(host_allowed("127.0.0.1:3333", &allowed)); @@ -515,7 +502,7 @@ fn build_allowed_hosts_covers_loopback_and_bound_host() { #[test] fn build_allowed_hosts_honors_env_extra_hosts() { - let _g = ENV_LOCK.lock().expect("env lock"); + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::set_var(ALLOWED_HOSTS_ENV, "box.local:3333, 10.0.0.5:3333"); let allowed = build_allowed_hosts("127.0.0.1", 3333); crate::test_env::remove_var(ALLOWED_HOSTS_ENV); diff --git a/rust/src/doctor/mod.rs b/rust/src/doctor/mod.rs index 0e6982614..31c86778e 100644 --- a/rust/src/doctor/mod.rs +++ b/rust/src/doctor/mod.rs @@ -900,6 +900,7 @@ mod tests { #[test] fn bashrc_not_active_on_windows_even_with_bash_in_shell_env() { + let _env_lock = crate::core::data_dir::test_env_lock(); // Issue #214: On Windows, Git Bash sets $SHELL globally to bash.exe. // .bashrc should NOT be flagged on Windows unless actually inside bash. crate::test_env::remove_var("BASH_VERSION"); @@ -934,6 +935,7 @@ mod tests { #[test] fn bashrc_not_active_on_windows_without_powershell_detection() { + let _env_lock = crate::core::data_dir::test_env_lock(); // Windows + $SHELL=bash but NOT in actual bash session (no BASH_VERSION). // This is the exact scenario from issue #214: Git Bash sets $SHELL globally. crate::test_env::remove_var("BASH_VERSION"); diff --git a/rust/src/server/execute.rs b/rust/src/server/execute.rs index c484c0e98..ed46e0d40 100644 --- a/rust/src/server/execute.rs +++ b/rust/src/server/execute.rs @@ -566,6 +566,7 @@ mod tests { #[test] fn ensure_utf8_locale_sets_fallback_when_none_inherited() { + let _env_lock = crate::core::data_dir::test_env_lock(); let empty: std::collections::HashMap = std::collections::HashMap::new(); let mut cmd = std::process::Command::new("true"); diff --git a/rust/src/shell_hook.rs b/rust/src/shell_hook.rs index 7a7306250..6b6bf34aa 100644 --- a/rust/src/shell_hook.rs +++ b/rust/src/shell_hook.rs @@ -1205,6 +1205,7 @@ mod tests { #[cfg(unix)] #[test] fn shell_available_rejects_unknown_shell() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _g = SHELL_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1217,6 +1218,7 @@ mod tests { #[cfg(unix)] #[test] fn shell_available_finds_installed_shells() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _g = SHELL_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1237,6 +1239,7 @@ mod tests { #[cfg(unix)] #[test] fn shell_hook_force_overrides_detection() { + let _env_lock = crate::core::data_dir::test_env_lock(); let _g = SHELL_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/rust/src/tools/ctx_refactor/tests.rs b/rust/src/tools/ctx_refactor/tests.rs index 1158adfa4..3dcbc661e 100644 --- a/rust/src/tools/ctx_refactor/tests.rs +++ b/rust/src/tools/ctx_refactor/tests.rs @@ -400,6 +400,7 @@ fn resolve_name_path_ambiguous_trait_impls() { /// #845: file_scope narrows ambiguous symbols to a single file. #[test] fn file_scope_disambiguates_ambiguous_symbol() { + let _env_lock = crate::core::data_dir::test_env_lock(); let dir = tempfile::tempdir().unwrap(); let proj = dir.path().join("proj845"); std::fs::create_dir_all(proj.join("src")).unwrap(); diff --git a/rust/src/tools/ctx_tools.rs b/rust/src/tools/ctx_tools.rs index dd856d64d..d48ce7c56 100644 --- a/rust/src/tools/ctx_tools.rs +++ b/rust/src/tools/ctx_tools.rs @@ -123,6 +123,7 @@ mod tests { /// `run` resolves a runtime handle. #[test] fn disabled_gateway_returns_hint() { + let _env_lock = crate::core::data_dir::test_env_lock(); // Ensure no env override flips it on. crate::test_env::remove_var("LEAN_CTX_GATEWAY"); let rt = tokio::runtime::Builder::new_multi_thread() @@ -146,6 +147,7 @@ mod tests { /// einen current_thread-Runtime. Erwartung: Ok | Err, aber NIEMALS Panik. #[test] fn run_without_ambient_runtime_does_not_panic() { + let _env_lock = crate::core::data_dir::test_env_lock(); crate::test_env::remove_var("LEAN_CTX_GATEWAY"); let args = json!({ "action": "list" }); let _ = run(args.as_object().unwrap(), ""); // Ok|Err, niemals Panic diff --git a/rust/tests/autonomy_tests.rs b/rust/tests/autonomy_tests.rs index 05e7da067..c3e2be4a7 100644 --- a/rust/tests/autonomy_tests.rs +++ b/rust/tests/autonomy_tests.rs @@ -11,9 +11,12 @@ use std::sync::atomic::Ordering; fn init_test_data_dir() { static DIR: OnceLock = OnceLock::new(); let dir = DIR.get_or_init(|| tempfile::tempdir().expect("tempdir")); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // Every test in this binary points LEAN_CTX_DATA_DIR at the SAME process-wide + // temp dir, so the writes never disagree — the lock only has to keep two of + // them from running at the same instant now that tests are multi-threaded. + let _env_lock = lean_ctx::core::data_dir::test_env_lock(); + // SAFETY: serialized by `test_env_lock`, which every env-mutating test in + // this binary takes, so no other thread writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", dir.path()) }; } diff --git a/rust/tests/codex_proxy_554.rs b/rust/tests/codex_proxy_554.rs index 7d08b3b8b..0b2db9baa 100644 --- a/rust/tests/codex_proxy_554.rs +++ b/rust/tests/codex_proxy_554.rs @@ -40,9 +40,9 @@ struct CodexHome(Option); impl CodexHome { fn set(dir: &Path) -> Self { let prev = std::env::var_os("CODEX_HOME"); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("CODEX_HOME", dir) }; CodexHome(prev) } @@ -51,13 +51,13 @@ impl CodexHome { impl Drop for CodexHome { fn drop(&mut self) { match &self.0 { - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. Some(v) => unsafe { std::env::set_var("CODEX_HOME", v) }, - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. None => unsafe { std::env::remove_var("CODEX_HOME") }, } } @@ -74,18 +74,18 @@ struct EnvVar { impl EnvVar { fn set(key: &'static str, value: &str) -> Self { let prev = std::env::var_os(key); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var(key, value) }; EnvVar { key, prev } } fn cleared(key: &'static str) -> Self { let prev = std::env::var_os(key); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::remove_var(key) }; EnvVar { key, prev } } @@ -94,13 +94,13 @@ impl EnvVar { impl Drop for EnvVar { fn drop(&mut self) { match &self.prev { - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. Some(v) => unsafe { std::env::set_var(self.key, v) }, - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. None => unsafe { std::env::remove_var(self.key) }, } } diff --git a/rust/tests/compliance_frameworks.rs b/rust/tests/compliance_frameworks.rs index 43b425a1a..ea9b77d4e 100644 --- a/rust/tests/compliance_frameworks.rs +++ b/rust/tests/compliance_frameworks.rs @@ -83,10 +83,12 @@ fn eu_ai_act_reference_report_has_ten_plus_enforced_full_controls() { /// only requires the named `fn`s to exist. #[test] fn audit_chain_proofs_run_sequentially() { + // Held for the whole test: tests run multi-threaded, so the env writes + // below must not overlap another test's. + let _env_lock = lean_ctx::core::data_dir::test_env_lock(); let tmp = tempfile::tempdir().expect("tempdir"); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above serializes every env-mutating test in this + // binary, so no other thread touches the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path()) }; aia_12_1_logging_is_automatic_and_chained(); @@ -94,9 +96,8 @@ fn audit_chain_proofs_run_sequentially() { // Destroys the chain — must run last. aia_12_1_tampered_log_fails_verification(tmp.path()); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above is still held, so no other thread touches the + // environment concurrently. unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") }; } diff --git a/rust/tests/ctx_compose_scenarios.rs b/rust/tests/ctx_compose_scenarios.rs index bfe75156a..a08306cec 100644 --- a/rust/tests/ctx_compose_scenarios.rs +++ b/rust/tests/ctx_compose_scenarios.rs @@ -36,9 +36,9 @@ fn compose_returns_all_sections_with_symbol_body() { let _guard = ENV_GUARD .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::remove_var("LEAN_CTX_COMPOSE_BUDGET_MS") }; let dir = write_corpus(); @@ -71,9 +71,9 @@ fn compose_degrades_under_tight_budget_without_stalling() { .unwrap_or_else(std::sync::PoisonError::into_inner); // A 1 ms budget guarantees the semantic worker cannot finish in time, so the // call must degrade gracefully instead of blocking on the (cold) build. - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::set_var("LEAN_CTX_COMPOSE_BUDGET_MS", "1") }; let dir = write_corpus(); @@ -84,9 +84,9 @@ fn compose_degrades_under_tight_budget_without_stalling() { CrpMode::Off, ); let elapsed = start.elapsed(); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::remove_var("LEAN_CTX_COMPOSE_BUDGET_MS") }; // The exact-match + symbol stages are synchronous and index-backed, so the @@ -120,14 +120,14 @@ fn compose_surfaces_associative_neighbours() { let _guard = ENV_GUARD .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::remove_var("LEAN_CTX_COMPOSE_BUDGET_MS") }; // Generous graph budget so the (tiny) index build never times out here. - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::set_var("LEAN_CTX_COMPOSE_GRAPH_BUDGET_MS", "8000") }; let dir = write_corpus(); @@ -139,9 +139,9 @@ fn compose_surfaces_associative_neighbours() { &dir.path().to_string_lossy(), CrpMode::Off, ); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::remove_var("LEAN_CTX_COMPOSE_GRAPH_BUDGET_MS") }; assert!( @@ -184,7 +184,7 @@ fn compose_disambiguates_same_named_symbol_by_task_keywords() { let _guard = ENV_GUARD .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - // SAFETY: the suite runs with --test-threads=1 (see ci.yml). + // SAFETY: serialized by this file's local `ENV_GUARD` mutex. unsafe { std::env::remove_var("LEAN_CTX_COMPOSE_BUDGET_MS") }; let dir = write_ambiguous_symbol_corpus(); // `ctx_compose` reads symbol bodies from the graph index. Build the tiny diff --git a/rust/tests/dropin_install_tests.rs b/rust/tests/dropin_install_tests.rs index 377dda2ff..1f156830d 100644 --- a/rust/tests/dropin_install_tests.rs +++ b/rust/tests/dropin_install_tests.rs @@ -5,10 +5,8 @@ //! function with an explicit `home: &Path` argument, which keeps them race- //! free. The tests in this file cover the top-level `install_all_with_style` //! entry point, which resolves `$HOME` via `dirs::home_dir()`. Because that -//! reads the live env, these tests must run single-threaded — CI already -//! invokes `cargo test --all-features -- --test-threads=1`, and a -//! repo-local mutex below covers the case where someone runs tests -//! without that flag. +//! reads the live env, these tests must not run concurrently with each other +//! — the repo-local mutex below serializes them. //! //! The intent is to validate the cross-file behaviour: //! - All four touchpoints (.zshenv, .bashenv, .zshrc, .bashrc) end up in @@ -22,9 +20,8 @@ use std::sync::Mutex; use lean_ctx::shell_hook::{Style, install_all, install_all_with_style, uninstall_all}; /// Serialises tests in this file so concurrent `$HOME` mutation doesn't -/// race. Cargo runs each integration test binary's tests in parallel by -/// default; this guard plus CI's `--test-threads=1` keeps us correct in -/// both modes. +/// race. Cargo runs each integration test binary's tests in parallel, so this +/// guard is what keeps them correct. static HOME_LOCK: Mutex<()> = Mutex::new(()); fn with_home(f: F) { @@ -39,29 +36,19 @@ fn with_home(f: F) { // These tests validate the cross-file *install logic*, not host shell // detection (which has its own unit test). CI runners may lack zsh, so force // both shells "available" to keep the assertions host-independent. - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialised via HOME_LOCK. unsafe { std::env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all") }; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(tmp.path()))); match prev { - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialised via HOME_LOCK. Some(v) => unsafe { std::env::set_var("HOME", v) }, - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialised via HOME_LOCK. None => unsafe { std::env::remove_var("HOME") }, } match prev_force { - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialised via HOME_LOCK. Some(v) => unsafe { std::env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", v) }, - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialised via HOME_LOCK. None => unsafe { std::env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE") }, } if let Err(p) = result { diff --git a/rust/tests/intent_protocol_side_effects.rs b/rust/tests/intent_protocol_side_effects.rs index 5291172f0..bdd2e94df 100644 --- a/rust/tests/intent_protocol_side_effects.rs +++ b/rust/tests/intent_protocol_side_effects.rs @@ -3,9 +3,9 @@ use lean_ctx::core::intent_protocol; #[test] fn ctx_intent_knowledge_fact_routes_to_project_knowledge() { let tmp = tempfile::tempdir().expect("tempdir"); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var( "LEAN_CTX_DATA_DIR", @@ -31,8 +31,8 @@ fn ctx_intent_knowledge_fact_routes_to_project_knowledge() { && f.value == "v1") ); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") }; } diff --git a/rust/tests/issue_244_ledger_reset.rs b/rust/tests/issue_244_ledger_reset.rs index fe60afb6e..a1049adb8 100644 --- a/rust/tests/issue_244_ledger_reset.rs +++ b/rust/tests/issue_244_ledger_reset.rs @@ -287,11 +287,13 @@ mod file_locking { #[test] fn save_and_load_roundtrip_with_locking() { + // Held for the whole test: tests run multi-threaded, so the env writes + // below must not overlap another test's. + let _env_lock = lean_ctx::core::data_dir::test_env_lock(); let dir = std::env::temp_dir().join(format!("lean_ctx_test_lock_{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above serializes every env-mutating test in this + // binary, so no other thread touches the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap()) }; let mut ledger = ContextLedger::with_window_size(50000); @@ -305,9 +307,8 @@ mod file_locking { // Clean up let _ = std::fs::remove_dir_all(&dir); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above is still held, so no other thread touches + // the environment concurrently. unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") }; } } diff --git a/rust/tests/issue_249_index_observability.rs b/rust/tests/issue_249_index_observability.rs index ff575cb87..088956893 100644 --- a/rust/tests/issue_249_index_observability.rs +++ b/rust/tests/issue_249_index_observability.rs @@ -37,13 +37,13 @@ fn oversized_index_records_observable_not_persisted_note() { // Isolate the index store and force the "too large" branch for any non-empty // index by setting the disk ceiling to 0 MB. - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.path()) }; - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_BM25_MAX_CACHE_MB", "0") }; // A small but non-empty source tree so the build produces real chunks. @@ -87,12 +87,12 @@ fn oversized_index_records_observable_not_persisted_note() { "status_json must expose the non-persistence note, got: {status}" ); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::remove_var("LEAN_CTX_BM25_MAX_CACHE_MB") }; - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") }; } diff --git a/rust/tests/issue_326_parallel_knowledge.rs b/rust/tests/issue_326_parallel_knowledge.rs index 5e0e92917..bc5864278 100644 --- a/rust/tests/issue_326_parallel_knowledge.rs +++ b/rust/tests/issue_326_parallel_knowledge.rs @@ -10,9 +10,9 @@ use lean_ctx::core::memory_policy::MemoryPolicy; /// store is never touched. fn isolate_data_dir() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", dir.path()) }; dir } diff --git a/rust/tests/local_free_invariant.rs b/rust/tests/local_free_invariant.rs index 37fec9080..5b44462d7 100644 --- a/rust/tests/local_free_invariant.rs +++ b/rust/tests/local_free_invariant.rs @@ -42,6 +42,9 @@ fn local_and_commercial_planes_are_disjoint() { #[test] fn local_features_are_unaffected_by_license_or_plan_env() { + // Held for the whole test: tests run multi-threaded, so the env writes + // below must not overlap another test's. + let _env_lock = lean_ctx::core::data_dir::test_env_lock(); let snapshot = || { let v = capabilities_value(); LOCAL_ALWAYS_ON_FEATURES @@ -53,16 +56,14 @@ fn local_features_are_unaffected_by_license_or_plan_env() { let before = snapshot(); for var in ["LEAN_CTX_LICENSE", "LEAN_CTX_PLAN", "LEAN_CTX_ACCOUNT"] { - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above serializes every env-mutating test in this + // binary, so no other thread touches the environment concurrently. unsafe { std::env::set_var(var, "expired") }; } let after = snapshot(); for var in ["LEAN_CTX_LICENSE", "LEAN_CTX_PLAN", "LEAN_CTX_ACCOUNT"] { - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above is still held, so no other thread touches + // the environment concurrently. unsafe { std::env::remove_var(var) }; } diff --git a/rust/tests/multi_read_cap_scenarios.rs b/rust/tests/multi_read_cap_scenarios.rs index 56077e41d..c868afb24 100644 --- a/rust/tests/multi_read_cap_scenarios.rs +++ b/rust/tests/multi_read_cap_scenarios.rs @@ -25,9 +25,9 @@ fn multi_read_respects_output_cap() { let _guard = ENV_GUARD .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::set_var("LCTX_MAX_MULTI_READ_BYTES", "10000") }; let (_dir, paths) = setup_test_files(20, 5000); @@ -43,9 +43,9 @@ fn multi_read_respects_output_cap() { output.contains("file(s) skipped"), "must report skipped files" ); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::remove_var("LCTX_MAX_MULTI_READ_BYTES") }; } @@ -54,9 +54,9 @@ fn multi_read_no_cap_when_under_limit() { let _guard = ENV_GUARD .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::set_var("LCTX_MAX_MULTI_READ_BYTES", "1000000") }; let (_dir, paths) = setup_test_files(3, 100); @@ -68,9 +68,9 @@ fn multi_read_no_cap_when_under_limit() { "should not cap when under limit" ); assert!(output.contains("Read 3 files"), "should read all files"); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::remove_var("LCTX_MAX_MULTI_READ_BYTES") }; } @@ -89,9 +89,9 @@ fn multi_read_single_large_file_passes() { let _guard = ENV_GUARD .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::set_var("LCTX_MAX_MULTI_READ_BYTES", "100000") }; let (_dir, paths) = setup_test_files(1, 50000); @@ -106,8 +106,8 @@ fn multi_read_single_large_file_passes() { !output.contains("Output capped"), "single file should not trigger cap" ); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: serialized by this file's local `ENV_GUARD` mutex, which every + + // env-mutating test here holds for its whole body. unsafe { std::env::remove_var("LCTX_MAX_MULTI_READ_BYTES") }; } diff --git a/rust/tests/ocla_integration_suite.rs b/rust/tests/ocla_integration_suite.rs index 6e7c70cc1..7a78399cc 100644 --- a/rust/tests/ocla_integration_suite.rs +++ b/rust/tests/ocla_integration_suite.rs @@ -1,5 +1,4 @@ use std::collections::HashSet; -use std::sync::MutexGuard; use std::time::{Duration, Instant}; use axum::body::Body; @@ -28,7 +27,7 @@ use tower::ServiceExt; struct IsolatedDataDir { temp: tempfile::TempDir, - _lock: MutexGuard<'static, ()>, + _lock: lean_ctx::core::data_dir::TestEnvGuard, previous_data_dir: Option, previous_ledger_setting: Option, } diff --git a/rust/tests/ocp_self_validation.rs b/rust/tests/ocp_self_validation.rs index 7aad3b0c9..849750ba4 100644 --- a/rust/tests/ocp_self_validation.rs +++ b/rust/tests/ocp_self_validation.rs @@ -37,9 +37,9 @@ fn assert_valid(v: &jsonschema::Validator, instance: &serde_json::Value, what: & #[test] fn engine_output_validates_against_ocp_schemas() { let tmp = tempfile::tempdir().expect("tempdir"); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path()) }; part1_context_ir(); @@ -48,9 +48,9 @@ fn engine_output_validates_against_ocp_schemas() { part4_evidence_chain(); part5_events(); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") }; } diff --git a/rust/tests/plugin_hooks_e2e.rs b/rust/tests/plugin_hooks_e2e.rs index 47a9b29e0..dab062791 100644 --- a/rust/tests/plugin_hooks_e2e.rs +++ b/rust/tests/plugin_hooks_e2e.rs @@ -43,9 +43,9 @@ fn plugin_observes_real_read_event() { .expect("manifest"); // Point the global registry at our isolated root, then activate it. - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_PLUGINS_DIR", root.path()) }; PluginManager::init(); assert!( diff --git a/rust/tests/plugin_tools_e2e.rs b/rust/tests/plugin_tools_e2e.rs index 544b8acc8..cf374fbfc 100644 --- a/rust/tests/plugin_tools_e2e.rs +++ b/rust/tests/plugin_tools_e2e.rs @@ -29,9 +29,9 @@ fn manifest_tool_is_discovered_registered_and_invocable() { ) .expect("manifest"); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_PLUGINS_DIR", root.path()) }; PluginManager::init(); diff --git a/rust/tests/quality_loop_golden.rs b/rust/tests/quality_loop_golden.rs index 685409497..cdc6140a2 100644 --- a/rust/tests/quality_loop_golden.rs +++ b/rust/tests/quality_loop_golden.rs @@ -30,9 +30,9 @@ fn params_for(path: &str, old_string: &str) -> EditParams { #[test] fn edit_fail_after_map_read_escalates_and_penalizes() { let tmp = tempfile::tempdir().unwrap(); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: this integration-test binary contains a single `#[test]`, so no + + // other thread reads or writes the environment concurrently. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path()) }; let file = tmp.path().join("golden.rs"); diff --git a/rust/tests/savings_verification.rs b/rust/tests/savings_verification.rs index c3cf0ed52..f0489da14 100644 --- a/rust/tests/savings_verification.rs +++ b/rust/tests/savings_verification.rs @@ -195,15 +195,18 @@ fn verify_overall_savings_estimation() { fn verify_cep_delta_tracking_prevents_overcounting() { use std::collections::HashMap; + // Held for the whole test: tests run multi-threaded, so the env writes + // below must not overlap another test's. + let _env_lock = lean_ctx::core::data_dir::test_env_lock(); + let test_dir = std::env::temp_dir().join(format!("lean-ctx-cep-test-{}", std::process::id())); let lean_ctx_dir = test_dir.join(".lean-ctx"); let _ = std::fs::create_dir_all(&lean_ctx_dir); let stats_path = lean_ctx_dir.join("stats.json"); let _ = std::fs::remove_file(&stats_path); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above serializes every env-mutating test in this + // binary, so no other thread touches the environment concurrently. unsafe { std::env::set_var( "LEAN_CTX_DATA_DIR", @@ -254,9 +257,8 @@ fn verify_cep_delta_tracking_prevents_overcounting() { eprintln!(" Without fix: totals would be 3000/1800 (1000+2000 / 600+1200)"); let _ = std::fs::remove_dir_all(&test_dir); - // SAFETY: the project's test suite always runs with `--test-threads=1` - // (env-race legacy — see .github/workflows/ci.yml), so no other test in - // this binary touches the environment concurrently. + // SAFETY: `_env_lock` above is still held, so no other thread touches the + // environment concurrently. unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") }; } diff --git a/rust/tests/suite/performance_stress.rs b/rust/tests/suite/performance_stress.rs index 2f943dc61..a3102c387 100644 --- a/rust/tests/suite/performance_stress.rs +++ b/rust/tests/suite/performance_stress.rs @@ -4,9 +4,19 @@ //! above typical wall-clock so hosted-runner CPU contention (observed 2x+ //! slowdowns on otherwise green runs) never flakes the gate, while a real //! algorithmic regression still lands far above the bound. +//! +//! That slack is not enough once the suite itself runs multi-threaded: the +//! runner is no longer quiet, and 500-chunk attention assembly measured 733ms +//! against a 350ms bound purely from CPU contention. The *timing* half of each +//! guard is therefore opt-in — CI's perf-gate step sets `LEAN_CTX_PERF_GATE=1` +//! and runs these serialized. The functional assertions run always, everywhere. use std::time::Instant; +fn timing_enforced() -> bool { + std::env::var("LEAN_CTX_PERF_GATE").as_deref() == Ok("1") +} + mod bm25_performance { use super::*; use lean_ctx::core::bm25_index::BM25Index; @@ -31,7 +41,7 @@ mod bm25_performance { assert!(!results.is_empty()); assert!( - elapsed.as_millis() < 100, + !timing_enforced() || elapsed.as_millis() < 100, "BM25 search over 500-file corpus took {}ms — must be <100ms", elapsed.as_millis() ); @@ -56,7 +66,7 @@ mod bm25_performance { let elapsed = start.elapsed(); assert!( - elapsed.as_millis() < 500, + !timing_enforced() || elapsed.as_millis() < 500, "100 BM25 searches took {}ms — must be <500ms", elapsed.as_millis() ); @@ -93,7 +103,7 @@ mod hnsw_stress { assert_eq!(results.len(), 20); assert!( - elapsed.as_millis() < 1000, + !timing_enforced() || elapsed.as_millis() < 1000, "Top-20 from 10K 384d vectors took {}ms — must be <1000ms", elapsed.as_millis() ); @@ -143,7 +153,7 @@ mod homeostasis_stress { let elapsed = start.elapsed(); assert!( - elapsed.as_micros() < 50_000, + !timing_enforced() || elapsed.as_micros() < 50_000, "1000 homeostasis evaluations took {}µs — must be <50000µs", elapsed.as_micros() ); @@ -194,7 +204,7 @@ mod hebbian_stress { let elapsed = start.elapsed(); assert!( - elapsed.as_millis() < 100, + !timing_enforced() || elapsed.as_millis() < 100, "200 bursts with 3 files each took {}ms — must be <100ms", elapsed.as_millis() ); @@ -223,7 +233,7 @@ mod hebbian_stress { // green run purely from CPU contention. let limit_us = if cfg!(windows) { 200_000 } else { 100_000 }; assert!( - elapsed.as_micros() < limit_us, + !timing_enforced() || elapsed.as_micros() < limit_us, "Evicting 100 from 1000 entries took {}µs — must be <{limit_us}µs", elapsed.as_micros() ); @@ -253,7 +263,7 @@ mod predictive_coding_stress { let elapsed = start.elapsed(); assert!( - elapsed.as_millis() < 250, + !timing_enforced() || elapsed.as_millis() < 250, "Delta computation for 2000-line file took {}ms — must be <250ms", elapsed.as_millis() ); @@ -276,7 +286,7 @@ mod predictive_coding_stress { let elapsed = start.elapsed(); assert!( - elapsed.as_millis() < 250, + !timing_enforced() || elapsed.as_millis() < 250, "Delta of identical 5000-line file took {}ms — must be <250ms", elapsed.as_millis() ); @@ -311,7 +321,7 @@ mod attention_stress { assert_eq!(result.len(), 500); // 350ms budget for debug builds on shared CI runners; release is ~10x faster assert!( - elapsed.as_millis() < 350, + !timing_enforced() || elapsed.as_millis() < 350, "Attention assembly of 500 chunks took {}ms — must be <350ms", elapsed.as_millis() );