From 8a9f420a06b79408bb5b813201899b1ecb6eed6f Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 20 May 2026 08:27:28 +0200 Subject: [PATCH] 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. --- PEER_CLI_SCENARIOS.md | 1 + .../scripts/run_extended_scenarios.py | 56 ++++++++++++ crates/lanspread-peer-cli/src/main.rs | 85 ++++++++++++++++--- 3 files changed, 128 insertions(+), 14 deletions(-) diff --git a/PEER_CLI_SCENARIOS.md b/PEER_CLI_SCENARIOS.md index 91da868..8405cf6 100644 --- a/PEER_CLI_SCENARIOS.md +++ b/PEER_CLI_SCENARIOS.md @@ -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. | | 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. | +| 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 diff --git a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py index af30d2d..79d400c 100644 --- a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py +++ b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py @@ -26,6 +26,8 @@ CONTAINER_PREFIX = "lanspread-peer-cli-ext" CATALOG_DB = "/app/game.db" FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures" CHUNK_SIZE = 32 * 1024 * 1024 +PERF_GAME_ID = "bf1942" +PERF_GAME_SIZE = 2 * 1024 * 1024 * 1024 IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"} @@ -322,6 +324,7 @@ class Runner: ("S34", self.s34_many_small_files), ("S35", self.s35_unknown_game_filtered), ("S36", self.s36_latest_singleton), + ("S37", self.s37_single_source_download_throughput), ] for scenario_id, scenario in scenarios: @@ -1015,6 +1018,45 @@ class Runner: 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" + 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]: result = subprocess.run( @@ -1122,6 +1164,20 @@ def create_many_small_game(root: Path) -> None: (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: for peer in peers: client.connect_to(peer) diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 0015b8c..99c626d 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -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, game_files: HashMap>, unavailable_games: HashSet, + downloads: HashMap, } #[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, peer_game_db: Arc>, @@ -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