huskies: merge 1111 bug Test isolation: init_for_test() and ensure_content_store() are once-per-thread, not once-per-test, polluting CRDT state across tests

This commit is contained in:
dave
2026-05-17 00:28:48 +00:00
parent f8212f102f
commit a40500eea9
9 changed files with 111 additions and 36 deletions
+42 -34
View File
@@ -122,49 +122,57 @@ pub(super) fn get_crdt() -> Option<&'static Mutex<CrdtState>> {
/// This avoids the async SQLite setup from `init()`. Ops are sent to a
/// channel whose receiver is leaked (so nothing is persisted, but the channel
/// stays open and `apply_and_persist` succeeds silently).
/// Safe to call multiple times — subsequent calls are no-ops (thread-local).
/// Always resets all thread-local state so each call produces a clean slate —
/// no cross-test pollution when two tests share the same thread.
#[cfg(test)]
pub fn init_for_test() {
// Initialise thread-local CRDT for test isolation.
// Only creates a new CRDT if one isn't set yet on this thread;
// subsequent calls are no-ops (matching the old OnceLock semantics
// while keeping each thread isolated).
let keypair = make_keypair();
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
let (persist_tx, rx) = mpsc::unbounded_channel();
// Leak the receiver so the channel stays open: apply_and_persist
// can then send without error, preventing [crdt_persist] WARNs
// from racing with other tests that watch the global log buffer.
std::mem::forget(rx);
let fresh = CrdtState {
crdt,
keypair,
index: HashMap::new(),
node_index: HashMap::new(),
token_index: HashMap::new(),
merge_job_index: HashMap::new(),
active_agent_index: HashMap::new(),
test_job_index: HashMap::new(),
agent_throttle_index: HashMap::new(),
gateway_project_index: HashMap::new(),
persist_tx,
lamport_floor: 0,
tombstones: HashSet::new(),
};
CRDT_STATE_TL.with(|lock| {
if lock.get().is_none() {
let keypair = make_keypair();
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
let (persist_tx, rx) = mpsc::unbounded_channel();
// Leak the receiver so the channel stays open: apply_and_persist
// can then send without error, preventing [crdt_persist] WARNs
// from racing with other tests that watch the global log buffer.
std::mem::forget(rx);
let state = CrdtState {
crdt,
keypair,
index: HashMap::new(),
node_index: HashMap::new(),
token_index: HashMap::new(),
merge_job_index: HashMap::new(),
active_agent_index: HashMap::new(),
test_job_index: HashMap::new(),
agent_throttle_index: HashMap::new(),
gateway_project_index: HashMap::new(),
persist_tx,
lamport_floor: 0,
tombstones: HashSet::new(),
};
let _ = lock.set(Mutex::new(state));
if let Some(mutex) = lock.get() {
// Already set on this thread — replace contents so the second
// (and subsequent) test on the same thread starts clean.
*mutex.lock().unwrap() = fresh;
} else {
let _ = lock.set(Mutex::new(fresh));
}
});
let _ = statics::CRDT_EVENT_TX.get_or_init(|| broadcast::channel::<CrdtEvent>(256).0);
let _ = statics::SYNC_TX.get_or_init(|| broadcast::channel::<SignedOp>(1024).0);
// Per-thread op journal + vector clock — keeps parallel tests' writes
// from corrupting each other's view of ALL_OPS (notably, one thread's
// `apply_compaction` could otherwise prune another thread's ops).
// Per-thread op journal + vector clock — always cleared so a second test
// on the same thread cannot see ops written by the first.
statics::ALL_OPS_TL.with(|lock| {
let _ = lock.set(Mutex::new(Vec::new()));
if let Some(mutex) = lock.get() {
mutex.lock().unwrap().clear();
} else {
let _ = lock.set(Mutex::new(Vec::new()));
}
});
statics::VECTOR_CLOCK_TL.with(|lock| {
let _ = lock.set(Mutex::new(VectorClock::new()));
if let Some(mutex) = lock.get() {
mutex.lock().unwrap().clear();
} else {
let _ = lock.set(Mutex::new(VectorClock::new()));
}
});
}
+43 -2
View File
@@ -165,7 +165,9 @@ pub fn delete_content(key: ContentKey<'_>) {
/// Ensure the in-memory content store is initialised.
///
/// Safe to call multiple times — the `OnceLock` is set at most once.
/// In non-test builds: init-once via `OnceLock` (safe to call multiple times).
/// In test builds: always resets `CONTENT_STORE_TL` to an empty `HashMap` so
/// each test on the same thread starts with a clean store.
pub fn ensure_content_store() {
#[cfg(not(test))]
{
@@ -175,7 +177,11 @@ pub fn ensure_content_store() {
#[cfg(test)]
{
CONTENT_STORE_TL.with(|lock| {
if lock.get().is_none() {
if let Some(mutex) = lock.get() {
// Already initialised on this thread — reset to empty so the
// next test does not see content written by a previous test.
mutex.lock().unwrap().clear();
} else {
let _ = lock.set(Mutex::new(HashMap::new()));
}
});
@@ -203,6 +209,41 @@ pub(super) fn init_content_store(map: HashMap<String, String>) {
mod tests {
use super::*;
/// Regression: two sequential `ensure_content_store()` + write + read cycles
/// in the same test body must not see each other's content. Before the fix,
/// `ensure_content_store()` was a no-op on the second call (OnceLock gating),
/// so the second cycle could read items written in the first cycle.
#[test]
fn sequential_ensure_content_store_resets_state() {
// ── Cycle 1 ──────────────────────────────────────────────────────────
ensure_content_store();
write_content(ContentKey::Story("1111_cycle1"), "cycle-one body");
assert_eq!(
read_content(ContentKey::Story("1111_cycle1")).as_deref(),
Some("cycle-one body"),
"cycle 1: item must be readable after write"
);
// ── Cycle 2: reset, write a different item ────────────────────────────
ensure_content_store();
// Cycle-1 item must no longer be visible.
assert!(
read_content(ContentKey::Story("1111_cycle1")).is_none(),
"cycle 2: store must be empty; cycle-1 content must not bleed through"
);
write_content(ContentKey::Story("1111_cycle2"), "cycle-two body");
assert_eq!(
read_content(ContentKey::Story("1111_cycle2")).as_deref(),
Some("cycle-two body"),
"cycle 2: own item must be readable"
);
// And cycle-1 key must still be absent.
assert!(
read_content(ContentKey::Story("1111_cycle1")).is_none(),
"cycle 2: cycle-1 content must remain absent after cycle-2 write"
);
}
/// AC 2 regression: writing under `ContentKey::Story` is not visible under
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
/// runtime substring matching, enforces the separation.
+6
View File
@@ -72,6 +72,12 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta:
.and_then(|d| serde_json::to_string(d).ok());
// Update in-memory content store.
// In test builds, the caller (test setup) is responsible for calling
// ensure_content_store() once before writing — calling it here would
// reset the store on every write, losing items from prior writes in the
// same test. In production, the lazy-init call is safe because nothing
// resets the store between writes.
#[cfg(not(test))]
ensure_content_store();
write_content(ContentKey::Story(story_id), content);
+1
View File
@@ -86,6 +86,7 @@ mod tests {
use crate::http::test_helpers::test_ctx;
fn setup_git_repo_in(dir: &std::path::Path) {
crate::db::ensure_content_store();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir)
@@ -115,6 +115,7 @@ mod tests {
#[test]
fn tool_create_refactor_accepts_single_criterion() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_refactor(
@@ -146,6 +147,7 @@ mod tests {
#[test]
fn tool_create_refactor_accepts_mixed_junk_and_real_acceptance_criteria() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_refactor(
+4
View File
@@ -118,6 +118,7 @@ mod tests {
#[test]
fn tool_create_spike_creates_file() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
@@ -147,6 +148,7 @@ mod tests {
#[test]
fn tool_create_spike_creates_file_without_description() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
@@ -202,6 +204,7 @@ mod tests {
#[test]
fn tool_create_spike_accepts_single_criterion() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_spike(
@@ -233,6 +236,7 @@ mod tests {
#[test]
fn tool_create_spike_accepts_mixed_junk_and_real_acceptance_criteria() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_spike(
@@ -256,6 +256,7 @@ mod tests {
#[test]
fn tool_create_story_accepts_single_criterion() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_story(
@@ -283,6 +284,7 @@ mod tests {
#[test]
fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_story(
@@ -299,6 +301,7 @@ mod tests {
#[test]
fn tool_create_story_description_is_written_to_file() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
@@ -368,6 +371,7 @@ mod tests {
#[test]
fn tool_create_story_html_sanitised_in_name() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
// HTML in name is sanitised (not rejected)
@@ -124,6 +124,7 @@ mod tests {
#[test]
fn tool_create_story_and_list_upcoming() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// No git repo needed: spike 61 — create_story just writes the file;
// the filesystem watcher handles the commit asynchronously.
@@ -6,6 +6,7 @@ use super::spike::create_spike_file;
use std::fs;
fn setup_git_repo(root: &std::path::Path) {
crate::db::ensure_content_store();
std::process::Command::new("git")
.args(["init"])
.current_dir(root)
@@ -166,6 +167,7 @@ fn extract_bug_name_from_content_parses_heading() {
#[test]
fn create_bug_file_writes_correct_content() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
@@ -257,6 +259,7 @@ fn create_bug_file_rejects_empty_acceptance_criteria() {
#[test]
fn create_spike_file_writes_correct_content() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let spike_id = create_spike_file(
@@ -294,6 +297,7 @@ fn create_spike_file_writes_correct_content() {
#[test]
fn create_spike_file_uses_description_when_provided() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let description = "What is the best approach for watching filesystem events?";
@@ -319,6 +323,7 @@ fn create_spike_file_uses_description_when_provided() {
#[test]
fn create_spike_file_uses_placeholder_when_no_description() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let spike_id = create_spike_file(
tmp.path(),
@@ -350,6 +355,7 @@ fn create_spike_file_rejects_empty_name() {
#[test]
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
let name = "Spike: compare \"fast\" vs slow encoders";
let result = create_spike_file(
@@ -423,6 +429,7 @@ fn create_bug_file_with_depends_on_persists_to_crdt() {
#[test]
fn create_bug_file_without_depends_on_omits_field() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
@@ -474,6 +481,7 @@ fn create_refactor_file_with_depends_on_persists_to_crdt() {
#[test]
fn create_refactor_file_without_depends_on_omits_field() {
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());