Merge branch 'refactor-node' into refactor-node-next

Re-express the handshake-state carrier collapse onto the XX code: the leg's
handshake_state field is deleted and the displayed state is derived from the
peer machine's phase, with failure carried on the machine (a send_failed flag
that preserves the handshake phase) rather than on the leg. The next projection
maps the SentMsg2 responder phase to received_msg1 and the anonymous-dial
Discovered phase to sent_msg1; the three initiator send-failure sites carry
failure via send_failed. Telemetry strings, wire bytes, index allocation, and
stale-connection reaping are byte-identical to next.
This commit is contained in:
Johnathan Corgan
2026-07-18 04:08:26 +00:00
12 changed files with 322 additions and 254 deletions
+1 -1
View File
@@ -1320,7 +1320,7 @@ pub fn show_connections(node: &Node) -> Value {
let mut conn_json = json!({
"link_id": conn.link_id().as_u64(),
"direction": format!("{}", conn.direction()),
"handshake_state": format!("{}", conn.handshake_state()),
"handshake_state": node.connection_handshake_state(conn.link_id()),
"started_at_ms": conn.started_at(),
"idle_ms": now.saturating_sub(conn.last_activity()),
"resend_count": node.connection_resend_count(conn.link_id()),
+1 -1
View File
@@ -95,7 +95,7 @@ pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
pub use proto::fmp::{PromotionResult, cross_connection_winner};
// Re-export peer types
pub use peer::{ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError};
pub use peer::{ActivePeer, ConnectivityState, PeerConnection, PeerError};
// Re-export node types
pub use node::{Node, NodeError, NodeState, UpdatePeersOutcome};
+24
View File
@@ -640,7 +640,18 @@ impl Node {
error = %e,
"Handshake completion failed"
);
// Drop the leg's Noise handle (byte-identical point) and
// record the failure on the control machine as `send_failed`
// — the failure state's new home. The machine PHASE stays
// exactly where the old leg-carried failure left it
// (`Handshaking{SentMsg1}`): the stale-connection sweep
// reclaims the leg via the machine `is_failed()` at the next
// tick, before any projection or resend, byte-identical to
// the pre-collapse leg mark.
conn.mark_failed();
if let Some(machine) = self.peer_machines.get_mut(&link_id) {
machine.mark_send_failed();
}
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
@@ -653,7 +664,13 @@ impl Node {
Ok(()) => {}
Err(e) => {
warn!(link_id = %link_id, our_profile = %our_profile, error = %e, "FMP negotiation failed");
// Failure moves to the machine (`send_failed`); the phase
// stays `Handshaking{SentMsg1}` so the sweep reclaims the
// leg exactly as the pre-collapse leg mark did.
conn.mark_failed();
if let Some(machine) = self.peer_machines.get_mut(&link_id) {
machine.mark_send_failed();
}
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
@@ -741,9 +758,16 @@ impl Node {
error = %e,
"Failed to send msg3"
);
// Failure moves to the machine (`send_failed`); the phase
// stays `Handshaking{SentMsg1}` (promote has not run yet) so
// the sweep reclaims the leg exactly as the pre-collapse leg
// mark did.
if let Some(conn) = self.leg_mut(&link_id) {
conn.mark_failed();
}
if let Some(machine) = self.peer_machines.get_mut(&link_id) {
machine.mark_send_failed();
}
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
+12 -6
View File
@@ -19,15 +19,15 @@ impl LifecycleView for Node {
// reap.
self.peer_machines
.iter()
.filter_map(|(link_id, machine)| machine.leg().map(|conn| (link_id, conn)))
.filter(|(link_id, conn)| {
conn.is_failed()
.filter_map(|(link_id, machine)| machine.leg().map(|conn| (link_id, machine, conn)))
.filter(|(link_id, machine, conn)| {
machine.is_failed()
|| (conn.is_timed_out(now_ms, timeout_ms)
&& !self.peer_timers.get(*link_id).is_some_and(|timers| {
timers.contains_key(&TimerKind::HandshakeTimeout)
}))
})
.map(|(link_id, conn)| ConnSnapshot {
.map(|(link_id, _machine, conn)| ConnSnapshot {
link: *link_id,
is_outbound: conn.is_outbound(),
retry_addr: conn.expected_identity().map(|id| *id.node_addr()),
@@ -70,10 +70,16 @@ impl Node {
match action {
ConnAction::ScheduleRetry { peer } => self.note_handshake_timeout(peer, now_ms),
ConnAction::Teardown { link } => {
// Log before cleanup (needs live connection state).
// Log before cleanup (needs live connection state). The
// failure signal is now read from the control machine; the
// leg still carries direction/idle for the log fields.
let is_failed = self
.peer_machines
.get(&link)
.is_some_and(|machine| machine.is_failed());
if let Some(conn) = self.leg(&link) {
let direction = conn.direction();
if conn.is_failed() {
if is_failed {
debug!(
link_id = %link,
direction = %direction,
+12 -1
View File
@@ -2021,7 +2021,7 @@ impl Node {
.map(|conn| snap::ConnectionRow {
link_id: conn.link_id().as_u64(),
direction: format!("{}", conn.direction()),
handshake_state: format!("{}", conn.handshake_state()),
handshake_state: self.connection_handshake_state(conn.link_id()).to_string(),
started_at_ms: conn.started_at(),
last_activity_ms: conn.last_activity(),
resend_count: self.connection_resend_count(conn.link_id()),
@@ -2348,6 +2348,17 @@ impl Node {
.map_or(0, |machine| machine.resend_count())
}
/// Operator-visible handshake-state string for a pending handshake `link`,
/// derived from the per-peer control machine (the phase's home now that the
/// leg no longer carries it). Every leg surfaced by `connections()` is
/// embedded in a machine, so the lookup resolves; the `"initial"` default is
/// unreachable in that view and only guards a missing machine.
pub(crate) fn connection_handshake_state(&self, link: LinkId) -> &'static str {
self.peer_machines
.get(&link)
.map_or("initial", |machine| machine.displayed_handshake_state())
}
pub(crate) fn cleanup_bootstrap_transport_if_unused(&mut self, transport_id: TransportId) {
if !self
.supervisor
+18 -1
View File
@@ -792,7 +792,6 @@ async fn test_failed_connection_cleanup() {
conn.set_our_index(our_index);
conn.set_transport_id(transport_id);
conn.set_source_addr(remote_addr.clone());
conn.mark_failed(); // Simulate send failure
let link = Link::connectionless(
link_id,
@@ -808,6 +807,24 @@ async fn test_failed_connection_cleanup() {
node.pending_outbound
.insert((transport_id, our_index.as_u32()), link_id);
// Simulate a stored-handshake send failure through the control machine —
// the failure carrier the stale-connection sweep now reads (the leg no
// longer carries a failed phase of its own).
{
let machine = node
.peer_machines
.get_mut(&link_id)
.expect("machine seeded by add_connection");
let alloc = &mut node.index_allocator;
let actions = machine.step(
crate::peer::machine::PeerEvent::HandshakeSendFailed,
now_ms,
alloc,
);
assert!(actions.is_empty());
assert!(machine.is_failed());
}
assert_eq!(node.connection_count(), 1);
// Failed connections should be cleaned up immediately regardless of age
+56 -89
View File
@@ -1,8 +1,11 @@
//! Peer Connection (Handshake Phase)
//!
//! Represents an in-progress connection before authentication completes.
//! PeerConnection tracks the Noise IK handshake state and transitions to
//! ActivePeer upon successful authentication.
//! PeerConnection tracks the Noise IK handshake and transitions to
//! ActivePeer upon successful authentication. The handshake *phase* (initial /
//! sent_msg1 / complete / failed) is no longer tracked here — it lives on the
//! per-peer control machine; the leg's crypto methods gate on the presence of
//! their Noise handles (`noise_handshake` / `noise_session`) directly.
use crate::PeerIdentity;
use crate::noise::{self, NoiseError, NoiseSession};
@@ -12,12 +15,6 @@ use crate::utils::index::SessionIndex;
use secp256k1::Keypair;
use std::fmt;
// 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.
@@ -114,11 +111,6 @@ impl PeerConnection {
self.state.direction()
}
/// Get the handshake state.
pub fn handshake_state(&self) -> HandshakeState {
self.state.handshake_state()
}
/// Get the expected/learned peer identity, if known.
pub fn expected_identity(&self) -> Option<&PeerIdentity> {
self.state.expected_identity()
@@ -134,21 +126,6 @@ impl PeerConnection {
self.state.is_inbound()
}
/// Check if handshake is in progress.
pub fn is_in_progress(&self) -> bool {
self.state.is_in_progress()
}
/// Check if handshake completed.
pub fn is_complete(&self) -> bool {
self.state.is_complete()
}
/// Check if handshake failed.
pub fn is_failed(&self) -> bool {
self.state.is_failed()
}
/// When the connection started.
pub fn started_at(&self) -> u64 {
self.state.started_at()
@@ -281,20 +258,15 @@ impl PeerConnection {
});
}
if self.state.handshake_state() != HandshakeState::Initial {
return Err(NoiseError::WrongState {
expected: "initial state".to_string(),
got: self.state.handshake_state().to_string(),
});
}
// XX initiator: no remote static needed upfront
// XX initiator: no remote static needed upfront. The old `!= Initial`
// phase guard is dropped — this method creates the Noise handshake
// handle, so there is no `take().expect()` to protect, and its Err path
// was unreachable in production (one call per fresh outbound leg).
let mut hs = noise::HandshakeState::new_initiator(our_keypair);
hs.set_local_epoch(epoch);
let msg1 = hs.write_message_1()?;
self.noise_handshake = Some(hs);
self.state.set_handshake_state(HandshakeState::SentMsg1);
self.state.touch(current_time_ms);
Ok(msg1)
@@ -324,13 +296,6 @@ impl PeerConnection {
});
}
if self.state.handshake_state() != HandshakeState::Initial {
return Err(NoiseError::WrongState {
expected: "initial state".to_string(),
got: self.state.handshake_state().to_string(),
});
}
let mut hs = noise::HandshakeState::new_responder(our_keypair);
hs.set_local_epoch(epoch);
@@ -346,10 +311,11 @@ impl PeerConnection {
msg2.extend_from_slice(&encrypted);
}
// XX: handshake NOT complete yet — need msg3.
// Keep the handshake state for complete_handshake_msg3().
// XX: handshake NOT complete yet — need msg3. Keep the Noise handshake
// handle for complete_handshake_msg3(); the phase (formerly
// `ReceivedMsg1`) now lives on the control machine, so no phase is set
// here. The handle's presence is the leg-local `ReceivedMsg1` signal.
self.noise_handshake = Some(hs);
self.state.set_handshake_state(HandshakeState::ReceivedMsg1);
self.state.touch(current_time_ms);
Ok(msg2)
@@ -370,10 +336,14 @@ impl PeerConnection {
negotiation_payload: Option<&[u8]>,
current_time_ms: u64,
) -> Result<(Vec<u8>, Option<Vec<u8>>), NoiseError> {
if self.state.handshake_state() != HandshakeState::SentMsg1 {
// The leg is at `SentMsg1` iff its Noise handshake handle is present
// (set by `start_handshake`, taken here on completion). Gate on the
// handle directly now that the phase enum is gone — byte-equivalent to
// the old `!= SentMsg1` guard for every reachable transition.
if self.noise_handshake.is_none() {
return Err(NoiseError::WrongState {
expected: "sent_msg1 state".to_string(),
got: self.state.handshake_state().to_string(),
got: "no active handshake".to_string(),
});
}
@@ -422,7 +392,6 @@ impl PeerConnection {
// Handshake complete for initiator
let session = hs.into_session()?;
self.noise_session = Some(session);
self.state.set_handshake_state(HandshakeState::Complete);
self.state.touch(current_time_ms);
Ok((msg3, received_negotiation))
@@ -440,10 +409,15 @@ impl PeerConnection {
message: &[u8],
current_time_ms: u64,
) -> Result<Option<Vec<u8>>, NoiseError> {
if self.state.handshake_state() != HandshakeState::ReceivedMsg1 {
// The responder leg is at `ReceivedMsg1` iff its Noise handshake handle
// is present (set by `receive_handshake_init`, taken here on msg3). Gate
// on the handle directly now that the phase enum is gone — byte-equivalent
// to the old `!= ReceivedMsg1` guard for every reachable transition, and
// it protects the `take().expect()` below.
if self.noise_handshake.is_none() {
return Err(NoiseError::WrongState {
expected: "received_msg1 state".to_string(),
got: self.state.handshake_state().to_string(),
got: "no active handshake".to_string(),
});
}
@@ -480,10 +454,10 @@ impl PeerConnection {
// Capture remote epoch from msg3
self.state.set_remote_epoch(hs.remote_epoch());
// Handshake complete for responder
// Handshake complete for responder. The completion signal is now the
// Noise session's presence (the phase enum is gone); no phase is set.
let session = hs.into_session()?;
self.noise_session = Some(session);
self.state.set_handshake_state(HandshakeState::Complete);
self.state.touch(current_time_ms);
Ok(received_negotiation)
@@ -494,24 +468,23 @@ 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.state.handshake_state() == HandshakeState::Complete {
self.noise_session.take()
} else {
None
}
// The session exists iff the handshake reached `Complete`, so taking it
// unconditionally is byte-equivalent to the old `== Complete` gate.
self.noise_session.take()
}
/// Check if we have a completed session ready to take.
pub fn has_session(&self) -> bool {
self.state.handshake_state() == HandshakeState::Complete && self.noise_session.is_some()
self.noise_session.is_some()
}
// === State Transitions (for manual control if needed) ===
/// Mark handshake as failed. Sets the pure lifecycle state and drops the
/// shell-owned crypto handshake handle.
/// Drop the shell-owned crypto handshake handle. The failure *state* now
/// lives on the control machine (`PeerMachine`); this only releases the
/// leg's Noise handle at the identical point it was released before, so a
/// subsequent `complete_handshake` on this leg still reports `WrongState`.
pub fn mark_failed(&mut self) {
self.state.mark_failed();
self.noise_handshake = None;
}
@@ -533,7 +506,6 @@ impl fmt::Debug for PeerConnection {
f.debug_struct("PeerConnection")
.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())
@@ -568,18 +540,6 @@ mod tests {
epoch
}
#[test]
fn test_handshake_state_properties() {
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::Failed.is_failed());
}
#[test]
fn test_outbound_connection() {
let identity = make_peer_identity();
@@ -587,7 +547,7 @@ mod tests {
assert!(conn.is_outbound());
assert!(!conn.is_inbound());
assert_eq!(conn.handshake_state(), HandshakeState::Initial);
assert!(!conn.has_session());
assert!(conn.expected_identity().is_some());
assert_eq!(conn.started_at(), 1000);
}
@@ -598,7 +558,7 @@ mod tests {
assert!(conn.is_inbound());
assert!(!conn.is_outbound());
assert_eq!(conn.handshake_state(), HandshakeState::Initial);
assert!(!conn.has_session());
assert!(conn.expected_identity().is_none());
assert_eq!(conn.started_at(), 2000);
}
@@ -625,16 +585,16 @@ mod tests {
let msg1 = initiator_conn
.start_handshake(initiator_keypair, initiator_epoch, 1100)
.unwrap();
assert_eq!(initiator_conn.handshake_state(), HandshakeState::SentMsg1);
// Post-msg1 the initiator holds an in-flight handshake, not yet a session.
assert!(!initiator_conn.has_session());
// Responder processes msg1 and sends msg2 (XX: does NOT complete yet)
let msg2 = responder_conn
.receive_handshake_init(responder_keypair, responder_epoch, &msg1, None, 1200)
.unwrap();
assert_eq!(
responder_conn.handshake_state(),
HandshakeState::ReceivedMsg1
);
// XX: the responder parks awaiting msg3 — it holds an in-flight
// handshake, not yet a session (the deleted phase was `ReceivedMsg1`).
assert!(!responder_conn.has_session());
// Responder does NOT know initiator's identity yet (XX property)
assert!(responder_conn.expected_identity().is_none());
@@ -642,16 +602,17 @@ mod tests {
let (msg3, _neg) = initiator_conn
.complete_handshake(&msg2, None, 1300)
.unwrap();
assert_eq!(initiator_conn.handshake_state(), HandshakeState::Complete);
// The initiator completes at msg3 generation: it now holds a session.
assert!(initiator_conn.has_session());
// Initiator learned responder's identity from msg2
let discovered = initiator_conn.expected_identity().unwrap();
assert_eq!(discovered.pubkey(), responder_identity.pubkey());
assert_eq!(initiator_conn.remote_epoch(), Some(responder_epoch));
// Responder processes msg3 (completes handshake)
// Responder processes msg3 (completes handshake): it now holds a session.
let _neg = responder_conn.complete_handshake_msg3(&msg3, 1400).unwrap();
assert_eq!(responder_conn.handshake_state(), HandshakeState::Complete);
assert!(responder_conn.has_session());
// Responder learned initiator's identity from msg3
let discovered = responder_conn.expected_identity().unwrap();
@@ -686,13 +647,19 @@ mod tests {
#[test]
fn test_connection_failure() {
// `mark_failed` releases the leg's Noise handshake handle. The failure
// *state* now lives on the control machine, but the leg-local effect is
// still observable: a completion attempt afterward reports `WrongState`
// (the handle-presence gate) and no session is produced.
let identity = make_peer_identity();
let keypair = make_keypair();
let mut conn = PeerConnection::outbound(LinkId::new(1), identity, 1000);
conn.start_handshake(keypair, make_epoch(), 1100).unwrap();
conn.mark_failed();
assert!(conn.is_failed());
assert!(!conn.is_in_progress());
assert!(!conn.is_complete());
assert!(!conn.has_session());
assert!(conn.complete_handshake(&[0u8; 96], None, 1200).is_err());
}
#[test]
+182 -4
View File
@@ -162,6 +162,57 @@ pub(crate) enum HandshakePhase {
SentMsg2,
}
/// Map a lifecycle state to the operator-visible pending-connection handshake
/// string. Total over `PeerState`; byte-identical to the strings the deleted
/// leg `HandshakeState` `Display` produced for every (leg, machine) pairing that
/// rests in the pending-connection view. On XX three arms are
/// production-reachable in that view (verified against the pre-collapse leg
/// display):
/// - `Handshaking{SentMsg1}` → `"sent_msg1"` (outbound identified leg).
/// - `Handshaking{SentMsg2}` → `"received_msg1"` (the inbound responder leg,
/// which parks at `SentMsg2` after replying to msg1 while the deleted leg
/// phase displayed `received_msg1` — a direction-aware synthesis with no leg
/// twin).
/// - `Discovered` → `"sent_msg1"` (GAP A: the anonymous-outbound leg rests with
/// its machine parked at `Discovered` while its leg is already at `SentMsg1`;
/// the anon path sends msg1 inline and dispatches no handshake-start event, so
/// advancing the machine to `Handshaking{SentMsg1}` here would arm a retransmit
/// timer the anon leg does not have — the overload is the neutral choice).
///
/// The `send_failed` → `"failed"` override is applied by the caller
/// ([`displayed_handshake_state`](PeerMachine::displayed_handshake_state)). The
/// remaining arms are kept total for a complete mapping and are not reachable in
/// the view.
fn handshake_state_str(state: PeerState) -> &'static str {
match state {
PeerState::Handshaking {
phase: HandshakePhase::Initial,
..
} => "initial",
PeerState::Handshaking {
phase: HandshakePhase::SentMsg1,
..
} => "sent_msg1",
PeerState::Handshaking {
phase: HandshakePhase::SentMsg2,
..
} => "received_msg1",
// GAP A: the anonymous-outbound leg rests in the view with its machine
// parked at `Discovered` while the leg itself is at `SentMsg1`, so it
// displayed `"sent_msg1"` before the collapse. The only in-view
// `Discovered`-with-leg case is this anon leg (identified dials leave
// `Discovered` atomically within their handler before any tick), so the
// overload preserves display parity byte-for-byte.
PeerState::Discovered => "sent_msg1",
PeerState::Established { .. }
| PeerState::Active { .. }
| PeerState::Maintaining { .. }
| PeerState::Closing { .. } => "complete",
PeerState::Failed { .. } => "failed",
PeerState::Connecting { .. } | PeerState::Closed { .. } => "initial",
}
}
/// Which maintenance sub-machine `Maintaining` is running.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MaintainKind {
@@ -461,6 +512,13 @@ pub(crate) struct PeerMachine {
conn: ConnectionState,
/// Remote startup epoch (establish-path-only; NOT in send-state).
remote_epoch: Option<[u8; 8]>,
/// A stored-handshake send failure was observed on this leg. The failure is
/// carried as a flag (not a `PeerState::Failed` transition) so retransmit
/// eligibility (`is_handshaking_sent_msg1`) survives until the
/// stale-connection sweep reclaims the leg. It drives both `is_failed`
/// (reaping) and the displayed handshake state (`"failed"`), reproducing the
/// pre-collapse leg `is_failed`/display signal byte-for-byte.
send_failed: bool,
// --- rekey negotiation sub-state (control tier; NOT the pending send slot) ---
rekey_in_progress: bool,
@@ -509,6 +567,7 @@ impl PeerMachine {
None => ConnectionState::outbound_anonymous(link, now),
},
remote_epoch: None,
send_failed: false,
rekey_in_progress: false,
rekey_our_index: None,
rekey_msg1: None,
@@ -537,6 +596,7 @@ impl PeerMachine {
leg: None,
conn: ConnectionState::inbound(link, now),
remote_epoch: None,
send_failed: false,
rekey_in_progress: false,
rekey_our_index: None,
rekey_msg1: None,
@@ -605,6 +665,7 @@ impl PeerMachine {
leg: None,
conn,
remote_epoch,
send_failed: false,
rekey_in_progress: false,
rekey_our_index: None,
rekey_msg1: None,
@@ -708,6 +769,41 @@ impl PeerMachine {
)
}
/// Whether this peer's handshake has failed. The sole failure carrier now
/// that the leg's phase enum is gone: a terminal `PeerState::Failed`
/// (hard crypto/transport/ACL failures) OR the `send_failed` flag (a stored
/// handshake-initiation send failure that deliberately keeps the machine at
/// `Handshaking{SentMsg1}`). The stale-connection sweep reads this to reclaim
/// the leg, exactly as it read the leg's `is_failed` before.
pub(crate) fn is_failed(&self) -> bool {
matches!(self.state, PeerState::Failed { .. }) || self.send_failed
}
/// The operator-visible handshake-state string for the pending-connection
/// view, derived from the machine phase. Byte-identical to the strings the
/// leg's `HandshakeState` `Display` produced before the phase collapsed onto
/// the machine. A `send_failed` leg renders `"failed"` while its phase stays
/// `SentMsg1`, matching the pre-collapse leg display.
pub(crate) fn displayed_handshake_state(&self) -> &'static str {
if self.send_failed {
return "failed";
}
handshake_state_str(self.state)
}
/// Record a handshake failure that the shell observed on the leg (e.g. a
/// `complete_handshake` that rejected msg2), WITHOUT leaving the current
/// handshake phase. Mirrors the `HandshakeSendFailed` carve-out: the failure
/// is carried as `send_failed`, so the machine PHASE is unchanged (matching
/// the pre-collapse behavior, where the shell marked only the leg failed and
/// left the machine in place) while `is_failed`/display report the failure.
/// The stale-connection sweep reclaims the leg via
/// [`is_failed`](Self::is_failed) at the next tick, before any projection or
/// resend.
pub(crate) fn mark_send_failed(&mut self) {
self.send_failed = true;
}
/// The crystallized node address, if identity is known.
fn addr(&self) -> Option<NodeAddr> {
self.node_addr
@@ -849,8 +945,11 @@ impl PeerMachine {
/// actions are emitted.
fn on_handshake_send_failed(&mut self) -> Vec<PeerAction> {
if let Some(leg) = self.leg.as_mut() {
// Drop the leg's Noise handshake handle at the identical point as
// before; the failure *state* is recorded on the machine.
leg.mark_failed();
}
self.send_failed = true;
Vec::new()
}
@@ -2872,7 +2971,8 @@ mod tests {
);
m.set_leg(PeerConnection::outbound(LinkId::new(1), peer, 100));
assert!(m.is_handshaking_sent_msg1());
assert!(!m.leg().expect("leg embedded").is_failed());
assert!(!m.is_failed());
assert_eq!(m.displayed_handshake_state(), "sent_msg1");
let actions = m.step(PeerEvent::HandshakeSendFailed, 200, &mut alloc);
assert_eq!(actions, Vec::new(), "HandshakeSendFailed emits no actions");
@@ -2881,11 +2981,17 @@ mod tests {
"retransmit eligibility survives a send failure"
);
assert!(
m.leg().expect("leg retained").is_failed(),
"the leg carries the failed mark the sweep reads"
m.is_failed(),
"the machine carries the failed mark the sweep reads"
);
assert_eq!(
m.displayed_handshake_state(),
"failed",
"the send-failed leg still displays as failed"
);
// With no leg (e.g. after take_leg) the event is a defensive no-op.
// With no leg (e.g. after take_leg) the event stays a defensive no-op
// for the leg handle and keeps the state retransmit-eligible.
let _ = m.take_leg();
let actions = m.step(PeerEvent::HandshakeSendFailed, 300, &mut alloc);
assert_eq!(actions, Vec::new());
@@ -2893,6 +2999,78 @@ mod tests {
assert!(m.leg().is_none());
}
// ---- Test 7f: handshake_state_str is a total, byte-identical mapping ---
// Pins the displayed-string derivation for every `PeerState` arm against the
// strings the deleted leg `HandshakeState` `Display` produced.
#[test]
fn handshake_state_str_total_mapping() {
let link = LinkId::new(1);
let addr = *peer_identity().node_addr();
assert_eq!(
handshake_state_str(PeerState::Handshaking {
link,
phase: HandshakePhase::Initial,
}),
"initial"
);
assert_eq!(
handshake_state_str(PeerState::Handshaking {
link,
phase: HandshakePhase::SentMsg1,
}),
"sent_msg1"
);
assert_eq!(
handshake_state_str(PeerState::Handshaking {
link,
phase: HandshakePhase::SentMsg2,
}),
"received_msg1"
);
assert_eq!(
handshake_state_str(PeerState::Established { addr }),
"complete"
);
assert_eq!(handshake_state_str(PeerState::Active { addr }), "complete");
assert_eq!(
handshake_state_str(PeerState::Maintaining {
addr,
kind: MaintainKind::Mtu,
}),
"complete"
);
assert_eq!(
handshake_state_str(PeerState::Closing {
addr,
reason: CloseReason::Requested,
}),
"complete"
);
assert_eq!(
handshake_state_str(PeerState::Failed {
reason: FailReason::HandshakeFailed,
}),
"failed"
);
// GAP A: the anonymous-outbound leg rests at `Discovered` while its leg
// is at `SentMsg1`, so the pending-connection view displayed "sent_msg1".
assert_eq!(handshake_state_str(PeerState::Discovered), "sent_msg1");
assert_eq!(
handshake_state_str(PeerState::Connecting { link }),
"initial"
);
assert_eq!(
handshake_state_str(PeerState::Closed {
backoff_deadline_ms: 0,
}),
"initial"
);
}
// ---- Test 8: liveness -> LinkDeadSuspected -> ReportLost --------------
#[test]
fn liveness_to_link_dead() {
+1 -1
View File
@@ -11,7 +11,7 @@ mod connection;
pub(crate) mod machine;
pub use active::{ActivePeer, ConnectivityState};
pub use connection::{HandshakeState, PeerConnection};
pub use connection::PeerConnection;
use crate::NodeAddr;
use crate::transport::LinkId;
+2 -3
View File
@@ -20,8 +20,8 @@
//! - `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`.
//! handles), plus [`Fmp`], the (stateless) lifecycle anchor owned by `Node`.
//! The handshake phase itself lives on the per-peer control machine.
//! - `wire.rs` — the FMP wire codec: XX handshake message types, disconnect
//! reasons, the orderly disconnect message, and the negotiation payload.
//! Also carries the relocated FMP link wire framing (moved from
@@ -43,7 +43,6 @@ pub(crate) use core::{
};
pub use core::{PromotionResult, cross_connection_winner};
pub(crate) use limits::backoff_ms;
pub use state::HandshakeState;
pub(crate) use state::{ConnectionState, Fmp};
pub(crate) use wire::{Disconnect, DisconnectReason};
pub use wire::{HandshakeMessageType, NegotiationPayload, NodeProfile, TlvEntry};
+8 -102
View File
@@ -1,19 +1,20 @@
//! 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.
//! connection — link/direction identity, learned peer identity and epoch,
//! index/transport/address tracking, handshake-resend scheduling, and link
//! statistics — extracted out of the async node shell. The handshake phase
//! itself lives solely on the per-peer control machine
//! ([`PeerMachine`](crate::peer::machine::PeerMachine)), not here.
//!
//! [`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`).
//! shell's XX transition methods drive the Noise objects, then write learned
//! results back through the pure setters here (`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`
@@ -31,59 +32,6 @@ use super::wire::NodeProfile;
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 XX pattern:
/// - Initiator: Initial → SentMsg1 → Complete (after processing msg2 + sending msg3)
/// - Responder: Initial → ReceivedMsg1 → Complete (after processing msg3)
#[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.
///
@@ -101,10 +49,6 @@ pub struct ConnectionState {
/// 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>,
@@ -171,7 +115,6 @@ impl ConnectionState {
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,
@@ -199,7 +142,6 @@ impl ConnectionState {
Self {
link_id,
direction: LinkDirection::Outbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
@@ -225,7 +167,6 @@ impl ConnectionState {
Self {
link_id,
direction: LinkDirection::Inbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
@@ -255,7 +196,6 @@ impl ConnectionState {
Self {
link_id,
direction: LinkDirection::Inbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
@@ -285,11 +225,6 @@ impl ConnectionState {
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()
@@ -305,21 +240,6 @@ impl ConnectionState {
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
@@ -425,20 +345,6 @@ impl ConnectionState {
self.peer_profile = Some(peer_profile);
}
// === 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.
+5 -45
View File
@@ -1,9 +1,9 @@
//! 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.
//! Unit tests for the pure FMP connection state ([`ConnectionState`]). These
//! exercise the extracted bookkeeping directly, with no crypto involved; the
//! crypto-driving transition behavior is covered by the shell `peer::connection`
//! suite, and the handshake phase itself lives on the control machine.
use crate::proto::fmp::{ConnectionState, HandshakeState, NodeProfile};
use crate::proto::fmp::{ConnectionState, NodeProfile};
use crate::transport::{LinkId, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
use crate::{Identity, PeerIdentity};
@@ -12,21 +12,6 @@ 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();
@@ -34,8 +19,6 @@ fn outbound_initializes_pure_fields() {
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);
@@ -53,7 +36,6 @@ fn inbound_initializes_pure_fields() {
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);
}
@@ -111,27 +93,6 @@ fn identity_and_epoch_setters() {
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);
@@ -182,7 +143,6 @@ fn outbound_anonymous_initializes_pure_fields() {
let state = ConnectionState::outbound_anonymous(LinkId::new(5), 4000);
assert!(state.is_outbound());
assert!(!state.is_inbound());
assert_eq!(state.handshake_state(), HandshakeState::Initial);
assert!(state.expected_identity().is_none());
assert_eq!(state.link_id(), LinkId::new(5));
assert_eq!(state.started_at(), 4000);