diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4ef43..8c52ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- NAT-traversal cross-init adoption is now deterministic under + simultaneous dual-initiation. Previously, when two peers' + Nostr-mediated UDP punches completed within the same scheduling + window, each side's bootstrap-completion event arrived with an + in-flight handshake already recorded against the other peer (each + side had received an inbound msg1 from the other's pre-punch + outbound attempt). The deduplication skip then fired on both + sides, neither installed the fresh traversal socket as canonical, + and the 45-second peer-adoption budget expired with both nodes + stuck waiting for an adoption that never happened. The handler now + applies the same deterministic NodeAddr tie-breaker the codebase + already uses for rekey dual-initiation and cross-connection + resolution: the smaller NodeAddr wins as adopter, tears down its + in-flight handshake state, and proceeds with adoption; the larger + NodeAddr keeps the skip semantics, and its in-flight outbound is + reconciled by the cross-connection logic when the winner's fresh + msg1 arrives over the adopted socket. The dual cross-init stall is + eliminated; cross-init NAT-traversal completes in well under a + second even under host CPU contention. - FSP session rekey is now hitless under packet loss and reordering. Previously, a rekey could leave the two endpoints holding different key sets for a brief window — if a handshake message was lost in diff --git a/src/node/handlers/timeout.rs b/src/node/handlers/timeout.rs index a026d1b..e87a3db 100644 --- a/src/node/handlers/timeout.rs +++ b/src/node/handlers/timeout.rs @@ -62,7 +62,7 @@ impl Node { /// Frees the session index, removes pending_outbound entry, and cleans up /// the link and address mapping. Does not log — callers provide context-appropriate /// log messages. - fn cleanup_stale_connection(&mut self, link_id: LinkId, _now_ms: u64) { + pub(in crate::node) fn cleanup_stale_connection(&mut self, link_id: LinkId, _now_ms: u64) { let conn = match self.connections.remove(&link_id) { Some(c) => c, None => return, diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index a8ee430..97a9419 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -423,11 +423,44 @@ impl Node { continue; } if self.is_connecting_to_peer(&peer_addr) { + // Dual cross-init: both nodes' Nostr-mediated punches + // completed simultaneously, and each side already + // holds in-flight handshake state for the other. + // Apply the deterministic NodeAddr tie-breaker — + // smaller NodeAddr wins as adopter (same convention + // as cross_connection_winner and the rekey dual- + // init resolution at handshake.rs:269). The winner + // tears down its in-flight state and adopts the + // fresh traversal socket; the loser keeps continue + // semantics, and its existing cross-connection + // logic in handle_msg1 reconciles when the winner's + // fresh msg1 arrives over the adopted socket. + let our_addr = self.identity.node_addr(); + if our_addr >= &peer_addr { + debug!( + peer_npub = %peer_npub, + "Dual cross-init NAT traversal: we lose (larger addr), keeping in-flight handshake" + ); + continue; + } debug!( peer_npub = %peer_npub, - "Ignoring established NAT traversal while peer handshake is already in progress" + "Dual cross-init NAT traversal: we win (smaller addr), tearing down in-flight handshake to adopt fresh socket" ); - continue; + let now_ms = Self::now_ms(); + let stale: Vec = self + .connections + .iter() + .filter(|(_, conn)| { + conn.expected_identity() + .map(|id| id.node_addr() == &peer_addr) + .unwrap_or(false) + }) + .map(|(link_id, _)| *link_id) + .collect(); + for link_id in stale { + self.cleanup_stale_connection(link_id, now_ms); + } } } match self.adopt_established_traversal(traversal).await {