Files
lanspread/crates/lanspread-peer/src/network.rs
T
ddidderr be00a7a298 fix(peer): exchange full library snapshots during handshake
Peer A failed to learn Peer B's games. The handshake only carried
library_rev/library_digest metadata, and the post-handshake sync path
compared those revisions against per-peer revision numbers that were
never advanced via this code path, so the games map for the remote
peer stayed empty and the UI never showed them.

The fix is to put the authoritative library data into the handshake
itself. Hello and HelloAck now carry a LibrarySnapshot directly, and
both perform_handshake_with_peer (outbound) and accept_inbound_hello
(inbound) apply that snapshot to the peer DB before emitting the UI
events. The initial peer-game-list event is now driven by the
handshake rather than by a follow-up LibrarySummary/LibrarySnapshot
roundtrip.

Bumps PROTOCOL_VERSION to 4 because the wire layout of Hello/HelloAck
changed. Per CLAUDE.md's protocol policy there is no compatibility
shim; older peers will fail the version check and be ignored.

Cleanups that fall out of the new design:

  - The Hello / HelloAck library_rev and library_digest fields were
    duplicated by the embedded LibrarySnapshot (which carries its own
    library_rev, and whose digest is recomputed on apply). Collapsed
    both messages to just `library: LibrarySnapshot` to remove the
    foot-gun where the two could diverge.
  - Request::LibrarySummary and Request::LibrarySnapshot are now dead
    on the sender side and were removed along with their stream.rs
    handlers and the LibrarySummary struct. LibraryDelta stays — it
    is still sent from handlers.rs when the local library changes.
  - record_remote_library previously called update_peer_library and
    then apply_library_snapshot, which immediately overwrote the
    rev/digest just written. Added update_peer_features and rewired
    the call site so each peer-DB field is written exactly once.
    update_peer_library is retained because discovery.rs still uses
    it for the mDNS TXT-record path, where no snapshot is available.
  - Removed the now-unused LibraryUpdate enum, select_library_update,
    send_local_library_summary, send_local_library_update_if_needed,
    LocalLibraryState::delta_since, build_library_summary,
    send_library_summary, and send_library_snapshot.

Behavior change visible to users: when two peers come up on the LAN
they now see each other's full game lists immediately after the
handshake instead of waiting for a follow-up sync that, in the broken
case, never made the games visible at all.

Test Plan

  - just clippy (clean for the touched crates)
  - just test (workspace: all suites pass, including the two new
    handshake tests: outbound_hello_carries_local_library_snapshot
    and inbound_hello_applies_remote_library_snapshot, the latter
    asserting PeerDiscovered + PeerCountUpdated + ListGames events
    fire with the remote game visible)
  - Manual: start `just peer-cli-alpha` and `just peer-cli-bravo` in
    separate terminals; confirm each peer's game list shows the
    other's library entries after discovery completes, without
    requiring any additional command.

Refs

  - FINDINGS.md: triage note that Claude's review surfaced only
    in-scope cleanups (dead variants, duplicated header fields,
    redundant DB writes, stale test fixture), all addressed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:06:39 +02:00

268 lines
8.0 KiB
Rust

