Tree snapshot of d9c62a8 (2026-08-18). The 20 source commits between
16ef583 (c9) and d9c62a8 were never pushed and the clone that held them
was lost; this commit carries their combined tree verbatim so the build
history stays auditable from the c1–c9 commits below it. Original
hashes as recorded in the session handoff:
c10 f03e94d pid targeting + auto-serialization (RemotePid, D14 name
on the wire); Phase 3 gate
c11 7ef4bad DownReason::Disconnected, wire tag 5
c12 d124162 remote monitors (Monitor/Demonitor/Down frames)
c13 9de967b connection-loss synthesis (A+B: Monitors::teardown +
unread-command Disconnected); Phase 4 gate
c14 7e822b7 eager pg eviction (reaper actor, ReaperInboxes)
dbe1a22 InboundVerdict::label(), trace::Event::ClusterInbound
31a9877 tests/channel.rs monitor-churn target gated on `go`
653559e Discovery::Withdrawn{name, addr}
c15 b41d76e distributed pg: Sync on NodeUp, Join/Leave broadcast,
NodeDown sweep, members_all; PgMsg wire type
c16 fafa881 pick_any / dispatch_any; Phase 5 complete
Phase 6 Tier A:
195c73e p4 NodeEvent::NodeDown(NodeInfo)
48fd766 p1 connector Candidate{name, addr, state}
ce8cf99 p2+p7 conn.rs select arms as Vec<Arm>; Outbound::Drained
bf24988 p6 RemotePid::from_local -> Option
9ae0380 p3 PeerStanding{Free, Claimed, Dialing}
c7d62a1 p11 cluster::Timing knobs, threaded by value
46f171d p11 cluster_disconnect un-ignored on SMARM_FAST_TIMING
Phase 6 Tier B:
391a9ae p5 cluster::RemoteDownReason{Local, Disconnected};
DownReason::Disconnected removed from core
7ddd908 p9 pg ctl channel unconditional, one cfg seam at spawn
d9c62a8 PeerNameMismatch parks the candidate; ClusterDial trace
Verified at d9c62a8: default 361/0, cluster 448/0, clippy --lib on
default / cluster / cluster+smarm-trace, fmt, 10x flake on
cluster_dial_mismatch, 5x on cluster_pg.
241 lines
8.2 KiB
Rust
241 lines
8.2 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, Initiator, InitiatorOutcome, Local, PeerStanding, 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, PeerStanding::Free) {
|
|
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, PeerStanding::Free) {
|
|
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, PeerStanding::Free) {
|
|
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 = PeerStanding::Claimed;
|
|
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"), PeerStanding::Free) {
|
|
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 = PeerStanding::Claimed;
|
|
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 = PeerStanding::Dialing;
|
|
|
|
// 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"), PeerStanding::Free);
|
|
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(), PeerStanding::Free);
|
|
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:?}"),
|
|
}
|
|
}
|
|
}
|