Files
smarm/tests/cluster_handshake.rs
T
claude 9e49038474 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.
2026-08-14 17:17:14 +00:00

250 lines
8.4 KiB
Rust

//! RFC 010 c5 — handshake state-machine tests (roadmap: happy path; hash
//! mismatch; proto-version mismatch; name already claimed; simultaneous-connect
//! tie-break; garbage before Hello). Pure — no IO, no actors, no runtime.
#![cfg(feature = "cluster")]
use smarm::cluster::envelope::{Frame, NodeMeta, RejectReason, PROTO_VERSION};
use smarm::cluster::handshake::{
dial_wins, HelloCtx, Initiator, InitiatorOutcome, Local, Responder, ResponderOutcome,
};
use smarm::pg::Incarnation;
const HASH: u64 = 0xDEAD_BEEF_CAFE_F00D;
fn local(name: &str) -> Local {
Local {
node_name: name.into(),
incarnation: Incarnation::new(7),
build_hash: HASH,
meta: NodeMeta {
role: "worker".into(),
region: "eu-west".into(),
},
}
}
/// The Hello that `Initiator::new(&local(name))` emits, built by hand.
fn hello_from(name: &str) -> Frame {
let l = local(name);
Frame::Hello {
proto_version: PROTO_VERSION,
build_hash: l.build_hash,
node_name: l.node_name,
incarnation: l.incarnation,
meta: l.meta,
}
}
#[test]
fn happy_path_establishes_both_ends() {
// alpha dials beta.
let (initiator, hello) = Initiator::new(&local("alpha"));
assert_eq!(hello, hello_from("alpha"), "initiator emits its identity");
let responder = Responder::new(local("beta"));
let (reply, peer) = match responder.on_frame(hello, HelloCtx::default()) {
ResponderOutcome::Accepted { reply, peer } => (reply, peer),
other => panic!("expected Accepted, got {other:?}"),
};
assert_eq!(peer.node_name, "alpha");
assert_eq!(peer.incarnation, Incarnation::new(7));
assert_eq!(peer.meta.role, "worker");
// The ack carries the responder's identity, no hash/version (one-sided
// check — sound because equality is symmetric).
let l = local("beta");
assert_eq!(
reply,
Frame::HelloAck {
node_name: l.node_name,
incarnation: l.incarnation,
meta: l.meta,
}
);
match initiator.on_frame(reply) {
InitiatorOutcome::Established(peer) => {
assert_eq!(peer.node_name, "beta");
assert_eq!(peer.incarnation, Incarnation::new(7));
assert_eq!(peer.meta.region, "eu-west");
}
other => panic!("expected Established, got {other:?}"),
}
}
#[test]
fn hash_mismatch_rejected() {
let responder = Responder::new(local("beta"));
let hello = Frame::Hello {
proto_version: PROTO_VERSION,
build_hash: HASH ^ 1,
node_name: "alpha".into(),
incarnation: Incarnation::new(7),
meta: local("alpha").meta,
};
match responder.on_frame(hello, HelloCtx::default()) {
ResponderOutcome::Rejected { reply, reason } => {
assert_eq!(reason, RejectReason::HashMismatch);
assert_eq!(reply, Frame::HelloReject { reason });
}
other => panic!("expected Rejected, got {other:?}"),
}
// The dialer side of the same story: a reject frame comes back.
let (initiator, _hello) = Initiator::new(&local("alpha"));
match initiator.on_frame(Frame::HelloReject {
reason: RejectReason::HashMismatch,
}) {
InitiatorOutcome::Rejected(RejectReason::HashMismatch) => {}
other => panic!("expected Rejected(HashMismatch), got {other:?}"),
}
}
#[test]
fn proto_version_mismatch_rejected_and_checked_first() {
// Both proto and hash wrong: proto wins — nothing after the version can
// be trusted, and HelloReject is the cross-version compatibility anchor.
let responder = Responder::new(local("beta"));
let hello = Frame::Hello {
proto_version: PROTO_VERSION + 1,
build_hash: HASH ^ 1,
node_name: "alpha".into(),
incarnation: Incarnation::new(7),
meta: local("alpha").meta,
};
match responder.on_frame(hello, HelloCtx::default()) {
ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::ProtoVersion);
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[test]
fn claimed_name_rejected() {
let responder = Responder::new(local("beta"));
let ctx = HelloCtx {
name_claimed: true,
dialing_this_peer: false,
};
match responder.on_frame(hello_from("alpha"), ctx) {
ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::NameTaken);
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[test]
fn own_name_offered_rejected_as_name_taken() {
// Self-connect or genuine collision: the responder's own name arrives.
let responder = Responder::new(local("beta"));
match responder.on_frame(hello_from("beta"), HelloCtx::default()) {
ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::NameTaken);
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[test]
fn hash_checked_before_name() {
// Wrong hash AND claimed name: hash wins (validity before identity).
let responder = Responder::new(local("beta"));
let hello = Frame::Hello {
proto_version: PROTO_VERSION,
build_hash: HASH ^ 1,
node_name: "alpha".into(),
incarnation: Incarnation::new(7),
meta: local("alpha").meta,
};
let ctx = HelloCtx {
name_claimed: true,
dialing_this_peer: false,
};
match responder.on_frame(hello, ctx) {
ResponderOutcome::Rejected { reason, .. } => {
assert_eq!(reason, RejectReason::HashMismatch);
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[test]
fn dial_wins_is_deterministic_and_antisymmetric() {
// The smaller name's dial survives; both ends compute the same verdict.
assert!(dial_wins("alpha", "beta"));
assert!(!dial_wins("beta", "alpha"));
for (a, b) in [("a", "b"), ("node-1", "node-2"), ("x", "xx")] {
assert_ne!(dial_wins(a, b), dial_wins(b, a), "({a}, {b})");
}
}
#[test]
fn simultaneous_connect_exactly_one_side_accepts() {
// alpha and beta dial each other at once. Each responder sees the peer's
// Hello while its own dial is in flight.
let ctx = HelloCtx {
name_claimed: false,
dialing_this_peer: true,
};
// On beta: inbound is alpha's dial; alpha < beta, so the inbound wins.
let on_beta = Responder::new(local("beta")).on_frame(hello_from("alpha"), ctx);
assert!(
matches!(on_beta, ResponderOutcome::Accepted { .. }),
"beta must accept alpha's dial, got {on_beta:?}"
);
// On alpha: inbound is beta's dial; it loses — close silently, no frame
// (ratified: both ends can compute the outcome, a reject adds nothing).
let on_alpha = Responder::new(local("alpha")).on_frame(hello_from("beta"), ctx);
assert!(
matches!(on_alpha, ResponderOutcome::TieBreakLoss),
"alpha must silently drop beta's dial, got {on_alpha:?}"
);
}
#[test]
fn tiebreak_loss_only_applies_when_dialing() {
// Same inbound Hello, no dial in flight: plain accept.
let on_alpha = Responder::new(local("alpha")).on_frame(hello_from("beta"), HelloCtx::default());
assert!(matches!(on_alpha, ResponderOutcome::Accepted { .. }));
}
#[test]
fn garbage_before_hello_fails_without_reply() {
// Any valid-but-wrong frame before Hello is a protocol violation: close,
// no reject frame. (Undecodable bytes are the codec's Err, not ours.)
for frame in [
Frame::Heartbeat,
Frame::HelloAck {
node_name: "alpha".into(),
incarnation: Incarnation::new(7),
meta: local("alpha").meta,
},
Frame::Demonitor { monitor_id: 3 },
] {
let out = Responder::new(local("beta")).on_frame(frame.clone(), HelloCtx::default());
match out {
ResponderOutcome::Failed(f) => assert_eq!(f, frame),
other => panic!("expected Failed({frame:?}), got {other:?}"),
}
}
}
#[test]
fn garbage_before_ack_fails_the_initiator() {
for frame in [
Frame::Heartbeat,
hello_from("beta"),
Frame::Demonitor { monitor_id: 3 },
] {
let (initiator, _hello) = Initiator::new(&local("alpha"));
match initiator.on_frame(frame.clone()) {
InitiatorOutcome::Failed(f) => assert_eq!(f, frame),
other => panic!("expected Failed({frame:?}), got {other:?}"),
}
}
}