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,21 +1,26 @@
|
||||
//! Command handlers for peer commands.
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use std::{collections::hash_map::Entry, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use lanspread_db::db::GameFileDescription;
|
||||
use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
InstallOperation,
|
||||
PeerEvent,
|
||||
context::{Ctx, DownloadStateGuard},
|
||||
context::{Ctx, OperationGuard, OperationKind},
|
||||
download::download_game_files,
|
||||
events,
|
||||
identity::FEATURE_LIBRARY_DELTA,
|
||||
install,
|
||||
local_games::{
|
||||
LocalLibraryScan,
|
||||
game_from_summary,
|
||||
get_game_file_descriptions,
|
||||
local_dir_is_directory,
|
||||
local_download_available,
|
||||
scan_local_library,
|
||||
version_ini_is_regular_file,
|
||||
},
|
||||
network::{announce_games_to_peer, request_game_details_from_peer, send_library_delta},
|
||||
peer_db::PeerGameDB,
|
||||
@@ -40,11 +45,13 @@ async fn try_serve_local_game(
|
||||
) -> bool {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
|
||||
let downloading = ctx.downloading_games.read().await;
|
||||
if !local_download_available(&game_dir, id, &downloading).await {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
|
||||
return false;
|
||||
}
|
||||
drop(downloading);
|
||||
drop(active_operations);
|
||||
drop(catalog);
|
||||
|
||||
match get_game_file_descriptions(id, &game_dir).await {
|
||||
Ok(file_descriptions) => {
|
||||
@@ -187,8 +194,9 @@ pub async fn handle_download_game_files_command(
|
||||
}
|
||||
|
||||
let local_dl_available = {
|
||||
let downloading = ctx.downloading_games.read().await;
|
||||
local_download_available(&games_folder, &id, &downloading).await
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
local_download_available(&games_folder, &id, &active_operations, &catalog).await
|
||||
};
|
||||
|
||||
if peer_whitelist.is_empty() {
|
||||
@@ -203,6 +211,12 @@ pub async fn handle_download_game_files_command(
|
||||
{
|
||||
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||
}
|
||||
spawn_install_operation(
|
||||
ctx,
|
||||
tx_notify_ui,
|
||||
id.clone(),
|
||||
RequestedInstallOperation::Auto,
|
||||
);
|
||||
} else {
|
||||
log::error!("No trusted peers available after majority validation for game {id}");
|
||||
}
|
||||
@@ -210,18 +224,24 @@ pub async fn handle_download_game_files_command(
|
||||
}
|
||||
|
||||
{
|
||||
let mut in_progress = ctx.downloading_games.write().await;
|
||||
if !in_progress.insert(id.clone()) {
|
||||
log::warn!("Download for {id} already in progress; ignoring new request");
|
||||
return;
|
||||
let mut in_progress = ctx.active_operations.write().await;
|
||||
match in_progress.entry(id.clone()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(OperationKind::Downloading);
|
||||
}
|
||||
Entry::Occupied(_) => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring new download request");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let downloading_games = ctx.downloading_games.clone();
|
||||
let active_operations = ctx.active_operations.clone();
|
||||
let active_downloads = ctx.active_downloads.clone();
|
||||
let tx_notify_ui_clone = tx_notify_ui.clone();
|
||||
let download_id = id.clone();
|
||||
let cancel_token = ctx.shutdown.child_token();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
@@ -229,26 +249,218 @@ pub async fn handle_download_game_files_command(
|
||||
.insert(id, cancel_token.clone());
|
||||
|
||||
ctx.task_tracker.spawn(async move {
|
||||
let _download_state_guard =
|
||||
DownloadStateGuard::new(download_id.clone(), downloading_games, active_downloads);
|
||||
let result = {
|
||||
let _download_state_guard =
|
||||
OperationGuard::download(download_id.clone(), active_operations, active_downloads);
|
||||
|
||||
let result = download_game_files(
|
||||
&download_id,
|
||||
resolved_descriptions,
|
||||
games_folder,
|
||||
peer_whitelist,
|
||||
file_peer_map,
|
||||
tx_notify_ui_clone.clone(),
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
download_game_files(
|
||||
&download_id,
|
||||
resolved_descriptions,
|
||||
games_folder,
|
||||
peer_whitelist,
|
||||
file_peer_map,
|
||||
tx_notify_ui_clone.clone(),
|
||||
cancel_token,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Download failed for {download_id}: {e}");
|
||||
match result {
|
||||
Ok(()) => {
|
||||
run_install_operation(
|
||||
&ctx_clone,
|
||||
&tx_notify_ui_clone,
|
||||
download_id,
|
||||
RequestedInstallOperation::Auto,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Download failed for {download_id}: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles the `InstallGame` command.
|
||||
pub async fn handle_install_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id, RequestedInstallOperation::Install);
|
||||
}
|
||||
|
||||
/// Handles the `UpdateGame` command.
|
||||
pub async fn handle_update_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
spawn_install_operation(ctx, tx_notify_ui, id, RequestedInstallOperation::Update);
|
||||
}
|
||||
|
||||
/// Handles the `UninstallGame` command.
|
||||
pub async fn handle_uninstall_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.clone().spawn(async move {
|
||||
run_uninstall_operation(&ctx, &tx_notify_ui, id).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RequestedInstallOperation {
|
||||
Auto,
|
||||
Install,
|
||||
Update,
|
||||
}
|
||||
|
||||
fn spawn_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
requested: RequestedInstallOperation,
|
||||
) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.clone().spawn(async move {
|
||||
run_install_operation(&ctx, &tx_notify_ui, id, requested).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_install_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
requested: RequestedInstallOperation,
|
||||
) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
log::warn!("Ignoring install command for non-catalog game {id}");
|
||||
return;
|
||||
}
|
||||
|
||||
let game_root = { ctx.game_dir.read().await.join(&id) };
|
||||
if !version_ini_is_regular_file(&game_root).await {
|
||||
log::warn!("Ignoring install command for {id}: version.ini sentinel is absent");
|
||||
events::send(tx_notify_ui, PeerEvent::InstallGameFailed { id });
|
||||
return;
|
||||
}
|
||||
|
||||
let local_present = local_dir_is_directory(&game_root).await;
|
||||
let operation = match requested {
|
||||
RequestedInstallOperation::Auto | RequestedInstallOperation::Install if local_present => {
|
||||
InstallOperation::Updating
|
||||
}
|
||||
RequestedInstallOperation::Auto | RequestedInstallOperation::Install => {
|
||||
InstallOperation::Installing
|
||||
}
|
||||
RequestedInstallOperation::Update => InstallOperation::Updating,
|
||||
};
|
||||
let operation_kind = match operation {
|
||||
InstallOperation::Installing => OperationKind::Installing,
|
||||
InstallOperation::Updating => OperationKind::Updating,
|
||||
};
|
||||
|
||||
if !begin_operation(ctx, &id, operation_kind).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring install command");
|
||||
return;
|
||||
}
|
||||
|
||||
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameBegin {
|
||||
id: id.clone(),
|
||||
operation,
|
||||
},
|
||||
);
|
||||
|
||||
let result = match operation {
|
||||
InstallOperation::Installing => {
|
||||
install::install(&game_root, &id, ctx.unpacker.clone()).await
|
||||
}
|
||||
InstallOperation::Updating => install::update(&game_root, &id, ctx.unpacker.clone()).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||
);
|
||||
if let Err(err) = load_local_library(ctx, tx_notify_ui).await {
|
||||
log::error!("Failed to refresh local library after install: {err}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Install operation failed for {id}: {err}");
|
||||
events::send(tx_notify_ui, PeerEvent::InstallGameFailed { id });
|
||||
if let Err(refresh_err) = load_local_library(ctx, tx_notify_ui).await {
|
||||
log::error!("Failed to refresh local library after install failure: {refresh_err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
log::warn!("Ignoring uninstall command for non-catalog game {id}");
|
||||
return;
|
||||
}
|
||||
|
||||
if !begin_operation(ctx, &id, OperationKind::Uninstalling).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
|
||||
return;
|
||||
}
|
||||
|
||||
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
|
||||
let game_root = { ctx.game_dir.read().await.join(&id) };
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::UninstallGameBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
match install::uninstall(&game_root, &id).await {
|
||||
Ok(()) => {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::UninstallGameFinished { id: id.clone() },
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Uninstall operation failed for {id}: {err}");
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::UninstallGameFailed { id: id.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = load_local_library(ctx, tx_notify_ui).await {
|
||||
log::error!("Failed to refresh local library after uninstall: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn begin_operation(ctx: &Ctx, id: &str, operation: OperationKind) -> bool {
|
||||
let mut active_operations = ctx.active_operations.write().await;
|
||||
match active_operations.entry(id.to_string()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(operation);
|
||||
true
|
||||
}
|
||||
Entry::Occupied(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
|
||||
ctx.catalog.read().await.contains(id)
|
||||
}
|
||||
|
||||
/// Handles the `SetGameDir` command.
|
||||
pub async fn handle_set_game_dir_command(
|
||||
ctx: &Ctx,
|
||||
@@ -277,7 +489,9 @@ pub async fn load_local_library(
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
) -> eyre::Result<()> {
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let scan = scan_local_library(&game_dir).await?;
|
||||
install::recover_on_startup(&game_dir).await?;
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(&game_dir, &catalog).await?;
|
||||
update_and_announce_games(ctx, tx_notify_ui, scan).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -299,11 +513,30 @@ pub async fn update_and_announce_games(
|
||||
scan: LocalLibraryScan,
|
||||
) {
|
||||
let LocalLibraryScan {
|
||||
game_db,
|
||||
summaries,
|
||||
mut game_db,
|
||||
mut summaries,
|
||||
revision,
|
||||
} = scan;
|
||||
|
||||
let active_ids = ctx
|
||||
.active_operations
|
||||
.read()
|
||||
.await
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !active_ids.is_empty() {
|
||||
let previous = ctx.local_library.read().await.games.clone();
|
||||
for id in active_ids {
|
||||
if let Some(summary) = previous.get(&id) {
|
||||
summaries.insert(id, summary.clone());
|
||||
} else {
|
||||
summaries.remove(&id);
|
||||
}
|
||||
}
|
||||
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
||||
}
|
||||
|
||||
let delta = {
|
||||
let mut library_guard = ctx.local_library.write().await;
|
||||
library_guard.update_from_scan(summaries, revision)
|
||||
|
||||
Reference in New Issue
Block a user