Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,66 @@ pub fn is_excluded(file_path: &str, config: &TraceDecayConfig) -> bool {
false
}

/// Serializes lib unit tests that mutate process-wide storage env vars
/// (`TRACEDECAY_DATA_DIR` and related HOME/profile pins). Parallel tests
/// otherwise race on profile resolution and hook analytics paths.
#[cfg(test)]
pub static USER_DATA_DIR_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Acquires [`USER_DATA_DIR_TEST_LOCK`], recovering even when poisoned.
#[cfg(test)]
pub fn lock_user_data_dir_test_env() -> std::sync::MutexGuard<'static, ()> {
USER_DATA_DIR_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// Pins [`USER_DATA_DIR_ENV`] to an isolated temp profile while holding
/// [`USER_DATA_DIR_TEST_LOCK`], so parallel lib tests cannot race profile
/// resolution during `TraceDecay::init` / indexing.
#[cfg(test)]
pub struct PinnedUserDataDir {
_lock: std::sync::MutexGuard<'static, ()>,
_root: tempfile::TempDir,
previous: Option<OsString>,
}

#[cfg(test)]
impl PinnedUserDataDir {
pub fn new() -> Self {
let lock = lock_user_data_dir_test_env();
let root = tempfile::TempDir::new()
.unwrap_or_else(|err| panic!("failed to create temp profile dir: {err}"));
let profile = root.path().join(TRACEDECAY_DIR);
fs::create_dir_all(&profile)
.unwrap_or_else(|err| panic!("failed to create isolated profile root: {err}"));
let previous = std::env::var_os(USER_DATA_DIR_ENV);
std::env::set_var(USER_DATA_DIR_ENV, &profile);
Self {
_lock: lock,
_root: root,
previous,
}
}
}

#[cfg(test)]
impl Default for PinnedUserDataDir {
fn default() -> Self {
Self::new()
}
}

