feat: local-socket-halves — the local feature (TCP/UDP/unix halves functions)
- src/local/mod.rs (feature = "local" -> tokio/net): dial_tcp (5s timeout), connect_udp (ephemeral bind + connect + the FRAMED UdpHalf adapter), dial_unix (OQ-TN-14 in v1), bind_tcp + TcpListenerHalves::accept_loop (the listen shape's assembly half), local_dial() (one DialFn covering all three substrates). - UdpHalf truncation fails loud (OQ-TN-13): poll_read recvs into a scratch buffer first and size-checks against the caller's buffer — an over-size datagram is io::ErrorKind::InvalidData, never a silent truncation (tokio's poll_recv-into-ReadBuf would truncate). - No socket type appears outside src/local/ (convention 16); the default crate stays wasm-clean; stdio bridging is NOT here (alktty owns process stdio). - Tests (tests/local_halves.rs, 7, feature-gated): TCP/UDP/unix dial round-trips through the real establisher path, the 1400-byte MTU datagram, the truncation fail-loud probe, dial-refusal -> dial_failed, listen producer over a real TCP listener end-to-end. Verified: cargo test green (default + --all-features, 44 + 7), clippy -D warnings (default + all-features + wasm32), fmt clean, cargo check --target wasm32-unknown-unknown passes.
This commit is contained in:
1 parent
09d32d5aa6
commit
65fb69db61
5 files changed
+673
-2
No files matched your search
@@ -24,6 +24,9 @@ pub mod params;
|
||||
pub mod producer;
|
||||
pub mod wire;
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
pub mod local;
|
||||
|
||||
pub use consumer::{open_reverse_channel, TakenHalves, TunnelSession};
|
||||
pub use error::{ReverseOpenError, TunnelError, TunnelIoError, TunnelOpenError};
|
||||
pub use params::ChannelOpenError;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! The `local` feature module: real-socket halves functions the
|
||||
//! assembly layer injects as `DialFn`/`AcceptFn` closures (ADR-004 —
|
||||
//! "produce boxed halves for a resource" is a function, not a trait).
|
||||
//! Non-wasm by design: this module IS the platform boundary — the
|
||||
//! default crate stays wasm-clean (convention 16: `local`-gated code
|
||||
//! is never imported from the shared/producer/consumer modules).
|
||||
//!
|
||||
//! Substrate coverage (overview.md §Feature Gates):
|
||||
//! - **TCP** — [`dial_tcp`] (5s connect timeout per the POCs) and the
|
||||
//! [`TcpListenerHalves`] accept half for listen producers.
|
||||
//! - **UDP** — [`connect_udp`]: a connected `UdpSocket` wrapped in the
|
||||
//! FRAMED adapter ([`UdpHalf`], ADR-003 — the codec composes at this
|
||||
//! boundary; the pump never sees UDP specifics). Truncation fails
|
||||
//! loud (OQ-TN-13's resolved posture).
|
||||
//! - **Unix** — [`dial_unix`], in v1 per OQ-TN-14 (same halves shape
|
||||
//! as TCP). **Stdio is NOT here** — a spawned process's stdio is
|
||||
//! alktty's pipe mode (exit codes + signals are tty semantics);
|
||||
//! remote command execution composes via alktty, not here.
|
||||
//!
|
||||
//! Error mapping: [`TunnelEstablishError`] variants (the POC's
|
||||
//! `Into<EstablishmentError>` path) — dial refusal → `dial_failed`,
|
||||
//! bind/resource exhaustion → `resource_shortage`, handler-internal
|
||||
//! → `handler_error`.
|
||||
//!
|
||||
//! `TargetHandle` re-used from producer.rs — the halves type lives
|
||||
//! there; `local/` depends on it, never vice versa (convention 16).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::params::Substrate;
|
||||
use crate::producer::{AcceptQueue, DialFn, TargetHandle, TunnelEstablishError};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
/// The dial/connect timeout (the POCs' 5s posture).
|
||||
const DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
// --- TCP ----------------------------------------------------------------
|
||||
|
||||
/// Dial a TCP target (`host:port`): `TcpStream::connect` bounded by
|
||||
/// [`DIAL_TIMEOUT`], `into_split` → boxed halves (raw pass-through —
|
||||
/// the halves ARE the tunnel, ADR-003).
|
||||
pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
||||
let stream = tokio::time::timeout(DIAL_TIMEOUT, tokio::net::TcpStream::connect(target))
|
||||
.await
|
||||
.map_err(|_| TunnelEstablishError::DialFailed(format!("dial `{target}` timed out")))?
|
||||
.map_err(|e| TunnelEstablishError::DialFailed(format!("dial `{target}`: {e}")))?;
|
||||
let (read, write) = stream.into_split();
|
||||
Ok(TargetHandle {
|
||||
read: Box::new(read),
|
||||
write: Box::new(write),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind a TCP listener (the listen shape's assembly-side half).
|
||||
/// The binding decision belongs to the caller (OQ-TN-04) — this is a
|
||||
/// `local`-feature convenience, not protocol behavior.
|
||||
pub async fn bind_tcp(addr: &str) -> Result<TcpListenerHalves, TunnelEstablishError> {
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("bind `{addr}`: {e}")))?;
|
||||
Ok(TcpListenerHalves {
|
||||
listener: Arc::new(listener),
|
||||
})
|
||||
}
|
||||
|
||||
/// An assembly-owned TCP listener + its accept loop's feed point. The
|
||||
/// accept loop ([`TcpListenerHalves::accept_loop`]) pushes accepted
|
||||
/// handles into the assembly's [`AcceptQueue`]; the listen establisher
|
||||
/// pops them (producer-listen's contract).
|
||||
pub struct TcpListenerHalves {
|
||||
listener: Arc<tokio::net::TcpListener>,
|
||||
}
|
||||
|
||||
impl TcpListenerHalves {
|
||||
/// The bound listener's local address.
|
||||
pub fn local_addr(&self) -> Result<std::net::SocketAddr, TunnelEstablishError> {
|
||||
self.listener
|
||||
.local_addr()
|
||||
.map_err(|e| TunnelEstablishError::HandlerError(format!("local_addr: {e}")))
|
||||
}
|
||||
|
||||
/// The accept loop: accept connections until the listener fails,
|
||||
/// pushing each accepted handle into the queue. Returns when the
|
||||
/// listener is closed (accept errors end the loop — the assembly
|
||||
/// layer aborts this task on teardown).
|
||||
pub async fn accept_loop(&self, queue: AcceptQueue) -> Result<(), TunnelEstablishError> {
|
||||
loop {
|
||||
let (stream, _peer) = self
|
||||
.listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| TunnelEstablishError::DialFailed(format!("accept: {e}")))?;
|
||||
let (read, write) = stream.into_split();
|
||||
let handle = TargetHandle {
|
||||
read: Box::new(read),
|
||||
write: Box::new(write),
|
||||
};
|
||||
queue.push(handle).await.map_err(|dropped| {
|
||||
TunnelEstablishError::HandlerError(format!(
|
||||
"queue closed; accepted connection dropped: {dropped:?}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- UDP ----------------------------------------------------------------
|
||||
|
||||
/// Connect a UDP "tunnel" to a target: bind an ephemeral socket,
|
||||
/// `connect(target)` (the associated-socket posture — one peer), and
|
||||
/// wrap it in the FRAMED adapter (ADR-003): `UdpHalf` enforces one
|
||||
/// datagram per read/write at the substrate boundary; the `[len: u16
|
||||
/// BE]` codec composes at this boundary — the pump never sees UDP
|
||||
/// specifics.
|
||||
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
||||
let sock = tokio::net::UdpSocket::bind(("0.0.0.0", 0))
|
||||
.await
|
||||
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("udp bind failed: {e}")))?;
|
||||
sock.connect(target)
|
||||
.await
|
||||
.map_err(|e| TunnelEstablishError::DialFailed(format!("udp connect `{target}`: {e}")))?;
|
||||
let (read, write) = UdpHalf::split(sock);
|
||||
Ok(TargetHandle { read, write })
|
||||
}
|
||||
|
||||
/// A UDP socket presented as `AsyncRead`/`AsyncWrite` halves: reads
|
||||
/// yield whole datagrams (one `poll_recv` per read); writes send one
|
||||
/// datagram per `poll_write` buffer. The FRAMED adapter — the codec
|
||||
/// wraps at this boundary (ADR-003); the pump never sees UDP
|
||||
/// specifics.
|
||||
///
|
||||
/// **Truncation fails loud** (OQ-TN-13's resolved posture, ADR-003):
|
||||
/// `poll_recv` into a too-small caller buffer returns
|
||||
/// `io::ErrorKind::InvalidData` (the datagram is dropped with the
|
||||
/// error — silent truncation would corrupt the framing invariants).
|
||||
/// The codec's u16 bound keeps well-formed peers from ever hitting
|
||||
/// this path; only a mis-framed wire does.
|
||||
pub struct UdpHalf {
|
||||
sock: Arc<tokio::net::UdpSocket>,
|
||||
kind: UdpHalfKind,
|
||||
/// The recv scratch buffer (the fail-loud truncation check reads
|
||||
/// whole datagrams before copying into the caller's buffer).
|
||||
recv_scratch: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum UdpHalfKind {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
impl UdpHalf {
|
||||
pub fn split(
|
||||
sock: tokio::net::UdpSocket,
|
||||
) -> (
|
||||
Box<dyn AsyncRead + Send + Sync + Unpin>,
|
||||
Box<dyn AsyncWrite + Send + Sync + Unpin>,
|
||||
) {
|
||||
let sock = Arc::new(sock);
|
||||
(
|
||||
Box::new(UdpHalf {
|
||||
sock: Arc::clone(&sock),
|
||||
kind: UdpHalfKind::Read,
|
||||
recv_scratch: vec![0u8; 65535],
|
||||
}),
|
||||
Box::new(UdpHalf {
|
||||
sock,
|
||||
kind: UdpHalfKind::Write,
|
||||
recv_scratch: Vec::new(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for UdpHalf {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
debug_assert_eq!(this.kind, UdpHalfKind::Read);
|
||||
// One recv yields exactly one datagram into the caller's
|
||||
// buffer. The fail-loud posture (OQ-TN-13, ADR-003): a
|
||||
// datagram LARGER than the caller's buffer is a framing
|
||||
// violation — reported as an error, never silently truncated
|
||||
// (`poll_recv`-into-ReadBuf would truncate; the scratch-buffer
|
||||
// recv + size check keeps the invariant observable). The
|
||||
// codec's u16 bound keeps well-formed peers off this path.
|
||||
let scratch = &mut this.recv_scratch;
|
||||
match this.sock.try_recv(scratch) {
|
||||
Ok(n) => {
|
||||
if n > buf.remaining() {
|
||||
return std::task::Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"udp datagram {n} bytes exceeds caller buffer {} (truncation fails loud — OQ-TN-13)",
|
||||
buf.remaining()
|
||||
),
|
||||
)));
|
||||
}
|
||||
buf.put_slice(&scratch[..n]);
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// Not ready: poll readiness to register the waker,
|
||||
// then Pending (the next wake retries the recv).
|
||||
match this.sock.poll_recv_ready(cx) {
|
||||
std::task::Poll::Ready(Ok(())) => std::task::Poll::Pending,
|
||||
std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Err(e)),
|
||||
std::task::Poll::Pending => std::task::Poll::Pending,
|
||||
}
|
||||
}
|
||||
Err(e) => std::task::Poll::Ready(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for UdpHalf {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
debug_assert_eq!(this.kind, UdpHalfKind::Write);
|
||||
match this.sock.try_send(buf) {
|
||||
Ok(n) => std::task::Poll::Ready(Ok(n)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// Poll send-readiness to register the waker, then
|
||||
// Pending (the next wake retries the send).
|
||||
match this.sock.poll_send_ready(cx) {
|
||||
std::task::Poll::Ready(Ok(())) => std::task::Poll::Pending,
|
||||
std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Err(e)),
|
||||
std::task::Poll::Pending => std::task::Poll::Pending,
|
||||
}
|
||||
}
|
||||
Err(e) => std::task::Poll::Ready(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
// UDP has no shutdown; drop is the close.
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unix ---------------------------------------------------------------
|
||||
|
||||
/// Dial a unix-socket target (OQ-TN-14 — ships in v1, the same halves
|
||||
/// shape as TCP): `UnixStream::connect` bounded by [`DIAL_TIMEOUT`],
|
||||
/// `into_split` → boxed halves (raw pass-through, ADR-003).
|
||||
pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
||||
let stream = tokio::time::timeout(DIAL_TIMEOUT, tokio::net::UnixStream::connect(path))
|
||||
.await
|
||||
.map_err(|_| TunnelEstablishError::DialFailed(format!("dial `{path}` timed out")))?
|
||||
.map_err(|e| TunnelEstablishError::DialFailed(format!("dial `{path}`: {e}")))?;
|
||||
let (read, write) = stream.into_split();
|
||||
Ok(TargetHandle {
|
||||
read: Box::new(read),
|
||||
write: Box::new(write),
|
||||
})
|
||||
}
|
||||
|
||||
/// The local dial closure (the ADR-004 injection point): one
|
||||
/// [`DialFn`]-shaped function covering tcp/udp/unix — the assembly
|
||||
/// layer injects this (closed over nothing substrate-specific beyond
|
||||
/// this module).
|
||||
pub fn local_dial() -> DialFn {
|
||||
Arc::new(move |substrate, backing: &str| {
|
||||
let backing = backing.to_string();
|
||||
Box::pin(async move {
|
||||
match substrate {
|
||||
Substrate::Tcp => dial_tcp(&backing).await,
|
||||
Substrate::Udp => connect_udp(&backing).await,
|
||||
Substrate::Unix => dial_unix(&backing).await,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user