proto/fmp: sans-IO connection-lifecycle state machine

This commit is contained in:
Johnathan Corgan
2026-07-07 06:20:02 +00:00
parent 9ea57b483a
commit 4802792e38
32 changed files with 4279 additions and 1392 deletions
+9 -3
View File
@@ -61,8 +61,8 @@ pub use transport::{
// Re-export protocol types
pub use protocol::{
FilterAnnounce, HandshakeMessageType, LinkMessageType, ProtocolError, SessionAck,
SessionDatagram, SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce,
FilterAnnounce, LinkMessageType, ProtocolError, SessionAck, SessionDatagram, SessionFlags,
SessionMessageType, SessionSetup, TreeAnnounce,
};
// Re-export discovery wire types (relocated from protocol:: to proto::discovery)
@@ -73,13 +73,19 @@ pub use proto::routing::{
COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken,
};
// Re-export FMP link-framing wire type (relocated from protocol:: to proto::fmp)
pub use proto::fmp::HandshakeMessageType;
// Re-export cache types
pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
// Re-export FMP tie-break helper (relocated from peer:: to proto::fmp)
pub use proto::fmp::cross_connection_winner;
// Re-export peer types
pub use peer::{
ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot,
PromotionResult, cross_connection_winner,
PromotionResult,
};
// Re-export node types
+1 -1
View File
@@ -73,7 +73,7 @@ impl Node {
/// entries — other removal paths (link-dead, decrypt failure, peer
/// restart) all schedule reconnect.
pub(in crate::node) fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) {
let disconnect = match crate::protocol::Disconnect::decode(payload) {
let disconnect = match crate::proto::fmp::Disconnect::decode(payload) {
Ok(msg) => msg,
Err(e) => {
debug!(from = %self.peer_display_name(from), error = %e, "Malformed disconnect message");
+257 -260
View File
@@ -1,15 +1,62 @@
//! Handshake handlers and connection promotion.
use crate::NodeAddr;
use crate::PeerIdentity;
use crate::node::acl::PeerAclContext;
use crate::node::reject::{HandshakeReject, RejectReason};
use crate::node::wire::{Msg1Header, Msg2Header, build_msg2};
use crate::node::{Node, NodeError};
use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner};
use crate::peer::{ActivePeer, PeerConnection, PromotionResult};
use crate::proto::fmp::{
ConnAction, EstablishSnapshot, EstablishView, InboundDecision, InboundReject, OutboundDecision,
OutboundSnapshot, WireOutcome, cross_connection_winner,
};
use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket};
use std::time::Duration;
use tracing::{debug, info, warn};
impl EstablishView for Node {
fn establish_snapshot(&self, peer_addr: &NodeAddr) -> EstablishSnapshot {
let existing = self.peers.get(peer_addr);
let max_peers = self.max_peers();
EstablishSnapshot {
has_existing_peer: existing.is_some(),
existing_peer_epoch: existing.and_then(|p| p.remote_epoch()),
existing_session_age_secs: existing
.map(|p| p.session_established_at().elapsed().as_secs())
.unwrap_or(0),
has_session: existing.map(|p| p.has_session()).unwrap_or(false),
is_healthy: existing.map(|p| p.is_healthy()).unwrap_or(false),
pending_new_session: existing
.map(|p| p.pending_new_session().is_some())
.unwrap_or(false),
rekey_in_progress: existing.map(|p| p.rekey_in_progress()).unwrap_or(false),
existing_msg2: existing.and_then(|p| p.handshake_msg2().map(|m| m.to_vec())),
at_max_peers: max_peers > 0 && self.peers.len() >= max_peers,
has_pending_outbound_to_peer: self.connections.values().any(|conn| {
conn.expected_identity()
.map(|id| id.node_addr() == peer_addr)
.unwrap_or(false)
}),
rekey_enabled: self.config().node.rekey.enabled,
our_node_addr: *self.identity().node_addr(),
}
}
fn outbound_snapshot(&self, peer_addr: &NodeAddr) -> OutboundSnapshot {
OutboundSnapshot {
has_existing_peer: self.peers.contains_key(peer_addr),
// Tie-break for THIS outbound connection (`is_outbound = true`),
// pre-evaluated here so the core stays free of the peer helper.
our_outbound_wins: cross_connection_winner(
self.identity().node_addr(),
peer_addr,
true,
),
}
}
}
impl Node {
/// Returns true if an inbound msg1 should be admitted past the
/// `accept_connections` gate.
@@ -96,32 +143,23 @@ impl Node {
}
};
// Check for existing connection from this address.
//
// If we already have an *inbound* link from this address, this could be:
// 1. A duplicate msg1 (our msg2 was lost) — resend msg2
// 2. A restarted peer (different epoch) — tear down and reprocess
//
// If we have an *outbound* link to this address (we initiated to them
// AND they initiated to us), this is a cross-connection — allow it.
//
// Epoch-based restart detection: if the sender already has an inbound
// link AND is an active peer in self.peers, fall through to decrypt
// the msg1 and check the epoch. Otherwise, treat as duplicate.
// Pre-crypto duplicate short-circuit. An *inbound* link from this
// address that is not (yet) a promoted peer means our earlier msg2 was
// lost: resend the stored msg2 without paying the crypto cost and
// return. An inbound link that DOES belong to an active peer (a possible
// restart/rekey) or an *outbound* link (a cross-connection) falls
// through to the wire step and the structured classification below —
// the pre-refactor `possible_restart` flag is no longer needed because
// that classification now gates on `has_existing_peer` (identity), which
// subsumes it.
let addr_key = (packet.transport_id, packet.remote_addr.clone());
let mut possible_restart = false;
if let Some(&existing_link_id) = self.addr_to_link.get(&addr_key)
&& let Some(link) = self.links.get(&existing_link_id)
{
if link.direction() == LinkDirection::Inbound {
// Check if this link belongs to an already-promoted active peer
let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id);
if is_active_peer {
// Possible restart — fall through to decrypt and check epoch
possible_restart = true;
} else {
// Genuinely pending handshake — resend msg2
if !is_active_peer {
// Genuinely pending handshake — resend msg2.
let msg2_bytes = self.find_stored_msg2(existing_link_id);
if let Some(msg2) = msg2_bytes {
if let Some(transport) = self.transports.get(&packet.transport_id) {
@@ -150,14 +188,11 @@ impl Node {
return;
}
} else {
// Outbound link to this address. If it belongs to an active
// peer, this may be a rekey msg1 (same epoch) or a
// restart (different epoch). Set possible_restart to enable
// the epoch/rekey check below.
// Outbound link to this address with no active peer yet: a
// cross-connection. Just log; it is classified as a net-new
// inbound below.
let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id);
if is_active_peer {
possible_restart = true;
} else {
if !is_active_peer {
debug!(
transport_id = %packet.transport_id,
remote_addr = %packet.remote_addr,
@@ -212,247 +247,184 @@ impl Node {
let peer_node_addr = *peer_identity.node_addr();
// Identity-based restart/rekey detection: if the peer is already
// active but addr_to_link didn't match (different source address, e.g.,
// TCP from a different port), we still need to check for restart/rekey.
if !possible_restart && self.peers.contains_key(&peer_node_addr) {
possible_restart = true;
}
// === PHASE B result ===
// Bundle the Noise wire-step outputs (identity, remote epoch, sender
// index, opaque msg2 payload). The wire step touched no `Node` registry
// state; from here the decision reads only `wire` and the snapshot.
let wire = WireOutcome {
peer_identity,
remote_epoch: conn.remote_epoch(),
their_index: header.sender_idx,
msg2_payload: msg2_response,
};
// Early cap check: at max_peers and this is a net-new identity?
// Bypass for known peers (reconnect / cross-connection) — admitting
// them doesn't grow peers.len(). This silent-drops the Msg1 before
// the Msg2 build/send and index allocation, avoiding wasted wire
// bytes and giving the peer cleaner semantics (no fake-completed
// handshake whose data frames subsequently fail decryption here).
// The late cap check inside promote_connection() is intentionally
// left in place as defense-in-depth.
if self.max_peers() > 0 && self.peers.len() >= self.max_peers() {
let is_known_active = self.peers.contains_key(&peer_node_addr);
let is_pending_outbound = self.connections.iter().any(|(_, conn)| {
conn.expected_identity()
.map(|id| *id.node_addr() == peer_node_addr)
.unwrap_or(false)
});
if !is_known_active && !is_pending_outbound {
debug!(
peer = %self.peer_display_name(&peer_node_addr),
max = self.max_peers(),
"Silent-dropping Msg1 at max_peers cap (early gate; no Msg2 sent)"
);
// `link_id` was allocated above but `conn` is still a local
// (not yet inserted into self.connections / self.links /
// self.addr_to_link), so the local drop suffices.
// === PHASE C input ===
// Snapshot the registry state the inbound classification reads about
// this peer identity (existing epoch/session/rekey state with the
// session age resolved here, the max-peers cap, our own address for the
// tie-break). Taken before this connection is inserted into the
// registry, matching the pre-refactor read points.
let est = self.establish_snapshot(&peer_node_addr);
// === PHASE C: structured classification (pure core) ===
// The decision reads only the snapshot + wire outcome; the shell below
// drives the effects. `Promote`/`RestartThenPromote` fall through to the
// shared authorize → allocate → send-msg2 → promote tail; the other
// variants complete the rate-limiter and return here.
match self.fmp.establish_inbound(&est, &wire) {
InboundDecision::Reject { reason } => {
match reason {
InboundReject::AtMaxPeers => debug!(
peer = %self.peer_display_name(&peer_node_addr),
max = self.max_peers(),
"Silent-dropping Msg1 at max_peers cap (early gate; no Msg2 sent)"
),
InboundReject::PendingSession => debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Rekey msg1 received but already have pending session, dropping"
),
InboundReject::DualRekeyWon => debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Dual rekey initiation: we win (smaller addr), dropping their msg1"
),
}
// `conn`/`link_id` were never inserted into the registry, so the
// local drop suffices — no cleanup needed.
self.msg1_rate_limiter.complete_handshake();
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
}
// Epoch-based restart detection and duplicate msg1 handling.
//
// If we fell through from the addr_to_link check above with
// possible_restart=true, we now have the decrypted epoch from msg1.
// Compare it against the stored epoch for this peer.
if possible_restart && let Some(existing_peer) = self.peers.get(&peer_node_addr) {
let new_epoch = conn.remote_epoch();
let existing_epoch = existing_peer.remote_epoch();
match (existing_epoch, new_epoch) {
(Some(existing), Some(new)) if existing != new => {
// Epoch mismatch — peer restarted. Tear down stale session.
debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Peer restart detected (epoch mismatch), removing stale session"
);
self.remove_active_peer(&peer_node_addr);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.schedule_reconnect(peer_node_addr, now_ms);
// Fall through to process as new connection
InboundDecision::ResendMsg2 { msg2 } => {
if let Some(msg2) = msg2.as_deref()
&& let Some(transport) = self.transports.get(&packet.transport_id)
{
match transport.send(&packet.remote_addr, msg2).await {
Ok(_) => debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Resent msg2 for duplicate msg1 (same epoch)"
),
Err(e) => debug!(
peer = %self.peer_display_name(&peer_node_addr),
error = %e,
"Failed to resend msg2"
),
}
}
_ => {
// Same epoch (or no epoch stored).
// If the peer has an active session and rekey is enabled,
// this is a rekey msg1 (not a duplicate initial msg1).
// Guard: the session must be at least 30s old to avoid
// misidentifying a cross-connection msg1 as a rekey.
// During simultaneous connection, both sides promote
// within the same tick and the peer's msg1 arrives
// immediately — a genuine rekey can't fire that fast.
let session_age_secs =
existing_peer.session_established_at().elapsed().as_secs();
if self.config().node.rekey.enabled
&& existing_peer.has_session()
&& existing_peer.is_healthy()
&& session_age_secs >= 30
self.msg1_rate_limiter.complete_handshake();
return;
}
InboundDecision::RekeyRespond {
peer,
abandon_first,
} => {
if abandon_first {
// Dual-initiation loser: abandon our own in-flight rekey and
// free its index before responding as the rekey responder.
debug!(
peer = %self.peer_display_name(&peer),
"Dual rekey initiation: we lose (larger addr), abandoning ours"
);
if let Some(existing) = self.peers.get_mut(&peer)
&& let Some(idx) = existing.abandon_rekey()
{
// Guard: already have a pending session from a completed
// rekey (waiting for K-bit cutover). Don't overwrite it
// with a new handshake — drop this msg1.
if existing_peer.pending_new_session().is_some() {
if let Some(tid) = existing.transport_id() {
self.peers_by_index.remove(&(tid, idx.as_u32()));
self.pending_outbound.remove(&(tid, idx.as_u32()));
}
let _ = self.index_allocator.free(idx);
}
}
// Rekey: process as responder, store new session as pending.
let noise_session = conn.take_session();
let our_new_index = match self.index_allocator.allocate() {
Ok(idx) => idx,
Err(e) => {
warn!(error = %e, "Failed to allocate index for rekey");
self.msg1_rate_limiter.complete_handshake();
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
};
let noise_session = match noise_session {
Some(s) => s,
None => {
warn!("Rekey msg1: no session from handshake");
let _ = self.index_allocator.free(our_new_index);
self.msg1_rate_limiter.complete_handshake();
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
};
// Send msg2 response using the new handshake.
let wire_msg2 = build_msg2(our_new_index, wire.their_index, &wire.msg2_payload);
if let Some(transport) = self.transports.get(&packet.transport_id) {
match transport.send(&packet.remote_addr, &wire_msg2).await {
Ok(_) => {
debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Rekey msg1 received but already have pending session, dropping"
peer = %self.peer_display_name(&peer),
new_our_index = %our_new_index,
"Sent rekey msg2 response"
);
self.connections.remove(&link_id);
self.links.remove(&link_id);
}
Err(e) => {
warn!(
peer = %self.peer_display_name(&peer),
error = %e,
"Failed to send rekey msg2"
);
let _ = self.index_allocator.free(our_new_index);
self.msg1_rate_limiter.complete_handshake();
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
// Dual-initiation detection: both sides sent msg1
// simultaneously. Apply tie-breaker — smaller NodeAddr
// wins as initiator (same as cross-connection resolution).
if existing_peer.rekey_in_progress() {
let our_addr = self.identity().node_addr();
if our_addr < &peer_node_addr {
// We win as initiator — drop their msg1.
// Our msg2 will arrive at peer, who completes
// as our responder.
debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Dual rekey initiation: we win (smaller addr), dropping their msg1"
);
self.connections.remove(&link_id);
self.links.remove(&link_id);
self.msg1_rate_limiter.complete_handshake();
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
return;
}
// We lose — abandon our rekey, become responder below.
debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Dual rekey initiation: we lose (larger addr), abandoning ours"
);
if let Some(peer) = self.peers.get_mut(&peer_node_addr)
&& let Some(idx) = peer.abandon_rekey()
{
if let Some(tid) = peer.transport_id() {
self.peers_by_index.remove(&(tid, idx.as_u32()));
self.pending_outbound.remove(&(tid, idx.as_u32()));
}
let _ = self.index_allocator.free(idx);
}
// Fall through to respond as responder
}
// Rekey: process as responder, store new session as pending
let noise_session = conn.take_session();
let our_new_index = match self.index_allocator.allocate() {
Ok(idx) => idx,
Err(e) => {
warn!(error = %e, "Failed to allocate index for rekey");
self.msg1_rate_limiter.complete_handshake();
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
return;
}
};
let noise_session = match noise_session {
Some(s) => s,
None => {
warn!("Rekey msg1: no session from handshake");
let _ = self.index_allocator.free(our_new_index);
self.msg1_rate_limiter.complete_handshake();
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
return;
}
};
// Send msg2 response using the new handshake
let wire_msg2 =
build_msg2(our_new_index, header.sender_idx, &msg2_response);
if let Some(transport) = self.transports.get(&packet.transport_id) {
match transport.send(&packet.remote_addr, &wire_msg2).await {
Ok(_) => {
debug!(
peer = %self.peer_display_name(&peer_node_addr),
new_our_index = %our_new_index,
"Sent rekey msg2 response"
);
}
Err(e) => {
warn!(
peer = %self.peer_display_name(&peer_node_addr),
error = %e,
"Failed to send rekey msg2"
);
let _ = self.index_allocator.free(our_new_index);
self.msg1_rate_limiter.complete_handshake();
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
return;
}
}
}
// Store pending session on the existing peer
if let Some(peer) = self.peers.get_mut(&peer_node_addr) {
peer.set_pending_session(
noise_session,
our_new_index,
header.sender_idx,
);
peer.record_peer_rekey();
}
// Register new index in peers_by_index
self.peers_by_index.insert(
(packet.transport_id, our_new_index.as_u32()),
peer_node_addr,
);
// Clean up: remove the temporary connection/link we created.
// Do NOT remove addr_to_link — the entry must remain pointing
// to the original link so future msg1s from this address are
// recognized as rekeys (not new connections).
self.connections.remove(&link_id);
self.links.remove(&link_id);
self.msg1_rate_limiter.complete_handshake();
return;
}
// Not a rekey — duplicate msg1. Resend stored msg2.
if let Some(msg2) = existing_peer.handshake_msg2().map(|m| m.to_vec())
&& let Some(transport) = self.transports.get(&packet.transport_id)
{
match transport.send(&packet.remote_addr, &msg2).await {
Ok(_) => debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Resent msg2 for duplicate msg1 (same epoch)"
),
Err(e) => debug!(
peer = %self.peer_display_name(&peer_node_addr),
error = %e,
"Failed to resend msg2"
),
}
}
self.msg1_rate_limiter.complete_handshake();
return;
}
// Store pending session on the existing peer.
if let Some(existing) = self.peers.get_mut(&peer) {
existing.set_pending_session(noise_session, our_new_index, wire.their_index);
existing.record_peer_rekey();
}
// Register new index in peers_by_index.
self.peers_by_index
.insert((packet.transport_id, our_new_index.as_u32()), peer);
// Do NOT touch addr_to_link — the entry must keep pointing at the
// original link so future msg1s from this address are recognized
// as rekeys (not new connections). The temporary `conn`/`link_id`
// were never inserted into the registry, so no cleanup is needed.
self.msg1_rate_limiter.complete_handshake();
return;
}
InboundDecision::RestartThenPromote { peer } => {
// Epoch mismatch — peer restarted. Tear down the stale session
// and schedule a reconnect, then fall through to promote the
// fresh handshake as a new connection.
debug!(
peer = %self.peer_display_name(&peer),
"Peer restart detected (epoch mismatch), removing stale session"
);
self.remove_active_peer(&peer);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.schedule_reconnect(peer, now_ms);
}
InboundDecision::Promote => {}
}
// If possible_restart was true but peer is no longer in self.peers
// (removed by another path), fall through to process as new connection.
if self
.authorize_peer(
&peer_identity,
&wire.peer_identity,
PeerAclContext::InboundHandshake,
packet.transport_id,
&packet.remote_addr,
@@ -481,7 +453,7 @@ impl Node {
};
conn.set_our_index(our_index);
conn.set_their_index(header.sender_idx);
conn.set_their_index(wire.their_index);
// Create link
let link = Link::connectionless(
@@ -497,7 +469,7 @@ impl Node {
self.connections.insert(link_id, conn);
// Build and send msg2 response, storing for potential resend
let wire_msg2 = build_msg2(our_index, header.sender_idx, &msg2_response);
let wire_msg2 = build_msg2(our_index, wire.their_index, &wire.msg2_payload);
if let Some(conn) = self.connections.get_mut(&link_id) {
conn.set_handshake_msg2(wire_msg2.clone());
}
@@ -508,7 +480,7 @@ impl Node {
debug!(
link_id = %link_id,
our_index = %our_index,
their_index = %header.sender_idx,
their_index = %wire.their_index,
bytes,
"Sent msg2 response"
);
@@ -536,7 +508,8 @@ impl Node {
// Responder handshake is complete after receive_handshake_init (Noise IK
// pattern: responder processes msg1 and generates msg2 in one step).
// Promote the connection to active peer now.
match self.promote_connection(link_id, peer_identity, packet.timestamp_ms) {
let promote = ConnAction::PromoteToActive { link: link_id };
match self.drive_promote_to_active(promote, wire.peer_identity, packet.timestamp_ms) {
Ok(result) => {
match result {
PromotionResult::Promoted(node_addr) => {
@@ -841,13 +814,12 @@ impl Node {
//
// This ensures both nodes use the same Noise handshake (the winner's
// outbound = the loser's inbound).
if self.peers.contains_key(&peer_node_addr) {
let our_outbound_wins = cross_connection_winner(
self.identity().node_addr(),
&peer_node_addr,
true, // this IS our outbound
);
// Structured classification (pure core): cross-connection swap/keep, or
// a net-new promote. The tie-break is pre-evaluated in the snapshot; the
// effect bodies below are unchanged.
let out_snap = self.outbound_snapshot(&peer_node_addr);
let out_decision = self.fmp.establish_outbound(&out_snap);
if out_decision != OutboundDecision::Promote {
// Extract the outbound connection
let mut conn = match self.connections.remove(&link_id) {
Some(c) => c,
@@ -859,7 +831,7 @@ impl Node {
}
};
if our_outbound_wins {
if out_decision == OutboundDecision::CrossConnectionSwap {
// We're the smaller node. Swap to outbound session + indices.
// The peer will keep their inbound session (complement of ours).
let outbound_our_index = conn.our_index();
@@ -961,7 +933,8 @@ impl Node {
}
// Normal path: promote to active peer
match self.promote_connection(link_id, peer_identity, packet.timestamp_ms) {
let promote = ConnAction::PromoteToActive { link: link_id };
match self.drive_promote_to_active(promote, peer_identity, packet.timestamp_ms) {
Ok(result) => {
// Clean up pending_outbound
self.pending_outbound.remove(&key);
@@ -1041,6 +1014,30 @@ impl Node {
}
}
/// Execute a [`ConnAction::PromoteToActive`] from the establish machine.
///
/// The decision to promote is made by the establish handlers (and, from the
/// establish-core stage on, the pure decision in `proto::fmp`); this is the
/// executor half of the seam. It runs the promotion through
/// [`Self::promote_connection`], resolving the verified identity and
/// promotion timestamp from the ambient wire context, and returns the
/// [`PromotionResult`] so the caller can drive the site-specific
/// post-promotion tail (TreeAnnounce, bloom mark, discovery-backoff reset,
/// loser-link cleanup).
fn drive_promote_to_active(
&mut self,
action: ConnAction,
verified_identity: PeerIdentity,
current_time_ms: u64,
) -> Result<PromotionResult, NodeError> {
match action {
ConnAction::PromoteToActive { link } => {
self.promote_connection(link, verified_identity, current_time_ms)
}
_ => unreachable!("drive_promote_to_active requires a PromoteToActive action"),
}
}
/// Promote a connection to active peer after successful authentication.
///
/// Handles cross-connection detection and resolution using tie-breaker rules.
+165 -159
View File
@@ -9,6 +9,7 @@ use crate::NodeAddr;
use crate::node::Node;
use crate::node::wire::build_msg1;
use crate::noise::HandshakeState;
use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot};
use crate::protocol::{SessionDatagram, SessionSetup};
use tracing::{debug, trace, warn};
@@ -41,121 +42,112 @@ impl Node {
return;
}
let rekey_after_secs = self.config().node.rekey.after_secs;
let rekey_after_messages = self.config().node.rekey.after_messages;
let cfg = RekeyCfg {
after_secs: self.config().node.rekey.after_secs,
after_messages: self.config().node.rekey.after_messages,
};
// Collect peers that need action (to avoid borrow conflicts)
let mut peers_to_cutover: Vec<NodeAddr> = Vec::new();
let mut peers_to_drain: Vec<NodeAddr> = Vec::new();
let mut peers_to_rekey: Vec<NodeAddr> = Vec::new();
for (node_addr, peer) in &self.peers {
if !peer.has_session() || !peer.is_healthy() {
continue;
}
// 1. Initiator-side cutover: we completed a rekey and have
// a pending session ready. Cut over on the next tick.
if peer.pending_new_session().is_some() && !peer.rekey_in_progress() {
peers_to_cutover.push(*node_addr);
continue;
}
// 2. Drain window expiry
if peer.is_draining() && peer.drain_expired(DRAIN_WINDOW_SECS) {
peers_to_drain.push(*node_addr);
}
// 3. Rekey trigger
if peer.rekey_in_progress() {
continue;
}
if peer.is_rekey_dampened(REKEY_DAMPENING_SECS) {
continue;
}
let elapsed = peer.session_established_at().elapsed().as_secs();
let counter = peer
.noise_session()
.map(|s| s.current_send_counter())
.unwrap_or(0);
// Apply per-session symmetric jitter to desynchronize
// dual-initiation in symmetric-start meshes.
let effective_after_secs =
rekey_after_secs.saturating_add_signed(peer.rekey_jitter_secs());
if elapsed >= effective_after_secs || counter >= rekey_after_messages {
peers_to_rekey.push(*node_addr);
}
}
// Execute cutover for initiator side
for node_addr in peers_to_cutover {
let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) {
if let Some(_old_our_index) = peer.cutover_to_new_session() {
// New index was pre-registered in peers_by_index
// during msg2 handling (handshake.rs).
debug_assert!(
peer.transport_id().is_some()
&& peer.our_index().is_some()
&& self.peers_by_index.contains_key(&(
peer.transport_id().unwrap(),
peer.our_index().unwrap().as_u32()
)),
"peers_by_index should contain pre-registered new index after cutover"
);
debug!(
peer = %self.peer_display_name(&node_addr),
"Rekey cutover complete (initiator), K-bit flipped"
);
true
} else {
false
}
} else {
false
};
// Re-register the new session with the decrypt worker — the
// cache_key (transport_id, our_index) just changed, so the
// old worker entry is stale and every packet on the new
// session would miss the worker's HashMap lookup.
#[cfg(unix)]
if did_cutover {
self.register_decrypt_worker_session(&node_addr);
}
#[cfg(not(unix))]
let _ = did_cutover;
}
// Execute drain completion
for node_addr in peers_to_drain {
// Extract the old index and transport_id under the peer
// borrow, then drop the borrow so the cache_key cleanup
// below can take &mut self for unregister_decrypt_worker_session.
let drained = self
.peers
.get_mut(&node_addr)
.and_then(|peer| peer.complete_drain().map(|idx| (idx, peer.transport_id())));
if let Some((old_our_index, transport_id)) = drained {
if let Some(tid) = transport_id {
let cache_key = (tid, old_our_index.as_u32());
self.peers_by_index.remove(&cache_key);
// The shell snapshots each healthy peer's rekey ages/flags (every clock
// read resolved here); the core decides cutover/drain/trigger with no
// clock, phase-grouped to preserve the pre-refactor execution order.
let snapshots = self.rekey_peers();
for action in self.fmp.poll_rekey(snapshots, &cfg) {
match action {
// Execute cutover for initiator side.
ConnAction::Cutover { peer: node_addr } => {
let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) {
if let Some(_old_our_index) = peer.cutover_to_new_session() {
// New index was pre-registered in peers_by_index
// during msg2 handling (handshake.rs).
debug_assert!(
peer.transport_id().is_some()
&& peer.our_index().is_some()
&& self.peers_by_index.contains_key(&(
peer.transport_id().unwrap(),
peer.our_index().unwrap().as_u32()
)),
"peers_by_index should contain pre-registered new index after cutover"
);
debug!(
peer = %self.peer_display_name(&node_addr),
"Rekey cutover complete (initiator), K-bit flipped"
);
true
} else {
false
}
} else {
false
};
// Re-register the new session with the decrypt worker — the
// cache_key (transport_id, our_index) just changed, so the
// old worker entry is stale and every packet on the new
// session would miss the worker's HashMap lookup.
#[cfg(unix)]
self.unregister_decrypt_worker_session(cache_key);
if did_cutover {
self.register_decrypt_worker_session(&node_addr);
}
#[cfg(not(unix))]
let _ = did_cutover;
}
let _ = self.index_allocator.free(old_our_index);
trace!(
peer = %self.peer_display_name(&node_addr),
old_index = %old_our_index,
"Drain complete, previous session erased"
);
// Execute drain completion.
ConnAction::Drain { peer: node_addr } => {
// Extract the old index and transport_id under the peer
// borrow, then drop the borrow so the cache_key cleanup
// below can take &mut self for unregister_decrypt_worker_session.
let drained = self.peers.get_mut(&node_addr).and_then(|peer| {
peer.complete_drain().map(|idx| (idx, peer.transport_id()))
});
if let Some((old_our_index, transport_id)) = drained {
if let Some(tid) = transport_id {
let cache_key = (tid, old_our_index.as_u32());
self.peers_by_index.remove(&cache_key);
#[cfg(unix)]
self.unregister_decrypt_worker_session(cache_key);
}
let _ = self.index_allocator.free(old_our_index);
trace!(
peer = %self.peer_display_name(&node_addr),
old_index = %old_our_index,
"Drain complete, previous session erased"
);
}
}
// Initiate a new rekey.
ConnAction::InitiateRekey { peer: node_addr } => {
self.initiate_rekey(&node_addr).await;
}
#[allow(unreachable_patterns)]
_ => {}
}
}
}
// Initiate new rekeys
for node_addr in peers_to_rekey {
self.initiate_rekey(&node_addr).await;
}
/// Snapshot every healthy peer with a session for the rekey decision,
/// pre-computing its monotonic ages and timer predicates so the pure core
/// applies the thresholds without reading a clock (see [`PeerSnapshot`]).
///
/// Lives here, beside the drain/dampening constants and the FSP analog, so
/// the forward-merge onto `next` reconciles rekey timing in one place.
pub(in crate::node) fn rekey_peer_snapshots(&self) -> Vec<PeerSnapshot> {
self.peers
.iter()
.filter(|(_, peer)| peer.has_session() && peer.is_healthy())
.map(|(node_addr, peer)| PeerSnapshot {
addr: *node_addr,
has_pending: peer.pending_new_session().is_some(),
rekey_in_progress: peer.rekey_in_progress(),
is_draining: peer.is_draining(),
drain_expired: peer.drain_expired(DRAIN_WINDOW_SECS),
is_dampened: peer.is_rekey_dampened(REKEY_DAMPENING_SECS),
elapsed_secs: peer.session_established_at().elapsed().as_secs(),
counter: peer
.noise_session()
.map(|s| s.current_send_counter())
.unwrap_or(0),
jitter_secs: peer.rekey_jitter_secs(),
})
.collect()
}
/// Initiate an outbound rekey to a peer.
@@ -260,60 +252,74 @@ impl Node {
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
let max_resends = self.config().node.rate_limit.handshake_max_resends;
// Collect peers needing action
let mut to_resend: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
let mut to_abandon: Vec<NodeAddr> = Vec::new();
// The shell snapshots each in-flight rekey (resend-due predicate
// resolved here); the core classifies abandon-vs-resend and computes
// the backoff, abandons first.
let candidates = self.rekey_resend_candidates(now_ms);
for action in
self.fmp
.poll_rekey_resends(candidates, now_ms, interval_ms, backoff, max_resends)
{
match action {
// Abandon rekey cycles that exhausted their retransmission budget.
ConnAction::AbandonRekey { peer: node_addr } => {
if let Some(peer) = self.peers.get_mut(&node_addr) {
peer.abandon_rekey();
}
debug!(
peer = %self.peer_display_name(&node_addr),
"FMP rekey aborted: msg1 unconfirmed after max retransmissions, abandoning cycle"
);
}
ConnAction::ResendRekeyMsg1 {
peer: node_addr,
bytes,
next_resend_at_ms,
} => {
let (transport_id, remote_addr) = match self.peers.get(&node_addr) {
Some(p) => match (p.transport_id(), p.current_addr()) {
(Some(tid), Some(addr)) => (tid, addr.clone()),
_ => continue,
},
None => continue,
};
for (node_addr, peer) in &self.peers {
if !peer.rekey_in_progress() || peer.rekey_msg1().is_none() {
continue;
}
if peer.rekey_msg1_resend_count() >= max_resends {
to_abandon.push(*node_addr);
continue;
}
if peer.needs_msg1_resend(now_ms) {
to_resend.push((*node_addr, peer.rekey_msg1().unwrap().to_vec()));
let sent = if let Some(transport) = self.transports.get(&transport_id) {
transport.send(&remote_addr, &bytes).await.is_ok()
} else {
false
};
if sent && let Some(peer) = self.peers.get_mut(&node_addr) {
peer.record_rekey_msg1_resend(next_resend_at_ms);
let count = peer.rekey_msg1_resend_count();
trace!(
peer = %self.peer_display_name(&node_addr),
resend = count,
"Resent rekey msg1"
);
}
}
#[allow(unreachable_patterns)]
_ => {}
}
}
}
// Abandon rekey cycles that exhausted their retransmission budget.
for node_addr in to_abandon {
if let Some(peer) = self.peers.get_mut(&node_addr) {
peer.abandon_rekey();
}
debug!(
peer = %self.peer_display_name(&node_addr),
"FMP rekey aborted: msg1 unconfirmed after max retransmissions, abandoning cycle"
);
}
for (node_addr, msg1_bytes) in to_resend {
let (transport_id, remote_addr) = match self.peers.get(&node_addr) {
Some(p) => match (p.transport_id(), p.current_addr()) {
(Some(tid), Some(addr)) => (tid, addr.clone()),
_ => continue,
},
None => continue,
};
let sent = if let Some(transport) = self.transports.get(&transport_id) {
transport.send(&remote_addr, &msg1_bytes).await.is_ok()
} else {
false
};
if sent && let Some(peer) = self.peers.get_mut(&node_addr) {
let count = peer.rekey_msg1_resend_count() + 1;
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
peer.record_rekey_msg1_resend(next);
trace!(
peer = %self.peer_display_name(&node_addr),
resend = count,
"Resent rekey msg1"
);
}
}
/// Snapshot every peer with a rekey handshake in flight (and a stored
/// msg1) for the retransmission decision, pre-evaluating the resend-due
/// predicate against `now_ms` so the core reads no clock.
pub(in crate::node) fn rekey_resend_snapshots(&self, now_ms: u64) -> Vec<RekeyResendSnapshot> {
self.peers
.iter()
.filter(|(_, peer)| peer.rekey_in_progress() && peer.rekey_msg1().is_some())
.map(|(node_addr, peer)| RekeyResendSnapshot {
peer: *node_addr,
resend_count: peer.rekey_msg1_resend_count(),
needs_resend: peer.needs_msg1_resend(now_ms),
msg1: peer.rekey_msg1().unwrap().to_vec(),
})
.collect()
}
/// Retransmit FSP rekey msg3 until the responder is confirmed on the
+103 -59
View File
@@ -3,14 +3,69 @@
use crate::node::Node;
use crate::peer::HandshakeState;
use crate::proto::fmp::{
ConnAction, ConnSnapshot, LifecycleView, PeerSnapshot, RekeyResendSnapshot,
};
use crate::transport::LinkId;
use tracing::{debug, info};
impl LifecycleView for Node {
fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec<ConnSnapshot> {
self.connections
.iter()
.filter(|(_, conn)| conn.is_timed_out(now_ms, timeout_ms) || conn.is_failed())
.map(|(link_id, conn)| ConnSnapshot {
link: *link_id,
is_outbound: conn.is_outbound(),
retry_addr: conn.expected_identity().map(|id| *id.node_addr()),
resend_count: 0,
msg1: Vec::new(),
})
.collect()
}
fn resend_candidates(&self, now_ms: u64, max_resends: u32) -> Vec<ConnSnapshot> {
self.connections
.iter()
.filter(|(_, conn)| {
conn.is_outbound()
&& conn.handshake_state() == HandshakeState::SentMsg1
&& conn.resend_count() < max_resends
&& conn.next_resend_at_ms() > 0
&& now_ms >= conn.next_resend_at_ms()
})
.filter_map(|(link_id, conn)| {
conn.handshake_msg1().map(|msg1| ConnSnapshot {
link: *link_id,
is_outbound: true,
retry_addr: None,
resend_count: conn.resend_count(),
msg1: msg1.to_vec(),
})
})
.collect()
}
fn rekey_peers(&self) -> Vec<PeerSnapshot> {
// The snapshot builder lives in `rekey` beside its drain/dampening
// constants; the read-seam unifies here.
self.rekey_peer_snapshots()
}
fn rekey_resend_candidates(&self, now_ms: u64) -> Vec<RekeyResendSnapshot> {
self.rekey_resend_snapshots(now_ms)
}
}
impl Node {
/// Check for timed-out handshake connections and clean them up.
///
/// Called periodically by the RX event loop. Removes connections that have
/// been idle longer than the configured handshake timeout or are in Failed state.
///
/// The stale/failed predicate and every registry mutation stay shell-side;
/// the retry-then-teardown choreography is the pure
/// [`Fmp::poll_timeouts`](crate::proto::fmp::Fmp::poll_timeouts) decision.
pub(in crate::node) fn check_timeouts(&mut self) {
if self.connections.is_empty() {
return;
@@ -19,41 +74,34 @@ impl Node {
let now_ms = Self::now_ms();
let timeout_ms = self.config().node.rate_limit.handshake_timeout_secs * 1000;
let stale: Vec<LinkId> = self
.connections
.iter()
.filter(|(_, conn)| conn.is_timed_out(now_ms, timeout_ms) || conn.is_failed())
.map(|(link_id, _)| *link_id)
.collect();
for link_id in stale {
// Log and schedule retry before cleanup (need connection state)
if let Some(conn) = self.connections.get(&link_id) {
let direction = conn.direction();
let idle_ms = conn.idle_time(now_ms);
if conn.is_failed() {
debug!(
link_id = %link_id,
direction = %direction,
"Failed handshake connection cleaned up"
);
} else {
debug!(
link_id = %link_id,
direction = %direction,
idle_secs = idle_ms / 1000,
"Stale handshake connection timed out"
);
}
// Schedule retry for failed outbound auto-connect peers
if conn.is_outbound()
&& let Some(identity) = conn.expected_identity()
{
self.schedule_retry(*identity.node_addr(), now_ms);
let stale = self.stale_connections(now_ms, timeout_ms);
for action in self.fmp.poll_timeouts(stale) {
match action {
ConnAction::ScheduleRetry { peer } => self.schedule_retry(peer, now_ms),
ConnAction::Teardown { link } => {
// Log before cleanup (needs live connection state).
if let Some(conn) = self.connections.get(&link) {
let direction = conn.direction();
if conn.is_failed() {
debug!(
link_id = %link,
direction = %direction,
"Failed handshake connection cleaned up"
);
} else {
debug!(
link_id = %link,
direction = %direction,
idle_secs = conn.idle_time(now_ms) / 1000,
"Stale handshake connection timed out"
);
}
}
self.cleanup_stale_connection(link, now_ms);
}
#[allow(unreachable_patterns)]
_ => {}
}
self.cleanup_stale_connection(link_id, now_ms);
}
}
@@ -97,26 +145,24 @@ impl Node {
let interval_ms = self.config().node.rate_limit.handshake_resend_interval_ms;
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
// Collect resend candidates: outbound, in SentMsg1, with stored msg1,
// under max resends, and past the scheduled time.
let candidates: Vec<(LinkId, Vec<u8>)> = self
.connections
.iter()
.filter(|(_, conn)| {
conn.is_outbound()
&& conn.handshake_state() == HandshakeState::SentMsg1
&& conn.resend_count() < max_resends
&& conn.next_resend_at_ms() > 0
&& now_ms >= conn.next_resend_at_ms()
})
.filter_map(|(link_id, conn)| {
conn.handshake_msg1().map(|msg1| (*link_id, msg1.to_vec()))
})
.collect();
// The shell resolves the resend-candidate predicate and copies the
// opaque msg1 bytes; the core computes the backoff schedule.
let candidates = self.resend_candidates(now_ms, max_resends);
for action in self
.fmp
.poll_resends(candidates, now_ms, interval_ms, backoff)
{
let ConnAction::ResendMsg1 {
link,
bytes,
next_resend_at_ms,
} = action
else {
continue;
};
for (link_id, msg1_bytes) in candidates {
// Get transport and address info from the connection
let (transport_id, remote_addr) = match self.connections.get(&link_id) {
let (transport_id, remote_addr) = match self.connections.get(&link) {
Some(conn) => match (conn.transport_id(), conn.source_addr()) {
(Some(tid), Some(addr)) => (tid, addr.clone()),
_ => continue,
@@ -126,11 +172,11 @@ impl Node {
// Send the stored msg1
let sent = if let Some(transport) = self.transports.get(&transport_id) {
match transport.send(&remote_addr, &msg1_bytes).await {
match transport.send(&remote_addr, &bytes).await {
Ok(_) => true,
Err(e) => {
debug!(
link_id = %link_id,
link_id = %link,
error = %e,
"Handshake msg1 resend failed"
);
@@ -141,13 +187,11 @@ impl Node {
false
};
if sent && let Some(conn) = self.connections.get_mut(&link_id) {
let count = conn.resend_count() + 1;
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
conn.record_resend(next);
if sent && let Some(conn) = self.connections.get_mut(&link) {
conn.record_resend(next_resend_at_ms);
debug!(
link_id = %link_id,
resend = count,
link_id = %link,
resend = conn.resend_count(),
"Resent handshake msg1"
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::discovery::{BootstrapHandoffResult, EstablishedTraversal};
use crate::node::acl::PeerAclContext;
use crate::node::wire::build_msg1;
use crate::peer::PeerConnection;
use crate::protocol::{Disconnect, DisconnectReason};
use crate::proto::fmp::{Disconnect, DisconnectReason};
use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId, packet_channel};
use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface};
use crate::{NodeAddr, PeerIdentity};
+6
View File
@@ -45,6 +45,7 @@ use crate::cache::CoordCache;
use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection};
use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter};
use crate::proto::fmp::Fmp;
use crate::proto::routing::{self, Router, RoutingErrorRateLimiter};
#[cfg(unix)]
use crate::transport::ethernet::EthernetTransport;
@@ -416,6 +417,9 @@ pub struct Node {
icmp_rate_limiter: IcmpRateLimiter,
/// Routing-subsystem state (routing error-signal rate limiter).
routing: Router,
/// FMP connection-lifecycle decision anchor (stateless; drives the
/// tick-poll maintain/teardown decisions).
fmp: Fmp,
/// Rate limiter for source-side CoordsRequired/PathBroken responses.
coords_response_rate_limiter: RoutingErrorRateLimiter,
@@ -658,6 +662,7 @@ impl Node {
msg1_rate_limiter,
icmp_rate_limiter: IcmpRateLimiter::new(),
routing: Router::new(),
fmp: Fmp::new(),
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
coords_response_interval_ms,
),
@@ -817,6 +822,7 @@ impl Node {
msg1_rate_limiter,
icmp_rate_limiter: IcmpRateLimiter::new(),
routing: Router::new(),
fmp: Fmp::new(),
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
coords_response_interval_ms,
),
+5 -87
View File
@@ -8,6 +8,7 @@ use super::{Node, NodeError};
use crate::PeerIdentity;
use crate::config::PeerConfig;
use crate::identity::NodeAddr;
use crate::proto::fmp::backoff_ms;
use tracing::{debug, info, warn};
// MAX_BACKOFF_MS is now derived from config: node.retry.max_backoff_secs * 1000
@@ -45,17 +46,6 @@ impl RetryState {
expires_at_ms: None,
}
}
/// Calculate the backoff delay in milliseconds for the current retry count.
///
/// Uses exponential backoff: `base_interval_ms * 2^retry_count`,
/// capped at `MAX_BACKOFF_MS`.
pub fn backoff_ms(&self, base_interval_ms: u64, max_backoff_ms: u64) -> u64 {
let multiplier = 1u64.checked_shl(self.retry_count).unwrap_or(u64::MAX);
base_interval_ms
.saturating_mul(multiplier)
.min(max_backoff_ms)
}
}
impl Node {
@@ -93,7 +83,7 @@ impl Node {
self.retry_pending.remove(&node_addr);
return;
}
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms);
state.retry_after_ms = now_ms + delay;
debug!(
peer = %peer_name,
@@ -118,7 +108,7 @@ impl Node {
let mut state = RetryState::new(pc);
state.retry_count = 1;
state.reconnect = true;
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms);
state.retry_after_ms = now_ms + delay;
debug!(
peer = %self.peer_display_name(&node_addr),
@@ -175,7 +165,7 @@ impl Node {
if let Some(state) = self.retry_pending.get_mut(&node_addr) {
state.reconnect = true;
state.retry_count += 1;
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms);
state.retry_after_ms = now_ms + delay;
debug!(
peer = %peer_name,
@@ -188,7 +178,7 @@ impl Node {
let mut state = RetryState::new(pc);
state.reconnect = true;
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
let delay = backoff_ms(state.retry_count, base_interval_ms, max_backoff_ms);
state.retry_after_ms = now_ms + delay;
debug!(
@@ -343,75 +333,3 @@ impl Node {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::PeerConfig;
const TEST_MAX_BACKOFF_MS: u64 = 300_000;
#[test]
fn test_backoff_exponential() {
let state = RetryState {
peer_config: PeerConfig::default(),
retry_count: 0,
retry_after_ms: 0,
reconnect: false,
expires_at_ms: None,
};
// base = 5000ms
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 5000); // 5s * 2^0
let state = RetryState {
retry_count: 1,
..state
};
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 10_000); // 5s * 2^1
let state = RetryState {
retry_count: 2,
..state
};
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 20_000); // 5s * 2^2
let state = RetryState {
retry_count: 3,
..state
};
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 40_000); // 5s * 2^3
let state = RetryState {
retry_count: 4,
..state
};
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 80_000); // 5s * 2^4
}
#[test]
fn test_backoff_cap() {
let state = RetryState {
peer_config: PeerConfig::default(),
retry_count: 20, // 2^20 * 5000 would be huge
retry_after_ms: 0,
reconnect: false,
expires_at_ms: None,
};
assert_eq!(
state.backoff_ms(5000, TEST_MAX_BACKOFF_MS),
TEST_MAX_BACKOFF_MS
);
}
#[test]
fn test_backoff_zero_base() {
let state = RetryState {
peer_config: PeerConfig::default(),
retry_count: 3,
retry_after_ms: 0,
reconnect: false,
expires_at_ms: None,
};
assert_eq!(state.backoff_ms(0, TEST_MAX_BACKOFF_MS), 0);
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
use super::spanning_tree::*;
use super::*;
use crate::protocol::{Disconnect, DisconnectReason};
use crate::proto::fmp::{Disconnect, DisconnectReason};
/// 3-node chain: middle node disconnects one peer.
///
@@ -295,7 +295,7 @@ async fn test_disconnect_clears_session() {
);
// Node 0 sends Disconnect to node 1.
let disconnect = crate::protocol::Disconnect::new(DisconnectReason::Shutdown);
let disconnect = crate::proto::fmp::Disconnect::new(DisconnectReason::Shutdown);
nodes[0]
.node
.send_encrypted_link_message(&node1_addr, &disconnect.encode())
+998
View File
@@ -0,0 +1,998 @@
//! Characterization tests for the inbound-handshake (`handle_msg1`) establish
//! branches.
//!
//! These lock in the *current* observable behavior of the undertested inbound
//! classification paths by driving a REAL framed msg1 into `handle_msg1`
//! (constructing a genuine Noise IK msg1 and delivering it as a
//! `ReceivedPacket`), rather than poking rekey state directly the way the
//! `arm_rekey` helper does. They are an oracle for a later behavior-neutral
//! refactor: assertions capture what happens today, surprising or not.
//!
//! Coverage map (branch → test):
//! * epoch-restart → `chartest_msg1_epoch_restart_replaces_active_peer`
//! * duplicate (pre-crypto) → `chartest_msg1_duplicate_pending_resends_stored_msg2`
//! * duplicate (post-crypto) → `chartest_msg1_duplicate_active_same_epoch_resends_stored_msg2`
//! * cross-connection precedence→ `chartest_msg1_inbound_promote_defers_pending_outbound_to_same_identity`
//! * max-peers cap (bypass) → `chartest_msg1_at_cap_with_pending_outbound_bypasses_early_gate`
//! * tie-break (winner+loser) → `chartest_cross_connection_tiebreak_winner_and_loser`
//! * rekey-responder → `chartest_msg1_rekey_responder_stores_pending_session`
//! * rekey dual-init (we win) → `chartest_msg1_rekey_dual_init_we_win_drops_their_msg1`
//! * rekey dual-init (we lose) → `chartest_msg1_rekey_dual_init_we_lose_becomes_responder`
//!
//! The three rekey branches sit behind the hardcoded
//! `existing_session_age_secs >= 30` guard in `handle_msg1`, resolved from
//! `ActivePeer::session_established_at()` (a monotonic `std::time::Instant`
//! with no natural test seam — the field is private and `tokio::time` cannot
//! advance a std `Instant`). They are unblocked by the sole `#[cfg(test)]`
//! production seam `ActivePeer::test_backdate_session_established(age)`, which
//! only shifts that private timestamp — it changes no decision logic and no
//! threshold, and is compiled out of release builds.
use super::*;
use crate::config::UdpConfig;
use crate::noise::HandshakeState;
use crate::peer::ActivePeer;
use crate::transport::udp::UdpTransport;
use crate::transport::{TransportHandle, packet_channel};
use tokio::time::timeout;
/// Build a genuine wire-format Noise IK msg1 addressed to `node`, carrying a
/// chosen startup `epoch` and `sender_index`, from `sender`'s identity. Returns
/// the opaque wire bytes ready to place in a `ReceivedPacket`.
fn craft_msg1_wire(
node: &Node,
sender: &Identity,
epoch: [u8; 8],
sender_index: SessionIndex,
ts: u64,
) -> Vec<u8> {
use crate::node::wire::build_msg1;
let peer_b_identity = PeerIdentity::from_pubkey_full(node.identity().pubkey_full());
let link_id = LinkId::new(0x0BAD_C0DE);
let mut conn = PeerConnection::outbound(link_id, peer_b_identity, ts);
let noise_msg1 = conn
.start_handshake(sender.keypair(), epoch, ts)
.expect("start_handshake produces noise msg1");
build_msg1(sender_index, &noise_msg1)
}
/// Register a real UDP transport on `node` and return an independent socket
/// (plus its addr) that plays the peer: the node's msg2 responses are sent to
/// this addr, so a test can observe wire-level output.
async fn register_udp_with_peer_socket(
node: &mut Node,
transport_id: TransportId,
) -> (tokio::net::UdpSocket, TransportAddr) {
let peer_sock = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("bind peer socket");
let peer_addr = TransportAddr::from_string(&peer_sock.local_addr().unwrap().to_string());
let cfg = UdpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
mtu: Some(1280),
..Default::default()
};
let (tx, _rx) = packet_channel(64);
let mut transport = UdpTransport::new(transport_id, None, cfg, tx);
transport.start_async().await.unwrap();
node.transports
.insert(transport_id, TransportHandle::Udp(transport));
(peer_sock, peer_addr)
}
/// Local re-impl of the `unit.rs` dummy-peer injector (that one is private to
/// its module). Fills the peer table with distinct identities so cap tests can
/// reach saturation.
fn inject_dummy_peers(node: &mut Node, count: usize) {
for i in 0..count {
let identity = make_peer_identity();
let addr = *identity.node_addr();
let peer = ActivePeer::new(identity, LinkId::new((i + 1) as u64), 0);
node.peers.insert(addr, peer);
}
}
/// Epoch-restart: an inbound msg1 from an already-active peer that carries a
/// DIFFERENT startup epoch is treated as a peer restart. The stale peer is torn
/// down and the fresh handshake is promoted in its place.
///
/// Oracle: after the push, the identity is still present but is a NEW peer —
/// it now holds a live Noise session (the stale one had none), its stored
/// remote epoch has advanced to the restart value, and it occupies a fresh link
/// and session index. `schedule_reconnect` is a no-op under a bare test config
/// (no auto-connect peer configured), so `retry_pending` stays empty.
#[tokio::test]
async fn chartest_msg1_epoch_restart_replaces_active_peer() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
let sender = Identity::generate();
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
let sender_addr = *sender_pid.node_addr();
let old_epoch = [1u8; 8];
let new_epoch = [2u8; 8];
let old_link = LinkId::new(4242);
// Pre-existing active peer at the OLD epoch, sessionless. `current_addr`
// makes `should_admit_msg1` recognize the source as an established peer.
let mut old_peer = ActivePeer::new(sender_pid, old_link, 1000);
old_peer.set_remote_epoch(Some(old_epoch));
old_peer.set_current_addr(transport_id, peer_addr.clone());
node.peers.insert(sender_addr, old_peer);
assert!(!node.get_peer(&sender_addr).unwrap().has_session());
assert_eq!(
node.get_peer(&sender_addr).unwrap().remote_epoch(),
Some(old_epoch)
);
// Real msg1 carrying the NEW (restart) epoch.
let data = craft_msg1_wire(&node, &sender, new_epoch, SessionIndex::new(0x77), 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
let peer = node
.get_peer(&sender_addr)
.expect("restarted peer must remain present (replaced, not dropped)");
assert!(
peer.has_session(),
"restart promotes a fresh handshake, so the new peer holds a session"
);
assert_eq!(
peer.remote_epoch(),
Some(new_epoch),
"stored remote epoch advances to the restart value"
);
assert_ne!(
peer.link_id(),
old_link,
"restart replaces the link with the freshly allocated one"
);
let our_index = peer.our_index().expect("promoted peer has our_index");
assert!(
node.peers_by_index
.contains_key(&(transport_id, our_index.as_u32())),
"fresh session index registered in peers_by_index"
);
assert_eq!(node.peer_count(), 1, "old peer removed, new peer added");
assert!(
node.retry_pending.is_empty(),
"schedule_reconnect is a no-op with no auto-connect config"
);
// A msg2 response was emitted to the restarting peer.
let mut buf = [0u8; 2048];
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
assert!(
got.is_ok(),
"restart path must emit a msg2 response to the peer"
);
}
/// Duplicate msg1, pre-crypto short-circuit: a second msg1 from an address that
/// already has a genuinely-pending (not yet promoted) inbound link resends the
/// stored msg2 without paying the crypto cost or touching registry state.
///
/// Oracle: the exact stored msg2 bytes are resent verbatim, nothing is
/// promoted, the pending connection is left intact, and the msg1 rate limiter
/// rebalances (start then complete) to baseline.
#[tokio::test]
async fn chartest_msg1_duplicate_pending_resends_stored_msg2() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
// A pending inbound connection with a stored msg2, keyed in addr_to_link,
// NOT promoted to an active peer.
let link_id = node.allocate_link_id();
let mut conn =
PeerConnection::inbound_with_transport(link_id, transport_id, peer_addr.clone(), 1000);
let stored_msg2 = vec![0xC1, 0xC2, 0xC3, 0xC4, 0xC5];
conn.set_handshake_msg2(stored_msg2.clone());
let link = Link::connectionless(
link_id,
transport_id,
peer_addr.clone(),
LinkDirection::Inbound,
Duration::from_millis(100),
);
node.links.insert(link_id, link);
node.addr_to_link
.insert((transport_id, peer_addr.clone()), link_id);
node.connections.insert(link_id, conn);
assert_eq!(node.peer_count(), 0);
let before_pending = node.msg1_rate_limiter.pending_count();
// Duplicate msg1 from the same address. Content is irrelevant past a valid
// header — the pre-crypto branch fires before any decrypt.
let sender = Identity::generate();
let data = craft_msg1_wire(&node, &sender, [9u8; 8], SessionIndex::new(5), 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
let mut buf = [0u8; 2048];
let (n, _) = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf))
.await
.expect("stored msg2 must be resent")
.expect("recv_from");
assert_eq!(
&buf[..n],
&stored_msg2[..],
"the exact stored msg2 is resent for a duplicate msg1"
);
assert_eq!(node.peer_count(), 0, "duplicate msg1 promotes nothing");
assert!(
node.connections.contains_key(&link_id),
"pending connection is left intact"
);
assert_eq!(
node.msg1_rate_limiter.pending_count(),
before_pending,
"rate limiter rebalances to baseline"
);
}
/// Duplicate msg1, post-crypto same-epoch path: an inbound msg1 from an active
/// peer at the SAME epoch, on a session too young (< 30s) to be a rekey, is a
/// duplicate. The peer's stored msg2 is resent.
///
/// Oracle: the active peer's stored msg2 is resent, no new peer or session
/// index is allocated, and the existing peer is untouched.
#[tokio::test]
async fn chartest_msg1_duplicate_active_same_epoch_resends_stored_msg2() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
let sender = Identity::generate();
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
let sender_addr = *sender_pid.node_addr();
let epoch = [7u8; 8];
let stored_msg2 = vec![0xD0, 0xD1, 0xD2, 0xD3];
let link_id = LinkId::new(555);
let mut peer = ActivePeer::new(sender_pid, link_id, 1000);
peer.set_remote_epoch(Some(epoch));
peer.set_current_addr(transport_id, peer_addr.clone());
peer.set_handshake_msg2(stored_msg2.clone());
node.peers.insert(sender_addr, peer);
// Session age is ~0 (< 30s) → the rekey gate is false → a same-epoch msg1
// classifies as a duplicate, not a rekey initiation.
assert!(!node.get_peer(&sender_addr).unwrap().has_session());
let data = craft_msg1_wire(&node, &sender, epoch, SessionIndex::new(0x33), 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
let mut buf = [0u8; 2048];
let (n, _) = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf))
.await
.expect("stored msg2 must be resent")
.expect("recv_from");
assert_eq!(&buf[..n], &stored_msg2[..]);
assert_eq!(node.peer_count(), 1);
assert_eq!(
node.get_peer(&sender_addr).unwrap().link_id(),
link_id,
"existing peer untouched by a duplicate msg1"
);
assert!(
node.peers_by_index.is_empty(),
"no new session index allocated on the duplicate path"
);
}
/// Cross-connection precedence: an inbound establish that promotes a peer while
/// a concurrent PENDING OUTBOUND connection to the SAME identity exists must NOT
/// tear that outbound down — it is deferred (kept alive so its later msg2 can
/// update `their_index` on the promoted peer).
///
/// Oracle: the inbound msg1 promotes the peer (with a live session), and both
/// the pending outbound connection and its `pending_outbound` index entry are
/// preserved.
#[tokio::test]
async fn chartest_msg1_inbound_promote_defers_pending_outbound_to_same_identity() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let (_peer_sock, inbound_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
let sender = Identity::generate();
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
let sender_addr = *sender_pid.node_addr();
// A concurrent pending OUTBOUND connection to the same identity, at a
// different source address.
let out_link = node.allocate_link_id();
let out_addr = TransportAddr::from_string("10.0.0.9:2121");
let mut out_conn = PeerConnection::outbound(out_link, sender_pid, 1000);
let our_keypair = node.identity().keypair();
let _ = out_conn
.start_handshake(our_keypair, node.startup_epoch(), 1000)
.unwrap();
let out_index = node.index_allocator.allocate().unwrap();
out_conn.set_our_index(out_index);
out_conn.set_transport_id(transport_id);
out_conn.set_source_addr(out_addr.clone());
let out_l = Link::connectionless(
out_link,
transport_id,
out_addr.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
);
node.links.insert(out_link, out_l);
node.addr_to_link
.insert((transport_id, out_addr.clone()), out_link);
node.connections.insert(out_link, out_conn);
node.pending_outbound
.insert((transport_id, out_index.as_u32()), out_link);
assert_eq!(node.peer_count(), 0);
// Inbound msg1 from the same identity, different source addr.
let data = craft_msg1_wire(&node, &sender, [3u8; 8], SessionIndex::new(0x22), 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: inbound_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
let peer = node
.get_peer(&sender_addr)
.expect("inbound establish must promote the peer");
assert!(peer.has_session());
assert_eq!(node.peer_count(), 1);
assert!(
node.connections.contains_key(&out_link),
"pending outbound to the same identity must be preserved (deferred cleanup)"
);
assert!(
node.pending_outbound
.contains_key(&(transport_id, out_index.as_u32())),
"the outbound pending_outbound entry is preserved for msg2 index-learning"
);
}
/// Max-peers cap, pending-outbound bypass: at saturation, a msg1 from a NEW
/// identity that already has a pending outbound to it is NOT silent-dropped by
/// the early cap gate — it proceeds far enough to emit a msg2, then the late gate
/// inside `promote_connection` rejects it (peer table is full).
///
/// Oracle discriminator vs. the plain new-peer silent-drop: a msg2 IS observed
/// on the wire (the early gate was bypassed), yet the peer is NOT promoted (the
/// late gate rejects). This locks in the asymmetry between the two cap gates.
#[tokio::test]
async fn chartest_msg1_at_cap_with_pending_outbound_bypasses_early_gate() {
let mut node = make_node_with_max_peers(2);
let transport_id = TransportId::new(1);
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
inject_dummy_peers(&mut node, 2);
assert_eq!(node.peer_count(), 2, "precondition: at cap");
let sender = Identity::generate();
let sender_pid = PeerIdentity::from_pubkey_full(sender.pubkey_full());
let sender_addr = *sender_pid.node_addr();
// A pending outbound to the (new) sender identity — this sets
// `has_pending_outbound_to_peer`, which turns off the early silent-drop.
let out_link = node.allocate_link_id();
let out_addr = TransportAddr::from_string("10.0.0.9:2121");
let mut out_conn = PeerConnection::outbound(out_link, sender_pid, 1000);
let our_keypair = node.identity().keypair();
let _ = out_conn
.start_handshake(our_keypair, node.startup_epoch(), 1000)
.unwrap();
let out_index = node.index_allocator.allocate().unwrap();
out_conn.set_our_index(out_index);
out_conn.set_transport_id(transport_id);
out_conn.set_source_addr(out_addr.clone());
node.connections.insert(out_link, out_conn);
node.pending_outbound
.insert((transport_id, out_index.as_u32()), out_link);
let data = craft_msg1_wire(&node, &sender, [4u8; 8], SessionIndex::new(0x44), 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
// Late gate rejects: still at cap, sender not promoted.
assert_eq!(
node.peer_count(),
2,
"late cap gate rejects the new identity"
);
assert!(
!node.peers.contains_key(&sender_addr),
"new identity is not adopted at capacity"
);
// But the early gate was bypassed: a msg2 WAS put on the wire before the
// late-gate rejection (the discriminator against the plain silent-drop).
let mut buf = [0u8; 2048];
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
assert!(
got.is_ok() && got.unwrap().is_ok(),
"pending-outbound identity bypasses the early silent-drop, so a msg2 \
is emitted before the late cap gate rejects"
);
}
/// Cross-connection tie-break, winner AND loser in one deterministic run: both
/// nodes initiate to each other (simultaneous cross-connection). The rule is
/// "the smaller node_addr's OUTBOUND wins" (`cross_connection_winner`). After
/// both sides exchange msg1 (promote inbound) and msg2 (resolve), the winner has
/// swapped to its outbound session index while the loser keeps the inbound index
/// it assigned during its own msg1 handling.
///
/// Oracle: the smaller-addr node's peer.our_index equals the OUTBOUND index it
/// allocated at setup; the larger-addr node's peer.our_index equals the INBOUND
/// index it assigned while promoting the peer's msg1.
#[tokio::test]
async fn chartest_cross_connection_tiebreak_winner_and_loser() {
use crate::node::wire::build_msg1;
let mut node_a = make_node();
let mut node_b = make_node();
let transport_id_a = TransportId::new(1);
let transport_id_b = TransportId::new(1);
let udp_config = UdpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
mtu: Some(1280),
..Default::default()
};
let (packet_tx_a, mut packet_rx_a) = packet_channel(64);
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
transport_a.start_async().await.unwrap();
transport_b.start_async().await.unwrap();
let addr_a = transport_a.local_addr().unwrap();
let addr_b = transport_b.local_addr().unwrap();
let remote_addr_b = TransportAddr::from_string(&addr_b.to_string());
let remote_addr_a = TransportAddr::from_string(&addr_a.to_string());
node_a
.transports
.insert(transport_id_a, TransportHandle::Udp(transport_a));
node_b
.transports
.insert(transport_id_b, TransportHandle::Udp(transport_b));
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
let peer_a_identity = PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full());
let node_a_addr = *node_a.node_addr();
let node_b_addr = *node_b.node_addr();
// A initiates to B.
let link_a_out = node_a.allocate_link_id();
let mut conn_a = PeerConnection::outbound(link_a_out, peer_b_identity, 1000);
let out_index_a = node_a.index_allocator.allocate().unwrap();
let noise_msg1_a = conn_a
.start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000)
.unwrap();
conn_a.set_our_index(out_index_a);
conn_a.set_transport_id(transport_id_a);
conn_a.set_source_addr(remote_addr_b.clone());
let wire_msg1_a = build_msg1(out_index_a, &noise_msg1_a);
node_a.links.insert(
link_a_out,
Link::connectionless(
link_a_out,
transport_id_a,
remote_addr_b.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
),
);
node_a
.addr_to_link
.insert((transport_id_a, remote_addr_b.clone()), link_a_out);
node_a.connections.insert(link_a_out, conn_a);
node_a
.pending_outbound
.insert((transport_id_a, out_index_a.as_u32()), link_a_out);
// B initiates to A.
let link_b_out = node_b.allocate_link_id();
let mut conn_b = PeerConnection::outbound(link_b_out, peer_a_identity, 1000);
let out_index_b = node_b.index_allocator.allocate().unwrap();
let noise_msg1_b = conn_b
.start_handshake(node_b.identity().keypair(), node_b.startup_epoch(), 1000)
.unwrap();
conn_b.set_our_index(out_index_b);
conn_b.set_transport_id(transport_id_b);
conn_b.set_source_addr(remote_addr_a.clone());
let wire_msg1_b = build_msg1(out_index_b, &noise_msg1_b);
node_b.links.insert(
link_b_out,
Link::connectionless(
link_b_out,
transport_id_b,
remote_addr_a.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
),
);
node_b
.addr_to_link
.insert((transport_id_b, remote_addr_a.clone()), link_b_out);
node_b.connections.insert(link_b_out, conn_b);
node_b
.pending_outbound
.insert((transport_id_b, out_index_b.as_u32()), link_b_out);
// Both put msg1 on the wire.
node_a
.transports
.get(&transport_id_a)
.unwrap()
.send(&remote_addr_b, &wire_msg1_a)
.await
.unwrap();
node_b
.transports
.get(&transport_id_b)
.unwrap()
.send(&remote_addr_a, &wire_msg1_b)
.await
.unwrap();
// Each processes the other's msg1 (promotes inbound, assigns an inbound index).
let pkt_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await
.unwrap()
.unwrap();
node_b.handle_msg1(pkt_at_b).await;
let pkt_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
.await
.unwrap()
.unwrap();
node_a.handle_msg1(pkt_at_a).await;
// Inbound indices assigned during promotion (before resolution).
let inbound_index_a = node_a.get_peer(&node_b_addr).unwrap().our_index().unwrap();
let inbound_index_b = node_b.get_peer(&node_a_addr).unwrap().our_index().unwrap();
// Each processes the other's msg2 (cross-connection resolution).
let msg2_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
.await
.unwrap()
.unwrap();
node_a.handle_msg2(msg2_at_a).await;
let msg2_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await
.unwrap()
.unwrap();
node_b.handle_msg2(msg2_at_b).await;
// Rule: smaller node_addr's OUTBOUND wins → that node swaps to its outbound
// index; the larger node keeps the inbound index from its own msg1 handling.
let a_is_winner = node_a_addr < node_b_addr;
let final_our_index_a = node_a.get_peer(&node_b_addr).unwrap().our_index().unwrap();
let final_our_index_b = node_b.get_peer(&node_a_addr).unwrap().our_index().unwrap();
if a_is_winner {
assert_eq!(
final_our_index_a, out_index_a,
"winner (smaller addr) swaps to its outbound session index"
);
assert_eq!(
final_our_index_b, inbound_index_b,
"loser (larger addr) keeps the inbound index from its msg1 handling"
);
} else {
assert_eq!(
final_our_index_b, out_index_b,
"winner (smaller addr) swaps to its outbound session index"
);
assert_eq!(
final_our_index_a, inbound_index_a,
"loser (larger addr) keeps the inbound index from its msg1 handling"
);
}
// Both remain single, sendable peers after resolution.
assert_eq!(node_a.peer_count(), 1);
assert_eq!(node_b.peer_count(), 1);
assert!(node_a.get_peer(&node_b_addr).unwrap().can_send());
assert!(node_b.get_peer(&node_a_addr).unwrap().can_send());
for (_, t) in node_a.transports.iter_mut() {
t.stop().await.ok();
}
for (_, t) in node_b.transports.iter_mut() {
t.stop().await.ok();
}
}
// ===========================================================================
// Rekey establish branches (unblocked by the `#[cfg(test)]`
// `ActivePeer::test_backdate_session_established` seam that lets a test age a
// real session past the hardcoded 30s rekey gate in `handle_msg1`).
// ===========================================================================
/// Drive a real inbound msg1 through `handle_msg1` so `node` promotes an active
/// peer for `sender` at startup `epoch`, draining the msg2 the promotion emits.
/// Returns the sender's NodeAddr.
async fn establish_active_peer_via_msg1(
node: &mut Node,
sender: &Identity,
epoch: [u8; 8],
transport_id: TransportId,
peer_addr: &TransportAddr,
peer_sock: &tokio::net::UdpSocket,
ts: u64,
) -> NodeAddr {
let sender_addr = *PeerIdentity::from_pubkey_full(sender.pubkey_full()).node_addr();
let data = craft_msg1_wire(node, sender, epoch, SessionIndex::new(0x01), ts);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: ts,
};
node.handle_msg1(packet).await;
// Promotion emits a msg2 AND an initial TreeAnnounce; drain every queued
// datagram so a later recv observes only the rekey response (or its
// absence), never a leftover from establishment.
let mut buf = [0u8; 2048];
while timeout(Duration::from_millis(150), peer_sock.recv_from(&mut buf))
.await
.is_ok()
{}
sender_addr
}
/// Draw a fresh sender identity whose NodeAddr is greater-than (`want_greater`)
/// or less-than the node's own NodeAddr, so the dual-init tie-break outcome is
/// deterministic. The comparison invariant is enforced, so the test outcome is
/// deterministic even though the identity draw is random.
fn sender_with_addr_relation(node: &Node, want_greater: bool) -> Identity {
let node_addr = *node.node_addr();
loop {
let s = Identity::generate();
let a = *PeerIdentity::from_pubkey_full(s.pubkey_full()).node_addr();
if a != node_addr && (a > node_addr) == want_greater {
return s;
}
}
}
/// Arm a local in-flight (initiator) rekey on `node`'s peer for `sender`, with a
/// real allocated index registered in `peers_by_index`/`pending_outbound` (as a
/// genuine in-flight rekey would be). Returns the armed rekey index.
fn arm_local_rekey(
node: &mut Node,
sender: &Identity,
sender_addr: &NodeAddr,
transport_id: TransportId,
) -> SessionIndex {
let rekey_index = node.index_allocator.allocate().unwrap();
node.peers_by_index
.insert((transport_id, rekey_index.as_u32()), *sender_addr);
node.pending_outbound
.insert((transport_id, rekey_index.as_u32()), LinkId::new(0xF00D));
let local = Identity::generate();
let hs = HandshakeState::new_initiator(local.keypair(), sender.pubkey_full());
node.get_peer_mut(sender_addr)
.unwrap()
.set_rekey_state(hs, rekey_index, vec![0xAB; 64], 0);
rekey_index
}
/// Rekey-responder: a genuine rekey msg1 (a fresh IK handshake at the SAME
/// epoch) arriving for an active peer whose session is past the 30s gate is
/// processed as a rekey. The responder extracts the new session and holds it as
/// PENDING (awaiting K-bit cutover) without disturbing the live session.
///
/// Oracle: the new session lands in `pending_new_session` with a freshly
/// allocated `pending_our_index` and `pending_their_index` == the rekey msg1
/// sender index; the current session/index stay live and registered; the new
/// index is additionally registered in `peers_by_index`; and a rekey msg2 is
/// emitted. The peer is neither replaced nor left `rekey_in_progress`.
#[tokio::test]
async fn chartest_msg1_rekey_responder_stores_pending_session() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
let sender = Identity::generate();
let epoch = [5u8; 8];
let sender_addr = establish_active_peer_via_msg1(
&mut node,
&sender,
epoch,
transport_id,
&peer_addr,
&peer_sock,
1000,
)
.await;
let old_index = {
let p = node.get_peer(&sender_addr).expect("peer established");
assert!(p.has_session());
assert!(p.is_healthy());
assert_eq!(p.remote_epoch(), Some(epoch));
assert!(!p.rekey_in_progress());
assert!(p.pending_new_session().is_none());
p.our_index().unwrap()
};
assert!(
node.peers_by_index
.contains_key(&(transport_id, old_index.as_u32()))
);
let index_count_before = node.index_allocator.count();
// Age the live session past the 30s rekey gate (test-only seam).
node.get_peer_mut(&sender_addr)
.unwrap()
.test_backdate_session_established(Duration::from_secs(31));
// A genuine rekey msg1 (fresh IK handshake, SAME epoch) arrives.
let rekey_sender_index = SessionIndex::new(0xBEEF);
let data = craft_msg1_wire(&node, &sender, epoch, rekey_sender_index, 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
let p = node
.get_peer(&sender_addr)
.expect("peer still present (not replaced)");
assert_eq!(
node.peer_count(),
1,
"rekey neither adds nor replaces the peer"
);
assert!(
p.pending_new_session().is_some(),
"new session held as pending"
);
let new_index = p.pending_our_index().expect("pending our_index allocated");
assert_eq!(
p.pending_their_index(),
Some(rekey_sender_index),
"pending their_index = the rekey msg1 sender index"
);
assert!(
!p.rekey_in_progress(),
"set_pending_session clears rekey_in_progress"
);
assert_eq!(
p.our_index(),
Some(old_index),
"current session index stays live until cutover"
);
assert!(p.has_session(), "current session remains live");
assert!(
node.peers_by_index
.contains_key(&(transport_id, old_index.as_u32())),
"current index still registered"
);
assert!(
node.peers_by_index
.contains_key(&(transport_id, new_index.as_u32())),
"new pending index registered"
);
assert_eq!(
node.index_allocator.count(),
index_count_before + 1,
"exactly one extra index allocated for the pending session"
);
let mut buf = [0u8; 2048];
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
assert!(
got.is_ok() && got.unwrap().is_ok(),
"rekey responder emits a rekey msg2"
);
}
/// Rekey dual-init, WE WIN: with a local rekey in flight, a simultaneous rekey
/// msg1 arrives from a peer whose NodeAddr is larger than ours. The tie-break
/// ("smaller NodeAddr wins as initiator") makes us the winner, so we DROP their
/// msg1 and keep driving our own rekey.
///
/// Oracle: our in-flight rekey is untouched (`rekey_in_progress` stays true, our
/// rekey index retained and still registered), no responder session is stored,
/// no responder index is allocated, and no rekey msg2 is emitted.
#[tokio::test]
async fn chartest_msg1_rekey_dual_init_we_win_drops_their_msg1() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
// We win when our node_addr < peer's → pick a sender greater than us.
let sender = sender_with_addr_relation(&node, true);
let epoch = [6u8; 8];
let sender_addr = establish_active_peer_via_msg1(
&mut node,
&sender,
epoch,
transport_id,
&peer_addr,
&peer_sock,
1000,
)
.await;
assert!(
*node.node_addr() < sender_addr,
"precondition: node wins the tie-break"
);
node.get_peer_mut(&sender_addr)
.unwrap()
.test_backdate_session_established(Duration::from_secs(31));
let rekey_index = arm_local_rekey(&mut node, &sender, &sender_addr, transport_id);
assert!(node.get_peer(&sender_addr).unwrap().rekey_in_progress());
let index_count_before = node.index_allocator.count();
// Their simultaneous rekey msg1 arrives.
let data = craft_msg1_wire(&node, &sender, epoch, SessionIndex::new(0xAAAA), 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
let p = node.get_peer(&sender_addr).expect("peer present");
assert!(
p.rekey_in_progress(),
"our rekey survives; the winner does not abandon it"
);
assert!(
p.pending_new_session().is_none(),
"no responder session stored on the winner path"
);
assert_eq!(
p.rekey_our_index(),
Some(rekey_index),
"our rekey index retained"
);
assert!(
node.peers_by_index
.contains_key(&(transport_id, rekey_index.as_u32())),
"our rekey index still registered in peers_by_index"
);
assert!(
node.pending_outbound
.contains_key(&(transport_id, rekey_index.as_u32())),
"our rekey pending_outbound entry retained"
);
assert_eq!(
node.index_allocator.count(),
index_count_before,
"no responder index allocated on the winner path"
);
let mut buf = [0u8; 2048];
let got = timeout(Duration::from_millis(300), peer_sock.recv_from(&mut buf)).await;
assert!(
got.is_err(),
"winner emits no msg2 in response to the dropped rekey msg1"
);
}
/// Rekey dual-init, WE LOSE: with a local rekey in flight, a simultaneous rekey
/// msg1 arrives from a peer whose NodeAddr is smaller than ours. The tie-break
/// makes us the loser, so we ABANDON our own rekey and respond as the rekey
/// responder.
///
/// Oracle: our in-flight rekey is abandoned (`rekey_in_progress` cleared, the
/// abandoned index freed and unregistered from `pending_outbound`), the new
/// session is stored as pending with `pending_their_index` == the rekey msg1
/// sender index, the pending index is registered, and a rekey msg2 is emitted.
#[tokio::test]
async fn chartest_msg1_rekey_dual_init_we_lose_becomes_responder() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let (peer_sock, peer_addr) = register_udp_with_peer_socket(&mut node, transport_id).await;
// We lose when our node_addr > peer's → pick a sender smaller than us.
let sender = sender_with_addr_relation(&node, false);
let epoch = [6u8; 8];
let sender_addr = establish_active_peer_via_msg1(
&mut node,
&sender,
epoch,
transport_id,
&peer_addr,
&peer_sock,
1000,
)
.await;
assert!(
*node.node_addr() > sender_addr,
"precondition: node loses the tie-break"
);
node.get_peer_mut(&sender_addr)
.unwrap()
.test_backdate_session_established(Duration::from_secs(31));
let rekey_index = arm_local_rekey(&mut node, &sender, &sender_addr, transport_id);
assert!(node.get_peer(&sender_addr).unwrap().rekey_in_progress());
// Their simultaneous rekey msg1 arrives.
let rekey_sender_index = SessionIndex::new(0xCCCC);
let data = craft_msg1_wire(&node, &sender, epoch, rekey_sender_index, 2000);
let packet = ReceivedPacket {
transport_id,
remote_addr: peer_addr.clone(),
data,
timestamp_ms: 2000,
};
node.handle_msg1(packet).await;
let p = node.get_peer(&sender_addr).expect("peer present");
assert!(
!p.rekey_in_progress(),
"we abandoned our rekey and became responder"
);
assert!(
p.pending_new_session().is_some(),
"responder stores the new session as pending"
);
assert_eq!(
p.pending_their_index(),
Some(rekey_sender_index),
"pending their_index = the rekey msg1 sender index"
);
let new_index = p
.pending_our_index()
.expect("responder allocated a pending index");
assert!(
!node
.pending_outbound
.contains_key(&(transport_id, rekey_index.as_u32())),
"abandoned rekey pending_outbound entry removed"
);
assert!(
node.peers_by_index
.contains_key(&(transport_id, new_index.as_u32())),
"new pending index registered"
);
let mut buf = [0u8; 2048];
let got = timeout(Duration::from_millis(500), peer_sock.recv_from(&mut buf)).await;
assert!(
got.is_ok() && got.unwrap().is_ok(),
"loser (now responder) emits a rekey msg2"
);
}
+1
View File
@@ -13,6 +13,7 @@ mod bootstrap;
mod decrypt_failure;
mod disconnect;
mod discovery;
mod establish_chartests;
#[cfg(target_os = "linux")]
mod ethernet;
mod forwarding;
+1 -1
View File
@@ -809,7 +809,7 @@ async fn test_routing_reachability_100_nodes() {
/// Node 0 should no longer be able to route to node 3.
#[tokio::test]
async fn test_routing_stops_after_peer_removal() {
use crate::protocol::{Disconnect, DisconnectReason};
use crate::proto::fmp::{Disconnect, DisconnectReason};
let edges = vec![(0, 1), (1, 2), (2, 3)];
let mut nodes = run_tree_test(4, &edges, false).await;
+3 -3
View File
@@ -1260,7 +1260,7 @@ fn test_schedule_reconnect_preserves_backoff() {
// With count=3, backoff should be 5s * 2^3 = 40s.
let base_ms = node.config().node.retry.base_interval_secs * 1000;
let max_ms = node.config().node.retry.max_backoff_secs * 1000;
let expected_delay = state.backoff_ms(base_ms, max_ms);
let expected_delay = crate::proto::fmp::backoff_ms(state.retry_count, base_ms, max_ms);
assert_eq!(
state.retry_after_ms,
31_000 + expected_delay,
@@ -1296,7 +1296,7 @@ fn test_schedule_reconnect_fresh_state() {
// Base delay: 5s * 2^0 = 5s
let base_ms = node.config().node.retry.base_interval_secs * 1000;
let max_ms = node.config().node.retry.max_backoff_secs * 1000;
let expected_delay = state.backoff_ms(base_ms, max_ms);
let expected_delay = crate::proto::fmp::backoff_ms(state.retry_count, base_ms, max_ms);
assert_eq!(state.retry_after_ms, 1_000 + expected_delay);
}
@@ -1308,7 +1308,7 @@ fn test_schedule_reconnect_fresh_state() {
/// decrypt failure, peer restart) all schedule reconnect.
#[test]
fn test_disconnect_schedules_reconnect() {
use crate::protocol::{Disconnect, DisconnectReason};
use crate::proto::fmp::{Disconnect, DisconnectReason};
let peer_identity = Identity::generate();
let peer_npub = peer_identity.npub();
+12
View File
@@ -880,6 +880,18 @@ impl ActivePeer {
self.session_established_at
}
/// Test-only seam: backdate the session-established instant so a test can
/// construct a session that reads as `age`-old. This only shifts the
/// private timestamp field; it changes no decision logic, no threshold, and
/// is compiled out of release builds.
#[cfg(test)]
pub(crate) fn test_backdate_session_established(&mut self, age: std::time::Duration) {
self.session_established_at = self
.session_established_at
.checked_sub(age)
.unwrap_or_else(Instant::now);
}
/// Per-session symmetric rekey-timer jitter offset (seconds).
///
/// Drawn at session construction and at each rekey cutover; uniform
+93 -232
View File
@@ -6,133 +6,37 @@
use crate::PeerIdentity;
use crate::noise::{self, NoiseError, NoiseSession};
use crate::proto::fmp::ConnectionState;
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
use secp256k1::Keypair;
use std::fmt;
/// Handshake protocol state machine.
///
/// For Noise IK pattern:
/// - Initiator: Initial → SentMsg1 → Complete
/// - Responder: Initial → ReceivedMsg1 → Complete
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HandshakeState {
/// Initial state, ready to start handshake.
Initial,
/// Initiator: Sent message 1, awaiting message 2.
SentMsg1,
/// Responder: Received message 1, ready to send message 2.
ReceivedMsg1,
/// Handshake completed successfully.
Complete,
/// Handshake failed.
Failed,
}
impl HandshakeState {
/// Check if handshake is still in progress.
pub fn is_in_progress(&self) -> bool {
matches!(
self,
HandshakeState::Initial | HandshakeState::SentMsg1 | HandshakeState::ReceivedMsg1
)
}
/// Check if handshake completed successfully.
pub fn is_complete(&self) -> bool {
matches!(self, HandshakeState::Complete)
}
/// Check if handshake failed.
pub fn is_failed(&self) -> bool {
matches!(self, HandshakeState::Failed)
}
}
impl fmt::Display for HandshakeState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
HandshakeState::Initial => "initial",
HandshakeState::SentMsg1 => "sent_msg1",
HandshakeState::ReceivedMsg1 => "received_msg1",
HandshakeState::Complete => "complete",
HandshakeState::Failed => "failed",
};
write!(f, "{}", s)
}
}
// The pure handshake-phase bookkeeping (`ConnectionState`) and its
// `HandshakeState` phase enum now live in `proto::fmp::state`. Re-export
// `HandshakeState` here so its original public path
// (`crate::peer::HandshakeState`) is preserved for existing call sites.
pub use crate::proto::fmp::HandshakeState;
/// A connection in the handshake phase, before authentication completes.
///
/// For outbound connections, we know the expected peer identity from config.
/// For inbound connections, we learn the identity during the Noise handshake.
///
/// This is the shell holder for the FMP crypto/state split: the pure
/// connection bookkeeping lives in [`ConnectionState`] (`proto::fmp::state`),
/// and the two Noise crypto handles stay here beside it. Pure public methods
/// delegate to `self.state`; the XX transition methods drive the crypto and
/// write results back through `self.state`'s setters.
pub struct PeerConnection {
// === Link Reference ===
/// The link carrying this connection.
link_id: LinkId,
/// Connection direction (we initiated or they initiated).
direction: LinkDirection,
// === Handshake State ===
/// Current handshake state.
handshake_state: HandshakeState,
/// Expected peer identity (known for outbound, learned for inbound).
/// Updated after receiving their static key in the handshake.
expected_identity: Option<PeerIdentity>,
/// Pure, runtime-agnostic connection bookkeeping.
state: ConnectionState,
/// Noise handshake state (consumes on completion).
noise_handshake: Option<noise::HandshakeState>,
/// Completed Noise session (available after handshake complete).
noise_session: Option<NoiseSession>,
// === Timing ===
/// When the connection attempt started (Unix milliseconds).
started_at: u64,
/// When the last handshake message was sent/received.
last_activity: u64,
// === Statistics ===
/// Link statistics during handshake.
link_stats: LinkStats,
// === Wire Protocol Index Tracking ===
/// Our sender_idx for this handshake (chosen by us).
/// For outbound: included in msg1, used as receiver_idx in msg2 echo.
/// For inbound: chosen after processing msg1, included in msg2.
our_index: Option<SessionIndex>,
/// Their sender_idx (learned from their messages).
/// For outbound: learned from msg2.
/// For inbound: learned from msg1.
their_index: Option<SessionIndex>,
/// Transport ID (for index namespace).
transport_id: Option<TransportId>,
/// Current source address (updated on packet receipt).
source_addr: Option<TransportAddr>,
// === Epoch (Restart Detection) ===
/// Remote peer's startup epoch (learned from handshake).
remote_epoch: Option<[u8; 8]>,
// === Handshake Resend ===
/// Wire-format msg1 bytes for resend (initiator only).
handshake_msg1: Option<Vec<u8>>,
/// Wire-format msg2 bytes for resend (responder only).
handshake_msg2: Option<Vec<u8>>,
/// Number of resends performed so far.
resend_count: u32,
/// When the next resend should fire (Unix ms). 0 = no resend scheduled.
next_resend_at_ms: u64,
}
impl PeerConnection {
@@ -146,25 +50,9 @@ impl PeerConnection {
current_time_ms: u64,
) -> Self {
Self {
link_id,
direction: LinkDirection::Outbound,
handshake_state: HandshakeState::Initial,
expected_identity: Some(expected_identity),
state: ConnectionState::outbound(link_id, expected_identity, current_time_ms),
noise_handshake: None,
noise_session: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: None,
source_addr: None,
remote_epoch: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
next_resend_at_ms: 0,
}
}
@@ -174,25 +62,9 @@ impl PeerConnection {
/// identity from Noise message 1.
pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self {
Self {
link_id,
direction: LinkDirection::Inbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
state: ConnectionState::inbound(link_id, current_time_ms),
noise_handshake: None,
noise_session: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: None,
source_addr: None,
remote_epoch: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
next_resend_at_ms: 0,
}
}
@@ -206,195 +78,181 @@ impl PeerConnection {
current_time_ms: u64,
) -> Self {
Self {
link_id,
direction: LinkDirection::Inbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
state: ConnectionState::inbound_with_transport(
link_id,
transport_id,
source_addr,
current_time_ms,
),
noise_handshake: None,
noise_session: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: Some(transport_id),
source_addr: Some(source_addr),
remote_epoch: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
next_resend_at_ms: 0,
}
}
// === Accessors ===
// === Accessors (delegated to the pure ConnectionState) ===
/// Get the link ID.
pub fn link_id(&self) -> LinkId {
self.link_id
self.state.link_id()
}
/// Get the connection direction.
pub fn direction(&self) -> LinkDirection {
self.direction
self.state.direction()
}
/// Get the handshake state.
pub fn handshake_state(&self) -> HandshakeState {
self.handshake_state
self.state.handshake_state()
}
/// Get the expected/learned peer identity, if known.
pub fn expected_identity(&self) -> Option<&PeerIdentity> {
self.expected_identity.as_ref()
self.state.expected_identity()
}
/// Check if this is an outbound connection.
pub fn is_outbound(&self) -> bool {
self.direction == LinkDirection::Outbound
self.state.is_outbound()
}
/// Check if this is an inbound connection.
pub fn is_inbound(&self) -> bool {
self.direction == LinkDirection::Inbound
self.state.is_inbound()
}
/// Check if handshake is in progress.
pub fn is_in_progress(&self) -> bool {
self.handshake_state.is_in_progress()
self.state.is_in_progress()
}
/// Check if handshake completed.
pub fn is_complete(&self) -> bool {
self.handshake_state.is_complete()
self.state.is_complete()
}
/// Check if handshake failed.
pub fn is_failed(&self) -> bool {
self.handshake_state.is_failed()
self.state.is_failed()
}
/// When the connection started.
pub fn started_at(&self) -> u64 {
self.started_at
self.state.started_at()
}
/// When the last activity occurred.
pub fn last_activity(&self) -> u64 {
self.last_activity
self.state.last_activity()
}
/// Connection duration so far.
pub fn duration(&self, current_time_ms: u64) -> u64 {
current_time_ms.saturating_sub(self.started_at)
self.state.duration(current_time_ms)
}
/// Time since last activity.
pub fn idle_time(&self, current_time_ms: u64) -> u64 {
current_time_ms.saturating_sub(self.last_activity)
self.state.idle_time(current_time_ms)
}
/// Get link statistics.
pub fn link_stats(&self) -> &LinkStats {
&self.link_stats
self.state.link_stats()
}
/// Get mutable link statistics.
pub fn link_stats_mut(&mut self) -> &mut LinkStats {
&mut self.link_stats
self.state.link_stats_mut()
}
// === Index Accessors ===
/// Get our session index (if set).
pub fn our_index(&self) -> Option<SessionIndex> {
self.our_index
self.state.our_index()
}
/// Set our session index.
pub fn set_our_index(&mut self, index: SessionIndex) {
self.our_index = Some(index);
self.state.set_our_index(index);
}
/// Get their session index (if known).
pub fn their_index(&self) -> Option<SessionIndex> {
self.their_index
self.state.their_index()
}
/// Set their session index.
pub fn set_their_index(&mut self, index: SessionIndex) {
self.their_index = Some(index);
self.state.set_their_index(index);
}
/// Get the transport ID (if set).
pub fn transport_id(&self) -> Option<TransportId> {
self.transport_id
self.state.transport_id()
}
/// Set the transport ID.
pub fn set_transport_id(&mut self, id: TransportId) {
self.transport_id = Some(id);
self.state.set_transport_id(id);
}
/// Get the source address (if known).
pub fn source_addr(&self) -> Option<&TransportAddr> {
self.source_addr.as_ref()
self.state.source_addr()
}
/// Set the source address.
pub fn set_source_addr(&mut self, addr: TransportAddr) {
self.source_addr = Some(addr);
self.state.set_source_addr(addr);
}
// === Epoch Accessors ===
/// Get the remote peer's startup epoch (available after handshake).
pub fn remote_epoch(&self) -> Option<[u8; 8]> {
self.remote_epoch
self.state.remote_epoch()
}
// === Handshake Resend ===
/// Store the wire-format msg1 bytes for resend and schedule the first resend.
pub fn set_handshake_msg1(&mut self, msg1: Vec<u8>, first_resend_at_ms: u64) {
self.handshake_msg1 = Some(msg1);
self.resend_count = 0;
self.next_resend_at_ms = first_resend_at_ms;
self.state.set_handshake_msg1(msg1, first_resend_at_ms);
}
/// Store the wire-format msg2 bytes for resend on duplicate msg1.
pub fn set_handshake_msg2(&mut self, msg2: Vec<u8>) {
self.handshake_msg2 = Some(msg2);
self.state.set_handshake_msg2(msg2);
}
/// Get the stored msg1 bytes (if any).
pub fn handshake_msg1(&self) -> Option<&[u8]> {
self.handshake_msg1.as_deref()
self.state.handshake_msg1()
}
/// Get the stored msg2 bytes (if any).
pub fn handshake_msg2(&self) -> Option<&[u8]> {
self.handshake_msg2.as_deref()
self.state.handshake_msg2()
}
/// Number of resends performed.
pub fn resend_count(&self) -> u32 {
self.resend_count
self.state.resend_count()
}
/// When the next resend is scheduled (Unix ms).
pub fn next_resend_at_ms(&self) -> u64 {
self.next_resend_at_ms
self.state.next_resend_at_ms()
}
/// Record a resend and schedule the next one.
pub fn record_resend(&mut self, next_resend_at_ms: u64) {
self.resend_count += 1;
self.next_resend_at_ms = next_resend_at_ms;
self.state.record_resend(next_resend_at_ms);
}
// === Noise Handshake Operations ===
// === Noise Handshake Operations (shell: drives crypto, updates pure state) ===
/// Start the handshake as initiator and generate message 1.
///
@@ -406,23 +264,23 @@ impl PeerConnection {
epoch: [u8; 8],
current_time_ms: u64,
) -> Result<Vec<u8>, NoiseError> {
if self.direction != LinkDirection::Outbound {
if self.state.direction() != LinkDirection::Outbound {
return Err(NoiseError::WrongState {
expected: "outbound connection".to_string(),
got: "inbound connection".to_string(),
});
}
if self.handshake_state != HandshakeState::Initial {
if self.state.handshake_state() != HandshakeState::Initial {
return Err(NoiseError::WrongState {
expected: "initial state".to_string(),
got: self.handshake_state.to_string(),
got: self.state.handshake_state().to_string(),
});
}
let remote_static = self
.expected_identity
.as_ref()
.state
.expected_identity()
.expect("outbound must have expected identity")
.pubkey_full();
@@ -431,8 +289,8 @@ impl PeerConnection {
let msg1 = hs.write_message_1()?;
self.noise_handshake = Some(hs);
self.handshake_state = HandshakeState::SentMsg1;
self.last_activity = current_time_ms;
self.state.set_handshake_state(HandshakeState::SentMsg1);
self.state.touch(current_time_ms);
Ok(msg1)
}
@@ -448,17 +306,17 @@ impl PeerConnection {
message: &[u8],
current_time_ms: u64,
) -> Result<Vec<u8>, NoiseError> {
if self.direction != LinkDirection::Inbound {
if self.state.direction() != LinkDirection::Inbound {
return Err(NoiseError::WrongState {
expected: "inbound connection".to_string(),
got: "outbound connection".to_string(),
});
}
if self.handshake_state != HandshakeState::Initial {
if self.state.handshake_state() != HandshakeState::Initial {
return Err(NoiseError::WrongState {
expected: "initial state".to_string(),
got: self.handshake_state.to_string(),
got: self.state.handshake_state().to_string(),
});
}
@@ -468,14 +326,16 @@ impl PeerConnection {
// Process message 1 (this reveals the initiator's identity and epoch)
hs.read_message_1(message)?;
// Extract the discovered identity
// Extract the discovered identity from the crypto and record it as
// pure data on the state.
let remote_static = *hs
.remote_static()
.expect("remote static available after msg1");
self.expected_identity = Some(PeerIdentity::from_pubkey_full(remote_static));
self.state
.set_expected_identity(PeerIdentity::from_pubkey_full(remote_static));
// Capture remote epoch from msg1
self.remote_epoch = hs.remote_epoch();
self.state.set_remote_epoch(hs.remote_epoch());
// Generate message 2
let msg2 = hs.write_message_2()?;
@@ -483,8 +343,8 @@ impl PeerConnection {
// Handshake is complete for responder
let session = hs.into_session()?;
self.noise_session = Some(session);
self.handshake_state = HandshakeState::Complete;
self.last_activity = current_time_ms;
self.state.set_handshake_state(HandshakeState::Complete);
self.state.touch(current_time_ms);
Ok(msg2)
}
@@ -497,10 +357,10 @@ impl PeerConnection {
message: &[u8],
current_time_ms: u64,
) -> Result<(), NoiseError> {
if self.handshake_state != HandshakeState::SentMsg1 {
if self.state.handshake_state() != HandshakeState::SentMsg1 {
return Err(NoiseError::WrongState {
expected: "sent_msg1 state".to_string(),
got: self.handshake_state.to_string(),
got: self.state.handshake_state().to_string(),
});
}
@@ -512,12 +372,12 @@ impl PeerConnection {
hs.read_message_2(message)?;
// Capture remote epoch from msg2
self.remote_epoch = hs.remote_epoch();
self.state.set_remote_epoch(hs.remote_epoch());
let session = hs.into_session()?;
self.noise_session = Some(session);
self.handshake_state = HandshakeState::Complete;
self.last_activity = current_time_ms;
self.state.set_handshake_state(HandshakeState::Complete);
self.state.touch(current_time_ms);
Ok(())
}
@@ -527,7 +387,7 @@ impl PeerConnection {
/// Returns the NoiseSession for use in ActivePeer. Can only be called
/// once after handshake completes.
pub fn take_session(&mut self) -> Option<NoiseSession> {
if self.handshake_state == HandshakeState::Complete {
if self.state.handshake_state() == HandshakeState::Complete {
self.noise_session.take()
} else {
None
@@ -536,44 +396,45 @@ impl PeerConnection {
/// Check if we have a completed session ready to take.
pub fn has_session(&self) -> bool {
self.handshake_state == HandshakeState::Complete && self.noise_session.is_some()
self.state.handshake_state() == HandshakeState::Complete && self.noise_session.is_some()
}
// === State Transitions (for manual control if needed) ===
/// Mark handshake as failed.
/// Mark handshake as failed. Sets the pure lifecycle state and drops the
/// shell-owned crypto handshake handle.
pub fn mark_failed(&mut self) {
self.handshake_state = HandshakeState::Failed;
self.state.mark_failed();
self.noise_handshake = None;
}
/// Update last activity timestamp.
pub fn touch(&mut self, current_time_ms: u64) {
self.last_activity = current_time_ms;
self.state.touch(current_time_ms);
}
// === Validation ===
/// Check if the connection has timed out.
pub fn is_timed_out(&self, current_time_ms: u64, timeout_ms: u64) -> bool {
self.idle_time(current_time_ms) > timeout_ms
self.state.is_timed_out(current_time_ms, timeout_ms)
}
}
impl fmt::Debug for PeerConnection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PeerConnection")
.field("link_id", &self.link_id)
.field("direction", &self.direction)
.field("handshake_state", &self.handshake_state)
.field("expected_identity", &self.expected_identity)
.field("link_id", &self.state.link_id())
.field("direction", &self.state.direction())
.field("handshake_state", &self.state.handshake_state())
.field("expected_identity", &self.state.expected_identity())
.field("has_noise_handshake", &self.noise_handshake.is_some())
.field("has_noise_session", &self.noise_session.is_some())
.field("our_index", &self.our_index)
.field("their_index", &self.their_index)
.field("transport_id", &self.transport_id)
.field("started_at", &self.started_at)
.field("last_activity", &self.last_activity)
.field("our_index", &self.state.our_index())
.field("their_index", &self.state.their_index())
.field("transport_id", &self.state.transport_id())
.field("started_at", &self.state.started_at())
.field("last_activity", &self.state.last_activity())
.finish()
}
}
-68
View File
@@ -119,35 +119,6 @@ impl PromotionResult {
}
}
/// Determine winner of cross-connection tie-breaker.
///
/// Rule: The node with the smaller node_addr prefers its OUTBOUND connection.
/// This is deterministic and symmetric: both nodes will reach the same conclusion.
///
/// # Arguments
/// * `our_node_addr` - Our node's ID
/// * `their_node_addr` - The peer's node ID
/// * `this_is_outbound` - Whether the connection being evaluated is our outbound
///
/// # Returns
/// `true` if this connection should win (survive), `false` if it should close.
pub fn cross_connection_winner(
our_node_addr: &NodeAddr,
their_node_addr: &NodeAddr,
this_is_outbound: bool,
) -> bool {
let we_are_smaller = our_node_addr < their_node_addr;
// Smaller node's outbound wins
// If we're smaller: our outbound wins, our inbound loses
// If they're smaller: our outbound loses, our inbound wins
if we_are_smaller {
this_is_outbound
} else {
!this_is_outbound
}
}
// ============================================================================
// PeerSlot
// ============================================================================
@@ -272,50 +243,11 @@ mod tests {
use crate::transport::LinkId;
use crate::{Identity, PeerIdentity};
fn make_node_addr(val: u8) -> NodeAddr {
let mut bytes = [0u8; 16];
bytes[0] = val;
NodeAddr::from_bytes(bytes)
}
fn make_peer_identity() -> PeerIdentity {
let identity = Identity::generate();
PeerIdentity::from_pubkey(identity.pubkey())
}
#[test]
fn test_cross_connection_smaller_node_wins_outbound() {
let node_a = make_node_addr(1); // smaller
let node_b = make_node_addr(2); // larger
// Node A's perspective
assert!(cross_connection_winner(&node_a, &node_b, true)); // A's outbound wins
assert!(!cross_connection_winner(&node_a, &node_b, false)); // A's inbound loses
// Node B's perspective
assert!(!cross_connection_winner(&node_b, &node_a, true)); // B's outbound loses
assert!(cross_connection_winner(&node_b, &node_a, false)); // B's inbound wins
}
#[test]
fn test_cross_connection_symmetric() {
let node_a = make_node_addr(1);
let node_b = make_node_addr(2);
// A's outbound = B's inbound
let a_outbound_wins = cross_connection_winner(&node_a, &node_b, true);
let b_inbound_wins = cross_connection_winner(&node_b, &node_a, false);
assert_eq!(a_outbound_wins, b_inbound_wins);
// A's inbound = B's outbound
let a_inbound_wins = cross_connection_winner(&node_a, &node_b, false);
let b_outbound_wins = cross_connection_winner(&node_b, &node_a, true);
assert_eq!(a_inbound_wins, b_outbound_wins);
// Exactly one survives
assert!(a_outbound_wins != a_inbound_wins);
}
#[test]
fn test_peer_slot_connecting() {
let identity = make_peer_identity();
+644
View File
@@ -0,0 +1,644 @@
//! Sans-IO FMP connection-lifecycle decision core.
//!
//! Pure, runtime-agnostic maintain/teardown decisions for the FMP peer
//! connection lifecycle: handshake-connection timeout/teardown and outbound
//! msg1 resend scheduling. The async I/O adapters in `node::handlers::timeout`
//! build a [`LifecycleView`] over live node state (pre-computing every clock
//! read into plain `u64`/`bool` snapshot fields), call the `poll_*` decisions,
//! and drive the returned [`ConnAction`]s — the actual sends, registry
//! mutations, metrics, and logging. No I/O, no clock, no metrics, no logging
//! here.
//!
//! The establish leaf's Noise wire construction and `promote_connection`
//! effects stay shell-side; handshake message bytes are carried as opaque blobs
//! only. The **inbound classification** decision, however, is modelled here:
//! [`Fmp::establish_inbound`] maps an [`EstablishSnapshot`] + [`WireOutcome`]
//! onto an [`InboundDecision`] the shell dispatches (E3). The outbound
//! (`handle_msg2`) classification and the born-on-next `handle_msg3` leaf remain
//! shell-side.
use super::state::Fmp;
use crate::transport::LinkId;
use crate::utils::index::SessionIndex;
use crate::{NodeAddr, PeerIdentity};
/// Determine winner of cross-connection tie-breaker.
///
/// Rule: The node with the smaller node_addr prefers its OUTBOUND connection.
/// This is deterministic and symmetric: both nodes will reach the same conclusion.
///
/// # Arguments
/// * `our_node_addr` - Our node's ID
/// * `their_node_addr` - The peer's node ID
/// * `this_is_outbound` - Whether the connection being evaluated is our outbound
///
/// # Returns
/// `true` if this connection should win (survive), `false` if it should close.
pub fn cross_connection_winner(
our_node_addr: &NodeAddr,
their_node_addr: &NodeAddr,
this_is_outbound: bool,
) -> bool {
let we_are_smaller = our_node_addr < their_node_addr;
// Smaller node's outbound wins
// If we're smaller: our outbound wins, our inbound loses
// If they're smaller: our outbound loses, our inbound wins
if we_are_smaller {
this_is_outbound
} else {
!this_is_outbound
}
}
/// A snapshot of one handshake connection's lifecycle-relevant state, taken by
/// the shell so the core decides without touching live `Node` state or reading
/// a clock.
///
/// Produced by the [`LifecycleView`] read-seam. Each `poll_*` decision only
/// reads the subset of fields relevant to it; the producing view method leaves
/// the rest at their defaults.
pub(crate) struct ConnSnapshot {
/// The connection's link identifier (teardown/resend target).
pub link: LinkId,
/// Teardown path: is this an outbound connection? Drives retry scheduling
/// (only outbound auto-connect peers are retried).
pub is_outbound: bool,
/// Teardown path: the retry target learned from the connection's expected
/// identity, if any. `None` when no identity is known.
pub retry_addr: Option<NodeAddr>,
/// Resend path: prior msg1 resend count. Drives the backoff exponent.
pub resend_count: u32,
/// Resend path: the stored outbound handshake msg1 wire bytes (an opaque
/// blob — the core never parses or constructs a Noise message). Empty on
/// the teardown path, which never reads it.
pub msg1: Vec<u8>,
}
/// A snapshot of one active peer's rekey-relevant state, taken by the shell.
///
/// Every clock read is resolved shell-side into a plain `u64`/`bool` before the
/// snapshot reaches the core: `elapsed_secs` is the monotonic session age, and
/// `drain_expired`/`is_dampened` are the pre-evaluated timer predicates. The
/// core applies the rekey thresholds and jitter with **no** clock read — the
/// deliberate master-side asymmetry with discovery (monotonic ages, not an
/// absolute `now_ms`), so the rekey timing stays behavior-identical under a
/// clock step.
pub(crate) struct PeerSnapshot {
/// The peer's node address (cutover/drain/rekey target).
pub addr: NodeAddr,
/// A pending post-rekey session is ready to cut over to.
pub has_pending: bool,
/// A rekey handshake is currently in flight.
pub rekey_in_progress: bool,
/// The peer is in its post-cutover drain window.
pub is_draining: bool,
/// The drain window has expired (pre-evaluated against the drain timer).
pub drain_expired: bool,
/// Local rekey initiation is dampened after a recently received peer rekey
/// msg1 (pre-evaluated against the dampening timer).
pub is_dampened: bool,
/// Monotonic session age in seconds (`session_established_at().elapsed()`).
pub elapsed_secs: u64,
/// Current Noise send counter (0 when there is no session).
pub counter: u64,
/// Per-session symmetric rekey jitter, added to the time threshold.
pub jitter_secs: i64,
}
/// A snapshot of one peer with a rekey handshake in flight, taken by the shell
/// for the rekey-msg1 retransmission decision.
pub(crate) struct RekeyResendSnapshot {
/// The peer's node address (abandon/resend target).
pub peer: NodeAddr,
/// How many rekey-msg1 retransmissions have already happened. Drives both
/// the abandon-vs-resend classification and the backoff exponent.
pub resend_count: u32,
/// The stored rekey msg1 is due for retransmission as of the shell's
/// `now_ms` (pre-evaluated against the resend timer).
pub needs_resend: bool,
/// The stored rekey msg1 wire bytes (an opaque blob).
pub msg1: Vec<u8>,
}
/// The rekey trigger thresholds, read shell-side from node config.
pub(crate) struct RekeyCfg {
/// Rekey after this many seconds of session age (before jitter).
pub after_secs: u64,
/// Rekey after this many sent messages.
pub after_messages: u64,
}
/// The result of the shell-side Noise wire step (Phase B) for one inbound
/// handshake msg1, handed to the establish decision core.
///
/// The Noise step (`receive_handshake_init`) runs shell-side on the
/// `PeerConnection`: it reads **no** `Node` registry state — the load-bearing
/// invariant of this decomposition — and yields the learned peer identity, the
/// remote startup epoch, the sender's session index, and the opaque msg2 noise
/// payload to frame and send. The core never parses or builds Noise bytes; the
/// payload is an opaque blob.
pub(crate) struct WireOutcome {
/// Peer identity learned from the handshake (msg1 static key).
pub peer_identity: PeerIdentity,
/// The peer's startup epoch captured from msg1, if present.
pub remote_epoch: Option<[u8; 8]>,
/// The sender's session index from the msg1 header (becomes our
/// `receiver_idx`/`their_index` in the msg2 response and the promotion).
pub their_index: SessionIndex,
/// The opaque Noise msg2 payload the responder produced (empty only if no
/// msg2 is to be sent).
pub msg2_payload: Vec<u8>,
}
/// A snapshot of the `Node` registry state the inbound establish decision reads
/// about the peer identified in a just-processed msg1, taken by the shell so the
/// core decides without touching live `Node` state or reading a clock.
///
/// Produced by the [`EstablishView`] read-seam. Every clock read
/// (`existing_session_age_secs`) is resolved shell-side into a plain `u64`, the
/// same monotonic-ages asymmetry the rekey snapshot uses.
pub(crate) struct EstablishSnapshot {
/// The peer is already an active peer in the registry.
pub has_existing_peer: bool,
/// The existing active peer's captured remote startup epoch, if any.
pub existing_peer_epoch: Option<[u8; 8]>,
/// Monotonic age in seconds of the existing peer's session
/// (`session_established_at().elapsed()`), resolved shell-side. `0` when
/// there is no existing peer.
pub existing_session_age_secs: u64,
/// The existing peer has an established Noise session.
pub has_session: bool,
/// The existing peer's session is healthy.
pub is_healthy: bool,
/// The existing peer already holds a pending post-rekey session awaiting
/// K-bit cutover.
pub pending_new_session: bool,
/// The existing peer has a rekey handshake in flight.
pub rekey_in_progress: bool,
/// The existing peer's stored msg2 wire bytes (an opaque blob), resent on a
/// same-epoch duplicate msg1. `None` when there is no existing peer or it
/// has no stored msg2.
pub existing_msg2: Option<Vec<u8>>,
/// Admitting this peer as a net-new identity would exceed `max_peers`
/// (pre-evaluated `max_peers > 0 && peers.len() >= max_peers`).
pub at_max_peers: bool,
/// A pending outbound connection to this same peer identity already exists
/// (a cross-connection in progress); bypasses the max-peers cap.
pub has_pending_outbound_to_peer: bool,
/// Whether the local rekey trigger is enabled in config (gates treating a
/// same-epoch msg1 from an established peer as a rekey rather than a
/// duplicate).
pub rekey_enabled: bool,
/// This node's own address, for the dual-initiation tie-break.
pub our_node_addr: NodeAddr,
}
/// A snapshot of the registry state the *outbound* establish decision reads
/// about the peer whose msg2 just completed our handshake, taken by the shell.
///
/// Both fields are pre-evaluated shell-side (the tie-break is a pure function of
/// the two node addresses, resolved into a plain `bool` here) so the core never
/// touches live `Node` state or the `crate::peer` tie-break helper.
pub(crate) struct OutboundSnapshot {
/// The peer identity is already a promoted active peer — i.e. this outbound
/// completion is a cross-connection (we also processed their msg1).
pub has_existing_peer: bool,
/// Pre-evaluated cross-connection tie-break: our *outbound* connection wins
/// (we are the smaller NodeAddr). Only meaningful when `has_existing_peer`.
pub our_outbound_wins: bool,
}
/// A registry/transport effect the async shell performs on the core's behalf.
///
/// The maintain/teardown subset (`Teardown`..`ResendRekeyMsg1`) covers the
/// tick-poll half of the lifecycle. The establish-machine subset
/// (`PromoteToActive`..) is the master-side IK handshake decision; the shell
/// executes each, resolving the ambient identity/time/wire payload it needs.
pub(crate) enum ConnAction {
/// Tear down and free the handshake connection on `link`
/// (`cleanup_stale_connection`): frees the session index, removes the
/// `pending_outbound` entry, and cleans up the link + address mapping.
Teardown { link: LinkId },
/// Schedule an auto-connect retry toward `peer` (`schedule_retry`) before
/// its failed/stale outbound connection is torn down.
ScheduleRetry { peer: NodeAddr },
/// Resend the stored handshake msg1 `bytes` on `link`, then (on a
/// successful send) record the resend and reschedule the next one at
/// `next_resend_at_ms`. The shell resolves the transport + remote address
/// from the live connection and performs the send; `bytes` is an opaque
/// blob the core neither parses nor builds.
ResendMsg1 {
link: LinkId,
bytes: Vec<u8>,
next_resend_at_ms: u64,
},
/// Perform the initiator-side K-bit cutover to `peer`'s pending session
/// (`cutover_to_new_session` + decrypt-worker re-registration).
Cutover { peer: NodeAddr },
/// Complete `peer`'s drain window: erase the previous session, free its
/// index, and unregister its decrypt-worker entry.
Drain { peer: NodeAddr },
/// Initiate a fresh outbound rekey to `peer` (`initiate_rekey`: allocates a
/// new index, builds and sends msg1, inserts `pending_outbound`). The msg1
/// construction is the establish leaf and stays shell-side; the action
/// carries only the target.
InitiateRekey { peer: NodeAddr },
/// Abandon `peer`'s in-flight rekey cycle (`abandon_rekey`): its msg1 went
/// unconfirmed past the retransmission budget.
AbandonRekey { peer: NodeAddr },
/// Retransmit `peer`'s stored rekey msg1 `bytes`, then (on a successful
/// send) record the retransmission and reschedule the next at
/// `next_resend_at_ms`. The shell resolves the transport + remote address;
/// `bytes` is an opaque blob.
ResendRekeyMsg1 {
peer: NodeAddr,
bytes: Vec<u8>,
next_resend_at_ms: u64,
},
/// Promote the completed handshake connection on `link` to an active peer
/// (`promote_connection`): moves the Noise session out of the
/// `PeerConnection`, resolves cross-connection precedence via the
/// tie-breaker, and installs the `ActivePeer`. The shell executes the
/// promotion (resolving the verified identity and promotion timestamp from
/// the ambient wire context) and then runs the post-promotion tail
/// (TreeAnnounce, bloom mark, discovery-backoff reset, loser-link cleanup).
PromoteToActive { link: LinkId },
}
/// Read-only view of FMP connection/peer state the lifecycle core needs.
///
/// The core defines this interface; the async shell (`node`) implements it over
/// the live `connections`/`peers` maps. It is a **snapshot-iterator** seam:
/// each method returns owned snapshot vectors with all clock reads already
/// resolved shell-side, so the pure decisions never borrow `Node` and never
/// read a clock. Keeping it a trait keeps `proto` free of a `node` dependency
/// and lets the decisions be unit-tested against hand-built snapshots.
pub(crate) trait LifecycleView {
/// Snapshot every handshake connection that is stale (idle past
/// `timeout_ms`) or failed, as of `now_ms`. The shell resolves the
/// timeout/failed predicate; the core decides retry-then-teardown.
fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec<ConnSnapshot>;
/// Snapshot every outbound connection whose stored msg1 is due for a
/// resend as of `now_ms` and still under `max_resends`. The shell resolves
/// the "outbound, in `SentMsg1`, has stored msg1, under budget, past the
/// scheduled time" predicate and copies the opaque msg1 bytes; the core
/// computes the backoff schedule.
fn resend_candidates(&self, now_ms: u64, max_resends: u32) -> Vec<ConnSnapshot>;
/// Snapshot every active peer with a session that is healthy, pre-computing
/// its rekey-relevant ages and timer predicates (see [`PeerSnapshot`]). The
/// shell resolves every clock read here; the core applies the thresholds.
fn rekey_peers(&self) -> Vec<PeerSnapshot>;
/// Snapshot every peer with a rekey handshake in flight (and a stored
/// msg1), pre-evaluating the resend-due predicate against `now_ms`. The
/// core classifies abandon-vs-resend and computes the backoff.
fn rekey_resend_candidates(&self, now_ms: u64) -> Vec<RekeyResendSnapshot>;
}
/// The classification outcome for one inbound handshake msg1, decided purely
/// from the [`EstablishSnapshot`] and [`WireOutcome`]. The shell matches on this
/// and drives the effects; the core consumes nothing and touches no live state.
///
/// The variants map one-to-one onto the pre-refactor inline branches of
/// `handle_msg1`'s post-crypto classification. There is deliberately **no**
/// inbound cross-connection won/lost variant: an existing same-identity peer is
/// always intercepted here first (restart / rekey / duplicate), and a net-new
/// [`Promote`](InboundDecision::Promote) reaches `promote_connection` with no
/// existing peer — so on the inbound path the tie-break never fires. The real
/// cross-connection resolution lives in `handle_msg2` (outbound completion).
#[derive(Debug)]
pub(crate) enum InboundDecision {
/// No existing peer for this identity: authorize, allocate our index, send
/// msg2, and promote. Everything the shell needs (verified identity, their
/// index, opaque msg2 payload) is in the `WireOutcome` it still holds, so
/// the variant carries nothing.
Promote,
/// Existing peer at a *different* startup epoch — a peer restart. The shell
/// tears down the stale peer and schedules its reconnect, then runs the same
/// authorize → … → promote sequence as [`Promote`](InboundDecision::Promote).
/// `peer` is the teardown / reconnect target.
RestartThenPromote { peer: NodeAddr },
/// Same-epoch rekey msg1 on an aged, healthy session: respond as the rekey
/// responder. The shell extracts the fresh Noise session from the live
/// connection, allocates a new index, sends the rekey msg2, and stores the
/// session as the peer's pending (post-rekey) session. `abandon_first` is set
/// only on the dual-initiation *loser* path, where we first abandon our own
/// in-flight rekey. `peer` is the rekey target.
RekeyRespond { peer: NodeAddr, abandon_first: bool },
/// Same-epoch duplicate msg1 (not a rekey): resend the existing peer's stored
/// msg2. `msg2` is the opaque stored bytes (`None` → nothing to resend, the
/// silent no-op preserved from the pre-refactor path).
ResendMsg2 { msg2: Option<Vec<u8>> },
/// Drop this msg1 with a handshake reject (`HandshakeReject::BadState`) and
/// no promotion. `reason` selects only the diagnostic log line — every reject
/// records the same stat and completes the rate-limiter identically.
Reject { reason: InboundReject },
}
/// Why an inbound msg1 was rejected. Distinguishes only the diagnostic log
/// message; all three reject identically (BadState stat, rate-limiter complete,
/// the local not-yet-registered connection dropped).
#[derive(Debug)]
pub(crate) enum InboundReject {
/// At `max_peers` and this is a net-new identity with no pending outbound to
/// bypass the cap: silent-drop before any msg2 build/send.
AtMaxPeers,
/// The peer already holds a pending post-rekey session awaiting K-bit
/// cutover; a second rekey msg1 must not overwrite it.
PendingSession,
/// Dual rekey initiation and we are the tie-break *winner* (smaller
/// NodeAddr): drop the peer's msg1 and keep driving our own rekey.
DualRekeyWon,
}
/// The classification outcome for one outbound `handle_msg2` completion, decided
/// purely from the [`OutboundSnapshot`]. The shell matches on this and drives
/// the effects; the core consumes nothing and touches no live state.
///
/// Only the case where the peer is *not* yet a promoted active peer is a plain
/// promotion; when it is, this msg2 completes the outbound half of a
/// cross-connection and the tie-break decides whether we swap our session to the
/// (winning) outbound one or keep our existing inbound session. The rekey-msg2
/// completion path is handled by a separate shell driver (it mutates
/// `ActivePeer`, not a `PeerConnection`) and never reaches this decision.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum OutboundDecision {
/// No existing peer for this identity: promote the completed outbound
/// connection to an active peer via the normal promotion path.
Promote,
/// Cross-connection and our outbound wins (smaller NodeAddr): swap the peer
/// to the outbound session + indices, freeing the old inbound index.
CrossConnectionSwap,
/// Cross-connection and our outbound loses (larger NodeAddr): keep the
/// existing inbound session and original `their_index`, freeing the unused
/// outbound index.
CrossConnectionKeep,
}
/// Minimum session age (seconds) before a same-epoch msg1 from an established
/// peer is treated as a rekey rather than a duplicate. Guards against
/// misreading a simultaneous cross-connection msg1 as a rekey (both sides
/// promote within a tick, so a genuine rekey cannot fire that fast). Unchanged
/// from the pre-refactor literal.
const REKEY_MIN_SESSION_AGE_SECS: u64 = 30;
/// Read-only view of the `Node` registry state the inbound establish decision
/// needs about a peer whose msg1 has just been processed.
///
/// The core defines this interface; the async shell (`node`) implements it over
/// the live `peers`/`connections` maps, resolving every clock read into a plain
/// `u64` before the [`EstablishSnapshot`] reaches the core. Keeping it a trait
/// keeps `proto` free of a `node` dependency and lets the establish decision be
/// unit-tested against hand-built snapshots.
pub(crate) trait EstablishView {
/// Snapshot the registry state relevant to classifying an inbound msg1 from
/// `peer_addr`: the existing peer's epoch/session/rekey state (with the
/// session age resolved shell-side), the max-peers cap, and this node's own
/// address for the tie-break.
fn establish_snapshot(&self, peer_addr: &NodeAddr) -> EstablishSnapshot;
/// Snapshot the registry state relevant to classifying an outbound msg2
/// completion for `peer_addr`: whether the identity is already an active
/// peer, and the pre-evaluated cross-connection tie-break.
fn outbound_snapshot(&self, peer_addr: &NodeAddr) -> OutboundSnapshot;
}
impl Fmp {
/// Decide the teardown choreography for the stale/failed connections the
/// shell snapshotted. For each connection, an outbound one with a known
/// identity first gets an auto-connect retry scheduled, then every
/// connection is torn down. Pure over the snapshots.
///
/// Preserves the pre-refactor per-connection order (retry before teardown).
pub(crate) fn poll_timeouts(&self, stale: Vec<ConnSnapshot>) -> Vec<ConnAction> {
let mut actions = Vec::new();
for snap in stale {
if snap.is_outbound
&& let Some(peer) = snap.retry_addr
{
actions.push(ConnAction::ScheduleRetry { peer });
}
actions.push(ConnAction::Teardown { link: snap.link });
}
actions
}
/// Decide the msg1 resend schedule for the outbound handshake connections
/// the shell snapshotted as due. Each candidate yields one
/// [`ConnAction::ResendMsg1`] carrying the opaque msg1 bytes and the
/// next-resend deadline computed from the exponential backoff
/// (`interval_ms * backoff^(count+1)`). Pure over the snapshots.
///
/// The shell performs the send and only commits the resend (count++ and
/// reschedule) when it succeeds, preserving the pre-refactor behavior where
/// a failed send neither advances the count nor reschedules.
pub(crate) fn poll_resends(
&self,
candidates: Vec<ConnSnapshot>,
now_ms: u64,
interval_ms: u64,
backoff: f64,
) -> Vec<ConnAction> {
candidates
.into_iter()
.map(|snap| ConnAction::ResendMsg1 {
link: snap.link,
next_resend_at_ms: next_resend_at_ms(
now_ms,
interval_ms,
backoff,
snap.resend_count,
),
bytes: snap.msg1,
})
.collect()
}
/// Decide the per-tick rekey choreography for the healthy peers the shell
/// snapshotted. Reproduces the pre-refactor priority and phase grouping
/// exactly:
///
/// - **Cutover** takes precedence: a peer with a pending session and no
/// in-flight rekey cuts over and is considered for nothing else.
/// - Otherwise an expired drain window is completed, and — independently —
/// the rekey trigger fires when the peer is neither mid-rekey nor
/// dampened and its jittered time threshold or send counter is reached.
/// A draining peer can thus both drain and re-trigger in the same tick,
/// as before.
///
/// Actions are returned phase-grouped (all cutovers, then all drains, then
/// all rekey initiations) to preserve the pre-refactor global execution
/// order across peers, which the shared `index_allocator` observes.
pub(crate) fn poll_rekey(&self, peers: Vec<PeerSnapshot>, cfg: &RekeyCfg) -> Vec<ConnAction> {
let mut cutovers = Vec::new();
let mut drains = Vec::new();
let mut rekeys = Vec::new();
for p in peers {
// 1. Initiator-side cutover.
if p.has_pending && !p.rekey_in_progress {
cutovers.push(ConnAction::Cutover { peer: p.addr });
continue;
}
// 2. Drain window expiry (does not preclude a trigger below).
if p.is_draining && p.drain_expired {
drains.push(ConnAction::Drain { peer: p.addr });
}
// 3. Rekey trigger.
if p.rekey_in_progress || p.is_dampened {
continue;
}
let effective_after = cfg.after_secs.saturating_add_signed(p.jitter_secs);
if p.elapsed_secs >= effective_after || p.counter >= cfg.after_messages {
rekeys.push(ConnAction::InitiateRekey { peer: p.addr });
}
}
cutovers.extend(drains);
cutovers.extend(rekeys);
cutovers
}
/// Decide the rekey-msg1 retransmission choreography for the peers the
/// shell snapshotted as having a rekey in flight. A peer whose
/// retransmission count has reached `max_resends` has its cycle abandoned;
/// otherwise, if its msg1 is due, it is retransmitted with the next
/// deadline computed from the shared backoff. Pure over the snapshots.
///
/// Actions are returned abandons-first (matching the pre-refactor
/// two-pass order), and the shell commits a retransmission's count++ and
/// reschedule only on a successful send.
pub(crate) fn poll_rekey_resends(
&self,
candidates: Vec<RekeyResendSnapshot>,
now_ms: u64,
interval_ms: u64,
backoff: f64,
max_resends: u32,
) -> Vec<ConnAction> {
let mut abandons = Vec::new();
let mut resends = Vec::new();
for c in candidates {
if c.resend_count >= max_resends {
abandons.push(ConnAction::AbandonRekey { peer: c.peer });
continue;
}
if c.needs_resend {
resends.push(ConnAction::ResendRekeyMsg1 {
peer: c.peer,
next_resend_at_ms: next_resend_at_ms(
now_ms,
interval_ms,
backoff,
c.resend_count,
),
bytes: c.msg1,
});
}
}
abandons.extend(resends);
abandons
}
/// Classify one inbound handshake msg1 from the establish snapshot and the
/// Noise wire outcome. Pure: reads only `snap` and `wire`, mutates nothing,
/// consumes nothing. The returned [`InboundDecision`] tells the shell which
/// effect sequence to drive.
///
/// Mirrors the pre-refactor `handle_msg1` post-crypto branch order exactly:
/// the early max-peers cap gate, then — for an existing same-identity peer —
/// the epoch-restart / rekey / duplicate classification, else a net-new
/// promote. The pre-refactor `possible_restart` flag is folded away: it was
/// forced true whenever `has_existing_peer` held, so gating the block on
/// `has_existing_peer` alone is behavior-identical.
pub(crate) fn establish_inbound(
&self,
snap: &EstablishSnapshot,
wire: &WireOutcome,
) -> InboundDecision {
// Early cap gate: at capacity and a net-new identity (no existing peer,
// no pending outbound to bypass) → silent-drop before any msg2.
if snap.at_max_peers && !snap.has_existing_peer && !snap.has_pending_outbound_to_peer {
return InboundDecision::Reject {
reason: InboundReject::AtMaxPeers,
};
}
if snap.has_existing_peer {
let peer_addr = *wire.peer_identity.node_addr();
match (snap.existing_peer_epoch, wire.remote_epoch) {
(Some(existing), Some(new)) if existing != new => {
// Epoch mismatch → peer restart.
InboundDecision::RestartThenPromote { peer: peer_addr }
}
_ => {
// Same epoch (or no epoch captured on either side).
let is_rekey = snap.rekey_enabled
&& snap.has_session
&& snap.is_healthy
&& snap.existing_session_age_secs >= REKEY_MIN_SESSION_AGE_SECS;
if !is_rekey {
// Duplicate msg1 — resend the stored msg2.
return InboundDecision::ResendMsg2 {
msg2: snap.existing_msg2.clone(),
};
}
if snap.pending_new_session {
// A completed rekey is already pending cutover.
return InboundDecision::Reject {
reason: InboundReject::PendingSession,
};
}
if snap.rekey_in_progress {
// Dual initiation — smaller NodeAddr wins as initiator.
// Our own rekey is the outbound/initiator side, so reuse
// the shared tie-break with `this_is_outbound = true`.
if cross_connection_winner(&snap.our_node_addr, &peer_addr, true) {
return InboundDecision::Reject {
reason: InboundReject::DualRekeyWon,
};
}
// We lose → abandon ours, then respond as responder.
return InboundDecision::RekeyRespond {
peer: peer_addr,
abandon_first: true,
};
}
InboundDecision::RekeyRespond {
peer: peer_addr,
abandon_first: false,
}
}
}
} else {
// No existing peer for this identity → net-new promote.
InboundDecision::Promote
}
}
/// Classify one outbound `handle_msg2` completion from the outbound snapshot.
/// Pure: reads only `snap`, mutates nothing.
///
/// Mirrors the pre-refactor branch exactly: an existing same-identity peer
/// makes this a cross-connection resolved by the (pre-evaluated) tie-break —
/// swap on a win, keep on a loss — otherwise a net-new promote.
pub(crate) fn establish_outbound(&self, snap: &OutboundSnapshot) -> OutboundDecision {
if !snap.has_existing_peer {
return OutboundDecision::Promote;
}
if snap.our_outbound_wins {
OutboundDecision::CrossConnectionSwap
} else {
OutboundDecision::CrossConnectionKeep
}
}
}
/// Exponential-backoff schedule for the next handshake/rekey msg1 resend:
/// `now_ms + interval_ms * backoff^(prior_count + 1)`. Matches the pre-refactor
/// arithmetic (the exponent is the resend count *after* this attempt).
fn next_resend_at_ms(now_ms: u64, interval_ms: u64, backoff: f64, prior_count: u32) -> u64 {
let count = prior_count + 1;
now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64
}
+16
View File
@@ -0,0 +1,16 @@
//! FMP connection-retry backoff timing.
//!
//! Pure, runtime-agnostic backoff math for the auto-connect retry scheduler.
//! The `Node`-coupled `schedule_*` / `process_pending_retries` async drivers
//! stay in the shell (`node::retry`) and pass the retry count in.
/// Calculate the backoff delay in milliseconds for the given retry count.
///
/// Uses exponential backoff: `base_interval_ms * 2^retry_count`, capped at
/// `max_backoff_ms`.
pub(crate) fn backoff_ms(retry_count: u32, base_interval_ms: u64, max_backoff_ms: u64) -> u64 {
let multiplier = 1u64.checked_shl(retry_count).unwrap_or(u64::MAX);
base_interval_ms
.saturating_mul(multiplier)
.min(max_backoff_ms)
}
+44
View File
@@ -0,0 +1,44 @@
//! Sans-IO FMP connection-lifecycle state machine.
//!
//! Pure, runtime-agnostic maintain/teardown decisions for the FMP peer
//! connection lifecycle, migrated out of the async node shell. The async I/O
//! adapters remain in `node::handlers::{timeout,rekey}`.
//!
//! This covers the four tick-poll maintain/teardown drivers (handshake
//! timeout/teardown, msg1 resend, rekey cutover/drain/trigger, rekey-msg1
//! resend) plus the inbound-msg1 classification decision
//! ([`Fmp::establish_inbound`]). Handshake message bytes are opaque blobs
//! throughout — the Noise wire construction and `promote_connection` effects,
//! the outbound `handle_msg2` classification, and the born-on-next
//! `handle_msg3` leaf stay in `node/`.
//!
//! - `core.rs` — the [`LifecycleView`] read-seam trait, the [`ConnAction`]
//! effect vocabulary, the snapshot types, the pure `poll_*` decisions, and
//! the [`cross_connection_winner`] tie-break helper.
//! - `limits.rs` — the pure connection-retry backoff math.
//! - `state.rs` — [`ConnectionState`], the pure handshake-phase connection
//! bookkeeping (owned by the shell `PeerConnection` beside its Noise crypto
//! handles) and its [`HandshakeState`] phase enum, plus [`Fmp`], the
//! (stateless) lifecycle anchor owned by `Node`.
//! - `wire.rs` — the FMP link-framing codec: handshake message types,
//! disconnect reasons, and the orderly disconnect message.
mod core;
mod limits;
mod state;
mod wire;
#[cfg(test)]
mod tests;
pub use core::cross_connection_winner;
pub(crate) use core::{
ConnAction, ConnSnapshot, EstablishSnapshot, EstablishView, InboundDecision, InboundReject,
LifecycleView, OutboundDecision, OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyResendSnapshot,
WireOutcome,
};
pub(crate) use limits::backoff_ms;
pub use state::HandshakeState;
pub(crate) use state::{ConnectionState, Fmp};
pub use wire::HandshakeMessageType;
pub(crate) use wire::{Disconnect, DisconnectReason};
+465
View File
@@ -0,0 +1,465 @@
//! Sans-IO FMP connection-lifecycle state.
//!
//! The pure, runtime-agnostic bookkeeping for an in-progress FMP peer
//! connection — link/direction identity, the handshake-phase enum, learned
//! peer identity and epoch, index/transport/address tracking, handshake-resend
//! scheduling, and link statistics — extracted out of the async node shell.
//!
//! [`ConnectionState`] owns every **pure** field of the handshake-phase
//! connection. The Noise crypto handles (`noise::HandshakeState`,
//! `NoiseSession`) stay shell-owned in
//! [`PeerConnection`](crate::peer::PeerConnection), which holds a
//! `ConnectionState` alongside them and drives the two halves side by side. The
//! shell's XX transition methods validate against the pure phase, drive the
//! Noise objects, then write learned results back through the pure setters here
//! (`set_handshake_state`, `set_expected_identity`, `set_remote_epoch`,
//! `touch`).
//!
//! This state is `no_std`+`alloc`-clean with respect to transport: the
//! identifier/address/statistics value types are the plain-data `transport`
//! primitives (defined in the `no_std` `transport::types` module, named here
//! via their `crate::transport` re-export). Two `std`-tethers remain —
//! [`PeerIdentity`] and [`SessionIndex`] — plain-data types whose defining
//! modules are not yet `no_std`. They are named here as data only (never
//! driving crypto) and mirror the tether already carried by the sibling
//! decision [`core`](super::core).
//!
//! [`Fmp`] is the separate, stateless lifecycle anchor owned by
//! [`Node`](crate::node::Node); see its doc below.
use crate::PeerIdentity;
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
use core::fmt;
/// Handshake protocol state machine.
///
/// For Noise IK pattern:
/// - Initiator: Initial → SentMsg1 → Complete
/// - Responder: Initial → ReceivedMsg1 → Complete
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HandshakeState {
/// Initial state, ready to start handshake.
Initial,
/// Initiator: Sent message 1, awaiting message 2.
SentMsg1,
/// Responder: Received message 1, ready to send message 2.
ReceivedMsg1,
/// Handshake completed successfully.
Complete,
/// Handshake failed.
Failed,
}
impl HandshakeState {
/// Check if handshake is still in progress.
pub fn is_in_progress(&self) -> bool {
matches!(
self,
HandshakeState::Initial | HandshakeState::SentMsg1 | HandshakeState::ReceivedMsg1
)
}
/// Check if handshake completed successfully.
pub fn is_complete(&self) -> bool {
matches!(self, HandshakeState::Complete)
}
/// Check if handshake failed.
pub fn is_failed(&self) -> bool {
matches!(self, HandshakeState::Failed)
}
}
impl fmt::Display for HandshakeState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
HandshakeState::Initial => "initial",
HandshakeState::SentMsg1 => "sent_msg1",
HandshakeState::ReceivedMsg1 => "received_msg1",
HandshakeState::Complete => "complete",
HandshakeState::Failed => "failed",
};
write!(f, "{}", s)
}
}
/// Pure, runtime-agnostic bookkeeping for a connection in the handshake phase.
///
/// Owns every non-crypto field of the handshake-phase connection. The Noise
/// crypto handles live beside it in the shell
/// [`PeerConnection`](crate::peer::PeerConnection); this struct is written only
/// as plain data — the shell extracts learned identity/epoch out of the crypto
/// objects and sets them here through the setters.
#[derive(Debug)]
pub struct ConnectionState {
// === Link Reference ===
/// The link carrying this connection.
link_id: LinkId,
/// Connection direction (we initiated or they initiated).
direction: LinkDirection,
// === Handshake State ===
/// Current handshake state.
handshake_state: HandshakeState,
/// Expected peer identity (known for outbound, learned for inbound).
/// Updated after receiving their static key in the handshake.
expected_identity: Option<PeerIdentity>,
// === Timing ===
/// When the connection attempt started (Unix milliseconds).
started_at: u64,
/// When the last handshake message was sent/received.
last_activity: u64,
// === Statistics ===
/// Link statistics during handshake.
link_stats: LinkStats,
// === Wire Protocol Index Tracking ===
/// Our sender_idx for this handshake (chosen by us).
/// For outbound: included in msg1, used as receiver_idx in msg2 echo.
/// For inbound: chosen after processing msg1, included in msg2.
our_index: Option<SessionIndex>,
/// Their sender_idx (learned from their messages).
/// For outbound: learned from msg2.
/// For inbound: learned from msg1.
their_index: Option<SessionIndex>,
/// Transport ID (for index namespace).
transport_id: Option<TransportId>,
/// Current source address (updated on packet receipt).
source_addr: Option<TransportAddr>,
// === Epoch (Restart Detection) ===
/// Remote peer's startup epoch (learned from handshake).
remote_epoch: Option<[u8; 8]>,
// === Handshake Resend ===
/// Wire-format msg1 bytes for resend (initiator only).
handshake_msg1: Option<Vec<u8>>,
/// Wire-format msg2 bytes for resend (responder only).
handshake_msg2: Option<Vec<u8>>,
/// Number of resends performed so far.
resend_count: u32,
/// When the next resend should fire (Unix ms). 0 = no resend scheduled.
next_resend_at_ms: u64,
}
impl ConnectionState {
/// Create the pure state for a new outbound connection (we initiate).
///
/// For outbound, we know who we're trying to reach from configuration.
pub fn outbound(
link_id: LinkId,
expected_identity: PeerIdentity,
current_time_ms: u64,
) -> Self {
Self {
link_id,
direction: LinkDirection::Outbound,
handshake_state: HandshakeState::Initial,
expected_identity: Some(expected_identity),
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: None,
source_addr: None,
remote_epoch: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
next_resend_at_ms: 0,
}
}
/// Create the pure state for a new inbound connection (they initiate).
///
/// For inbound, we don't know who they are until we decrypt their identity
/// from Noise message 1.
pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self {
Self {
link_id,
direction: LinkDirection::Inbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: None,
source_addr: None,
remote_epoch: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
next_resend_at_ms: 0,
}
}
/// Create the pure state for a new inbound connection with transport info.
///
/// Used when processing msg1 where we know the transport and source address.
pub fn inbound_with_transport(
link_id: LinkId,
transport_id: TransportId,
source_addr: TransportAddr,
current_time_ms: u64,
) -> Self {
Self {
link_id,
direction: LinkDirection::Inbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: Some(transport_id),
source_addr: Some(source_addr),
remote_epoch: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
next_resend_at_ms: 0,
}
}
// === Accessors ===
/// Get the link ID.
pub fn link_id(&self) -> LinkId {
self.link_id
}
/// Get the connection direction.
pub fn direction(&self) -> LinkDirection {
self.direction
}
/// Get the handshake state.
pub fn handshake_state(&self) -> HandshakeState {
self.handshake_state
}
/// Get the expected/learned peer identity, if known.
pub fn expected_identity(&self) -> Option<&PeerIdentity> {
self.expected_identity.as_ref()
}
/// Check if this is an outbound connection.
pub fn is_outbound(&self) -> bool {
self.direction == LinkDirection::Outbound
}
/// Check if this is an inbound connection.
pub fn is_inbound(&self) -> bool {
self.direction == LinkDirection::Inbound
}
/// Check if handshake is in progress.
pub fn is_in_progress(&self) -> bool {
self.handshake_state.is_in_progress()
}
/// Check if handshake completed.
pub fn is_complete(&self) -> bool {
self.handshake_state.is_complete()
}
/// Check if handshake failed.
pub fn is_failed(&self) -> bool {
self.handshake_state.is_failed()
}
/// When the connection started.
pub fn started_at(&self) -> u64 {
self.started_at
}
/// When the last activity occurred.
pub fn last_activity(&self) -> u64 {
self.last_activity
}
/// Connection duration so far.
pub fn duration(&self, current_time_ms: u64) -> u64 {
current_time_ms.saturating_sub(self.started_at)
}
/// Time since last activity.
pub fn idle_time(&self, current_time_ms: u64) -> u64 {
current_time_ms.saturating_sub(self.last_activity)
}
/// Get link statistics.
pub fn link_stats(&self) -> &LinkStats {
&self.link_stats
}
/// Get mutable link statistics.
pub fn link_stats_mut(&mut self) -> &mut LinkStats {
&mut self.link_stats
}
// === Index Accessors ===
/// Get our session index (if set).
pub fn our_index(&self) -> Option<SessionIndex> {
self.our_index
}
/// Set our session index.
pub fn set_our_index(&mut self, index: SessionIndex) {
self.our_index = Some(index);
}
/// Get their session index (if known).
pub fn their_index(&self) -> Option<SessionIndex> {
self.their_index
}
/// Set their session index.
pub fn set_their_index(&mut self, index: SessionIndex) {
self.their_index = Some(index);
}
/// Get the transport ID (if set).
pub fn transport_id(&self) -> Option<TransportId> {
self.transport_id
}
/// Set the transport ID.
pub fn set_transport_id(&mut self, id: TransportId) {
self.transport_id = Some(id);
}
/// Get the source address (if known).
pub fn source_addr(&self) -> Option<&TransportAddr> {
self.source_addr.as_ref()
}
/// Set the source address.
pub fn set_source_addr(&mut self, addr: TransportAddr) {
self.source_addr = Some(addr);
}
// === Epoch Accessors ===
/// Get the remote peer's startup epoch (available after handshake).
pub fn remote_epoch(&self) -> Option<[u8; 8]> {
self.remote_epoch
}
/// Record the remote peer's startup epoch, as extracted from the crypto
/// handshake by the shell.
pub fn set_remote_epoch(&mut self, epoch: Option<[u8; 8]>) {
self.remote_epoch = epoch;
}
// === Learned Identity ===
/// Record the learned/confirmed peer identity, as extracted from the crypto
/// handshake by the shell.
pub fn set_expected_identity(&mut self, identity: PeerIdentity) {
self.expected_identity = Some(identity);
}
// === Handshake Phase Advance ===
/// Advance the pure handshake phase. Driven by the shell after it has
/// stepped the Noise objects.
pub fn set_handshake_state(&mut self, state: HandshakeState) {
self.handshake_state = state;
}
/// Mark the pure handshake phase failed. The shell drops the crypto handle
/// separately.
pub fn mark_failed(&mut self) {
self.handshake_state = HandshakeState::Failed;
}
// === Handshake Resend ===
/// Store the wire-format msg1 bytes for resend and schedule the first resend.
pub fn set_handshake_msg1(&mut self, msg1: Vec<u8>, first_resend_at_ms: u64) {
self.handshake_msg1 = Some(msg1);
self.resend_count = 0;
self.next_resend_at_ms = first_resend_at_ms;
}
/// Store the wire-format msg2 bytes for resend on duplicate msg1.
pub fn set_handshake_msg2(&mut self, msg2: Vec<u8>) {
self.handshake_msg2 = Some(msg2);
}
/// Get the stored msg1 bytes (if any).
pub fn handshake_msg1(&self) -> Option<&[u8]> {
self.handshake_msg1.as_deref()
}
/// Get the stored msg2 bytes (if any).
pub fn handshake_msg2(&self) -> Option<&[u8]> {
self.handshake_msg2.as_deref()
}
/// Number of resends performed.
pub fn resend_count(&self) -> u32 {
self.resend_count
}
/// When the next resend is scheduled (Unix ms).
pub fn next_resend_at_ms(&self) -> u64 {
self.next_resend_at_ms
}
/// Record a resend and schedule the next one.
pub fn record_resend(&mut self, next_resend_at_ms: u64) {
self.resend_count += 1;
self.next_resend_at_ms = next_resend_at_ms;
}
// === Activity / Timeout ===
/// Update last activity timestamp.
pub fn touch(&mut self, current_time_ms: u64) {
self.last_activity = current_time_ms;
}
/// Check if the connection has timed out.
pub fn is_timed_out(&self, current_time_ms: u64, timeout_ms: u64) -> bool {
self.idle_time(current_time_ms) > timeout_ms
}
}
/// FMP connection-lifecycle subsystem anchor owned by
/// [`Node`](crate::node::Node).
///
/// Unlike [`Router`](crate::proto::routing::Router), the FMP lifecycle core
/// owns **no** mutable state: every registry mutation (index allocation,
/// `peers_by_index`/`addr_to_link`/`connections` insertion and removal,
/// decrypt-worker register/unregister) stays shell-side, driven by the
/// [`ConnAction`](super::ConnAction)s the pure `poll_*` decisions emit. `Fmp`
/// is therefore an empty namespace anchor: it exists so the maintain/teardown
/// decisions can hang off a `Node` field (`self.fmp`) in the same shape the
/// other migrated subsystems use, not to hold data.
pub(crate) struct Fmp;
impl Fmp {
/// Create the (stateless) FMP lifecycle anchor.
pub(crate) fn new() -> Self {
Self
}
}
+592
View File
@@ -0,0 +1,592 @@
//! Tests for the sans-IO FMP connection-lifecycle decision core.
use super::util::{
establish_snapshot, peer_snapshot, rekey_resend_snapshot, resend_snapshot, stale_snapshot,
wire_outcome,
};
use crate::NodeAddr;
use crate::proto::fmp::{
ConnAction, Fmp, InboundDecision, InboundReject, OutboundDecision, OutboundSnapshot, RekeyCfg,
cross_connection_winner,
};
use crate::testutil::make_node_addr;
use crate::transport::LinkId;
/// Threshold config used across the rekey decision tests: rekey at 100s of
/// session age or 1000 sent messages.
fn cfg() -> RekeyCfg {
RekeyCfg {
after_secs: 100,
after_messages: 1_000,
}
}
#[test]
fn empty_stale_set_yields_no_actions() {
let fmp = Fmp::new();
assert!(fmp.poll_timeouts(Vec::new()).is_empty());
}
#[test]
fn inbound_stale_connection_is_torn_down_without_retry() {
let fmp = Fmp::new();
let link = LinkId::new(7);
let actions = fmp.poll_timeouts(vec![stale_snapshot(
link,
false,
Some(make_node_addr(0x22)),
)]);
assert_eq!(actions.len(), 1);
assert!(matches!(actions[0], ConnAction::Teardown { link: l } if l == link));
}
#[test]
fn outbound_stale_with_identity_schedules_retry_then_tears_down() {
let fmp = Fmp::new();
let link = LinkId::new(9);
let peer = make_node_addr(0x33);
let actions = fmp.poll_timeouts(vec![stale_snapshot(link, true, Some(peer))]);
assert_eq!(actions.len(), 2);
// Retry is scheduled before teardown, matching the pre-refactor order.
assert!(matches!(actions[0], ConnAction::ScheduleRetry { peer: p } if p == peer));
assert!(matches!(actions[1], ConnAction::Teardown { link: l } if l == link));
}
#[test]
fn outbound_stale_without_identity_only_tears_down() {
let fmp = Fmp::new();
let link = LinkId::new(11);
let actions = fmp.poll_timeouts(vec![stale_snapshot(link, true, None)]);
assert_eq!(actions.len(), 1);
assert!(matches!(actions[0], ConnAction::Teardown { link: l } if l == link));
}
#[test]
fn no_resend_candidates_yields_no_actions() {
let fmp = Fmp::new();
assert!(fmp.poll_resends(Vec::new(), 1_000, 500, 2.0).is_empty());
}
#[test]
fn resend_emits_bytes_and_backoff_schedule() {
let fmp = Fmp::new();
let link = LinkId::new(5);
let msg1 = vec![0xde, 0xad, 0xbe, 0xef];
// now=1000, interval=500, backoff=2.0, prior_count=0 -> exponent 1 ->
// next = 1000 + 500 * 2^1 = 2000.
let actions = fmp.poll_resends(
vec![resend_snapshot(link, 0, msg1.clone())],
1_000,
500,
2.0,
);
assert_eq!(actions.len(), 1);
match &actions[0] {
ConnAction::ResendMsg1 {
link: l,
bytes,
next_resend_at_ms,
} => {
assert_eq!(*l, link);
assert_eq!(bytes, &msg1);
assert_eq!(*next_resend_at_ms, 2_000);
}
_ => panic!("expected ResendMsg1"),
}
}
#[test]
fn resend_backoff_exponent_uses_count_plus_one() {
let fmp = Fmp::new();
let link = LinkId::new(6);
// prior_count=2 -> exponent 3 -> next = 0 + 100 * 2^3 = 800.
let actions = fmp.poll_resends(vec![resend_snapshot(link, 2, vec![1])], 0, 100, 2.0);
match &actions[0] {
ConnAction::ResendMsg1 {
next_resend_at_ms, ..
} => assert_eq!(*next_resend_at_ms, 800),
_ => panic!("expected ResendMsg1"),
}
}
// --- rekey decision (synthetic clock: elapsed_secs/counter fed directly) ---
#[test]
fn rekey_no_peers_yields_no_actions() {
let fmp = Fmp::new();
assert!(fmp.poll_rekey(Vec::new(), &cfg()).is_empty());
}
#[test]
fn rekey_cutover_takes_precedence_over_trigger() {
let fmp = Fmp::new();
let mut p = peer_snapshot(0x10);
p.has_pending = true;
// Wildly over the time threshold, but cutover wins and nothing else fires.
p.elapsed_secs = 10_000;
p.counter = 10_000;
let actions = fmp.poll_rekey(vec![p], &cfg());
assert_eq!(actions.len(), 1);
assert!(matches!(actions[0], ConnAction::Cutover { peer } if peer == make_node_addr(0x10)));
}
#[test]
fn rekey_pending_with_inflight_rekey_does_not_cut_over() {
let fmp = Fmp::new();
let mut p = peer_snapshot(0x11);
p.has_pending = true;
p.rekey_in_progress = true;
p.elapsed_secs = 10_000;
// has_pending gated by !rekey_in_progress -> no cutover; in-progress -> no
// trigger either.
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
}
#[test]
fn rekey_expired_drain_and_trigger_both_fire() {
let fmp = Fmp::new();
let mut p = peer_snapshot(0x12);
p.is_draining = true;
p.drain_expired = true;
p.elapsed_secs = 150; // past 100s threshold
let actions = fmp.poll_rekey(vec![p], &cfg());
// Draining does not preclude re-triggering in the same tick.
assert_eq!(actions.len(), 2);
assert!(matches!(actions[0], ConnAction::Drain { peer } if peer == make_node_addr(0x12)));
assert!(
matches!(actions[1], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x12))
);
}
#[test]
fn rekey_triggers_on_counter() {
let fmp = Fmp::new();
let mut p = peer_snapshot(0x13);
p.counter = 1_000; // == after_messages
let actions = fmp.poll_rekey(vec![p], &cfg());
assert!(matches!(actions[0], ConnAction::InitiateRekey { .. }));
}
#[test]
fn rekey_negative_jitter_lowers_time_threshold() {
let fmp = Fmp::new();
let mut p = peer_snapshot(0x14);
p.elapsed_secs = 90;
p.jitter_secs = -15; // effective threshold 85 -> 90 >= 85 fires
assert!(matches!(
fmp.poll_rekey(vec![p], &cfg())[0],
ConnAction::InitiateRekey { .. }
));
}
#[test]
fn rekey_positive_jitter_raises_time_threshold() {
let fmp = Fmp::new();
let mut p = peer_snapshot(0x15);
p.elapsed_secs = 105;
p.jitter_secs = 10; // effective threshold 110 -> 105 < 110, no time trigger
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
}
#[test]
fn rekey_dampening_suppresses_trigger() {
let fmp = Fmp::new();
let mut p = peer_snapshot(0x16);
p.elapsed_secs = 10_000;
p.is_dampened = true;
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
}
#[test]
fn rekey_actions_are_phase_grouped_across_peers() {
let fmp = Fmp::new();
// Peer A: trigger only. Peer B: cutover. Peer C: drain + trigger.
let mut a = peer_snapshot(0x01);
a.elapsed_secs = 200;
let mut b = peer_snapshot(0x02);
b.has_pending = true;
let mut c = peer_snapshot(0x03);
c.is_draining = true;
c.drain_expired = true;
c.counter = 5_000;
let actions = fmp.poll_rekey(vec![a, b, c], &cfg());
// Order must be: all cutovers, then all drains, then all rekeys.
assert!(matches!(actions[0], ConnAction::Cutover { peer } if peer == make_node_addr(0x02)));
assert!(matches!(actions[1], ConnAction::Drain { peer } if peer == make_node_addr(0x03)));
assert!(
matches!(actions[2], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x01))
);
assert!(
matches!(actions[3], ConnAction::InitiateRekey { peer } if peer == make_node_addr(0x03))
);
assert_eq!(actions.len(), 4);
}
// --- rekey msg1 retransmission decision ---
#[test]
fn rekey_resend_no_candidates_yields_no_actions() {
let fmp = Fmp::new();
assert!(
fmp.poll_rekey_resends(Vec::new(), 1_000, 500, 2.0, 5)
.is_empty()
);
}
#[test]
fn rekey_resend_over_budget_abandons() {
let fmp = Fmp::new();
// resend_count == max_resends -> abandon (even though it is "due").
let c = rekey_resend_snapshot(0x40, 5, true, vec![9]);
let actions = fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5);
assert_eq!(actions.len(), 1);
assert!(
matches!(actions[0], ConnAction::AbandonRekey { peer } if peer == make_node_addr(0x40))
);
}
#[test]
fn rekey_resend_due_retransmits_with_backoff() {
let fmp = Fmp::new();
let msg1 = vec![0xaa, 0xbb];
// prior_count=1 -> exponent 2 -> next = 1000 + 500 * 2^2 = 3000.
let c = rekey_resend_snapshot(0x41, 1, true, msg1.clone());
let actions = fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5);
assert_eq!(actions.len(), 1);
match &actions[0] {
ConnAction::ResendRekeyMsg1 {
peer,
bytes,
next_resend_at_ms,
} => {
assert_eq!(*peer, make_node_addr(0x41));
assert_eq!(bytes, &msg1);
assert_eq!(*next_resend_at_ms, 3_000);
}
_ => panic!("expected ResendRekeyMsg1"),
}
}
#[test]
fn rekey_resend_not_due_is_skipped() {
let fmp = Fmp::new();
// Under budget but not due -> no action.
let c = rekey_resend_snapshot(0x42, 1, false, vec![1]);
assert!(
fmp.poll_rekey_resends(vec![c], 1_000, 500, 2.0, 5)
.is_empty()
);
}
#[test]
fn rekey_resend_abandons_precede_retransmits() {
let fmp = Fmp::new();
let due = rekey_resend_snapshot(0x43, 0, true, vec![1]);
let over = rekey_resend_snapshot(0x44, 9, true, vec![2]);
// Input order: due-resend first, then over-budget; output must be
// abandons-first regardless.
let actions = fmp.poll_rekey_resends(vec![due, over], 1_000, 500, 2.0, 5);
assert_eq!(actions.len(), 2);
assert!(
matches!(actions[0], ConnAction::AbandonRekey { peer } if peer == make_node_addr(0x44))
);
assert!(
matches!(actions[1], ConnAction::ResendRekeyMsg1 { peer, .. } if peer == make_node_addr(0x43))
);
}
// ===========================================================================
// establish_inbound — inbound msg1 classification (E3)
// ===========================================================================
/// All-0xFF NodeAddr: strictly greater than any pubkey-derived peer addr, so a
/// tie-break with `our_node_addr` set to this makes us the larger side.
fn max_node_addr() -> NodeAddr {
NodeAddr::from_bytes([0xFF; 16])
}
#[test]
fn establish_inbound_net_new_promotes() {
let fmp = Fmp::new();
let snap = establish_snapshot();
let wire = wire_outcome(Some([1u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Promote
));
}
#[test]
fn establish_inbound_at_cap_net_new_rejects() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.at_max_peers = true;
let wire = wire_outcome(Some([1u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Reject {
reason: InboundReject::AtMaxPeers
}
));
}
#[test]
fn establish_inbound_at_cap_with_pending_outbound_bypasses_and_promotes() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.at_max_peers = true;
snap.has_pending_outbound_to_peer = true;
let wire = wire_outcome(Some([1u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Promote
));
}
#[test]
fn establish_inbound_at_cap_existing_peer_not_capped() {
// At cap but the identity is already a peer → the cap gate is bypassed and
// the same-epoch classification runs (here: a duplicate resend).
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.at_max_peers = true;
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([9u8; 8]);
snap.existing_msg2 = Some(vec![0xAB, 0xCD]);
let wire = wire_outcome(Some([9u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
#[test]
fn establish_inbound_epoch_mismatch_restarts() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([1u8; 8]);
let wire = wire_outcome(Some([2u8; 8]));
let peer = *wire.peer_identity.node_addr();
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RestartThenPromote { peer: p } if p == peer
));
}
#[test]
fn establish_inbound_same_epoch_young_session_resends() {
// Same epoch but session younger than the rekey gate → duplicate resend,
// carrying the stored msg2 bytes verbatim.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 5;
snap.existing_msg2 = Some(vec![0x01, 0x02, 0x03]);
let wire = wire_outcome(Some([7u8; 8]));
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::ResendMsg2 { msg2 } => {
assert_eq!(msg2.as_deref(), Some(&[0x01, 0x02, 0x03][..]));
}
other => panic!("expected ResendMsg2, got a different variant: {other:?}"),
}
}
#[test]
fn establish_inbound_aged_session_rekey_responds() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
let wire = wire_outcome(Some([7u8; 8]));
let peer = *wire.peer_identity.node_addr();
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RekeyRespond { peer: p, abandon_first: false } if p == peer
));
}
#[test]
fn establish_inbound_rekey_gate_requires_enabled() {
// Aged healthy session but rekey disabled → same-epoch msg1 is a duplicate,
// not a rekey.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
snap.rekey_enabled = false;
let wire = wire_outcome(Some([7u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
#[test]
fn establish_inbound_rekey_gate_boundary_at_30s() {
// Exactly 30s satisfies `>= 30` → rekey; 29s does not → duplicate.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
let wire = wire_outcome(Some([7u8; 8]));
snap.existing_session_age_secs = 30;
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RekeyRespond { .. }
));
snap.existing_session_age_secs = 29;
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
#[test]
fn establish_inbound_pending_session_rejects() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
snap.pending_new_session = true;
let wire = wire_outcome(Some([7u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Reject {
reason: InboundReject::PendingSession
}
));
}
#[test]
fn establish_inbound_dual_init_we_win_rejects() {
// rekey in progress + our addr < peer addr (our = 0x10, peer = pubkey-derived
// non-zero) → we win, drop theirs.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
snap.rekey_in_progress = true;
snap.our_node_addr = make_node_addr(0x00); // minimal → strictly smaller
let wire = wire_outcome(Some([7u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Reject {
reason: InboundReject::DualRekeyWon
}
));
}
#[test]
fn establish_inbound_dual_init_we_lose_responds_with_abandon() {
// rekey in progress + our addr > peer addr → we lose, abandon ours and
// respond as responder.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
snap.rekey_in_progress = true;
snap.our_node_addr = max_node_addr(); // strictly larger than any peer addr
let wire = wire_outcome(Some([7u8; 8]));
let peer = *wire.peer_identity.node_addr();
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RekeyRespond { peer: p, abandon_first: true } if p == peer
));
}
// ===========================================================================
// establish_outbound — outbound msg2 classification (E4)
// ===========================================================================
#[test]
fn establish_outbound_no_existing_peer_promotes() {
let fmp = Fmp::new();
// our_outbound_wins is irrelevant when there is no existing peer.
let snap = OutboundSnapshot {
has_existing_peer: false,
our_outbound_wins: true,
};
assert_eq!(fmp.establish_outbound(&snap), OutboundDecision::Promote);
}
#[test]
fn establish_outbound_cross_connection_win_swaps() {
let fmp = Fmp::new();
let snap = OutboundSnapshot {
has_existing_peer: true,
our_outbound_wins: true,
};
assert_eq!(
fmp.establish_outbound(&snap),
OutboundDecision::CrossConnectionSwap
);
}
#[test]
fn establish_outbound_cross_connection_loss_keeps() {
let fmp = Fmp::new();
let snap = OutboundSnapshot {
has_existing_peer: true,
our_outbound_wins: false,
};
assert_eq!(
fmp.establish_outbound(&snap),
OutboundDecision::CrossConnectionKeep
);
}
#[test]
fn test_cross_connection_smaller_node_wins_outbound() {
let node_a = make_node_addr(1); // smaller
let node_b = make_node_addr(2); // larger
// Node A's perspective
assert!(cross_connection_winner(&node_a, &node_b, true)); // A's outbound wins
assert!(!cross_connection_winner(&node_a, &node_b, false)); // A's inbound loses
// Node B's perspective
assert!(!cross_connection_winner(&node_b, &node_a, true)); // B's outbound loses
assert!(cross_connection_winner(&node_b, &node_a, false)); // B's inbound wins
}
#[test]
fn test_cross_connection_symmetric() {
let node_a = make_node_addr(1);
let node_b = make_node_addr(2);
// A's outbound = B's inbound
let a_outbound_wins = cross_connection_winner(&node_a, &node_b, true);
let b_inbound_wins = cross_connection_winner(&node_b, &node_a, false);
assert_eq!(a_outbound_wins, b_inbound_wins);
// A's inbound = B's outbound
let a_inbound_wins = cross_connection_winner(&node_a, &node_b, false);
let b_outbound_wins = cross_connection_winner(&node_b, &node_a, true);
assert_eq!(a_inbound_wins, b_outbound_wins);
// Exactly one survives
assert!(a_outbound_wins != a_inbound_wins);
}
+28
View File
@@ -0,0 +1,28 @@
//! Tests for the FMP connection-retry backoff timing helper.
use crate::proto::fmp::backoff_ms;
const TEST_MAX_BACKOFF_MS: u64 = 300_000;
#[test]
fn test_backoff_exponential() {
assert_eq!(backoff_ms(0, 5000, TEST_MAX_BACKOFF_MS), 5000); // 5s * 2^0
assert_eq!(backoff_ms(1, 5000, TEST_MAX_BACKOFF_MS), 10_000); // 5s * 2^1
assert_eq!(backoff_ms(2, 5000, TEST_MAX_BACKOFF_MS), 20_000); // 5s * 2^2
assert_eq!(backoff_ms(3, 5000, TEST_MAX_BACKOFF_MS), 40_000); // 5s * 2^3
assert_eq!(backoff_ms(4, 5000, TEST_MAX_BACKOFF_MS), 80_000); // 5s * 2^4
}
#[test]
fn test_backoff_cap() {
// 2^20 * 5000 would be huge; capped at the max.
assert_eq!(
backoff_ms(20, 5000, TEST_MAX_BACKOFF_MS),
TEST_MAX_BACKOFF_MS
);
}
#[test]
fn test_backoff_zero_base() {
assert_eq!(backoff_ms(3, 0, TEST_MAX_BACKOFF_MS), 0);
}
+8
View File
@@ -0,0 +1,8 @@
//! FMP connection-lifecycle subsystem unit tests. Shared helpers live in
//! `util`.
mod core;
mod limits;
mod state;
mod util;
mod wire;
+178
View File
@@ -0,0 +1,178 @@
//! Unit tests for the pure FMP connection state ([`ConnectionState`]) and its
//! [`HandshakeState`] phase enum. These exercise the extracted bookkeeping
//! directly, with no crypto involved; the crypto-driving transition behavior is
//! covered by the shell `peer::connection` suite.
use crate::proto::fmp::{ConnectionState, HandshakeState};
use crate::transport::{LinkId, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
use crate::{Identity, PeerIdentity};
fn make_peer_identity() -> PeerIdentity {
PeerIdentity::from_pubkey(Identity::generate().pubkey())
}
#[test]
fn handshake_state_predicates() {
assert!(HandshakeState::Initial.is_in_progress());
assert!(HandshakeState::SentMsg1.is_in_progress());
assert!(HandshakeState::ReceivedMsg1.is_in_progress());
assert!(!HandshakeState::Complete.is_in_progress());
assert!(!HandshakeState::Failed.is_in_progress());
assert!(HandshakeState::Complete.is_complete());
assert!(!HandshakeState::Initial.is_complete());
assert!(HandshakeState::Failed.is_failed());
assert!(!HandshakeState::Complete.is_failed());
}
#[test]
fn outbound_initializes_pure_fields() {
let identity = make_peer_identity();
let state = ConnectionState::outbound(LinkId::new(1), identity, 1000);
assert!(state.is_outbound());
assert!(!state.is_inbound());
assert_eq!(state.handshake_state(), HandshakeState::Initial);
assert!(state.is_in_progress());
assert!(state.expected_identity().is_some());
assert_eq!(state.link_id(), LinkId::new(1));
assert_eq!(state.started_at(), 1000);
assert_eq!(state.last_activity(), 1000);
assert!(state.transport_id().is_none());
assert!(state.source_addr().is_none());
assert!(state.remote_epoch().is_none());
assert_eq!(state.resend_count(), 0);
assert_eq!(state.next_resend_at_ms(), 0);
}
#[test]
fn inbound_initializes_pure_fields() {
let state = ConnectionState::inbound(LinkId::new(2), 2000);
assert!(state.is_inbound());
assert!(!state.is_outbound());
assert_eq!(state.handshake_state(), HandshakeState::Initial);
assert!(state.expected_identity().is_none());
assert_eq!(state.started_at(), 2000);
}
#[test]
fn inbound_with_transport_sets_transport_and_addr() {
let addr = TransportAddr::from_string("192.0.2.1:5000");
let state =
ConnectionState::inbound_with_transport(LinkId::new(3), TransportId::new(7), addr, 3000);
assert!(state.is_inbound());
assert_eq!(state.transport_id(), Some(TransportId::new(7)));
assert_eq!(
state.source_addr().map(|a| a.as_str().unwrap().to_string()),
Some("192.0.2.1:5000".to_string())
);
}
#[test]
fn index_setters_round_trip() {
let mut state = ConnectionState::inbound(LinkId::new(1), 0);
assert!(state.our_index().is_none());
assert!(state.their_index().is_none());
state.set_our_index(SessionIndex::new(0x1111));
state.set_their_index(SessionIndex::new(0x2222));
assert_eq!(state.our_index(), Some(SessionIndex::new(0x1111)));
assert_eq!(state.their_index(), Some(SessionIndex::new(0x2222)));
}
#[test]
fn transport_and_source_setters_round_trip() {
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0);
state.set_transport_id(TransportId::new(9));
state.set_source_addr(TransportAddr::from_string("peer"));
assert_eq!(state.transport_id(), Some(TransportId::new(9)));
assert_eq!(state.source_addr().and_then(|a| a.as_str()), Some("peer"));
}
#[test]
fn identity_and_epoch_setters() {
let mut state = ConnectionState::inbound(LinkId::new(1), 0);
assert!(state.expected_identity().is_none());
assert!(state.remote_epoch().is_none());
let identity = make_peer_identity();
let node_addr = *identity.node_addr();
state.set_expected_identity(identity);
state.set_remote_epoch(Some([9u8; 8]));
assert_eq!(
state.expected_identity().map(|id| *id.node_addr()),
Some(node_addr)
);
assert_eq!(state.remote_epoch(), Some([9u8; 8]));
}
#[test]
fn handshake_state_advance_and_fail() {
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0);
assert!(state.is_in_progress());
state.set_handshake_state(HandshakeState::SentMsg1);
assert_eq!(state.handshake_state(), HandshakeState::SentMsg1);
assert!(state.is_in_progress());
assert!(!state.is_complete());
state.set_handshake_state(HandshakeState::Complete);
assert!(state.is_complete());
assert!(!state.is_in_progress());
state.mark_failed();
assert!(state.is_failed());
assert!(!state.is_in_progress());
assert!(!state.is_complete());
assert_eq!(state.handshake_state(), HandshakeState::Failed);
}
#[test]
fn resend_bookkeeping() {
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 0);
assert!(state.handshake_msg1().is_none());
assert!(state.handshake_msg2().is_none());
state.set_handshake_msg1(vec![1, 2, 3], 500);
assert_eq!(state.handshake_msg1(), Some(&[1u8, 2, 3][..]));
assert_eq!(state.resend_count(), 0);
assert_eq!(state.next_resend_at_ms(), 500);
state.record_resend(900);
assert_eq!(state.resend_count(), 1);
assert_eq!(state.next_resend_at_ms(), 900);
state.record_resend(1300);
assert_eq!(state.resend_count(), 2);
assert_eq!(state.next_resend_at_ms(), 1300);
// set_handshake_msg1 resets the resend counter.
state.set_handshake_msg1(vec![4, 5], 100);
assert_eq!(state.handshake_msg1(), Some(&[4u8, 5][..]));
assert_eq!(state.resend_count(), 0);
assert_eq!(state.next_resend_at_ms(), 100);
state.set_handshake_msg2(vec![6, 7, 8]);
assert_eq!(state.handshake_msg2(), Some(&[6u8, 7, 8][..]));
}
#[test]
fn timing_and_touch() {
let mut state = ConnectionState::outbound(LinkId::new(1), make_peer_identity(), 1000);
assert_eq!(state.duration(1500), 500);
assert_eq!(state.idle_time(1500), 500);
assert!(!state.is_timed_out(1500, 1000));
assert!(state.is_timed_out(2500, 1000));
// touch resets idle_time but not duration.
state.touch(2000);
assert_eq!(state.last_activity(), 2000);
assert_eq!(state.idle_time(2500), 500);
assert_eq!(state.duration(2500), 1500);
assert!(!state.is_timed_out(2500, 1000));
}
+105
View File
@@ -0,0 +1,105 @@
//! Shared test helpers for the FMP connection-lifecycle unit tests.
use crate::proto::fmp::{
ConnSnapshot, EstablishSnapshot, PeerSnapshot, RekeyResendSnapshot, WireOutcome,
};
use crate::testutil::make_node_addr;
use crate::transport::LinkId;
use crate::utils::index::SessionIndex;
use crate::{Identity, PeerIdentity};
/// Build a `RekeyResendSnapshot` for the given peer byte, prior retransmission
/// count, due-flag, and opaque msg1 bytes.
pub(super) fn rekey_resend_snapshot(
peer_byte: u8,
resend_count: u32,
needs_resend: bool,
msg1: Vec<u8>,
) -> RekeyResendSnapshot {
RekeyResendSnapshot {
peer: make_node_addr(peer_byte),
resend_count,
needs_resend,
msg1,
}
}
/// Build a quiescent `PeerSnapshot` for `addr`: session-healthy but with no
/// pending cutover, no drain, no dampening, zero ages/counter/jitter. Tests set
/// only the fields the case exercises.
pub(super) fn peer_snapshot(addr_byte: u8) -> PeerSnapshot {
PeerSnapshot {
addr: make_node_addr(addr_byte),
has_pending: false,
rekey_in_progress: false,
is_draining: false,
drain_expired: false,
is_dampened: false,
elapsed_secs: 0,
counter: 0,
jitter_secs: 0,
}
}
/// Build a `ConnSnapshot` for the teardown path with the given link, direction,
/// and retry target. Fields the teardown decision ignores are left at their
/// natural defaults.
pub(super) fn stale_snapshot(
link: LinkId,
is_outbound: bool,
retry_addr: Option<crate::NodeAddr>,
) -> ConnSnapshot {
ConnSnapshot {
link,
is_outbound,
retry_addr,
resend_count: 0,
msg1: Vec::new(),
}
}
/// Build a `ConnSnapshot` for the msg1-resend path with the given link, prior
/// resend count, and opaque msg1 bytes. Fields the resend decision ignores are
/// left at their natural defaults.
pub(super) fn resend_snapshot(link: LinkId, resend_count: u32, msg1: Vec<u8>) -> ConnSnapshot {
ConnSnapshot {
link,
is_outbound: true,
retry_addr: None,
resend_count,
msg1,
}
}
/// Build a quiescent `EstablishSnapshot` for a net-new inbound msg1: no existing
/// peer, not at cap, rekey enabled, our node addr fixed. Tests override only the
/// fields the case exercises.
pub(super) fn establish_snapshot() -> EstablishSnapshot {
EstablishSnapshot {
has_existing_peer: false,
existing_peer_epoch: None,
existing_session_age_secs: 0,
has_session: false,
is_healthy: false,
pending_new_session: false,
rekey_in_progress: false,
existing_msg2: None,
at_max_peers: false,
has_pending_outbound_to_peer: false,
rekey_enabled: true,
our_node_addr: make_node_addr(0x10),
}
}
/// Build a `WireOutcome` carrying a freshly generated peer identity and the
/// given remote epoch (empty msg2 payload, fixed sender index). Callers read the
/// peer's NodeAddr back via `wire.peer_identity.node_addr()` when they need it
/// for the tie-break.
pub(super) fn wire_outcome(remote_epoch: Option<[u8; 8]>) -> WireOutcome {
WireOutcome {
peer_identity: PeerIdentity::from_pubkey_full(Identity::generate().pubkey_full()),
remote_epoch,
their_index: SessionIndex::new(0x1234),
msg2_payload: Vec::new(),
}
}
+109
View File
@@ -0,0 +1,109 @@
//! Tests for the FMP link-framing wire codec.
use crate::proto::fmp::{Disconnect, DisconnectReason, HandshakeMessageType};
// ===== HandshakeMessageType Tests =====
#[test]
fn test_handshake_message_type_roundtrip() {
let types = [
HandshakeMessageType::NoiseIKMsg1,
HandshakeMessageType::NoiseIKMsg2,
];
for ty in types {
let byte = ty.to_byte();
let restored = HandshakeMessageType::from_byte(byte);
assert_eq!(restored, Some(ty));
}
}
#[test]
fn test_handshake_message_type_invalid() {
assert!(HandshakeMessageType::from_byte(0x00).is_none());
assert!(HandshakeMessageType::from_byte(0x03).is_none());
assert!(HandshakeMessageType::from_byte(0x10).is_none());
}
#[test]
fn test_handshake_message_type_is_handshake() {
assert!(HandshakeMessageType::is_handshake(0x01));
assert!(HandshakeMessageType::is_handshake(0x02));
assert!(!HandshakeMessageType::is_handshake(0x00));
assert!(!HandshakeMessageType::is_handshake(0x10));
}
// ===== DisconnectReason Tests =====
#[test]
fn test_disconnect_reason_roundtrip() {
let reasons = [
DisconnectReason::Shutdown,
DisconnectReason::Restart,
DisconnectReason::ProtocolError,
DisconnectReason::TransportFailure,
DisconnectReason::ResourceExhaustion,
DisconnectReason::SecurityViolation,
DisconnectReason::ConfigurationChange,
DisconnectReason::Timeout,
DisconnectReason::Other,
];
for reason in reasons {
let byte = reason.to_byte();
let restored = DisconnectReason::from_byte(byte);
assert_eq!(restored, Some(reason));
}
}
#[test]
fn test_disconnect_reason_unknown_byte() {
assert!(DisconnectReason::from_byte(0x08).is_none());
assert!(DisconnectReason::from_byte(0x80).is_none());
assert!(DisconnectReason::from_byte(0xFE).is_none());
}
// ===== Disconnect Message Tests =====
#[test]
fn test_disconnect_encode_decode() {
let msg = Disconnect::new(DisconnectReason::Shutdown);
let encoded = msg.encode();
assert_eq!(encoded.len(), 2);
assert_eq!(encoded[0], 0x50); // LinkMessageType::Disconnect
assert_eq!(encoded[1], 0x00); // DisconnectReason::Shutdown
// Decode from payload (after msg_type byte)
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.reason, DisconnectReason::Shutdown);
}
#[test]
fn test_disconnect_all_reasons() {
let reasons = [
DisconnectReason::Shutdown,
DisconnectReason::Restart,
DisconnectReason::ProtocolError,
DisconnectReason::Other,
];
for reason in reasons {
let msg = Disconnect::new(reason);
let encoded = msg.encode();
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.reason, reason);
}
}
#[test]
fn test_disconnect_decode_empty_payload() {
let result = Disconnect::decode(&[]);
assert!(result.is_err());
}
#[test]
fn test_disconnect_decode_unknown_reason() {
let decoded = Disconnect::decode(&[0x80]).unwrap();
assert_eq!(decoded.reason, DisconnectReason::Other);
}
+164
View File
@@ -0,0 +1,164 @@
//! FMP link-framing messages: handshake message types and orderly disconnect.
//!
//! The Noise IK handshake message-type discriminants and the orderly
//! disconnect codec, relocated from `protocol::link` per the
//! wire-migrates-with-subsystem policy. `Disconnect::encode` reads the shared
//! `LinkMessageType::Disconnect` catalog variant (a downward `proto ->
//! protocol` dependency); the catalog itself stays in `protocol::link`.
use crate::protocol::{LinkMessageType, ProtocolError};
use std::fmt;
/// Handshake message type identifiers.
///
/// These messages are exchanged during Noise IK handshake before link
/// encryption is established. They use the same TLV framing as link
/// messages but payloads are not encrypted (except Noise-internal encryption).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum HandshakeMessageType {
/// Noise IK message 1: initiator sends ephemeral + encrypted static.
/// Payload: 82 bytes (33 ephemeral + 33 static + 16 tag).
NoiseIKMsg1 = 0x01,
/// Noise IK message 2: responder sends ephemeral.
/// Payload: 33 bytes (ephemeral pubkey only).
NoiseIKMsg2 = 0x02,
}
impl HandshakeMessageType {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(HandshakeMessageType::NoiseIKMsg1),
0x02 => Some(HandshakeMessageType::NoiseIKMsg2),
_ => None,
}
}
/// Convert to a byte.
pub fn to_byte(self) -> u8 {
self as u8
}
/// Check if a byte represents a handshake message type.
pub fn is_handshake(b: u8) -> bool {
matches!(b, 0x01 | 0x02)
}
}
impl fmt::Display for HandshakeMessageType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
HandshakeMessageType::NoiseIKMsg1 => "NoiseIKMsg1",
HandshakeMessageType::NoiseIKMsg2 => "NoiseIKMsg2",
};
write!(f, "{}", name)
}
}
/// Reason for an orderly disconnect notification.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum DisconnectReason {
/// Normal shutdown (operator requested).
Shutdown = 0x00,
/// Restarting (may reconnect soon).
Restart = 0x01,
/// Protocol error encountered.
ProtocolError = 0x02,
/// Transport failure.
TransportFailure = 0x03,
/// Resource exhaustion (memory, connections).
ResourceExhaustion = 0x04,
/// Authentication or security policy violation.
SecurityViolation = 0x05,
/// Configuration change (peer removed from config).
ConfigurationChange = 0x06,
/// Timeout or keepalive failure.
Timeout = 0x07,
/// Unspecified reason.
Other = 0xFF,
}
impl DisconnectReason {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x00 => Some(DisconnectReason::Shutdown),
0x01 => Some(DisconnectReason::Restart),
0x02 => Some(DisconnectReason::ProtocolError),
0x03 => Some(DisconnectReason::TransportFailure),
0x04 => Some(DisconnectReason::ResourceExhaustion),
0x05 => Some(DisconnectReason::SecurityViolation),
0x06 => Some(DisconnectReason::ConfigurationChange),
0x07 => Some(DisconnectReason::Timeout),
0xFF => Some(DisconnectReason::Other),
_ => None,
}
}
/// Convert to a byte.
pub fn to_byte(self) -> u8 {
self as u8
}
}
impl fmt::Display for DisconnectReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
DisconnectReason::Shutdown => "Shutdown",
DisconnectReason::Restart => "Restart",
DisconnectReason::ProtocolError => "ProtocolError",
DisconnectReason::TransportFailure => "TransportFailure",
DisconnectReason::ResourceExhaustion => "ResourceExhaustion",
DisconnectReason::SecurityViolation => "SecurityViolation",
DisconnectReason::ConfigurationChange => "ConfigurationChange",
DisconnectReason::Timeout => "Timeout",
DisconnectReason::Other => "Other",
};
write!(f, "{}", name)
}
}
/// Orderly disconnect notification sent before closing a peer link.
///
/// Sent as a link-layer message (type 0x50) inside an encrypted frame.
/// Allows the receiving peer to immediately clean up state rather than
/// waiting for timeout-based detection.
///
/// ## Wire Format
///
/// | Offset | Field | Size | Notes |
/// |--------|----------|--------|------------------------|
/// | 0 | msg_type | 1 byte | 0x50 |
/// | 1 | reason | 1 byte | DisconnectReason value |
#[derive(Clone, Debug)]
pub struct Disconnect {
/// Reason for disconnection.
pub reason: DisconnectReason,
}
impl Disconnect {
/// Create a new Disconnect message.
pub fn new(reason: DisconnectReason) -> Self {
Self { reason }
}
/// Encode as link-layer plaintext (msg_type + reason).
pub fn encode(&self) -> [u8; 2] {
[LinkMessageType::Disconnect.to_byte(), self.reason.to_byte()]
}
/// Decode from link-layer payload (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
if payload.is_empty() {
return Err(ProtocolError::MessageTooShort {
expected: 1,
got: 0,
});
}
let reason = DisconnectReason::from_byte(payload[0]).unwrap_or(DisconnectReason::Other);
Ok(Self { reason })
}
}
+1
View File
@@ -4,4 +4,5 @@
//! I/O adapters remain in `node::handlers`.
pub(crate) mod discovery;
pub(crate) mod fmp;
pub(crate) mod routing;
+1 -273
View File
@@ -1,61 +1,9 @@
//! Link-layer message types: handshake, link control, disconnect, session datagram.
//! Link-layer message types: the shared frame catalog and session datagram.
use super::ProtocolError;
use crate::NodeAddr;
use std::fmt;
// ============================================================================
// Handshake Message Types
// ============================================================================
/// Handshake message type identifiers.
///
/// These messages are exchanged during Noise IK handshake before link
/// encryption is established. They use the same TLV framing as link
/// messages but payloads are not encrypted (except Noise-internal encryption).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum HandshakeMessageType {
/// Noise IK message 1: initiator sends ephemeral + encrypted static.
/// Payload: 82 bytes (33 ephemeral + 33 static + 16 tag).
NoiseIKMsg1 = 0x01,
/// Noise IK message 2: responder sends ephemeral.
/// Payload: 33 bytes (ephemeral pubkey only).
NoiseIKMsg2 = 0x02,
}
impl HandshakeMessageType {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(HandshakeMessageType::NoiseIKMsg1),
0x02 => Some(HandshakeMessageType::NoiseIKMsg2),
_ => None,
}
}
/// Convert to a byte.
pub fn to_byte(self) -> u8 {
self as u8
}
/// Check if a byte represents a handshake message type.
pub fn is_handshake(b: u8) -> bool {
matches!(b, 0x01 | 0x02)
}
}
impl fmt::Display for HandshakeMessageType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
HandshakeMessageType::NoiseIKMsg1 => "NoiseIKMsg1",
HandshakeMessageType::NoiseIKMsg2 => "NoiseIKMsg2",
};
write!(f, "{}", name)
}
}
// ============================================================================
// Link-Layer Message Types
// ============================================================================
@@ -141,120 +89,6 @@ impl fmt::Display for LinkMessageType {
}
}
// ============================================================================
// Disconnect Reason Codes
// ============================================================================
/// Reason for an orderly disconnect notification.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum DisconnectReason {
/// Normal shutdown (operator requested).
Shutdown = 0x00,
/// Restarting (may reconnect soon).
Restart = 0x01,
/// Protocol error encountered.
ProtocolError = 0x02,
/// Transport failure.
TransportFailure = 0x03,
/// Resource exhaustion (memory, connections).
ResourceExhaustion = 0x04,
/// Authentication or security policy violation.
SecurityViolation = 0x05,
/// Configuration change (peer removed from config).
ConfigurationChange = 0x06,
/// Timeout or keepalive failure.
Timeout = 0x07,
/// Unspecified reason.
Other = 0xFF,
}
impl DisconnectReason {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x00 => Some(DisconnectReason::Shutdown),
0x01 => Some(DisconnectReason::Restart),
0x02 => Some(DisconnectReason::ProtocolError),
0x03 => Some(DisconnectReason::TransportFailure),
0x04 => Some(DisconnectReason::ResourceExhaustion),
0x05 => Some(DisconnectReason::SecurityViolation),
0x06 => Some(DisconnectReason::ConfigurationChange),
0x07 => Some(DisconnectReason::Timeout),
0xFF => Some(DisconnectReason::Other),
_ => None,
}
}
/// Convert to a byte.
pub fn to_byte(self) -> u8 {
self as u8
}
}
impl fmt::Display for DisconnectReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
DisconnectReason::Shutdown => "Shutdown",
DisconnectReason::Restart => "Restart",
DisconnectReason::ProtocolError => "ProtocolError",
DisconnectReason::TransportFailure => "TransportFailure",
DisconnectReason::ResourceExhaustion => "ResourceExhaustion",
DisconnectReason::SecurityViolation => "SecurityViolation",
DisconnectReason::ConfigurationChange => "ConfigurationChange",
DisconnectReason::Timeout => "Timeout",
DisconnectReason::Other => "Other",
};
write!(f, "{}", name)
}
}
// ============================================================================
// Disconnect Message
// ============================================================================
/// Orderly disconnect notification sent before closing a peer link.
///
/// Sent as a link-layer message (type 0x50) inside an encrypted frame.
/// Allows the receiving peer to immediately clean up state rather than
/// waiting for timeout-based detection.
///
/// ## Wire Format
///
/// | Offset | Field | Size | Notes |
/// |--------|----------|--------|------------------------|
/// | 0 | msg_type | 1 byte | 0x50 |
/// | 1 | reason | 1 byte | DisconnectReason value |
#[derive(Clone, Debug)]
pub struct Disconnect {
/// Reason for disconnection.
pub reason: DisconnectReason,
}
impl Disconnect {
/// Create a new Disconnect message.
pub fn new(reason: DisconnectReason) -> Self {
Self { reason }
}
/// Encode as link-layer plaintext (msg_type + reason).
pub fn encode(&self) -> [u8; 2] {
[LinkMessageType::Disconnect.to_byte(), self.reason.to_byte()]
}
/// Decode from link-layer payload (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
if payload.is_empty() {
return Err(ProtocolError::MessageTooShort {
expected: 1,
got: 0,
});
}
let reason = DisconnectReason::from_byte(payload[0]).unwrap_or(DisconnectReason::Other);
Ok(Self { reason })
}
}
// ============================================================================
// Session Datagram (Link-Layer Encapsulation)
// ============================================================================
@@ -417,37 +251,6 @@ pub type MessageType = LinkMessageType;
mod tests {
use super::*;
// ===== HandshakeMessageType Tests =====
#[test]
fn test_handshake_message_type_roundtrip() {
let types = [
HandshakeMessageType::NoiseIKMsg1,
HandshakeMessageType::NoiseIKMsg2,
];
for ty in types {
let byte = ty.to_byte();
let restored = HandshakeMessageType::from_byte(byte);
assert_eq!(restored, Some(ty));
}
}
#[test]
fn test_handshake_message_type_invalid() {
assert!(HandshakeMessageType::from_byte(0x00).is_none());
assert!(HandshakeMessageType::from_byte(0x03).is_none());
assert!(HandshakeMessageType::from_byte(0x10).is_none());
}
#[test]
fn test_handshake_message_type_is_handshake() {
assert!(HandshakeMessageType::is_handshake(0x01));
assert!(HandshakeMessageType::is_handshake(0x02));
assert!(!HandshakeMessageType::is_handshake(0x00));
assert!(!HandshakeMessageType::is_handshake(0x10));
}
// ===== LinkMessageType Tests =====
#[test]
@@ -476,81 +279,6 @@ mod tests {
assert!(LinkMessageType::from_byte(0x40).is_none());
}
// ===== DisconnectReason Tests =====
#[test]
fn test_disconnect_reason_roundtrip() {
let reasons = [
DisconnectReason::Shutdown,
DisconnectReason::Restart,
DisconnectReason::ProtocolError,
DisconnectReason::TransportFailure,
DisconnectReason::ResourceExhaustion,
DisconnectReason::SecurityViolation,
DisconnectReason::ConfigurationChange,
DisconnectReason::Timeout,
DisconnectReason::Other,
];
for reason in reasons {
let byte = reason.to_byte();
let restored = DisconnectReason::from_byte(byte);
assert_eq!(restored, Some(reason));
}
}
#[test]
fn test_disconnect_reason_unknown_byte() {
assert!(DisconnectReason::from_byte(0x08).is_none());
assert!(DisconnectReason::from_byte(0x80).is_none());
assert!(DisconnectReason::from_byte(0xFE).is_none());
}
// ===== Disconnect Message Tests =====
#[test]
fn test_disconnect_encode_decode() {
let msg = Disconnect::new(DisconnectReason::Shutdown);
let encoded = msg.encode();
assert_eq!(encoded.len(), 2);
assert_eq!(encoded[0], 0x50); // LinkMessageType::Disconnect
assert_eq!(encoded[1], 0x00); // DisconnectReason::Shutdown
// Decode from payload (after msg_type byte)
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.reason, DisconnectReason::Shutdown);
}
#[test]
fn test_disconnect_all_reasons() {
let reasons = [
DisconnectReason::Shutdown,
DisconnectReason::Restart,
DisconnectReason::ProtocolError,
DisconnectReason::Other,
];
for reason in reasons {
let msg = Disconnect::new(reason);
let encoded = msg.encode();
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.reason, reason);
}
}
#[test]
fn test_disconnect_decode_empty_payload() {
let result = Disconnect::decode(&[]);
assert!(result.is_err());
}
#[test]
fn test_disconnect_decode_unknown_reason() {
let decoded = Disconnect::decode(&[0x80]).unwrap();
assert_eq!(decoded.reason, DisconnectReason::Other);
}
// ===== SessionDatagram Tests =====
fn make_node_addr(val: u8) -> NodeAddr {
+1 -2
View File
@@ -30,8 +30,7 @@ mod tree;
pub use error::ProtocolError;
pub use filter::FilterAnnounce;
pub use link::{
Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType,
SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
};
pub use session::{
FspFlags, FspInnerFlags, PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification,
+5 -241
View File
@@ -34,6 +34,9 @@ use tor::TorTransport;
use tor::control::TorMonitoringInfo;
use udp::UdpTransport;
mod types;
pub use types::*;
// ============================================================================
// Packet Channel Types
// ============================================================================
@@ -93,54 +96,6 @@ pub fn packet_channel(buffer: usize) -> (PacketTx, PacketRx) {
tokio::sync::mpsc::channel(buffer)
}
// ============================================================================
// Transport Identifiers
// ============================================================================
/// Unique identifier for a transport instance.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TransportId(u32);
impl TransportId {
/// Create a new transport ID.
pub fn new(id: u32) -> Self {
Self(id)
}
/// Get the raw ID value.
pub fn as_u32(&self) -> u32 {
self.0
}
}
impl fmt::Display for TransportId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "transport:{}", self.0)
}
}
/// Unique identifier for a link instance.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LinkId(u64);
impl LinkId {
/// Create a new link ID.
pub fn new(id: u64) -> Self {
Self(id)
}
/// Get the raw ID value.
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl fmt::Display for LinkId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "link:{}", self.0)
}
}
// ============================================================================
// Errors
// ============================================================================
@@ -373,208 +328,17 @@ impl fmt::Display for LinkState {
}
}
/// Direction of link establishment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkDirection {
/// We initiated the connection.
Outbound,
/// They initiated the connection.
Inbound,
}
impl fmt::Display for LinkDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
LinkDirection::Outbound => "outbound",
LinkDirection::Inbound => "inbound",
};
write!(f, "{}", s)
}
}
// ============================================================================
// Transport Address
// Transport Address (std-bound helper; the plain type lives in `types`)
// ============================================================================
/// Opaque transport-specific address.
///
/// Each transport type interprets this differently:
/// - UDP/TCP: "host:port" (IP address or DNS hostname)
/// - Ethernet: MAC address (6 bytes)
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct TransportAddr(Vec<u8>);
impl TransportAddr {
/// Create a transport address from raw bytes.
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
/// Create a transport address from a byte slice.
pub fn from_bytes(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
/// Create a transport address from a string.
pub fn from_string(s: &str) -> Self {
Self(s.as_bytes().to_vec())
}
/// Create a UDP/TCP transport address directly from a socket address.
pub fn from_socket_addr(addr: std::net::SocketAddr) -> Self {
use std::io::Write;
let mut buf = Vec::with_capacity(56);
write!(&mut buf, "{addr}").expect("Vec<u8>::write_fmt is infallible");
Self(buf)
}
/// Get the raw bytes.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Try to interpret as a UTF-8 string.
pub fn as_str(&self) -> Option<&str> {
std::str::from_utf8(&self.0).ok()
}
/// Get the length in bytes.
pub fn len(&self) -> usize {
self.0.len()
}
/// Check if empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for TransportAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.as_str() {
Some(s) => write!(f, "TransportAddr(\"{}\")", s),
None => write!(f, "TransportAddr({:?})", self.0),
}
}
}
impl fmt::Display for TransportAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Best-effort display as string if valid UTF-8. Otherwise render a
// 6-byte payload as a colon-separated MAC (standard Unix notation,
// matching BLE addrs, `ip link`/`ip neigh`, and packet logs), and
// any other non-UTF-8 byte string as bare hex.
match self.as_str() {
Some(s) => write!(f, "{}", s),
None if self.0.len() == 6 => {
for (i, byte) in self.0.iter().enumerate() {
if i > 0 {
write!(f, ":")?;
}
write!(f, "{:02x}", byte)?;
}
Ok(())
}
None => {
for byte in &self.0 {
write!(f, "{:02x}", byte)?;
}
Ok(())
}
}
}
}
impl From<&str> for TransportAddr {
fn from(s: &str) -> Self {
Self::from_string(s)
}
}
impl From<String> for TransportAddr {
fn from(s: String) -> Self {
Self(s.into_bytes())
}
}
// ============================================================================
// Link Statistics
// ============================================================================
/// Statistics for a link.
#[derive(Clone, Debug, Default)]
pub struct LinkStats {
/// Total packets sent.
pub packets_sent: u64,
/// Total packets received.
pub packets_recv: u64,
/// Total bytes sent.
pub bytes_sent: u64,
/// Total bytes received.
pub bytes_recv: u64,
/// Timestamp of last received packet (Unix milliseconds).
pub last_recv_ms: u64,
/// Estimated round-trip time.
rtt_estimate: Option<Duration>,
/// Observed packet loss rate (0.0-1.0).
pub loss_rate: f32,
/// Estimated throughput in bytes/second.
pub throughput_estimate: u64,
}
impl LinkStats {
/// Create new link statistics.
pub fn new() -> Self {
Self::default()
}
/// Record a sent packet.
pub fn record_sent(&mut self, bytes: usize) {
self.packets_sent += 1;
self.bytes_sent += bytes as u64;
}
/// Record a received packet.
pub fn record_recv(&mut self, bytes: usize, timestamp_ms: u64) {
self.packets_recv += 1;
self.bytes_recv += bytes as u64;
self.last_recv_ms = timestamp_ms;
}
/// Get the RTT estimate, if available.
pub fn rtt_estimate(&self) -> Option<Duration> {
self.rtt_estimate
}
/// Update RTT estimate from a probe response.
///
/// Uses exponential moving average with alpha=0.2.
pub fn update_rtt(&mut self, rtt: Duration) {
match self.rtt_estimate {
Some(old_rtt) => {
let alpha = 0.2;
let new_rtt_nanos = (alpha * rtt.as_nanos() as f64
+ (1.0 - alpha) * old_rtt.as_nanos() as f64)
as u64;
self.rtt_estimate = Some(Duration::from_nanos(new_rtt_nanos));
}
None => {
self.rtt_estimate = Some(rtt);
}
}
}
/// Time since last receive (for keepalive/timeout).
pub fn time_since_recv(&self, current_time_ms: u64) -> u64 {
if self.last_recv_ms == 0 {
return u64::MAX;
}
current_time_ms.saturating_sub(self.last_recv_ms)
}
/// Reset all statistics.
pub fn reset(&mut self) {
*self = Self::default();
Self::new(buf)
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Plain, `no_std`+`alloc`-clean transport primitives.
//!
//! The identifier/address/statistics value types shared across the transport
//! layer, defined free of any `std` dependency so the sans-IO protocol cores
//! (e.g. `proto::fmp`) can name them without pulling in `std`. The
//! `std`-requiring helpers (`TransportAddr::from_socket_addr`, socket-address
//! resolution, `local_addr`) stay in `transport`. Re-exported from `transport`
//! via `pub use types::*`, so existing `crate::transport::{LinkId, ...}`
//! imports are unaffected.
use core::fmt;
use core::time::Duration;
// ============================================================================
// Transport Identifiers
// ============================================================================
/// Unique identifier for a transport instance.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TransportId(u32);
impl TransportId {
/// Create a new transport ID.
pub fn new(id: u32) -> Self {
Self(id)
}
/// Get the raw ID value.
pub fn as_u32(&self) -> u32 {
self.0
}
}
impl fmt::Display for TransportId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "transport:{}", self.0)
}
}
/// Unique identifier for a link instance.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LinkId(u64);
impl LinkId {
/// Create a new link ID.
pub fn new(id: u64) -> Self {
Self(id)
}
/// Get the raw ID value.
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl fmt::Display for LinkId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "link:{}", self.0)
}
}
// ============================================================================
// Link Direction
// ============================================================================
/// Direction of link establishment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkDirection {
/// We initiated the connection.
Outbound,
/// They initiated the connection.
Inbound,
}
impl fmt::Display for LinkDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
LinkDirection::Outbound => "outbound",
LinkDirection::Inbound => "inbound",
};
write!(f, "{}", s)
}
}
// ============================================================================
// Transport Address
// ============================================================================
/// Opaque transport-specific address.
///
/// Each transport type interprets this differently:
/// - UDP/TCP: "host:port" (IP address or DNS hostname)
/// - Ethernet: MAC address (6 bytes)
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct TransportAddr(Vec<u8>);
impl TransportAddr {
/// Create a transport address from raw bytes.
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
/// Create a transport address from a byte slice.
pub fn from_bytes(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
/// Create a transport address from a string.
pub fn from_string(s: &str) -> Self {
Self(s.as_bytes().to_vec())
}
/// Get the raw bytes.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Try to interpret as a UTF-8 string.
pub fn as_str(&self) -> Option<&str> {
core::str::from_utf8(&self.0).ok()
}
/// Get the length in bytes.
pub fn len(&self) -> usize {
self.0.len()
}
/// Check if empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for TransportAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.as_str() {
Some(s) => write!(f, "TransportAddr(\"{}\")", s),
None => write!(f, "TransportAddr({:?})", self.0),
}
}
}
impl fmt::Display for TransportAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Best-effort display as string if valid UTF-8. Otherwise render a
// 6-byte payload as a colon-separated MAC (standard Unix notation,
// matching BLE addrs, `ip link`/`ip neigh`, and packet logs), and
// any other non-UTF-8 byte string as bare hex.
match self.as_str() {
Some(s) => write!(f, "{}", s),
None if self.0.len() == 6 => {
for (i, byte) in self.0.iter().enumerate() {
if i > 0 {
write!(f, ":")?;
}
write!(f, "{:02x}", byte)?;
}
Ok(())
}
None => {
for byte in &self.0 {
write!(f, "{:02x}", byte)?;
}
Ok(())
}
}
}
}
impl From<&str> for TransportAddr {
fn from(s: &str) -> Self {
Self::from_string(s)
}
}
impl From<String> for TransportAddr {
fn from(s: String) -> Self {
Self(s.into_bytes())
}
}
// ============================================================================
// Link Statistics
// ============================================================================
/// Statistics for a link.
#[derive(Clone, Debug, Default)]
pub struct LinkStats {
/// Total packets sent.
pub packets_sent: u64,
/// Total packets received.
pub packets_recv: u64,
/// Total bytes sent.
pub bytes_sent: u64,
/// Total bytes received.
pub bytes_recv: u64,
/// Timestamp of last received packet (Unix milliseconds).
pub last_recv_ms: u64,
/// Estimated round-trip time in milliseconds (0 = no estimate yet).
rtt_estimate: u64,
/// Observed packet loss rate (0.0-1.0).
pub loss_rate: f32,
/// Estimated throughput in bytes/second.
pub throughput_estimate: u64,
}
impl LinkStats {
/// Create new link statistics.
pub fn new() -> Self {
Self::default()
}
/// Record a sent packet.
pub fn record_sent(&mut self, bytes: usize) {
self.packets_sent += 1;
self.bytes_sent += bytes as u64;
}
/// Record a received packet.
pub fn record_recv(&mut self, bytes: usize, timestamp_ms: u64) {
self.packets_recv += 1;
self.bytes_recv += bytes as u64;
self.last_recv_ms = timestamp_ms;
}
/// Get the RTT estimate, if available.
pub fn rtt_estimate(&self) -> Option<Duration> {
if self.rtt_estimate == 0 {
None
} else {
Some(Duration::from_millis(self.rtt_estimate))
}
}
/// Update RTT estimate from a probe response.
///
/// Uses exponential moving average with alpha=0.2.
pub fn update_rtt(&mut self, rtt: Duration) {
let rtt_ms = rtt.as_millis() as u64;
if self.rtt_estimate == 0 {
self.rtt_estimate = rtt_ms;
} else {
let alpha = 0.2;
self.rtt_estimate =
(alpha * rtt_ms as f64 + (1.0 - alpha) * self.rtt_estimate as f64) as u64;
}
}
/// Time since last receive (for keepalive/timeout).
pub fn time_since_recv(&self, current_time_ms: u64) -> u64 {
if self.last_recv_ms == 0 {
return u64::MAX;
}
current_time_ms.saturating_sub(self.last_recv_ms)
}
/// Reset all statistics.
pub fn reset(&mut self) {
*self = Self::default();
}
}