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:
@@ -19,6 +19,7 @@ mod error;
|
||||
mod events;
|
||||
mod handlers;
|
||||
mod identity;
|
||||
mod install;
|
||||
mod library;
|
||||
mod local_games;
|
||||
mod network;
|
||||
@@ -33,10 +34,11 @@ mod startup;
|
||||
// Public re-exports
|
||||
// =============================================================================
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
|
||||
pub use error::PeerError;
|
||||
pub use install::{UnpackFuture, Unpacker};
|
||||
use lanspread_db::db::{Game, GameFileDescription};
|
||||
pub use peer_db::{MajorityValidationResult, PeerGameDB, PeerId, PeerInfo, PeerUpsert};
|
||||
use tokio::sync::{
|
||||
@@ -52,8 +54,11 @@ use crate::{
|
||||
handle_download_game_files_command,
|
||||
handle_get_game_command,
|
||||
handle_get_peer_count_command,
|
||||
handle_install_game_command,
|
||||
handle_list_games_command,
|
||||
handle_set_game_dir_command,
|
||||
handle_uninstall_game_command,
|
||||
handle_update_game_command,
|
||||
load_local_library,
|
||||
},
|
||||
};
|
||||
@@ -80,6 +85,21 @@ pub enum PeerEvent {
|
||||
DownloadGameFilesFailed { id: String },
|
||||
/// All peers with the game have disconnected during download.
|
||||
DownloadGameFilesAllPeersGone { id: String },
|
||||
/// Install or update transaction has started for a game.
|
||||
InstallGameBegin {
|
||||
id: String,
|
||||
operation: InstallOperation,
|
||||
},
|
||||
/// Install or update transaction has completed successfully.
|
||||
InstallGameFinished { id: String },
|
||||
/// Install or update transaction has failed after rollback.
|
||||
InstallGameFailed { id: String },
|
||||
/// Uninstall transaction has started for a game.
|
||||
UninstallGameBegin { id: String },
|
||||
/// Uninstall transaction has completed successfully.
|
||||
UninstallGameFinished { id: String },
|
||||
/// Uninstall transaction has failed after rollback.
|
||||
UninstallGameFailed { id: String },
|
||||
/// No peers have the requested game.
|
||||
NoPeersHaveGame { id: String },
|
||||
/// A peer has connected.
|
||||
@@ -116,6 +136,15 @@ pub enum PeerRuntimeComponent {
|
||||
LocalMonitor,
|
||||
}
|
||||
|
||||
/// Install-side operation represented in lifecycle events.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
|
||||
pub enum InstallOperation {
|
||||
/// Fresh install into a missing `local/` directory.
|
||||
Installing,
|
||||
/// Update that replaces an existing `local/` directory.
|
||||
Updating,
|
||||
}
|
||||
|
||||
/// Commands sent to the peer system from the UI.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PeerCommand {
|
||||
@@ -128,6 +157,12 @@ pub enum PeerCommand {
|
||||
id: String,
|
||||
file_descriptions: Vec<GameFileDescription>,
|
||||
},
|
||||
/// Install already-downloaded archives into `local/`.
|
||||
InstallGame { id: String },
|
||||
/// Update an installed game from already-downloaded archives.
|
||||
UpdateGame { id: String },
|
||||
/// Remove only the `local/` install for a game.
|
||||
UninstallGame { id: String },
|
||||
/// Set the local game directory.
|
||||
SetGameDir(PathBuf),
|
||||
/// Request the current peer count.
|
||||
@@ -150,10 +185,13 @@ pub enum PeerCommand {
|
||||
/// * `game_dir` - Path to the local game directory
|
||||
/// * `tx_notify_ui` - Channel for sending events to the UI
|
||||
/// * `peer_game_db` - Shared peer game database
|
||||
#[allow(clippy::implicit_hasher)]
|
||||
pub fn start_peer(
|
||||
game_dir: impl Into<PathBuf>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
) -> eyre::Result<PeerRuntimeHandle> {
|
||||
let game_dir = game_dir.into();
|
||||
log::info!(
|
||||
@@ -171,20 +209,33 @@ pub fn start_peer(
|
||||
peer_game_db,
|
||||
peer_id,
|
||||
game_dir,
|
||||
unpacker,
|
||||
catalog,
|
||||
))
|
||||
}
|
||||
|
||||
/// Main peer execution loop that handles peer commands and manages the peer system.
|
||||
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
|
||||
async fn run_peer(
|
||||
mut rx_control: UnboundedReceiver<PeerCommand>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
peer_id: String,
|
||||
game_dir: PathBuf,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
shutdown: CancellationToken,
|
||||
task_tracker: TaskTracker,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
) -> eyre::Result<()> {
|
||||
let ctx = Ctx::new(peer_game_db, peer_id, game_dir, shutdown, task_tracker);
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db,
|
||||
peer_id,
|
||||
game_dir,
|
||||
unpacker,
|
||||
shutdown,
|
||||
task_tracker,
|
||||
catalog,
|
||||
);
|
||||
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
|
||||
log::error!("Failed to load initial local game database: {err}");
|
||||
}
|
||||
@@ -237,6 +288,15 @@ async fn handle_peer_commands(
|
||||
} => {
|
||||
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions).await;
|
||||
}
|
||||
PeerCommand::InstallGame { id } => {
|
||||
handle_install_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::UpdateGame { id } => {
|
||||
handle_update_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::UninstallGame { id } => {
|
||||
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
||||
}
|
||||
PeerCommand::SetGameDir(game_dir) => {
|
||||
handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user