#[cfg(test)]
impl Drop for PinnedUserDataDir {
fn drop(&mut self) {
match self.previous.take() {
Some(previous) => std::env::set_var(USER_DATA_DIR_ENV, previous),
None => std::env::remove_var(USER_DATA_DIR_ENV),
}
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;
10 changes: 4 additions & 6 deletions src/config/tests.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
use super::{
db_filename, get_project_db_path, get_tracedecay_dir, is_excluded, is_excluded_dir,
is_ignored_by_explicit_global_excludes, is_ignored_by_git, is_included, user_data_dir,
TraceDecayConfig, USER_DATA_DIR_ENV,
is_ignored_by_explicit_global_excludes, is_ignored_by_git, is_included,
lock_user_data_dir_test_env, user_data_dir, TraceDecayConfig, USER_DATA_DIR_ENV,
};
use std::ffi::OsString;
use std::fs;
use std::process::Command;
use tempfile::TempDir;

static USER_DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

struct EnvRestore {
key: &'static str,
previous: Option<OsString>,
Expand Down Expand Up @@ -58,7 +56,7 @@ fn test_data_dir_uses_tracedecay_when_present() {
#[cfg(unix)]
#[test]
fn user_data_dir_canonicalizes_symlinked_existing_parent() {
let _lock = USER_DATA_DIR_ENV_LOCK.lock().unwrap();
let _lock = lock_user_data_dir_test_env();
let root = TempDir::new().unwrap();
let real_home = root.path().join("real-home");
let linked_home = root.path().join("linked-home");
Expand Down Expand Up @@ -294,7 +292,7 @@ fn partial_sync_table_fills_missing_fields_with_defaults() {

#[test]
fn sync_config_env_overrides_bool_and_int() {
let _lock = USER_DATA_DIR_ENV_LOCK.lock().unwrap();
let _lock = lock_user_data_dir_test_env();
let _watch = EnvRestore::set("TRACEDECAY_SYNC_AUTO_WATCH", "false");
let _debounce = EnvRestore::set("TRACEDECAY_SYNC_WATCH_DEBOUNCE_MS", "5000");
// Unparsable ints/bools are ignored (field keeps its base value).
Expand Down
22 changes: 10 additions & 12 deletions src/daemon/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -869,16 +869,14 @@ fn launchctl_stderr_is_not_loaded(stderr: &str) -> bool {
mod tests {
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::sync::Mutex;

#[cfg(target_os = "linux")]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use tempfile::TempDir;

use super::{DaemonServiceSpec, LaunchctlFailureMode, LaunchdCommand};

static ENV_LOCK: Mutex<()> = Mutex::new(());
use crate::config::lock_user_data_dir_test_env;

struct EnvVarGuard {
key: &'static str,
Expand Down Expand Up @@ -938,7 +936,7 @@ mod tests {
#[cfg(target_os = "macos")]
#[test]
fn service_status_includes_launchd_debug_commands() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let profile = tempfile::TempDir::new().expect("profile temp dir");
let _data_dir_guard = EnvVarGuard::set(crate::config::USER_DATA_DIR_ENV, profile.path());

Expand Down Expand Up @@ -1022,7 +1020,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn render_launchd_plist_includes_program_arguments_socket_logs_and_label() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let profile = tempfile::TempDir::new().expect("profile temp dir");
let home = tempfile::TempDir::new().expect("home temp dir");
let _home_guard = EnvVarGuard::set("HOME", home.path());
Expand Down Expand Up @@ -1061,7 +1059,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn render_launchd_plist_escapes_xml_and_parser_unescapes_socket_path() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let profile = tempfile::TempDir::new().expect("profile temp dir");
let home = tempfile::TempDir::new().expect("home temp dir");
let _home_guard = EnvVarGuard::set("HOME", home.path());
Expand Down Expand Up @@ -1117,7 +1115,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn launchd_plist_env_value_round_trips_data_dir_override() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let profile = tempfile::TempDir::new().expect("profile temp dir");
let home = tempfile::TempDir::new().expect("home temp dir");
let _home_guard = EnvVarGuard::set("HOME", home.path());
Expand All @@ -1139,7 +1137,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn launchd_plist_env_value_ignores_plist_without_override() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let profile = tempfile::TempDir::new().expect("profile temp dir");
let home = tempfile::TempDir::new().expect("home temp dir");
let _home_guard = EnvVarGuard::set("HOME", home.path());
Expand Down Expand Up @@ -1227,7 +1225,7 @@ mod tests {
#[cfg(target_os = "linux")]
#[test]
fn refresh_service_rewrites_unit_and_restarts_daemon() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let dir = TempDir::new().expect("temp dir");
let config_home = dir.path().join("config");
let fake_bin = dir.path().join("bin");
Expand Down Expand Up @@ -1276,7 +1274,7 @@ mod tests {
#[cfg(target_os = "linux")]
#[test]
fn refresh_installed_service_skips_missing_unit() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let dir = TempDir::new().expect("temp dir");
let config_home = dir.path().join("config");
let fake_bin = dir.path().join("bin");
Expand Down Expand Up @@ -1305,7 +1303,7 @@ mod tests {
#[cfg(target_os = "linux")]
#[test]
fn refresh_installed_service_preserves_existing_socket_path() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let dir = TempDir::new().expect("temp dir");
let config_home = dir.path().join("config");
let fake_bin = dir.path().join("bin");
Expand Down Expand Up @@ -1365,7 +1363,7 @@ mod tests {

#[test]
fn default_socket_path_is_profile_scoped_not_project_scoped() {
let _env_lock = ENV_LOCK.lock().expect("env lock");
let _env_lock = lock_user_data_dir_test_env();
let profile = tempfile::TempDir::new().expect("profile temp dir");
let project_a = tempfile::TempDir::new().expect("project a temp dir");
let project_b = tempfile::TempDir::new().expect("project b temp dir");
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ mod tests {

#[test]
fn codex_prompt_hints_dedupe_by_session_and_category() {
let _lock = crate::hooks::test_env_lock().lock().unwrap();
let _lock = crate::hooks::lock_test_env();
let project = tempfile::tempdir().unwrap();
let profile = tempfile::tempdir().unwrap();
let project_root = project.path().canonicalize().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ mod tests {
/// so a prompt-shaped trigger steers identically on both agents.
#[test]
fn cursor_prompt_hint_runs_decide_hint_and_dedupes_per_session() {
let _lock = crate::hooks::test_env_lock().lock().unwrap();
let _lock = crate::hooks::lock_test_env();
let project = tempfile::tempdir().unwrap();
let profile = tempfile::tempdir().unwrap();
let project_root = project.path().canonicalize().unwrap();
Expand Down
15 changes: 6 additions & 9 deletions src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(test)]
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};

use serde_json::Value;
Expand Down Expand Up @@ -157,9 +155,8 @@ fn now_unix_secs() -> i64 {
}

#[cfg(test)]
pub(crate) fn test_env_lock() -> &'static Mutex<()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK.get_or_init(|| Mutex::new(()))
pub(crate) fn lock_test_env() -> std::sync::MutexGuard<'static, ()> {
crate::config::lock_user_data_dir_test_env()
}

/// Mints a unique id for one hint candidate so its `hint_candidate` row and its
Expand Down Expand Up @@ -661,7 +658,7 @@ mod hint_analytics_tests {

#[test]
fn record_hint_emitted_missing_session_is_single_terminal() {
let _lock = super::test_env_lock().lock().unwrap();
let _lock = super::lock_test_env();
let project = tempfile::tempdir().unwrap();
let profile = tempfile::tempdir().unwrap();
let project_root = project.path().canonicalize().unwrap();
Expand Down Expand Up @@ -689,7 +686,7 @@ mod hint_analytics_tests {
/// known.
#[test]
fn every_hint_branch_yields_exactly_one_terminal_with_hint_id() {
let _lock = super::test_env_lock().lock().unwrap();
let _lock = super::lock_test_env();
let project = tempfile::tempdir().unwrap();
let profile = tempfile::tempdir().unwrap();
let project_root = project.path().canonicalize().unwrap();
Expand Down Expand Up @@ -784,7 +781,7 @@ mod hint_analytics_tests {
/// terminal, and no hint is returned to the caller.
#[test]
fn budget_exhaustion_records_suppressed_budget_terminal() {
let _lock = super::test_env_lock().lock().unwrap();
let _lock = super::lock_test_env();
let project = tempfile::tempdir().unwrap();
let profile = tempfile::tempdir().unwrap();
let project_root = project.path().canonicalize().unwrap();
Expand Down Expand Up @@ -848,7 +845,7 @@ mod hint_analytics_tests {
/// stronger re-hint recorded as `hint_escalated`, with the escalation prefix.
#[test]
fn repeated_usage_records_hint_escalated_terminal() {
let _lock = super::test_env_lock().lock().unwrap();
let _lock = super::lock_test_env();
let project = tempfile::tempdir().unwrap();
let profile = tempfile::tempdir().unwrap();
let project_root = project.path().canonicalize().unwrap();
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/response_handles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,3 +659,11 @@ fn clipped_handle_for_log(handle: &str) -> String {
clipped
}
}

/// Serializes lib unit tests that store response handles under the
/// process-global profile root (`TRACEDECAY_DATA_DIR`). Uses the shared
/// user-data-dir test lock so env mutation cannot race profile resolution.
#[cfg(test)]
pub(crate) fn lock_response_handle_store() -> std::sync::MutexGuard<'static, ()> {
crate::config::lock_user_data_dir_test_env()
}
6 changes: 3 additions & 3 deletions src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1372,7 +1372,7 @@ impl McpServer {
return;
}

self.spawn_read_refresh_task(Arc::clone(cg), self.sync_config.full_sync_escalation_files);
self.spawn_read_refresh_task(cg, self.sync_config.full_sync_escalation_files);
}

/// Spawns the detached D4 refresh task. The task owns cheap `Arc` clones
Expand All @@ -1383,7 +1383,7 @@ impl McpServer {
///
/// The caller MUST have already set `background_refresh_running` to
/// `true`; this task clears it on completion.
fn spawn_read_refresh_task(&self, cg: Arc<TraceDecay>, escalation: usize) {
fn spawn_read_refresh_task(&self, cg: &Arc<TraceDecay>, escalation: usize) {
let running = Arc::clone(&self.background_refresh_running);
let done_at = Arc::clone(&self.last_background_refresh_done_at);
let token_map = Arc::clone(&self.file_token_map);
Expand Down Expand Up @@ -1461,7 +1461,7 @@ impl McpServer {
}
self.last_background_refresh_at
.store(now, Ordering::Release);
self.spawn_read_refresh_task(cg, self.sync_config.full_sync_escalation_files);
self.spawn_read_refresh_task(&cg, self.sync_config.full_sync_escalation_files);
}

/// Returns a compact one-line notice when automation runs have staged
Expand Down
10 changes: 6 additions & 4 deletions src/mcp/server/freshness_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::{format_index_age_phrase, staleness_banner, McpServer, StalenessBannerInputs};
use crate::config::PinnedUserDataDir;
use crate::tracedecay::TraceDecay;
use std::sync::atomic::Ordering;
use std::time::Duration;
Expand All @@ -15,7 +16,8 @@ fn git(root: &std::path::Path, args: &[&str]) {
assert!(ok, "git {args:?} failed");
}

async fn init_indexed_repo() -> (TraceDecay, TempDir) {
async fn init_indexed_repo() -> (TraceDecay, TempDir, PinnedUserDataDir) {
let pin = PinnedUserDataDir::new();
let dir = TempDir::new().unwrap();
let root = dir.path();
git(root, &["init", "-q", "-b", "main"]);
Expand All @@ -28,7 +30,7 @@ async fn init_indexed_repo() -> (TraceDecay, TempDir) {
git(root, &["commit", "-q", "-m", "initial"]);
let cg = TraceDecay::init(root).await.expect("init");
cg.index_all().await.expect("index");
(cg, dir)
(cg, dir, pin)
}

// ---- D7 pure-logic banner tests (test c) --------------------------
Expand Down Expand Up @@ -113,7 +115,7 @@ fn banner_instructs_manual_sync_when_auto_sync_disabled() {

#[tokio::test]
async fn startup_catch_up_spawned_once_per_server() {
let (cg, _dir) = init_indexed_repo().await;
let (cg, _dir, _pin) = init_indexed_repo().await;
let server = McpServer::new(cg, None).await;
// The D1 spawn should have claimed the one-shot flag.
assert!(
Expand Down Expand Up @@ -144,7 +146,7 @@ async fn startup_catch_up_spawned_once_per_server() {

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn read_refresh_is_non_blocking_and_single_flighted() {
let (cg, dir) = init_indexed_repo().await;
let (cg, dir, _pin) = init_indexed_repo().await;
let root = dir.path().to_path_buf();
let mut config = crate::config::load_config(&root).expect("load config");
config.sync.session_start_sync = false;
Expand Down
Loading
Loading