test(peer-cli): cover full docker scenario matrix

Merge the S18-S36 scenario ideas into the official peer-cli scenario matrix
and add a Docker-backed runner that now exercises S1-S36 with concrete file
proofs. The runner creates temporary fixtures under .lanspread-peer-cli, drives
JSONL peer containers, checks transferred roots with diff and SHA-256 manifests,
and covers startup, discovery, transfer, failure, mutation, concurrency, mesh,
lifecycle, and catalog edge cases.

The scenarios exposed a few harness/runtime boundary gaps that would otherwise
make the contract ambiguous. The peer CLI now rejects self-connects, rejects
commands for game IDs outside the receiver catalog, filters unknown remote games
from its command/event surface, and reports duplicate active same-game commands
as operation-in-progress errors. The peer core also refuses non-catalog download
commands before transfer, and PeerGameDB has a unit check that address changes
preserve identity and library state.

S12 and S28 remain unit-level invariants because the CLI cannot stably race raw
serve-gate requests or rebind a live listener without restart. The runner treats
those scenarios as covered by just test and checks the expected unit test names
appear in the output.

Test Plan:
- just fmt
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= just peer-cli-build
- just peer-cli-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --check

Refs: PEER_CLI_SCENARIOS.md S1-S36
This commit is contained in:
2026-05-18 23:54:10 +02:00
parent 8b3aefd2db
commit a8edcd7450
5 changed files with 1599 additions and 2 deletions
+108
View File
@@ -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`
@@ -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())
+58 -2
View File
@@ -106,6 +106,7 @@ struct LocalPeer {
struct SharedState {
state: RwLock<CliState>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
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<Value> {
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
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::<Vec<_>>();
Ok(json!({
"local": state.local_games.clone(),
"remote": remote,
@@ -283,6 +300,40 @@ async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
}))
}
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<Value> {
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::<Vec<_>>();
shared.state.write().await.remote_games = games.clone();
("list-games", json!({ "games": games }))
}
+8
View File
@@ -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
+30
View File
@@ -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);