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
+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;