refactor(peer): tighten listener-addr handshake invariant

Follow-up hardening for 348a02c, where `listen_addr` was added to Hello and
HelloAck as `Option<SocketAddr>`. Code review surfaced three concrete problems
that the previous commit left open:

1. Cold-start asymmetry. Discovery and the QUIC/mDNS advertiser are spawned
   concurrently. If discovery saw a cached peer advertisement before our own
   advertiser had written `ctx.local_peer_addr`, our outbound Hello carried
   `listen_addr: None`. The receiver's `peer_record_addr` then returned `None`
   and silently dropped the Hello while we still recorded their HelloAck, so
   peer A learned about peer B but B never learned about A until a later
   handshake happened to win the race.

2. Duplicate game-list pipeline. The previous commit added
   `refresh_peer_games`, which post-handshake issued a `ListGames` to fetch
   `peer.games`. The library-sync path (`LibrarySnapshot`) already populates
   the same field. Both could race on first contact and overwrite each other.
   Worse, `refresh_peer_games` was misnamed: a `peer_game_count > 0` guard
   turned it into a fetch-once-then-no-op helper, while
   `handle_library_summary` independently re-triggered a full handshake when
   `previous_count == 0` was observed, producing a redundant ping-pong on
   every first contact.

3. Argument explosion. `perform_handshake_with_peer`, `spawn_library_resync`,
   and `after_peer_library_recorded` had grown to 6-8 individual parameters
   and acquired `#[allow(clippy::too_many_arguments)]` opt-outs. Every caller
   was destructuring the same fields out of `Ctx`/`PeerCtx`.

