// Integration tests for Issue #326: parallel MCP `remember` calls clobbered // each other (lost updates) and could leave `knowledge.json` with trailing // JSON garbage. The fix serializes the read-modify-write under a per-project // lock and writes the file atomically (temp + rename). use lean_ctx::core::knowledge::ProjectKnowledge; use lean_ctx::core::memory_policy::MemoryPolicy; /// Sets an isolated data dir for this test binary so the real `~/.lean-ctx` /// store is never touched. fn isolate_data_dir() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); // TODO: Audit that the environment access only happens in single-threaded code. unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", dir.path()) }; dir } // A single `#[test]` keeps the process-global `LEAN_CTX_DATA_DIR` override // race-free (cargo runs test fns in this binary on parallel threads). #[test] fn parallel_writes_are_lossless_atomic_and_clean() { let _data = isolate_data_dir(); parallel_remember_preserves_every_fact_and_valid_json(); save_is_atomic_and_leaves_no_temp_files(); } fn parallel_remember_preserves_every_fact_and_valid_json() { // A real, unique project directory (no identity markers → stable hash). let project = tempfile::tempdir().unwrap(); let root = project.path().to_string_lossy().to_string(); // Seven distinct facts written from seven threads at once — the exact // scenario from the bug report. let facts = [ ("architecture", "project-purpose", "context layer"), ("architecture", "example-projects", "samples dir"), ("architecture", "skill-pattern", "skill md"), ("deployment", "build-run", "cargo build"), ("conventions", "env-shell", "bash"), ("conventions", "repo-boundary", "single repo"), ("conventions", "lean-ctx-note", "use mcp"), ]; let handles: Vec<_> = facts .iter() .map(|(cat, key, val)| { let root = root.clone(); let cat = (*cat).to_string(); let key = (*key).to_string(); let val = (*val).to_string(); std::thread::spawn(move || { let policy = MemoryPolicy::default(); ProjectKnowledge::mutate_locked(&root, |k| { k.remember(&cat, &key, &val, "sess", 0.8, &policy); }) .expect("mutate_locked must succeed"); }) }) .collect(); for h in handles { h.join().expect("thread panicked"); } // All seven facts must survive — no lost updates. let knowledge = ProjectKnowledge::load(&root).expect("store must exist"); for (cat, key, _) in &facts { assert!( knowledge .facts .iter() .any(|f| &f.category == cat && &f.key == key), "fact {cat}/{key} was lost to a write race" ); } assert_eq!(knowledge.facts.len(), facts.len(), "expected all 7 facts"); // The on-disk file must be valid JSON (no trailing garbage). let data_dir = lean_ctx::core::data_dir::lean_ctx_data_dir().unwrap(); let path = data_dir .join("knowledge") .join(&knowledge.project_hash) .join("knowledge.json"); let content = std::fs::read_to_string(&path).unwrap(); serde_json::from_str::(&content) .expect("knowledge.json must be valid JSON after concurrent writes"); } fn save_is_atomic_and_leaves_no_temp_files() { let project = tempfile::tempdir().unwrap(); let root = project.path().to_string_lossy().to_string(); let policy = MemoryPolicy::default(); let (knowledge, ()) = ProjectKnowledge::mutate_locked(&root, |k| { k.remember("conventions", "lang", "rust", "sess", 0.9, &policy); }) .unwrap(); let data_dir = lean_ctx::core::data_dir::lean_ctx_data_dir().unwrap(); let dir = data_dir.join("knowledge").join(&knowledge.project_hash); // The committed file exists and parses… let content = std::fs::read_to_string(dir.join("knowledge.json")).unwrap(); serde_json::from_str::(&content).unwrap(); // …and the atomic write left no `.tmp.*` siblings behind. let leftover = std::fs::read_dir(&dir) .unwrap() .filter_map(Result::ok) .any(|e| e.file_name().to_string_lossy().contains(".tmp.")); assert!(!leftover, "atomic write must not leave temp files"); }