mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Merge branch 'refactor-node' into refactor-node-next
Fold the handshake timer lifecycle (msg1 retransmit + timeout/stale reap) through the per-peer machine, re-expressed onto the XX handshake surface. - Route peer-machine removal through remove_peer_machine so each link's timer store is dropped together with its machine (choke-point). - Home the msg1-retransmit decision on the machine-armed retransmit timer; advance the resend counter only on send success and relocate the operator-visible resend count onto the machine. - Reap outbound handshake timeouts via a presence-scan over the machine HandshakeTimeout timer, reading the threshold from config each tick so the reap stays neutral for any handshake_timeout_secs. Cancel both dial-armed handshake timers on outbound promote so a promoted machine carries no stale timer entry. Preserve the guard that suppresses a msg1 resend at a peer already promoted via the inbound cross-connection path. The XX inbound HandshakeTimeout presence-scan is neutral only because the inbound establish path sends msg2 inline and never dispatches the inbound machine event, so no inbound leg ever populates a HandshakeTimeout timer. A future change that drives inbound establish through the machine must re-verify the reap equivalence for inbound legs.
This commit is contained in:
@@ -1323,7 +1323,7 @@ pub fn show_connections(node: &Node) -> Value {
|
||||
"handshake_state": format!("{}", conn.handshake_state()),
|
||||
"started_at_ms": conn.started_at(),
|
||||
"idle_ms": now.saturating_sub(conn.last_activity()),
|
||||
"resend_count": conn.resend_count(),
|
||||
"resend_count": node.connection_resend_count(conn.link_id()),
|
||||
});
|
||||
|
||||
if let Some(identity) = conn.expected_identity() {
|
||||
|
||||
@@ -191,7 +191,7 @@ impl Node {
|
||||
// derived above (a peer's link_id is immutable, so the key never
|
||||
// moved). Keeps peers <-> peer_machines in exact correspondence on
|
||||
// teardown. NEUTRAL: nothing reads peer_machines yet.
|
||||
self.peer_machines.remove(&link_id);
|
||||
self.remove_peer_machine(link_id);
|
||||
if let Some(transport_id) = transport_id {
|
||||
self.cleanup_bootstrap_transport_if_unused(transport_id);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,14 @@
|
||||
//! `SendHandshake` `their_index == Some` (msg2) branch stays dormant.
|
||||
//!
|
||||
//! Not yet driven, so their arms stay inert stubs: rekey/crypto installs,
|
||||
//! link-control frames, the timers (`SetTimer`/`CancelTimer` — the legacy tick
|
||||
//! still runs them), and the connected-UDP plane. `RegisterDecryptSession` is a
|
||||
//! deliberate no-op — see its arm for the note.
|
||||
//! link-control frames, and the connected-UDP plane. `RegisterDecryptSession` is
|
||||
//! a deliberate no-op — see its arm for the note.
|
||||
//!
|
||||
//! The timer arms (`SetTimer`/`CancelTimer`) populate/clear the per-peer timer
|
||||
//! store (`peer_timers`). The `HandshakeRetransmit` and `HandshakeTimeout`
|
||||
//! deadlines are read and fired by `drive_peer_timers` (the handshake resend +
|
||||
//! reap home). The rekey/liveness kinds are still SHADOW — driven by their own
|
||||
//! shell drivers — so populating them stays behavior-neutral.
|
||||
|
||||
use crate::PeerIdentity;
|
||||
use crate::node::reject::{HandshakeReject, RejectReason};
|
||||
@@ -149,7 +154,7 @@ impl Node {
|
||||
Err(_e) => {
|
||||
self.links.remove(&link);
|
||||
self.addr_to_link.remove(&(transport_id, remote_addr));
|
||||
self.peer_machines.remove(&link);
|
||||
self.remove_peer_machine(link);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -194,7 +199,7 @@ impl Node {
|
||||
if let Some(idx) = ambient.our_index {
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
self.peer_machines.remove(&link);
|
||||
self.remove_peer_machine(link);
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
|
||||
return;
|
||||
@@ -572,10 +577,21 @@ impl Node {
|
||||
// Connected-UDP plane ownership (`connected_udp.rs`). Out of
|
||||
// scope for now.
|
||||
}
|
||||
PeerAction::SetTimer { .. } | PeerAction::CancelTimer { .. } => {
|
||||
// Timers become actions on the existing quantized tick. INERT:
|
||||
// the legacy tick timers still run, so driving these would
|
||||
// double-schedule.
|
||||
PeerAction::SetTimer { kind, at_ms } => {
|
||||
// Populate the per-peer timer store (overwrite = reschedule).
|
||||
// The `HandshakeRetransmit` and `HandshakeTimeout` deadlines
|
||||
// are read + fired by `drive_peer_timers`. Rekey/liveness kinds
|
||||
// are still SHADOW here — they keep their own shell drivers —
|
||||
// so populating them stays behavior-neutral.
|
||||
self.peer_timers
|
||||
.entry(link)
|
||||
.or_default()
|
||||
.insert(kind, at_ms);
|
||||
}
|
||||
PeerAction::CancelTimer { kind } => {
|
||||
if let Some(timers) = self.peer_timers.get_mut(&link) {
|
||||
timers.remove(&kind);
|
||||
}
|
||||
}
|
||||
PeerAction::ReportLost { peer, kind } => {
|
||||
// The single loss token, routed to the reconciler reflex the
|
||||
|
||||
@@ -336,7 +336,7 @@ impl Node {
|
||||
self.poll_pending_connects().await;
|
||||
self.poll_nostr_rendezvous().await;
|
||||
self.poll_lan_rendezvous().await;
|
||||
self.resend_pending_handshakes(now_ms).await;
|
||||
self.drive_peer_timers(now_ms).await;
|
||||
self.resend_pending_rekeys(now_ms).await;
|
||||
self.resend_pending_fmp_rekey_msg3(now_ms).await;
|
||||
self.resend_pending_session_handshakes(now_ms).await;
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::node::acl::PeerAclContext;
|
||||
use crate::node::dataplane::PeerActionCtx;
|
||||
use crate::node::reject::{HandshakeReject, RejectReason};
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::peer::machine::{PeerAction, PeerEvent, PeerMachine};
|
||||
use crate::peer::machine::{PeerAction, PeerEvent, PeerMachine, TimerKind};
|
||||
use crate::peer::{ActivePeer, PeerConnection};
|
||||
use crate::proto::fmp::wire::{Msg1Header, Msg2Header, Msg3Header, build_msg2, build_msg3};
|
||||
use crate::proto::fmp::{
|
||||
@@ -598,7 +598,7 @@ impl Node {
|
||||
self.pending_outbound.remove(&key);
|
||||
self.connections.remove(&link_id);
|
||||
// Drop the machine persisted at dial — this leg never promotes.
|
||||
self.peer_machines.remove(&link_id);
|
||||
self.remove_peer_machine(link_id);
|
||||
self.remove_link(&link_id);
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
|
||||
@@ -667,7 +667,7 @@ impl Node {
|
||||
// directly, with no machine). Drop it on entry so none of this block's
|
||||
// exits leave a dangling machine — matching the pre-persistence path,
|
||||
// which created no machine for a cross-connection.
|
||||
self.peer_machines.remove(&link_id);
|
||||
self.remove_peer_machine(link_id);
|
||||
let our_outbound_wins = cross_connection_winner(
|
||||
self.identity().node_addr(),
|
||||
&peer_node_addr,
|
||||
@@ -827,9 +827,22 @@ impl Node {
|
||||
)
|
||||
}
|
||||
};
|
||||
// The outbound Msg2 Promote step cancels the two dial-armed handshake
|
||||
// timers (the machine survives promotion, so they would otherwise linger
|
||||
// in the driver's shadow store) and then promotes. The cancels are
|
||||
// behavior-neutral at this rung — `peer_timers` is written but not yet
|
||||
// driven — and `PromoteToActive` is still what performs the promotion.
|
||||
debug_assert_eq!(
|
||||
promote_actions,
|
||||
vec![PeerAction::PromoteToActive { link: link_id }]
|
||||
vec![
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeRetransmit
|
||||
},
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeTimeout
|
||||
},
|
||||
PeerAction::PromoteToActive { link: link_id },
|
||||
]
|
||||
);
|
||||
|
||||
let ambient = PeerActionCtx {
|
||||
@@ -1443,7 +1456,7 @@ impl Node {
|
||||
// drop it so no machine orphans when its ActivePeer is removed.
|
||||
// The winning connection's machine is inserted below keyed by
|
||||
// the winner link_id. NEUTRAL: nothing reads peer_machines yet.
|
||||
self.peer_machines.remove(&loser_link_id);
|
||||
self.remove_peer_machine(loser_link_id);
|
||||
|
||||
// Clean up old peer's index from peers_by_index
|
||||
if let (Some(old_tid), Some(old_idx)) =
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! and handshake message resend scheduling.
|
||||
|
||||
use crate::node::Node;
|
||||
use crate::peer::HandshakeState;
|
||||
use crate::peer::machine::TimerKind;
|
||||
use crate::proto::fmp::{
|
||||
ConnAction, ConnSnapshot, LifecycleView, PeerSnapshot, RekeyResendSnapshot,
|
||||
};
|
||||
@@ -11,9 +11,21 @@ use tracing::{debug, info};
|
||||
|
||||
impl LifecycleView for Node {
|
||||
fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec<ConnSnapshot> {
|
||||
// `is_failed()` legs are always reaped here (~1s), as before. The
|
||||
// idle-timeout is reaped here ONLY for legs whose timeout is not already
|
||||
// driven by a machine `HandshakeTimeout` timer — i.e. inbound legs (IK
|
||||
// inbound arms none) and machine-less test connections. Outbound legs with
|
||||
// an armed timer are reaped by `drive_handshake_timeouts`, so excluding
|
||||
// them here avoids a double reap.
|
||||
self.connections
|
||||
.iter()
|
||||
.filter(|(_, conn)| conn.is_timed_out(now_ms, timeout_ms) || conn.is_failed())
|
||||
.filter(|(link_id, conn)| {
|
||||
conn.is_failed()
|
||||
|| (conn.is_timed_out(now_ms, timeout_ms)
|
||||
&& !self.peer_timers.get(*link_id).is_some_and(|timers| {
|
||||
timers.contains_key(&TimerKind::HandshakeTimeout)
|
||||
}))
|
||||
})
|
||||
.map(|(link_id, conn)| ConnSnapshot {
|
||||
link: *link_id,
|
||||
is_outbound: conn.is_outbound(),
|
||||
@@ -24,36 +36,6 @@ impl LifecycleView for Node {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resend_candidates(&self, now_ms: u64, max_resends: u32) -> Vec<ConnSnapshot> {
|
||||
self.connections
|
||||
.iter()
|
||||
.filter(|(_, conn)| {
|
||||
conn.is_outbound()
|
||||
&& conn.handshake_state() == HandshakeState::SentMsg1
|
||||
&& conn.resend_count() < max_resends
|
||||
&& conn.next_resend_at_ms() > 0
|
||||
&& now_ms >= conn.next_resend_at_ms()
|
||||
// Skip resend if the target peer is already promoted — a
|
||||
// cross-connection was resolved via the inbound path and
|
||||
// resending msg1 would start a new handshake on the peer,
|
||||
// creating a session mismatch.
|
||||
&& !conn
|
||||
.expected_identity()
|
||||
.map(|id| self.peers.contains_key(id.node_addr()))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|(link_id, conn)| {
|
||||
conn.handshake_msg1().map(|msg1| ConnSnapshot {
|
||||
link: *link_id,
|
||||
is_outbound: true,
|
||||
retry_addr: None,
|
||||
resend_count: conn.resend_count(),
|
||||
msg1: msg1.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rekey_peers(&self) -> Vec<PeerSnapshot> {
|
||||
// The snapshot builder lives in `rekey` beside its drain/dampening
|
||||
// constants; the read-seam unifies here.
|
||||
@@ -127,7 +109,7 @@ impl Node {
|
||||
// `link_id` and lifetime; drop it here so a reaped handshake leg leaves
|
||||
// no dangling machine. A no-op for promoted peers — `promote_connection`
|
||||
// already removed their connection, so this reaper never runs for them.
|
||||
self.peer_machines.remove(&link_id);
|
||||
self.remove_peer_machine(link_id);
|
||||
let transport_id = conn.transport_id();
|
||||
|
||||
// Free session index and pending_outbound/pending_inbound if allocated
|
||||
@@ -146,22 +128,161 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resend handshake messages for pending connections.
|
||||
/// Act on the per-peer machine timers this tick.
|
||||
///
|
||||
/// For outbound connections in SentMsg1 state, resends the stored msg1
|
||||
/// with exponential backoff. Called periodically from the RX event loop.
|
||||
pub(in crate::node) async fn resend_pending_handshakes(&mut self, now_ms: u64) {
|
||||
if self.connections.is_empty() {
|
||||
/// The sans-IO machine arms `SetTimer`/`CancelTimer` actions into
|
||||
/// [`peer_timers`](Node::peer_timers); this is the shell driver that acts on
|
||||
/// them: timeout reaps idle-timed-out outbound legs, retransmit resends the
|
||||
/// due msg1s. Handshake-TIMEOUT is driven before handshake-RETRANSMIT so a
|
||||
/// timed-out leg is reaped rather than resent on the same tick. The
|
||||
/// rekey/liveness kinds keep their own shell drivers, so only the two
|
||||
/// handshake kinds are driven here.
|
||||
pub(in crate::node) async fn drive_peer_timers(&mut self, now_ms: u64) {
|
||||
if self.peer_timers.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.drive_handshake_timeouts(now_ms);
|
||||
self.drive_handshake_retransmits(now_ms).await;
|
||||
}
|
||||
|
||||
/// Reap the outbound legs whose machine `HandshakeTimeout` timer marks them
|
||||
/// as machine-timeout-owned and which have idle-timed-out this tick.
|
||||
///
|
||||
/// The timer's PRESENCE selects the leg (only OUTBOUND legs arm one — IK
|
||||
/// inbound arms none); the reap THRESHOLD is the shell `is_timed_out(now,
|
||||
/// config)` predicate, NOT the timer's stored deadline. This matters because
|
||||
/// the machine arms the timer from a hardcoded constant at dial, which is not
|
||||
/// authoritative for an operator-tuned `handshake_timeout_secs` — reading the
|
||||
/// threshold from config each tick keeps the reap neutral for any config, and
|
||||
/// off the `last_activity` clock exactly as the old `check_timeouts` did. A
|
||||
/// timed-out leg is reaped by the old Teardown path: the outbound retry
|
||||
/// reflex, then `cleanup_stale_connection` (which drops the machine + timers).
|
||||
///
|
||||
/// `check_timeouts` keeps reaping everything else — `is_failed()` legs and the
|
||||
/// idle-timeout of legs without a machine timer (inbound / machine-less).
|
||||
fn drive_handshake_timeouts(&mut self, now_ms: u64) {
|
||||
let timeout_ms = self.config().node.rate_limit.handshake_timeout_secs * 1000;
|
||||
let timer_links: Vec<LinkId> = self
|
||||
.peer_timers
|
||||
.iter()
|
||||
.filter(|(_, timers)| timers.contains_key(&TimerKind::HandshakeTimeout))
|
||||
.map(|(link, _)| *link)
|
||||
.collect();
|
||||
for link in timer_links {
|
||||
let (reap, retry_peer) = match self.connections.get(&link) {
|
||||
Some(conn) if conn.is_timed_out(now_ms, timeout_ms) => {
|
||||
let retry_peer = if conn.is_outbound() {
|
||||
conn.expected_identity().map(|id| *id.node_addr())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(true, retry_peer)
|
||||
}
|
||||
// Not yet idle-timed-out: leave the timer for a later tick.
|
||||
Some(_) => (false, None),
|
||||
None => {
|
||||
// Orphan timer (connection already reaped elsewhere) — drop it.
|
||||
if let Some(timers) = self.peer_timers.get_mut(&link) {
|
||||
timers.remove(&TimerKind::HandshakeTimeout);
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
};
|
||||
if reap {
|
||||
if let Some(peer) = retry_peer {
|
||||
self.note_handshake_timeout(peer, now_ms);
|
||||
}
|
||||
debug!(link_id = %link, "Handshake connection timed out");
|
||||
self.cleanup_stale_connection(link, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire due handshake-retransmit timers: resend the stored msg1.
|
||||
///
|
||||
/// The pre-fold `resend_pending_handshakes` logic, re-homed: the *due* signal
|
||||
/// is the machine-armed timer (not the connection's `next_resend_at_ms`), and
|
||||
/// the resend counter lives on the machine (the operator-visible count reads
|
||||
/// from there). The wire bytes and transport target still come from the shell
|
||||
/// connection, the pure core computes the backoff schedule, and — matching the
|
||||
/// old shell exactly — the count and reschedule advance only on a successful
|
||||
/// send; a failed send neither advances the count nor marks the connection
|
||||
/// failed, it just retries next tick.
|
||||
async fn drive_handshake_retransmits(&mut self, now_ms: u64) {
|
||||
let max_resends = self.config().node.rate_limit.handshake_max_resends;
|
||||
let interval_ms = self.config().node.rate_limit.handshake_resend_interval_ms;
|
||||
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
|
||||
|
||||
// The shell resolves the resend-candidate predicate and copies the
|
||||
// opaque msg1 bytes; the core computes the backoff schedule.
|
||||
let candidates = self.resend_candidates(now_ms, max_resends);
|
||||
// Collect due retransmit timers (kind-filtered).
|
||||
let due: Vec<LinkId> = self
|
||||
.peer_timers
|
||||
.iter()
|
||||
.filter(|(_, timers)| {
|
||||
timers
|
||||
.get(&TimerKind::HandshakeRetransmit)
|
||||
.is_some_and(|&at_ms| now_ms >= at_ms)
|
||||
})
|
||||
.map(|(link, _)| *link)
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Classify each due link against the machine + connection. A timer whose
|
||||
// machine has left `SentMsg1` (promoted/gone) or has hit the resend cap
|
||||
// is dropped — no more resends, exactly as the old shell stopped
|
||||
// selecting a capped/settled connection; the handshake-timeout reaper
|
||||
// takes it from there.
|
||||
let mut candidates: Vec<ConnSnapshot> = Vec::new();
|
||||
let mut drop_timers: Vec<LinkId> = Vec::new();
|
||||
for link in due {
|
||||
// Skip a resend whose target peer already promoted via the inbound
|
||||
// cross-connection path — resending msg1 would start a new handshake
|
||||
// (session mismatch).
|
||||
if let Some(conn) = self.connections.get(&link)
|
||||
&& conn
|
||||
.expected_identity()
|
||||
.map(|id| self.peers.contains_key(id.node_addr()))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
drop_timers.push(link);
|
||||
continue;
|
||||
}
|
||||
let armed = match self.peer_machines.get(&link) {
|
||||
Some(machine)
|
||||
if machine.is_handshaking_sent_msg1()
|
||||
&& machine.resend_count() < max_resends =>
|
||||
{
|
||||
machine.resend_count()
|
||||
}
|
||||
Some(_) => {
|
||||
drop_timers.push(link);
|
||||
continue;
|
||||
}
|
||||
None => {
|
||||
drop_timers.push(link);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self.connections.get(&link).and_then(|c| c.handshake_msg1()) {
|
||||
// Armed but the stored wire isn't there yet — leave the timer and
|
||||
// retry next tick (matches the old candidate filter skipping it).
|
||||
None => continue,
|
||||
Some(msg1) => candidates.push(ConnSnapshot {
|
||||
link,
|
||||
is_outbound: true,
|
||||
retry_addr: None,
|
||||
resend_count: armed,
|
||||
msg1: msg1.to_vec(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
for link in drop_timers {
|
||||
if let Some(timers) = self.peer_timers.get_mut(&link) {
|
||||
timers.remove(&TimerKind::HandshakeRetransmit);
|
||||
}
|
||||
}
|
||||
|
||||
for action in self
|
||||
.fmp
|
||||
.poll_resends(candidates, now_ms, interval_ms, backoff)
|
||||
@@ -175,7 +296,6 @@ impl Node {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get transport and address info from the connection
|
||||
let (transport_id, remote_addr) = match self.connections.get(&link) {
|
||||
Some(conn) => match (conn.transport_id(), conn.source_addr()) {
|
||||
(Some(tid), Some(addr)) => (tid, addr.clone()),
|
||||
@@ -184,7 +304,6 @@ impl Node {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Send the stored msg1
|
||||
let sent = if let Some(transport) = self.transports.get(&transport_id) {
|
||||
match transport.send(&remote_addr, &bytes).await {
|
||||
Ok(_) => true,
|
||||
@@ -201,13 +320,26 @@ impl Node {
|
||||
false
|
||||
};
|
||||
|
||||
if sent && let Some(conn) = self.connections.get_mut(&link) {
|
||||
conn.record_resend(next_resend_at_ms);
|
||||
debug!(
|
||||
link_id = %link,
|
||||
resend = conn.resend_count(),
|
||||
"Resent handshake msg1"
|
||||
);
|
||||
if sent {
|
||||
if let Some(machine) = self.peer_machines.get_mut(&link) {
|
||||
machine.record_resend(next_resend_at_ms);
|
||||
debug!(
|
||||
link_id = %link,
|
||||
resend = machine.resend_count(),
|
||||
"Resent handshake msg1"
|
||||
);
|
||||
}
|
||||
self.peer_timers
|
||||
.entry(link)
|
||||
.or_default()
|
||||
.insert(TimerKind::HandshakeRetransmit, next_resend_at_ms);
|
||||
} else {
|
||||
// Failed send: keep retrying at the tick cadence (the old shell
|
||||
// left next_resend_at_ms unchanged so the connection stayed due).
|
||||
self.peer_timers
|
||||
.entry(link)
|
||||
.or_default()
|
||||
.insert(TimerKind::HandshakeRetransmit, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,7 +607,7 @@ impl Node {
|
||||
self.links.remove(&link_id);
|
||||
self.addr_to_link
|
||||
.remove(&(transport_id, remote_addr.clone()));
|
||||
self.peer_machines.remove(&link_id);
|
||||
self.remove_peer_machine(link_id);
|
||||
return Err(NodeError::IndexAllocationFailed(e.to_string()));
|
||||
}
|
||||
};
|
||||
@@ -623,7 +623,7 @@ impl Node {
|
||||
self.links.remove(&link_id);
|
||||
self.addr_to_link
|
||||
.remove(&(transport_id, remote_addr.clone()));
|
||||
self.peer_machines.remove(&link_id);
|
||||
self.remove_peer_machine(link_id);
|
||||
return Err(NodeError::HandshakeFailed(e.to_string()));
|
||||
}
|
||||
};
|
||||
@@ -1393,7 +1393,7 @@ impl Node {
|
||||
);
|
||||
// Clean up link and dial-time machine on handshake failure
|
||||
self.remove_link(&pending.link_id);
|
||||
self.peer_machines.remove(&pending.link_id);
|
||||
self.remove_peer_machine(pending.link_id);
|
||||
} else {
|
||||
// Drive the dial-persisted machine: `Connecting` →
|
||||
// `on_transport_connected` → `start_outbound_handshake`,
|
||||
@@ -1460,7 +1460,7 @@ impl Node {
|
||||
self.remove_link(&pending.link_id);
|
||||
self.links.remove(&pending.link_id);
|
||||
if let Some(id) = &pending.peer_identity {
|
||||
self.peer_machines.remove(&pending.link_id);
|
||||
self.remove_peer_machine(pending.link_id);
|
||||
self.note_handshake_timeout(*id.node_addr(), Self::now_ms());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ use self::reloadable::Reloadable;
|
||||
pub(crate) const REKEY_JITTER_SECS: i64 = 15;
|
||||
use crate::cache::CoordCache;
|
||||
use crate::node::session::SessionEntry;
|
||||
use crate::peer::machine::PeerMachine;
|
||||
use crate::peer::machine::{PeerMachine, TimerKind};
|
||||
use crate::peer::{ActivePeer, PeerConnection};
|
||||
use crate::proto::bloom::{BloomFilter, BloomState};
|
||||
use crate::proto::fmp::Fmp;
|
||||
@@ -380,6 +380,15 @@ pub struct Node {
|
||||
#[allow(dead_code)]
|
||||
peer_machines: HashMap<LinkId, PeerMachine>,
|
||||
|
||||
/// Per-peer timer store, keyed by `LinkId` then `TimerKind`, holding each
|
||||
/// armed timer's absolute deadline (ms). The sans-IO time-as-input backing
|
||||
/// for `PeerEvent::Timeout`: populated/cleared by the machine's
|
||||
/// `SetTimer`/`CancelTimer` actions (`dataplane/peer_actions.rs`) and dropped
|
||||
/// alongside the machine through the `remove_peer_machine` choke-point. At
|
||||
/// this rung it is a SHADOW of the legacy tick timers — written but not yet
|
||||
/// read by any driver (the handshake-kind fold wires the reader).
|
||||
peer_timers: HashMap<LinkId, HashMap<TimerKind, u64>>,
|
||||
|
||||
// === Peers (Active Phase) ===
|
||||
/// Authenticated peers.
|
||||
/// Indexed by NodeAddr (verified identity).
|
||||
@@ -653,6 +662,7 @@ impl Node {
|
||||
child_exit_rx: None,
|
||||
connections: HashMap::new(),
|
||||
peer_machines: HashMap::new(),
|
||||
peer_timers: HashMap::new(),
|
||||
peers: HashMap::new(),
|
||||
sessions: HashMap::new(),
|
||||
identity_cache: HashMap::new(),
|
||||
@@ -803,6 +813,7 @@ impl Node {
|
||||
child_exit_rx: None,
|
||||
connections: HashMap::new(),
|
||||
peer_machines: HashMap::new(),
|
||||
peer_timers: HashMap::new(),
|
||||
peers: HashMap::new(),
|
||||
sessions: HashMap::new(),
|
||||
identity_cache: HashMap::new(),
|
||||
@@ -2013,7 +2024,7 @@ impl Node {
|
||||
handshake_state: format!("{}", conn.handshake_state()),
|
||||
started_at_ms: conn.started_at(),
|
||||
last_activity_ms: conn.last_activity(),
|
||||
resend_count: conn.resend_count(),
|
||||
resend_count: self.connection_resend_count(conn.link_id()),
|
||||
expected_peer: conn.expected_identity().map(|id| id.npub()),
|
||||
})
|
||||
.collect();
|
||||
@@ -2287,6 +2298,26 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Single choke-point for dropping a per-peer control machine. Also drops the
|
||||
/// machine's timer store so no armed `SetTimer` outlives it (the store and the
|
||||
/// machine share the `LinkId` lifetime). Every teardown path routes machine
|
||||
/// removal here rather than calling `peer_machines.remove` directly.
|
||||
pub(in crate::node) fn remove_peer_machine(&mut self, link: LinkId) {
|
||||
self.peer_machines.remove(&link);
|
||||
self.peer_timers.remove(&link);
|
||||
}
|
||||
|
||||
/// Operator-visible msg1 resend count for a pending handshake `link`, read
|
||||
/// from the per-peer machine (the counter's home once the resend drive moved
|
||||
/// off the shell connection). Machine-less connections (inbound legs that
|
||||
/// never resend, and test-created connections) report 0, matching what the
|
||||
/// shell connection reported before the counter moved.
|
||||
pub(crate) fn connection_resend_count(&self, link: LinkId) -> u32 {
|
||||
self.peer_machines
|
||||
.get(&link)
|
||||
.map_or(0, |machine| machine.resend_count())
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup_bootstrap_transport_if_unused(&mut self, transport_id: TransportId) {
|
||||
if !self
|
||||
.supervisor
|
||||
|
||||
@@ -899,28 +899,125 @@ async fn test_resend_scheduling() {
|
||||
);
|
||||
node.links.insert(link_id, link);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, remote_addr), link_id);
|
||||
.insert((transport_id, remote_addr.clone()), link_id);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, our_index.as_u32()), link_id);
|
||||
node.connections.insert(link_id, conn);
|
||||
|
||||
// Before resend time: nothing should happen (no transport = can't send,
|
||||
// but the filter should exclude it because now < next_resend_at)
|
||||
node.resend_pending_handshakes(now_ms + 500).await;
|
||||
let conn = node.connections.get(&link_id).unwrap();
|
||||
assert_eq!(conn.resend_count(), 0, "No resend before scheduled time");
|
||||
// The msg1-resend counter and its due timer live on the per-peer machine.
|
||||
// Dial it to `SentMsg1` (connectionless: no connect step) and arm its
|
||||
// retransmit timer at now + 1000ms, mirroring what a real dial arms.
|
||||
let mut machine =
|
||||
crate::peer::machine::PeerMachine::new_outbound(link_id, peer_identity, now_ms);
|
||||
let _ = machine.step(
|
||||
crate::peer::machine::PeerEvent::Dial {
|
||||
transport_id,
|
||||
remote_addr: remote_addr.clone(),
|
||||
peer_identity,
|
||||
connection_oriented: false,
|
||||
},
|
||||
now_ms,
|
||||
&mut node.index_allocator,
|
||||
);
|
||||
node.peer_machines.insert(link_id, machine);
|
||||
node.peer_timers.entry(link_id).or_default().insert(
|
||||
crate::peer::machine::TimerKind::HandshakeRetransmit,
|
||||
now_ms + 1000,
|
||||
);
|
||||
|
||||
// At resend time: would resend if transport existed. Without transport,
|
||||
// the send fails silently and resend_count stays at 0.
|
||||
// This tests the filtering logic — the connection IS a candidate.
|
||||
node.resend_pending_handshakes(now_ms + 1000).await;
|
||||
// No transport registered, so send fails — count stays 0.
|
||||
// That's the expected behavior (transport absence is a transient condition).
|
||||
let conn = node.connections.get(&link_id).unwrap();
|
||||
// Before the scheduled time the timer isn't due, so nothing fires.
|
||||
node.drive_peer_timers(now_ms + 500).await;
|
||||
assert_eq!(
|
||||
conn.resend_count(),
|
||||
node.connection_resend_count(link_id),
|
||||
0,
|
||||
"No transport means no resend recorded"
|
||||
"No resend before scheduled time"
|
||||
);
|
||||
|
||||
// At the scheduled time the timer is due, but no transport is registered so
|
||||
// the send fails. Record-on-success: the count does NOT advance (and the
|
||||
// connection is not marked failed) — a failed resend just retries next tick.
|
||||
node.drive_peer_timers(now_ms + 1000).await;
|
||||
assert_eq!(
|
||||
node.connection_resend_count(link_id),
|
||||
0,
|
||||
"Failed send records no resend"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that the timer driver reaps an outbound leg whose machine
|
||||
/// `HandshakeTimeout` timer has come due (the timeout fold). The reap re-checks
|
||||
/// the shell `is_timed_out` predicate, then tears the connection down exactly as
|
||||
/// the old `check_timeouts` Teardown path did.
|
||||
#[tokio::test]
|
||||
async fn test_handshake_timeout_drive() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let peer_identity = make_peer_identity();
|
||||
let remote_addr = TransportAddr::from_string("10.0.0.2:2121");
|
||||
|
||||
let dial_ms = 1000u64;
|
||||
let link_id = node.allocate_link_id();
|
||||
let mut conn = PeerConnection::outbound(link_id, peer_identity, dial_ms);
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
let our_keypair = node.identity().keypair();
|
||||
let _ = conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), dial_ms)
|
||||
.unwrap();
|
||||
conn.set_our_index(our_index);
|
||||
conn.set_transport_id(transport_id);
|
||||
conn.set_source_addr(remote_addr.clone());
|
||||
|
||||
let link = Link::connectionless(
|
||||
link_id,
|
||||
transport_id,
|
||||
remote_addr.clone(),
|
||||
LinkDirection::Outbound,
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node.links.insert(link_id, link);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, remote_addr.clone()), link_id);
|
||||
node.connections.insert(link_id, conn);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, our_index.as_u32()), link_id);
|
||||
|
||||
// Machine in SentMsg1 with a HandshakeTimeout timer armed at dial + 30s.
|
||||
let mut machine =
|
||||
crate::peer::machine::PeerMachine::new_outbound(link_id, peer_identity, dial_ms);
|
||||
let _ = machine.step(
|
||||
crate::peer::machine::PeerEvent::Dial {
|
||||
transport_id,
|
||||
remote_addr: remote_addr.clone(),
|
||||
peer_identity,
|
||||
connection_oriented: false,
|
||||
},
|
||||
dial_ms,
|
||||
&mut node.index_allocator,
|
||||
);
|
||||
node.peer_machines.insert(link_id, machine);
|
||||
node.peer_timers.entry(link_id).or_default().insert(
|
||||
crate::peer::machine::TimerKind::HandshakeTimeout,
|
||||
dial_ms + 30_000,
|
||||
);
|
||||
|
||||
assert_eq!(node.connection_count(), 1);
|
||||
|
||||
// Well past dial + 30s: the timer is due and the leg is idle-timed-out.
|
||||
node.drive_peer_timers(dial_ms + 100_000).await;
|
||||
|
||||
assert_eq!(
|
||||
node.connection_count(),
|
||||
0,
|
||||
"Timed-out leg reaped by the timer drive"
|
||||
);
|
||||
assert_eq!(node.index_allocator.count(), 0, "Session index freed");
|
||||
assert!(
|
||||
!node.peer_machines.contains_key(&link_id),
|
||||
"Control machine dropped with the reaped connection"
|
||||
);
|
||||
assert!(
|
||||
!node.peer_timers.contains_key(&link_id),
|
||||
"Timer store dropped with the reaped connection"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,14 @@ pub(crate) enum FailReason {
|
||||
}
|
||||
|
||||
/// A timer the machine schedules on the driver's quantized tick.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
///
|
||||
/// `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,
|
||||
@@ -536,7 +543,37 @@ impl PeerMachine {
|
||||
self.our_index
|
||||
}
|
||||
|
||||
/// The crystallized node address, if known.
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// The crystallized node address, if identity is known.
|
||||
fn addr(&self) -> Option<NodeAddr> {
|
||||
self.node_addr
|
||||
}
|
||||
@@ -806,7 +843,19 @@ impl PeerMachine {
|
||||
self.conn.set_their_index(their_index);
|
||||
let addr = self.addr().unwrap_or_else(zero_addr);
|
||||
self.state = PeerState::Established { addr };
|
||||
vec![PeerAction::PromoteToActive { link: self.link }]
|
||||
// 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 },
|
||||
]
|
||||
}
|
||||
|
||||
/// XX inbound cross-connection at msg3. The Noise session swap and the
|
||||
@@ -1426,10 +1475,50 @@ mod tests {
|
||||
|
||||
let actions = m.step(PeerEvent::OutboundMsg2 { their_index }, 0, &mut alloc);
|
||||
|
||||
assert_eq!(actions, vec![PeerAction::PromoteToActive { link }]);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeRetransmit
|
||||
},
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeTimeout
|
||||
},
|
||||
PeerAction::PromoteToActive { link },
|
||||
]
|
||||
);
|
||||
assert_eq!(m.state(), PeerState::Established { addr });
|
||||
}
|
||||
|
||||
// ---- Test: outbound promote at msg2 cancels handshake timers -----------
|
||||
// The promoted machine survives as the active peer's control machine, so
|
||||
// its outbound handshake retransmit + timeout timers must be cancelled at
|
||||
// promotion or they linger in the driver's store. The promote must emit the
|
||||
// two CancelTimer actions ahead of PromoteToActive, in that exact order.
|
||||
#[test]
|
||||
fn outbound_msg2_promote_cancels_handshake_timers() {
|
||||
let mut alloc = IndexAllocator::new();
|
||||
let id = peer_identity();
|
||||
let link = LinkId::new(4);
|
||||
let mut m = PeerMachine::new_outbound(link, id, 0);
|
||||
let their_index = SessionIndex::new(0x88);
|
||||
|
||||
let actions = m.step(PeerEvent::OutboundMsg2 { their_index }, 0, &mut alloc);
|
||||
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeRetransmit
|
||||
},
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeTimeout
|
||||
},
|
||||
PeerAction::PromoteToActive { link },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Contract: actions are runtime-agnostic data ----------------------
|
||||
//
|
||||
// The machine's emitted actions are the message contract between the sync
|
||||
@@ -2045,9 +2134,17 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
promote,
|
||||
vec![PeerAction::PromoteToActive {
|
||||
link: LinkId::new(1)
|
||||
}]
|
||||
vec![
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeRetransmit
|
||||
},
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeTimeout
|
||||
},
|
||||
PeerAction::PromoteToActive {
|
||||
link: LinkId::new(1)
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
m.our_index(),
|
||||
@@ -2142,9 +2239,17 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
promote,
|
||||
vec![PeerAction::PromoteToActive {
|
||||
link: LinkId::new(1)
|
||||
}]
|
||||
vec![
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeRetransmit
|
||||
},
|
||||
PeerAction::CancelTimer {
|
||||
kind: TimerKind::HandshakeTimeout
|
||||
},
|
||||
PeerAction::PromoteToActive {
|
||||
link: LinkId::new(1)
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(m.our_index(), None);
|
||||
}
|
||||
|
||||
@@ -328,13 +328,6 @@ pub(crate) trait LifecycleView {
|
||||
/// timeout/failed predicate; the core decides retry-then-teardown.
|
||||
fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec<ConnSnapshot>;
|
||||
|
||||
/// Snapshot every outbound connection whose stored msg1 is due for a
|
||||
/// resend as of `now_ms` and still under `max_resends`. The shell resolves
|
||||
/// the "outbound, in `SentMsg1`, has stored msg1, under budget, past the
|
||||
/// scheduled time" predicate and copies the opaque msg1 bytes; the core
|
||||
/// computes the backoff schedule.
|
||||
fn resend_candidates(&self, now_ms: u64, max_resends: u32) -> Vec<ConnSnapshot>;
|
||||
|
||||
/// Snapshot every active peer with a session that is healthy, pre-computing
|
||||
/// its rekey-relevant ages and timer predicates (see [`PeerSnapshot`]). The
|
||||
/// shell resolves every clock read here; the core applies the thresholds.
|
||||
|
||||
Reference in New Issue
Block a user