feat(peer): add transactional local game operations
Implement the peer-owned state model from PLAN.md. A root-level version.ini is now the download completion sentinel, local/ as a directory is the install predicate, and exact root-level version.ini detection prevents nested files from becoming sentinels by accident. Add the peer operation table that gates downloads, installs, updates, and uninstalls by game ID. Serving paths now reject non-catalog games, active operations, missing sentinels, and any request that points under local/. Remote aggregation treats LocalOnly peers as non-downloadable so they do not contribute peer counts, candidate source selection, or latest-version checks. Move install-side filesystem mutation into lanspread-peer::install. The new module writes atomic .lanspread.json intents, uses .local.installing and .local.backup with .lanspread_owned markers, and performs startup recovery from recorded intent plus filesystem state. Downloads now buffer version.ini chunks in memory and commit the sentinel last through .version.ini.tmp. Replace the fixed 15-second monitor with notify-backed non-recursive watches, per-ID rescan gating, and a 300-second fallback scan. The optimized rescan path updates one cached library-index entry and active operation IDs preserve their previous summary during scans. Test Plan: - just fmt - just clippy - just test - just build Refs: PLAN.md
This commit is contained in:
@@ -1,10 +1,6 @@
|
||||
//! Peer liveness checks and stale-peer cleanup.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
@@ -12,6 +8,7 @@ use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
use crate::{
|
||||
PeerEvent,
|
||||
config::{PEER_PING_IDLE_SECS, PEER_PING_INTERVAL_SECS, peer_stale_timeout},
|
||||
context::OperationKind,
|
||||
events,
|
||||
network::ping_peer,
|
||||
peer_db::{PeerGameDB, PeerId},
|
||||
@@ -21,7 +18,7 @@ use crate::{
|
||||
pub async fn run_ping_service(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
downloading_games: Arc<RwLock<HashSet<String>>>,
|
||||
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
@@ -43,7 +40,7 @@ pub async fn run_ping_service(
|
||||
|
||||
ping_idle_peers(
|
||||
&peer_game_db,
|
||||
&downloading_games,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
&shutdown,
|
||||
@@ -53,7 +50,7 @@ pub async fn run_ping_service(
|
||||
|
||||
prune_stale_peers(
|
||||
&peer_game_db,
|
||||
&downloading_games,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
)
|
||||
@@ -63,7 +60,7 @@ pub async fn run_ping_service(
|
||||
|
||||
async fn ping_idle_peers(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
downloading_games: &Arc<RwLock<HashSet<String>>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
shutdown: &CancellationToken,
|
||||
@@ -78,7 +75,7 @@ async fn ping_idle_peers(
|
||||
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
let peer_game_db = peer_game_db.clone();
|
||||
let downloading_games = downloading_games.clone();
|
||||
let active_operations = active_operations.clone();
|
||||
let active_downloads = active_downloads.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
|
||||
@@ -96,7 +93,7 @@ async fn ping_idle_peers(
|
||||
log::warn!("Peer {peer_addr} failed ping check");
|
||||
remove_peer_and_refresh(
|
||||
&peer_game_db,
|
||||
&downloading_games,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
peer_id,
|
||||
@@ -108,7 +105,7 @@ async fn ping_idle_peers(
|
||||
log::error!("Failed to ping peer {peer_addr}: {err}");
|
||||
remove_peer_and_refresh(
|
||||
&peer_game_db,
|
||||
&downloading_games,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx_notify_ui,
|
||||
peer_id,
|
||||
@@ -123,7 +120,7 @@ async fn ping_idle_peers(
|
||||
|
||||
async fn prune_stale_peers(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
downloading_games: &Arc<RwLock<HashSet<String>>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) {
|
||||
@@ -143,7 +140,7 @@ async fn prune_stale_peers(
|
||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
downloading_games,
|
||||
active_operations,
|
||||
active_downloads,
|
||||
tx_notify_ui,
|
||||
)
|
||||
@@ -153,7 +150,7 @@ async fn prune_stale_peers(
|
||||
|
||||
async fn remove_peer_and_refresh(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
downloading_games: &Arc<RwLock<HashSet<String>>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
peer_id: PeerId,
|
||||
@@ -163,7 +160,7 @@ async fn remove_peer_and_refresh(
|
||||
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
|
||||
handle_active_downloads_without_peers(
|
||||
peer_game_db,
|
||||
downloading_games,
|
||||
active_operations,
|
||||
active_downloads,
|
||||
tx_notify_ui,
|
||||
)
|
||||
@@ -189,16 +186,16 @@ async fn remove_peer(
|
||||
|
||||
async fn handle_active_downloads_without_peers(
|
||||
peer_game_db: &Arc<RwLock<PeerGameDB>>,
|
||||
downloading_games: &Arc<RwLock<HashSet<String>>>,
|
||||
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
|
||||
active_downloads: &Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) {
|
||||
let active_ids = {
|
||||
downloading_games
|
||||
active_operations
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter_map(|(id, kind)| (*kind == OperationKind::Downloading).then_some(id.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if active_ids.is_empty() {
|
||||
@@ -210,6 +207,7 @@ async fn handle_active_downloads_without_peers(
|
||||
continue;
|
||||
}
|
||||
|
||||
active_operations.write().await.remove(&id);
|
||||
let Some(cancel_token) = active_downloads.write().await.remove(&id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -229,21 +227,21 @@ async fn peers_still_have_game(peer_game_db: &Arc<RwLock<PeerGameDB>>, game_id:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::handle_active_downloads_without_peers;
|
||||
use crate::{PeerEvent, peer_db::PeerGameDB};
|
||||
use crate::{PeerEvent, context::OperationKind, peer_db::PeerGameDB};
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_peers_gone_cancels_download_and_emits_only_peers_gone() {
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let downloading_games = Arc::new(RwLock::new(HashSet::from(["game".to_string()])));
|
||||
let active_operations = Arc::new(RwLock::new(HashMap::from([(
|
||||
"game".to_string(),
|
||||
OperationKind::Downloading,
|
||||
)])));
|
||||
let cancel = CancellationToken::new();
|
||||
let active_downloads = Arc::new(RwLock::new(HashMap::from([(
|
||||
"game".to_string(),
|
||||
@@ -253,13 +251,14 @@ mod tests {
|
||||
|
||||
handle_active_downloads_without_peers(
|
||||
&peer_game_db,
|
||||
&downloading_games,
|
||||
&active_operations,
|
||||
&active_downloads,
|
||||
&tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(cancel.is_cancelled());
|
||||
assert!(!active_operations.read().await.contains_key("game"));
|
||||
assert!(!active_downloads.read().await.contains_key("game"));
|
||||
|
||||
let event = rx.recv().await.expect("peers-gone event should be emitted");
|
||||
|
||||
Reference in New Issue
Block a user