feat(peer): prototype streamed installs
Add a streamed-install prototype that can receive archive-derived install bytes straight into local/ without first storing the peer-owned root archive payload. This is intended for low-disk clients that want to install a game but opt out of becoming a downloadable peer source for that game. The protocol gains a current-version-only StreamInstall request and framed StreamInstallFrame responses. The peer core owns the generic transport, transaction, path validation, size checks, CRC32 verification, and lifecycle state. The archive-specific work is hidden behind StreamInstallProvider so the prototype can use unrar while the final implementation can swap in a better provider without rewriting the peer command path. The receiver writes into .local.installing and only promotes to local/ after the full stream verifies. It deliberately does not write the root version.ini or archive files, so the settled local state is installed=true, downloaded=false, and availability=LocalOnly. That preserves the existing rule that local/ is not served to peers and makes streamed receivers non-sources by construction. The CLI is the only caller for now. It exposes stream-install and provides the prototype unrar implementation with unrar lt for entry metadata and unrar p for file bytes. This is simple and good enough to prove non-solid archive streaming, but it is not the production provider shape for solid archives because per-file unrar p would repeatedly decompress prefixes. The Tauri app explicitly passes stream_install_provider: None, so the GUI behavior stays unchanged until a real product path is designed. Document the production-readiness work in NEXT_STEPS.md. The main follow-up is to make the provider abstraction final-ish and replace the per-file CLI unrar provider with a one-pass archive provider, then wire a deliberate GUI low-disk mode, retry semantics, and broader failure scenarios. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S39 S40 --build-image - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Follow-up: NEXT_STEPS.md
This commit is contained in:
@@ -11,6 +11,7 @@ use std::{
|
||||
|
||||
use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
InstallOperation,
|
||||
@@ -33,6 +34,7 @@ use crate::{
|
||||
peer_db::PeerGameDB,
|
||||
remote_peer::ensure_peer_id_for_addr,
|
||||
services::{HandshakeCtx, perform_handshake_with_peer},
|
||||
stream_install::receive_streamed_install,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -450,6 +452,91 @@ pub async fn handle_install_game_command(
|
||||
spawn_install_operation(ctx, tx_notify_ui, id);
|
||||
}
|
||||
|
||||
pub async fn handle_stream_install_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
log::warn!("Ignoring streamed install command for non-catalog game {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
|
||||
let games_folder = { ctx.game_dir.read().await.clone() };
|
||||
let game_root = games_folder.join(&id);
|
||||
if local_dir_is_directory(&game_root).await {
|
||||
log::warn!("Ignoring streamed install command for already-installed game {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
|
||||
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||
let mut peers = {
|
||||
match ctx
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.validate_file_sizes_majority(&id, expected_version.as_deref())
|
||||
{
|
||||
Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => {
|
||||
peer_whitelist
|
||||
}
|
||||
Ok(_) => {
|
||||
log::error!("No trusted peers available for streamed install of {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"File size majority validation failed for streamed install {id}: {err}"
|
||||
);
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
peers.sort();
|
||||
let Some(peer_addr) = peers.into_iter().next() else {
|
||||
log::error!("No peer selected for streamed install of {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
};
|
||||
|
||||
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
||||
BeginOperationResult::Started => {}
|
||||
BeginOperationResult::AlreadyActive => {
|
||||
log::warn!("Operation for {id} already in progress; ignoring streamed install request");
|
||||
return;
|
||||
}
|
||||
BeginOperationResult::DrainTimedOut => {
|
||||
log::error!("Timed out waiting for outbound transfers before streamed install of {id}");
|
||||
send_download_failed(tx_notify_ui, &id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let cancel_token = ctx.shutdown.child_token();
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), cancel_token.clone());
|
||||
|
||||
let ctx_clone = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.spawn(async move {
|
||||
run_stream_install_operation(
|
||||
ctx_clone,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
game_root,
|
||||
peer_addr,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles the `UninstallGame` command.
|
||||
pub async fn handle_uninstall_game_command(
|
||||
ctx: &Ctx,
|
||||
@@ -490,6 +577,151 @@ pub async fn handle_cancel_download_command(
|
||||
cancel_token.cancel();
|
||||
}
|
||||
|
||||
async fn run_stream_install_operation(
|
||||
ctx: Ctx,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
game_root: PathBuf,
|
||||
peer_addr: SocketAddr,
|
||||
cancel_token: CancellationToken,
|
||||
) {
|
||||
let download_guard = OperationGuard::download(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
ctx.active_downloads.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
|
||||
events::send(
|
||||
&tx_notify_ui,
|
||||
PeerEvent::DownloadGameFilesBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
let transaction = match install::begin_streamed_install(&game_root, ctx.state_dir.as_ref(), &id)
|
||||
.await
|
||||
{
|
||||
Ok(transaction) => transaction,
|
||||
Err(err) => {
|
||||
log::error!("Failed to prepare streamed install for {id}: {err}");
|
||||
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let receive_result = receive_streamed_install(
|
||||
peer_addr,
|
||||
&id,
|
||||
transaction.staging_dir(),
|
||||
tx_notify_ui.clone(),
|
||||
cancel_token.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match receive_result {
|
||||
Ok(()) => {
|
||||
if transition_download_to_install(&ctx, &tx_notify_ui, &id, OperationKind::Installing)
|
||||
.await
|
||||
{
|
||||
clear_active_download(&ctx, &id).await;
|
||||
send_download_finished(&tx_notify_ui, &id);
|
||||
download_guard.disarm();
|
||||
commit_streamed_install(&ctx, &tx_notify_ui, id, transaction).await;
|
||||
} else {
|
||||
if let Err(err) = transaction.rollback().await {
|
||||
log::error!("Failed to roll back streamed install for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if let Err(rollback_err) = transaction.rollback().await {
|
||||
log::error!("Failed to roll back streamed install for {id}: {rollback_err}");
|
||||
}
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Streamed install download cancelled for {id}: {err}");
|
||||
} else {
|
||||
log::error!("Streamed install download failed for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
download_guard,
|
||||
download_was_cancelled,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn finish_failed_stream_download(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
guard: OperationGuard,
|
||||
cancelled: bool,
|
||||
) {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, id).await {
|
||||
log::error!("Failed to refresh local library after streamed install failure: {err}");
|
||||
}
|
||||
end_download_operation(ctx, tx_notify_ui, id).await;
|
||||
guard.disarm();
|
||||
send_download_failed_unless_cancelled(tx_notify_ui, id, cancelled);
|
||||
}
|
||||
|
||||
async fn commit_streamed_install(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
transaction: install::StreamedInstallTransaction,
|
||||
) {
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameBegin {
|
||||
id: id.clone(),
|
||||
operation: InstallOperation::Installing,
|
||||
},
|
||||
);
|
||||
|
||||
match transaction.commit().await {
|
||||
Ok(()) => {
|
||||
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!("Failed to refresh local library after streamed install: {err}");
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFinished { id: id.clone() },
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Streamed install commit failed for {id}: {err}");
|
||||
if let Err(refresh_err) =
|
||||
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to refresh local library after streamed install commit failure: {refresh_err}"
|
||||
);
|
||||
}
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::InstallGameFailed { id: id.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
@@ -1264,6 +1496,7 @@ mod tests {
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user