diff --git a/PEER_CLI_SCENARIOS.md b/PEER_CLI_SCENARIOS.md index 8336ba1..1c2867c 100644 --- a/PEER_CLI_SCENARIOS.md +++ b/PEER_CLI_SCENARIOS.md @@ -25,6 +25,25 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path. | S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. | | S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. | | S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. | +| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. | +| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. | +| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. | +| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. | +| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. | +| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root gets a newer `version.ini`. | The other peer receives a library update without reconnecting, and the aggregated row reflects the newer `eti_game_version`. | +| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. | +| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. | +| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. | +| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. | +| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. | +| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. | +| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique games, shared games, and differing versions; a sixth client connects to all five. | The client shows one row per game ID, correct ready-source `peer_count`, latest `eti_game_version`, no duplicates, and no self entries. | +| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. | +| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. | +| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes 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. | +| 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. | ## Version-Skew Contract @@ -48,6 +67,23 @@ game ID: `download-chunk-finished` source addresses, and source/receiver SHA-256 manifests. +## Extended Failure And Mutation Contracts + +Use S18-S36 to pin down operational behavior that is awkward to prove with the +GUI: + +- A failed download must not commit the root `version.ini` sentinel. Partial + payload files may remain, but they must not be advertised as a ready local + game and must not leave an active operation stuck. +- Source failure during a redundant download should retry failed chunks against + another validated source for the same latest-version file. +- Live local library changes are observable by connected peers through library + deltas; reconnect is not required for add, remove, or version-bump cases. +- Same-game operations are single-flight. A duplicate download request while a + game is already active is rejected instead of starting another writer. +- Unknown remote game IDs are filtered by the receiver's current catalog and + are not downloadable. + For a manual run, prefer a catalog game ID already served by the fixture lab, such as `cnc4`, then create temporary `just peer-cli-run` game roots with different `version.ini` contents. The existing alpha/bravo/charlie fixtures @@ -56,6 +92,78 @@ until a dedicated fixture or temporary games root is prepared. ## Run Log +### 2026-05-18 - Full Automated Docker Matrix Pass + +- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` + passed S1-S36 against the current `lanspread-peer-cli:dev` image. +- S1-S17 rerun highlights: startup, direct connect, aggregation, download, + install/uninstall, duplicate-source, ambiguous metadata, missing game, + shutdown cleanup, identity reconnect, serve gates, exact equality, large + multi-peer chunking, and latest-version selection/conflict all passed. Exact + transfer scenarios used `diff -r`/SHA-256 manifest checks; S14 chunk totals + were `58,721,049` and `67,108,864` bytes, balanced within one `32 MiB` chunk. +- S18-S36 rerun highlights: source-drop, disk-full, live mutation, concurrency, + duplicate-operation rejection, self-connect rejection, empty-peer sourcing, + 5-peer aggregation, bootstrapped sourcing, reinstall, external mutation, + many-small-files, unknown catalog filtering, and stale-majority/latest + singleton cases all passed. File-copy scenarios used diff/manifests or `cmp` + for the mutated install payload. + +### 2026-05-18 - Extended Scenario Docker Pass + +- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` + passed for S18-S36 after rebuilding `lanspread-peer-cli:dev` with + `just peer-cli-image`. +- S18 redundant source drop: one `alienswarm` source was killed after + `download-begin`; the client emitted `download-finished`, no + `download-failed`, and `diff -r`/SHA-256 manifest comparison matched the + surviving source. Recorded large-file chunk bytes from the surviving source: + `58,721,049`. +- S19 sole-source drop: killing the only source after `download-begin` emitted + `download-failed`; the receiver had no committed `alienswarm/version.ini`, no + ready local row, and no active operation left. +- S20 receiver write failure: a client with `/games` constrained to a `32m` + tmpfs emitted `download-failed`; `/games/alienswarm/version.ini` was absent + inside the container and active operations were empty. +- S21-S23 live mutation propagation: a connected peer observed `cod5` added, + `cod5` removed, and `cnc4` bumped from `20250101` to `20260501` without + reconnecting or dropping the peer. +- S24-S25 concurrency: two clients downloaded `alienswarm` from one source at + the same time and both diffed cleanly; one client downloaded `bfbc2` and + `cnctw` concurrently and both roots diffed cleanly. +- S26 duplicate same-game download: the second `alienswarm` download command + returned `operation already in progress for game alienswarm`; the first + download still finished and diffed cleanly. +- S27 self-connect rejection: connecting a peer to its own listener returned + `cannot connect peer to itself ...`; `list-peers` stayed empty and the peer + stayed responsive. +- S28 address-change invariant: `just test` passed and included + `peer_db::tests::address_update_preserves_peer_identity_and_library`. +- S29 empty-library peer: an observer first saw the empty peer with zero games; + after that peer downloaded `alienswarm`, the downloaded root diffed cleanly + and the observer's peer snapshot for that same peer contained `alienswarm`. +- S30 5-peer aggregation: a sixth client connected to five peers and aggregated + six game IDs with expected `peer_count` and latest versions, with no duplicate + game rows and no self-peer entry. +- S31 bootstrapped source: after the original source was killed, a third peer + downloaded `alienswarm` from the bootstrapped client and diffed cleanly + against the original fixture. +- S32 reinstall: reinstall after uninstall recreated `local/`, reported + `installed=true`, and produced no transfer chunk events during reinstall. +- S33 external root mutation: after mutating the downloaded `bfbc2.eti` inside + the client container, `install` wrote `local/fixture-payload.txt` that matched + the mutated archive exactly by `cmp`. +- S34 many-small-files transfer: a `bf1942` fixture with 20 small regular files + and no `.eti` downloaded with `install=false`; 21 file chunks were observed + including `version.ini`, and the receiver diffed cleanly against the source. +- S35 unknown game ID: a source advertised `mystery-game` via `--fixture`; the + receiver filtered it out of `list-games`, `download mystery-game` returned + `game mystery-game is not in the local catalog`, and no local files were + created. +- S36 latest singleton: with one peer on `20260501` and four peers on + `20250101`, the client reported `peer_count=5` and latest `20260501`; only + the singleton latest peer sent chunks and the final root diffed cleanly. + ### 2026-05-18 - Full Matrix Manual Docker Pass - Build/setup: `just peer-cli-image` passed. Local `just peer-cli-build` diff --git a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py new file mode 100644 index 0000000..af30d2d --- /dev/null +++ b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py @@ -0,0 +1,1395 @@ +#!/usr/bin/env python3 +"""Run the peer-cli scenarios S1-S36 through Docker.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import queue +import shutil +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + + +REPO = Path(__file__).resolve().parents[3] +RUN_ROOT = REPO / ".lanspread-peer-cli" / "extended-scenarios" +IMAGE = "lanspread-peer-cli:dev" +NETWORK = "lanspread" +CONTAINER_PREFIX = "lanspread-peer-cli-ext" +CATALOG_DB = "/app/game.db" +FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures" +CHUNK_SIZE = 32 * 1024 * 1024 +IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"} + + +class ScenarioError(RuntimeError): + pass + + +@dataclass +class LineWaiter: + seen: int = 0 + + +@dataclass +class Peer: + runner: "Runner" + name: str + games_dir: Path | None = None + readonly_games: bool = False + tmpfs_size: str | None = None + fixtures: list[str] = field(default_factory=list) + extra_args: list[str] = field(default_factory=list) + + process: subprocess.Popen[str] | None = None + output: list[dict[str, Any]] = field(default_factory=list) + raw_output: list[str] = field(default_factory=list) + events: queue.Queue[dict[str, Any]] = field(default_factory=queue.Queue) + condition: threading.Condition = field(default_factory=threading.Condition) + request_index: int = 0 + ready_addr: str | None = None + peer_id: str | None = None + + @property + def container_name(self) -> str: + return f"{CONTAINER_PREFIX}-{self.runner.run_id}-{self.name}" + + @property + def host_games_dir(self) -> Path: + if self.games_dir is not None: + return self.games_dir + return self.runner.games_root / self.name + + @property + def host_state_dir(self) -> Path: + return self.runner.state_root / self.name + + def start(self) -> "Peer": + self.host_state_dir.mkdir(parents=True, exist_ok=True) + if self.tmpfs_size is None: + self.host_games_dir.mkdir(parents=True, exist_ok=True) + + command = [ + "docker", + "run", + "--rm", + "--init", + "--network", + NETWORK, + "--name", + self.container_name, + "-i", + "-v", + f"{self.host_state_dir}:/state", + ] + + if self.tmpfs_size is None: + mode = "ro" if self.readonly_games else "rw" + command.extend(["-v", f"{self.host_games_dir}:/games:{mode}"]) + else: + command.extend(["--tmpfs", f"/games:size={self.tmpfs_size}"]) + + command.extend( + [ + IMAGE, + "--name", + self.name, + "--games-dir", + "/games", + "--state-dir", + "/state", + "--catalog-db", + CATALOG_DB, + ] + ) + for fixture in self.fixtures: + command.extend(["--fixture", fixture]) + command.extend(self.extra_args) + + self.runner.log(f"start {self.name}: {' '.join(command)}") + self.process = subprocess.Popen( + command, + cwd=REPO, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + threading.Thread(target=self._read_output, daemon=True).start() + self.wait_ready() + return self + + def _read_output(self) -> None: + assert self.process is not None + assert self.process.stdout is not None + for line in self.process.stdout: + stripped = line.rstrip("\r\n") + with self.condition: + self.raw_output.append(stripped) + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = {"type": "raw", "line": stripped} + self.output.append(parsed) + self.condition.notify_all() + self.events.put(parsed) + + def wait_ready(self) -> None: + line = self.wait_for( + lambda item: item.get("type") == "event" + and item.get("event") == "local-peer-ready", + timeout=20, + description=f"{self.name} local-peer-ready", + ) + data = line["data"] + self.ready_addr = data["addr"] + self.peer_id = data["peer_id"] + + def send( + self, + payload: dict[str, Any], + *, + expect_error: bool = False, + timeout: float = 20, + ) -> dict[str, Any]: + assert self.process is not None + assert self.process.stdin is not None + self.request_index += 1 + request_id = f"{self.name}-{self.request_index}" + payload = dict(payload) + payload["id"] = request_id + line = json.dumps(payload, separators=(",", ":")) + self.process.stdin.write(line + "\n") + self.process.stdin.flush() + + response = self.wait_for( + lambda item: item.get("id") == request_id + and item.get("type") in {"result", "error"}, + timeout=timeout, + description=f"{self.name} response to {payload.get('cmd')}", + ) + if response["type"] == "error": + if expect_error: + return response + raise ScenarioError(f"{self.name} command failed: {response}") + if expect_error: + raise ScenarioError(f"{self.name} command unexpectedly succeeded: {response}") + return response + + def wait_for( + self, + predicate: Callable[[dict[str, Any]], bool], + *, + timeout: float, + description: str, + waiter: LineWaiter | None = None, + ) -> dict[str, Any]: + deadline = time.monotonic() + timeout + with self.condition: + if waiter is None: + start = 0 + else: + start = waiter.seen + while True: + for index in range(start, len(self.output)): + item = self.output[index] + if predicate(item): + if waiter is not None: + waiter.seen = index + 1 + return item + start = len(self.output) + if waiter is not None: + waiter.seen = start + remaining = deadline - time.monotonic() + if remaining <= 0: + tail = "\n".join(self.raw_output[-20:]) + raise ScenarioError( + f"timed out waiting for {description} on {self.name}\n{tail}" + ) + self.condition.wait(remaining) + + def list_games(self) -> dict[str, Any]: + return self.send({"cmd": "list-games"})["data"] + + def list_peers(self) -> list[dict[str, Any]]: + return self.send({"cmd": "list-peers"})["data"]["peers"] + + def status(self) -> dict[str, Any]: + return self.send({"cmd": "status"})["data"] + + def connect_to(self, other: "Peer") -> None: + if other.ready_addr is None: + raise ScenarioError(f"{other.name} is not ready") + self.send({"cmd": "connect", "addr": other.ready_addr}) + self.send({"cmd": "wait-peers", "count": 1, "timeout_ms": 10000}) + + def shutdown(self) -> None: + if self.process is None: + return + if self.process.poll() is None: + try: + self.send({"cmd": "shutdown"}, timeout=8) + except Exception: + self.kill() + try: + self.process.wait(timeout=8) + except subprocess.TimeoutExpired: + self.kill() + + def kill(self) -> None: + subprocess.run( + ["docker", "rm", "-f", self.container_name], + cwd=REPO, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if self.process is not None: + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + + def docker_exec(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["docker", "exec", self.container_name, *args], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=check, + ) + + +class Runner: + def __init__(self, selected: set[str] | None = None, build_image: bool = False) -> None: + self.selected = selected + self.build_image = build_image + self.run_id = str(int(time.time())) + self.state_root = RUN_ROOT / "state" + self.games_root = RUN_ROOT / "games" + self.fixture_root = RUN_ROOT / "fixtures" + self.current_peers: list[Peer] = [] + self.results: list[tuple[str, str]] = [] + + def log(self, message: str) -> None: + print(message, flush=True) + + def run(self) -> None: + self.prepare() + scenarios: list[tuple[str, Callable[[], str]]] = [ + ("S1", self.s1_startup_scan), + ("S2", self.s2_direct_connect_handshake), + ("S3", self.s3_remote_aggregation), + ("S4", self.s4_single_source_download), + ("S5", self.s5_auto_install_download), + ("S6", self.s6_manual_install_uninstall), + ("S7", self.s7_duplicate_source_download), + ("S8", self.s8_ambiguous_metadata_rejection), + ("S9", self.s9_missing_game), + ("S10", self.s10_shutdown_cleanup), + ("S11", self.s11_same_identity_reconnect), + ("S12", self.s12_transfer_serving_gates), + ("S13", self.s13_exact_transfer_equality), + ("S14", self.s14_large_multi_peer_chunking), + ("S15", self.s15_three_way_version_skew), + ("S16", self.s16_latest_fanout_with_stale), + ("S17", self.s17_latest_conflict_rejection), + ("S18", self.s18_redundant_source_drop), + ("S19", self.s19_sole_source_drop), + ("S20", self.s20_receiver_write_failure), + ("S21", self.s21_add_game_propagation), + ("S22", self.s22_remove_game_propagation), + ("S23", self.s23_version_bump_propagation), + ("S24", self.s24_two_clients_one_source), + ("S25", self.s25_two_downloads_one_client), + ("S26", self.s26_duplicate_download_rejection), + ("S27", self.s27_self_connect_rejection), + ("S28", self.s28_address_change_unit), + ("S29", self.s29_empty_peer_participates), + ("S30", self.s30_mesh_aggregation), + ("S31", self.s31_bootstrapped_peer_source), + ("S32", self.s32_reinstall_after_uninstall), + ("S33", self.s33_install_after_mutation), + ("S34", self.s34_many_small_files), + ("S35", self.s35_unknown_game_filtered), + ("S36", self.s36_latest_singleton), + ] + + for scenario_id, scenario in scenarios: + if self.selected and scenario_id.lower() not in self.selected: + continue + self.cleanup_containers() + self.current_peers = [] + try: + self.log(f"\n== {scenario_id} ==") + evidence = scenario() + self.results.append((scenario_id, evidence)) + self.log(f"{scenario_id} PASS: {evidence}") + finally: + self.stop_peers() + + if not self.results: + raise ScenarioError("no scenarios selected") + + self.log("\nSummary:") + for scenario_id, evidence in self.results: + self.log(f"- {scenario_id}: {evidence}") + + def prepare(self) -> None: + reset_run_root() + self.state_root.mkdir(parents=True, exist_ok=True) + self.games_root.mkdir(parents=True, exist_ok=True) + self.fixture_root.mkdir(parents=True, exist_ok=True) + self.cleanup_containers() + if self.build_image: + run(["just", "peer-cli-image"], "build peer-cli image") + run(["just", "peer-cli-net"], "prepare peer-cli docker network") + + def cleanup_containers(self) -> None: + result = subprocess.run( + ["docker", "ps", "-a", "--format", "{{.Names}}"], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + names = [ + name + for name in result.stdout.splitlines() + if name.startswith(f"{CONTAINER_PREFIX}-") + ] + if names: + subprocess.run( + ["docker", "rm", "-f", *names], + cwd=REPO, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + def stop_peers(self) -> None: + for peer in reversed(self.current_peers): + peer.shutdown() + self.cleanup_containers() + + def peer( + self, + name: str, + *, + games_dir: Path | None = None, + readonly_games: bool = False, + tmpfs_size: str | None = None, + fixtures: list[str] | None = None, + extra_args: list[str] | None = None, + ) -> Peer: + peer = Peer( + runner=self, + name=name, + games_dir=games_dir, + readonly_games=readonly_games, + tmpfs_size=tmpfs_size, + fixtures=fixtures or [], + extra_args=extra_args or [], + ).start() + self.current_peers.append(peer) + return peer + + def s1_startup_scan(self) -> str: + alpha = self.peer("s1-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + games = alpha.list_games()["local"] + expected = {"alienswarm", "bf1942", "ggoo"} + seen = {game["id"] for game in games} + if not expected.issubset(seen): + raise ScenarioError(f"fixture-alpha games missing: expected {expected}, saw {seen}") + for game in games: + if game["id"] in expected: + assert_game_state(game, downloaded=True, installed=False, availability="Ready") + return "fixture-alpha emitted ready local games alienswarm, bf1942, and ggoo" + + def s2_direct_connect_handshake(self) -> str: + alpha = self.peer("s2-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + bravo = self.peer("s2-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + connect_many(alpha, [bravo]) + peers = alpha.list_peers() + if len(peers) != 1 or peers[0]["peer_id"] == alpha.peer_id: + raise ScenarioError(f"bad alpha peers after connect: {peers}") + if peers[0]["game_count"] != 4: + raise ScenarioError(f"expected bravo game_count=4, got {peers}") + return "alpha connected to bravo, saw one non-self peer with four games" + + def s3_remote_aggregation(self) -> str: + alpha = self.peer("s3-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + bravo = self.peer("s3-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s3-client") + connect_many(client, [alpha, bravo]) + expected_counts = { + "ggoo": 2, + "alienswarm": 1, + "bf1942": 1, + "bfbc2": 1, + "cnc4": 1, + "cnctw": 1, + } + for game_id, peer_count in expected_counts.items(): + wait_remote_game(client, game_id, peer_count=peer_count) + return "empty client aggregated alpha/bravo with ggoo peer_count=2 and unique games peer_count=1" + + def s4_single_source_download(self) -> str: + bravo = self.peer("s4-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s4-client") + connect_many(client, [bravo]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "bfbc2", "install": False}) + client.wait_for(event_is("got-game-files", "bfbc2"), timeout=20, description="got bfbc2", waiter=waiter) + client.wait_for(event_is("download-begin", "bfbc2"), timeout=20, description="begin bfbc2", waiter=waiter) + client.wait_for(event_is("download-finished", "bfbc2"), timeout=60, description="finish bfbc2", waiter=waiter) + game = wait_local_game(client, "bfbc2", downloaded=True, installed=False) + diff_game_dirs(FIXTURES / "fixture-bravo" / "bfbc2", client.host_games_dir / "bfbc2") + if (client.host_games_dir / "bfbc2" / "local").exists(): + raise ScenarioError("bfbc2 local/ exists after install=false") + return f"bfbc2 downloaded with install=false, local state installed={game['installed']}, diff matched" + + def s5_auto_install_download(self) -> str: + bravo = self.peer("s5-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s5-client") + connect_many(client, [bravo]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "cnctw"}) + client.wait_for(event_is("download-finished", "cnctw"), timeout=60, description="finish cnctw", waiter=waiter) + client.wait_for(event_is("install-finished", "cnctw"), timeout=30, description="install cnctw", waiter=waiter) + wait_local_game(client, "cnctw", downloaded=True, installed=True) + if not (client.host_games_dir / "cnctw" / "local" / "fixture-payload.txt").is_file(): + raise ScenarioError("cnctw install payload missing") + diff_game_dirs(FIXTURES / "fixture-bravo" / "cnctw", client.host_games_dir / "cnctw") + return "cnctw auto-installed, local fixture payload existed, root diff matched excluding local metadata" + + def s6_manual_install_uninstall(self) -> str: + bravo = self.peer("s6-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s6-client") + connect_many(client, [bravo]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "bfbc2", "install": False}) + client.wait_for(event_is("download-finished", "bfbc2"), timeout=60, description="finish bfbc2", waiter=waiter) + client.send({"cmd": "install", "game_id": "bfbc2"}) + client.wait_for(event_is("install-finished", "bfbc2"), timeout=30, description="install bfbc2", waiter=waiter) + wait_local_game(client, "bfbc2", downloaded=True, installed=True) + client.send({"cmd": "uninstall", "game_id": "bfbc2"}) + client.wait_for(event_is("uninstall-finished", "bfbc2"), timeout=30, description="uninstall bfbc2", waiter=waiter) + wait_local_game(client, "bfbc2", downloaded=True, installed=False) + if (client.host_games_dir / "bfbc2" / "local").exists(): + raise ScenarioError("bfbc2 local/ remained after uninstall") + diff_game_dirs(FIXTURES / "fixture-bravo" / "bfbc2", client.host_games_dir / "bfbc2") + return "manual install/uninstall toggled installed state, removed local/, preserved downloaded root" + + def s7_duplicate_source_download(self) -> str: + alpha = self.peer("s7-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + bravo = self.peer("s7-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s7-client") + connect_many(client, [alpha, bravo]) + wait_remote_game(client, "ggoo", peer_count=2) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "ggoo", "install": False}) + client.wait_for(event_is("download-finished", "ggoo"), timeout=60, description="finish ggoo", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-alpha" / "ggoo", client.host_games_dir / "ggoo") + return "shared ggoo downloaded once from duplicate sources and diffed cleanly" + + def s8_ambiguous_metadata_rejection(self) -> str: + dir_a = self.fixture_root / "s8-a" + dir_b = self.fixture_root / "s8-b" + copy_game("ggoo", dir_a, version="20260101") + copy_game("ggoo", dir_b, version="20260101") + with (dir_b / "ggoo" / "ggoo.eti").open("ab") as handle: + handle.write(b"conflict") + peer_a = self.peer("s8-a", games_dir=dir_a) + peer_b = self.peer("s8-b", games_dir=dir_b) + client = self.peer("s8-client") + connect_many(client, [peer_a, peer_b]) + wait_remote_game(client, "ggoo", peer_count=2, version="20260101") + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "ggoo", "install": False}) + client.wait_for(event_is("download-failed", "ggoo"), timeout=30, description="ggoo failed", waiter=waiter) + assert_not_exists(client.host_games_dir / "ggoo" / "version.ini") + return "conflicting latest ggoo file sizes emitted download-failed and left no version.ini" + + def s9_missing_game(self) -> str: + client = self.peer("s9-client") + err = client.send({"cmd": "download", "game_id": "cod2", "install": False}, expect_error=True) + if "no peers have game cod2" not in err["error"]: + raise ScenarioError(f"unexpected missing game error: {err}") + assert_not_exists(client.host_games_dir / "cod2") + return f"missing game command errored '{err['error']}' and created no local directory" + + def s10_shutdown_cleanup(self) -> str: + alpha = self.peer("s10-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + bravo = self.peer("s10-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + connect_many(alpha, [bravo]) + wait_remote_game(alpha, "bfbc2", peer_count=1) + bravo.shutdown() + wait_remote_absent(alpha, "bfbc2") + if alpha.list_peers(): + raise ScenarioError(f"alpha still has peers after bravo shutdown: {alpha.list_peers()}") + return "bravo graceful shutdown removed peer and bravo-only games from alpha" + + def s11_same_identity_reconnect(self) -> str: + alpha = self.peer("s11-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + bravo_dir = FIXTURES / "fixture-bravo" + bravo = self.peer("s11-bravo", games_dir=bravo_dir, readonly_games=True) + connect_many(alpha, [bravo]) + first_peer = alpha.list_peers()[0] + first_addr = first_peer["addr"] + first_id = first_peer["peer_id"] + bravo.shutdown() + wait_remote_absent(alpha, "bfbc2") + bravo = self.peer("s11-bravo", games_dir=bravo_dir, readonly_games=True) + connect_many(alpha, [bravo]) + peers = alpha.list_peers() + if len(peers) != 1: + raise ScenarioError(f"expected one bravo peer after reconnect, got {peers}") + if peers[0]["peer_id"] != first_id: + raise ScenarioError(f"bravo peer id changed: {first_id} -> {peers[0]['peer_id']}") + if peers[0]["addr"] == first_addr: + raise ScenarioError(f"bravo listener address did not change: {first_addr}") + return f"bravo reused peer id {first_id} with new address {peers[0]['addr']}" + + def s12_transfer_serving_gates(self) -> str: + output = run_just_test() + required = [ + "local_download_available_gates_on_catalog_operation_and_sentinel", + "get_game_response_respects_serve_gates", + "file_transfer_dispatch_respects_serve_gates", + "local_relative_paths_are_never_transferable", + ] + missing = [name for name in required if name not in output] + if missing: + raise ScenarioError(f"S12 unit proof missing tests: {missing}") + return "just test passed including catalog/sentinel/active/local-path serve gate tests" + + def s13_exact_transfer_equality(self) -> str: + bravo = self.peer("s13-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + alpha = self.peer("s13-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + client = self.peer("s13-client") + connect_many(client, [bravo, alpha]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "bfbc2", "install": False}) + client.wait_for(event_is("download-finished", "bfbc2"), timeout=60, description="bfbc2 finish", waiter=waiter) + client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-bravo" / "bfbc2", client.host_games_dir / "bfbc2") + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm") + return "small bfbc2 and large alienswarm transfers both diffed cleanly against sources" + + def s14_large_multi_peer_chunking(self) -> str: + alpha = self.peer("s14-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + stage = self.peer("s14-stage") + connect_many(stage, [alpha]) + waiter = LineWaiter(len(stage.output)) + stage.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + stage.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="stage finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", stage.host_games_dir / "alienswarm") + client = self.peer("s14-client") + connect_many(client, [alpha, stage]) + wait_remote_game(client, "alienswarm", peer_count=2) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm") + totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti") + if len(totals) < 2: + raise ScenarioError(f"expected chunks from two peers, got {totals}") + values = list(totals.values()) + if max(values) - min(values) > CHUNK_SIZE: + raise ScenarioError(f"chunk totals not balanced within one chunk: {totals}") + return f"alienswarm downloaded from two sources, diff matched, chunk totals={totals}" + + def s15_three_way_version_skew(self) -> str: + specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")] + peers = [] + for name, version in specs: + game_dir = self.fixture_root / name + copy_game("cnc4", game_dir, version=version) + peers.append(self.peer(name, games_dir=game_dir)) + client = self.peer("s15-client") + connect_many(client, peers) + wait_remote_game(client, "cnc4", peer_count=3, version="20250301") + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "cnc4", "install": False}) + client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="cnc4 finish", waiter=waiter) + assert_only_chunk_sources(client, "cnc4", {peers[2].ready_addr}) + diff_game_dirs(peers[2].host_games_dir / "cnc4", client.host_games_dir / "cnc4") + return "three-way skew selected only 20250301 peer and receiver diffed cleanly" + + def s16_latest_fanout_with_stale(self) -> str: + specs = [ + ("s16-a", "20250101"), + ("s16-b", "20250301"), + ("s16-c", "20250301"), + ] + peers = [] + for name, version in specs: + game_dir = self.fixture_root / name + copy_game("alienswarm", game_dir, version=version) + peers.append(self.peer(name, games_dir=game_dir)) + client = self.peer("s16-client") + connect_many(client, peers) + wait_remote_game(client, "alienswarm", peer_count=3, version="20250301") + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter) + assert_only_chunk_sources(client, "alienswarm", {peers[1].ready_addr, peers[2].ready_addr}) + totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti") + if peers[0].ready_addr in totals: + raise ScenarioError(f"stale peer contributed chunks: {totals}") + diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm") + return f"latest B/C peers served alienswarm while stale A contributed zero; totals={totals}" + + def s17_latest_conflict_rejection(self) -> str: + specs = [ + ("s17-a", "20250101", False), + ("s17-b", "20250301", False), + ("s17-c", "20250301", True), + ] + peers = [] + for name, version, conflict in specs: + game_dir = self.fixture_root / name + copy_game("cnc4", game_dir, version=version) + if conflict: + with (game_dir / "cnc4" / "cnc4.eti").open("ab") as handle: + handle.write(b"conflict") + peers.append(self.peer(name, games_dir=game_dir)) + client = self.peer("s17-client") + connect_many(client, peers) + wait_remote_game(client, "cnc4", peer_count=3, version="20250301") + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "cnc4", "install": False}) + client.wait_for(event_is("download-failed", "cnc4"), timeout=30, description="cnc4 failed", waiter=waiter) + assert_not_exists(client.host_games_dir / "cnc4" / "version.ini") + return "latest-version file conflict failed download and left no committed version.ini" + + def s18_redundant_source_drop(self) -> str: + source_a_dir = self.fixture_root / "s18-a" + source_b_dir = self.fixture_root / "s18-b" + copy_game("alienswarm", source_a_dir) + copy_game("alienswarm", source_b_dir) + source_a = self.peer("s18-a", games_dir=source_a_dir) + source_b = self.peer("s18-b", games_dir=source_b_dir) + client = self.peer("s18-client") + connect_many(client, [source_a, source_b]) + wait_remote_game(client, "alienswarm", peer_count=2) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + client.wait_for(event_is("download-begin", "alienswarm"), timeout=20, description="download begin", waiter=waiter) + source_a.kill() + client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="download finish", waiter=waiter) + assert_no_event(client, waiter, "download-failed", "alienswarm") + diff_game_dirs(source_b_dir / "alienswarm", client.host_games_dir / "alienswarm") + totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti") + if not totals: + raise ScenarioError("S18 did not record chunk evidence") + return f"source killed after begin; download finished; diff matched; chunk bytes={totals}" + + def s19_sole_source_drop(self) -> str: + source_dir = self.fixture_root / "s19-source" + copy_game("alienswarm", source_dir) + source = self.peer("s19-source", games_dir=source_dir) + client = self.peer("s19-client") + connect_many(client, [source]) + wait_remote_game(client, "alienswarm", peer_count=1) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + client.wait_for(event_is("download-begin", "alienswarm"), timeout=20, description="download begin", waiter=waiter) + source.shutdown() + client.wait_for(event_is("download-failed", "alienswarm"), timeout=90, description="download failed", waiter=waiter) + assert_not_exists(client.host_games_dir / "alienswarm" / "version.ini") + assert_no_active(client, "alienswarm") + assert_local_absent(client, "alienswarm") + return "download-failed emitted; version.ini absent; local ready row absent; active operations empty" + + def s20_receiver_write_failure(self) -> str: + source_dir = self.fixture_root / "s20-source" + copy_game("alienswarm", source_dir) + source = self.peer("s20-source", games_dir=source_dir) + client = self.peer("s20-client", tmpfs_size="32m") + connect_many(client, [source]) + wait_remote_game(client, "alienswarm", peer_count=1) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + client.wait_for(event_is("download-failed", "alienswarm"), timeout=90, description="download failed", waiter=waiter) + client.docker_exec("test", "!", "-e", "/games/alienswarm/version.ini") + assert_no_active(client, "alienswarm") + return "32m tmpfs receiver emitted download-failed; /games/alienswarm/version.ini absent; active operations empty" + + def s21_add_game_propagation(self) -> str: + alpha = self.peer("s21-alpha") + bravo_dir = self.fixture_root / "s21-bravo" + bravo_dir.mkdir(parents=True, exist_ok=True) + bravo = self.peer("s21-bravo", games_dir=bravo_dir) + connect_many(alpha, [bravo]) + assert len(alpha.list_peers()) == 1 + stage_game_drop(bravo_dir, "cod5") + game = wait_remote_game(alpha, "cod5", peer_count=1) + return f"alpha saw {game['id']} from the existing bravo peer with peer_count={game['peer_count']}" + + def s22_remove_game_propagation(self) -> str: + alpha = self.peer("s22-alpha") + bravo_dir = self.fixture_root / "s22-bravo" + copy_game("cod5", bravo_dir) + bravo = self.peer("s22-bravo", games_dir=bravo_dir) + connect_many(alpha, [bravo]) + wait_remote_game(alpha, "cod5", peer_count=1) + shutil.rmtree(bravo_dir / "cod5") + wait_remote_absent(alpha, "cod5") + peers = alpha.list_peers() + if len(peers) != 1: + raise ScenarioError(f"expected bravo peer to remain, got {peers}") + return "alpha removed cod5 while keeping one bravo peer" + + def s23_version_bump_propagation(self) -> str: + alpha = self.peer("s23-alpha") + bravo_dir = self.fixture_root / "s23-bravo" + copy_game("cnc4", bravo_dir, version="20250101") + bravo = self.peer("s23-bravo", games_dir=bravo_dir) + connect_many(alpha, [bravo]) + wait_remote_game(alpha, "cnc4", peer_count=1, version="20250101") + (bravo_dir / "cnc4" / "version.ini").write_text("20260501", encoding="utf-8") + wait_remote_game(alpha, "cnc4", peer_count=1, version="20260501") + return "alpha observed cnc4 eti_game_version change 20250101 -> 20260501 without reconnect" + + def s24_two_clients_one_source(self) -> str: + source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + c1 = self.peer("s24-client-a") + c2 = self.peer("s24-client-b") + connect_many(c1, [source]) + connect_many(c2, [source]) + waiter1 = LineWaiter(len(c1.output)) + waiter2 = LineWaiter(len(c2.output)) + c1.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + c2.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + c1.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client-a finish", waiter=waiter1) + c2.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client-b finish", waiter=waiter2) + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", c1.host_games_dir / "alienswarm") + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", c2.host_games_dir / "alienswarm") + source.status() + return "two concurrent clients finished alienswarm; both diffs matched; source status responded" + + def s25_two_downloads_one_client(self) -> str: + source = self.peer("s25-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s25-client") + connect_many(client, [source]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "bfbc2", "install": False}) + client.send({"cmd": "download", "game_id": "cnctw", "install": False}) + client.wait_for(event_is("download-finished", "bfbc2"), timeout=60, description="bfbc2 finish", waiter=waiter) + client.wait_for(event_is("download-finished", "cnctw"), timeout=60, description="cnctw finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-bravo" / "bfbc2", client.host_games_dir / "bfbc2") + diff_game_dirs(FIXTURES / "fixture-bravo" / "cnctw", client.host_games_dir / "cnctw") + return "bfbc2 and cnctw concurrent downloads both finished and diffed cleanly" + + def s26_duplicate_download_rejection(self) -> str: + source = self.peer("s26-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + client = self.peer("s26-client") + connect_many(client, [source]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + client.wait_for( + lambda item: item.get("type") == "event" + and item.get("event") == "active-operations-changed" + and any( + op.get("game_id") == "alienswarm" + for op in item.get("data", {}).get("active_operations", []) + ), + timeout=20, + description="active operation", + waiter=waiter, + ) + err = client.send( + {"cmd": "download", "game_id": "alienswarm", "install": False}, + expect_error=True, + ) + if "operation already in progress" not in err["error"]: + raise ScenarioError(f"unexpected duplicate error: {err}") + client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="first download finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm") + return f"second command errored '{err['error']}'; first download diff matched" + + def s27_self_connect_rejection(self) -> str: + alpha = self.peer("s27-alpha") + err = alpha.send({"cmd": "connect", "addr": alpha.ready_addr}, expect_error=True) + if "cannot connect peer to itself" not in err["error"]: + raise ScenarioError(f"unexpected self-connect error: {err}") + peers = alpha.list_peers() + if peers: + raise ScenarioError(f"self-connect created peers: {peers}") + alpha.status() + return f"self-connect errored '{err['error']}'; peer list stayed empty" + + def s28_address_change_unit(self) -> str: + output = run_just_test() + test_name = "peer_db::tests::address_update_preserves_peer_identity_and_library" + if test_name not in output: + raise ScenarioError(f"S28 unit proof missing from just test output:\n{output}") + return "`just test` passed including address_update_preserves_peer_identity_and_library" + + def s29_empty_peer_participates(self) -> str: + source = self.peer("s29-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + empty = self.peer("s29-empty") + observer = self.peer("s29-observer") + connect_many(observer, [empty]) + peers = observer.list_peers() + if len(peers) != 1 or peers[0]["game_count"] != 0: + raise ScenarioError(f"expected empty peer with zero games, got {peers}") + connect_many(empty, [source]) + wait_remote_game(empty, "alienswarm", peer_count=1) + waiter = LineWaiter(len(empty.output)) + empty.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + empty.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="empty download finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", empty.host_games_dir / "alienswarm") + wait_peer_has_game(observer, empty.peer_id, "alienswarm") + return "observer saw zero-game peer; empty downloaded alienswarm, diff matched, then observer's snapshot for that peer contained alienswarm" + + def s30_mesh_aggregation(self) -> str: + dirs = [] + specs = [ + ("s30-a", [("ggoo", "20250101"), ("bf1942", "20250101")]), + ("s30-b", [("ggoo", "20250101"), ("cnc4", "20250101")]), + ("s30-c", [("cnc4", "20250301"), ("cod5", "20250101")]), + ("s30-d", [("cnctw", "20250101"), ("coh", "20250101")]), + ("s30-e", [("cnctw", "20250201"), ("bf1942", "20250201")]), + ] + peers = [] + for name, games in specs: + game_dir = self.fixture_root / name + for game_id, version in games: + copy_game(game_id, game_dir, version=version) + dirs.append(game_dir) + peers.append(self.peer(name, games_dir=game_dir)) + client = self.peer("s30-client") + connect_many(client, peers) + expected = { + "ggoo": (2, "20250101"), + "bf1942": (2, "20250201"), + "cnc4": (2, "20250301"), + "cod5": (1, "20250101"), + "cnctw": (2, "20250201"), + "coh": (1, "20250101"), + } + for game_id, (peer_count, version) in expected.items(): + wait_remote_game(client, game_id, peer_count=peer_count, version=version) + game_rows = client.list_games()["remote"] + ids = [game["id"] for game in game_rows] + if len(ids) != len(set(ids)): + raise ScenarioError(f"duplicate game rows: {ids}") + if any(peer["peer_id"] == client.peer_id for peer in client.list_peers()): + raise ScenarioError("client listed itself as a peer") + return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/latest versions" + + def s31_bootstrapped_peer_source(self) -> str: + source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + bootstrap = self.peer("s31-bootstrap") + connect_many(bootstrap, [source]) + waiter = LineWaiter(len(bootstrap.output)) + bootstrap.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + bootstrap.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="bootstrap finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", bootstrap.host_games_dir / "alienswarm") + source.kill() + third = self.peer("s31-third") + connect_many(third, [bootstrap]) + waiter = LineWaiter(len(third.output)) + third.send({"cmd": "download", "game_id": "alienswarm", "install": False}) + third.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="third finish", waiter=waiter) + diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", third.host_games_dir / "alienswarm") + return "third peer downloaded from bootstrapped client after original source kill; diff matched original" + + def s32_reinstall_after_uninstall(self) -> str: + source = self.peer("s32-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s32-client") + connect_many(client, [source]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "bfbc2", "install": False}) + client.wait_for(event_is("download-finished", "bfbc2"), timeout=60, description="download finish", waiter=waiter) + client.send({"cmd": "install", "game_id": "bfbc2"}) + client.wait_for(event_is("install-finished", "bfbc2"), timeout=30, description="install finish", waiter=waiter) + client.send({"cmd": "uninstall", "game_id": "bfbc2"}) + client.wait_for(event_is("uninstall-finished", "bfbc2"), timeout=30, description="uninstall finish", waiter=waiter) + before = len(client.output) + client.send({"cmd": "install", "game_id": "bfbc2"}) + reinstall_waiter = LineWaiter(before) + client.wait_for(event_is("install-finished", "bfbc2"), timeout=30, description="reinstall finish", waiter=reinstall_waiter) + recent = client.output[before:] + if any(item.get("event") == "download-chunk-finished" for item in recent): + raise ScenarioError("reinstall produced transfer chunk events") + wait_local_game(client, "bfbc2", downloaded=True, installed=True) + if not (client.host_games_dir / "bfbc2" / "local").is_dir(): + raise ScenarioError("local/ was not recreated") + return "reinstall recreated local/, local state installed=true, no transfer events during reinstall" + + def s33_install_after_mutation(self) -> str: + source = self.peer("s33-bravo", games_dir=FIXTURES / "fixture-bravo", readonly_games=True) + client = self.peer("s33-client") + connect_many(client, [source]) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "bfbc2", "install": False}) + client.wait_for(event_is("download-finished", "bfbc2"), timeout=60, description="download finish", waiter=waiter) + client.docker_exec( + "sh", + "-c", + "printf 'mutated archive bytes\\n' > /games/bfbc2/bfbc2.eti", + ) + client.send({"cmd": "install", "game_id": "bfbc2"}) + client.wait_for(event_is("install-finished", "bfbc2"), timeout=30, description="install finish", waiter=waiter) + client.docker_exec( + "cmp", + "/games/bfbc2/bfbc2.eti", + "/games/bfbc2/local/fixture-payload.txt", + ) + return "fixture installer installed current mutated archive bytes exactly" + + def s34_many_small_files(self) -> str: + source_dir = self.fixture_root / "s34-source" + create_many_small_game(source_dir / "bf1942") + source = self.peer("s34-source", games_dir=source_dir) + client = self.peer("s34-client") + connect_many(client, [source]) + wait_remote_game(client, "bf1942", peer_count=1) + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "bf1942", "install": False}) + client.wait_for(event_is("download-finished", "bf1942"), timeout=60, description="download finish", waiter=waiter) + diff_game_dirs(source_dir / "bf1942", client.host_games_dir / "bf1942") + chunks = [ + item + for item in client.output + if item.get("type") == "event" + and item.get("event") == "download-chunk-finished" + and item.get("data", {}).get("game_id") == "bf1942" + ] + if len(chunks) < 21: + raise ScenarioError(f"expected at least 21 file chunks, got {len(chunks)}") + return f"20 small files plus version.ini transferred; diff matched; chunk events={len(chunks)}" + + def s35_unknown_game_filtered(self) -> str: + source = self.peer("s35-source", fixtures=["mystery-game"]) + client = self.peer("s35-client") + connect_many(client, [source]) + wait_remote_absent(client, "mystery-game") + err = client.send({"cmd": "download", "game_id": "mystery-game", "install": False}, expect_error=True) + if "not in the local catalog" not in err["error"]: + raise ScenarioError(f"unexpected unknown game error: {err}") + assert_not_exists(client.host_games_dir / "mystery-game") + return f"unknown game absent from list-games; download errored '{err['error']}'; no local files" + + def s36_latest_singleton(self) -> str: + peers = [] + for index in range(5): + game_dir = self.fixture_root / f"s36-{index}" + version = "20260501" if index == 0 else "20250101" + copy_game("cnc4", game_dir, version=version) + peers.append(self.peer(f"s36-{index}", games_dir=game_dir)) + client = self.peer("s36-client") + connect_many(client, peers) + wait_remote_game(client, "cnc4", peer_count=5, version="20260501") + waiter = LineWaiter(len(client.output)) + client.send({"cmd": "download", "game_id": "cnc4", "install": False}) + got = client.wait_for(event_is("got-game-files", "cnc4"), timeout=20, description="got game files", waiter=waiter) + client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="download finish", waiter=waiter) + latest_addr = peers[0].ready_addr + if latest_addr is None: + raise ScenarioError("latest peer had no ready addr") + for item in client.output: + if item.get("type") != "event" or item.get("event") != "download-chunk-finished": + continue + data = item["data"] + if data.get("game_id") == "cnc4" and data.get("peer_addr") != latest_addr: + raise ScenarioError(f"stale peer contributed chunk: {data}") + diff_game_dirs(peers[0].host_games_dir / "cnc4", client.host_games_dir / "cnc4") + descs = got["data"]["file_descriptions"] + if not descs: + 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 run(command: list[str], description: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if result.returncode != 0: + raise ScenarioError(f"{description} failed:\n{result.stdout}") + return result + + +def run_just_test() -> str: + env = os.environ.copy() + env["RUSTC_WRAPPER"] = "" + result = subprocess.run( + ["just", "test"], + cwd=REPO, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if result.returncode != 0: + raise ScenarioError(f"just test failed:\n{result.stdout}") + return result.stdout + + +def reset_run_root() -> None: + if not RUN_ROOT.exists(): + return + + try: + shutil.rmtree(RUN_ROOT) + return + except PermissionError: + pass + + subprocess.run( + [ + "docker", + "run", + "--rm", + "-v", + f"{RUN_ROOT}:/cleanup", + "--entrypoint", + "/bin/sh", + "debian:bookworm-slim", + "-c", + "rm -rf /cleanup/* /cleanup/.[!.]* /cleanup/..?*", + ], + cwd=REPO, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + shutil.rmtree(RUN_ROOT) + + +def copy_game(game_id: str, destination_games_dir: Path, *, version: str | None = None) -> None: + source = find_fixture_game(game_id) + destination = destination_games_dir / game_id + if destination.exists(): + shutil.rmtree(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, destination) + if version is not None: + (destination / "version.ini").write_text(version, encoding="utf-8") + + +def find_fixture_game(game_id: str) -> Path: + for fixture_dir in FIXTURES.iterdir(): + candidate = fixture_dir / game_id + if candidate.exists(): + return candidate + raise ScenarioError(f"fixture game not found: {game_id}") + + +def stage_game_drop(destination_games_dir: Path, game_id: str) -> None: + source = find_fixture_game(game_id) + root = destination_games_dir / game_id + if root.exists(): + shutil.rmtree(root) + root.mkdir(parents=True) + for child in source.iterdir(): + if child.name == "version.ini": + continue + target = root / child.name + if child.is_dir(): + shutil.copytree(child, target) + else: + shutil.copy2(child, target) + shutil.copy2(source / "version.ini", root / "version.ini") + + +def create_many_small_game(root: Path) -> None: + if root.exists(): + shutil.rmtree(root) + root.mkdir(parents=True) + for index in range(20): + child = root / f"file-{index:02}.bin" + child.write_bytes(hashlib.sha256(f"small-{index}".encode()).digest() * 8) + (root / "version.ini").write_text("20250101", encoding="utf-8") + + +def connect_many(client: Peer, peers: list[Peer]) -> None: + for peer in peers: + client.connect_to(peer) + client.send({"cmd": "wait-peers", "count": len(peers), "timeout_ms": 15000}) + + +def wait_remote_game( + peer: Peer, + game_id: str, + *, + peer_count: int | None = None, + version: str | None = None, + timeout: float = 20, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last_rows: list[dict[str, Any]] = [] + while time.monotonic() < deadline: + rows = peer.list_games()["remote"] + last_rows = rows + for row in rows: + if row["id"] != game_id: + continue + if peer_count is not None and row.get("peer_count") != peer_count: + continue + if version is not None and row.get("eti_game_version") != version: + continue + return row + time.sleep(0.4) + raise ScenarioError( + f"{peer.name} never saw remote {game_id} peer_count={peer_count} version={version}; rows={last_rows}" + ) + + +def wait_remote_absent(peer: Peer, game_id: str, timeout: float = 20) -> None: + deadline = time.monotonic() + timeout + last_rows: list[dict[str, Any]] = [] + while time.monotonic() < deadline: + rows = peer.list_games()["remote"] + last_rows = rows + if all(row["id"] != game_id for row in rows): + return + time.sleep(0.4) + raise ScenarioError(f"{peer.name} still lists remote {game_id}; rows={last_rows}") + + +def wait_local_game( + peer: Peer, + game_id: str, + *, + downloaded: bool | None = None, + installed: bool | None = None, + timeout: float = 20, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last_rows: list[dict[str, Any]] = [] + while time.monotonic() < deadline: + rows = peer.list_games()["local"] + last_rows = rows + for row in rows: + if row["id"] != game_id: + continue + if downloaded is not None and row.get("downloaded") != downloaded: + continue + if installed is not None and row.get("installed") != installed: + continue + return row + time.sleep(0.4) + raise ScenarioError( + f"{peer.name} never reached local {game_id} downloaded={downloaded} installed={installed}; rows={last_rows}" + ) + + +def assert_game_state( + game: dict[str, Any], + *, + downloaded: bool, + installed: bool, + availability: str, +) -> None: + if ( + game.get("downloaded") != downloaded + or game.get("installed") != installed + or game.get("availability") != availability + ): + raise ScenarioError( + f"unexpected game state for {game.get('id')}: " + f"downloaded={game.get('downloaded')} installed={game.get('installed')} " + f"availability={game.get('availability')}" + ) + + +def wait_peer_has_game( + observer: Peer, + peer_id: str | None, + game_id: str, + timeout: float = 20, +) -> dict[str, Any]: + if peer_id is None: + raise ScenarioError("cannot wait for a peer without peer_id") + + deadline = time.monotonic() + timeout + last_peers: list[dict[str, Any]] = [] + while time.monotonic() < deadline: + peers = observer.list_peers() + last_peers = peers + for peer in peers: + if peer.get("peer_id") != peer_id: + continue + if any(game.get("id") == game_id for game in peer.get("games", [])): + return peer + time.sleep(0.4) + raise ScenarioError( + f"{observer.name} never saw peer {peer_id} advertise {game_id}; peers={last_peers}" + ) + + +def assert_local_absent(peer: Peer, game_id: str) -> None: + rows = peer.list_games()["local"] + if any(row["id"] == game_id and row.get("downloaded") for row in rows): + raise ScenarioError(f"{peer.name} advertises failed local {game_id}: {rows}") + + +def assert_no_active(peer: Peer, game_id: str) -> None: + status = peer.status() + active = status["active_operations"] + if any(item["game_id"] == game_id for item in active): + raise ScenarioError(f"{peer.name} still has active operation for {game_id}: {active}") + + +def assert_not_exists(path: Path) -> None: + if path.exists(): + raise ScenarioError(f"expected path to be absent: {path}") + + +def event_is(event: str, game_id: str | None = None) -> Callable[[dict[str, Any]], bool]: + def predicate(item: dict[str, Any]) -> bool: + if item.get("type") != "event" or item.get("event") != event: + return False + if game_id is None: + return True + return item.get("data", {}).get("game_id") == game_id + + return predicate + + +def assert_no_event(peer: Peer, waiter: LineWaiter, event: str, game_id: str) -> None: + for item in peer.output[waiter.seen :]: + if item.get("type") == "event" and item.get("event") == event: + if item.get("data", {}).get("game_id") == game_id: + raise ScenarioError(f"unexpected {event} for {game_id}: {item}") + + +def assert_only_chunk_sources( + peer: Peer, + game_id: str, + allowed_sources: set[str | None], +) -> None: + allowed = {source for source in allowed_sources if source is not None} + if not allowed: + raise ScenarioError("no allowed chunk sources supplied") + + seen: set[str] = set() + for item in peer.output: + if item.get("type") != "event" or item.get("event") != "download-chunk-finished": + continue + data = item["data"] + if data.get("game_id") != game_id: + continue + source = data.get("peer_addr") + seen.add(source) + if source not in allowed: + raise ScenarioError(f"unexpected chunk source for {game_id}: {data}") + + if not seen: + raise ScenarioError(f"no chunk events recorded for {game_id}") + + +def chunk_totals(peer: Peer, game_id: str, relative_path: str) -> dict[str, int]: + totals: dict[str, int] = {} + for item in peer.output: + if item.get("type") != "event" or item.get("event") != "download-chunk-finished": + continue + data = item["data"] + if data.get("game_id") != game_id or data.get("relative_path") != relative_path: + continue + totals[data["peer_addr"]] = totals.get(data["peer_addr"], 0) + int(data["length"]) + return totals + + +def diff_game_dirs(source: Path, destination: Path) -> None: + source_manifest = manifest(source) + destination_manifest = manifest(destination) + if source_manifest != destination_manifest: + diff = run_diff(source, destination) + raise ScenarioError( + f"manifest mismatch between {source} and {destination}\n{diff}" + ) + diff = run_diff(source, destination) + if diff: + raise ScenarioError(f"diff mismatch between {source} and {destination}\n{diff}") + + +def manifest(root: Path) -> dict[str, str]: + if not root.exists(): + raise ScenarioError(f"missing manifest root: {root}") + entries: dict[str, str] = {} + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if any(part in IGNORED_DIFF_NAMES for part in rel.parts): + continue + hasher = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + entries[str(rel)] = hasher.hexdigest() + return entries + + +def run_diff(source: Path, destination: Path) -> str: + command = [ + "diff", + "-r", + "-x", + ".lanspread", + "-x", + ".lanspread.json", + "-x", + "local", + str(source), + str(destination), + ] + result = subprocess.run( + command, + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + return result.stdout if result.returncode else "" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "scenarios", + nargs="*", + help="Scenario IDs to run, e.g. S18 S20. Defaults to all implemented scenarios.", + ) + parser.add_argument( + "--build-image", + action="store_true", + help="Run `just peer-cli-image` before starting scenarios.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + selected = {item.lower() for item in args.scenarios} if args.scenarios else None + try: + Runner(selected=selected, build_image=args.build_image).run() + except ScenarioError as error: + print(f"\nFAILED: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 34a1a7d..3794862 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -106,6 +106,7 @@ struct LocalPeer { struct SharedState { state: RwLock, peer_game_db: Arc>, + catalog: Arc>>, notify: Notify, } @@ -131,7 +132,7 @@ async fn main() -> eyre::Result<()> { tx_events, peer_game_db.clone(), unpacker, - catalog, + catalog.clone(), PeerStartOptions { state_dir: Some(args.state_dir.clone()), }, @@ -141,6 +142,7 @@ async fn main() -> eyre::Result<()> { let shared = Arc::new(SharedState { state: RwLock::new(CliState::default()), peer_game_db, + catalog: catalog.clone(), notify: Notify::new(), }); let writer = JsonlWriter::new(); @@ -221,6 +223,8 @@ async fn handle_command( game_id, install_after_download, } => { + ensure_catalog_game(shared, game_id).await?; + ensure_no_active_operation(shared, game_id).await?; let files = game_files_for_download(sender, shared, game_id).await?; sender.send(PeerCommand::DownloadGameFilesWithOptions { id: game_id.clone(), @@ -230,12 +234,16 @@ async fn handle_command( Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download})) } CliCommand::Install { game_id } => { + ensure_catalog_game(shared, game_id).await?; + ensure_no_active_operation(shared, game_id).await?; sender.send(PeerCommand::InstallGame { id: game_id.clone(), })?; Ok(json!({"queued": true, "game_id": game_id})) } CliCommand::Uninstall { game_id } => { + ensure_catalog_game(shared, game_id).await?; + ensure_no_active_operation(shared, game_id).await?; sender.send(PeerCommand::UninstallGame { id: game_id.clone(), })?; @@ -243,6 +251,7 @@ async fn handle_command( } CliCommand::WaitPeers { count, timeout } => wait_peers(shared, *count, *timeout).await, CliCommand::Connect { addr } => { + ensure_not_self_connect(shared, *addr).await?; sender.send(PeerCommand::ConnectPeer(*addr))?; Ok(json!({"queued": true, "addr": addr.to_string()})) } @@ -275,7 +284,15 @@ async fn list_peers(shared: &SharedState) -> eyre::Result { async fn list_games(shared: &SharedState) -> eyre::Result { let state = shared.state.read().await; - let remote = shared.peer_game_db.read().await.get_all_games(); + let catalog = shared.catalog.read().await.clone(); + let remote = shared + .peer_game_db + .read() + .await + .get_all_games() + .into_iter() + .filter(|game| catalog.contains(&game.id)) + .collect::>(); Ok(json!({ "local": state.local_games.clone(), "remote": remote, @@ -283,6 +300,40 @@ async fn list_games(shared: &SharedState) -> eyre::Result { })) } +async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> { + if shared.catalog.read().await.contains(game_id) { + return Ok(()); + } + + eyre::bail!("game {game_id} is not in the local catalog"); +} + +async fn ensure_no_active_operation(shared: &SharedState, game_id: &str) -> eyre::Result<()> { + let state = shared.state.read().await; + if state + .active_operations + .iter() + .any(|operation| operation.id == game_id) + { + eyre::bail!("operation already in progress for game {game_id}"); + } + + Ok(()) +} + +async fn ensure_not_self_connect(shared: &SharedState, addr: SocketAddr) -> eyre::Result<()> { + let state = shared.state.read().await; + if state + .local_peer + .as_ref() + .is_some_and(|peer| peer.addr == addr.to_string()) + { + eyre::bail!("cannot connect peer to itself at {addr}"); + } + + Ok(()) +} + async fn wait_peers(shared: &SharedState, count: usize, timeout: Duration) -> eyre::Result { let wait = async { loop { @@ -356,6 +407,11 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s ("local-peer-ready", json!(local_peer)) } PeerEvent::ListGames(games) => { + let catalog = shared.catalog.read().await.clone(); + let games = games + .into_iter() + .filter(|game| catalog.contains(&game.id)) + .collect::>(); shared.state.write().await.remote_games = games.clone(); ("list-games", json!({ "games": games })) } diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 1443ef9..b944e6c 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -198,6 +198,14 @@ pub async fn handle_download_game_files_command( install_after_download: bool, ) { log::info!("Got PeerCommand::DownloadGameFiles"); + if !catalog_contains(ctx, &id).await { + log::warn!("Ignoring download command for non-catalog game {id}"); + if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) { + log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); + } + return; + } + let games_folder = { ctx.game_dir.read().await.clone() }; // Use majority validation to get trusted file descriptions and peer whitelist diff --git a/crates/lanspread-peer/src/peer_db.rs b/crates/lanspread-peer/src/peer_db.rs index f90743d..764729c 100644 --- a/crates/lanspread-peer/src/peer_db.rs +++ b/crates/lanspread-peer/src/peer_db.rs @@ -948,6 +948,36 @@ mod tests { assert_eq!(db.peer_id_for_transport_addr(&source), None); } + #[test] + fn address_update_preserves_peer_identity_and_library() { + let old_addr = ip_addr([10, 66, 0, 2], 40000); + let new_addr = ip_addr([10, 66, 0, 3], 41000); + let mut db = PeerGameDB::new(); + + let first = db.upsert_peer("peer".to_string(), old_addr); + assert!(first.is_new); + db.update_peer_games( + &"peer".to_string(), + vec![summary("game", "20250101", Availability::Ready)], + ); + + let second = db.upsert_peer("peer".to_string(), new_addr); + assert!(!second.is_new); + assert!(second.addr_changed); + + let peers = db.peer_snapshots(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].peer_id, "peer"); + assert_eq!(peers[0].addr, new_addr); + assert_eq!(peers[0].games.len(), 1); + assert_eq!(peers[0].games[0].id, "game"); + assert_eq!(db.peer_id_for_addr(&old_addr), None); + assert_eq!( + db.peer_id_for_addr(&new_addr).map(String::as_str), + Some("peer") + ); + } + #[test] fn validation_uses_latest_version_file_metadata() { let old_addr = addr(12003);