test(peer-cli): add large exact-transfer coverage

Add deeper peer CLI coverage for file-transfer integrity and multi-peer
chunking. The alpha fixture now carries a real renamed RAR archive larger
than 100 MB for alienswarm, which gives the chunk planner enough work to
split a single game archive across multiple peers.

Expose completed chunk source details as a peer event and have the CLI print
that event as JSONL. This keeps transfer behavior in lanspread-peer while the
CLI remains a harness that reports what the peer runtime did. The Tauri shell
logs the event at debug level so the shared PeerEvent enum stays exhaustive.

Document the new S13/S14 scenarios and record the manual run evidence,
including SHA-256 manifests and the per-peer byte split for the large archive.

Test Plan:
- just fmt
- just test
- just peer-cli-build
- just clippy
- just peer-cli-image
- unrar t -idq crates/lanspread-peer-cli/fixtures/fixture-alpha/alienswarm/alienswarm.eti
- Manual peer CLI: bravo -> deep-small-client bfbc2 download with matching SHA-256 manifests
- Manual peer CLI: alpha -> deep-stage-b alienswarm download with matching SHA-256 manifests
- Manual peer CLI: alpha + deep-stage-b -> deep-stage-c alienswarm download with chunk events from both peers and matching SHA-256 manifests

Refs: PEER_CLI_SCENARIOS.md S13 S14
This commit is contained in:
2026-05-17 10:25:26 +02:00
parent 5d58791192
commit 274b9d2fd4
7 changed files with 157 additions and 19 deletions
+16
View File
@@ -390,6 +390,22 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
)
}
PeerEvent::DownloadGameFilesBegin { id } => ("download-begin", json!({"game_id": id})),
PeerEvent::DownloadGameFileChunkFinished {
id,
peer_addr,
relative_path,
offset,
length,
} => (
"download-chunk-finished",
json!({
"game_id": id,
"peer_addr": peer_addr.to_string(),
"relative_path": relative_path,
"offset": offset,
"length": length,
}),
),
PeerEvent::DownloadGameFilesFinished { id } => {
("download-finished", json!({"game_id": id}))
}
@@ -109,21 +109,32 @@ pub async fn download_game_files(
}
for chunk_result in results {
if let Err(e) = chunk_result.result {
log::warn!(
"Failed to download chunk from {}: {e}",
chunk_result.peer_addr
);
if chunk_result.chunk.retry_count < MAX_RETRY_COUNT {
let mut retry_chunk = chunk_result.chunk;
retry_chunk.retry_count += 1;
retry_chunk.last_peer = Some(chunk_result.peer_addr);
failed_chunks.push(retry_chunk);
} else {
last_err = Some(eyre::eyre!(
"Max retries exceeded for chunk: {}",
chunk_result.chunk.relative_path
));
match chunk_result.result {
Ok(()) => {
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
id: game_id.to_string(),
peer_addr: chunk_result.peer_addr,
relative_path: chunk_result.chunk.relative_path,
offset: chunk_result.chunk.offset,
length: chunk_result.chunk.length,
});
}
Err(e) => {
log::warn!(
"Failed to download chunk from {}: {e}",
chunk_result.peer_addr
);
if chunk_result.chunk.retry_count < MAX_RETRY_COUNT {
let mut retry_chunk = chunk_result.chunk;
retry_chunk.retry_count += 1;
retry_chunk.last_peer = Some(chunk_result.peer_addr);
failed_chunks.push(retry_chunk);
} else {
last_err = Some(eyre::eyre!(
"Max retries exceeded for chunk: {}",
chunk_result.chunk.relative_path
));
}
}
}
}
@@ -174,9 +185,20 @@ pub async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}");
}
if let Err(e) = chunk_result.result {
log::error!("Retry failed for chunk: {e}");
last_err = Some(e);
match chunk_result.result {
Ok(()) => {
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
id: game_id.to_string(),
peer_addr: chunk_result.peer_addr,
relative_path: chunk_result.chunk.relative_path,
offset: chunk_result.chunk.offset,
length: chunk_result.chunk.length,
});
}
Err(e) => {
log::error!("Retry failed for chunk: {e}");
last_err = Some(e);
}
}
}
}
@@ -220,6 +220,54 @@ mod tests {
);
}
#[test]
fn build_peer_plans_spreads_large_file_chunks_across_shared_peers() {
let peers = vec![loopback_addr(12000), loopback_addr(12001)];
let large_file = "game/large.eti";
let file_size = 120 * 1024 * 1024;
let mut file_peer_map = HashMap::new();
file_peer_map.insert("game/version.ini".to_string(), peers.clone());
file_peer_map.insert(large_file.to_string(), peers.clone());
let file_descs = vec![
GameFileDescription {
game_id: "game".to_string(),
relative_path: "game/version.ini".to_string(),
is_dir: false,
size: 9,
},
GameFileDescription {
game_id: "game".to_string(),
relative_path: large_file.to_string(),
is_dir: false,
size: file_size,
},
];
let plans = build_peer_plans(&peers, &file_descs, &file_peer_map);
let mut chunk_counts = HashMap::new();
let mut byte_counts = HashMap::new();
for (peer, plan) in plans {
for chunk in plan.chunks {
if chunk.relative_path == large_file {
*chunk_counts.entry(peer).or_insert(0usize) += 1;
*byte_counts.entry(peer).or_insert(0u64) += chunk.length;
}
}
}
assert_eq!(chunk_counts.get(&peers[0]), Some(&2));
assert_eq!(chunk_counts.get(&peers[1]), Some(&2));
let peer_a_bytes = byte_counts.get(&peers[0]).copied().unwrap_or_default();
let peer_b_bytes = byte_counts.get(&peers[1]).copied().unwrap_or_default();
assert_eq!(peer_a_bytes + peer_b_bytes, file_size);
assert!(
peer_a_bytes.abs_diff(peer_b_bytes) <= CHUNK_SIZE,
"large file bytes should be balanced within one chunk: {peer_a_bytes} vs {peer_b_bytes}"
);
}
#[test]
fn build_peer_plans_respects_file_peer_map() {
let shared_a = loopback_addr(12010);
+8
View File
@@ -91,6 +91,14 @@ pub enum PeerEvent {
},
/// Download has started for a game.
DownloadGameFilesBegin { id: String },
/// A file chunk has been downloaded from a peer.
DownloadGameFileChunkFinished {
id: String,
peer_addr: SocketAddr,
relative_path: String,
offset: u64,
length: u64,
},
/// Download has completed successfully.
DownloadGameFilesFinished { id: String },
/// Download has failed.
@@ -795,6 +795,18 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
"PeerEvent::DownloadGameFilesBegin",
);
}
PeerEvent::DownloadGameFileChunkFinished {
id,
peer_addr,
relative_path,
offset,
length,
} => {
log::debug!(
"PeerEvent::DownloadGameFileChunkFinished received for {id}: \
{relative_path} offset {offset} length {length} from {peer_addr}"
);
}
PeerEvent::DownloadGameFilesFinished { id } => {
handle_download_finished(app_handle, id).await;
}