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:
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user