From 733e3b57a8c631c94681cbeb5b844396b0262915 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:12:41 -0500 Subject: [PATCH] community: one writer at a time per ledger, and an inbox publish that bumps from what the network really holds --- src-tauri/src/inbox.rs | 58 ++++++++++++++++++++++++++++--- src-tauri/src/ledger.rs | 75 +++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 3 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/ledger.rs diff --git a/src-tauri/src/inbox.rs b/src-tauri/src/inbox.rs index dc82000..e43f9df 100644 --- a/src-tauri/src/inbox.rs +++ b/src-tauri/src/inbox.rs @@ -30,6 +30,7 @@ use persona_state::inbox as ib; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; +use crate::ledger; use crate::persona; use crate::state::AppState; @@ -147,17 +148,20 @@ fn dir(app: &tauri::AppHandle) -> Result { .map_err(|e| e.to_string())? .join("freenet")) } +/// Both the file the ledger lives in and the key its write lock is taken on. +const LEDGER_FILE: &str = "inbox-ledger.json"; + fn load(app: &tauri::AppHandle) -> Ledger { dir(app) .ok() - .and_then(|d| std::fs::read(d.join("inbox-ledger.json")).ok()) + .and_then(|d| std::fs::read(d.join(LEDGER_FILE)).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { let d = dir(app)?; std::fs::create_dir_all(&d).map_err(|e| e.to_string())?; - std::fs::write(d.join("inbox-ledger.json"), serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?) + std::fs::write(d.join(LEDGER_FILE), serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?) .map_err(|e| e.to_string()) } @@ -204,13 +208,37 @@ pub fn status(app: &tauri::AppHandle) -> InboxStatus { /// Publish (or re-publish) how to reach this persona. Minting the box key /// and publishing the descriptor is what makes the inbox exist. pub async fn publish(app: &tauri::AppHandle) -> Result { + let _ledger = ledger::hold(LEDGER_FILE).await; let adapter = app.state::().freenet.clone(); let owner = own_key(app).await?; + // Whether we already had a box key, read before minting one. + let had_key = box_secret(app, false)?.is_some(); let secret = box_secret(app, true)?.expect("minted"); let mut l = load(app); - let first = l.desc_seq == 0; - let seq = l.desc_seq + 1; - let key_id = if l.box_key_id == 0 { 1 } else { l.box_key_id }; + // A box key with no sequence is the signature of a ledger that lost a + // write: the key is minted inside this function, just before the put. Ask + // the network what it holds rather than trusting the zero, or we would + // re-put an existing contract at a sequence it already has and the + // descriptor would never move again. + let published = if l.desc_seq == 0 && had_key { + published_descriptor(app, &owner).await? + } else { + None + }; + if let Some(p) = &published { + log::warn!( + "inbox: ledger said never published but the network holds sequence {} — bumping from there", + p.sequence + ); + } + let net_seq = published.as_ref().map(|p| p.sequence).unwrap_or(0); + let seq = l.desc_seq.max(net_seq) + 1; + let first = seq == 1; + let key_id = match (l.box_key_id, published.as_ref().map(|p| p.box_key_id)) { + (0, Some(id)) if id > 0 => id, + (0, _) => 1, + (id, _) => id, + }; let mut d = ib::InboxDescriptor { schema_version: ib::SCHEMA_VERSION, owner, @@ -280,6 +308,25 @@ pub async fn resolve(app: &tauri::AppHandle, key: &[u8; 32]) -> Option Result, String> { + let adapter = app.state::().freenet.clone(); + let ids = FreenetAdapter::contract_ids(DESC_WASM.to_vec(), &ib::make_desc_params(owner)); + match adapter.contract_get(&ids.instance, false).await { + Ok(bytes) => Ok(ib::validate_descriptor(owner, &bytes)), + // The contract genuinely is not out there: this really is a first put. + Err(e) if e.to_string().contains("not found") => Ok(None), + // Anything else means we could not ask. Refusing is the module rule — + // guessing "nothing" here re-puts an existing descriptor at a sequence + // the network already holds, and it would never move again. + Err(e) => Err(format!("could not read the published descriptor: {e}")), + } +} + /// True when we can reach `key` through Freenet right now. pub async fn reachable(app: &tauri::AppHandle, key: &[u8; 32]) -> bool { resolve(app, key).await.is_some() @@ -340,6 +387,7 @@ pub struct CheckResult { /// Read our inbox, open what we can, act on it, prune what we handled. pub async fn check(app: &tauri::AppHandle) -> Result { + let _ledger = ledger::hold(LEDGER_FILE).await; let adapter = app.state::().freenet.clone(); let owner = own_key(app).await?; let Some(secret) = box_secret(app, false)? else { diff --git a/src-tauri/src/ledger.rs b/src-tauri/src/ledger.rs new file mode 100644 index 0000000..5b52fae --- /dev/null +++ b/src-tauri/src/ledger.rs @@ -0,0 +1,75 @@ +//! 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"); + } + + /// 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"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9aba379..194b756 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod persona; mod hero_crop; mod import; mod inbox; +mod ledger; mod hero_detector; mod loudness; mod lyric_align;