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