Files
smarm/src/cluster/transport/loopback.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

275 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(),
}))
}
}