feat(cluster): RFC 010 c5 — handshake as a pure state machine
Frames in, actions out — no IO, no clocks, no actors; the c6 connection actor will drive it. Initiator (dial: emit Hello, interpret the single response) and Responder (accept: judge the first frame) as consuming-self machines; check order proto -> hash -> name -> tie-break. Driver-supplied HelloCtx carries the two facts the pure machine cannot know (name claimed, own dial in flight). Tie-break ratified as a wire fact: the smaller name's dial survives; the losing inbound closes silently (both ends compute the same verdict, no reject frame needed). Peer's own name offered => NameTaken. build_hash is config-supplied; derivation lands with c6.
This commit is contained in:
+3
-1
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! c1: feature flag + optional deps. c2: the owned envelope. c3: the
|
||||
//! transport trait (control connection), framed codec, and the TCP +
|
||||
//! loopback impls. Everything above them lands in later chunks.
|
||||
//! loopback impls. c5: the handshake state machine. Everything above them
|
||||
//! lands in later chunks.
|
||||
|
||||
pub mod envelope;
|
||||
pub mod handshake;
|
||||
pub mod transport;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! RFC 010 c5 — the handshake as a pure state machine.
|
||||
//!
|
||||
//! Frames in, actions out — no IO, no clocks, no actors. The c6 connection
|
||||
//! actor drives these machines and executes their actions; everything
|
||||
//! time-shaped (handshake deadline, heartbeats) lives there.
|
||||
|
||||
use crate::cluster::envelope::{Frame, NodeMeta, RejectReason, PROTO_VERSION};
|
||||
use crate::pg::Incarnation;
|
||||
|
||||
/// This node's identity and metadata, as offered in (or checked against) a
|
||||
/// `Hello`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Local {
|
||||
pub node_name: String,
|
||||
pub incarnation: Incarnation,
|
||||
pub build_hash: u64,
|
||||
pub meta: NodeMeta,
|
||||
}
|
||||
|
||||
/// The peer identity a successful handshake yields (what c7 feeds `node_up`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Peer {
|
||||
pub node_name: String,
|
||||
pub incarnation: Incarnation,
|
||||
pub meta: NodeMeta,
|
||||
}
|
||||
|
||||
/// Driver-supplied context for an inbound `Hello` — knowledge the pure
|
||||
/// machine cannot have (c6 owns the connection table and dial set).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HelloCtx {
|
||||
/// The offered name is already claimed by an established peer.
|
||||
pub name_claimed: bool,
|
||||
/// We have our own dial in flight to this peer name.
|
||||
pub dialing_this_peer: bool,
|
||||
}
|
||||
|
||||
/// Simultaneous-connect tie-break: does the connection dialed by
|
||||
/// `dialer_name` survive against the reverse dial?
|
||||
/// The rule (ratified 2026-08-14, a wire-protocol fact): the connection
|
||||
/// dialed by the lexicographically **smaller** name survives. Both ends know
|
||||
/// both names, so both compute the same verdict — which is why the losing
|
||||
/// side may close silently instead of sending a reject.
|
||||
pub fn dial_wins(dialer_name: &str, acceptor_name: &str) -> bool {
|
||||
dialer_name < acceptor_name
|
||||
}
|
||||
|
||||
/// Dial side: emits `Hello` at construction, interprets the single response.
|
||||
#[must_use]
|
||||
#[derive(Debug)]
|
||||
pub struct Initiator(());
|
||||
|
||||
/// What the dial side's response frame meant.
|
||||
#[must_use]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum InitiatorOutcome {
|
||||
Established(Peer),
|
||||
Rejected(RejectReason),
|
||||
/// Protocol violation before the ack — close. Carries the offending frame.
|
||||
Failed(Frame),
|
||||
}
|
||||
|
||||
impl Initiator {
|
||||
/// Start a dial-side handshake: the returned frame is the `Hello` to
|
||||
/// send; the returned machine is the right to interpret the response.
|
||||
pub fn new(local: &Local) -> (Self, Frame) {
|
||||
let hello = Frame::Hello {
|
||||
proto_version: PROTO_VERSION,
|
||||
build_hash: local.build_hash,
|
||||
node_name: local.node_name.clone(),
|
||||
incarnation: local.incarnation,
|
||||
meta: local.meta.clone(),
|
||||
};
|
||||
(Initiator(()), hello)
|
||||
}
|
||||
|
||||
/// Interpret the response. The `HelloAck` carries no hash or version —
|
||||
/// the responder already checked ours against its own, and equality is
|
||||
/// symmetric, so a one-sided check is sound.
|
||||
pub fn on_frame(self, frame: Frame) -> InitiatorOutcome {
|
||||
match frame {
|
||||
Frame::HelloAck {
|
||||
node_name,
|
||||
incarnation,
|
||||
meta,
|
||||
} => InitiatorOutcome::Established(Peer {
|
||||
node_name,
|
||||
incarnation,
|
||||
meta,
|
||||
}),
|
||||
Frame::HelloReject { reason } => InitiatorOutcome::Rejected(reason),
|
||||
other => InitiatorOutcome::Failed(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept side: awaits exactly one `Hello`, answers or closes.
|
||||
#[must_use]
|
||||
#[derive(Debug)]
|
||||
pub struct Responder {
|
||||
local: Local,
|
||||
}
|
||||
|
||||
/// What to do with an inbound connection's first frame.
|
||||
#[must_use]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ResponderOutcome {
|
||||
/// Send the ack; the connection is established.
|
||||
Accepted { reply: Frame, peer: Peer },
|
||||
/// Send the reject, then close.
|
||||
Rejected { reply: Frame, reason: RejectReason },
|
||||
/// Lost the simultaneous-connect tie-break: close silently, no frame.
|
||||
TieBreakLoss,
|
||||
/// Protocol violation before Hello — close, no reply. Carries the frame.
|
||||
Failed(Frame),
|
||||
}
|
||||
|
||||
impl Responder {
|
||||
pub fn new(local: Local) -> Self {
|
||||
Responder { local }
|
||||
}
|
||||
|
||||
/// Judge the connection's first frame. Check order is proto → hash →
|
||||
/// name → tie-break: validity before identity. `HelloReject` is the
|
||||
/// cross-version compatibility anchor, so a version-mismatched peer
|
||||
/// still gets one.
|
||||
pub fn on_frame(self, frame: Frame, ctx: HelloCtx) -> ResponderOutcome {
|
||||
let Frame::Hello {
|
||||
proto_version,
|
||||
build_hash,
|
||||
node_name,
|
||||
incarnation,
|
||||
meta,
|
||||
} = frame
|
||||
else {
|
||||
return ResponderOutcome::Failed(frame);
|
||||
};
|
||||
|
||||
let reject = |reason| ResponderOutcome::Rejected {
|
||||
reply: Frame::HelloReject { reason },
|
||||
reason,
|
||||
};
|
||||
|
||||
if proto_version != PROTO_VERSION {
|
||||
return reject(RejectReason::ProtoVersion);
|
||||
}
|
||||
if build_hash != self.local.build_hash {
|
||||
return reject(RejectReason::HashMismatch);
|
||||
}
|
||||
if node_name == self.local.node_name || ctx.name_claimed {
|
||||
return reject(RejectReason::NameTaken);
|
||||
}
|
||||
// Simultaneous connect: the inbound frame is the peer's dial. If our
|
||||
// own in-flight dial wins instead, drop this one silently — the peer
|
||||
// computes the same verdict (see `dial_wins`).
|
||||
if ctx.dialing_this_peer && !dial_wins(&node_name, &self.local.node_name) {
|
||||
return ResponderOutcome::TieBreakLoss;
|
||||
}
|
||||
|
||||
ResponderOutcome::Accepted {
|
||||
reply: Frame::HelloAck {
|
||||
node_name: self.local.node_name,
|
||||
incarnation: self.local.incarnation,
|
||||
meta: self.local.meta,
|
||||
},
|
||||
peer: Peer {
|
||||
node_name,
|
||||
incarnation,
|
||||
meta,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user