community: every ledger takes its write lock, so the inbox check can no longer land on top of a friend accept

This commit is contained in:
KeelrLeek committed 2026-09-03 11:51:35 -05:00
1 parent 07227a90a7
commit 4d03cf849b
6 files changed
+89 -29

No files matched your search

+23 -8
View File
@@ -30,6 +30,7 @@ use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use crate::inbox::{self, Cargo};
use crate::ledger;
use crate::persona;
use crate::slsk_link;
use crate::state::AppState;
@@ -114,8 +115,11 @@ fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
.app_data_dir()
.map_err(|e| e.to_string())?
.join("freenet")
.join("friends.json"))
.join(LEDGER_FILE))
}
/// Both the file the ledger lives in and the key its write lock is taken on.
const LEDGER_FILE: &str = "friends.json";
fn load(app: &tauri::AppHandle) -> Store {
path(app)
.ok()
@@ -294,6 +298,7 @@ async fn start_request(
updated_at: now,
};
deliver(app, &mut rel, Cargo::FriendRequest(req.clone()), encode(REQ_PREFIX, &req)?).await?;
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
store.relationships.insert(id.clone(), rel);
save(app, &store)?;
@@ -322,6 +327,7 @@ pub async fn request_by_key(app: &tauri::AppHandle, persona_key: &str) -> Result
pub async fn accept(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
let me = my_key(app).await?;
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
let rel = store.relationships.get_mut(id).ok_or("no such request")?;
if rel.request_from_me {
@@ -340,6 +346,7 @@ pub async fn accept(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView
}
pub async fn decline(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
let rel = store.relationships.get_mut(id).ok_or("no such request")?;
let now = now_secs();
@@ -355,6 +362,7 @@ pub async fn decline(app: &tauri::AppHandle, id: &str) -> Result<RelationshipVie
/// "revoked" — the design's honesty about what a removal can and cannot do.
pub async fn remove(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
let me = my_key(app).await?;
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
let rel = store.relationships.get_mut(id).ok_or("no such relationship")?;
let their = key_of(&rel.their_key)?;
@@ -368,7 +376,8 @@ pub async fn remove(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView
Ok(v)
}
pub fn forget(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
pub async fn forget(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
store.relationships.remove(id);
save(app, &store)
@@ -444,6 +453,7 @@ async fn store_request(app: &tauri::AppHandle, from: From, req: fr::FriendReques
}
};
let id = b58(&req.relationship_id);
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
if store.relationships.contains_key(&id) {
return;
@@ -479,6 +489,7 @@ async fn apply_assertion(app: &tauri::AppHandle, from: From, a: fr::FriendAssert
return;
}
let id = b58(&a.relationship_id);
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
let Some(rel) = store.relationships.get_mut(&id) else {
log::debug!("friends: assertion for an unknown relationship");
@@ -533,7 +544,8 @@ async fn apply_assertion(app: &tauri::AppHandle, from: From, a: fr::FriendAssert
}
}
fn apply_decline(app: &tauri::AppHandle, from: From, id: &str) {
async fn apply_decline(app: &tauri::AppHandle, from: From, id: &str) {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut store = load(app);
let Some(rel) = store.relationships.get_mut(id) else { return };
let sender_ok = match &from {
@@ -555,8 +567,8 @@ pub(crate) async fn on_inbox_request(app: &tauri::AppHandle, sender: [u8; 32], r
pub(crate) async fn on_inbox_assertion(app: &tauri::AppHandle, sender: [u8; 32], a: fr::FriendAssertion) {
apply_assertion(app, From::Inbox(sender), a).await;
}
pub(crate) fn on_inbox_decline(app: &tauri::AppHandle, sender: [u8; 32], id: [u8; 16]) {
apply_decline(app, From::Inbox(sender), &b58(&id));
pub(crate) async fn on_inbox_decline(app: &tauri::AppHandle, sender: [u8; 32], id: [u8; 16]) {
apply_decline(app, From::Inbox(sender), &b58(&id)).await;
}
/// The event pump hands every private message here after the link proof.
@@ -579,7 +591,10 @@ pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
return true;
}
if let Some(rest) = msg.strip_prefix(DECLINE_PREFIX) {
apply_decline(app, From::Pm(from.to_string()), rest.trim());
// Its own task like the two above: this now takes the ledger, and an
// inline await here deadlocks the event pump.
let (app, from, id) = (app.clone(), from.to_string(), rest.trim().to_string());
tauri::async_runtime::spawn(async move { apply_decline(&app, From::Pm(from), &id).await });
return true;
}
false
@@ -612,6 +627,6 @@ pub async fn friend_remove(app: tauri::AppHandle, id: String) -> Result<Relation
remove(&app, &id).await
}
#[tauri::command]
pub fn friend_forget(app: tauri::AppHandle, id: String) -> Result<(), String> {
forget(&app, &id)
pub async fn friend_forget(app: tauri::AppHandle, id: String) -> Result<(), String> {
forget(&app, &id).await
}
+4 -4
View File
@@ -479,10 +479,10 @@ async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) {
match cargo {
Cargo::FriendRequest(r) => crate::friends::on_inbox_request(app, sender, r).await,
Cargo::FriendAssertion(a) => crate::friends::on_inbox_assertion(app, sender, a).await,
Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id),
Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s),
Cargo::SpaceInvite(i) => crate::spaces::on_inbox_invite(app, sender, i),
Cargo::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k),
Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id).await,
Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s).await,
Cargo::SpaceInvite(i) => crate::spaces::on_inbox_invite(app, sender, i).await,
Cargo::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k).await,
}
}
+12
View File
@@ -63,6 +63,18 @@ mod tests {
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]
+9 -1
View File
@@ -30,6 +30,7 @@ use crate::persona;
use crate::pipeline::LibraryState;
use crate::playlists;
use crate::shared_playlists::{self, Published};
use crate::ledger;
use crate::slsk_link;
use crate::spaces;
use crate::state::AppState;
@@ -192,8 +193,11 @@ fn board_id(owner: &[u8; 32], uuid: &[u8; 16]) -> String {
format!("{}/{}", b58(owner), b58(uuid))
}
/// Both the file the ledger lives in and the key its write lock is taken on.
const LEDGER_FILE: &str = "boards.json";
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join("freenet").join("boards.json"))
Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join("freenet").join(LEDGER_FILE))
}
fn load(app: &tauri::AppHandle) -> Ledger {
path(app)
@@ -392,6 +396,7 @@ pub async fn open(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str
let have: Vec<[u8; 32]> = board.authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default();
let same = want.iter().collect::<BTreeSet<_>>() == have.iter().collect::<BTreeSet<_>>();
if !same {
let _ledger = ledger::hold(LEDGER_FILE).await;
let bid = board_id(&pin.owner, &pin.uuid);
let mut l = load(app);
let rec = l.boards.get(&bid).cloned().unwrap_or_default();
@@ -517,6 +522,7 @@ pub async fn decide(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &s
return Err("the board has not reached this node".into());
}
let p = board.proposals.iter().find(|p| p.proposal_id == pid).cloned().ok_or("that proposal is no longer pending")?;
let ledger = ledger::hold(LEDGER_FILE).await;
let bid = board_id(&pin.owner, &pin.uuid);
let mut l = load(app);
let rec = l.boards.get(&bid).cloned().unwrap_or_default();
@@ -544,6 +550,8 @@ pub async fn decide(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &s
l.boards.insert(bid, BoardRecord { last_seq: seq, ..rec });
save(app, &l)?;
log::info!("proposals: decision {} ({}) on board {}", seq, if accept { "accept" } else { "reject" }, b58(&pin.uuid));
// open() takes this same ledger; the write is done, so let go first.
drop(ledger);
open(app, space_owner_b58, space_b58).await
}
+20 -10
View File
@@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use crate::inbox::{self, Cargo, ShareCargo};
use crate::ledger as ledger_lock;
use crate::persona;
use crate::pipeline::LibraryState;
use crate::playlist_formats::{ParsedEntry, ParsedPlaylist};
@@ -170,8 +171,11 @@ fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
.app_data_dir()
.map_err(|e| e.to_string())?
.join("freenet")
.join("playlists-ledger.json"))
.join(LEDGER_FILE))
}
/// Both the file the ledger lives in and the key its write lock is taken on.
const LEDGER_FILE: &str = "playlists-ledger.json";
fn load(app: &tauri::AppHandle) -> Ledger {
path(app)
.ok()
@@ -231,6 +235,7 @@ pub async fn publish(app: &tauri::AppHandle, lib: &LibraryState, playlist_id: i6
if items.len() > pl::MAX_ENTRIES {
return Err(format!("{} entries is over the {} cap", items.len(), pl::MAX_ENTRIES));
}
let _guard = ledger_lock::hold(LEDGER_FILE).await;
let mut ledger = load(app);
let key = playlist_id.to_string();
let prev = ledger.published.get(&key).cloned();
@@ -331,6 +336,7 @@ pub async fn publish(app: &tauri::AppHandle, lib: &LibraryState, playlist_id: i6
pub async fn withdraw(app: &tauri::AppHandle, playlist_id: i64) -> Result<Published, String> {
let adapter = app.state::<AppState>().freenet.clone();
let owner = own_key(app).await?;
let _guard = ledger_lock::hold(LEDGER_FILE).await;
let mut ledger = load(app);
let key = playlist_id.to_string();
let prev = ledger.published.get(&key).cloned().ok_or("that playlist was never published")?;
@@ -564,8 +570,9 @@ pub async fn send(app: &tauri::AppHandle, username: &str, playlist_id: i64) -> R
Ok(())
}
fn keep_card(app: &tauri::AppHandle, card: ShareCard) {
async fn keep_card(app: &tauri::AppHandle, card: ShareCard) {
let who = if card.from_username.is_empty() { card.from_key.clone() } else { card.from_username.clone() };
let _guard = ledger_lock::hold(LEDGER_FILE).await;
let mut ledger = load(app);
ledger.inbox.retain(|c| c.id != card.id);
ledger.inbox.push(card);
@@ -577,7 +584,7 @@ fn keep_card(app: &tauri::AppHandle, card: ShareCard) {
}
/// Sealed cargo from inbox.rs: the envelope's signer is the sender.
pub(crate) fn on_inbox_share(app: &tauri::AppHandle, sender: [u8; 32], s: ShareCargo) {
pub(crate) async fn on_inbox_share(app: &tauri::AppHandle, sender: [u8; 32], s: ShareCargo) {
let from_key = b58(&sender);
let from_username = crate::slsk_link::username_for_key(app, &from_key).unwrap_or_default();
keep_card(
@@ -593,7 +600,8 @@ pub(crate) fn on_inbox_share(app: &tauri::AppHandle, sender: [u8; 32], s: ShareC
entry_count: s.entry_count,
received_at: now_secs(),
},
);
)
.await;
}
/// The event pump hands private messages here after the link and friend
@@ -603,9 +611,7 @@ pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
use base64::Engine;
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(rest.trim()) else { return true };
let Ok(r) = bincode::deserialize::<ShareRef>(&bytes) else { return true };
keep_card(
app,
ShareCard {
let card = ShareCard {
id: format!("{}:{}:{}", normalize_username(from), b58(&r.owner), b58(&r.playlist_uuid)),
from_username: from.to_string(),
from_key: String::new(),
@@ -615,8 +621,11 @@ pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
name: cap(&r.name, pl::MAX_NAME_BYTES),
entry_count: r.entry_count,
received_at: now_secs(),
},
);
};
// Its own task: keeping a card now takes the ledger, and an inline await
// here would block the event pump.
let app = app.clone();
tauri::async_runtime::spawn(async move { keep_card(&app, card).await });
true
}
@@ -664,7 +673,8 @@ pub fn shares_inbox(app: tauri::AppHandle) -> Vec<ShareCard> {
v
}
#[tauri::command]
pub fn share_dismiss(app: tauri::AppHandle, id: String) -> Result<(), String> {
pub async fn share_dismiss(app: tauri::AppHandle, id: String) -> Result<(), String> {
let _guard = ledger_lock::hold(LEDGER_FILE).await;
let mut ledger = load(&app);
ledger.inbox.retain(|c| c.id != id);
save(&app, &ledger)
+21 -6
View File
@@ -25,6 +25,7 @@ use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use crate::inbox::{self, Cargo, SpaceInvite, SpaceKeyCargo};
use crate::ledger;
use crate::persona;
use crate::slsk_link;
use crate::state::AppState;
@@ -159,8 +160,11 @@ fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
.app_data_dir()
.map_err(|e| e.to_string())?
.join("freenet")
.join("spaces.json"))
.join(LEDGER_FILE))
}
/// Both the file the ledger lives in and the key its write lock is taken on.
const LEDGER_FILE: &str = "spaces.json";
fn load(app: &tauri::AppHandle) -> Ledger {
path(app)
.ok()
@@ -363,6 +367,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
keys,
last_epoch: 1,
};
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.spaces.insert(record_id(&rec.owner, &rec.space_id), rec.clone());
save(app, &l)?;
@@ -374,6 +379,7 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R
let me = my_key(app).await?;
let owner = key_of(owner_b58)?;
let space_id = id_of(space_b58)?;
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rid = record_id(owner_b58, space_b58);
let rec = l.spaces.get(&rid).cloned().ok_or("not a Space this device knows")?;
@@ -475,6 +481,7 @@ pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pe
})
.await?;
let rid = record_id(owner_b58, space_b58);
let ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
let mut key = None;
@@ -493,6 +500,8 @@ pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pe
)
.await?;
log::info!("spaces: invited {persona_key} to {:?} (epoch {})", c.name, c.epoch);
// open() takes this same ledger; the write is done, so let go first.
drop(ledger);
open(app, owner_b58, space_b58).await
}
@@ -506,6 +515,7 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &
}
let c = bump(app, me, owner, space_id, |c| c.members.retain(|m| m.key != gone)).await?;
let rid = record_id(owner_b58, space_b58);
let ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
rec.last_epoch = c.epoch;
@@ -523,6 +533,8 @@ pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &
} else {
save(app, &l)?;
}
// open() takes this same ledger; the write is done, so let go first.
drop(ledger);
open(app, owner_b58, space_b58).await
}
@@ -538,7 +550,8 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str
open(app, owner_b58, space_b58).await
}
pub fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
pub async fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rid = record_id(owner_b58, space_b58);
if l.spaces.get(&rid).is_some_and(|r| r.my_role == "owner") {
@@ -550,12 +563,13 @@ pub fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result
// ---------------------------------------------------------------- inbox cargo
pub(crate) fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], inv: SpaceInvite) {
pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], inv: SpaceInvite) {
if sender != inv.owner {
log::warn!("spaces: an invitation was sealed by someone other than the Space's owner");
return;
}
let rid = record_id(&b58(&inv.owner), &b58(&inv.space_id));
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord {
owner: b58(&inv.owner),
@@ -579,11 +593,12 @@ pub(crate) fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], inv: Spa
}
}
pub(crate) fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) {
pub(crate) async fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) {
if sender != sk.owner {
return;
}
let rid = record_id(&b58(&sk.owner), &b58(&sk.space_id));
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let Some(rec) = l.spaces.get_mut(&rid) else { return };
rec.keys.insert(sk.epoch.to_string(), b58(&sk.key));
@@ -621,8 +636,8 @@ pub async fn space_set_pinned(app: tauri::AppHandle, owner: String, space: Strin
set_pinned(&app, &owner, &space, playlist_owner, playlist_uuid).await
}
#[tauri::command]
pub fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> Result<(), String> {
leave(&app, &owner, &space)
pub async fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> Result<(), String> {
leave(&app, &owner, &space).await
}
#[cfg(test)]