fix(peer): settle current-protocol local state cleanup

The follow-up backlog had drifted into three settled peer/runtime issues: the
legacy game-list fallback contradicted the one-wire-version policy, the Tauri
shell still re-derived local install state from disk after peer snapshots, and
`Availability::Downloading` existed even though active operations are already
reported through a separate operation table.

Remove the legacy `AnnounceGames` request and fallback service. Discovery now
ignores peers that do not advertise the current protocol and a peer id, and
library changes are sent through the current delta path only. This keeps the
runtime aligned with the documented current-build-only interoperability model.

Make peer `LocalGamesUpdated` snapshots authoritative for local fields in the
Tauri database. The GUI-side catalog still owns static metadata such as names,
sizes, and descriptions, but downloaded, installed, local version, and
availability now come from the peer runtime instead of a second whole-library
filesystem scan. Snapshot reconciliation also pins the missing-begin and
missing-finish lifecycle cases in tests.

Collapse availability back to the settled `Ready` and `LocalOnly` states.
Aggregation now counts only `Ready` peers as download sources, and the frontend
no longer carries a dead `Downloading` enum value.

The core peer also exposes the small non-GUI hooks needed by scripted callers:
startup options for state and mDNS, a local-ready event, direct connection, peer
snapshots, and an explicit post-download install policy. Those hooks reuse the
same current protocol path and do not add compatibility shims.

Test Plan:
- `git diff --check`
- `just fmt`
- `just clippy`
- `just test`

