Files
smarm/tests/cluster_transport.rs
T
Claude 39ab92871e 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.
2026-08-14 14:31:39 +00:00

273 lines
8.9 KiB
Rust

//! 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();
});
}