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
+1
View File
@@ -44,6 +44,7 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. | | S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. | | S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
| S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. | | S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. |
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
## Version-Skew Contract ## Version-Skew Contract
@@ -26,6 +26,8 @@ CONTAINER_PREFIX = "lanspread-peer-cli-ext"
CATALOG_DB = "/app/game.db" CATALOG_DB = "/app/game.db"
FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures" FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures"
CHUNK_SIZE = 32 * 1024 * 1024 CHUNK_SIZE = 32 * 1024 * 1024
PERF_GAME_ID = "bf1942"
PERF_GAME_SIZE = 2 * 1024 * 1024 * 1024
IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"} IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"}
@@ -322,6 +324,7 @@ class Runner:
("S34", self.s34_many_small_files), ("S34", self.s34_many_small_files),
("S35", self.s35_unknown_game_filtered), ("S35", self.s35_unknown_game_filtered),
("S36", self.s36_latest_singleton), ("S36", self.s36_latest_singleton),
("S37", self.s37_single_source_download_throughput),
] ]
for scenario_id, scenario in scenarios: for scenario_id, scenario in scenarios:
@@ -1015,6 +1018,45 @@ class Runner:
raise ScenarioError("got-game-files had no descriptors") raise ScenarioError("got-game-files had no descriptors")
return "client reported latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched" return "client reported latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched"
def s37_single_source_download_throughput(self) -> str:
source_dir = self.fixture_root / "s37-source"
create_large_sparse_game(source_dir / PERF_GAME_ID, size=PERF_GAME_SIZE)
source = self.peer("s37-source", games_dir=source_dir)
client = self.peer("s37-client")
connect_many(client, [source])
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version="20260520")
waiter = LineWaiter(len(client.output))
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
finished = client.wait_for(
event_is("download-finished", PERF_GAME_ID),
timeout=300,
description=f"{PERF_GAME_ID} throughput download",
waiter=waiter,
)
destination_archive = client.host_games_dir / PERF_GAME_ID / f"{PERF_GAME_ID}.eti"
if destination_archive.stat().st_size != PERF_GAME_SIZE:
raise ScenarioError(
f"downloaded archive size mismatch: {destination_archive.stat().st_size} != {PERF_GAME_SIZE}"
)
throughput = finished.get("data", {}).get("throughput")
if not throughput:
raise ScenarioError(f"download-finished did not include throughput: {finished}")
expected_bytes = PERF_GAME_SIZE + len("20260520")
if int(throughput["bytes"]) != expected_bytes:
raise ScenarioError(
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
)
return (
f"{PERF_GAME_ID} {format_bytes(PERF_GAME_SIZE)} single-source download: "
f"{throughput['mib_per_s']:.2f} MiB/s, "
f"{throughput['mbit_per_s']:.2f} Mbit/s, "
f"{throughput['duration_ms'] / 1000.0:.3f}s, "
f"{throughput['chunks']} chunks"
)
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]: def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run( result = subprocess.run(
@@ -1122,6 +1164,20 @@ def create_many_small_game(root: Path) -> None:
(root / "version.ini").write_text("20250101", encoding="utf-8") (root / "version.ini").write_text("20250101", encoding="utf-8")
def create_large_sparse_game(root: Path, *, size: int) -> None:
if root.exists():
shutil.rmtree(root)
root.mkdir(parents=True)
(root / "version.ini").write_text("20260520", encoding="utf-8")
archive = root / f"{root.name}.eti"
with archive.open("wb") as handle:
handle.truncate(size)
def format_bytes(size: int) -> str:
return f"{size / 1024 / 1024 / 1024:.2f} GiB"
def connect_many(client: Peer, peers: list[Peer]) -> None: def connect_many(client: Peer, peers: list[Peer]) -> None:
for peer in peers: for peer in peers:
client.connect_to(peer) client.connect_to(peer)
+71 -14
View File
@@ -7,7 +7,7 @@ use std::{
net::SocketAddr, net::SocketAddr,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Duration, time::{Duration, Instant},
}; };
use eyre::Context; use eyre::Context;
@@ -95,6 +95,7 @@ struct CliState {
active_operations: Vec<ActiveOperation>, active_operations: Vec<ActiveOperation>,
game_files: HashMap<String, Vec<GameFileDescription>>, game_files: HashMap<String, Vec<GameFileDescription>>,
unavailable_games: HashSet<String>, unavailable_games: HashSet<String>,
downloads: HashMap<String, DownloadMeasurement>,
} }
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
@@ -103,6 +104,12 @@ struct LocalPeer {
addr: String, addr: String,
} }
struct DownloadMeasurement {
started_at: Instant,
bytes: u64,
chunks: u64,
}
struct SharedState { struct SharedState {
state: RwLock<CliState>, state: RwLock<CliState>,
peer_game_db: Arc<RwLock<PeerGameDB>>, 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}), 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 { PeerEvent::DownloadGameFileChunkFinished {
id, id,
peer_addr, peer_addr,
relative_path, relative_path,
offset, offset,
length, length,
} => ( } => {
"download-chunk-finished", if let Some(measurement) = shared.state.write().await.downloads.get_mut(&id) {
json!({ measurement.bytes = measurement.bytes.saturating_add(length);
"game_id": id, measurement.chunks = measurement.chunks.saturating_add(1);
"peer_addr": peer_addr.to_string(), }
"relative_path": relative_path, (
"offset": offset, "download-chunk-finished",
"length": length, json!({
}), "game_id": id,
), "peer_addr": peer_addr.to_string(),
PeerEvent::DownloadGameFilesFinished { id } => game_id_event("download-finished", id), "relative_path": relative_path,
PeerEvent::DownloadGameFilesFailed { id } => game_id_event("download-failed", id), "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::DownloadGameFilesAllPeersGone { id } => game_id_event("download-peers-gone", id),
PeerEvent::InstallGameBegin { id, operation } => ( PeerEvent::InstallGameBegin { id, operation } => (
"install-begin", "install-begin",
@@ -494,6 +521,36 @@ fn game_id_event(kind: &'static str, id: String) -> (&'static str, Value) {
(kind, json!({"game_id": id})) (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) { async fn no_peers_event(shared: &SharedState, id: String) -> (&'static str, Value) {
shared shared
.state .state