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
+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)
}