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:
@@ -319,6 +319,7 @@ mod tests {
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
);
|
||||
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ mod tests {
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog},
|
||||
peer::{send_game_file_chunk, send_game_file_data},
|
||||
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
|
||||
stream_install::{send_game_install_stream, send_stream_install_error},
|
||||
};
|
||||
|
||||
type ResponseWriter = FramedWrite<SendStream, LengthDelimitedCodec>;
|
||||
@@ -99,6 +100,9 @@ async fn dispatch_request(
|
||||
} => {
|
||||
handle_file_chunk_request(ctx, game_id, relative_path, offset, length, framed_tx).await
|
||||
}
|
||||
Request::StreamInstall { game_id } => {
|
||||
handle_stream_install_request(ctx, game_id, framed_tx).await
|
||||
}
|
||||
Request::Goodbye { peer_id } => {
|
||||
handle_goodbye(ctx, remote_addr, peer_id).await;
|
||||
framed_tx
|
||||
@@ -386,6 +390,49 @@ async fn handle_file_chunk_request(
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
async fn handle_stream_install_request(
|
||||
ctx: &PeerCtx,
|
||||
game_id: String,
|
||||
framed_tx: ResponseWriter,
|
||||
) -> ResponseWriter {
|
||||
log::info!("Received StreamInstall request for {game_id} from peer");
|
||||
|
||||
let (guard, cancel_token) = TransferGuard::new(
|
||||
game_id.clone(),
|
||||
ctx.active_outbound_transfers.clone(),
|
||||
ctx.tx_notify_ui.clone(),
|
||||
&ctx.shutdown,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if !can_serve_game(ctx, &game_dir, &game_id).await {
|
||||
log::info!(
|
||||
"Declining StreamInstall for {game_id} because the game is not currently transferable"
|
||||
);
|
||||
tx = send_stream_install_error(tx, format!("game {game_id} is not transferable")).await;
|
||||
drop(guard);
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
let game_root = game_dir.join(&game_id);
|
||||
let (returned_tx, result) = send_game_install_stream(
|
||||
ctx.stream_install_provider.clone(),
|
||||
tx,
|
||||
&game_root,
|
||||
&game_id,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
log::warn!("StreamInstall for {game_id} ended with error: {err}");
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
FramedWrite::new(returned_tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
|
||||
async fn handle_goodbye(ctx: &PeerCtx, _remote_addr: Option<SocketAddr>, peer_id: String) {
|
||||
log::info!("Received Goodbye from peer {peer_id}");
|
||||
let removed = { ctx.peer_game_db.write().await.remove_peer(&peer_id) };
|
||||
@@ -442,6 +489,7 @@ mod tests {
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(catalog)),
|
||||
Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
)
|
||||
.to_peer_ctx(tx_notify_ui)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user