test(peer-cli): measure single-source download throughput

Add peer-cli accounting for download sessions so terminal download events
report bytes, chunks, elapsed time, MiB/s, and Mbit/s. The extended scenario
runner now has S37, a focused single-source download benchmark that creates a
2 GiB sparse bf1942 archive, downloads it from one peer with install disabled,
and checks the destination archive size and reported byte count.

This gives the QUIC performance work a repeatable measurement below the 5 GiB
limit from the original request. The source file is sparse, so S37 is aimed at
the app, QUIC, and destination-write path rather than raw source-disk reads;
the existing correctness scenarios still cover normal game downloads.

Baseline S37 before QUIC tuning:
- 733.22 MiB/s
- 6150.72 Mbit/s
- 2.793s for 2.00 GiB plus version.ini
- 65 reported chunks

Test Plan:
- just fmt
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S37 --build-image

Refs: local LAN download performance investigation on 2026-05-20.
This commit is contained in:
2026-05-20 08:27:28 +02:00
parent 6a90ca951d
commit 8a9f420a06
3 changed files with 128 additions and 14 deletions
+71 -14
View File
@@ -7,7 +7,7 @@ use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
time::{Duration, Instant},
};
use eyre::Context;
@@ -95,6 +95,7 @@ struct CliState {
active_operations: Vec<ActiveOperation>,
game_files: HashMap<String, Vec<GameFileDescription>>,
unavailable_games: HashSet<String>,
downloads: HashMap<String, DownloadMeasurement>,
}
#[derive(Clone, serde::Serialize)]
@@ -103,6 +104,12 @@ struct LocalPeer {
addr: String,
}
struct DownloadMeasurement {
started_at: Instant,
bytes: u64,
chunks: u64,
}
struct SharedState {
state: RwLock<CliState>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
@@ -443,25 +450,45 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
json!({"game_id": id, "file_descriptions": file_descriptions}),
)
}
PeerEvent::DownloadGameFilesBegin { id } => ("download-begin", json!({"game_id": id})),
PeerEvent::DownloadGameFilesBegin { id } => {
shared.state.write().await.downloads.insert(
id.clone(),
DownloadMeasurement {
started_at: Instant::now(),
bytes: 0,
chunks: 0,
},
);
("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 } => game_id_event("download-finished", id),
PeerEvent::DownloadGameFilesFailed { id } => game_id_event("download-failed", id),
} => {
if let Some(measurement) = shared.state.write().await.downloads.get_mut(&id) {
measurement.bytes = measurement.bytes.saturating_add(length);
measurement.chunks = measurement.chunks.saturating_add(1);
}
(
"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_terminal_event(shared, "download-finished", id).await
}
PeerEvent::DownloadGameFilesFailed { id } => {
download_terminal_event(shared, "download-failed", id).await
}
PeerEvent::DownloadGameFilesAllPeersGone { id } => game_id_event("download-peers-gone", id),
PeerEvent::InstallGameBegin { id, operation } => (
"install-begin",
@@ -494,6 +521,36 @@ fn game_id_event(kind: &'static str, id: String) -> (&'static str, Value) {
(kind, json!({"game_id": id}))
}
async fn download_terminal_event(
shared: &SharedState,
kind: &'static str,
id: String,
) -> (&'static str, Value) {
let measurement = shared.state.write().await.downloads.remove(&id);
let Some(measurement) = measurement else {
return game_id_event(kind, id);
};
let duration = measurement.started_at.elapsed();
let seconds = duration.as_secs_f64().max(f64::EPSILON);
#[allow(clippy::cast_precision_loss)]
let bytes = measurement.bytes as f64;
(
kind,
json!({
"game_id": id,
"throughput": {
"bytes": measurement.bytes,
"chunks": measurement.chunks,
"duration_ms": duration.as_secs_f64() * 1000.0,
"mib_per_s": bytes / seconds / 1_048_576.0,
"mbit_per_s": bytes * 8.0 / seconds / 1_000_000.0,
},
}),
)
}
async fn no_peers_event(shared: &SharedState, id: String) -> (&'static str, Value) {
shared
.state