Changes (all in one commit because they jointly enforce the same invariant:
"a peer is only ever recorded by its listener address, and the local
listener address must exist before we participate in the protocol"):

- `Hello.listen_addr` and `HelloAck.listen_addr` are now `SocketAddr`, not
  `Option<SocketAddr>`. Wire-incompatible, but PROTOCOL_VERSION already moved
  to 3 in 348a02c so no additional version bump is needed.
- `required_listen_addr` reads `ctx.local_peer_addr` and returns an
  `eyre::Result`; `build_hello_from_state` and `build_hello_ack` both call
  it, so an outbound or inbound Hello can no longer be constructed before
  the local QUIC listener is bound. The inbound path maps this into a
  `Response::InternalPeerError` so the remote peer fails cleanly instead of
  seeing a malformed HelloAck.
- `run_peer_discovery` blocks on `wait_for_local_peer_addr` (25 ms poll,
  shutdown-aware) before subscribing to the mDNS browser. This closes the
  cold-start race for outbound handshakes at the source.
- `refresh_peer_games`, `request_game_list_from_peer`, and the
  `previous_count == 0` re-handshake trigger are removed. The post-handshake
  flow now relies solely on `LibrarySummary`/`LibrarySnapshot`/`LibraryDelta`
  for peer-library state; `ListGames` survives only for the
  `request_game_details_*` paths that fetch per-game file descriptions on
  demand.
- New `HandshakeCtx` (with `from_ctx` and `from_peer_ctx` constructors)
  replaces the long argument lists. All `too_many_arguments` allow-attrs in
  `handshake.rs` are gone, and call sites in `handlers.rs`, `discovery.rs`,
  and `stream.rs` collapse to a single clone.
- `handle_library_delta` no longer acquires a read lock on the apply path:
  the `peer_addr` lookup moved into the `else` resync branch where it is
  actually needed.
- `accept_inbound_hello`'s `remote_addr` parameter is renamed to
  `transport_addr`. It is now used only for warn-log formatting, and the
  new name signals that this is the ephemeral QUIC source port, never the
  authoritative listener address that gets recorded.

User-visible effect: on cold start, peers can no longer end up with an
asymmetric view of each other ("A sees B but B never sees A"). First-contact
library sync now does one handshake plus one snapshot/delta exchange instead
of the previous handshake + ListGames + redundant follow-up handshake. The
direct-connect CLI path (`handle_connect_peer_command`) now fails fast with
"local peer listener address is not ready" if invoked before the QUIC server
has bound; this is intentional - the previous behaviour would have sent a
Hello that the receiver had to silently discard.

Test Plan:
- just fmt
- just clippy
- just test (80 peer + 3 cli + 5 tauri tests pass)
- just build
- Manual: bring up `just peer-cli-alpha`/`bravo`/`charlie`, confirm symmetric
  peer discovery and that games show up on every side after one library
  digest cycle, with no duplicated ListGames traffic in trace logs.

Refs: Review feedback on commit 348a02c (listener-address handshake fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:21:19 +02:00
parent 348a02c35f
commit ce51d92df0
9 changed files with 182 additions and 314 deletions
+29 -45
View File
@@ -15,6 +15,7 @@ use crate::{
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
peer::{send_game_file_chunk, send_game_file_data},
services::handshake::{
HandshakeCtx,
accept_inbound_hello,
perform_handshake_with_peer,
spawn_library_resync,
@@ -76,10 +77,18 @@ async fn dispatch_request(
) -> ResponseWriter {
match request {
Request::Ping => send_response(framed_tx, Response::Pong, "pong").await,
Request::Hello(hello) => {
let ack = accept_inbound_hello(ctx, remote_addr, hello).await;
send_response(framed_tx, Response::HelloAck(ack), "HelloAck").await
}
Request::Hello(hello) => match accept_inbound_hello(ctx, remote_addr, hello).await {
Ok(ack) => send_response(framed_tx, Response::HelloAck(ack), "HelloAck").await,
Err(err) => {
log::error!("Failed to accept inbound hello: {err}");
send_response(
framed_tx,
Response::InternalPeerError(err.to_string()),
"HelloAck",
)
.await
}
},
Request::ListGames => handle_list_games(ctx, framed_tx).await,
Request::LibrarySummary { peer_id, summary } => {
handle_library_summary(ctx, peer_id, summary).await;
@@ -160,19 +169,14 @@ async fn handle_list_games(ctx: &PeerCtx, framed_tx: ResponseWriter) -> Response
}
async fn handle_library_summary(ctx: &PeerCtx, peer_id: String, summary: LibrarySummary) {
let (addr, previous_digest, previous_count, features) = {
let (addr, previous_digest, features) = {
let db = ctx.peer_game_db.read().await;
let Some(addr) = db.peer_addr(&peer_id) else {
log::debug!("Ignoring library summary from unknown peer {peer_id}");
return;
};
let (_, digest) = db.peer_library_state(&peer_id).unwrap_or((0, 0));
(
addr,
digest,
db.peer_game_count(&peer_id),
db.peer_features(&peer_id),
)
(addr, digest, db.peer_features(&peer_id))
};
{
@@ -185,24 +189,12 @@ async fn handle_library_summary(ctx: &PeerCtx, peer_id: String, summary: Library
);
}
if summary.library_digest != previous_digest || previous_count == 0 {
if summary.library_digest != previous_digest {
ctx.task_tracker.spawn({
let peer_id_arc = ctx.peer_id.clone();
let local_peer_addr = ctx.local_peer_addr.clone();
let local_library = ctx.local_library.clone();
let peer_game_db = ctx.peer_game_db.clone();
let tx_notify_ui = ctx.tx_notify_ui.clone();
let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx);
async move {
if let Err(err) = perform_handshake_with_peer(
peer_id_arc,
local_peer_addr,
local_library,
peer_game_db,
tx_notify_ui,
addr,
Some(peer_id),
)
.await
if let Err(err) =
perform_handshake_with_peer(handshake_ctx, addr, Some(peer_id)).await
{
log::warn!("Failed to refresh library from {addr}: {err}");
}
@@ -229,14 +221,6 @@ async fn handle_library_snapshot(ctx: &PeerCtx, peer_id: String, snapshot: Libra
}
async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelta) {
let Some(addr) = ({
let db = ctx.peer_game_db.read().await;
db.peer_addr(&peer_id)
}) else {
log::debug!("Ignoring library delta from unknown peer {peer_id}");
return;
};
let applied = {
let mut db = ctx.peer_game_db.write().await;
db.apply_library_delta(&peer_id, delta)
@@ -245,16 +229,16 @@ async fn handle_library_delta(ctx: &PeerCtx, peer_id: String, delta: LibraryDelt
if applied {
events::emit_peer_game_list(&ctx.peer_game_db, &ctx.tx_notify_ui).await;
} else {
spawn_library_resync(
ctx.peer_id.clone(),
ctx.local_peer_addr.clone(),
ctx.local_library.clone(),
ctx.peer_game_db.clone(),
ctx.tx_notify_ui.clone(),
addr,
peer_id,
"resync",
);
let addr = {
let db = ctx.peer_game_db.read().await;
db.peer_addr(&peer_id)
};
let Some(addr) = addr else {
log::debug!("Ignoring library delta from unknown peer {peer_id}");
return;
};
spawn_library_resync(HandshakeCtx::from_peer_ctx(ctx), addr, peer_id, "resync");
}
}