fix(peer): harden streamed install lifecycle
Claude Fable 5's branch review found that receiver cancellation or a QUIC send failure could leave the sender-side archive producer blocked on the bounded frame channel. That kept the outbound transfer guard alive and could block later installs or updates of the same game. Route archive frames through a cancellable StreamInstallFrameSink instead of exposing the raw channel sender to providers. The QUIC forwarder now cancels and closes the receive side before awaiting the producer, so a blocked send wakes and the transfer guard can drop normally. Make PeerCommand::StreamInstallGame own its peer metadata preflight inside the peer core. The Tauri layer now sends the command directly, and the peer runtime fetches file details from catalog-version peers before running the existing majority validation and retry logic. This removes the UI-only pending streamed install set and gives PeerEvent::GotGameFiles one meaning again: continue a normal archive download. Tighten the receiver transaction edge cases too. Rollback removes a newly created empty game root, but preserves pre-existing roots. Once streamed staging has been promoted to local/, intent or launch-settings cleanup failures are logged for startup recovery instead of reporting a failed install for bytes that are already committed. Accept missing RAR CRC32 metadata for zero-byte files as CRC32 00000000 while still requiring CRC32 metadata for non-empty files. Update the peer README, scenario docs, and next-steps handoff so the documented ownership and remaining trust limitation match the implementation. Test Plan: - just fmt - just test - just frontend-test - just clippy - git diff --check - python3 -m py_compile \ crates/lanspread-peer-cli/scripts/run_extended_scenarios.py - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image Refs: streamed-install review handoff from Claude Fable 5
This commit is contained in:
@@ -471,38 +471,6 @@ pub async fn handle_stream_install_game_command(
|
||||
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();
|
||||
if peers.is_empty() {
|
||||
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 => {
|
||||
@@ -516,6 +484,7 @@ pub async fn handle_stream_install_game_command(
|
||||
}
|
||||
}
|
||||
|
||||
let expected_version = catalog_expected_version(ctx, &id).await;
|
||||
let cancel_token = ctx.shutdown.child_token();
|
||||
ctx.active_downloads
|
||||
.write()
|
||||
@@ -525,8 +494,15 @@ pub async fn handle_stream_install_game_command(
|
||||
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, peers, cancel_token)
|
||||
.await;
|
||||
run_stream_install_operation(
|
||||
ctx_clone,
|
||||
tx_notify_ui,
|
||||
id,
|
||||
game_root,
|
||||
expected_version,
|
||||
cancel_token,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -575,7 +551,7 @@ async fn run_stream_install_operation(
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
game_root: PathBuf,
|
||||
peer_addrs: Vec<SocketAddr>,
|
||||
expected_version: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) {
|
||||
let download_guard = OperationGuard::download(
|
||||
@@ -590,27 +566,94 @@ async fn run_stream_install_operation(
|
||||
PeerEvent::DownloadGameFilesBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
let peer_addrs =
|
||||
match select_stream_install_peers(&ctx, &id, expected_version.as_deref(), &cancel_token)
|
||||
.await
|
||||
{
|
||||
Ok(peers) => peers,
|
||||
Err(err) => {
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if download_was_cancelled {
|
||||
log::info!("Streamed install preflight cancelled for {id}: {err}");
|
||||
} else {
|
||||
log::error!("Streamed install preflight failed for {id}: {err}");
|
||||
}
|
||||
finish_failed_stream_download(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
download_guard,
|
||||
download_was_cancelled,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match receive_streamed_install_from_peers(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
&game_root,
|
||||
&peer_addrs,
|
||||
&cancel_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(transaction) => {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 receive_streamed_install_from_peers(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: &str,
|
||||
game_root: &Path,
|
||||
peer_addrs: &[SocketAddr],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<install::StreamedInstallTransaction> {
|
||||
let mut last_receive_error = None;
|
||||
for peer_addr in peer_addrs {
|
||||
for &peer_addr in peer_addrs {
|
||||
if cancel_token.is_cancelled() {
|
||||
last_receive_error = Some(eyre::eyre!("streamed install for {id} was cancelled"));
|
||||
break;
|
||||
eyre::bail!("streamed install for {id} was cancelled");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
install::begin_streamed_install(game_root, ctx.state_dir.as_ref(), id).await?;
|
||||
let receive_result = receive_streamed_install(
|
||||
peer_addr,
|
||||
&id,
|
||||
id,
|
||||
transaction.staging_dir(),
|
||||
tx_notify_ui.clone(),
|
||||
cancel_token.clone(),
|
||||
@@ -618,37 +661,13 @@ async fn run_stream_install_operation(
|
||||
.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;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
return;
|
||||
}
|
||||
Ok(()) => return Ok(transaction),
|
||||
Err(err) => {
|
||||
if let Err(rollback_err) = transaction.rollback().await {
|
||||
log::error!("Failed to roll back streamed install for {id}: {rollback_err}");
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
log::info!("Streamed install download cancelled for {id}: {err}");
|
||||
last_receive_error = Some(err);
|
||||
break;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
@@ -659,24 +678,84 @@ async fn run_stream_install_operation(
|
||||
}
|
||||
}
|
||||
|
||||
let download_was_cancelled = cancel_token.is_cancelled();
|
||||
if let Some(err) = last_receive_error {
|
||||
if download_was_cancelled {
|
||||
log::info!("Streamed install download cancelled for {id}: {err}");
|
||||
} else {
|
||||
log::error!("Streamed install download failed for {id}: {err}");
|
||||
}
|
||||
} else {
|
||||
log::error!("Streamed install download failed for {id}: no peer attempts were made");
|
||||
Err(last_receive_error.unwrap_or_else(|| {
|
||||
eyre::eyre!("streamed install download failed for {id}: no peer attempts were made")
|
||||
}))
|
||||
}
|
||||
|
||||
async fn select_stream_install_peers(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
expected_version: Option<&str>,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<Vec<SocketAddr>> {
|
||||
let mut metadata_peers = {
|
||||
ctx.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.peers_with_expected_version(id, expected_version)
|
||||
};
|
||||
metadata_peers.sort();
|
||||
if metadata_peers.is_empty() {
|
||||
eyre::bail!("no peers have game {id}");
|
||||
}
|
||||
finish_failed_stream_download(
|
||||
&ctx,
|
||||
&tx_notify_ui,
|
||||
&id,
|
||||
download_guard,
|
||||
download_was_cancelled,
|
||||
)
|
||||
.await;
|
||||
|
||||
refresh_stream_install_file_details(ctx, id, &metadata_peers, cancel_token).await?;
|
||||
|
||||
let mut peers = match ctx
|
||||
.peer_game_db
|
||||
.read()
|
||||
.await
|
||||
.validate_file_sizes_majority(id, expected_version)
|
||||
{
|
||||
Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => peer_whitelist,
|
||||
Ok(_) => {
|
||||
eyre::bail!("no trusted peers available for streamed install of {id}");
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err.wrap_err(format!(
|
||||
"file size majority validation failed for streamed install {id}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
peers.sort();
|
||||
if peers.is_empty() {
|
||||
eyre::bail!("no peer selected for streamed install of {id}");
|
||||
}
|
||||
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
async fn refresh_stream_install_file_details(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
peers: &[SocketAddr],
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
let mut fetched_any = false;
|
||||
for &peer_addr in peers {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("streamed install for {id} was cancelled");
|
||||
}
|
||||
|
||||
match request_game_details_and_update(peer_addr, id, ctx.peer_game_db.clone()).await {
|
||||
Ok(_) => {
|
||||
log::info!("Fetched streamed-install file list for {id} from peer {peer_addr}");
|
||||
fetched_any = true;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to fetch streamed-install files for {id} from {peer_addr}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !fetched_any {
|
||||
eyre::bail!("failed to retrieve game files for {id} from any peer");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_failed_stream_download(
|
||||
|
||||
Reference in New Issue
Block a user