//! One writer at a time, per ledger file. //! //! Every Freenet slice keeps a small JSON ledger beside the node's data and //! mutates it in the same shape: load → talk to the network → save. The //! network leg takes seconds, so two overlapping operations both read the old //! file and whichever finishes last writes its stale copy over the other's //! result. //! //! That is what zeroed the inbox descriptor on 2026-09-02. The Community view //! checks the inbox every 90 s; one of those checks loaded `desc_seq: 0` while //! a publish was still putting the descriptor contract, and saved that stale //! zero two minutes later — silently downgrading every sealed delivery to the //! Soulseek fallback. //! //! `hold(file)` serializes a whole load → save span against any other span on //! the same file. Locks are keyed by file name, so a slice may take another //! slice's ledger while holding its own — the inbox does exactly that when it //! dispatches sealed cargo into friends, shares and Spaces. The one rule: a //! path must never re-take the ledger it already holds. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; static LOCKS: OnceLock>>>> = OnceLock::new(); /// Take the write lock for one ledger file. Hold the guard across the whole /// load → mutate → save span, network legs included. pub async fn hold(file: &'static str) -> OwnedMutexGuard<()> { let lock = { let mut map = LOCKS.get_or_init(Default::default).lock().unwrap(); map.entry(file).or_default().clone() }; lock.lock_owned().await } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; /// The lost update this module exists to prevent: two tasks read the same /// counter, await, then write. Under the guard the second must observe the /// first one's write. #[tokio::test] async fn a_holder_sees_the_previous_writers_value() { static CELL: AtomicU32 = AtomicU32::new(0); let one = tokio::spawn(async { let _g = hold("test-race.json").await; let seen = CELL.load(Ordering::SeqCst); tokio::time::sleep(std::time::Duration::from_millis(40)).await; CELL.store(seen + 1, Ordering::SeqCst); }); // Long enough that the first task is parked on its sleep, still holding. tokio::time::sleep(std::time::Duration::from_millis(10)).await; let two = tokio::spawn(async { let _g = hold("test-race.json").await; let seen = CELL.load(Ordering::SeqCst); CELL.store(seen + 1, Ordering::SeqCst); }); one.await.unwrap(); two.await.unwrap(); assert_eq!(CELL.load(Ordering::SeqCst), 2, "the second write clobbered the first"); } /// Why spaces::invite, spaces::remove_member and proposals::decide all /// `drop(ledger)` before their tail call into open(): this lock is NOT /// reentrant, so re-taking the same file in the same task is a hang, not /// an error. Nothing in the compiler catches that. #[tokio::test] async fn the_same_file_twice_would_hang() { let _first = hold("test-reentrant.json").await; let again = tokio::time::timeout(std::time::Duration::from_millis(200), hold("test-reentrant.json")).await; assert!(again.is_err(), "a second hold on an already-held file must never be granted"); } /// Different files must not block each other, or dispatching sealed cargo /// out of a held inbox would deadlock. #[tokio::test] async fn different_files_do_not_block() { let _a = hold("test-a.json").await; let _b = tokio::time::timeout(std::time::Duration::from_millis(500), hold("test-b.json")) .await .expect("a second ledger blocked on an unrelated one"); } }