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:
+33
-1
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 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`.
|
||||||
|
|||||||
Binary file not shown.
@@ -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::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 } => {
|
PeerEvent::DownloadGameFilesFinished { id } => {
|
||||||
("download-finished", json!({"game_id": id}))
|
("download-finished", json!({"game_id": id}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,21 +109,32 @@ pub async fn download_game_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for chunk_result in results {
|
for chunk_result in results {
|
||||||
if let Err(e) = chunk_result.result {
|
match chunk_result.result {
|
||||||
log::warn!(
|
Ok(()) => {
|
||||||
"Failed to download chunk from {}: {e}",
|
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||||
chunk_result.peer_addr
|
id: game_id.to_string(),
|
||||||
);
|
peer_addr: chunk_result.peer_addr,
|
||||||
if chunk_result.chunk.retry_count < MAX_RETRY_COUNT {
|
relative_path: chunk_result.chunk.relative_path,
|
||||||
let mut retry_chunk = chunk_result.chunk;
|
offset: chunk_result.chunk.offset,
|
||||||
retry_chunk.retry_count += 1;
|
length: chunk_result.chunk.length,
|
||||||
retry_chunk.last_peer = Some(chunk_result.peer_addr);
|
});
|
||||||
failed_chunks.push(retry_chunk);
|
}
|
||||||
} else {
|
Err(e) => {
|
||||||
last_err = Some(eyre::eyre!(
|
log::warn!(
|
||||||
"Max retries exceeded for chunk: {}",
|
"Failed to download chunk from {}: {e}",
|
||||||
chunk_result.chunk.relative_path
|
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}");
|
eyre::bail!("download cancelled for game {game_id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = chunk_result.result {
|
match chunk_result.result {
|
||||||
log::error!("Retry failed for chunk: {e}");
|
Ok(()) => {
|
||||||
last_err = Some(e);
|
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]
|
#[test]
|
||||||
fn build_peer_plans_respects_file_peer_map() {
|
fn build_peer_plans_respects_file_peer_map() {
|
||||||
let shared_a = loopback_addr(12010);
|
let shared_a = loopback_addr(12010);
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ pub enum PeerEvent {
|
|||||||
},
|
},
|
||||||
/// Download has started for a game.
|
/// Download has started for a game.
|
||||||
DownloadGameFilesBegin { id: String },
|
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.
|
/// Download has completed successfully.
|
||||||
DownloadGameFilesFinished { id: String },
|
DownloadGameFilesFinished { id: String },
|
||||||
/// Download has failed.
|
/// Download has failed.
|
||||||
|
|||||||
@@ -795,6 +795,18 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
"PeerEvent::DownloadGameFilesBegin",
|
"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 } => {
|
PeerEvent::DownloadGameFilesFinished { id } => {
|
||||||
handle_download_finished(app_handle, id).await;
|
handle_download_finished(app_handle, id).await;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user