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:
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
//! RFC 010 — clustering (smarm⇄smarm, explicit remote boundary).
|
//! RFC 010 — clustering (smarm⇄smarm, explicit remote boundary).
|
||||||
//!
|
//!
|
||||||
//! c1: feature flag + optional deps only. The owned envelope (c2), transport
|
//! c1: feature flag + optional deps. c2: the owned envelope. c3: the
|
||||||
//! trait (c3), and everything above them land in later chunks. This module is
|
//! transport trait (control connection), framed codec, and the TCP +
|
||||||
//! intentionally empty so the `cluster` feature's default-build invariance is
|
//! loopback impls. Everything above them lands in later chunks.
|
||||||
//! reviewable in isolation.
|
|
||||||
|
|
||||||
pub mod envelope;
|
pub mod envelope;
|
||||||
|
pub mod transport;
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! RFC 010 c3 — transport abstraction for the **control** connection.
|
||||||
|
//!
|
||||||
|
//! Scope, per RFC 010 v2 §5 and D2:
|
||||||
|
//!
|
||||||
|
//! - A "connection" here is the *control* connection: the one carrying this
|
||||||
|
//! RFC's frame inventory ([`crate::cluster::envelope::Frame`]), whose
|
||||||
|
//! heartbeats feed failure detection. The trait deliberately says nothing
|
||||||
|
//! about how many connections a peer pair may hold — the jarred rkyv bulk
|
||||||
|
//! plane opens **additional per-peer connections** outside this trait, and
|
||||||
|
//! nothing here may foreclose that.
|
||||||
|
//! - Homogeneous smarm⇄smarm only. The BEAM membrane is *not* a transport
|
||||||
|
//! impl and the trait does not accommodate it (D2).
|
||||||
|
//! - Addresses are opaque, **pre-resolved** strings. Name resolution is a
|
||||||
|
//! single separate seam (roadmap c9); impls reject unresolved names rather
|
||||||
|
//! than resolving them.
|
||||||
|
//!
|
||||||
|
//! Blocking model: [`Conn`] calls block the caller. The TCP impl parks the
|
||||||
|
//! calling *actor* (fd readiness via the scheduler); the loopback impl blocks
|
||||||
|
//! the calling *OS thread* and is a test transport — do not drive it from a
|
||||||
|
//! scheduler thread.
|
||||||
|
//!
|
||||||
|
//! Framing is not part of the trait: [`FramedConn`] is the single shared
|
||||||
|
//! codec that turns any byte-stream [`Conn`] into a frame pipe, feeding
|
||||||
|
//! [`Frame::decode`]'s incremental contract. Impls never re-implement
|
||||||
|
//! framing, and the conformance suite exercises the same codec over every
|
||||||
|
//! impl.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crate::cluster::envelope::{DecodeError, EncodeError, Frame};
|
||||||
|
|
||||||
|
pub mod loopback;
|
||||||
|
pub mod tcp;
|
||||||
|
|
||||||
|
/// An established control connection: a bidirectional byte stream.
|
||||||
|
pub trait Conn: Send {
|
||||||
|
/// Read at least one byte, blocking the caller until data is available,
|
||||||
|
/// EOF, or error. `Ok(0)` means EOF: the peer closed and all bytes it
|
||||||
|
/// wrote before closing have been consumed.
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
|
||||||
|
|
||||||
|
/// Write the whole buffer, blocking the caller as needed.
|
||||||
|
fn write_all(&mut self, buf: &[u8]) -> io::Result<()>;
|
||||||
|
|
||||||
|
/// Close both directions. Idempotent. Bytes already written remain
|
||||||
|
/// readable at the peer, which then observes EOF; peer writes after this
|
||||||
|
/// fail.
|
||||||
|
fn close(&mut self);
|
||||||
|
|
||||||
|
/// Diagnostic label for logs only. Mesh identity comes from the
|
||||||
|
/// handshake (`Hello`/`HelloAck`), never from the transport.
|
||||||
|
fn peer_addr(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bound listen point producing inbound [`Conn`]s.
|
||||||
|
pub trait Listener: Send {
|
||||||
|
/// Accept the next inbound connection, blocking the caller.
|
||||||
|
fn accept(&mut self) -> io::Result<Box<dyn Conn>>;
|
||||||
|
|
||||||
|
/// The concrete bound address, dialable as-is (e.g. the real port when
|
||||||
|
/// bound with port 0).
|
||||||
|
fn local_addr(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A way of establishing control connections. Object-safe on purpose: the
|
||||||
|
/// connector and membership layers hold `&dyn Transport` / boxed conns
|
||||||
|
/// rather than growing a generic parameter.
|
||||||
|
pub trait Transport: Send + Sync {
|
||||||
|
/// Connect to a peer's listen address. Blocks the caller until
|
||||||
|
/// established or failed.
|
||||||
|
fn dial(&self, addr: &str) -> io::Result<Box<dyn Conn>>;
|
||||||
|
|
||||||
|
/// Bind a listen point.
|
||||||
|
fn listen(&self, addr: &str) -> io::Result<Box<dyn Listener>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for dyn Conn {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "Conn({})", self.peer_addr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for dyn Listener {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "Listener({})", self.local_addr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error surface of [`FramedConn::send`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SendError {
|
||||||
|
/// The frame could not be encoded (e.g. a field over its wire limit).
|
||||||
|
Encode(EncodeError),
|
||||||
|
/// The transport failed mid-write.
|
||||||
|
Io(io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SendError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SendError::Encode(e) => write!(f, "frame encode failed: {e:?}"),
|
||||||
|
SendError::Io(e) => write!(f, "transport write failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SendError {}
|
||||||
|
|
||||||
|
/// Error surface of [`FramedConn::recv`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RecvError {
|
||||||
|
/// The byte stream is not a valid frame stream (bad tag, lying length,
|
||||||
|
/// oversized frame, …). The connection is unusable.
|
||||||
|
Corrupt(DecodeError),
|
||||||
|
/// The peer closed mid-frame: EOF arrived with a partial frame buffered.
|
||||||
|
/// Distinct from a clean close, which is `Ok(None)`.
|
||||||
|
TruncatedByPeer,
|
||||||
|
/// The transport failed mid-read.
|
||||||
|
Io(io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RecvError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
RecvError::Corrupt(e) => write!(f, "frame stream corrupt: {e:?}"),
|
||||||
|
RecvError::TruncatedByPeer => write!(f, "peer closed mid-frame"),
|
||||||
|
RecvError::Io(e) => write!(f, "transport read failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for RecvError {}
|
||||||
|
|
||||||
|
/// How many bytes each blocking read asks the transport for.
|
||||||
|
const READ_CHUNK: usize = 8 * 1024;
|
||||||
|
|
||||||
|
/// The shared framed codec: one of these per control connection, owning the
|
||||||
|
/// [`Conn`] and the reassembly buffer. Frames may arrive split or coalesced
|
||||||
|
/// arbitrarily; [`recv`](FramedConn::recv) reassembles either way.
|
||||||
|
pub struct FramedConn {
|
||||||
|
conn: Box<dyn Conn>,
|
||||||
|
rbuf: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FramedConn {
|
||||||
|
pub fn new(conn: Box<dyn Conn>) -> Self {
|
||||||
|
FramedConn {
|
||||||
|
conn,
|
||||||
|
rbuf: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode and write one frame.
|
||||||
|
pub fn send(&mut self, frame: &Frame) -> Result<(), SendError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
frame.encode(&mut out).map_err(SendError::Encode)?;
|
||||||
|
self.conn.write_all(&out).map_err(SendError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive the next frame. `Ok(None)` is a clean close: EOF at a frame
|
||||||
|
/// boundary. EOF mid-frame is [`RecvError::TruncatedByPeer`].
|
||||||
|
pub fn recv(&mut self) -> Result<Option<Frame>, RecvError> {
|
||||||
|
loop {
|
||||||
|
match Frame::decode(&self.rbuf) {
|
||||||
|
Ok(Some((frame, consumed))) => {
|
||||||
|
self.rbuf.drain(..consumed);
|
||||||
|
return Ok(Some(frame));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => return Err(RecvError::Corrupt(e)),
|
||||||
|
}
|
||||||
|
let mut chunk = [0u8; READ_CHUNK];
|
||||||
|
let n = self.conn.read(&mut chunk).map_err(RecvError::Io)?;
|
||||||
|
if n == 0 {
|
||||||
|
return if self.rbuf.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Err(RecvError::TruncatedByPeer)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
self.rbuf.extend_from_slice(&chunk[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the underlying connection (idempotent, see [`Conn::close`]).
|
||||||
|
pub fn close(&mut self) {
|
||||||
|
self.conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostic label of the underlying connection.
|
||||||
|
pub fn peer_addr(&self) -> String {
|
||||||
|
self.conn.peer_addr()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
//! TCP transport — the production control-plane transport.
|
||||||
|
//!
|
||||||
|
//! Blocking model: every blocking point parks the **calling actor** on fd
|
||||||
|
//! readiness ([`crate::scheduler::wait_readable`] / `wait_writable`); the
|
||||||
|
//! scheduler thread is never blocked. All conn/listener methods must
|
||||||
|
//! therefore run inside an actor. `listen` itself only binds (no waiting)
|
||||||
|
//! and is callable anywhere.
|
||||||
|
//!
|
||||||
|
//! Addresses are pre-resolved `ip:port` strings (`SocketAddr` syntax, IPv4
|
||||||
|
//! or IPv6). Hostnames are rejected with `InvalidInput`: name resolution is
|
||||||
|
//! the single c9 seam, not something each transport does on the side.
|
||||||
|
//!
|
||||||
|
//! Writes use `send(2)` with `MSG_NOSIGNAL` — a peer reset must surface as
|
||||||
|
//! `BrokenPipe`/`ConnectionReset`, not `SIGPIPE`.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::net::{SocketAddr, TcpListener as StdListener, TcpStream};
|
||||||
|
use std::os::fd::{AsRawFd, RawFd};
|
||||||
|
|
||||||
|
use crate::scheduler::{wait_readable, wait_writable};
|
||||||
|
|
||||||
|
use super::{Conn, Listener, Transport};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// sockaddr plumbing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A `sockaddr_in`/`sockaddr_in6` built from a parsed `SocketAddr`, plus its
|
||||||
|
/// length, ready for `connect(2)`.
|
||||||
|
union SockAddrUnion {
|
||||||
|
v4: libc::sockaddr_in,
|
||||||
|
v6: libc::sockaddr_in6,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_sockaddr(sa: &SocketAddr) -> (SockAddrUnion, libc::socklen_t) {
|
||||||
|
match sa {
|
||||||
|
SocketAddr::V4(v4) => {
|
||||||
|
let raw = libc::sockaddr_in {
|
||||||
|
sin_family: libc::AF_INET as libc::sa_family_t,
|
||||||
|
sin_port: v4.port().to_be(),
|
||||||
|
sin_addr: libc::in_addr {
|
||||||
|
s_addr: u32::from_be_bytes(v4.ip().octets()).to_be(),
|
||||||
|
},
|
||||||
|
sin_zero: [0; 8],
|
||||||
|
};
|
||||||
|
(
|
||||||
|
SockAddrUnion { v4: raw },
|
||||||
|
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SocketAddr::V6(v6) => {
|
||||||
|
let raw = libc::sockaddr_in6 {
|
||||||
|
sin6_family: libc::AF_INET6 as libc::sa_family_t,
|
||||||
|
sin6_port: v6.port().to_be(),
|
||||||
|
sin6_flowinfo: v6.flowinfo(),
|
||||||
|
sin6_addr: libc::in6_addr {
|
||||||
|
s6_addr: v6.ip().octets(),
|
||||||
|
},
|
||||||
|
sin6_scope_id: v6.scope_id(),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
SockAddrUnion { v6: raw },
|
||||||
|
std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_addr(addr: &str) -> io::Result<SocketAddr> {
|
||||||
|
addr.parse().map_err(|_| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
format!("{addr:?} is not a resolved ip:port — resolution is the c9 seam"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn so_error(fd: RawFd) -> io::Result<()> {
|
||||||
|
let mut err: libc::c_int = 0;
|
||||||
|
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
fd,
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_ERROR,
|
||||||
|
(&mut err) as *mut _ as *mut libc::c_void,
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if err != 0 {
|
||||||
|
return Err(io::Error::from_raw_os_error(err));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conn
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One established TCP control connection. Owns the socket; drop closes it.
|
||||||
|
pub struct TcpConn {
|
||||||
|
stream: TcpStream,
|
||||||
|
closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TcpConn {
|
||||||
|
fn fd(&self) -> RawFd {
|
||||||
|
self.stream.as_raw_fd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Conn for TcpConn {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
if self.closed {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
wait_readable(self.fd())?;
|
||||||
|
let n = unsafe { libc::read(self.fd(), buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||||
|
if n >= 0 {
|
||||||
|
return Ok(n as usize);
|
||||||
|
}
|
||||||
|
let e = io::Error::last_os_error();
|
||||||
|
match e.kind() {
|
||||||
|
// Spurious readiness or signal: park again.
|
||||||
|
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted => continue,
|
||||||
|
_ => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_all(&mut self, mut buf: &[u8]) -> io::Result<()> {
|
||||||
|
if self.closed {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotConnected,
|
||||||
|
"tcp conn closed locally",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
while !buf.is_empty() {
|
||||||
|
wait_writable(self.fd())?;
|
||||||
|
let n = unsafe {
|
||||||
|
libc::send(
|
||||||
|
self.fd(),
|
||||||
|
buf.as_ptr() as *const _,
|
||||||
|
buf.len(),
|
||||||
|
libc::MSG_NOSIGNAL,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if n >= 0 {
|
||||||
|
buf = &buf[n as usize..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let e = io::Error::last_os_error();
|
||||||
|
match e.kind() {
|
||||||
|
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted => continue,
|
||||||
|
_ => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) {
|
||||||
|
if !self.closed {
|
||||||
|
self.closed = true;
|
||||||
|
// Best-effort: the peer sees EOF after draining. The fd itself
|
||||||
|
// is released when the owning stream drops.
|
||||||
|
let _ = self.stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer_addr(&self) -> String {
|
||||||
|
match self.stream.peer_addr() {
|
||||||
|
Ok(sa) => sa.to_string(),
|
||||||
|
Err(_) => "<disconnected>".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Listener
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A bound TCP listen point (non-blocking socket; accept parks the actor).
|
||||||
|
pub struct TcpListener {
|
||||||
|
inner: StdListener,
|
||||||
|
local: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Listener for TcpListener {
|
||||||
|
fn accept(&mut self) -> io::Result<Box<dyn Conn>> {
|
||||||
|
loop {
|
||||||
|
wait_readable(self.inner.as_raw_fd())?;
|
||||||
|
match self.inner.accept() {
|
||||||
|
Ok((stream, _peer)) => {
|
||||||
|
stream.set_nonblocking(true)?;
|
||||||
|
return Ok(Box::new(TcpConn {
|
||||||
|
stream,
|
||||||
|
closed: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
if e.kind() == io::ErrorKind::WouldBlock
|
||||||
|
|| e.kind() == io::ErrorKind::Interrupted =>
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_addr(&self) -> String {
|
||||||
|
self.local.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transport
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The TCP transport. Stateless; every call stands alone.
|
||||||
|
pub struct TcpTransport;
|
||||||
|
|
||||||
|
impl Transport for TcpTransport {
|
||||||
|
fn dial(&self, addr: &str) -> io::Result<Box<dyn Conn>> {
|
||||||
|
let sa = parse_addr(addr)?;
|
||||||
|
let family = match sa {
|
||||||
|
SocketAddr::V4(_) => libc::AF_INET,
|
||||||
|
SocketAddr::V6(_) => libc::AF_INET6,
|
||||||
|
};
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::socket(
|
||||||
|
family,
|
||||||
|
libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// From here the fd is owned by `stream`; any early return drops it.
|
||||||
|
let stream = unsafe {
|
||||||
|
use std::os::fd::FromRawFd;
|
||||||
|
TcpStream::from_raw_fd(fd)
|
||||||
|
};
|
||||||
|
let (raw, len) = to_sockaddr(&sa);
|
||||||
|
let rc = unsafe { libc::connect(fd, (&raw) as *const _ as *const libc::sockaddr, len) };
|
||||||
|
if rc != 0 {
|
||||||
|
let e = io::Error::last_os_error();
|
||||||
|
if e.raw_os_error() != Some(libc::EINPROGRESS) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
// Connect in flight: park until the socket is writable, then the
|
||||||
|
// verdict is in SO_ERROR.
|
||||||
|
wait_writable(fd)?;
|
||||||
|
so_error(fd)?;
|
||||||
|
}
|
||||||
|
Ok(Box::new(TcpConn {
|
||||||
|
stream,
|
||||||
|
closed: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen(&self, addr: &str) -> io::Result<Box<dyn Listener>> {
|
||||||
|
let sa = parse_addr(addr)?;
|
||||||
|
let inner = StdListener::bind(sa)?;
|
||||||
|
inner.set_nonblocking(true)?;
|
||||||
|
let local = inner.local_addr()?;
|
||||||
|
Ok(Box::new(TcpListener { inner, local }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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