Files
SoulStream/src-tauri/src/ledger.rs
T

88 lines
3.9 KiB
Rust

//! 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<Mutex<HashMap<&'static str, Arc<AsyncMutex<()>>>>> = 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");
}
}