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:
@@ -0,0 +1,332 @@
|
||||
//! Tests for the `local` feature: real-socket halves through the real
|
||||
//! establisher path (`local_dial` injected as the `DialFn`), TCP/UDP/
|
||||
//! unix round-trips against real local echo servers, the OQ-TN-13
|
||||
//! truncation fail-loud probe, and the 1400-byte MTU datagram.
|
||||
//!
|
||||
//! The forward topology (`wire_forward`) carries the full path: the
|
||||
//! consumer opens (`TunnelSession::open`), the producer's establisher
|
||||
//! dials via `local_dial`, the pump handler pumps — real sockets at
|
||||
//! both ends.
|
||||
|
||||
#![cfg(feature = "local")]
|
||||
|
||||
mod harness;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alktunnels::params::{Substrate, TunnelParams};
|
||||
use alktunnels::producer::ResourceRegistry;
|
||||
use alktunnels::TunnelSession;
|
||||
|
||||
use harness::wire_forward;
|
||||
|
||||
fn params(resource: &str, substrate: Substrate) -> TunnelParams {
|
||||
TunnelParams {
|
||||
resource: resource.to_string(),
|
||||
substrate,
|
||||
}
|
||||
}
|
||||
|
||||
/// A TCP echo server on an ephemeral port.
|
||||
async fn tcp_echo_server() -> std::net::SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind echo");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let (mut r, mut w) = tokio::io::split(sock);
|
||||
let _ = tokio::io::copy(&mut r, &mut w).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// A UDP echo server on an ephemeral port (one task, echo loop).
|
||||
async fn udp_echo_server() -> std::net::SocketAddr {
|
||||
let sock = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind udp echo");
|
||||
let addr = sock.local_addr().expect("addr");
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65535];
|
||||
loop {
|
||||
let Ok((n, peer)) = sock.recv_from(&mut buf).await else {
|
||||
return;
|
||||
};
|
||||
if sock.send_to(&buf[..n], peer).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_dial_round_trips_through_the_real_establisher_path() {
|
||||
let addr = tcp_echo_server().await;
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, &addr.to_string())
|
||||
.await;
|
||||
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
||||
|
||||
let mut session = TunnelSession::open(&topo.consumer, params("echo", Substrate::Tcp))
|
||||
.await
|
||||
.expect("tcp open");
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let (read, write) = session.stream_halves().expect("halves");
|
||||
write.write_all(b"tcp-local").await.expect("write");
|
||||
write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 9];
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"tcp-local");
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_datagram_round_trip_through_the_framed_adapter() {
|
||||
// The FRAMED adapter end-to-end (ADR-003): session sends the codec
|
||||
// frame; the pump copies raw bytes; the dialed UdpHalf strips the
|
||||
// frame on recv and delivers ONE datagram to the target; the echo
|
||||
// comes back through the same path.
|
||||
let addr = udp_echo_server().await;
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("dns", Substrate::Udp, &addr.to_string())
|
||||
.await;
|
||||
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
||||
|
||||
let mut session = TunnelSession::open(&topo.consumer, params("dns", Substrate::Udp))
|
||||
.await
|
||||
.expect("udp open");
|
||||
session.send_datagram(b"query").await.expect("send");
|
||||
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("io ok")
|
||||
.expect("datagram");
|
||||
assert_eq!(&dg[..], b"query");
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_large_datagram_through_bounded_buffers() {
|
||||
// The POC's sizing test: a 1400-byte datagram (max ethernet MTU
|
||||
// payload) round-trips through the codec + the bounded-buffer path.
|
||||
let addr = udp_echo_server().await;
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("big", Substrate::Udp, &addr.to_string())
|
||||
.await;
|
||||
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
||||
|
||||
let mut session = TunnelSession::open(&topo.consumer, params("big", Substrate::Udp))
|
||||
.await
|
||||
.expect("udp open");
|
||||
let payload: Vec<u8> = (0..1400u32).map(|i| (i % 7) as u8).collect();
|
||||
session.send_datagram(&payload).await.expect("send");
|
||||
let got = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("io ok")
|
||||
.expect("datagram");
|
||||
assert_eq!(got.len(), payload.len());
|
||||
assert_eq!(&got[..], &payload[..]);
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unix_dial_round_trips() {
|
||||
// OQ-TN-14: unix ships with local v1 (same halves shape as TCP).
|
||||
let dir = std::env::temp_dir().join(format!("alktunnels-test-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("echo.sock");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let listener = tokio::net::UnixListener::bind(&path).expect("bind unix echo");
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let (mut r, mut w) = tokio::io::split(sock);
|
||||
let _ = tokio::io::copy(&mut r, &mut w).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("uds", Substrate::Unix, &path.to_string_lossy())
|
||||
.await;
|
||||
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
||||
|
||||
let mut session = TunnelSession::open(&topo.consumer, params("uds", Substrate::Unix))
|
||||
.await
|
||||
.expect("unix open");
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let (read, write) = session.stream_halves().expect("halves");
|
||||
write.write_all(b"unix-local").await.expect("write");
|
||||
write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 10];
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"unix-local");
|
||||
session.close().await;
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncation_fails_loud_oq_tn_13() {
|
||||
// A receive buffer smaller than the datagram surfaces as an error
|
||||
// (never a silent truncation). Probed at the adapter level: feed a
|
||||
// >buffer datagram directly into a UdpHalf pair and read with a
|
||||
// small buffer.
|
||||
let sock_a = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind a");
|
||||
let sock_b = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind b");
|
||||
sock_a
|
||||
.connect(sock_b.local_addr().expect("b addr"))
|
||||
.await
|
||||
.expect("connect a");
|
||||
sock_b
|
||||
.connect(sock_a.local_addr().expect("a addr"))
|
||||
.await
|
||||
.expect("connect b");
|
||||
|
||||
// The producer-side adapter (what the establisher dials): sock_a
|
||||
// wrapped as halves. Send a large datagram from the "wire" side.
|
||||
let (read, _write) = alktunnels::local::UdpHalf::split(sock_a);
|
||||
let mut read = read;
|
||||
let payload = vec![0u8; 60000]; // well-formed for UDP but big
|
||||
sock_b.send(&payload).await.expect("send big");
|
||||
|
||||
// The pump reads with the codec's chunk size (16 KiB) — smaller
|
||||
// than 60000: the adapter must FAIL LOUD, not truncate.
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut chunk = vec![0u8; 16 * 1024];
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read(&mut chunk)).await;
|
||||
match result {
|
||||
Ok(Err(e)) => {
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
other => panic!("expected InvalidData error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_refusal_maps_to_dial_failed() {
|
||||
// Error mapping: a refused dial (nothing listening on the port) is
|
||||
// `dial_failed` on the wire (`channel:open_failed` reason).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("dead", Substrate::Tcp, "127.0.0.1:1")
|
||||
.await;
|
||||
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
||||
|
||||
let err = match TunnelSession::open(&topo.consumer, params("dead", Substrate::Tcp)).await {
|
||||
Ok(_) => panic!("refused dial must fail"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert_eq!(err.open_ref().establishment_reason(), Some("dial_failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listen_producer_over_a_real_tcp_listener() {
|
||||
// The listen shape + local halves compose: a real TcpListenerHalves
|
||||
// accept loop feeds an AcceptQueue; a listen-registered producer
|
||||
// pops accepted sockets; the consumer pumps the tunnel against the
|
||||
// accepted local socket.
|
||||
let queue = alktunnels::producer::AcceptQueue::new();
|
||||
let listener = alktunnels::local::bind_tcp("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
|
||||
let accept_loop = tokio::spawn({
|
||||
let queue = queue.clone();
|
||||
async move { listener.accept_loop(queue).await }
|
||||
});
|
||||
|
||||
// A local client connects (the accepted connection's source). The
|
||||
// driving end comes from `try_clone` — the pump owns the accepted
|
||||
// handle; the driver pushes/reads the echo through the same real
|
||||
// socket.
|
||||
// std-level try_clone BEFORE converting to tokio streams: one fd
|
||||
// for the driver, one wrapped for the pump (two independent
|
||||
// handles to the same socket). O_NONBLOCK is shared across the
|
||||
// dup, so BOTH ends go nonblocking and the driver is driven
|
||||
// async too.
|
||||
let local_std = std::net::TcpStream::connect(addr).expect("std connect");
|
||||
let driver_std = local_std.try_clone().expect("try_clone driver");
|
||||
local_std.set_nonblocking(true).expect("nonblocking");
|
||||
let mut driver = tokio::net::TcpStream::from_std(driver_std).expect("tokio driver");
|
||||
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let accept_fn: alktunnels::producer::AcceptFn = Arc::new(move || {
|
||||
let queue = queue.clone();
|
||||
Box::pin(async move {
|
||||
queue.pop().await.ok_or_else(|| {
|
||||
alktunnels::producer::TunnelEstablishError::DialFailed(
|
||||
"listener closed".to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
});
|
||||
let topo = harness::wire_listen(registry, accept_fn).await;
|
||||
|
||||
let channel_id = alktunnels::open_reverse_channel(
|
||||
&topo.consumer_call,
|
||||
¶ms("listener", Substrate::Tcp),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("listen open");
|
||||
|
||||
let session = TunnelSession::adopt(
|
||||
&topo.consumer_manager,
|
||||
channel_id,
|
||||
Substrate::Tcp,
|
||||
alktunnels::TUNNEL_ALPN,
|
||||
)
|
||||
.await
|
||||
.expect("adopt");
|
||||
// The producer's pump (wrapper-managed) copies channel <-> the
|
||||
// accepted handle; the accepted handle IS the client socket — so
|
||||
// the session's borrowed halves drive it directly: the driver end
|
||||
// writes into the client socket, the pump carries it over the
|
||||
// channel, and the session halves read it (and back).
|
||||
let mut session = session;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
driver.write_all(b"listen-local").await.expect("write");
|
||||
driver.flush().await.expect("flush");
|
||||
let (read, _write) = session.stream_halves().expect("halves");
|
||||
let mut buf = vec![0u8; 12];
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"listen-local");
|
||||
|
||||
session.close().await;
|
||||
accept_loop.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user