Refs: BACKLOG.md, FINDINGS.md, IMPL_DECISIONS.md
This commit is contained in:
2026-05-16 18:32:24 +02:00
parent 6242d64583
commit e711cf3454
23 changed files with 531 additions and 723 deletions
+6
View File
@@ -39,6 +39,7 @@ pub struct Ctx {
pub unpacker: Arc<dyn Unpacker>,
pub catalog: Arc<RwLock<HashSet<String>>>,
pub peer_id: Arc<String>,
pub enable_mdns: bool,
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
}
@@ -54,6 +55,7 @@ pub struct PeerCtx {
pub peer_game_db: Arc<RwLock<PeerGameDB>>,
pub catalog: Arc<RwLock<HashSet<String>>>,
pub peer_id: Arc<String>,
pub enable_mdns: bool,
pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
pub shutdown: CancellationToken,
pub task_tracker: TaskTracker,
@@ -72,6 +74,7 @@ impl std::fmt::Debug for PeerCtx {
impl Ctx {
/// Creates a new context with the given peer game database.
#[allow(clippy::too_many_arguments)]
pub fn new(
peer_game_db: Arc<RwLock<PeerGameDB>>,
peer_id: String,
@@ -80,6 +83,7 @@ impl Ctx {
shutdown: CancellationToken,
task_tracker: TaskTracker,
catalog: Arc<RwLock<HashSet<String>>>,
enable_mdns: bool,
) -> Self {
Self {
game_dir: Arc::new(RwLock::new(game_dir)),
@@ -92,6 +96,7 @@ impl Ctx {
unpacker,
catalog,
peer_id: Arc::new(peer_id),
enable_mdns,
shutdown,
task_tracker,
}
@@ -111,6 +116,7 @@ impl Ctx {
peer_game_db: self.peer_game_db.clone(),
catalog: self.catalog.clone(),
peer_id: self.peer_id.clone(),
enable_mdns: self.enable_mdns,
tx_notify_ui,
shutdown: self.shutdown.clone(),
task_tracker: self.task_tracker.clone(),
+104 -38
View File
@@ -8,7 +8,8 @@ use std::{
sync::Arc,
};
use lanspread_db::db::{GameDB, GameFileDescription};
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_proto::GameSummary;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
@@ -19,7 +20,6 @@ use crate::{
context::{Ctx, OperationGuard, OperationKind},
download::download_game_files,
events,
identity::FEATURE_LIBRARY_DELTA,
install,
local_games::{
LocalLibraryScan,
@@ -31,9 +31,10 @@ use crate::{
scan_local_library,
version_ini_is_regular_file,
},
network::{announce_games_to_peer, request_game_details_from_peer, send_library_delta},
network::{request_game_details_from_peer, request_game_list_from_peer, send_library_delta},
peer_db::PeerGameDB,
remote_peer::ensure_peer_id_for_addr,
services::perform_handshake_with_peer,
};
// =============================================================================
@@ -197,6 +198,7 @@ pub async fn handle_download_game_files_command(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
let games_folder = { ctx.game_dir.read().await.clone() };
@@ -262,7 +264,9 @@ pub async fn handle_download_game_files_command(
{
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
}
spawn_install_operation(ctx, tx_notify_ui, id.clone());
if install_after_download {
spawn_install_operation(ctx, tx_notify_ui, id.clone());
}
} else {
log::error!("No trusted peers available after majority validation for game {id}");
}
@@ -319,19 +323,32 @@ pub async fn handle_download_game_files_command(
return;
};
if transition_download_to_install(&ctx_clone, &download_id, prepared.operation_kind)
.await
{
clear_active_download(&ctx_clone, &download_id).await;
run_started_install_operation(
if install_after_download {
if transition_download_to_install(
&ctx_clone,
&tx_notify_ui_clone,
download_id,
prepared,
&download_id,
prepared.operation_kind,
)
.await;
.await
{
clear_active_download(&ctx_clone, &download_id).await;
run_started_install_operation(
&ctx_clone,
&tx_notify_ui_clone,
download_id,
prepared,
)
.await;
} else {
clear_active_download(&ctx_clone, &download_id).await;
}
} else {
clear_active_download(&ctx_clone, &download_id).await;
end_download_operation(&ctx_clone, &download_id).await;
if let Err(err) =
refresh_local_game(&ctx_clone, &tx_notify_ui_clone, &download_id).await
{
log::error!("Failed to refresh local library after download: {err}");
}
}
download_state_guard.disarm();
}
@@ -681,6 +698,69 @@ pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSe
events::emit_peer_count(&ctx.peer_game_db, tx_notify_ui).await;
}
/// Connects to a peer directly, bypassing mDNS discovery.
pub async fn handle_connect_peer_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
addr: SocketAddr,
) {
log::info!("Direct connect command received for {addr}");
let peer_id = ctx.peer_id.clone();
let local_library = ctx.local_library.clone();
let peer_game_db = ctx.peer_game_db.clone();
let tx_notify_ui = tx_notify_ui.clone();
ctx.task_tracker.spawn(async move {
if let Err(err) = perform_handshake_with_peer(
peer_id,
local_library,
peer_game_db.clone(),
tx_notify_ui.clone(),
addr,
None,
)
.await
{
log::warn!("Failed direct connect to {addr}: {err}");
return;
}
if let Err(err) = refresh_direct_peer_games(&peer_game_db, &tx_notify_ui, addr).await {
log::warn!("Failed to refresh direct peer games from {addr}: {err}");
}
});
}
async fn refresh_direct_peer_games(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
addr: SocketAddr,
) -> eyre::Result<()> {
let games = request_game_list_from_peer(addr).await?;
let summaries = games.into_iter().map(game_to_summary).collect::<Vec<_>>();
let peer_id = ensure_peer_id_for_addr(peer_game_db, addr).await;
{
let mut db = peer_game_db.write().await;
db.update_peer_games(&peer_id, summaries);
}
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
Ok(())
}
fn game_to_summary(game: Game) -> GameSummary {
let availability = game.normalized_availability();
GameSummary {
id: game.id,
name: game.name,
size: game.size,
downloaded: game.downloaded,
installed: game.installed,
eti_version: game.eti_game_version,
manifest_hash: 0,
availability,
}
}
// =============================================================================
// Game announcement helpers
// =============================================================================
@@ -737,32 +817,17 @@ pub async fn update_and_announce_games(
let db = ctx.peer_game_db.read().await;
db.peer_identities()
.into_iter()
.map(|(peer_id, addr)| {
let features = db.peer_features(&peer_id);
(peer_id, addr, features)
})
.map(|(_peer_id, addr)| addr)
.collect::<Vec<_>>()
};
for (_peer_id, peer_addr, features) in peer_targets {
if features
.iter()
.any(|feature| feature == FEATURE_LIBRARY_DELTA)
{
let delta = delta.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = send_library_delta(peer_addr, delta).await {
log::warn!("Failed to send library delta to {peer_addr}: {e}");
}
});
} else {
let games_clone = all_games.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await {
log::warn!("Failed to announce games to {peer_addr}: {e}");
}
});
}
for peer_addr in peer_targets {
let delta = delta.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = send_library_delta(peer_addr, delta).await {
log::warn!("Failed to send library delta to {peer_addr}: {e}");
}
});
}
}
@@ -832,6 +897,7 @@ mod tests {
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
true,
)
}
@@ -851,7 +917,7 @@ mod tests {
id: id.to_string(),
name: id.to_string(),
size: 42,
downloaded: availability.is_downloaded(),
downloaded: availability == Availability::Ready,
installed: true,
eti_version: Some(version.to_string()),
manifest_hash: 7,
+8 -4
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use uuid::Uuid;
@@ -7,8 +7,8 @@ const PEER_ID_FILE: &str = "peer_id";
pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1";
pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1";
pub fn load_or_create_peer_id() -> eyre::Result<String> {
let path = peer_id_path();
pub fn load_or_create_peer_id(state_dir: Option<&Path>) -> eyre::Result<String> {
let path = peer_id_path(state_dir);
if let Ok(existing) = std::fs::read_to_string(&path) {
let trimmed = existing.trim();
if !trimmed.is_empty() {
@@ -31,7 +31,11 @@ pub fn default_features() -> Vec<String> {
]
}
fn peer_id_path() -> PathBuf {
fn peer_id_path(state_dir: Option<&Path>) -> PathBuf {
if let Some(dir) = state_dir {
return dir.join(PEER_ID_FILE);
}
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
return PathBuf::from(dir).join(PEER_ID_FILE);
}
+84 -3
View File
@@ -42,7 +42,14 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT};
pub use error::PeerError;
pub use install::{UnpackFuture, Unpacker};
use lanspread_db::db::{Game, GameFileDescription};
pub use peer_db::{MajorityValidationResult, PeerGameDB, PeerId, PeerInfo, PeerUpsert};
pub use peer_db::{
MajorityValidationResult,
PeerGameDB,
PeerId,
PeerInfo,
PeerSnapshot,
PeerUpsert,
};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
@@ -54,6 +61,7 @@ use crate::{
context::Ctx,
handlers::{
GameDetailSource,
handle_connect_peer_command,
handle_download_game_files_command,
handle_get_game_command,
handle_get_peer_count_command,
@@ -72,6 +80,8 @@ use crate::{
/// Events sent from the peer system to the UI.
#[derive(Debug, strum::IntoStaticStr)]
pub enum PeerEvent {
/// The local QUIC server is listening and ready to accept peer connections.
LocalPeerReady { peer_id: String, addr: SocketAddr },
/// List of available games from peers.
ListGames(Vec<Game>),
/// File descriptions for a specific game.
@@ -184,6 +194,12 @@ pub enum PeerCommand {
id: String,
file_descriptions: Vec<GameFileDescription>,
},
/// Download game files with an explicit install policy.
DownloadGameFilesWithOptions {
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
},
/// Install already-downloaded archives into `local/`.
InstallGame { id: String },
/// Remove only the `local/` install for a game.
@@ -192,6 +208,26 @@ pub enum PeerCommand {
SetGameDir(PathBuf),
/// Request the current peer count.
GetPeerCount,
/// Connect directly to a peer address without waiting for mDNS discovery.
ConnectPeer(SocketAddr),
}
/// Optional startup settings for non-GUI callers and tests.
#[derive(Clone, Debug)]
pub struct PeerStartOptions {
/// Directory used for peer identity and other state.
pub state_dir: Option<PathBuf>,
/// Whether to advertise and discover peers via mDNS.
pub enable_mdns: bool,
}
impl Default for PeerStartOptions {
fn default() -> Self {
Self {
state_dir: None,
enable_mdns: true,
}
}
}
// =============================================================================
@@ -218,12 +254,36 @@ pub fn start_peer(
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<PeerRuntimeHandle> {
start_peer_with_options(
game_dir,
tx_notify_ui,
peer_game_db,
unpacker,
catalog,
PeerStartOptions::default(),
)
}
/// Initialize and start the peer system with explicit startup settings.
#[allow(clippy::implicit_hasher)]
pub fn start_peer_with_options(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
options: PeerStartOptions,
) -> eyre::Result<PeerRuntimeHandle> {
let PeerStartOptions {
state_dir,
enable_mdns,
} = options;
let game_dir = game_dir.into();
log::info!(
"Starting peer system with game directory: {}",
game_dir.display()
);
let peer_id = identity::load_or_create_peer_id()?;
let peer_id = identity::load_or_create_peer_id(state_dir.as_deref())?;
let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel();
@@ -236,6 +296,7 @@ pub fn start_peer(
game_dir,
unpacker,
catalog,
enable_mdns,
))
}
@@ -251,6 +312,7 @@ async fn run_peer(
shutdown: CancellationToken,
task_tracker: TaskTracker,
catalog: Arc<RwLock<HashSet<String>>>,
enable_mdns: bool,
) -> eyre::Result<()> {
let ctx = Ctx::new(
peer_game_db,
@@ -260,6 +322,7 @@ async fn run_peer(
shutdown,
task_tracker,
catalog,
enable_mdns,
);
if let Err(err) = load_local_library(&ctx, &tx_notify_ui).await {
log::error!("Failed to load initial local game database: {err}");
@@ -316,7 +379,22 @@ async fn handle_peer_commands(
id,
file_descriptions,
} => {
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions).await;
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
.await;
}
PeerCommand::DownloadGameFilesWithOptions {
id,
file_descriptions,
install_after_download,
} => {
handle_download_game_files_command(
ctx,
tx_notify_ui,
id,
file_descriptions,
install_after_download,
)
.await;
}
PeerCommand::InstallGame { id } => {
handle_install_game_command(ctx, tx_notify_ui, id).await;
@@ -330,6 +408,9 @@ async fn handle_peer_commands(
PeerCommand::GetPeerCount => {
handle_get_peer_count_command(ctx, tx_notify_ui).await;
}
PeerCommand::ConnectPeer(addr) => {
handle_connect_peer_command(ctx, tx_notify_ui, addr).await;
}
}
}
}
+27 -33
View File
@@ -123,39 +123,6 @@ pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result
}
}
/// Fetches the list of games from a peer.
pub async fn fetch_games_from_peer(peer_addr: SocketAddr) -> eyre::Result<Vec<Game>> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (rx, tx) = stream.split();
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
// Send ListGames request
framed_tx.send(Request::ListGames.encode()).await?;
let _ = framed_tx.close().await;
// Receive response
let mut data = BytesMut::new();
while let Some(Ok(bytes)) = framed_rx.next().await {
data.extend_from_slice(&bytes);
}
let response = Response::decode(data.freeze());
if let Response::ListGames(games) = response {
Ok(games)
} else {
log::warn!("Unexpected response from peer {peer_addr}: {response:?}");
Ok(Vec::new())
}
}
/// Announces local games to a peer.
pub async fn announce_games_to_peer(peer_addr: SocketAddr, games: Vec<Game>) -> eyre::Result<()> {
send_oneway_request(peer_addr, Request::AnnounceGames(games)).await
}
pub async fn send_library_summary(
peer_addr: SocketAddr,
summary: LibrarySummary,
@@ -178,6 +145,33 @@ pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Resul
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
}
/// Requests the current game list from a peer.
pub async fn request_game_list_from_peer(peer_addr: SocketAddr) -> eyre::Result<Vec<Game>> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (rx, tx) = stream.split();
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx.send(Request::ListGames.encode()).await?;
framed_tx.close().await?;
let mut data = BytesMut::new();
while let Some(Ok(bytes)) = framed_rx.next().await {
data.extend_from_slice(&bytes);
}
let response = Response::decode(data.freeze());
match response {
Response::ListGames(games) => Ok(games),
Response::InternalPeerError(error_msg) => {
eyre::bail!("peer {peer_addr} reported internal error: {error_msg}")
}
other => eyre::bail!("unexpected response from {peer_addr}: {other:?}"),
}
}
/// Requests game file details from a peer.
pub async fn request_game_details_from_peer(
peer_addr: SocketAddr,
+39 -5
View File
@@ -7,9 +7,7 @@ use std::{
time::{Duration, Instant},
};
#[cfg(test)]
use lanspread_db::db::Availability;
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_db::db::{Availability, Game, GameFileDescription};
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
use crate::library::compute_library_digest;
@@ -36,6 +34,18 @@ pub struct PeerInfo {
pub files: HashMap<String, Vec<GameFileDescription>>,
}
/// Immutable peer state suitable for CLI assertions and tests.
#[derive(Clone, Debug)]
pub struct PeerSnapshot {
pub peer_id: PeerId,
pub addr: SocketAddr,
pub library_rev: u64,
pub library_digest: u64,
pub features: Vec<String>,
pub game_count: usize,
pub games: Vec<GameSummary>,
}
/// Database tracking all discovered peers and their games.
#[derive(Debug)]
pub struct PeerGameDB {
@@ -363,6 +373,30 @@ impl PeerGameDB {
.collect()
}
/// Returns immutable snapshots for all known peers.
#[must_use]
pub fn peer_snapshots(&self) -> Vec<PeerSnapshot> {
let mut peers = self
.peers
.values()
.map(|peer| {
let mut games = peer.games.values().cloned().collect::<Vec<_>>();
games.sort_by(|a, b| a.id.cmp(&b.id));
PeerSnapshot {
peer_id: peer.peer_id.clone(),
addr: peer.addr,
library_rev: peer.library_rev,
library_digest: peer.library_digest,
features: peer.features.clone(),
game_count: games.len(),
games,
}
})
.collect::<Vec<_>>();
peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id));
peers
}
/// Checks if a peer is in the database.
#[must_use]
pub fn contains_peer(&self, peer_id: &PeerId) -> bool {
@@ -744,7 +778,7 @@ fn create_peer_whitelist(peer_scores: HashMap<SocketAddr, usize>) -> Vec<SocketA
}
fn game_is_ready(summary: &GameSummary) -> bool {
summary.availability.is_downloaded()
summary.availability == Availability::Ready
}
fn summary_to_game(summary: &GameSummary) -> Game {
@@ -762,7 +796,7 @@ fn summary_to_game(summary: &GameSummary) -> Game {
version: "1.0".to_string(),
genre: String::new(),
size: summary.size,
downloaded: summary.availability.is_downloaded(),
downloaded: game_is_ready(summary),
installed: summary.installed,
availability: summary.availability.clone(),
eti_game_version,
+6 -44
View File
@@ -1,15 +1,10 @@
//! Shared helpers for remote peer identity and legacy game announcements.
//! Shared helpers for remote peer identity.
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use std::{net::SocketAddr, sync::Arc};
use lanspread_db::db::Game;
use lanspread_proto::GameSummary;
use tokio::sync::RwLock;
use crate::{
library::compute_library_digest,
peer_db::{PeerGameDB, PeerId},
};
use crate::peer_db::{PeerGameDB, PeerId};
pub async fn ensure_peer_id_for_addr(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
@@ -20,40 +15,7 @@ pub async fn ensure_peer_id_for_addr(
return peer_id;
}
let legacy_id = format!("legacy-{peer_addr}");
db.upsert_peer(legacy_id.clone(), peer_addr);
legacy_id
}
pub fn summary_from_game(game: &Game) -> GameSummary {
GameSummary {
id: game.id.clone(),
name: game.name.clone(),
size: game.size,
downloaded: game.downloaded,
installed: game.installed,
eti_version: game.eti_game_version.clone(),
manifest_hash: 0,
availability: game.normalized_availability(),
}
}
pub async fn update_peer_from_game_list(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
peer_addr: SocketAddr,
games: &[Game],
) -> Vec<Game> {
let summaries = games.iter().map(summary_from_game).collect::<Vec<_>>();
let mut by_id = HashMap::with_capacity(summaries.len());
for summary in &summaries {
by_id.insert(summary.id.clone(), summary.clone());
}
let digest = compute_library_digest(&by_id);
let peer_id = ensure_peer_id_for_addr(peer_game_db, peer_addr).await;
let mut db = peer_game_db.write().await;
db.update_peer_games(&peer_id, summaries);
let features = db.peer_features(&peer_id);
db.update_peer_library(&peer_id, 0, digest, features);
db.get_all_games()
let addr_id = format!("addr-{peer_addr}");
db.upsert_peer(addr_id.clone(), peer_addr);
addr_id
}
+1 -1
View File
@@ -7,13 +7,13 @@
mod advertise;
mod discovery;
mod handshake;
mod legacy;
mod liveness;
mod local_monitor;
mod server;
mod stream;
pub use discovery::run_peer_discovery;
pub(crate) use handshake::perform_handshake_with_peer;
pub use liveness::run_ping_service;
pub use local_monitor::run_local_game_monitor;
pub use server::run_server_component;
+27 -23
View File
@@ -11,7 +11,7 @@ use crate::{
context::Ctx,
events,
peer_db::PeerId,
services::{handshake::perform_handshake_with_peer, legacy::request_games_from_peer},
services::handshake::perform_handshake_with_peer,
};
struct MdnsPeerInfo {
@@ -128,10 +128,22 @@ async fn handle_discovered_peer(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) {
let peer_id = info
.peer_id
.clone()
.unwrap_or_else(|| format!("legacy-{}", info.addr));
if info.proto_ver != Some(PROTOCOL_VERSION) {
log::debug!(
"Ignoring peer at {} with protocol {:?}; expected {PROTOCOL_VERSION}",
info.addr,
info.proto_ver
);
return;
}
let Some(peer_id) = info.peer_id.clone() else {
log::debug!(
"Ignoring current-protocol peer at {} without a peer_id TXT record",
info.addr
);
return;
};
let upsert = {
let mut db = ctx.peer_game_db.write().await;
@@ -160,30 +172,22 @@ fn spawn_protocol_negotiation(
peer_id: PeerId,
) {
let peer_addr = info.addr;
let proto_ver = info.proto_ver;
let peer_id_arc = ctx.peer_id.clone();
let local_library = ctx.local_library.clone();
let peer_game_db = ctx.peer_game_db.clone();
ctx.task_tracker.spawn(async move {
let handshake_result = if proto_ver.is_none() || proto_ver == Some(PROTOCOL_VERSION) {
perform_handshake_with_peer(
peer_id_arc,
local_library,
peer_game_db.clone(),
tx_notify_ui.clone(),
peer_addr,
Some(peer_id),
)
.await
} else {
Err(eyre::eyre!("Skipping hello for legacy peer"))
};
if handshake_result.is_err()
&& let Err(err) = request_games_from_peer(peer_addr, tx_notify_ui, peer_game_db).await
if let Err(err) = perform_handshake_with_peer(
peer_id_arc,
local_library,
peer_game_db,
tx_notify_ui,
peer_addr,
Some(peer_id),
)
.await
{
log::error!("Failed to request games from peer {peer_addr}: {err}");
log::warn!("Failed to negotiate protocol with peer {peer_addr}: {err}");
}
});
}
@@ -45,7 +45,7 @@ async fn build_hello_from_state(
}
}
pub(super) async fn perform_handshake_with_peer(
pub(crate) async fn perform_handshake_with_peer(
peer_id: Arc<String>,
local_library: Arc<RwLock<LocalLibraryState>>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
@@ -1,37 +0,0 @@
//! Compatibility path for peers that only support the original game-list protocol.
use std::{net::SocketAddr, sync::Arc, time::Duration};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
PeerEvent,
events,
network::fetch_games_from_peer,
peer_db::PeerGameDB,
remote_peer::update_peer_from_game_list,
};
pub(super) async fn request_games_from_peer(
peer_addr: SocketAddr,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
) -> eyre::Result<()> {
let mut retry_count = 0;
loop {
let games = fetch_games_from_peer(peer_addr).await?;
log::info!("Received {} games from peer {peer_addr}", games.len());
if games.is_empty() && retry_count < 1 {
log::info!("Received 0 games from peer {peer_addr}, scheduling retry in 5s");
tokio::time::sleep(Duration::from_secs(5)).await;
retry_count += 1;
continue;
}
let aggregated_games = update_peer_from_game_list(&peer_game_db, peer_addr, &games).await;
events::send(&tx_notify_ui, PeerEvent::ListGames(aggregated_games));
return Ok(());
}
}
@@ -375,6 +375,7 @@ mod tests {
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(catalog)),
true,
)
}
+31 -6
View File
@@ -35,12 +35,30 @@ pub async fn run_server_component(
let server_addr = server.local_addr()?;
log::info!("Peer server listening on {server_addr}");
let mdns_advertiser = start_mdns_advertiser(&ctx, server_addr).await?;
let mdns_monitor = mdns_advertiser.monitor.clone();
let mdns_shutdown = ctx.shutdown.clone();
ctx.task_tracker.spawn(async move {
monitor_mdns_events(mdns_monitor, mdns_shutdown).await;
});
let (ready_addr, _mdns_advertiser) = if ctx.enable_mdns {
let mdns_advertiser = start_mdns_advertiser(&ctx, server_addr).await?;
let mdns_monitor = mdns_advertiser.monitor.clone();
let mdns_shutdown = ctx.shutdown.clone();
ctx.task_tracker.spawn(async move {
monitor_mdns_events(mdns_monitor, mdns_shutdown).await;
});
let ready_addr =
(*ctx.local_peer_addr.read().await).unwrap_or_else(|| direct_connect_addr(server_addr));
(ready_addr, Some(mdns_advertiser))
} else {
let addr = direct_connect_addr(server_addr);
*ctx.local_peer_addr.write().await = Some(addr);
log::info!("mDNS disabled; direct peer address is {addr}");
(addr, None)
};
events::send(
&tx_notify_ui,
PeerEvent::LocalPeerReady {
peer_id: ctx.peer_id.as_ref().clone(),
addr: ready_addr,
},
);
loop {
let connection = tokio::select! {
@@ -64,6 +82,13 @@ pub async fn run_server_component(
}
}
fn direct_connect_addr(server_addr: SocketAddr) -> SocketAddr {
if server_addr.ip().is_unspecified() {
return SocketAddr::from(([127, 0, 0, 1], server_addr.port()));
}
server_addr
}
async fn handle_peer_connection(
mut connection: Connection,
ctx: PeerCtx,
+2 -18
View File
@@ -9,13 +9,12 @@ use s2n_quic::stream::{BidirectionalStream, SendStream};
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
use crate::{
PeerEvent,
context::PeerCtx,
error::PeerError,
events,
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
peer::{send_game_file_chunk, send_game_file_data},
remote_peer::{ensure_peer_id_for_addr, update_peer_from_game_list},
remote_peer::ensure_peer_id_for_addr,
services::handshake::{
accept_inbound_hello,
perform_handshake_with_peer,
@@ -113,10 +112,6 @@ async fn dispatch_request(
log::error!("Received invalid request from peer");
framed_tx
}
Request::AnnounceGames(games) => {
handle_announce_games(ctx, remote_addr, games).await;
framed_tx
}
}
}
@@ -381,18 +376,6 @@ async fn handle_goodbye(ctx: &PeerCtx, remote_addr: Option<SocketAddr>, peer_id:
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
}
async fn handle_announce_games(ctx: &PeerCtx, remote_addr: Option<SocketAddr>, games: Vec<Game>) {
log::info!(
"Received {} announced games from peer {remote_addr:?}",
games.len()
);
if let Some(addr) = remote_addr {
let aggregated_games = update_peer_from_game_list(&ctx.peer_game_db, addr, &games).await;
events::send(&ctx.tx_notify_ui, PeerEvent::ListGames(aggregated_games));
}
}
#[cfg(test)]
mod tests {
use std::{
@@ -438,6 +421,7 @@ mod tests {
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(catalog)),
true,
)
.to_peer_ctx(tx_notify_ui)
}
+5 -1
View File
@@ -84,6 +84,7 @@ pub(crate) fn spawn_peer_runtime(
game_dir: PathBuf,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<std::collections::HashSet<String>>>,
enable_mdns: bool,
) -> PeerRuntimeHandle {
let shutdown = CancellationToken::new();
let task_tracker = TaskTracker::new();
@@ -102,6 +103,7 @@ pub(crate) fn spawn_peer_runtime(
runtime_shutdown.clone(),
runtime_tracker.clone(),
catalog,
enable_mdns,
)
.await
{
@@ -125,7 +127,9 @@ pub(crate) fn spawn_peer_runtime(
pub(crate) fn spawn_startup_services(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
spawn_quic_server(ctx, tx_notify_ui);
spawn_peer_discovery_service(ctx, tx_notify_ui);
if ctx.enable_mdns {
spawn_peer_discovery_service(ctx, tx_notify_ui);
}
spawn_peer_liveness_service(ctx, tx_notify_ui);
spawn_local_library_monitor(ctx, tx_notify_ui);
}