26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
113 lines
4.4 KiB
Rust
113 lines
4.4 KiB
Rust
// 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::<serde_json::Value>(&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::<serde_json::Value>(&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");
|
|
}
|