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.
273 lines
8.9 KiB
Rust
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();
|
|
});
|
|
}
|