diff --git a/PEER_CLI_SCENARIOS.md b/PEER_CLI_SCENARIOS.md index 6b20222..be5b627 100644 --- a/PEER_CLI_SCENARIOS.md +++ b/PEER_CLI_SCENARIOS.md @@ -20,7 +20,39 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path. | S10 | Shutdown and goodbye cleanup | Alpha and bravo are connected, then bravo shuts down. | Alpha receives peer loss/removal and remote games from bravo disappear. | | S11 | Same identity reconnect | Bravo restarts with the same state dir but a new port, then alpha connects to the new address. | Alpha has one bravo peer entry with the updated address, not duplicate identities. | | S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. | +| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. | +| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. | ## Run Log -No scenario evidence recorded yet. +### 2026-05-17 - Exact Transfer And Large Multi-Peer Chunking + +- Fixture update: `fixture-alpha/alienswarm/alienswarm.eti` was rebuilt with + `rar a -idq -m0` from three random 40 MiB payload files, then renamed to + `.eti`. Final archive size: `125,829,913` bytes. `unrar t -idq` passed. +- Gates before manual runs: `just fmt`, `just test`, `just peer-cli-build`, + `just clippy`, and `just peer-cli-image` passed. +- S13 small exact transfer: `deep-small-client` downloaded `bfbc2` from + `fixture-bravo` with `install=false`. SHA-256 manifests matched exactly: + `bfbc2/bfbc2.eti` + `f7accef0833f29481acdeaac58261bc4fc23ebb58b7197049024d354f60daabc`; + `bfbc2/version.ini` + `f3d94f70edcebbbc7d8ce38fdf076412fb95114ce1ecf071b26c9c2f93586372`. +- S13 large exact transfer: `deep-stage-b` downloaded `alienswarm` from + `fixture-alpha` with `install=false`. SHA-256 manifests matched exactly: + `alienswarm/alienswarm.eti` + `8a4fb1fd458e731affb175134b7b99efc8d8a5eda80e978ba81f721d01aecc43`; + `alienswarm/notes.txt` + `3832bcb7057a4453981e975d2d2d528bfd9a26671423352f4a8527362d5b9810`; + `alienswarm/version.ini` + `8dfdc51d4dbfb06015b41a85a5f5d47f44144139e4a12db2b17eb040773082a3`. +- S14 multi-peer setup: `deep-stage-c` connected to alpha + (`10.66.0.3:53514`) and `deep-stage-b` (`10.66.0.2:58491`). `list-games` + showed `alienswarm` with `peer_count=2` before the download. +- S14 chunk-source evidence for `alienswarm/alienswarm.eti`: `deep-stage-c` + received chunks from `deep-stage-b` at offsets `0` and `67,108,864` + (`67,108,864` bytes total) and from alpha at offsets `33,554,432` and + `100,663,296` (`58,721,049` bytes total). The source-byte difference was + `8,387,815` bytes, below one `32 MiB` chunk. +- S14 final exactness: `deep-stage-c`'s `alienswarm` SHA-256 manifest matched + `fixture-alpha` exactly for `alienswarm.eti`, `notes.txt`, and `version.ini`. diff --git a/crates/lanspread-peer-cli/fixtures/fixture-alpha/alienswarm/alienswarm.eti b/crates/lanspread-peer-cli/fixtures/fixture-alpha/alienswarm/alienswarm.eti index b372d66..d30031b 100644 Binary files a/crates/lanspread-peer-cli/fixtures/fixture-alpha/alienswarm/alienswarm.eti and b/crates/lanspread-peer-cli/fixtures/fixture-alpha/alienswarm/alienswarm.eti differ diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 01673b1..8e1c038 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -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})) } diff --git a/crates/lanspread-peer/src/download/orchestrator.rs b/crates/lanspread-peer/src/download/orchestrator.rs index 3e4f0ce..cd0719f 100644 --- a/crates/lanspread-peer/src/download/orchestrator.rs +++ b/crates/lanspread-peer/src/download/orchestrator.rs @@ -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); + } } } } diff --git a/crates/lanspread-peer/src/download/planning.rs b/crates/lanspread-peer/src/download/planning.rs index aa91351..ee24f99 100644 --- a/crates/lanspread-peer/src/download/planning.rs +++ b/crates/lanspread-peer/src/download/planning.rs @@ -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); diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 39dc925..e6c26ea 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -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. diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 1f8df9a..0a5aa22 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -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; }