node/dataplane: add the per-peer machine home and action executor (unwired)

Add Node.peer_machines (a LinkId-keyed map from the stable link handle to
the per-peer control machine) as the home for the machines, and a new
dataplane/peer_actions.rs holding execute_peer_actions / advance_peer_machine:
the executor that maps each PeerAction the machine emits to its shell call —
frame and send a handshake via build_msg2, drive promote_connection and feed
the PromotionResult back through the machine, tear a peer down via
remove_active_peer, free session indices, report loss via note_link_dead.
Actions for the rekey, connected-UDP, and timer paths are stubbed with notes
for the commits that fold those mechanisms in.

Unwired: nothing drives the machine yet — no live handler path calls the
executor and peer_machines is never populated — so this is behavior-neutral;
the inbound and outbound establish paths still run their existing inline
logic. The executor is cut over path-by-path in the following commits.
This commit is contained in:
Johnathan Corgan
2026-07-13 11:11:20 +00:00
parent 59155df4e3
commit 4a0584a5e9
3 changed files with 231 additions and 0 deletions
+1
View File
@@ -12,4 +12,5 @@ pub(crate) mod connected_udp;
mod dispatch;
mod encrypted;
mod forwarding;
mod peer_actions;
mod rx_loop;
+216
View File
@@ -0,0 +1,216 @@
//! Executor for the per-peer control machine's [`PeerAction`]s (Step 2 / C3).
//!
//! The per-peer FSM in [`crate::peer::machine`] is a sans-IO reducer: it decides
//! *what* must happen and returns a `Vec<PeerAction>`; this module is the *doing*
//! half — the thin driver that maps each action onto the exact shell call it
//! stands for (`build_msg2` + `transport.send`, `promote_connection`,
//! `remove_active_peer`, `index_allocator.free`, `note_link_dead`, …).
//!
//! ## C3-1 skeleton (SHADOW-ONLY)
//!
//! This is the **C3-1** increment: the machine home (`Node.peer_machines`), the
//! executor, and the disjoint-borrow advance helper. It is **unwired** — the live
//! `handle_msg1`/`handle_msg2` path does not drive it yet, so every method here is
//! `#[allow(dead_code)]`. The inbound cutover (`handle_msg1` → `step(InboundMsg1)`)
//! lands in **C3-2**, the outbound cutover (`handle_msg2` / dial) in **C3-3**.
//!
//! Arms the C3 ladder does not yet exercise are inert stubs carrying the sub-commit
//! that realizes them (`OpenTransport`→C3-3, `SendRekey`/`SwapSendState`→C4,
//! `SendLinkMessage`→C4/C5, `SetTimer`/`CancelTimer`→C5 inert, connected-UDP→C6).
//! `RegisterDecryptSession` is a deliberate no-op — see its arm for the C3-2 note.
use crate::node::Node;
use crate::peer::machine::{PeerAction, PeerEvent};
use crate::proto::fmp::wire::build_msg2;
use crate::transport::{LinkId, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
use crate::{NodeAddr, PeerIdentity};
use std::collections::VecDeque;
/// Ambient shell facts a [`PeerAction`] executor needs that the machine's
/// runtime-agnostic action payloads deliberately omit (verified identity,
/// transport target, the msg2 framing indices, the promotion timestamp).
///
/// Unlike a machine event/action payload this is **executor-side**, so it may
/// hold real values resolved from the wire context (cf. `handle_msg1`'s
/// `wire`/`packet` locals and `drive_promote_to_active`'s ambient args). It is
/// built fresh per driven step by the caller at cutover time (C3-2/C3-3).
#[allow(dead_code)]
pub(in crate::node) struct PeerActionCtx {
/// The authenticated peer identity (GAP-1: `PromoteToActive` /
/// `InvalidateSendState` resolve their `NodeAddr` from this).
pub(in crate::node) verified_identity: PeerIdentity,
/// The transport the exchange is happening over (msg2 send target, decrypt
/// cache-key transport half).
pub(in crate::node) transport_id: TransportId,
/// The peer's wire address (msg2 send target).
pub(in crate::node) remote_addr: TransportAddr,
/// Our session index for this exchange (GAP-2: msg2 framing sender_idx).
pub(in crate::node) our_index: Option<SessionIndex>,
/// The peer's session index for this exchange (GAP-2: msg2 framing
/// receiver_idx).
pub(in crate::node) their_index: Option<SessionIndex>,
/// The wire timestamp driving this step (promotion ts / loss-report clock).
pub(in crate::node) now_ms: u64,
}
impl Node {
/// Advance the machine for `link` by one event and execute the resulting
/// actions.
///
/// The borrow structure the whole seam turns on (spec risk #8): the machine
/// needs `&mut IndexAllocator` as a synchronous capability *while it is
/// itself borrowed mutably out of `peer_machines`*. `peer_machines` and
/// `index_allocator` are **distinct `Node` fields**, so the collect below is
/// a disjoint two-field borrow the checker accepts; once the actions are
/// collected both borrows drop and the executor runs against `&mut self`.
#[allow(dead_code)]
pub(in crate::node) async fn advance_peer_machine(
&mut self,
link: LinkId,
event: PeerEvent,
now: u64,
ambient: &PeerActionCtx,
) {
let actions = match self.peer_machines.get_mut(&link) {
// Disjoint field borrow: `self.peer_machines` (the map entry) and
// `self.index_allocator` (the capability) are separate fields.
Some(machine) => machine.step(event, now, &mut self.index_allocator),
None => return,
};
self.execute_peer_actions(link, ambient, actions).await;
}
/// Map each [`PeerAction`] onto its shell call (spec's executor table).
///
/// `PromoteToActive` feeds its [`PromotionResult`](crate::proto::fmp::PromotionResult)
/// back into the machine (GAP-1) and appends the follow-up actions to the same
/// worklist — a queue rather than self-recursion so the async executor stays a
/// single flat future (no boxing) and the emitted order is preserved (the
/// establish sequences always end in `PromoteToActive`, so its follow-ups run
/// after any siblings).
#[allow(dead_code)]
pub(in crate::node) async fn execute_peer_actions(
&mut self,
link: LinkId,
ambient: &PeerActionCtx,
actions: Vec<PeerAction>,
) {
let _ = link;
let mut queue: VecDeque<PeerAction> = actions.into();
while let Some(action) = queue.pop_front() {
match action {
PeerAction::OpenTransport { .. } => {
// C3-3: outbound dial (`initiate_connection`,
// `lifecycle/mod.rs:470`). Outbound establish is not cut over
// until C3-3; inert in the C3-1 skeleton.
}
PeerAction::SendHandshake { bytes } => {
// GAP-2: the machine payload is the UNFRAMED Noise msg2 payload;
// frame it with our/their index (mirrors `handshake.rs:472`'s
// `build_msg2(our_index, their_index, &payload)`) before the
// wire send. A fresh-outbound msg1 (empty payload → build msg1
// from indices) is framed differently and lands in C3-3.
if let (Some(sender_idx), Some(receiver_idx)) =
(ambient.our_index, ambient.their_index)
{
let frame = build_msg2(sender_idx, receiver_idx, &bytes);
if let Some(transport) = self.transports.get(&ambient.transport_id) {
let _ = transport.send(&ambient.remote_addr, &frame).await;
}
}
}
PeerAction::SendRekey { .. } => {
// C4: rekey msg2 framing (`build_msg2(our_new_index, …)`,
// `handshake.rs:365`) + send. Rekey fold is out of C3 scope.
}
PeerAction::SendLinkMessage { .. } => {
// C4/C5: encrypt + send a link-control frame (heartbeat / filter
// / tree / disconnect). Data-plane-owned; out of C3 scope.
}
PeerAction::PromoteToActive { link: promote_link } => {
// GAP-1: ambient supplies the verified identity + promotion ts
// that `promote_connection` needs (cf. `drive_promote_to_active`).
match self.promote_connection(
promote_link,
ambient.verified_identity,
ambient.now_ms,
) {
Ok(result) => {
// Feed the outcome back into the machine and fold the
// follow-up actions (RegisterDecryptSession, cross-conn
// frees) into the worklist. Disjoint field borrow again.
let follow = match self.peer_machines.get_mut(&promote_link) {
Some(machine) => machine.step(
PeerEvent::PromotionResolved { result },
ambient.now_ms,
&mut self.index_allocator,
),
None => Vec::new(),
};
queue.extend(follow);
}
Err(_e) => {
// C3-2 realizes the promotion-failure cleanup tail
// (`handle_msg1:587` / `handle_msg2:1005`).
}
}
}
PeerAction::SwapSendState { .. } => {
// C4: initiator cutover (`active.rs:1033`
// `cutover_to_new_session`).
}
PeerAction::InvalidateSendState => {
// GAP-4 (biggest): the FULL teardown. `remove_active_peer`
// (`dispatch.rs:107`) frees the four index slots
// (current/rekey/pending/previous), drops `peers_by_index`,
// unregisters the decrypt worker, removes the FSP `sessions`
// entry and `pending_tun_packets`. The machine emits NO
// `FreeIndex` for those slots, so there is no double-free.
self.remove_active_peer(ambient.verified_identity.node_addr());
}
PeerAction::RegisterDecryptSession { index } => {
let _ = index;
// C3-2 (HALT-reported): the decrypt-worker registration still
// runs INSIDE `promote_connection` (`handshake.rs:1193/1305`),
// which is the single source of truth for its ~40 direct
// `promote_connection` callers (unit/integration tests) and the
// two live handlers. Relocating it out (GAP-3) would perturb the
// live promote path, so C3-1 keeps it there and drives this
// action as a no-op; the relocation lands with the inbound
// cutover in C3-2.
}
PeerAction::UnregisterDecryptSession { index } => {
// Executor supplies `transport_id` from ambient; keyed by
// (tid, index) like `remove_active_peer` / the rekey drain path.
#[cfg(unix)]
self.unregister_decrypt_worker_session((ambient.transport_id, index.as_u32()));
#[cfg(not(unix))]
let _ = index;
}
PeerAction::FreeIndex { index } => {
let _ = self.index_allocator.free(index);
}
PeerAction::ActivateConnectedUdp | PeerAction::TeardownConnectedUdp => {
// C6: connected-UDP plane ownership (`connected_udp.rs`).
}
PeerAction::SetTimer { .. } | PeerAction::CancelTimer { .. } => {
// C5: timers become actions on the existing quantized tick.
// INERT in C3 — the legacy tick timers still run, so driving
// these would double-schedule (spec risk #7).
}
PeerAction::ReportLost { peer } => {
// The single loss token → the reconciler reflex (`driver.rs:48`).
self.report_peer_lost(peer, ambient.now_ms);
}
}
}
}
/// `ReportLost` → `note_link_dead` (kept as a named seam so the ambient clock
/// source is explicit and C5 can thread the reconciler-computed backoff).
#[allow(dead_code)]
fn report_peer_lost(&mut self, peer: NodeAddr, now_ms: u64) {
self.note_link_dead(peer, now_ms);
}
}
+14
View File
@@ -37,6 +37,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::{ActivePeer, PeerConnection};
use crate::proto::bloom::{BloomFilter, BloomState};
use crate::proto::fmp::Fmp;
@@ -352,6 +353,17 @@ pub struct Node {
/// Indexed by LinkId since we don't know the peer's identity yet.
connections: HashMap<LinkId, PeerConnection>,
// === Per-Peer Control Machines (Step 2 / C3) ===
/// Per-peer lifecycle control FSMs, keyed by the stable `LinkId` that spans
/// the handshake→active lifetime. A NEW parallel structure introduced by the
/// node-runtime decomposition: `connections`/`peers` stay byte-unchanged (hot
/// path pristine) and are cut over to this machine home path-by-path. Unwired
/// in C3-1 — the executor (`dataplane/peer_actions.rs`) and advance helper
/// exist but the live `handle_msg1`/`handle_msg2` path does not drive them yet;
/// the inbound cutover lands in C3-2.
#[allow(dead_code)]
peer_machines: HashMap<LinkId, PeerMachine>,
// === Peers (Active Phase) ===
/// Authenticated peers.
/// Indexed by NodeAddr (verified identity).
@@ -618,6 +630,7 @@ impl Node {
child_exit_tx: None,
child_exit_rx: None,
connections: HashMap::new(),
peer_machines: HashMap::new(),
peers: HashMap::new(),
sessions: HashMap::new(),
identity_cache: HashMap::new(),
@@ -765,6 +778,7 @@ impl Node {
child_exit_tx: None,
child_exit_rx: None,
connections: HashMap::new(),
peer_machines: HashMap::new(),
peers: HashMap::new(),
sessions: HashMap::new(),
identity_cache: HashMap::new(),