feat(cluster): RFC 010 c3 — transport trait, framed codec, TCP + loopback impls

The control-connection abstraction (RFC v2 §5): object-safe Transport/
Listener/Conn over opaque pre-resolved addresses (resolution stays the c9
seam), with FramedConn as the single shared byte->Frame codec feeding
Frame::decode's incremental contract. Nothing forecloses additional
per-peer connections for the jarred bulk plane; the membrane is not a
transport (D2).

TCP parks the calling actor via scheduler fd readiness (MSG_NOSIGNAL
writes, EINPROGRESS dial resolved through SO_ERROR). Loopback is the
shipped in-memory test transport: OS-thread-blocking condvar pipes with
TCP-shaped close semantics, per-instance address registry.

Conformance suite runs the same codec over both impls: roundtrips both
directions, framing across split writes, coalesced frames, peer-close
mid-frame as TruncatedByPeer (not EOF), clean close as Ok(None). Plus
impl-specific establishment/error cases and a 4 MiB cross-buffer TCP
frame under real backpressure.
This commit is contained in:
Claude
2026-08-14 14:31:39 +00:00
parent 3850f6099b
commit 39ab92871e
5 changed files with 1021 additions and 4 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
//! RFC 010 — clustering (smarm⇄smarm, explicit remote boundary).
//!
//! c1: feature flag + optional deps only. The owned envelope (c2), transport
//! trait (c3), and everything above them land in later chunks. This module is
//! intentionally empty so the `cluster` feature's default-build invariance is
//! reviewable in isolation.
//! 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.
pub mod envelope;
pub mod transport;
+194
View File
@@ -0,0 +1,194 @@
//! RFC 010 c3 — transport abstraction for the **control** connection.
//!
//! Scope, per RFC 010 v2 §5 and D2:
//!
//! - A "connection" here is the *control* connection: the one carrying this
//! RFC's frame inventory ([`crate::cluster::envelope::Frame`]), whose
//! heartbeats feed failure detection. The trait deliberately says nothing
//! about how many connections a peer pair may hold — the jarred rkyv bulk
//! plane opens **additional per-peer connections** outside this trait, and
//! nothing here may foreclose that.
//! - Homogeneous smarm⇄smarm only. The BEAM membrane is *not* a transport
//! impl and the trait does not accommodate it (D2).
//! - Addresses are opaque, **pre-resolved** strings. Name resolution is a
//! single separate seam (roadmap c9); impls reject unresolved names rather
//! than resolving them.
//!
//! Blocking model: [`Conn`] calls block the caller. The TCP impl parks the
//! calling *actor* (fd readiness via the scheduler); the loopback impl blocks
//! the calling *OS thread* and is a test transport — do not drive it from a
//! scheduler thread.
//!
//! Framing is not part of the trait: [`FramedConn`] is the single shared
//! codec that turns any byte-stream [`Conn`] into a frame pipe, feeding
//! [`Frame::decode`]'s incremental contract. Impls never re-implement
//! framing, and the conformance suite exercises the same codec over every
//! impl.
use std::io;
use crate::cluster::envelope::{DecodeError, EncodeError, Frame};
pub mod loopback;
pub mod tcp;
/// An established control connection: a bidirectional byte stream.
pub trait Conn: Send {
/// Read at least one byte, blocking the caller until data is available,
/// EOF, or error. `Ok(0)` means EOF: the peer closed and all bytes it
/// wrote before closing have been consumed.
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
/// Write the whole buffer, blocking the caller as needed.
fn write_all(&mut self, buf: &[u8]) -> io::Result<()>;
/// Close both directions. Idempotent. Bytes already written remain
/// readable at the peer, which then observes EOF; peer writes after this
/// fail.
fn close(&mut self);
/// Diagnostic label for logs only. Mesh identity comes from the
/// handshake (`Hello`/`HelloAck`), never from the transport.
fn peer_addr(&self) -> String;
}
/// A bound listen point producing inbound [`Conn`]s.
pub trait Listener: Send {
/// Accept the next inbound connection, blocking the caller.
fn accept(&mut self) -> io::Result<Box<dyn Conn>>;
/// The concrete bound address, dialable as-is (e.g. the real port when
/// bound with port 0).
fn local_addr(&self) -> String;
}
/// A way of establishing control connections. Object-safe on purpose: the
/// connector and membership layers hold `&dyn Transport` / boxed conns
/// rather than growing a generic parameter.
pub trait Transport: Send + Sync {
/// Connect to a peer's listen address. Blocks the caller until
/// established or failed.
fn dial(&self, addr: &str) -> io::Result<Box<dyn Conn>>;
/// Bind a listen point.
fn listen(&self, addr: &str) -> io::Result<Box<dyn Listener>>;
}
impl std::fmt::Debug for dyn Conn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Conn({})", self.peer_addr())
}
}
impl std::fmt::Debug for dyn Listener {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Listener({})", self.local_addr())
}
}
/// Error surface of [`FramedConn::send`].
#[derive(Debug)]
pub enum SendError {
/// The frame could not be encoded (e.g. a field over its wire limit).
Encode(EncodeError),
/// The transport failed mid-write.
Io(io::Error),
}
impl std::fmt::Display for SendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SendError::Encode(e) => write!(f, "frame encode failed: {e:?}"),
SendError::Io(e) => write!(f, "transport write failed: {e}"),
}
}
}
impl std::error::Error for SendError {}
/// Error surface of [`FramedConn::recv`].
#[derive(Debug)]
pub enum RecvError {
/// The byte stream is not a valid frame stream (bad tag, lying length,
/// oversized frame, …). The connection is unusable.
Corrupt(DecodeError),
/// The peer closed mid-frame: EOF arrived with a partial frame buffered.
/// Distinct from a clean close, which is `Ok(None)`.
TruncatedByPeer,
/// The transport failed mid-read.
Io(io::Error),
}
impl std::fmt::Display for RecvError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RecvError::Corrupt(e) => write!(f, "frame stream corrupt: {e:?}"),
RecvError::TruncatedByPeer => write!(f, "peer closed mid-frame"),
RecvError::Io(e) => write!(f, "transport read failed: {e}"),
}
}
}
impl std::error::Error for RecvError {}
/// How many bytes each blocking read asks the transport for.
const READ_CHUNK: usize = 8 * 1024;
/// The shared framed codec: one of these per control connection, owning the
/// [`Conn`] and the reassembly buffer. Frames may arrive split or coalesced
/// arbitrarily; [`recv`](FramedConn::recv) reassembles either way.
pub struct FramedConn {
conn: Box<dyn Conn>,
rbuf: Vec<u8>,
}
impl FramedConn {
pub fn new(conn: Box<dyn Conn>) -> Self {
FramedConn {
conn,
rbuf: Vec::new(),
}
}
/// Encode and write one frame.
pub fn send(&mut self, frame: &Frame) -> Result<(), SendError> {
let mut out = Vec::new();
frame.encode(&mut out).map_err(SendError::Encode)?;
self.conn.write_all(&out).map_err(SendError::Io)
}
/// Receive the next frame. `Ok(None)` is a clean close: EOF at a frame
/// boundary. EOF mid-frame is [`RecvError::TruncatedByPeer`].
pub fn recv(&mut self) -> Result<Option<Frame>, RecvError> {
loop {
match Frame::decode(&self.rbuf) {
Ok(Some((frame, consumed))) => {
self.rbuf.drain(..consumed);
return Ok(Some(frame));
}
Ok(None) => {}
Err(e) => return Err(RecvError::Corrupt(e)),
}
let mut chunk = [0u8; READ_CHUNK];
let n = self.conn.read(&mut chunk).map_err(RecvError::Io)?;
if n == 0 {
return if self.rbuf.is_empty() {
Ok(None)
} else {
Err(RecvError::TruncatedByPeer)
};
}
self.rbuf.extend_from_slice(&chunk[..n]);
}
}
/// Close the underlying connection (idempotent, see [`Conn::close`]).
pub fn close(&mut self) {
self.conn.close();
}
/// Diagnostic label of the underlying connection.
pub fn peer_addr(&self) -> String {
self.conn.peer_addr()
}
}
+274
View File
@@ -0,0 +1,274 @@
//! In-memory loopback transport — a shipped **test** transport.
//!
//! Lets Phases 2–4 exercise protocol logic (connector, membership,
//! monitors) through the real transport trait and the real framed codec
//! without sockets or timing flake.
//!
//! Blocking model: calls block the **OS thread** on a condvar. That is the
//! right shape for plain `#[test]`s driving protocol state machines; it is
//! the wrong shape for scheduler threads. Do not drive a loopback conn from
//! inside an actor — use the TCP impl there.
//!
//! Semantics mirror TCP shutdown where it matters for the codec: bytes
//! written before `close` remain readable at the peer, which then sees EOF;
//! writes toward a closed peer fail with `BrokenPipe`. Write buffers are
//! unbounded, so writes never block — backpressure is not simulated.
use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use super::{Conn, Listener, Transport};
/// Poison-tolerant lock: a panicked holder in a *test* transport must not
/// cascade; the byte-queue state stays consistent under every early return.
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
match m.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
// ---------------------------------------------------------------------------
// One direction of a duplex: a byte queue with close flags for both ends
// ---------------------------------------------------------------------------
#[derive(Default)]
struct PipeState {
bytes: VecDeque<u8>,
/// The writing end closed: readers drain remaining bytes, then EOF.
write_closed: bool,
/// The reading end closed: writers fail with `BrokenPipe`.
read_closed: bool,
}
#[derive(Default)]
struct Pipe {
state: Mutex<PipeState>,
cv: Condvar,
}
impl Pipe {
fn write_all(&self, buf: &[u8]) -> io::Result<()> {
let mut st = lock(&self.state);
if st.write_closed {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"loopback conn closed locally",
));
}
if st.read_closed {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"loopback peer closed",
));
}
st.bytes.extend(buf);
self.cv.notify_all();
Ok(())
}
fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let mut st = lock(&self.state);
loop {
if !st.bytes.is_empty() {
let n = st.bytes.len().min(buf.len());
for (slot, byte) in buf.iter_mut().zip(st.bytes.drain(..n)) {
*slot = byte;
}
return Ok(n);
}
if st.write_closed || st.read_closed {
return Ok(0); // EOF: peer closed, or our own end closed.
}
st = match self.cv.wait(st) {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
}
}
/// Close from the writer side: remaining bytes stay readable, then EOF.
fn close_write(&self) {
lock(&self.state).write_closed = true;
self.cv.notify_all();
}
/// Close from the reader side: peer writes fail from now on.
fn close_read(&self) {
lock(&self.state).read_closed = true;
self.cv.notify_all();
}
}
// ---------------------------------------------------------------------------
// Conn: two pipes, one per direction
// ---------------------------------------------------------------------------
/// One end of an established loopback connection.
pub struct LoopbackConn {
tx: Arc<Pipe>,
rx: Arc<Pipe>,
peer: String,
}
impl Conn for LoopbackConn {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.rx.read(buf)
}
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
self.tx.write_all(buf)
}
fn close(&mut self) {
self.tx.close_write();
self.rx.close_read();
}
fn peer_addr(&self) -> String {
self.peer.clone()
}
}
impl Drop for LoopbackConn {
fn drop(&mut self) {
self.close();
}
}
fn conn_pair(listen_addr: &str, conn_no: u64) -> (LoopbackConn, LoopbackConn) {
let a_to_b = Arc::new(Pipe::default());
let b_to_a = Arc::new(Pipe::default());
let dialer = LoopbackConn {
tx: a_to_b.clone(),
rx: b_to_a.clone(),
peer: listen_addr.to_string(),
};
let accepted = LoopbackConn {
tx: b_to_a,
rx: a_to_b,
peer: format!("{listen_addr}#dialer-{conn_no}"),
};
(dialer, accepted)
}
// ---------------------------------------------------------------------------
// Listener + registry
// ---------------------------------------------------------------------------
#[derive(Default)]
struct AcceptState {
pending: VecDeque<LoopbackConn>,
closed: bool,
}
#[derive(Default)]
struct AcceptQueue {
state: Mutex<AcceptState>,
cv: Condvar,
}
/// A bound loopback listen point.
pub struct LoopbackListener {
addr: String,
queue: Arc<AcceptQueue>,
registry: Arc<Mutex<Registry>>,
}
impl Listener for LoopbackListener {
fn accept(&mut self) -> io::Result<Box<dyn Conn>> {
let mut st = lock(&self.queue.state);
loop {
if let Some(conn) = st.pending.pop_front() {
return Ok(Box::new(conn));
}
if st.closed {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"loopback listener closed",
));
}
st = match self.queue.cv.wait(st) {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
}
}
fn local_addr(&self) -> String {
self.addr.clone()
}
}
impl Drop for LoopbackListener {
fn drop(&mut self) {
lock(&self.registry).listeners.remove(&self.addr);
let mut st = lock(&self.queue.state);
st.closed = true;
self.queue.cv.notify_all();
}
}
#[derive(Default)]
struct Registry {
listeners: HashMap<String, Arc<AcceptQueue>>,
dial_count: u64,
}
/// The loopback transport. Addresses are arbitrary strings scoped to one
/// transport instance; distinct instances never see each other's listeners.
#[derive(Default)]
pub struct LoopbackTransport {
registry: Arc<Mutex<Registry>>,
}
impl Transport for LoopbackTransport {
fn dial(&self, addr: &str) -> io::Result<Box<dyn Conn>> {
let (queue, conn_no) = {
let mut reg = lock(&self.registry);
reg.dial_count += 1;
let no = reg.dial_count;
match reg.listeners.get(addr) {
Some(q) => (q.clone(), no),
None => {
return Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
format!("no loopback listener at {addr:?}"),
));
}
}
};
let (dialer, accepted) = conn_pair(addr, conn_no);
let mut st = lock(&queue.state);
if st.closed {
return Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
format!("loopback listener at {addr:?} closed"),
));
}
st.pending.push_back(accepted);
queue.cv.notify_all();
Ok(Box::new(dialer))
}
fn listen(&self, addr: &str) -> io::Result<Box<dyn Listener>> {
let queue = Arc::new(AcceptQueue::default());
let mut reg = lock(&self.registry);
if reg.listeners.contains_key(addr) {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("loopback listener already bound at {addr:?}"),
));
}
reg.listeners.insert(addr.to_string(), queue.clone());
Ok(Box::new(LoopbackListener {
addr: addr.to_string(),
queue,
registry: self.registry.clone(),
}))
}
}
+277
View File
@@ -0,0 +1,277 @@
//! TCP transport — the production control-plane transport.
//!
//! Blocking model: every blocking point parks the **calling actor** on fd
//! readiness ([`crate::scheduler::wait_readable`] / `wait_writable`); the
//! scheduler thread is never blocked. All conn/listener methods must
//! therefore run inside an actor. `listen` itself only binds (no waiting)
//! and is callable anywhere.
//!
//! Addresses are pre-resolved `ip:port` strings (`SocketAddr` syntax, IPv4
//! or IPv6). Hostnames are rejected with `InvalidInput`: name resolution is
//! the single c9 seam, not something each transport does on the side.
//!
//! Writes use `send(2)` with `MSG_NOSIGNAL` — a peer reset must surface as
//! `BrokenPipe`/`ConnectionReset`, not `SIGPIPE`.
use std::io;
use std::net::{SocketAddr, TcpListener as StdListener, TcpStream};
use std::os::fd::{AsRawFd, RawFd};
use crate::scheduler::{wait_readable, wait_writable};
use super::{Conn, Listener, Transport};
// ---------------------------------------------------------------------------
// sockaddr plumbing
// ---------------------------------------------------------------------------
/// A `sockaddr_in`/`sockaddr_in6` built from a parsed `SocketAddr`, plus its
/// length, ready for `connect(2)`.
union SockAddrUnion {
v4: libc::sockaddr_in,
v6: libc::sockaddr_in6,
}
fn to_sockaddr(sa: &SocketAddr) -> (SockAddrUnion, libc::socklen_t) {
match sa {
SocketAddr::V4(v4) => {
let raw = libc::sockaddr_in {
sin_family: libc::AF_INET as libc::sa_family_t,
sin_port: v4.port().to_be(),
sin_addr: libc::in_addr {
s_addr: u32::from_be_bytes(v4.ip().octets()).to_be(),
},
sin_zero: [0; 8],
};
(
SockAddrUnion { v4: raw },
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
)
}
SocketAddr::V6(v6) => {
let raw = libc::sockaddr_in6 {
sin6_family: libc::AF_INET6 as libc::sa_family_t,
sin6_port: v6.port().to_be(),
sin6_flowinfo: v6.flowinfo(),
sin6_addr: libc::in6_addr {
s6_addr: v6.ip().octets(),
},
sin6_scope_id: v6.scope_id(),
};
(
SockAddrUnion { v6: raw },
std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t,
)
}
}
}
fn parse_addr(addr: &str) -> io::Result<SocketAddr> {
addr.parse().map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{addr:?} is not a resolved ip:port — resolution is the c9 seam"),
)
})
}
fn so_error(fd: RawFd) -> io::Result<()> {
let mut err: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ERROR,
(&mut err) as *mut _ as *mut libc::c_void,
&mut len,
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
if err != 0 {
return Err(io::Error::from_raw_os_error(err));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Conn
// ---------------------------------------------------------------------------
/// One established TCP control connection. Owns the socket; drop closes it.
pub struct TcpConn {
stream: TcpStream,
closed: bool,
}
impl TcpConn {
fn fd(&self) -> RawFd {
self.stream.as_raw_fd()
}
}
impl Conn for TcpConn {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.closed {
return Ok(0);
}
if buf.is_empty() {
return Ok(0);
}
loop {
wait_readable(self.fd())?;
let n = unsafe { libc::read(self.fd(), buf.as_mut_ptr() as *mut _, buf.len()) };
if n >= 0 {
return Ok(n as usize);
}
let e = io::Error::last_os_error();
match e.kind() {
// Spurious readiness or signal: park again.
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted => continue,
_ => return Err(e),
}
}
}
fn write_all(&mut self, mut buf: &[u8]) -> io::Result<()> {
if self.closed {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"tcp conn closed locally",
));
}
while !buf.is_empty() {
wait_writable(self.fd())?;
let n = unsafe {
libc::send(
self.fd(),
buf.as_ptr() as *const _,
buf.len(),
libc::MSG_NOSIGNAL,
)
};
if n >= 0 {
buf = &buf[n as usize..];
continue;
}
let e = io::Error::last_os_error();
match e.kind() {
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted => continue,
_ => return Err(e),
}
}
Ok(())
}
fn close(&mut self) {
if !self.closed {
self.closed = true;
// Best-effort: the peer sees EOF after draining. The fd itself
// is released when the owning stream drops.
let _ = self.stream.shutdown(std::net::Shutdown::Both);
}
}
fn peer_addr(&self) -> String {
match self.stream.peer_addr() {
Ok(sa) => sa.to_string(),
Err(_) => "<disconnected>".to_string(),
}
}
}
// ---------------------------------------------------------------------------
// Listener
// ---------------------------------------------------------------------------
/// A bound TCP listen point (non-blocking socket; accept parks the actor).
pub struct TcpListener {
inner: StdListener,
local: SocketAddr,
}
impl Listener for TcpListener {
fn accept(&mut self) -> io::Result<Box<dyn Conn>> {
loop {
wait_readable(self.inner.as_raw_fd())?;
match self.inner.accept() {
Ok((stream, _peer)) => {
stream.set_nonblocking(true)?;
return Ok(Box::new(TcpConn {
stream,
closed: false,
}));
}
Err(e)
if e.kind() == io::ErrorKind::WouldBlock
|| e.kind() == io::ErrorKind::Interrupted =>
{
continue;
}
Err(e) => return Err(e),
}
}
}
fn local_addr(&self) -> String {
self.local.to_string()
}
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
/// The TCP transport. Stateless; every call stands alone.
pub struct TcpTransport;
impl Transport for TcpTransport {
fn dial(&self, addr: &str) -> io::Result<Box<dyn Conn>> {
let sa = parse_addr(addr)?;
let family = match sa {
SocketAddr::V4(_) => libc::AF_INET,
SocketAddr::V6(_) => libc::AF_INET6,
};
let fd = unsafe {
libc::socket(
family,
libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
0,
)
};
if fd < 0 {
return Err(io::Error::last_os_error());
}
// From here the fd is owned by `stream`; any early return drops it.
let stream = unsafe {
use std::os::fd::FromRawFd;
TcpStream::from_raw_fd(fd)
};
let (raw, len) = to_sockaddr(&sa);
let rc = unsafe { libc::connect(fd, (&raw) as *const _ as *const libc::sockaddr, len) };
if rc != 0 {
let e = io::Error::last_os_error();
if e.raw_os_error() != Some(libc::EINPROGRESS) {
return Err(e);
}
// Connect in flight: park until the socket is writable, then the
// verdict is in SO_ERROR.
wait_writable(fd)?;
so_error(fd)?;
}
Ok(Box::new(TcpConn {
stream,
closed: false,
}))
}
fn listen(&self, addr: &str) -> io::Result<Box<dyn Listener>> {
let sa = parse_addr(addr)?;
let inner = StdListener::bind(sa)?;
inner.set_nonblocking(true)?;
let local = inner.local_addr()?;
Ok(Box::new(TcpListener { inner, local }))
}
}
+272
View File
@@ -0,0 +1,272 @@
//! RFC 010 c3 — transport conformance suite, run against both shipped impls
//! (TCP and in-memory loopback), plus impl-specific cases.
//!
//! Shared suite (roadmap): frame roundtrips through the framed codec, framing
//! across a split write, coalesced frames in one write, peer-close mid-frame
//! (must error, not EOF), clean close at a frame boundary (EOF as `Ok(None)`).
//!
//! The TCP impl parks the calling actor, so its runs live inside `smarm::run`;
//! loopback blocks the OS thread and runs as plain tests.
#![cfg(feature = "cluster")]
use smarm::cluster::envelope::Frame;
use smarm::cluster::transport::loopback::LoopbackTransport;
use smarm::cluster::transport::tcp::TcpTransport;
use smarm::cluster::transport::{Conn, FramedConn, RecvError, Transport};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Listener + dial + accept against one transport, both conns returned.
/// Relies on dial not requiring a concurrent accept (TCP backlog / loopback
/// queue), so a single thread or actor can hold both ends.
fn pair(t: &dyn Transport, addr: &str) -> (Box<dyn Conn>, Box<dyn Conn>) {
let mut l = t.listen(addr).unwrap();
let a = t.dial(&l.local_addr()).unwrap();
let b = l.accept().unwrap();
(a, b)
}
fn frames() -> Vec<Frame> {
vec![
Frame::Heartbeat,
Frame::Send {
index: 42,
generation: 3,
type_hash: 0x1234_5678_9ABC_DEF0,
payload: vec![1, 2, 3, 4, 5],
},
Frame::SendNamed {
name: "the_counter".into(),
type_hash: 0xFFFF_0000_FFFF_0000,
payload: vec![],
},
Frame::Demonitor { monitor_id: 77 },
]
}
fn encode(f: &Frame) -> Vec<u8> {
let mut out = Vec::new();
f.encode(&mut out).unwrap();
out
}
// ---------------------------------------------------------------------------
// Shared conformance suite — generic over an established pair
// ---------------------------------------------------------------------------
fn suite_roundtrip(a: Box<dyn Conn>, b: Box<dyn Conn>) {
let mut fa = FramedConn::new(a);
let mut fb = FramedConn::new(b);
// a -> b, then b -> a: both directions carry every frame shape.
for f in frames() {
fa.send(&f).unwrap();
assert_eq!(fb.recv().unwrap().unwrap(), f);
}
for f in frames() {
fb.send(&f).unwrap();
assert_eq!(fa.recv().unwrap().unwrap(), f);
}
}
fn suite_split_write(mut a: Box<dyn Conn>, b: Box<dyn Conn>) {
let f = Frame::Send {
index: 7,
generation: 1,
type_hash: 0xAB,
payload: vec![9; 64],
};
let bytes = encode(&f);
// Split inside the length prefix, then inside the body: the reader must
// reassemble regardless of where the boundary falls.
a.write_all(&bytes[..2]).unwrap();
a.write_all(&bytes[2..10]).unwrap();
a.write_all(&bytes[10..]).unwrap();
let mut fb = FramedConn::new(b);
assert_eq!(fb.recv().unwrap().unwrap(), f);
}
fn suite_coalesced(mut a: Box<dyn Conn>, b: Box<dyn Conn>) {
let f1 = Frame::Heartbeat;
let f2 = Frame::Demonitor { monitor_id: 5 };
let mut bytes = encode(&f1);
bytes.extend_from_slice(&encode(&f2));
a.write_all(&bytes).unwrap();
let mut fb = FramedConn::new(b);
assert_eq!(fb.recv().unwrap().unwrap(), f1);
assert_eq!(fb.recv().unwrap().unwrap(), f2);
}
fn suite_close_mid_frame(mut a: Box<dyn Conn>, b: Box<dyn Conn>) {
let bytes = encode(&Frame::Send {
index: 1,
generation: 1,
type_hash: 1,
payload: vec![0; 128],
});
a.write_all(&bytes[..bytes.len() / 2]).unwrap();
a.close();
let mut fb = FramedConn::new(b);
match fb.recv() {
Err(RecvError::TruncatedByPeer) => {}
other => panic!("expected TruncatedByPeer, got {other:?}"),
}
}
fn suite_clean_close(mut a: Box<dyn Conn>, b: Box<dyn Conn>) {
let f = Frame::Heartbeat;
a.write_all(&encode(&f)).unwrap();
a.close();
let mut fb = FramedConn::new(b);
// The buffered frame is still delivered, then EOF at the boundary.
assert_eq!(fb.recv().unwrap().unwrap(), f);
assert!(fb.recv().unwrap().is_none());
}
fn run_suite(t: &dyn Transport, addr: &str) {
let (a, b) = pair(t, addr);
suite_roundtrip(a, b);
let (a, b) = pair(t, addr);
suite_split_write(a, b);
let (a, b) = pair(t, addr);
suite_coalesced(a, b);
let (a, b) = pair(t, addr);
suite_close_mid_frame(a, b);
let (a, b) = pair(t, addr);
suite_clean_close(a, b);
}
// ---------------------------------------------------------------------------
// Loopback — plain tests, no runtime
// ---------------------------------------------------------------------------
#[test]
fn loopback_conformance() {
// Fresh transport per pair() call is fine, but one instance must also
// support sequential re-listen on distinct addresses.
let t = LoopbackTransport::default();
run_suite(&t, "alpha");
}
#[test]
fn loopback_dial_unknown_addr_refused() {
let t = LoopbackTransport::default();
let err = t.dial("nobody-home").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::ConnectionRefused);
}
#[test]
fn loopback_addr_in_use() {
let t = LoopbackTransport::default();
let _l = t.listen("alpha").unwrap();
let err = t.listen("alpha").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse);
}
#[test]
fn loopback_listener_drop_frees_addr_and_refuses_dial() {
let t = LoopbackTransport::default();
let l = t.listen("alpha").unwrap();
drop(l);
let err = t.dial("alpha").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::ConnectionRefused);
// Address is reusable after the listener is gone.
let _l2 = t.listen("alpha").unwrap();
}
#[test]
fn loopback_write_after_peer_close_broken_pipe() {
let t = LoopbackTransport::default();
let (mut a, mut b) = pair(&t, "alpha");
b.close();
let err = a.write_all(&[1, 2, 3]).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe);
}
#[test]
fn loopback_cross_thread_blocking_read() {
// Reader blocks on an empty pipe until the writer thread delivers.
let t = LoopbackTransport::default();
let (a, b) = pair(&t, "alpha");
let mut fb = FramedConn::new(b);
let writer = std::thread::spawn(move || {
let mut a = a;
std::thread::sleep(std::time::Duration::from_millis(30));
a.write_all(&encode(&Frame::Heartbeat)).unwrap();
});
assert_eq!(fb.recv().unwrap().unwrap(), Frame::Heartbeat);
writer.join().unwrap();
}
// ---------------------------------------------------------------------------
// TCP — inside the runtime (read/write park the calling actor)
// ---------------------------------------------------------------------------
#[test]
fn tcp_conformance() {
smarm::run(|| {
run_suite(&TcpTransport, "127.0.0.1:0");
});
}
#[test]
fn tcp_dial_refused() {
smarm::run(|| {
// Bind to an OS-assigned port, learn it, close the listener, dial it.
let addr = {
let l = TcpTransport.listen("127.0.0.1:0").unwrap();
l.local_addr()
};
let err = TcpTransport.dial(&addr).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::ConnectionRefused);
});
}
#[test]
fn tcp_bad_addr_rejected_without_resolution() {
// Addresses are opaque pre-resolved strings; the c9 seam resolves names.
// A hostname is therefore invalid input here, not something to resolve.
let err = TcpTransport.dial("localhost:1234").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn tcp_local_addr_reports_real_port() {
let l = TcpTransport.listen("127.0.0.1:0").unwrap();
let addr = l.local_addr();
let port: u16 = addr.rsplit(':').next().unwrap().parse().unwrap();
assert_ne!(port, 0);
}
#[test]
fn tcp_big_frame_across_socket_buffers() {
// A payload far beyond socket buffer sizes forces genuine fragmentation
// and write backpressure: writer and reader must run concurrently.
smarm::run(|| {
let (tx, rx) = smarm::channel::<Frame>();
let payload = vec![0xA5u8; 4 * 1024 * 1024];
let f = Frame::Send {
index: 9,
generation: 2,
type_hash: 0xC0FFEE,
payload,
};
let mut l = TcpTransport.listen("127.0.0.1:0").unwrap();
let addr = l.local_addr();
let fw = f.clone();
let writer = smarm::spawn(move || {
let mut fa = FramedConn::new(TcpTransport.dial(&addr).unwrap());
fa.send(&fw).unwrap();
});
let reader = smarm::spawn(move || {
let mut fb = FramedConn::new(l.accept().unwrap());
let got = fb.recv().unwrap().unwrap();
tx.send(got).unwrap();
});
let got = rx.recv().unwrap();
assert_eq!(got, f);
writer.join().unwrap();
reader.join().unwrap();
});
}