Files
fips/src/peer/machine.rs
T

3541 lines
139 KiB
Rust

//! Per-peer FMP control FSM (sans-IO reducer).
//!
//! The unified per-peer lifecycle state machine that folds the scattered
//! `connections`/`peers`/rekey state carriers into one place. It provides the
//! FSM types, the machine struct (control-tier state only), and the pure `step`
//! reducer, plus its unit tests. `step` is driven in production by the
//! handshake handlers, the rekey-cadence and liveness-reap routers, and the
//! dial/lifecycle paths, with the executor in
//! `crate::node::dataplane::peer_actions` performing the returned actions.
//! Still dormant: `PeerEvent::Timeout` is never dispatched — timer FIRING
//! decisions stay with the shell drivers.
//!
//! ## Shape
//!
//! `step(event, now, index_allocator) -> Vec<PeerAction>` is a **pure reducer**:
//! every lifecycle *decision* is delegated to the existing sans-IO cores in
//! [`crate::proto::fmp`] ([`Fmp::establish_inbound`]/[`establish_outbound`],
//! [`Fmp::poll_timeouts`]/[`poll_resends`]/[`poll_rekey`]/[`poll_rekey_resends`],
//! and `cross_connection_winner`) — this module writes **no new decision
//! core**. The machine only (a) builds the plain-data snapshots those cores
//! consume from its control-tier state, (b) maps the returned
//! [`ConnAction`]/[`InboundDecision`]/[`OutboundDecision`]/[`PromotionResult`]
//! into the [`PeerAction`] vocabulary the driver executes, and (c) advances its
//! own control state. Shell-side effects (the Noise wire step, `promote_connection`
//! registry surgery, late ACL `authorize_peer`, decrypt-worker register/unregister)
//! are **emitted as actions**, never performed here.
//!
//! ## Control / send-state split
//!
//! The machine holds **control-tier** state only. The hot send-critical state
//! (the three epoch slots, transport target, connected-UDP handle, hot counters)
//! becomes `PeerSendState` and is *not* built here; the machine emits
//! actions (`PromoteToActive`, `SwapSendState`, `RegisterDecryptSession`, …) that
//! the driver applies to the published send-state. The remote startup epoch is
//! establish-path-only, hence control-tier, and lives on `conn` as the sole
//! carrier (`conn_remote_epoch`).
//!
//! ## Realizability notes
//!
//! - `SendHandshake`/`SendRekey`/`SendLinkMessage` carry **opaque bytes**
//! (`Vec<u8>`) — the driver applies outer wire framing / encryption. On the
//! resend paths the bytes are the stored wire frame; on a fresh inbound msg2 /
//! rekey msg2 they are the Noise payload the shell already produced
//! ([`WireOutcome::msg2_payload`]). A fresh outbound msg1 has no bytes the
//! control machine can build (the Noise step is shell-side), so it is emitted
//! with an empty payload and a note that the driver fills it in — that path is
//! not exercised by the tests.
//! - `SendLinkMessage { msg }` is opaque plaintext (there is **no** unifying
//! `LinkMessage` type in the tree today — heartbeat is a bare `[0x51]` byte,
//! while filter/tree/disconnect are distinct concrete types). The machine
//! builds the real heartbeat and disconnect frames; filter/tree announce
//! payloads are data-plane-owned and threaded in by the driver (empty here).
//! - `PeerSnapshot::counter` (the Noise send counter) is a send-state fact the
//! control machine cannot see; it is passed as `0` (the message-count rekey
//! trigger is threaded from `PeerSendState`). Irrelevant to every
//! test (cutover/drain ignore it).
#![allow(dead_code)]
use crate::identity::ErasingKeypair;
use crate::noise::{self, NoiseError, NoiseSession};
use crate::proto::fmp::{
ConnAction, ConnSnapshot, ConnectionState, EstablishSnapshot, Fmp, InboundDecision,
OutboundDecision, OutboundSnapshot, PeerSnapshot, PromotionResult, RekeyCfg,
RekeyResendSnapshot, RekeyRole, WireOutcome,
};
use crate::proto::link::LinkMessageType;
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
use crate::utils::index::{IndexAllocator, SessionIndex};
use crate::{NodeAddr, PeerIdentity};
use secp256k1::Keypair;
// ============================================================================
// Timing placeholders
//
// The `poll_*` cores already take the interval/backoff as arguments, so these
// are only used to compute `SetTimer{at_ms}` deadlines and the
// `Closed{backoff_deadline_ms}` park time. The handshake timers are armed
// live at dial time from these constants: the retransmit driver fires on the
// machine-armed deadline, while the timeout reaper keys on the timer's
// presence with its threshold read from `NodeConfig`, which also governs the
// reschedule cadence shell-side. The unit tests assert on timer *kinds*, not
// exact deadlines.
// ============================================================================
const HANDSHAKE_RETRANSMIT_INTERVAL_MS: u64 = 1_000;
const HANDSHAKE_TIMEOUT_MS: u64 = 30_000;
const HANDSHAKE_MAX_RESENDS: u32 = 5;
const RESEND_BACKOFF: f64 = 2.0;
const REKEY_CADENCE_INTERVAL_MS: u64 = 60_000;
const REKEY_RESEND_INTERVAL_MS: u64 = 1_000;
const REKEY_MAX_RESENDS: u32 = 5;
// `REKEY_AFTER_SECS`, `REKEY_AFTER_MESSAGES` and `LIVENESS_INTERVAL_MS` below
// are placeholders pinned to today's `RekeyConfig` and `NodeConfig` defaults.
// They are not a wiring to the config: nothing here reads a config value, so
// an operator override is not tracked. They are what the machine falls back to
// until it is wired to config. The tie to the defaults is asserted by
// `rekey_constants_match_the_rekey_config_defaults` and
// `liveness_interval_matches_the_heartbeat_config_default` rather than stated
// in these declarations, because `Default for NodeConfig` is an ordinary impl
// and cannot be called from a `const` initializer.
const REKEY_AFTER_SECS: u64 = 120;
const REKEY_AFTER_MESSAGES: u64 = 65_536;
/// Drain-window deadline armed at rekey cutover. Sourced from the value that
/// actually governs the live drain so the two cannot drift; the armed timer
/// is currently stored and never fired (`drive_peer_timers` has no
/// `DrainExpiry` arm), so this is a stored-value correction, not a live
/// timing change.
const DRAIN_WINDOW_MS: u64 = crate::proto::fsp::limits::DRAIN_WINDOW_SECS * 1_000;
const LIVENESS_INTERVAL_MS: u64 = 10_000;
const REKEY_DAMPEN_MS: u64 = 30_000;
const CLOSED_BACKOFF_MS: u64 = 5_000;
// ============================================================================
// FSM types
// ============================================================================
/// The unified per-peer lifecycle state (subsumes today's `HandshakeState`,
/// `ConnectivityState`, and the rekey flags). Keyed by `LinkId` until
/// `Established` crystallizes the peer to its `NodeAddr`. **Terminal at
/// `Closed`** — re-dial is the reconciler's, not a self-transition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PeerState {
/// Reconciler intent recorded; no transport work started yet.
Discovered,
/// Outbound transport connect in flight (connection-oriented transports).
Connecting { link: LinkId },
/// Handshake phase; identity not yet crystallized.
Handshaking { link: LinkId, phase: HandshakePhase },
/// Handshake complete; identity crystallized; send-state published.
Established { addr: NodeAddr },
/// Steady state.
Active { addr: NodeAddr },
/// A maintenance sub-machine is running (rekey / liveness / mtu).
Maintaining { addr: NodeAddr, kind: MaintainKind },
/// Graceful teardown in flight.
Closing { addr: NodeAddr, reason: CloseReason },
/// Terminal failure; carries the diagnostic reason.
Failed { reason: FailReason },
/// Terminal; parked at the reconciler-computed backoff deadline.
Closed { backoff_deadline_ms: u64 },
}
/// Handshake phase (the in-progress arms of the peer lifecycle).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum HandshakePhase {
Initial,
SentMsg1,
ReceivedMsg1,
}
/// 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. Only the `Handshaking{SentMsg1}`
/// arm (and, via the `send_failed` flag handled by the caller, `"failed"`) is
/// production-reachable in the IK pending-connection view — the rest are kept
/// total for a complete mapping and the next-branch inbound window.
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::ReceivedMsg1,
..
} => "received_msg1",
PeerState::Established { .. }
| PeerState::Active { .. }
| PeerState::Maintaining { .. }
| PeerState::Closing { .. } => "complete",
PeerState::Failed { .. } => "failed",
PeerState::Discovered | PeerState::Connecting { .. } | PeerState::Closed { .. } => {
"initial"
}
}
}
/// Which maintenance sub-machine `Maintaining` is running.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MaintainKind {
Rekey(RekeyPhase),
Liveness(LivenessPhase),
Mtu,
}
/// Rekey negotiation / cutover phase.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RekeyPhase {
/// Rekey msg1 sent (initiator) or msg2 sent (responder); negotiation in flight.
Msg1Sent,
/// A pending post-rekey session is ready; awaiting the K-bit cutover.
PendingCutover,
/// Post-cutover drain window open.
Draining,
}
/// Liveness sub-phase.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LivenessPhase {
Stale,
Reconnecting,
}
/// Why a graceful close was requested.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CloseReason {
/// Operator/protocol requested — no loss report.
Requested,
/// Post-rekey drain-driven close.
Draining,
}
/// Terminal-failure reason (diagnostic only).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FailReason {
TransportFailed,
HandshakeTimeout,
HandshakeFailed,
AclRejected,
Rejected,
LinkDead,
}
/// A timer the machine schedules on the driver's quantized tick.
///
/// `Hash` lets it key the driver's per-peer timer store; `Ord` lets the driver
/// collect due kinds deterministically. Note the driver must fire
/// `HandshakeTimeout` before `HandshakeRetransmit` on a same-tick coincidence
/// (a reaped leg must not be resent), which is the reverse of this declaration
/// order — the driver orders explicitly rather than relying on the derived
/// ascending `Ord`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum TimerKind {
HandshakeRetransmit,
HandshakeTimeout,
RekeyCadence,
RekeyResend,
DrainExpiry,
Liveness,
}
/// Outcome of an outbound cross-connection resolution, observed by the control
/// machine after the shell has already applied the effect inline.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CrossConnOutcome {
/// The outbound session replaced the existing inbound one: the control
/// shadow adopts the new local and remote session indices.
Swap {
our_index: SessionIndex,
their_index: SessionIndex,
},
/// The existing inbound session was kept: the control shadow is unchanged.
Keep,
}
/// An input to the machine. Cross-registry facts ride in the payload as
/// plain-data snapshots ([`WireOutcome`]/[`EstablishSnapshot`]/[`OutboundSnapshot`])
/// built shell-side; `now` is the `step` parameter, never duplicated here.
///
/// Not `Debug`/`PartialEq`: the reused core snapshot payloads derive neither.
pub(crate) enum PeerEvent {
/// Reconciler dial intent. `connection_oriented` selects the outbound
/// path: connection-oriented transports open the transport first
/// (`OpenTransport` → `Connecting`); connectionless ones send msg1
/// immediately (`start_outbound_handshake` → `Handshaking`).
Dial {
transport_id: TransportId,
remote_addr: TransportAddr,
peer_identity: PeerIdentity,
connection_oriented: bool,
},
/// Connection-oriented transport connected.
TransportConnected,
/// Transport connect failed.
TransportFailed,
/// The transport accepted the dial but sending a stored handshake
/// initiation failed. The machine marks the embedded leg failed so the
/// stale-connection sweep reclaims it, WITHOUT leaving the handshaking
/// state — the retransmit driver may still resend in the window before
/// the sweep.
HandshakeSendFailed,
/// Inbound handshake msg1 processed shell-side (Noise + snapshot).
InboundMsg1 {
link: LinkId,
wire: WireOutcome,
est: EstablishSnapshot,
},
/// Outbound handshake completed (their msg2 received + Noise finalized).
Msg2 {
their_index: SessionIndex,
out: OutboundSnapshot,
},
/// Late-ACL authorization succeeded (benign confirmation).
Authorized,
/// Late-ACL authorization rejected.
Rejected,
/// `promote_connection` resolved the [`PromoteToActive`](PeerAction::PromoteToActive)
/// action shell-side; the machine consumes the outcome (it does not
/// re-decide the tie-break).
PromotionResolved { result: PromotionResult },
/// Inbound rekey msg1 (a msg1 on an established peer).
RekeyMsg1 {
wire: WireOutcome,
est: EstablishSnapshot,
},
/// Inbound rekey msg2 (completes our initiated rekey).
RekeyMsg2 { their_index: SessionIndex },
/// A cadence-decided rekey `ConnAction` to CONSUME. The shell ran the
/// batch `poll_rekey` across the whole peer set (phase-grouped, index-order
/// preserving) and routes each decided action here; the machine
/// applies the control-tier transition + emits the send-state write
/// (`SwapSendState`/`CompleteDrain`) WITHOUT re-polling. Carries only
/// `Cutover`/`Drain` (`InitiateRekey` stays inline shell-side
/// with a [`RekeyInitiated`](PeerEvent::RekeyInitiated) observation).
RekeyConsume { action: ConnAction },
/// OBSERVATION: the shell initiated an outbound rekey inline (the Noise msg1
/// leaf + index allocation are shell-side). Advances the control state to
/// `Maintaining{Rekey(Msg1Sent)}` so the next tick's `Cutover`/`Drain` consume
/// transitions from a coherent phase. Emits no action.
RekeyInitiated,
/// OBSERVATION: the shell resolved an outbound cross-connection inline (a
/// session swap or keep, with the registry and index surgery already
/// applied). Reconciles the control shadow's session indices with reality
/// on a swap; leaves them untouched on a keep. Emits no action.
CrossConnResolved { outcome: CrossConnOutcome },
/// Data plane observed the responder K-bit flip inline.
PeerKbitFlip { epoch: [u8; 8] },
/// A filter announce is due for this peer.
FilterAnnounce,
/// A tree announce is due for this peer.
TreeAnnounceDue,
/// MMP saw a packet from the peer.
PeerHeard,
/// A keepalive heartbeat is due.
HeartbeatDue,
/// MMP declared the link dead.
LinkDeadSuspected,
/// A machine timer fired on the tick.
Timeout { kind: TimerKind },
/// Graceful disconnect requested.
Disconnect { reason: CloseReason },
/// The periodic quantized tick.
Tick,
}
/// Why a peer was reported lost. Selects the reconciler reflex the executor
/// routes the `ReportLost` token to: an un-promoted handshake attempt that
/// failed (`HandshakeTimeout`, connected-guarded like the old `schedule_retry`)
/// versus an established peer whose link died (`LinkDead`, unconditional like
/// the old `schedule_reconnect`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LostKind {
/// An outbound handshake attempt timed out or its dial failed before the
/// peer promoted — routes to the connected-guarded reflex.
HandshakeTimeout,
/// An established peer's link went dead or is being replaced — routes to
/// the unconditional reconnect reflex.
LinkDead,
}
/// An effect the driver executes on the machine's behalf. Runtime-agnostic
/// plain data — no tokio handles, time only as `at_ms` fields.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PeerAction {
/// Open a connection-oriented transport to the target.
OpenTransport {
transport_id: TransportId,
remote_addr: TransportAddr,
},
/// Transmit handshake bytes (driver applies outer framing; see module note).
SendHandshake { bytes: Vec<u8> },
/// Transmit rekey handshake bytes.
SendRekey { bytes: Vec<u8> },
/// Transmit an (encrypted) plaintext link-control frame. Opaque `Vec<u8>`
/// pending a unifying `LinkMessage` type; a future revision could type against
/// `proto::bloom::FilterAnnounce` / `proto::stp::TreeAnnounce` /
/// `proto::fmp::Disconnect` / a heartbeat marker.
SendLinkMessage { msg: Vec<u8> },
/// Crystallize identity, re-home the map key, publish send-state
/// (`promote_connection`). Resolves to a [`PromotionResolved`](PeerEvent::PromotionResolved).
PromoteToActive { link: LinkId },
/// A DECISION conveyed to the driver, not an effect: emitted by the
/// outbound-msg2 arm when the establish decision is a cross-connection
/// resolution. The shell intercepts it and runs the inline swap/keep
/// resolution; it must never reach the action executor.
ResolveCrossConnection { swap: bool },
/// Initiator-side rekey cutover: swap the published send-state to the pending
/// epoch. The remote epoch is not carried here: `conn` is its sole carrier
/// and promotion reads it from there via `conn_remote_epoch`.
SwapSendState,
/// Complete an initiator-side rekey drain: retire the previous session slot
/// (drop its `peers_by_index`/decrypt-worker entry, free its index). The
/// executor reads the REAL previous index from `ActivePeer::complete_drain`
/// (not a machine-shadow index, which can drift).
CompleteDrain { peer: NodeAddr },
/// Invalidate the published send-state (close/loss).
InvalidateSendState,
/// Register a decrypt-worker entry for `index`.
RegisterDecryptSession { index: SessionIndex },
/// Unregister the decrypt-worker entry for `index`.
UnregisterDecryptSession { index: SessionIndex },
/// Free `index` back to the shared allocator.
FreeIndex { index: SessionIndex },
/// Activate the per-peer connected-UDP plane.
ActivateConnectedUdp,
/// Tear down the per-peer connected-UDP plane.
TeardownConnectedUdp,
/// Schedule `kind` to fire at `at_ms` on the tick.
SetTimer { kind: TimerKind, at_ms: u64 },
/// Cancel a scheduled timer.
CancelTimer { kind: TimerKind },
/// Report the peer lost to the reconciler (the single loss token — there is
/// deliberately no `ScheduleRetry` machine action). `kind` selects the
/// reflex (handshake-timeout vs link-dead) the executor routes to.
ReportLost { peer: NodeAddr, kind: LostKind },
}
// ============================================================================
// The machine (control tier)
// ============================================================================
/// The handshake operations are only reachable while a pending connection is
/// attached; every path that drives the crypto attaches it first.
fn no_pending_connection() -> NoiseError {
NoiseError::WrongState {
expected: "attached connection".to_string(),
got: "no connection".to_string(),
}
}
/// The handshake-phase Noise crypto, owned by the control machine.
///
/// PRESENCE OF THIS STRUCT (`PeerMachine::leg().is_some()`) IS THE
/// HANDSHAKE-PHASE CARRIER SIGNAL — it is what `Node::connections()`,
/// `connection_count()`, the stale-connection sweep, the transport-in-use
/// check, and the peering budget all key on. It is attached and detached at
/// exactly the points the pending connection was, and its presence is NOT a
/// function of whether either handle is populated.
///
/// A present-but-empty value is legal and load-bearing: `mark_failed` drops
/// the initiation handle while deliberately retaining the carrier so the
/// sweep can reclaim it, and `take_session` empties the other. Deriving
/// presence from handle presence would make every failed handshake invisible
/// to the sweep — a permanent leak. See the presence tests in this module.
pub(crate) struct HandshakeCrypto {
/// Noise handshake state (consumed on completion).
pub(crate) noise_handshake: Option<noise::HandshakeState>,
/// Completed Noise session (available once the handshake completes).
pub(crate) noise_session: Option<NoiseSession>,
}
impl HandshakeCrypto {
/// A fresh carrier holding neither handle, as every handshake begins.
pub(crate) fn new() -> Self {
Self {
noise_handshake: None,
noise_session: None,
}
}
}
impl std::fmt::Debug for HandshakeCrypto {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HandshakeCrypto")
.field("has_noise_handshake", &self.noise_handshake.is_some())
.field("has_noise_session", &self.noise_session.is_some())
.finish()
}
}
/// Per-peer control FSM. Holds control-tier lifecycle state only; the
/// send-critical state is published as `PeerSendState` and mutated via the
/// emitted [`PeerAction`]s.
pub(crate) struct PeerMachine {
state: PeerState,
link: LinkId,
identity: Option<PeerIdentity>,
/// The handshake-phase Noise crypto this machine owns while it is in the
/// handshake window. `None` before the handshake begins (the dial window)
/// and after promotion consumes it (the machine survives as the active
/// peer's control machine). Its presence — not the state of the handles
/// inside it — is what marks this machine as carrying a pending
/// handshake; see [`HandshakeCrypto`].
leg: Option<HandshakeCrypto>,
/// Pure handshake-phase bookkeeping (link/direction/indices/transport/
/// stored handshake bytes/epoch). Reused verbatim from the FMP state core.
conn: ConnectionState,
/// Inbound two-phase authorize: the opaque Noise msg2
/// payload stashed in Phase 1 (`InboundMsg1`) and emitted in Phase 2
/// (`on_authorized`), so a rejected/unauthorized msg1 allocates no index.
pending_msg2_payload: Option<Vec<u8>>,
/// 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,
/// The index we allocated for our in-flight/negotiated rekey session.
rekey_our_index: Option<SessionIndex>,
/// Stored rekey msg1 wire bytes (for retransmit).
rekey_msg1: Option<Vec<u8>>,
rekey_resend_count: u32,
/// When we last processed a peer rekey msg1 (dampening).
last_peer_rekey_ms: u64,
// --- timing (control tier) ---
session_established_at_ms: u64,
authenticated_at_ms: u64,
rekey_jitter_secs: i64,
last_heartbeat_sent_ms: u64,
// --- decrypt-registration drain window ---
// The machine owns decrypt-worker register/unregister via actions. The
// currently-registered index lives on the surviving carrier (`conn`); this
// field tracks the previous index held open during a post-cutover drain
// window (to later unregister/free). Control knowledge of the registration
// lifecycle, distinct from the hot send-state slots.
/// The previous index held open during a post-cutover drain window.
draining_index: Option<SessionIndex>,
}
impl PeerMachine {
/// New outbound machine (we dial). Starts at `Discovered`; the reconciler's
/// `Dial` event drives the first transition.
pub(crate) fn new_outbound(link: LinkId, identity: PeerIdentity, now: u64) -> Self {
Self {
state: PeerState::Discovered,
link,
identity: Some(identity),
leg: None,
conn: ConnectionState::outbound(link, identity, now),
pending_msg2_payload: None,
send_failed: false,
rekey_in_progress: false,
rekey_our_index: None,
rekey_msg1: None,
rekey_resend_count: 0,
last_peer_rekey_ms: 0,
session_established_at_ms: 0,
authenticated_at_ms: 0,
rekey_jitter_secs: 0,
last_heartbeat_sent_ms: 0,
draining_index: None,
}
}
/// New inbound machine (they dialed us). Starts at `Handshaking{Initial}`.
pub(crate) fn new_inbound(link: LinkId, now: u64) -> Self {
Self {
state: PeerState::Handshaking {
link,
phase: HandshakePhase::Initial,
},
link,
identity: None,
leg: None,
conn: ConnectionState::inbound(link, now),
pending_msg2_payload: None,
send_failed: false,
rekey_in_progress: false,
rekey_our_index: None,
rekey_msg1: None,
rekey_resend_count: 0,
last_peer_rekey_ms: 0,
session_established_at_ms: 0,
authenticated_at_ms: 0,
rekey_jitter_secs: 0,
last_heartbeat_sent_ms: 0,
draining_index: None,
}
}
/// Current lifecycle state.
pub(crate) fn state(&self) -> PeerState {
self.state
}
/// The handshake crypto carrier, if this machine is still in the
/// handshake window. Presence answers "is there a pending handshake
/// here", independently of whether either handle is populated.
pub(crate) fn leg(&self) -> Option<&HandshakeCrypto> {
self.leg.as_ref()
}
/// Take the handshake crypto carrier off the machine (promotion and
/// teardown consume it by value).
pub(crate) fn take_leg(&mut self) -> Option<HandshakeCrypto> {
self.leg.take()
}
/// Attach a handshake crypto carrier to the machine.
pub(crate) fn set_leg(&mut self, leg: HandshakeCrypto) {
self.leg = Some(leg);
}
// === Noise handshake operations ===
//
// Mechanism, not decision: these are called by the shell, are never
// reached from `step()`, and no event triggers them. Each drives the
// Noise crypto on the pending connection and records the results on both
// that connection and the surviving carrier, so a reader of either sees
// the same value at the same point.
/// Start the handshake as initiator and generate message 1.
///
/// For outbound connections only. Returns the handshake message to send.
/// The epoch is our startup epoch, encrypted into msg1 for restart detection.
pub(crate) fn start_handshake(
&mut self,
mut our_keypair: Keypair,
epoch: [u8; 8],
current_time_ms: u64,
) -> Result<Vec<u8>, NoiseError> {
// The parameter is this frame's own copy of the node's long-term
// private key, and the state checks below return before it is used.
// The guard clears it in place on every exit path, and makes no copy
// of its own for an early return to leave behind.
let our_keypair = ErasingKeypair::new(&mut our_keypair);
let msg1 = {
let direction = self.conn.direction();
let expected_identity = self.conn.expected_identity().copied();
let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?;
if direction != LinkDirection::Outbound {
return Err(NoiseError::WrongState {
expected: "outbound connection".to_string(),
got: "inbound connection".to_string(),
});
}
let remote_static = expected_identity
.expect("outbound must have expected identity")
.pubkey_full();
let mut kp = *our_keypair.get();
let mut hs = noise::HandshakeState::new_initiator(kp, remote_static);
kp.non_secure_erase();
hs.set_local_epoch(epoch);
let msg1 = hs.write_message_1()?;
leg.noise_handshake = Some(hs);
msg1
};
self.conn.touch(current_time_ms);
Ok(msg1)
}
/// Initialize responder and process incoming message 1.
///
/// For inbound connections only. Returns the handshake message 2 to send.
/// The epoch is our startup epoch, encrypted into msg2 for restart detection.
pub(crate) fn receive_handshake_init(
&mut self,
mut our_keypair: Keypair,
epoch: [u8; 8],
message: &[u8],
current_time_ms: u64,
) -> Result<Vec<u8>, NoiseError> {
// Same as `start_handshake`: the parameter copy outlives two early
// returns, so the guard clears it in place rather than an erase per
// exit path.
let our_keypair = ErasingKeypair::new(&mut our_keypair);
let (msg2, learned_identity, remote_epoch) = {
let direction = self.conn.direction();
let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?;
if direction != LinkDirection::Inbound {
return Err(NoiseError::WrongState {
expected: "inbound connection".to_string(),
got: "outbound connection".to_string(),
});
}
let mut kp = *our_keypair.get();
let mut hs = noise::HandshakeState::new_responder(kp);
kp.non_secure_erase();
hs.set_local_epoch(epoch);
// Process message 1 (this reveals the initiator's identity and epoch)
hs.read_message_1(message)?;
// 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");
let learned_identity = PeerIdentity::from_pubkey_full(remote_static);
// Capture remote epoch from msg1
let remote_epoch = hs.remote_epoch();
// Generate message 2
let msg2 = hs.write_message_2()?;
// Handshake is complete for responder
let session = hs.into_session()?;
leg.noise_session = Some(session);
(msg2, learned_identity, remote_epoch)
};
self.conn.set_expected_identity(learned_identity);
self.conn.set_remote_epoch(remote_epoch);
self.conn.touch(current_time_ms);
Ok(msg2)
}
/// Complete the handshake by processing message 2.
///
/// For outbound connections only (initiator completing handshake).
pub(crate) fn complete_handshake(
&mut self,
message: &[u8],
current_time_ms: u64,
) -> Result<(), NoiseError> {
let remote_epoch = {
let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?;
// The connection is at `SentMsg1` iff its Noise handshake handle is
// present (set by `start_handshake`, taken here on completion).
// Gating on the handle directly is byte-equivalent to the old
// `!= SentMsg1` guard for every reachable transition.
if leg.noise_handshake.is_none() {
return Err(NoiseError::WrongState {
expected: "sent_msg1 state".to_string(),
got: "no active handshake".to_string(),
});
}
// The slot is heap memory that outlives this call; clearing it once
// the handshake has left keeps both private keys from staying
// there. Unwrapping before clearing lets the handshake move
// straight from the slot to `hs`, with no second stack copy.
let mut hs = leg
.noise_handshake
.take()
.expect("noise handshake must exist in SentMsg1 state");
noise::clear_slot(&mut leg.noise_handshake);
hs.read_message_2(message)?;
// Capture remote epoch from msg2
let remote_epoch = hs.remote_epoch();
let session = hs.into_session()?;
leg.noise_session = Some(session);
remote_epoch
};
self.conn.set_remote_epoch(remote_epoch);
self.conn.touch(current_time_ms);
Ok(())
}
/// Take the completed Noise session.
///
/// Returns the NoiseSession for use in ActivePeer. Can only be called
/// once after the handshake completes.
pub(crate) fn take_session(&mut self) -> Option<NoiseSession> {
// The session exists iff the handshake reached `Complete`, so taking it
// unconditionally is byte-equivalent to the old `== Complete` gate.
// The slot is cleared as the session leaves, so its two traffic keys
// do not stay behind in the machine.
self.leg
.as_mut()
.and_then(|leg| noise::take_cleared(&mut leg.noise_session))
}
/// Check if we have a completed session ready to take.
pub(crate) fn has_session(&self) -> bool {
self.leg
.as_ref()
.is_some_and(|leg| leg.noise_session.is_some())
}
/// Drop the crypto handshake handle. The failure *state* lives on this
/// machine; this only releases the Noise handle at the identical point it
/// was released before, so a subsequent `complete_handshake` still reports
/// `WrongState`.
pub(crate) fn mark_failed(&mut self) {
if let Some(leg) = self.leg.as_mut() {
leg.noise_handshake = None;
}
}
/// The session index we allocated for this peer, read from the surviving
/// carrier. Populated once the index is allocated on either establish path
/// (inbound at `on_authorized`, outbound at msg1 preparation). `None` before
/// allocation (and after a rejected/unauthorized msg1). The inbound cutover
/// reads this to perform the shell registry surgery.
pub(crate) fn our_index(&self) -> Option<SessionIndex> {
self.conn.our_index()
}
/// The msg1 resend count for this outbound handshake leg. The per-peer
/// machine is the home for this counter — the timer driver advances it via
/// [`record_resend`](Self::record_resend) on each successful resend, and the
/// control-socket connection snapshot reads it here so the operator-visible
/// count follows the machine rather than the (now inert) shell connection.
pub(crate) fn resend_count(&self) -> u32 {
self.conn.resend_count()
}
/// Record a successful msg1 resend: advance the count and store the next
/// backoff deadline. The driver calls this only after the resend actually
/// went out (record-on-success — a failed send neither advances the count
/// nor reschedules), matching the pre-fold shell semantics.
pub(crate) fn record_resend(&mut self, next_resend_at_ms: u64) {
self.conn.record_resend(next_resend_at_ms);
}
/// Connection-start timestamp of the surviving carrier — the home for the
/// operator-visible `started_at_ms` now that the leg no longer projects it.
pub(crate) fn conn_started_at(&self) -> u64 {
self.conn.started_at()
}
/// Last-activity timestamp of the surviving carrier — the home for the
/// operator-visible `last_activity_ms`/`idle_ms` and the idle-timeout check.
pub(crate) fn conn_last_activity(&self) -> u64 {
self.conn.last_activity()
}
/// Whether the carrier has been idle past `timeout_ms`.
pub(crate) fn conn_is_timed_out(&self, now_ms: u64, timeout_ms: u64) -> bool {
self.conn.is_timed_out(now_ms, timeout_ms)
}
/// Expected peer identity of the surviving carrier — the home for the
/// operator-visible `expected_peer` now that the leg no longer projects it.
/// Outbound carries the dial identity from construction; inbound records
/// the identity discovered in msg1, written here by `receive_handshake_init`
/// at the same point it reaches the pending connection. Everything that
/// names a peer mid-handshake reads this, including the stale-connection
/// sweep's retry address.
pub(crate) fn conn_expected_identity(&self) -> Option<&PeerIdentity> {
self.conn.expected_identity()
}
/// Remote startup epoch of the surviving carrier, recorded by the
/// handshake operations at the message that reveals it (msg1 inbound,
/// msg2 outbound). Promotion reads it to seed the active peer and to
/// detect a peer restart across a reconnect.
pub(crate) fn conn_remote_epoch(&self) -> Option<[u8; 8]> {
self.conn.remote_epoch()
}
/// Stored wire-format msg1 of the surviving carrier — the resend source for
/// the outbound handshake retransmit, now that the leg no longer carries it.
pub(crate) fn conn_handshake_msg1(&self) -> Option<&[u8]> {
self.conn.handshake_msg1()
}
/// Stored wire-format msg2 of the surviving carrier — the resend source for a
/// duplicate msg1 while the inbound handshake is still pending.
pub(crate) fn conn_handshake_msg2(&self) -> Option<&[u8]> {
self.conn.handshake_msg2()
}
/// Store the wire-format msg1 for resend on the surviving carrier and record
/// the first resend deadline, mirroring the leg's start-of-handshake write.
pub(crate) fn set_conn_handshake_msg1(&mut self, msg1: Vec<u8>, first_resend_at_ms: u64) {
self.conn.set_handshake_msg1(msg1, first_resend_at_ms);
}
/// Store the wire-format msg2 for duplicate-msg1 resend on the surviving
/// carrier, mirroring the leg's responder write.
pub(crate) fn set_conn_handshake_msg2(&mut self, msg2: Vec<u8>) {
self.conn.set_handshake_msg2(msg2);
}
/// The link this machine controls.
pub(crate) fn link_id(&self) -> LinkId {
self.link
}
/// Which side opened this connection, read from the surviving carrier.
/// Seeded at construction: an outbound machine carries an outbound
/// connection state and an inbound machine an inbound one.
pub(crate) fn conn_direction(&self) -> LinkDirection {
self.conn.direction()
}
/// Whether we opened this connection.
pub(crate) fn conn_is_outbound(&self) -> bool {
self.conn.is_outbound()
}
/// Whether the peer opened this connection.
pub(crate) fn conn_is_inbound(&self) -> bool {
self.conn.is_inbound()
}
/// The peer's address on this link, read from the surviving carrier.
/// Written at the same three points the connection's own copy was: the
/// inbound seed, the outbound dial, and message-2 completion.
pub(crate) fn conn_source_addr(&self) -> Option<&TransportAddr> {
self.conn.source_addr()
}
pub(crate) fn set_conn_source_addr(&mut self, addr: TransportAddr) {
self.conn.set_source_addr(addr);
}
/// Peer session index of the surviving carrier — the source for the
/// promotion hand-off now that the leg no longer projects it.
pub(crate) fn conn_their_index(&self) -> Option<SessionIndex> {
self.conn.their_index()
}
/// Transport ID of the surviving carrier — the source for the promotion
/// hand-off, the stale-connection cleanup, and the msg1 resend send.
pub(crate) fn conn_transport_id(&self) -> Option<TransportId> {
self.conn.transport_id()
}
/// Link statistics of the surviving carrier — the seed copied into the
/// active peer at promotion.
pub(crate) fn conn_link_stats(&self) -> &LinkStats {
self.conn.link_stats()
}
/// Record the peer session index on the surviving carrier, so the
/// promotion hand-off reads it from the machine.
pub(crate) fn set_conn_their_index(&mut self, index: SessionIndex) {
self.conn.set_their_index(index);
}
/// Record the transport ID on the surviving carrier. Written on the
/// inbound establish path at msg1 and on the outbound dial.
pub(crate) fn set_conn_transport_id(&mut self, id: TransportId) {
self.conn.set_transport_id(id);
}
/// Record our session index on the surviving carrier. The outbound establish
/// path allocates the index shell-side and writes it on the leg; this paired
/// write keeps the carrier the single index home once the leg dissolves (the
/// inbound path writes the carrier directly at authorize).
pub(crate) fn set_conn_our_index(&mut self, index: SessionIndex) {
self.conn.set_our_index(index);
}
/// Adopt an explicit connection-start timestamp on the carrier, so the
/// surviving state keeps the leg's start provenance rather than the
/// dial-time construction default.
pub(crate) fn set_conn_started_at(&mut self, started_at_ms: u64) {
self.conn.set_started_at(started_at_ms);
}
/// Advance the carrier's last-activity timestamp — the machine-tier paired
/// write for the leg's handshake `touch`.
pub(crate) fn touch_conn(&mut self, now_ms: u64) {
self.conn.touch(now_ms);
}
/// Whether this is an outbound leg parked at `SentMsg1` — the only state in
/// which a msg1 resend is due. Mirrors `on_handshake_retransmit`'s guard so
/// the shell timer driver can gate without reaching into machine state.
pub(crate) fn is_handshaking_sent_msg1(&self) -> bool {
matches!(
self.state,
PeerState::Handshaking {
phase: HandshakePhase::SentMsg1,
..
}
)
}
/// 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.identity.map(|id| *id.node_addr())
}
// ------------------------------------------------------------------
// The reducer.
// ------------------------------------------------------------------
/// Advance the machine one event. Pure reducer: delegates every decision to
/// the sans-IO cores, maps their results into [`PeerAction`]s, and updates
/// control state. `index_allocator` is a synchronous capability (the
/// handshake/rekey need an index mid-transition), never moved in, never an
/// action.
pub(crate) fn step(
&mut self,
event: PeerEvent,
now: u64,
index_allocator: &mut IndexAllocator,
) -> Vec<PeerAction> {
match event {
PeerEvent::Dial {
transport_id,
remote_addr,
connection_oriented,
..
} => self.on_dial(transport_id, remote_addr, connection_oriented, now),
PeerEvent::TransportConnected => self.on_transport_connected(now),
PeerEvent::TransportFailed => self.on_transport_failed(now),
PeerEvent::HandshakeSendFailed => self.on_handshake_send_failed(),
PeerEvent::InboundMsg1 { link, wire, est } => {
let (_decision, actions) = self.inbound_msg1(link, &wire, est, now);
actions
}
PeerEvent::Msg2 { their_index, out } => {
self.on_msg2(their_index, out, now, index_allocator)
}
PeerEvent::Authorized => self.on_authorized(now, index_allocator),
PeerEvent::Rejected => self.fail(FailReason::AclRejected),
PeerEvent::PromotionResolved { result } => self.on_promotion_resolved(result, now),
PeerEvent::RekeyMsg1 { wire, est } => {
// A rekey msg1 is a msg1 on an established peer — same core.
let (_decision, actions) = self.inbound_msg1(self.link, &wire, est, now);
actions
}
PeerEvent::RekeyMsg2 { their_index } => self.on_rekey_msg2(their_index),
PeerEvent::RekeyConsume { action } => self.map_rekey_action(action, now),
PeerEvent::RekeyInitiated => self.on_rekey_initiated(),
PeerEvent::CrossConnResolved { outcome } => self.on_cross_conn_resolved(outcome),
PeerEvent::PeerKbitFlip { .. } => {
// Responder cutover is data-plane-owned: the machine only
// schedules the drain-window unregister. NO slot mutation.
vec![PeerAction::SetTimer {
kind: TimerKind::DrainExpiry,
at_ms: now + DRAIN_WINDOW_MS,
}]
}
PeerEvent::FilterAnnounce => vec![PeerAction::SendLinkMessage {
// Filter-announce payload is data-plane-owned; threaded in
// at wiring time.
msg: Vec::new(),
}],
PeerEvent::TreeAnnounceDue => vec![PeerAction::SendLinkMessage {
// Tree-announce payload is data-plane-owned.
msg: Vec::new(),
}],
PeerEvent::PeerHeard => self.on_peer_heard(now),
PeerEvent::HeartbeatDue => self.on_heartbeat_due(now),
PeerEvent::LinkDeadSuspected => self.on_link_dead(now),
PeerEvent::Timeout { kind } => self.on_timeout(kind, now),
PeerEvent::Disconnect { reason } => self.on_disconnect(reason, now),
PeerEvent::Tick => self.on_tick(now),
}
}
// ------------------------------------------------------------------
// Outbound establish
// ------------------------------------------------------------------
fn on_dial(
&mut self,
transport_id: TransportId,
remote_addr: TransportAddr,
connection_oriented: bool,
now: u64,
) -> Vec<PeerAction> {
if !matches!(self.state, PeerState::Discovered) {
return Vec::new();
}
self.conn.set_transport_id(transport_id);
if connection_oriented {
// Connection-oriented transports open the transport first; the
// executor's `OpenTransport` arm connects, then feeds
// `TransportConnected` → `start_outbound_handshake`.
self.state = PeerState::Connecting { link: self.link };
vec![PeerAction::OpenTransport {
transport_id,
remote_addr,
}]
} else {
// Connectionless transports have no connect step — send msg1
// immediately (the executor's `SendHandshake` msg1 branch performs
// the Noise leaf, framing, index alloc, and send).
self.start_outbound_handshake(now)
}
}
fn on_transport_connected(&mut self, now: u64) -> Vec<PeerAction> {
if !matches!(self.state, PeerState::Connecting { .. }) {
return Vec::new();
}
self.start_outbound_handshake(now)
}
fn on_transport_failed(&mut self, now: u64) -> Vec<PeerAction> {
if !matches!(self.state, PeerState::Connecting { .. }) {
return Vec::new();
}
let mut actions = Vec::new();
if let Some(peer) = self.addr() {
// Dial failure on an un-promoted leg routes like a handshake timeout
// (the connected-guarded reflex). Dormant today — no `TransportFailed`
// event is dispatched until the connection-oriented cutover (C5).
actions.push(PeerAction::ReportLost {
peer,
kind: LostKind::HandshakeTimeout,
});
}
self.state = PeerState::Closed {
backoff_deadline_ms: now + CLOSED_BACKOFF_MS,
};
actions
}
/// A stored handshake initiation failed to send: mark the embedded leg
/// failed so the stale-connection sweep (which reads the leg's
/// `is_failed`) reclaims it. NO state flip — the machine stays in
/// `Handshaking{SentMsg1}` so retransmit eligibility
/// (`is_handshaking_sent_msg1`) survives until the sweep, and no timer
/// actions are emitted.
fn on_handshake_send_failed(&mut self) -> Vec<PeerAction> {
// Drop the Noise handshake handle at the identical point as before;
// the failure *state* is recorded on the machine.
self.mark_failed();
self.send_failed = true;
Vec::new()
}
/// Emit msg1 and arm the retransmit/timeout timers. The Noise msg1
/// construction and its index allocation are shell-side effects performed by
/// the driver when it executes this action; an empty payload is emitted (see
/// module note). This path is not exercised by the tests.
fn start_outbound_handshake(&mut self, now: u64) -> Vec<PeerAction> {
let bytes = Vec::new();
self.state = PeerState::Handshaking {
link: self.link,
phase: HandshakePhase::SentMsg1,
};
vec![
PeerAction::SendHandshake { bytes },
PeerAction::SetTimer {
kind: TimerKind::HandshakeRetransmit,
at_ms: now + HANDSHAKE_RETRANSMIT_INTERVAL_MS,
},
PeerAction::SetTimer {
kind: TimerKind::HandshakeTimeout,
at_ms: now + HANDSHAKE_TIMEOUT_MS,
},
]
}
/// Outbound completion: compute the establish decision from the snapshot
/// via `establish_outbound`. `Promote` drives promotion via actions; the
/// Swap/Keep outcomes are conveyed as a
/// [`ResolveCrossConnection`](PeerAction::ResolveCrossConnection) decision
/// for the shell's inline resolution, which owns all effects (index
/// frees, session replacement) permanently.
fn on_msg2(
&mut self,
their_index: SessionIndex,
out: OutboundSnapshot,
_now: u64,
_alloc: &mut IndexAllocator,
) -> Vec<PeerAction> {
self.conn.set_their_index(their_index);
match Fmp::new().establish_outbound(&out) {
OutboundDecision::Promote => {
// Net-new: our outbound index (allocated at dial) already sits on
// the surviving carrier; it is the one we register once promotion
// resolves.
// The machine survives promotion (it becomes the active peer's
// control machine), so cancel the outbound handshake timers here
// or they would linger in the driver's store. A late fire would
// no-op against the non-`Handshaking` state, but leaving them
// armed is a timer leak.
vec![
PeerAction::CancelTimer {
kind: TimerKind::HandshakeRetransmit,
},
PeerAction::CancelTimer {
kind: TimerKind::HandshakeTimeout,
},
PeerAction::PromoteToActive { link: self.link },
]
}
OutboundDecision::CrossConnectionSwap => {
// Our outbound wins: convey the decision only. The shell's
// inline resolution swaps the peer to the outbound session and
// owns the index frees and session replacement.
vec![PeerAction::ResolveCrossConnection { swap: true }]
}
OutboundDecision::CrossConnectionKeep => {
// Our outbound loses: convey the decision only. The shell's
// inline resolution keeps the existing inbound session and
// frees the unused outbound index.
vec![PeerAction::ResolveCrossConnection { swap: false }]
}
}
}
// ------------------------------------------------------------------
// Inbound establish
// ------------------------------------------------------------------
/// Inbound msg1 (fresh or rekey): compute the establish decision for the
/// driver, alongside any machine-phase actions. The single
/// `establish_inbound` evaluation happens here; the driver routes on the
/// returned [`InboundDecision`] and owns the effect-bearing arm bodies
/// (the rekey-respond abandon/alloc/send/store, the duplicate resend, the
/// reject bookkeeping). Only the `Promote`/`RestartThenPromote` phase-1
/// actions (and the fresh-context reject state flip) are machine-side.
pub(crate) fn inbound_msg1(
&mut self,
link: LinkId,
wire: &WireOutcome,
est: EstablishSnapshot,
_now: u64,
) -> (InboundDecision, Vec<PeerAction>) {
let decision = Fmp::new().establish_inbound(&est, wire);
let actions = match &decision {
InboundDecision::Reject { .. } => {
// In an establish-leg context this fails the leg; on an
// established peer (rekey context) the msg1 is dropped and the
// peer keeps running (DualRekeyWon/PendingSession keep our rekey).
if self.is_established_context() {
Vec::new()
} else {
self.fail(FailReason::Rejected)
}
}
// The decision carries the stored msg2 bytes; the driver's inline
// resend owns the send. No machine state is touched.
InboundDecision::ResendMsg2 { .. } => Vec::new(),
// Decision-only: the driver resends the held rekey msg2 on the
// peer's established link. No machine state is touched.
InboundDecision::ResendRekeyMsg2 { .. } => Vec::new(),
// Decision-only: the driver's inline body owns the abandon, the
// index allocation, the framed msg2 send, the pending-session
// store, and the dampening stamp. The machine mutates nothing.
InboundDecision::RekeyRespond { .. } => Vec::new(),
InboundDecision::RestartThenPromote { peer } => {
let peer = *peer;
let mut actions = vec![PeerAction::InvalidateSendState];
if let Some(idx) = self.conn.our_index() {
actions.push(PeerAction::UnregisterDecryptSession { index: idx });
self.conn.clear_our_index();
}
// An established peer being replaced by a fresh inbound leg — the
// unconditional reconnect reflex (a live, cut-over producer).
actions.push(PeerAction::ReportLost {
peer,
kind: LostKind::LinkDead,
});
actions.extend(self.inbound_classify(link, wire));
actions
}
InboundDecision::Promote => self.inbound_classify(link, wire),
};
(decision, actions)
}
/// Inbound **Phase 1**: classify the fresh leg *without*
/// allocating an index. Records identity/their-index and stashes the
/// opaque msg2 payload, parking at `Handshaking{ReceivedMsg1}` — the
/// "awaiting Authorized" marker. The index allocation and the msg2/promote
/// emission happen in Phase 2 ([`Self::on_authorized`]) only after the
/// shell's late-ACL gate passes, so a rejected/unauthorized msg1 allocates
/// nothing (preserving the pre-refactor global index-allocation sequence).
fn inbound_classify(&mut self, link: LinkId, wire: &WireOutcome) -> Vec<PeerAction> {
self.identity = Some(wire.peer_identity);
self.conn.set_their_index(wire.their_index);
self.pending_msg2_payload = Some(wire.msg2_payload.clone());
self.state = PeerState::Handshaking {
link,
phase: HandshakePhase::ReceivedMsg1,
};
Vec::new()
}
/// Inbound **Phase 2**: the late-ACL gate passed shell-side.
/// Allocate our index NOW — the single inbound allocation point — record it
/// on `conn`, and emit the msg2 send + promotion. `RegisterDecryptSession`
/// follows on the `PromotionResolved{Promoted}` feedback. Guarded to
/// the inbound `ReceivedMsg1` phase so the benign outbound `Authorized`
/// confirmation stays a no-op (state `Handshaking{SentMsg1}` and every other
/// state fall through to `Vec::new()`).
fn on_authorized(&mut self, _now: u64, alloc: &mut IndexAllocator) -> Vec<PeerAction> {
if !matches!(
self.state,
PeerState::Handshaking {
phase: HandshakePhase::ReceivedMsg1,
..
}
) {
return Vec::new();
}
let our_index = match alloc.allocate() {
Ok(idx) => idx,
Err(_) => {
// Allocation exhausted: no index, no msg2, no promote. The shell
// records the reject + completes the rate-limiter bracket
// (mirrors the pre-refactor `handle_msg1` allocate-failure path).
self.state = PeerState::Failed {
reason: FailReason::Rejected,
};
return Vec::new();
}
};
self.conn.set_our_index(our_index);
let bytes = self.pending_msg2_payload.take().unwrap_or_default();
let link = self.link;
vec![
PeerAction::SendHandshake { bytes },
PeerAction::PromoteToActive { link },
]
}
// ------------------------------------------------------------------
// Promotion feedback
// ------------------------------------------------------------------
fn on_promotion_resolved(&mut self, result: PromotionResult, now: u64) -> Vec<PeerAction> {
match result {
PromotionResult::Promoted(addr) => {
self.identity_addr_set(addr);
self.crystallize(now);
self.register_current_index()
}
PromotionResult::CrossConnectionWon { node_addr, .. } => {
self.identity_addr_set(node_addr);
self.crystallize(now);
let mut actions = Vec::new();
// Free + unregister the old (losing) index, register ours.
if let Some(idx) = self.draining_index.take() {
actions.push(PeerAction::UnregisterDecryptSession { index: idx });
actions.push(PeerAction::FreeIndex { index: idx });
}
actions.extend(self.register_current_index());
actions
}
PromotionResult::CrossConnectionLost { .. } => {
let mut actions = Vec::new();
if let Some(idx) = self.conn.our_index() {
actions.push(PeerAction::FreeIndex { index: idx });
self.conn.clear_our_index();
}
self.state = PeerState::Failed {
reason: FailReason::HandshakeFailed,
};
actions
}
}
}
fn register_current_index(&self) -> Vec<PeerAction> {
match self.conn.our_index() {
Some(idx) => vec![PeerAction::RegisterDecryptSession { index: idx }],
None => Vec::new(),
}
}
// ------------------------------------------------------------------
// Rekey (initiator) + cutover
// ------------------------------------------------------------------
fn on_rekey_msg2(&mut self, their_index: SessionIndex) -> Vec<PeerAction> {
// Completes our initiated rekey: a pending session is ready to cut over.
self.conn.set_their_index(their_index);
self.rekey_in_progress = false;
if let PeerState::Maintaining { addr, .. } = self.state {
self.state = PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::PendingCutover),
};
}
// The pending peers_by_index registration is driver-side; no action here.
Vec::new()
}
/// Observation: the shell ran `initiate_rekey` inline — the Noise msg1 leaf,
/// the index allocation, the wire send, and the `set_rekey_state` on the
/// `ActivePeer` all happened shell-side. This is a pure observation that
/// advances the machine's control state to `Maintaining{Rekey(Msg1Sent)}` so
/// the subsequent cadence `Cutover`/`Drain` consume transitions from a
/// coherent phase. Emits NO action (nothing left to do). No-op unless the peer
/// is in an established-like state (defensive; the shell only initiates on
/// established peers).
fn on_rekey_initiated(&mut self) -> Vec<PeerAction> {
let addr = match self.addr() {
Some(a) => a,
None => return Vec::new(),
};
if !self.is_established_context() {
return Vec::new();
}
self.rekey_in_progress = true;
self.rekey_resend_count = 0;
self.state = PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent),
};
Vec::new()
}
/// Observation: the shell resolved an outbound cross-connection inline. On a
/// session swap it adopts the new local and remote session indices into the
/// control shadow, mirroring the shell's in-place session replacement; on a
/// keep it leaves the shadow untouched. Emits NO action — the crypto effect
/// already ran shell-side.
fn on_cross_conn_resolved(&mut self, outcome: CrossConnOutcome) -> Vec<PeerAction> {
if let CrossConnOutcome::Swap {
our_index,
their_index,
} = outcome
{
self.conn.set_our_index(our_index);
self.conn.set_their_index(their_index);
}
Vec::new()
}
/// Rekey cadence: run `poll_rekey` over this one peer's snapshot and map the
/// phase-grouped `ConnAction`s.
fn on_rekey_cadence(&mut self, now: u64) -> Vec<PeerAction> {
let addr = match self.addr() {
Some(a) => a,
None => return Vec::new(),
};
let cfg = RekeyCfg {
after_secs: REKEY_AFTER_SECS,
after_messages: REKEY_AFTER_MESSAGES,
};
let snap = self.peer_snapshot(addr, now);
let mut actions = Vec::new();
for act in Fmp::new().poll_rekey(vec![snap], &cfg) {
actions.extend(self.map_rekey_action(act, now));
}
actions
}
fn map_rekey_action(&mut self, act: ConnAction, now: u64) -> Vec<PeerAction> {
match act {
ConnAction::Cutover { peer } => {
// Initiator cutover: swap to the pending epoch, register the new
// index, open the drain window. Slot-rotation mechanics stay in
// active.rs; the machine emits the action sequence.
self.draining_index = self.conn.our_index();
match self.rekey_our_index.take() {
Some(idx) => self.conn.set_our_index(idx),
None => self.conn.clear_our_index(),
}
self.rekey_in_progress = false;
self.state = PeerState::Maintaining {
addr: peer,
kind: MaintainKind::Rekey(RekeyPhase::Draining),
};
let mut actions = vec![PeerAction::SwapSendState];
if let Some(idx) = self.conn.our_index() {
actions.push(PeerAction::RegisterDecryptSession { index: idx });
}
actions.push(PeerAction::SetTimer {
kind: TimerKind::DrainExpiry,
at_ms: now + DRAIN_WINDOW_MS,
});
actions
}
ConnAction::Drain { peer } => {
// The executor reads the real previous_our_index from
// `ActivePeer::complete_drain` and does the peers_by_index /
// decrypt-worker / index-free cleanup, replacing the old
// shadow-index emission (which could drift from the real index).
//
// Clear the shadow `draining_index` set by the Cutover arm: the
// real previous index is now retired by `CompleteDrain`, so a
// leftover `Some(stale)` would double-free if a later
// `CrossConnectionWon` consumed it in `on_promotion_resolved`.
// Post-rekey cross-connection promotion is
// not a live path, but clearing here removes the hazard outright.
self.draining_index = None;
self.state = PeerState::Active { addr: peer };
vec![PeerAction::CompleteDrain { peer }]
}
ConnAction::InitiateRekey { peer } => {
// Fresh outbound rekey: allocate our new index, send msg1 (Noise
// leaf is shell-side → empty payload here), arm the resend timer.
self.rekey_in_progress = true;
self.rekey_resend_count = 0;
self.rekey_msg1 = Some(Vec::new());
self.state = PeerState::Maintaining {
addr: peer,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent),
};
vec![
PeerAction::SendRekey { bytes: Vec::new() },
PeerAction::SetTimer {
kind: TimerKind::RekeyResend,
at_ms: now + REKEY_RESEND_INTERVAL_MS,
},
]
}
// poll_rekey never emits the maintain/teardown-only variants.
_ => Vec::new(),
}
}
fn on_rekey_resend(&mut self, now: u64) -> Vec<PeerAction> {
let peer = match self.addr() {
Some(a) => a,
None => return Vec::new(),
};
let snap = RekeyResendSnapshot {
peer,
resend_count: self.rekey_resend_count,
needs_resend: true,
msg1: self.rekey_msg1.clone().unwrap_or_default(),
};
let mut actions = Vec::new();
for act in Fmp::new().poll_rekey_resends(
vec![snap],
now,
REKEY_RESEND_INTERVAL_MS,
RESEND_BACKOFF,
REKEY_MAX_RESENDS,
) {
match act {
ConnAction::AbandonRekey { .. } => {
if let Some(idx) = self.rekey_our_index.take() {
actions.push(PeerAction::FreeIndex { index: idx });
}
self.rekey_in_progress = false;
self.rekey_msg1 = None;
actions.push(PeerAction::CancelTimer {
kind: TimerKind::RekeyResend,
});
}
ConnAction::ResendRekeyMsg1 {
bytes,
next_resend_at_ms,
..
} => {
self.rekey_resend_count += 1;
actions.push(PeerAction::SendRekey { bytes });
actions.push(PeerAction::SetTimer {
kind: TimerKind::RekeyResend,
at_ms: next_resend_at_ms,
});
}
_ => {}
}
}
actions
}
// ------------------------------------------------------------------
// Liveness
// ------------------------------------------------------------------
fn on_heartbeat_due(&mut self, now: u64) -> Vec<PeerAction> {
if !self.is_active_like() {
return Vec::new();
}
self.last_heartbeat_sent_ms = now;
vec![
PeerAction::SendLinkMessage {
msg: vec![LinkMessageType::Heartbeat.to_byte()],
},
PeerAction::SetTimer {
kind: TimerKind::Liveness,
at_ms: now + LIVENESS_INTERVAL_MS,
},
]
}
fn on_peer_heard(&mut self, now: u64) -> Vec<PeerAction> {
if !self.is_active_like() {
return Vec::new();
}
vec![
PeerAction::CancelTimer {
kind: TimerKind::Liveness,
},
PeerAction::SetTimer {
kind: TimerKind::Liveness,
at_ms: now + LIVENESS_INTERVAL_MS,
},
]
}
fn on_link_dead(&mut self, now: u64) -> Vec<PeerAction> {
// Guard the full established set (Established | Active | Maintaining), not
// just `is_active_like()`: a peer that never rekeyed stays parked in
// `Established` (the machine reaches `Active` only via a rekey `Drain`),
// yet the pre-refactor liveness reap tore down EVERY dead established peer.
// A too-narrow `is_active_like()` guard here would silently skip the common
// (never-rekeyed) reap target. Mirrors `on_disconnect`'s guard.
if !self.is_established_context() {
return Vec::new();
}
// `InvalidateSendState` maps to the executor's `remove_active_peer`, which
// unregisters the decrypt worker by the REAL current index. The machine's
// tracked carrier index is deliberately NOT used to unregister here: it
// can drift to a reused index and wrongly unregister ANOTHER peer's worker
// session. `TeardownConnectedUdp` is inert (the old reap had no
// connected-UDP teardown, so inert is neutral); `ReportLost` drives the
// loss reflex (`note_link_dead`).
let mut actions = vec![
PeerAction::InvalidateSendState,
PeerAction::TeardownConnectedUdp,
];
if let Some(peer) = self.addr() {
// An established peer whose link died — the unconditional reconnect
// reflex (the live liveness-reap producer).
actions.push(PeerAction::ReportLost {
peer,
kind: LostKind::LinkDead,
});
}
self.state = PeerState::Closed {
backoff_deadline_ms: now + CLOSED_BACKOFF_MS,
};
actions
}
// ------------------------------------------------------------------
// Timeout / teardown / close
// ------------------------------------------------------------------
fn on_timeout(&mut self, kind: TimerKind, now: u64) -> Vec<PeerAction> {
match kind {
TimerKind::HandshakeRetransmit => self.on_handshake_retransmit(now),
TimerKind::HandshakeTimeout => self.on_handshake_timeout(now),
TimerKind::RekeyCadence => self.on_rekey_cadence(now),
TimerKind::RekeyResend => self.on_rekey_resend(now),
TimerKind::DrainExpiry => self.on_rekey_cadence(now),
TimerKind::Liveness => Vec::new(),
}
}
fn on_handshake_retransmit(&mut self, now: u64) -> Vec<PeerAction> {
if !matches!(
self.state,
PeerState::Handshaking {
phase: HandshakePhase::SentMsg1,
..
}
) {
return Vec::new();
}
let snap = self.conn_snapshot();
let mut actions = Vec::new();
for act in Fmp::new().poll_resends(
vec![snap],
now,
HANDSHAKE_RETRANSMIT_INTERVAL_MS,
RESEND_BACKOFF,
) {
if let ConnAction::ResendMsg1 {
bytes,
next_resend_at_ms,
..
} = act
{
self.conn.record_resend(next_resend_at_ms);
actions.push(PeerAction::SendHandshake { bytes });
actions.push(PeerAction::SetTimer {
kind: TimerKind::HandshakeRetransmit,
at_ms: next_resend_at_ms,
});
}
}
actions
}
fn on_handshake_timeout(&mut self, now: u64) -> Vec<PeerAction> {
if !matches!(self.state, PeerState::Handshaking { .. }) {
return Vec::new();
}
let snap = self.conn_snapshot();
// poll_timeouts emits [ScheduleRetry?, Teardown]; the machine REMAPS
// ScheduleRetry -> ReportLost (single loss token) and Teardown ->
// FreeIndex{our_index}, emitting FreeIndex before ReportLost.
let mut free = Vec::new();
let mut lost = Vec::new();
for act in Fmp::new().poll_timeouts(vec![snap]) {
match act {
ConnAction::ScheduleRetry { peer } => {
// Handshake timeout on an un-promoted leg — the connected-
// guarded reflex. Dormant today (no `Timeout` event is
// dispatched until the timeout fold in C5).
lost.push(PeerAction::ReportLost {
peer,
kind: LostKind::HandshakeTimeout,
});
}
ConnAction::Teardown { .. } => {
if let Some(idx) = self.conn.our_index() {
free.push(PeerAction::FreeIndex { index: idx });
}
}
_ => {}
}
}
self.state = PeerState::Closed {
backoff_deadline_ms: now + CLOSED_BACKOFF_MS,
};
free.extend(lost);
free
}
fn on_disconnect(&mut self, reason: CloseReason, now: u64) -> Vec<PeerAction> {
if !self.is_active_like() && !matches!(self.state, PeerState::Established { .. }) {
return Vec::new();
}
let addr = self.addr();
self.state = PeerState::Closing {
addr: addr.unwrap_or_else(zero_addr),
reason,
};
let mut actions = vec![PeerAction::SendLinkMessage {
msg: disconnect_frame(reason),
}];
actions.push(PeerAction::InvalidateSendState);
if let Some(idx) = self.conn.our_index() {
actions.push(PeerAction::UnregisterDecryptSession { index: idx });
self.conn.clear_our_index();
}
actions.push(PeerAction::TeardownConnectedUdp);
// No ReportLost on operator Requested.
self.state = PeerState::Closed {
backoff_deadline_ms: now + CLOSED_BACKOFF_MS,
};
actions
}
fn on_tick(&mut self, now: u64) -> Vec<PeerAction> {
// Dormant no-op: `PeerEvent::Tick` is not dispatched in production.
// The shell drivers evaluate due timer deadlines themselves, so there
// is no machine-side bookkeeping to advance here.
let _ = now;
Vec::new()
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
fn crystallize(&mut self, now: u64) {
let addr = self.addr().unwrap_or_else(zero_addr);
self.session_established_at_ms = now;
self.authenticated_at_ms = now;
self.state = PeerState::Established { addr };
}
fn identity_addr_set(&mut self, _addr: NodeAddr) {
// Identity is already crystallized from the wire outcome during the
// establish step; the PromotionResult's addr confirms it.
}
fn fail(&mut self, reason: FailReason) -> Vec<PeerAction> {
self.state = PeerState::Failed { reason };
Vec::new()
}
fn is_active_like(&self) -> bool {
matches!(
self.state,
PeerState::Active { .. } | PeerState::Maintaining { .. }
)
}
fn is_established_context(&self) -> bool {
matches!(
self.state,
PeerState::Established { .. }
| PeerState::Active { .. }
| PeerState::Maintaining { .. }
)
}
fn conn_snapshot(&self) -> ConnSnapshot {
ConnSnapshot {
link: self.conn.link_id(),
is_outbound: self.conn.is_outbound(),
retry_addr: self.conn.expected_identity().map(|id| *id.node_addr()),
resend_count: self.conn.resend_count(),
msg1: self
.conn
.handshake_msg1()
.map(|b| b.to_vec())
.unwrap_or_default(),
}
}
/// Build this peer's rekey snapshot from control-tier state. `counter` is a
/// send-state fact; passed as 0 here (see module note).
fn peer_snapshot(&self, addr: NodeAddr, now: u64) -> PeerSnapshot {
let phase = match self.state {
PeerState::Maintaining {
kind: MaintainKind::Rekey(p),
..
} => Some(p),
_ => None,
};
let elapsed_secs = now.saturating_sub(self.session_established_at_ms) / 1000;
PeerSnapshot {
addr,
has_pending: phase == Some(RekeyPhase::PendingCutover),
rekey_in_progress: phase == Some(RekeyPhase::Msg1Sent) || self.rekey_in_progress,
is_draining: phase == Some(RekeyPhase::Draining),
drain_expired: phase == Some(RekeyPhase::Draining),
is_dampened: now.saturating_sub(self.last_peer_rekey_ms) < REKEY_DAMPEN_MS
&& self.last_peer_rekey_ms != 0,
elapsed_secs,
counter: 0,
jitter_secs: self.rekey_jitter_secs,
pending_role: (phase == Some(RekeyPhase::PendingCutover))
.then_some(RekeyRole::Initiator),
pending_expired: false,
}
}
}
fn zero_addr() -> NodeAddr {
NodeAddr::from_bytes([0u8; 16])
}
/// Build the plaintext disconnect frame the driver encrypts + sends.
fn disconnect_frame(reason: CloseReason) -> Vec<u8> {
use crate::proto::fmp::{Disconnect, DisconnectReason};
let wire_reason = match reason {
CloseReason::Requested => DisconnectReason::Shutdown,
CloseReason::Draining => DisconnectReason::Restart,
};
Disconnect::new(wire_reason).encode().to_vec()
}
// ============================================================================
// Unit tests — assert on ACTION SEQUENCES + STATE transitions using
// hand-built synthetic snapshots (no real crypto sessions).
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::proto::fmp::{Msg1Digest, PromotionResult};
use crate::{Identity, PeerIdentity};
fn peer_identity() -> PeerIdentity {
PeerIdentity::from_pubkey(Identity::generate().pubkey())
}
/// Two identities with a known NodeAddr ordering: `.0` < `.1`.
fn ordered_identities() -> (PeerIdentity, PeerIdentity) {
loop {
let a = peer_identity();
let b = peer_identity();
if a.node_addr() < b.node_addr() {
return (a, b);
}
if b.node_addr() < a.node_addr() {
return (b, a);
}
}
}
fn wire_outcome(peer: PeerIdentity, epoch: Option<[u8; 8]>, their: u32) -> WireOutcome {
WireOutcome {
peer_identity: peer,
remote_epoch: epoch,
their_index: SessionIndex::new(their),
msg2_payload: vec![0xAB; 8],
msg1_digest: Msg1Digest::of(&[0xCD; 8]),
}
}
fn est_new_peer(our: NodeAddr) -> EstablishSnapshot {
EstablishSnapshot {
has_existing_peer: false,
existing_peer_epoch: None,
existing_session_age_secs: 0,
has_session: false,
pending_new_session: false,
rekey_in_progress: false,
held_answer: None,
msg1_answered_before: false,
existing_msg2: None,
at_max_peers: false,
has_pending_outbound_to_peer: false,
rekey_enabled: true,
our_node_addr: our,
}
}
// ---- Contract: actions are runtime-agnostic data ----------------------
//
// The machine's emitted actions are the message contract between the sync
// decision core and the async driver. This proves the contract carries no
// runtime handles: the action type is Send + Sync + 'static (so it can move
// across a task boundary), and every variant round-trips unchanged through
// an async channel. Were a variant to embed a runtime handle (a task handle,
// a raw socket, an Arc<Runtime>), it would stop being plain owned data and
// this construction + equality round-trip would no longer hold.
/// Compile-time proof that the action contract crosses task boundaries as
/// owned, runtime-agnostic data. Fails to compile if any variant field is
/// not `Send + Sync + 'static`.
fn assert_contract_bound<T: Send + Sync + 'static>() {}
/// One value of every [`PeerAction`] variant. The wildcard-free match makes
/// a newly-added variant a compile error, forcing it through this contract.
fn all_actions() -> Vec<PeerAction> {
let peer = *peer_identity().node_addr();
let sample = vec![
PeerAction::OpenTransport {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string("127.0.0.1:9999"),
},
PeerAction::SendHandshake {
bytes: vec![1, 2, 3],
},
PeerAction::SendRekey {
bytes: vec![4, 5, 6],
},
PeerAction::SendLinkMessage { msg: vec![7, 8, 9] },
PeerAction::PromoteToActive {
link: LinkId::new(7),
},
PeerAction::ResolveCrossConnection { swap: true },
PeerAction::SwapSendState,
PeerAction::CompleteDrain { peer },
PeerAction::InvalidateSendState,
PeerAction::RegisterDecryptSession {
index: SessionIndex::new(5),
},
PeerAction::UnregisterDecryptSession {
index: SessionIndex::new(6),
},
PeerAction::FreeIndex {
index: SessionIndex::new(7),
},
PeerAction::ActivateConnectedUdp,
PeerAction::TeardownConnectedUdp,
PeerAction::SetTimer {
kind: TimerKind::RekeyCadence,
at_ms: 1234,
},
PeerAction::CancelTimer {
kind: TimerKind::Liveness,
},
PeerAction::ReportLost {
peer,
kind: LostKind::LinkDead,
},
];
for a in &sample {
// Exhaustiveness guard: no `_` wildcard, so adding a variant without
// extending `sample` above breaks the build here.
match a {
PeerAction::OpenTransport { .. }
| PeerAction::SendHandshake { .. }
| PeerAction::SendRekey { .. }
| PeerAction::SendLinkMessage { .. }
| PeerAction::PromoteToActive { .. }
| PeerAction::ResolveCrossConnection { .. }
| PeerAction::SwapSendState
| PeerAction::CompleteDrain { .. }
| PeerAction::InvalidateSendState
| PeerAction::RegisterDecryptSession { .. }
| PeerAction::UnregisterDecryptSession { .. }
| PeerAction::FreeIndex { .. }
| PeerAction::ActivateConnectedUdp
| PeerAction::TeardownConnectedUdp
| PeerAction::SetTimer { .. }
| PeerAction::CancelTimer { .. }
| PeerAction::ReportLost { .. } => {}
}
}
sample
}
/// Route every action through a single-threaded async channel and assert it
/// arrives unchanged. `#[tokio::test]` runs on a current-thread runtime, so
/// sender and receiver share one thread — mirroring the eventual control
/// task boundary where actions cross to the driver over a channel.
#[tokio::test]
async fn peer_actions_round_trip_through_async_channel() {
assert_contract_bound::<PeerAction>();
let sent = all_actions();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<PeerAction>();
for action in sent.iter().cloned() {
tx.send(action).expect("channel send");
}
drop(tx);
let mut received = Vec::new();
while let Some(action) = rx.recv().await {
received.push(action);
}
assert_eq!(received, sent, "every action must round-trip unchanged");
}
// ---- Test 1: rekey initiator cutover ----------------------------------
#[test]
fn rekey_initiator_cutover() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
// Arrange: a completed rekey pending cutover.
m.state = PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::PendingCutover),
};
m.rekey_our_index = Some(SessionIndex::new(0x2222));
m.conn.set_our_index(SessionIndex::new(0x1111));
// The remote startup epoch lives on the surviving carrier, written
// there by BOTH handshake legs (`receive_handshake_init` from msg1,
// `complete_handshake` from msg2) through this same setter. Seed it the
// way production does, so the value asserted below is one an outbound
// machine can actually hold.
m.conn.set_remote_epoch(Some([9u8; 8]));
m.session_established_at_ms = 0;
let actions = m.step(
PeerEvent::Timeout {
kind: TimerKind::RekeyCadence,
},
10_000,
&mut alloc,
);
assert_eq!(
actions,
vec![
PeerAction::SwapSendState,
PeerAction::RegisterDecryptSession {
index: SessionIndex::new(0x2222)
},
PeerAction::SetTimer {
kind: TimerKind::DrainExpiry,
at_ms: 10_000 + DRAIN_WINDOW_MS
},
]
);
assert_eq!(
m.state(),
PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::Draining)
}
);
// The cutover carries no epoch of its own; `conn` is the sole carrier
// and the cutover must leave it exactly as the handshake wrote it.
assert_eq!(m.conn_remote_epoch(), Some([9u8; 8]));
// A second cadence tick from the (expired) drain window completes the
// drain: the machine now emits the single `CompleteDrain` send-state
// write (executor reads the real previous index) instead of the old
// shadow-index `[UnregisterDecryptSession, FreeIndex]` pair.
let drain_actions = m.step(
PeerEvent::Timeout {
kind: TimerKind::RekeyCadence,
},
20_000,
&mut alloc,
);
assert_eq!(
drain_actions,
vec![PeerAction::CompleteDrain { peer: addr }]
);
assert_eq!(m.state(), PeerState::Active { addr });
assert_eq!(m.conn_remote_epoch(), Some([9u8; 8]));
}
// ---- Test 2: responder cutover (data-plane owned) ---------------------
#[test]
fn responder_cutover_only_sets_drain_timer() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Active { addr };
let actions = m.step(
PeerEvent::PeerKbitFlip { epoch: [7u8; 8] },
5_000,
&mut alloc,
);
// ONLY the drain timer — no SwapSendState, no slot mutation.
assert_eq!(
actions,
vec![PeerAction::SetTimer {
kind: TimerKind::DrainExpiry,
at_ms: 5_000 + DRAIN_WINDOW_MS
}]
);
assert!(
!actions
.iter()
.any(|a| matches!(a, PeerAction::SwapSendState))
);
assert_eq!(m.state(), PeerState::Active { addr });
}
// ---- Test 3: dual-init tie-break, swapped addrs -----------------------
#[test]
fn dual_init_tiebreak_swapped_addrs() {
let (smaller, larger) = ordered_identities();
// Case A: WE are smaller -> we win -> Reject{DualRekeyWon} -> drop, keep.
{
let mut alloc = IndexAllocator::new();
let our = *smaller.node_addr();
let peer = larger;
let mut m = PeerMachine::new_outbound(LinkId::new(1), peer, 0);
let peer_addr = *peer.node_addr();
m.state = PeerState::Maintaining {
addr: peer_addr,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent),
};
m.rekey_in_progress = true;
m.rekey_our_index = Some(SessionIndex::new(0x55));
let mut est = est_new_peer(our);
est.has_existing_peer = true;
est.existing_peer_epoch = Some([1u8; 8]);
est.has_session = true;
est.existing_session_age_secs = 120;
est.rekey_in_progress = true;
let wire = wire_outcome(peer, Some([1u8; 8]), 0x77);
let actions = m.step(PeerEvent::RekeyMsg1 { wire, est }, 1_000, &mut alloc);
// We win the tie-break: drop the peer's msg1, no rekey response,
// established-context state untouched (the peer keeps running).
assert!(actions.is_empty());
assert!(
!actions
.iter()
.any(|a| matches!(a, PeerAction::SendRekey { .. }))
);
assert_eq!(
m.state(),
PeerState::Maintaining {
addr: peer_addr,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent)
}
);
}
// Case B: PEER is smaller -> we lose -> RekeyRespond{abandon_first:true}.
{
let our = *larger.node_addr();
let peer = smaller;
let mut m = PeerMachine::new_outbound(LinkId::new(2), peer, 0);
let peer_addr = *peer.node_addr();
m.state = PeerState::Maintaining {
addr: peer_addr,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent),
};
m.rekey_in_progress = true;
m.rekey_our_index = Some(SessionIndex::new(0x55));
let mut est = est_new_peer(our);
est.has_existing_peer = true;
est.existing_peer_epoch = Some([1u8; 8]);
est.has_session = true;
est.existing_session_age_secs = 120;
est.rekey_in_progress = true;
let wire = wire_outcome(peer, Some([1u8; 8]), 0x77);
let (decision, actions) = m.inbound_msg1(LinkId::new(2), &wire, est, 1_000);
// We lose the tie-break: the decision names the responder path with
// the abandon flag; the machine emits nothing and mutates nothing
// (the driver's inline body owns the abandon/alloc/send/store).
assert!(matches!(
decision,
InboundDecision::RekeyRespond {
abandon_first: true,
..
}
));
assert!(actions.is_empty());
assert_eq!(
m.state(),
PeerState::Maintaining {
addr: peer_addr,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent)
}
);
assert_eq!(m.rekey_our_index, Some(SessionIndex::new(0x55)));
assert!(m.rekey_in_progress);
}
}
// ---- Test 3b: duplicate msg1 -> resend decision only ------------------
#[test]
fn inbound_resend_msg2_decision_only() {
let peer = peer_identity();
let mut m = PeerMachine::new_inbound(LinkId::new(1), 0);
let our = *peer_identity().node_addr();
let mut est = est_new_peer(our);
est.has_existing_peer = true;
est.existing_peer_epoch = Some([1u8; 8]);
est.has_session = true;
est.existing_session_age_secs = 5; // young session -> duplicate, not rekey
est.existing_msg2 = Some(vec![0xC4; 16]);
let wire = wire_outcome(peer, Some([1u8; 8]), 0x77);
let (decision, actions) = m.inbound_msg1(LinkId::new(1), &wire, est, 1_000);
// The decision carries the stored msg2 bytes; no SendHandshake action,
// no state change (the driver's inline resend owns the send).
assert!(matches!(
&decision,
InboundDecision::ResendMsg2 { msg2: Some(bytes) } if bytes.as_slice() == [0xC4; 16]
));
assert!(actions.is_empty());
assert_eq!(
m.state(),
PeerState::Handshaking {
link: LinkId::new(1),
phase: HandshakePhase::Initial
}
);
}
// ---- Test 4: restart-override -----------------------------------------
#[test]
fn restart_override() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let peer_addr = *peer.node_addr();
let mut m = PeerMachine::new_inbound(LinkId::new(1), 0);
// Existing peer at a different epoch -> restart.
m.conn.set_our_index(SessionIndex::new(0xDEAD));
let our = *peer_identity().node_addr();
let mut est = est_new_peer(our);
est.has_existing_peer = true;
est.existing_peer_epoch = Some([1u8; 8]); // old
let wire = wire_outcome(peer, Some([2u8; 8]), 0x77); // new epoch
let actions = m.step(
PeerEvent::InboundMsg1 {
link: LinkId::new(1),
wire,
est,
},
1_000,
&mut alloc,
);
// Phase 1: restart tail only (invalidate, unregister old, report lost),
// then park at ReceivedMsg1 — no index allocated yet.
assert_eq!(
actions,
vec![
PeerAction::InvalidateSendState,
PeerAction::UnregisterDecryptSession {
index: SessionIndex::new(0xDEAD)
},
PeerAction::ReportLost {
peer: peer_addr,
kind: LostKind::LinkDead,
},
]
);
assert!(matches!(
m.state(),
PeerState::Handshaking {
phase: HandshakePhase::ReceivedMsg1,
..
}
));
assert_eq!(m.our_index(), None);
assert_eq!(alloc.count(), 0);
// Phase 2: late-ACL gate passed -> allocate + Promote tail.
let promote = m.step(PeerEvent::Authorized, 1_000, &mut alloc);
assert!(
promote
.iter()
.any(|a| matches!(a, PeerAction::SendHandshake { .. }))
);
assert!(
promote
.iter()
.any(|a| matches!(a, PeerAction::PromoteToActive { .. }))
);
// Promotion feedback -> Established.
let follow = m.step(
PeerEvent::PromotionResolved {
result: PromotionResult::Promoted(peer_addr),
},
1_000,
&mut alloc,
);
assert!(
follow
.iter()
.any(|a| matches!(a, PeerAction::RegisterDecryptSession { .. }))
);
assert_eq!(m.state(), PeerState::Established { addr: peer_addr });
}
// ---- Test 5: N:1 crystallization --------------------------------------
#[test]
fn n_to_one_crystallization() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let peer_addr = *peer.node_addr();
// Winner leg (link 1): net-new inbound promote -> Established.
let mut winner = PeerMachine::new_inbound(LinkId::new(1), 0);
let our = *peer_identity().node_addr();
let est_w = est_new_peer(our);
let wire_w = wire_outcome(peer, Some([3u8; 8]), 0x77);
let wp1 = winner.step(
PeerEvent::InboundMsg1 {
link: LinkId::new(1),
wire: wire_w,
est: est_w,
},
100,
&mut alloc,
);
assert!(wp1.is_empty()); // Phase 1 classifies without emitting.
let wa = winner.step(PeerEvent::Authorized, 100, &mut alloc);
assert!(
wa.iter().any(
|a| matches!(a, PeerAction::PromoteToActive { link } if *link == LinkId::new(1))
)
);
let wf = winner.step(
PeerEvent::PromotionResolved {
result: PromotionResult::Promoted(peer_addr),
},
100,
&mut alloc,
);
assert!(
wf.iter()
.any(|a| matches!(a, PeerAction::RegisterDecryptSession { .. }))
);
assert_eq!(winner.state(), PeerState::Established { addr: peer_addr });
// Loser leg (link 2): same identity, loses cross-connection at
// promote_connection -> Failed + FreeIndex, link terminates.
let mut loser = PeerMachine::new_inbound(LinkId::new(2), 0);
let est_l = est_new_peer(our);
let wire_l = wire_outcome(peer, Some([3u8; 8]), 0x88);
let lp1 = loser.step(
PeerEvent::InboundMsg1 {
link: LinkId::new(2),
wire: wire_l,
est: est_l,
},
100,
&mut alloc,
);
assert!(lp1.is_empty()); // Phase 1 classifies without emitting.
let la = loser.step(PeerEvent::Authorized, 100, &mut alloc);
assert!(
la.iter()
.any(|a| matches!(a, PeerAction::PromoteToActive { .. }))
);
let loser_index = loser.our_index();
let lf = loser.step(
PeerEvent::PromotionResolved {
result: PromotionResult::CrossConnectionLost {
winner_link_id: LinkId::new(1),
},
},
100,
&mut alloc,
);
assert_eq!(
lf,
vec![PeerAction::FreeIndex {
index: loser_index.unwrap()
}]
);
assert_eq!(
loser.state(),
PeerState::Failed {
reason: FailReason::HandshakeFailed
}
);
// One crystallized NodeAddr (the winner); loser never crystallizes.
assert_eq!(winner.addr(), Some(peer_addr));
}
// ---- Test 6: inbound establish ----------------------------------------
#[test]
fn inbound_establish() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let peer_addr = *peer.node_addr();
let mut m = PeerMachine::new_inbound(LinkId::new(1), 0);
assert_eq!(
m.state(),
PeerState::Handshaking {
link: LinkId::new(1),
phase: HandshakePhase::Initial
}
);
let our = *peer_identity().node_addr();
let est = est_new_peer(our);
let wire = wire_outcome(peer, Some([4u8; 8]), 0x77);
// Phase 1 (InboundMsg1): classify only — no actions, no allocation,
// parked at ReceivedMsg1.
let phase1 = m.step(
PeerEvent::InboundMsg1 {
link: LinkId::new(1),
wire,
est,
},
200,
&mut alloc,
);
assert!(phase1.is_empty());
assert_eq!(
m.state(),
PeerState::Handshaking {
link: LinkId::new(1),
phase: HandshakePhase::ReceivedMsg1
}
);
assert_eq!(m.our_index(), None);
assert_eq!(alloc.count(), 0); // allocator untouched pre-authorize
// Phase 2 (Authorized): allocate + [SendHandshake, PromoteToActive].
let phase2 = m.step(PeerEvent::Authorized, 200, &mut alloc);
assert!(matches!(phase2[0], PeerAction::SendHandshake { .. }));
assert_eq!(
phase2[1],
PeerAction::PromoteToActive {
link: LinkId::new(1)
}
);
assert!(m.our_index().is_some());
assert_eq!(alloc.count(), 1); // exactly one index allocated
// Phase 3 (PromotionResolved{Promoted}): register + Established.
let phase3 = m.step(
PeerEvent::PromotionResolved {
result: PromotionResult::Promoted(peer_addr),
},
200,
&mut alloc,
);
assert!(matches!(
phase3[0],
PeerAction::RegisterDecryptSession { .. }
));
assert_eq!(m.state(), PeerState::Established { addr: peer_addr });
}
// ---- Test 6b: inbound late-ACL rejected -> no allocation --------------
#[test]
fn inbound_authorize_rejected_no_alloc() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let mut m = PeerMachine::new_inbound(LinkId::new(1), 0);
let our = *peer_identity().node_addr();
let est = est_new_peer(our);
let wire = wire_outcome(peer, Some([4u8; 8]), 0x77);
// Phase 1 classifies (no alloc).
let phase1 = m.step(
PeerEvent::InboundMsg1 {
link: LinkId::new(1),
wire,
est,
},
200,
&mut alloc,
);
assert!(phase1.is_empty());
assert_eq!(alloc.count(), 0);
// Late-ACL rejects -> Failed{AclRejected}, still no allocation.
let rej = m.step(PeerEvent::Rejected, 200, &mut alloc);
assert!(rej.is_empty());
assert_eq!(
m.state(),
PeerState::Failed {
reason: FailReason::AclRejected
}
);
assert_eq!(alloc.count(), 0);
assert_eq!(m.our_index(), None);
}
// ---- Test 6c: inbound reject at max_peers -> no allocation ------------
#[test]
fn inbound_at_max_peers_reject_no_alloc() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let mut m = PeerMachine::new_inbound(LinkId::new(1), 0);
let our = *peer_identity().node_addr();
let mut est = est_new_peer(our);
est.at_max_peers = true;
let wire = wire_outcome(peer, Some([4u8; 8]), 0x77);
let actions = m.step(
PeerEvent::InboundMsg1 {
link: LinkId::new(1),
wire,
est,
},
200,
&mut alloc,
);
assert!(actions.is_empty());
assert_eq!(
m.state(),
PeerState::Failed {
reason: FailReason::Rejected
}
);
assert_eq!(alloc.count(), 0);
assert_eq!(m.our_index(), None);
}
// ---- Test 7: outbound establish (+ cross-connection) ------------------
#[test]
fn outbound_establish() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let peer_addr = *peer.node_addr();
// Net-new promote.
let mut m = PeerMachine::new_outbound(LinkId::new(1), peer, 0);
m.state = PeerState::Handshaking {
link: LinkId::new(1),
phase: HandshakePhase::SentMsg1,
};
m.conn.set_our_index(SessionIndex::new(0xABCD));
let out = OutboundSnapshot {
has_existing_peer: false,
our_outbound_wins: false,
};
let mut actions = m.step(
PeerEvent::Msg2 {
their_index: SessionIndex::new(0x77),
out,
},
300,
&mut alloc,
);
assert_eq!(
actions,
vec![
PeerAction::CancelTimer {
kind: TimerKind::HandshakeRetransmit
},
PeerAction::CancelTimer {
kind: TimerKind::HandshakeTimeout
},
PeerAction::PromoteToActive {
link: LinkId::new(1)
}
]
);
actions = m.step(
PeerEvent::PromotionResolved {
result: PromotionResult::Promoted(peer_addr),
},
300,
&mut alloc,
);
assert_eq!(
actions,
vec![PeerAction::RegisterDecryptSession {
index: SessionIndex::new(0xABCD)
}]
);
assert_eq!(m.state(), PeerState::Established { addr: peer_addr });
// Cross-connection SWAP: our outbound wins -> decision only; the
// shell's inline resolution owns the index frees and session
// replacement.
let mut m2 = PeerMachine::new_outbound(LinkId::new(2), peer, 0);
m2.state = PeerState::Handshaking {
link: LinkId::new(2),
phase: HandshakePhase::SentMsg1,
};
m2.conn.set_our_index(SessionIndex::new(0x2222)); // outbound index
let out_swap = OutboundSnapshot {
has_existing_peer: true,
our_outbound_wins: true,
};
let swap = m2.step(
PeerEvent::Msg2 {
their_index: SessionIndex::new(0x99),
out: out_swap,
},
400,
&mut alloc,
);
assert_eq!(
swap,
vec![PeerAction::ResolveCrossConnection { swap: true }]
);
assert!(!swap.iter().any(|a| matches!(
a,
PeerAction::FreeIndex { .. } | PeerAction::RegisterDecryptSession { .. }
)));
// The decision arm leaves the machine untouched: still Handshaking,
// the carrier's outbound index unchanged.
assert_eq!(
m2.state(),
PeerState::Handshaking {
link: LinkId::new(2),
phase: HandshakePhase::SentMsg1,
}
);
assert_eq!(m2.our_index(), Some(SessionIndex::new(0x2222)));
// Cross-connection KEEP: our outbound loses -> decision only; the
// shell's inline resolution frees the unused outbound index.
let mut m3 = PeerMachine::new_outbound(LinkId::new(3), peer, 0);
m3.state = PeerState::Handshaking {
link: LinkId::new(3),
phase: HandshakePhase::SentMsg1,
};
m3.conn.set_our_index(SessionIndex::new(0x3333));
let out_keep = OutboundSnapshot {
has_existing_peer: true,
our_outbound_wins: false,
};
let keep = m3.step(
PeerEvent::Msg2 {
their_index: SessionIndex::new(0x9A),
out: out_keep,
},
500,
&mut alloc,
);
assert_eq!(
keep,
vec![PeerAction::ResolveCrossConnection { swap: false }]
);
assert!(!keep.iter().any(|a| matches!(
a,
PeerAction::FreeIndex { .. } | PeerAction::RegisterDecryptSession { .. }
)));
assert_eq!(
m3.state(),
PeerState::Handshaking {
link: LinkId::new(3),
phase: HandshakePhase::SentMsg1,
}
);
assert_eq!(m3.our_index(), Some(SessionIndex::new(0x3333)));
}
// ---- Test 7b: dial-persisted outbound promote keeps the carrier index ---
// An outbound machine persisted at DIAL carries the allocated index on its
// surviving carrier (the shell writes it at msg1 preparation). On promote via
// msg2 the machine keeps that index, so `our_index()` reads `Some(dial_index)`
// through Established. A synthetic `step(InboundMsg1)` restart driven onto this
// map-resident machine then unregisters that index — but this is a TEST-ONLY
// construction: production handles inbound msg1 on a FRESH `new_inbound`
// machine (`handle_msg1`), never driving the promoted outbound machine through
// `inbound_msg1`, so the unregister never fires in production.
#[test]
fn dial_persisted_outbound_promote_keeps_carrier_index() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let peer_addr = *peer.node_addr();
let our = *peer_identity().node_addr();
let dial_index = SessionIndex::new(0x55AA);
// Persisted at dial: Discovered, with the carrier index written by the
// shell at msg1 preparation.
let mut m = PeerMachine::new_outbound(LinkId::new(1), peer, 0);
m.conn.set_our_index(dial_index);
assert_eq!(m.our_index(), Some(dial_index));
// Promote via msg2 from Discovered.
let out = OutboundSnapshot {
has_existing_peer: false,
our_outbound_wins: false,
};
let promote = m.step(
PeerEvent::Msg2 {
their_index: SessionIndex::new(0x77),
out,
},
300,
&mut alloc,
);
assert_eq!(
promote,
vec![
PeerAction::CancelTimer {
kind: TimerKind::HandshakeRetransmit
},
PeerAction::CancelTimer {
kind: TimerKind::HandshakeTimeout
},
PeerAction::PromoteToActive {
link: LinkId::new(1)
}
]
);
assert_eq!(m.our_index(), Some(dial_index));
// Drive promotion to Established.
let _ = m.step(
PeerEvent::PromotionResolved {
result: PromotionResult::Promoted(peer_addr),
},
300,
&mut alloc,
);
assert_eq!(m.state(), PeerState::Established { addr: peer_addr });
assert_eq!(m.our_index(), Some(dial_index));
// A synthetic inbound restart (peer restart, new epoch) driven onto this
// map-resident machine unregisters the carrier index. Test-only: production
// builds a fresh `new_inbound` machine for inbound msg1, so the promoted
// outbound machine is never driven through this path.
let mut est = est_new_peer(our);
est.has_existing_peer = true;
est.existing_peer_epoch = Some([1u8; 8]);
let wire = wire_outcome(peer, Some([2u8; 8]), 0x88);
let restart = m.step(
PeerEvent::InboundMsg1 {
link: LinkId::new(1),
wire,
est,
},
1_000,
&mut alloc,
);
assert!(
restart.iter().any(|a| matches!(
a,
PeerAction::UnregisterDecryptSession { index } if *index == dial_index
)),
"synthetic restart unregisters the carrier index (test-only path)"
);
assert!(
restart.iter().any(|a| matches!(
a,
PeerAction::ReportLost {
kind: LostKind::LinkDead,
..
}
)),
"restart still reports the loss via the link-dead reconnect reflex"
);
}
// ---- Test 7c: connectionless dial reaches Handshaking, Msg2 neutral ----
// The connectionless cutover drives the outbound machine
// Discovered -> (Dial, connection_oriented=false) -> Handshaking{SentMsg1}
// BEFORE msg2, whereas the pre-cutover path stepped Msg2 while still in
// Discovered. `on_msg2` is state-independent, so both must yield the
// identical `[PromoteToActive]` and leave `our_index == None`.
#[test]
fn connectionless_dial_then_msg2_promotes_from_handshaking() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let mut m = PeerMachine::new_outbound(LinkId::new(1), peer, 0);
// Connectionless dial: no OpenTransport, straight to Handshaking{SentMsg1}.
let dial = m.step(
PeerEvent::Dial {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string("127.0.0.1:9999"),
peer_identity: peer,
connection_oriented: false,
},
100,
&mut alloc,
);
assert!(matches!(
m.state(),
PeerState::Handshaking {
phase: HandshakePhase::SentMsg1,
..
}
));
assert!(
dial.iter()
.any(|a| matches!(a, PeerAction::SendHandshake { .. }))
);
assert!(
!dial
.iter()
.any(|a| matches!(a, PeerAction::OpenTransport { .. })),
"connectionless dial emits no OpenTransport"
);
// Step Msg2 from Handshaking — identical promote to the Discovered path.
let out = OutboundSnapshot {
has_existing_peer: false,
our_outbound_wins: false,
};
let promote = m.step(
PeerEvent::Msg2 {
their_index: SessionIndex::new(0x77),
out,
},
200,
&mut alloc,
);
assert_eq!(
promote,
vec![
PeerAction::CancelTimer {
kind: TimerKind::HandshakeRetransmit
},
PeerAction::CancelTimer {
kind: TimerKind::HandshakeTimeout
},
PeerAction::PromoteToActive {
link: LinkId::new(1)
}
]
);
assert_eq!(m.our_index(), None);
}
// ---- Test 7d: connection-oriented dial opens transport first ----------
// The connection-oriented cutover drives the outbound machine
// Discovered -> (Dial, connection_oriented=true) -> Connecting
// (emitting ONLY OpenTransport, no msg1 yet), then TransportConnected ->
// Handshaking{SentMsg1} with the same SendHandshake + two SetTimer that
// `start_outbound_handshake` emits. Covers the oriented reach into
// `start_outbound_handshake` via `on_transport_connected` (the connectionless
// reach via `on_dial` is already covered by the test above).
#[test]
fn connection_oriented_dial_opens_transport_then_connected_handshakes() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let mut m = PeerMachine::new_outbound(LinkId::new(1), peer, 0);
// Connection-oriented dial: open the transport first, no msg1 yet.
let dial = m.step(
PeerEvent::Dial {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string("127.0.0.1:9999"),
peer_identity: peer,
connection_oriented: true,
},
100,
&mut alloc,
);
assert_eq!(
m.state(),
PeerState::Connecting {
link: LinkId::new(1)
}
);
assert_eq!(
dial,
vec![PeerAction::OpenTransport {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string("127.0.0.1:9999"),
}],
"connection-oriented dial emits exactly one OpenTransport and no msg1"
);
// Transport connected: now send msg1 and arm the handshake timers.
let connected = m.step(PeerEvent::TransportConnected, 200, &mut alloc);
assert!(matches!(
m.state(),
PeerState::Handshaking {
phase: HandshakePhase::SentMsg1,
..
}
));
assert_eq!(
connected,
vec![
PeerAction::SendHandshake { bytes: Vec::new() },
PeerAction::SetTimer {
kind: TimerKind::HandshakeRetransmit,
at_ms: 200 + HANDSHAKE_RETRANSMIT_INTERVAL_MS,
},
PeerAction::SetTimer {
kind: TimerKind::HandshakeTimeout,
at_ms: 200 + HANDSHAKE_TIMEOUT_MS,
},
]
);
}
// ---- Test 7e: HandshakeSendFailed marks the leg, keeps the state ------
// A stored-msg1 send failure marks the embedded leg failed (the
// stale-connection sweep reads the leg's `is_failed`) WITHOUT leaving
// `Handshaking{SentMsg1}` — retransmit eligibility
// (`is_handshaking_sent_msg1`) must survive until the sweep — and emits
// no actions. On a machine with no leg it is a defensive no-op.
#[test]
fn handshake_send_failed_marks_leg_without_leaving_handshaking() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
// Dial-persisted outbound machine carrying a prepared leg, driven to
// Handshaking{SentMsg1} via the connectionless dial.
let mut m = PeerMachine::new_outbound(LinkId::new(1), peer, 0);
let _ = m.step(
PeerEvent::Dial {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string("127.0.0.1:9999"),
peer_identity: peer,
connection_oriented: false,
},
100,
&mut alloc,
);
m.set_leg(HandshakeCrypto::new());
assert!(m.is_handshaking_sent_msg1());
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");
assert!(
m.is_handshaking_sent_msg1(),
"retransmit eligibility survives a send failure"
);
assert!(
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 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());
assert!(m.is_handshaking_sent_msg1());
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::ReceivedMsg1,
}),
"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"
);
assert_eq!(handshake_state_str(PeerState::Discovered), "initial");
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() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Active { addr };
m.conn.set_our_index(SessionIndex::new(0x4242));
let hb = m.step(PeerEvent::HeartbeatDue, 1_000, &mut alloc);
assert_eq!(
hb,
vec![
PeerAction::SendLinkMessage {
msg: vec![LinkMessageType::Heartbeat.to_byte()]
},
PeerAction::SetTimer {
kind: TimerKind::Liveness,
at_ms: 1_000 + LIVENESS_INTERVAL_MS
},
]
);
let dead = m.step(PeerEvent::LinkDeadSuspected, 2_000, &mut alloc);
assert_eq!(
dead,
vec![
PeerAction::InvalidateSendState,
PeerAction::TeardownConnectedUdp,
PeerAction::ReportLost {
peer: addr,
kind: LostKind::LinkDead,
},
]
);
assert!(matches!(m.state(), PeerState::Closed { .. }));
// The exact action-sequence equality above is the "no ScheduleRetry"
// guarantee: loss is reported only via ReportLost, and no retry-schedule
// action exists in the PeerAction vocabulary at all (reconciler-owned).
}
// ---- Test 9: cadence CONSUME ------------------------------------------
// The shell polls the batch `poll_rekey` and routes each decided ConnAction
// as `RekeyConsume` — the machine maps it WITHOUT re-polling, yielding the
// same action sequence + transition as the machine-driven cadence (Test 1),
// and the Drain consume clears the shadow `draining_index`.
#[test]
fn rekey_consume_cutover_then_drain() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::PendingCutover),
};
m.rekey_our_index = Some(SessionIndex::new(0x2222));
m.conn.set_our_index(SessionIndex::new(0x1111));
// Seeded through the setter both handshake legs use; see Test 1.
m.conn.set_remote_epoch(Some([9u8; 8]));
// Consume the shell-decided Cutover: identical sequence to Test 1.
let cut = m.step(
PeerEvent::RekeyConsume {
action: ConnAction::Cutover { peer: addr },
},
10_000,
&mut alloc,
);
assert_eq!(
cut,
vec![
PeerAction::SwapSendState,
PeerAction::RegisterDecryptSession {
index: SessionIndex::new(0x2222)
},
PeerAction::SetTimer {
kind: TimerKind::DrainExpiry,
at_ms: 10_000 + DRAIN_WINDOW_MS
},
]
);
assert_eq!(
m.state(),
PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::Draining)
}
);
// Cutover stashed the old index in the drain shadow.
assert_eq!(m.draining_index, Some(SessionIndex::new(0x1111)));
// Consuming a shell-decided cutover is epoch-neutral too: `conn` still
// holds what the handshake wrote.
assert_eq!(m.conn_remote_epoch(), Some([9u8; 8]));
// Consume the shell-decided Drain: single CompleteDrain, Active, and the
// shadow drain index is CLEARED (double-free guard).
let drain = m.step(
PeerEvent::RekeyConsume {
action: ConnAction::Drain { peer: addr },
},
20_000,
&mut alloc,
);
assert_eq!(drain, vec![PeerAction::CompleteDrain { peer: addr }]);
assert_eq!(m.state(), PeerState::Active { addr });
assert_eq!(m.draining_index, None);
assert_eq!(m.conn_remote_epoch(), Some([9u8; 8]));
}
// ---- Test 10: RekeyInitiated observation ------------------------------
// The shell ran `initiate_rekey` inline; the obs advances control state to
// Msg1Sent and emits nothing.
#[test]
fn rekey_initiated_observation() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Established { addr };
let acts = m.step(PeerEvent::RekeyInitiated, 5_000, &mut alloc);
assert!(acts.is_empty());
assert_eq!(
m.state(),
PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent)
}
);
assert!(m.rekey_in_progress);
// No index allocation happened in the machine (shell-side leaf).
assert_eq!(alloc.count(), 0);
}
// ---- Test 11: RekeyMsg2 observation -----------------------------------
// The shell completed the initiated rekey inline; the obs records the peer's
// new index, clears the in-progress flag, advances to PendingCutover, and
// emits nothing.
#[test]
fn rekey_msg2_observation() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::Msg1Sent),
};
m.rekey_in_progress = true;
let their = SessionIndex::new(0x4444);
let acts = m.step(
PeerEvent::RekeyMsg2 { their_index: their },
6_000,
&mut alloc,
);
assert!(acts.is_empty());
assert_eq!(m.conn.their_index(), Some(their));
assert!(!m.rekey_in_progress);
assert_eq!(
m.state(),
PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::PendingCutover)
}
);
assert_eq!(alloc.count(), 0);
}
// ---- Test 12: CrossConnResolved observation ---------------------------
// A swap adopts the new local and remote indices into the control shadow and
// emits nothing; a keep leaves the shadow untouched and emits nothing.
#[test]
fn cross_conn_resolved_swap_updates_shadow() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Active { addr };
m.conn.set_our_index(SessionIndex::new(0x1111));
m.conn.set_their_index(SessionIndex::new(0x2222));
let our = SessionIndex::new(0xAAAA);
let their = SessionIndex::new(0xBBBB);
let acts = m.step(
PeerEvent::CrossConnResolved {
outcome: CrossConnOutcome::Swap {
our_index: our,
their_index: their,
},
},
7_000,
&mut alloc,
);
assert!(acts.is_empty());
assert_eq!(m.conn.our_index(), Some(our));
assert_eq!(m.conn.their_index(), Some(their));
assert_eq!(m.state(), PeerState::Active { addr });
assert_eq!(alloc.count(), 0);
}
#[test]
fn cross_conn_resolved_keep_is_noop() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Active { addr };
m.conn.set_our_index(SessionIndex::new(0x1111));
m.conn.set_their_index(SessionIndex::new(0x2222));
let acts = m.step(
PeerEvent::CrossConnResolved {
outcome: CrossConnOutcome::Keep,
},
7_000,
&mut alloc,
);
assert!(acts.is_empty());
assert_eq!(m.conn.our_index(), Some(SessionIndex::new(0x1111)));
assert_eq!(m.conn.their_index(), Some(SessionIndex::new(0x2222)));
assert_eq!(m.state(), PeerState::Active { addr });
assert_eq!(alloc.count(), 0);
}
// ---- Moved from `PeerConnection`'s own test module ---------------------
// These exercise the Noise handshake operations, which now live on the
// control machine. Constructed as a machine with a leg attached; the
// assertions are unchanged.
fn make_peer_identity() -> PeerIdentity {
let identity = Identity::generate();
PeerIdentity::from_pubkey(identity.pubkey())
}
fn make_keypair() -> Keypair {
let identity = Identity::generate();
identity.keypair()
}
fn make_epoch() -> [u8; 8] {
let mut epoch = [0u8; 8];
rand::Rng::fill_bytes(&mut rand::rng(), &mut epoch);
epoch
}
fn outbound_leg(
link_id: LinkId,
expected_identity: PeerIdentity,
current_time_ms: u64,
) -> PeerMachine {
let mut machine = PeerMachine::new_outbound(link_id, expected_identity, current_time_ms);
machine.set_leg(HandshakeCrypto::new());
machine
}
fn inbound_leg(link_id: LinkId, current_time_ms: u64) -> PeerMachine {
let mut machine = PeerMachine::new_inbound(link_id, current_time_ms);
machine.set_leg(HandshakeCrypto::new());
machine
}
#[test]
fn test_outbound_connection() {
let identity = make_peer_identity();
let conn = outbound_leg(LinkId::new(1), identity, 1000);
assert!(conn.conn_is_outbound());
assert!(!conn.conn_is_inbound());
assert!(!conn.has_session());
assert!(conn.conn_expected_identity().is_some());
assert_eq!(conn.conn_started_at(), 1000);
}
#[test]
fn test_inbound_connection() {
let conn = inbound_leg(LinkId::new(2), 2000);
assert!(conn.conn_is_inbound());
assert!(!conn.conn_is_outbound());
assert!(!conn.has_session());
assert!(conn.conn_expected_identity().is_none());
assert_eq!(conn.conn_started_at(), 2000);
}
#[test]
fn test_full_handshake_flow() {
// Create identities
let initiator_identity = Identity::generate();
let responder_identity = Identity::generate();
let initiator_keypair = initiator_identity.keypair();
let responder_keypair = responder_identity.keypair();
let initiator_epoch = make_epoch();
let responder_epoch = make_epoch();
// Use from_pubkey_full to preserve parity for ECDH
let responder_peer_id = PeerIdentity::from_pubkey_full(responder_identity.pubkey_full());
// Create connections
let mut initiator_conn = outbound_leg(LinkId::new(1), responder_peer_id, 1000);
let mut responder_conn = inbound_leg(LinkId::new(2), 1000);
// Initiator starts handshake
let msg1 = initiator_conn
.start_handshake(initiator_keypair, initiator_epoch, 1100)
.unwrap();
// Post-msg1 the initiator holds an in-flight handshake, not yet a session.
assert!(!initiator_conn.has_session());
// Responder processes msg1 and sends msg2
let msg2 = responder_conn
.receive_handshake_init(responder_keypair, responder_epoch, &msg1, 1200)
.unwrap();
// The IK responder completes in one step: it now holds a session.
assert!(responder_conn.has_session());
// Responder learned initiator's identity
let discovered = responder_conn.conn_expected_identity().unwrap();
assert_eq!(discovered.pubkey(), initiator_identity.pubkey());
// Responder learned initiator's epoch
assert_eq!(responder_conn.conn_remote_epoch(), Some(initiator_epoch));
// Initiator completes handshake
initiator_conn.complete_handshake(&msg2, 1300).unwrap();
assert!(initiator_conn.has_session());
// Initiator learned responder's epoch
assert_eq!(initiator_conn.conn_remote_epoch(), Some(responder_epoch));
// Both have sessions
assert!(initiator_conn.has_session());
assert!(responder_conn.has_session());
// Take and verify sessions work
let mut init_session = initiator_conn.take_session().unwrap();
let mut resp_session = responder_conn.take_session().unwrap();
// Encrypt/decrypt test
let plaintext = b"test message";
let ciphertext = init_session.encrypt(plaintext).unwrap();
let decrypted = resp_session.decrypt(&ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[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 = outbound_leg(LinkId::new(1), identity, 1000);
conn.start_handshake(keypair, make_epoch(), 1100).unwrap();
conn.mark_failed();
assert!(!conn.has_session());
assert!(conn.complete_handshake(&[0u8; 96], 1200).is_err());
}
#[test]
fn test_wrong_direction_errors() {
let identity = make_peer_identity();
let keypair = make_keypair();
// Outbound can't receive_handshake_init
let mut outbound = outbound_leg(LinkId::new(1), identity, 1000);
assert!(
outbound
.receive_handshake_init(keypair, make_epoch(), &[0u8; 106], 1100)
.is_err()
);
// Inbound can't start_handshake
let mut inbound = inbound_leg(LinkId::new(2), 1000);
assert!(
inbound
.start_handshake(keypair, make_epoch(), 1100)
.is_err()
);
}
/// `LIVENESS_INTERVAL_MS` stays pinned to `NodeConfig`'s heartbeat default.
///
/// The expectation is read from the default rather than repeated as a
/// literal, so raising or lowering `heartbeat_interval_secs` without
/// re-pinning the constant reds here instead of drifting unnoticed.
#[test]
fn liveness_interval_matches_the_heartbeat_config_default() {
assert_eq!(
LIVENESS_INTERVAL_MS,
crate::config::NodeConfig::default().heartbeat_interval_secs * 1_000
);
}
/// `REKEY_AFTER_SECS` and `REKEY_AFTER_MESSAGES` stay pinned to
/// `RekeyConfig`'s defaults, read from the impl for the same reason.
#[test]
fn rekey_constants_match_the_rekey_config_defaults() {
let defaults = crate::config::RekeyConfig::default();
assert_eq!(REKEY_AFTER_SECS, defaults.after_secs);
assert_eq!(REKEY_AFTER_MESSAGES, defaults.after_messages);
}
/// A rekey cutover arms the drain timer for the drain window FSP uses.
///
/// The expected offset is the literal `10_000`: `DRAIN_WINDOW_SECS` in
/// `src/proto/fsp/limits.rs` is 10 seconds, and that is the value this
/// deadline is meant to carry. Writing it out rather than reusing
/// `DRAIN_WINDOW_MS` is what keeps the assertion able to fail; expressed
/// in terms of the constant under test it would move with any re-pointing
/// of that constant and assert nothing.
#[test]
fn drain_expiry_deadline_is_the_configured_drain_window() {
let mut alloc = IndexAllocator::new();
let id = peer_identity();
let addr = *id.node_addr();
let mut m = PeerMachine::new_outbound(LinkId::new(1), id, 0);
m.state = PeerState::Maintaining {
addr,
kind: MaintainKind::Rekey(RekeyPhase::PendingCutover),
};
m.rekey_our_index = Some(SessionIndex::new(0x2222));
m.conn.set_our_index(SessionIndex::new(0x1111));
m.session_established_at_ms = 0;
let actions = m.step(
PeerEvent::Timeout {
kind: TimerKind::RekeyCadence,
},
7_000,
&mut alloc,
);
let deadline = actions
.iter()
.find_map(|a| match a {
PeerAction::SetTimer {
kind: TimerKind::DrainExpiry,
at_ms,
} => Some(*at_ms),
_ => None,
})
.expect("the cutover must arm a DrainExpiry timer");
assert_eq!(deadline, 7_000 + 10_000);
}
/// `DRAIN_WINDOW_MS` is the FSP drain limit in milliseconds.
///
/// This is a tautology as the constant is now declared, and is not
/// coverage: it is an executable statement of where the value comes from.
/// It reds only if a later edit replaces the const expression with a
/// literal that disagrees with the limit.
#[test]
fn drain_window_ms_is_sourced_from_the_fsp_limit() {
assert_eq!(
DRAIN_WINDOW_MS,
crate::proto::fsp::limits::DRAIN_WINDOW_SECS * 1_000
);
}
}
/// T-SANSIO: the action vocabulary must stay plain, comparable data.
///
/// `PeerAction` is the sans-IO boundary between the pure reducer and its
/// driver. Requiring `Clone + Eq` is a compile-time statement that no variant
/// may carry a runtime handle (a socket, a `JoinHandle`, a channel sender),
/// since none of those are `Clone + Eq`. If a future variant smuggles one in,
/// this bound stops compiling.
#[cfg(test)]
mod action_contract {
use super::PeerAction;
fn assert_clone_eq<T: Clone + Eq>() {}
#[test]
fn peer_action_is_plain_comparable_data() {
assert_clone_eq::<PeerAction>();
}
}