//! Network utilities for QUIC connections and peer communication.
use std::{
net::{IpAddr, SocketAddr},
time::Duration,
};
use bytes::BytesMut;
use futures::{SinkExt, StreamExt};
use if_addrs::{IfAddr, Interface, get_if_addrs};
use lanspread_db::db::GameFileDescription;
use lanspread_proto::{Hello, HelloAck, LibraryDelta, Message, Request, Response};
use s2n_quic::{Client as QuicClient, Connection, client::Connect, provider::limits::Limits};
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
use crate::config::CERT_PEM;
/// Establishes a QUIC connection to a peer.
pub async fn connect_to_peer(addr: SocketAddr) -> eyre::Result<Connection> {
let limits = Limits::default().with_max_handshake_duration(Duration::from_secs(3))?;
let client = QuicClient::builder()
.with_tls(CERT_PEM)?
.with_io("0.0.0.0:0")?
.with_limits(limits)?
.start()?;
let conn = Connect::new(addr).with_server_name("localhost");
let conn = client.connect(conn).await?;
Ok(conn)
}
/// Performs an initial ping check to verify peer is alive.
pub async fn initial_peer_alive_check(conn: &mut Connection) -> bool {
let remote_addr = conn.remote_addr().ok();
let stream = match conn.open_bidirectional_stream().await {
Ok(stream) => stream,
Err(e) => {
log::error!("{remote_addr:?} failed to open stream: {e}");
return false;
}
};
let (rx, tx) = stream.split();
let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
// send ping
if let Err(e) = framed_tx.send(Request::Ping.encode()).await {
log::error!("{remote_addr:?} failed to send ping to peer: {e}");
return false;
}
let _ = framed_tx.close().await;
// receive pong
if let Some(Ok(response_bytes)) = framed_rx.next().await {
let response = Response::decode(response_bytes.freeze());
match response {
Response::Pong => {
log::trace!("{remote_addr:?} peer is alive");
return true;
}
_ => {
log::error!("{remote_addr:?} peer sent invalid response to ping: {response:?}");
}
}
}
false
}
/// Pings a peer to check if it's alive.
pub async fn ping_peer(peer_addr: SocketAddr) -> eyre::Result<bool> {
let mut conn = connect_to_peer(peer_addr).await?;
let is_alive = initial_peer_alive_check(&mut conn).await;
Ok(is_alive)
}
/// Sends a single request without waiting for a response.
pub async fn send_oneway_request(peer_addr: SocketAddr, request: Request) -> eyre::Result<()> {
let mut conn = connect_to_peer(peer_addr).await?;
let stream = conn.open_bidirectional_stream().await?;
let (_, tx) = stream.split();
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
framed_tx.send(request.encode()).await?;
let _ = framed_tx.close().await;
Ok(())
}
/// Performs a hello/ack handshake with a peer.
pub async fn exchange_hello(peer_addr: SocketAddr, hello: Hello) -> eyre::Result<HelloAck> {
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::Hello(hello).encode()).await?;
let _ = 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::HelloAck(ack) => Ok(ack),
other => eyre::bail!("Unexpected response from peer {peer_addr}: {other:?}"),
}
}
pub async fn send_library_delta(
peer_addr: SocketAddr,
peer_id: &str,
delta: LibraryDelta,
) -> eyre::Result<()> {
send_oneway_request(
peer_addr,
Request::LibraryDelta {
peer_id: peer_id.to_string(),
delta,
},
)
.await
}
pub async fn send_goodbye(peer_addr: SocketAddr, peer_id: String) -> eyre::Result<()> {
send_oneway_request(peer_addr, Request::Goodbye { peer_id }).await
}
/// Requests game file details from a peer.
pub async fn request_game_details_from_peer(
peer_addr: SocketAddr,
game_id: &str,
) -> eyre::Result<(Vec<GameFileDescription>, Response)> {
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::GetGame {
id: game_id.to_string(),
}
.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::GetGame {
id,
file_descriptions,
} => {
if id != game_id {
eyre::bail!("peer {peer_addr} responded with mismatched game id {id}");
}
Ok((file_descriptions.clone(), response))
}
Response::GameNotFound(_) => {
eyre::bail!("peer {peer_addr} does not have game {game_id}")
}
Response::InternalPeerError(error_msg) => {
eyre::bail!("peer {peer_addr} reported internal error: {error_msg}")
}
_ => eyre::bail!("unexpected response from {peer_addr}: {response:?}"),
}
}
// =============================================================================
// IP address selection for mDNS advertisement
// =============================================================================
/// Selects the best IP address to advertise via mDNS.
pub fn select_advertise_ip() -> eyre::Result<IpAddr> {
let mut best_candidate: Option<(u8, IpAddr)> = None;
let mut loopback_fallback = None;
for interface in get_if_addrs()? {
if interface.is_loopback() {
loopback_fallback.get_or_insert(interface.ip());
continue;
}
if let Some(candidate) = classify_interface(&interface)
&& best_candidate
.as_ref()
.is_none_or(|(rank, _)| candidate.0 < *rank)
{
best_candidate = Some(candidate);
}
}
if let Some((_, ip)) = best_candidate {
return Ok(ip);
}
if let Some(ip) = loopback_fallback {
log::warn!(
"No non-loopback interface suitable for mDNS advertisement; falling back to {ip}"
);
return Ok(ip);
}
eyre::bail!("No usable network interface found for mDNS advertisement");
}
/// Classifies a network interface for mDNS advertisement priority.
fn classify_interface(interface: &Interface) -> Option<(u8, IpAddr)> {
match interface.addr {
IfAddr::V4(ref v4) => {
let ip = v4.ip;
if ip.is_unspecified() || ip.is_link_local() {
return None;
}
let mut rank = if ip.is_private() { 0 } else { 2 };
if is_virtual_interface(&interface.name) {
rank += 2;
}
Some((rank, IpAddr::V4(ip)))
}
IfAddr::V6(_) => None,
}
}
/// Checks if an interface name suggests it's a virtual interface.
fn is_virtual_interface(name: &str) -> bool {
const VIRTUAL_HINTS: &[&str] = &[
"awdl",
"br-",
"bridge",
"docker",
"ham",
"llw",
"tap",
"tailscale",
"tun",
"utun",
"vbox",
"veth",
"virbr",
"vmnet",
"wg",
"zt",
];
let lower = name.to_ascii_lowercase();
VIRTUAL_HINTS.iter().any(|hint| lower.contains(hint))
}