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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user