- `bind_tcp_first` + `TcpListenerHalves::accept_pair`: the real-socket BindFn source (port 0 resolved via local_addr — the `bound` reply field's data) and the per-accept (peer, halves) pair (the peer rides accept()'s return — the forwarded op's `peer` carrier) - unix bind-first parity shipped (OQ-TN-14): `bind_unix` + `UnixListenerHalves` (local_addr / queue-feeding accept_loop / bind-first accept_pair; an unnamed peer fails loud — the forwarded `peer` param requires an address) + `bind_unix_first` - assembly conveniences: `local_bind_first()` (tcp/unix dispatch; udp fails loud — no accept on a UDP socket, the shape is not defined there, never a fake listener) + `local_bindfirst_accept_loop()` (downcasts the opaque listener; a foreign type fails loud) - direct-op DialFn reuse verified + pinned: `local_dial` shared unchanged (the UDP framed wrap composes inside the closure); `direct_udp_through_local_dial_round_trips_framed` drives a REAL udp echo through the direct op's establisher path - tests/local_bindfirst.rs: tcp e2e over real sockets (bound fidelity — the test dials the reported address; two clients → two forwarded channels pumping independently, real distinct peers), unix parity (real path in `bound`, named-peer client via socket2, real peer path on the forwarded open), udp refusal, downcast-miss probe - accept-first shapes (accept_loop + AcceptQueue) untouched Verified: cargo test (default) + --all-features green (45 lib; 151 integration), clippy -D warnings clean (native all-features + wasm32), fmt clean, wasm32 check passes (default crate stays wasm-clean), cargo doc clean (no new warnings). Tasks: tunnels/local-bind-halves (status completed; Notes + Summary filled). CHANGELOG: Unreleased → Added.
1232 lines
47 KiB
Rust
1232 lines
47 KiB
Rust
//! 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, SubstrateAddr, TunnelParams};
|
|
use alktunnels::producer::ResourceRegistry;
|
|
use alktunnels::TunnelSession;
|
|
|
|
use harness::{wire_direct_forward, wire_forward};
|
|
|
|
/// 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 direct_udp_through_local_dial_round_trips_framed() {
|
|
// The DIRECT op's DialFn reuse pin (the local-bind-halves task):
|
|
// `local_dial` is the ONE dial closure the base op and the direct
|
|
// op share — the UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003)
|
|
// happens INSIDE the closure (review 001 U-1), so direct-udp gets
|
|
// datagram-boundary framing free. Driven against a REAL UDP echo
|
|
// socket: the session sends the codec frame, the direct establisher
|
|
// dials via `local_dial`, the framed adapter delivers ONE datagram
|
|
// to the target, and the echo returns through the same path.
|
|
let addr = udp_echo_server().await;
|
|
let topo = wire_direct_forward(alktunnels::local::local_dial()).await;
|
|
|
|
let mut session = TunnelSession::open_direct(
|
|
&topo.consumer,
|
|
SubstrateAddr::HostPort {
|
|
host: "127.0.0.1".to_string(),
|
|
port: addr.port(),
|
|
},
|
|
Substrate::Udp,
|
|
)
|
|
.await
|
|
.expect("udp direct open through local_dial");
|
|
session.send_datagram(b"direct-udp").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"direct-udp");
|
|
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() {
|
|
// The fail-loud posture (OQ-TN-13) at the FRAMED adapter: the
|
|
// socket-facing recv uses the full 65535-byte scratch (try_recv
|
|
// truncates silently at the buffer size), so a well-formed
|
|
// >caller-buffer datagram is received whole and STAGED — never
|
|
// silently truncated into a shorter frame. The staging surface is
|
|
// pinned by `oversized_frame_stages_across_reads` (below); this
|
|
// test pins the fail-loud path directly at the unit level: a
|
|
// scratch smaller than the datagram WOULD have corrupted the
|
|
// framing (the U-3 re-validation's warning), so the scratch must
|
|
// be full-size. A datagram at the IP/UDP max (65507) is within
|
|
// the u16 frame bound (65535) — it must arrive intact.
|
|
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. A 65507-byte datagram (the UDP theoretical
|
|
// max — a well-formed wire's largest possible frame) from the
|
|
// wire side.
|
|
let (read, _write) = alktunnels::local::UdpHalf::split(sock_a);
|
|
let mut read = read;
|
|
let payload: Vec<u8> = (0..65507u32).map(|i| (i % 251) as u8).collect();
|
|
sock_b.send(&payload).await.expect("send max-size");
|
|
|
|
// The pump reads with the codec's chunk size (16 KiB) — far
|
|
// smaller than the datagram: the adapter must STAGE, never
|
|
// truncate. Drain through small reads and reassemble the frame.
|
|
use tokio::io::AsyncReadExt;
|
|
let mut got = Vec::new();
|
|
let mut chunk = vec![0u8; 16 * 1024];
|
|
while got.len() < payload.len() + 2 {
|
|
let n = tokio::time::timeout(std::time::Duration::from_secs(5), read.read(&mut chunk))
|
|
.await
|
|
.expect("read timed out (datagram truncated?)")
|
|
.expect("read ok");
|
|
assert!(n > 0, "zero-length read before the frame completed");
|
|
got.extend_from_slice(&chunk[..n]);
|
|
}
|
|
assert_eq!(&got[..2], &[0xff, 0xe3], "declared length = 65507");
|
|
assert_eq!(
|
|
&got[2..],
|
|
&payload[..],
|
|
"full payload intact — no truncation"
|
|
);
|
|
}
|
|
|
|
/// The >caller-buffer (but within-frame-bound) datagram: the framed
|
|
/// adapter STAGES the frame and hands it out across reads — the pump's
|
|
/// far-side de-framer reassembles the true length (no silent
|
|
/// truncation, no shorter-datagram misread). The reassembly is pinned
|
|
/// by the end-to-end gates below; here the staging surface is pinned
|
|
/// directly: successive reads yield the full frame.
|
|
#[tokio::test]
|
|
async fn oversized_frame_stages_across_reads() {
|
|
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");
|
|
|
|
let (read, _write) = alktunnels::local::UdpHalf::split(sock_a);
|
|
let mut read = read;
|
|
let payload: Vec<u8> = (0..20000u32).map(|i| (i % 251) as u8).collect();
|
|
sock_b.send(&payload).await.expect("send 20k");
|
|
|
|
// Read through a small caller buffer (16 KiB — smaller than the
|
|
// ~20002-byte frame): the first read fills it completely, the
|
|
// second hands out the staged remainder.
|
|
use tokio::io::AsyncReadExt;
|
|
let mut got = Vec::new();
|
|
let mut chunk = vec![0u8; 16 * 1024];
|
|
let n1 = tokio::time::timeout(std::time::Duration::from_secs(5), read.read(&mut chunk))
|
|
.await
|
|
.expect("first read")
|
|
.expect("read ok");
|
|
got.extend_from_slice(&chunk[..n1]);
|
|
let n2 = tokio::time::timeout(std::time::Duration::from_secs(5), read.read(&mut chunk))
|
|
.await
|
|
.expect("second read")
|
|
.expect("read ok");
|
|
got.extend_from_slice(&chunk[..n2]);
|
|
|
|
assert_eq!(got.len(), payload.len() + 2, "frame length read out");
|
|
assert_eq!(&got[..2], &[len_hi(payload.len()), len_lo(payload.len())]);
|
|
assert_eq!(&got[2..], &payload[..], "payload bytes intact");
|
|
}
|
|
|
|
fn len_hi(n: usize) -> u8 {
|
|
(n >> 8) as u8
|
|
}
|
|
fn len_lo(n: usize) -> u8 {
|
|
(n & 0xff) as u8
|
|
}
|
|
|
|
/// A RECORDING UDP "target" (review 001's verification-gate probe
|
|
/// shape, made permanent): it records every datagram it receives
|
|
/// (byte-for-byte, to the shared log) and replies with canned,
|
|
/// configurable datagrams — including EMPTY and RAW-UNFRAMED ones the
|
|
/// echo server can't produce.
|
|
struct RecordingTarget {
|
|
addr: std::net::SocketAddr,
|
|
received: Arc<tokio::sync::Mutex<Vec<Vec<u8>>>>,
|
|
}
|
|
|
|
async fn recording_udp_target(replies: Vec<Vec<u8>>) -> RecordingTarget {
|
|
let sock = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
|
.await
|
|
.expect("bind recording target");
|
|
let addr = sock.local_addr().expect("addr");
|
|
let received = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
|
let log = Arc::clone(&received);
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 65535];
|
|
// Reply cyclically (raw, as a real target would) to each
|
|
// datagram that arrives, then keep recording.
|
|
let mut next = 0usize;
|
|
loop {
|
|
let Ok((n, peer)) = sock.recv_from(&mut buf).await else {
|
|
return;
|
|
};
|
|
log.lock().await.push(buf[..n].to_vec());
|
|
if !replies.is_empty() {
|
|
let reply = &replies[next % replies.len()];
|
|
next += 1;
|
|
if sock.send_to(reply, peer).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
RecordingTarget { addr, received }
|
|
}
|
|
|
|
fn params(resource: &str, substrate: Substrate) -> TunnelParams {
|
|
TunnelParams {
|
|
resource: resource.to_string(),
|
|
substrate,
|
|
}
|
|
}
|
|
|
|
/// GATE 1a (review 001): the target receives PAYLOAD BYTES ONLY — no
|
|
/// `[len: u16 BE]` codec prefix (the U-1 defect's primary face).
|
|
#[tokio::test]
|
|
async fn gate_target_receives_payload_bytes_only() {
|
|
let target = recording_udp_target(vec![]).await;
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("rec", Substrate::Udp, &target.addr.to_string())
|
|
.await;
|
|
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
|
|
|
let mut session = TunnelSession::open(&topo.consumer, params("rec", Substrate::Udp))
|
|
.await
|
|
.expect("udp open");
|
|
session.send_datagram(b"query").await.expect("send");
|
|
// Give the datagram time to cross the pump.
|
|
for _ in 0..50 {
|
|
if !target.received.lock().await.is_empty() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
let received = target.received.lock().await;
|
|
assert_eq!(received.len(), 1, "exactly one datagram at the target");
|
|
assert_eq!(
|
|
&received[0][..],
|
|
b"query",
|
|
"the target sees the payload, not the codec frame (no 00 05 prefix)"
|
|
);
|
|
session.close().await;
|
|
}
|
|
|
|
/// GATE 1b: an EMPTY datagram round-trips over the REAL socket path —
|
|
/// `recv_datagram` yields `Some(b"")` and the channel stays alive
|
|
/// (the F-2 fix end-to-end; the raw adapter collapsed it to EOF).
|
|
#[tokio::test]
|
|
async fn gate_empty_datagram_round_trips_over_the_real_socket_path() {
|
|
// The target replies cyclically: empty datagram first (the
|
|
// round-trip under test), then `still-alive` (the liveness probe).
|
|
let target = recording_udp_target(vec![vec![], b"still-alive".to_vec()]).await;
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("empty", Substrate::Udp, &target.addr.to_string())
|
|
.await;
|
|
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
|
|
|
let mut session = TunnelSession::open(&topo.consumer, params("empty", Substrate::Udp))
|
|
.await
|
|
.expect("udp open");
|
|
session.send_datagram(b"").await.expect("send empty");
|
|
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
|
.await
|
|
.expect("recv timed out (empty datagram collapsed to EOF?)")
|
|
.expect("io ok")
|
|
.expect("empty datagram is Some, NOT None (EOF)");
|
|
assert!(dg.is_empty(), "the empty datagram round-trips");
|
|
|
|
// The channel is still alive: a second exchange works (the
|
|
// canned reply cycles — the target sends b"still-alive" back).
|
|
session.send_datagram(b"second").await.expect("send 2");
|
|
let dg2 = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
|
.await
|
|
.expect("recv 2 timed out")
|
|
.expect("io ok 2")
|
|
.expect("datagram 2");
|
|
assert_eq!(&dg2[..], b"still-alive");
|
|
session.close().await;
|
|
}
|
|
|
|
/// GATE 2: a >8 KiB datagram round-trips as ONE datagram at the
|
|
/// target (the 8 KiB copy-buffer split is gone — the frame reassembles
|
|
/// at the adapter before crossing the pump).
|
|
#[tokio::test]
|
|
async fn gate_large_datagram_arrives_as_one_datagram() {
|
|
let target = recording_udp_target(vec![]).await;
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("big", Substrate::Udp, &target.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");
|
|
// 9000 bytes — the review's probe size that split as 8192 + 810.
|
|
let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
|
|
session.send_datagram(&payload).await.expect("send 9000");
|
|
for _ in 0..100 {
|
|
if !target.received.lock().await.is_empty() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
let received = target.received.lock().await;
|
|
assert_eq!(received.len(), 1, "ONE datagram at the target, not two");
|
|
assert_eq!(
|
|
received[0].len(),
|
|
9000,
|
|
"full payload in one datagram (no 8192 split)"
|
|
);
|
|
assert_eq!(&received[0][..], &payload[..], "payload intact");
|
|
session.close().await;
|
|
}
|
|
|
|
/// GATE 3: an unframed target-initiated datagram resolves at the
|
|
/// session (the session codec decodes the framed bytes the adapter
|
|
/// produced — real-target traffic is not parked mid-frame).
|
|
#[tokio::test]
|
|
async fn gate_unframed_target_datagram_resolves_at_the_session() {
|
|
// A raw "HELLO" — a real server's greeting the raw adapter parked
|
|
// mid-frame (the probe's 2s timeout).
|
|
let target = recording_udp_target(vec![b"HELLO".to_vec()]).await;
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("greet", Substrate::Udp, &target.addr.to_string())
|
|
.await;
|
|
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
|
|
|
let mut session = TunnelSession::open(&topo.consumer, params("greet", Substrate::Udp))
|
|
.await
|
|
.expect("udp open");
|
|
session.send_datagram(b"ping").await.expect("send");
|
|
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
|
.await
|
|
.expect("recv timed out — unframed target traffic was parked/dropped")
|
|
.expect("io ok")
|
|
.expect("datagram");
|
|
assert_eq!(&dg[..], b"HELLO");
|
|
session.close().await;
|
|
}
|
|
|
|
/// U-2 (review 001): `connect_udp`'s bind is family-correct — a v6
|
|
/// target dials from a v6-bound socket (the hardcoded `0.0.0.0` bind
|
|
/// broke every v6-only target at connect). A real ::1 target
|
|
/// round-trips through the full path.
|
|
#[tokio::test]
|
|
async fn connect_udp_binds_the_matching_family_v6_targets_dial() {
|
|
let sock = tokio::net::UdpSocket::bind(("::1", 0))
|
|
.await
|
|
.expect("bind v6 echo");
|
|
let addr = sock.local_addr().expect("v6 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;
|
|
}
|
|
}
|
|
});
|
|
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("v6", Substrate::Udp, &addr.to_string())
|
|
.await;
|
|
let topo = wire_forward(registry, alktunnels::local::local_dial()).await;
|
|
|
|
let mut session = TunnelSession::open(&topo.consumer, params("v6", Substrate::Udp))
|
|
.await
|
|
.expect("v6 udp open (family-correct bind)");
|
|
session.send_datagram(b"v6-query").await.expect("send v6");
|
|
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"v6-query");
|
|
session.close().await;
|
|
}
|
|
|
|
#[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();
|
|
}
|
|
|
|
// =====================================================================
|
|
// UdpHalf write-side machinery (review 001's U-1 invariants, the
|
|
// never-blocks paths the end-to-end gates don't reach): WouldBlock
|
|
// stashing, the accepted-byte high-water backpressure, the flush
|
|
// drain, and the shutdown best-effort drain — driven with poll_fn
|
|
// over a shrunken-SO_SNDBUF socket (deterministic, no timing).
|
|
// =====================================================================
|
|
|
|
/// An UNCONNECTED UdpHalf write half: `try_send` fails
|
|
/// deterministically with `EDESTADDRREQ` ("destination address
|
|
/// required") — the deterministic probe for the write path's ERROR
|
|
/// arms on this host, where no UDP sender-blocking shape was
|
|
/// reachable (loopback/veth drop silently; see the host-notes on the
|
|
/// drain test).
|
|
fn unconnected_write_half() -> Box<dyn tokio::io::AsyncWrite + Send + Sync + Unpin> {
|
|
let std_sock = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind unconnected");
|
|
std_sock.set_nonblocking(true).expect("nonblocking");
|
|
let sock = tokio::net::UdpSocket::from_std(std_sock).expect("tokio socket");
|
|
let (_read, write) = alktunnels::local::UdpHalf::split(sock);
|
|
write
|
|
}
|
|
|
|
/// Drive one `poll_write` with the given buffer (one poll — the
|
|
/// pump's copy shape).
|
|
async fn poll_write_once<W: tokio::io::AsyncWrite + Unpin>(
|
|
write: &mut W,
|
|
buf: &[u8],
|
|
) -> std::io::Result<usize> {
|
|
futures::future::poll_fn(|cx| tokio::io::AsyncWrite::poll_write(Pin::new(write), cx, buf)).await
|
|
}
|
|
|
|
/// Drive one `poll_flush` to completion (the copy's flush).
|
|
async fn poll_flush_once<W: tokio::io::AsyncWrite + Unpin>(write: &mut W) -> std::io::Result<()> {
|
|
futures::future::poll_fn(|cx| tokio::io::AsyncWrite::poll_flush(Pin::new(write), cx)).await
|
|
}
|
|
|
|
/// Drive one `poll_shutdown`.
|
|
async fn poll_shutdown_once<W: tokio::io::AsyncWrite + Unpin>(
|
|
write: &mut W,
|
|
) -> std::io::Result<()> {
|
|
futures::future::poll_fn(|cx| tokio::io::AsyncWrite::poll_shutdown(Pin::new(write), cx)).await
|
|
}
|
|
|
|
use std::pin::Pin;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn udp_half_write_queue_drains_on_flush_and_shutdown() {
|
|
// The queue/flush machinery on a WORKING socket: poll_write
|
|
// accepts frames (the de-framer's queue + accepted accounting),
|
|
// flush drains them onto the socket (Ready(Ok)), and shutdown's
|
|
// best-effort drain reports Ready(Ok) (UDP has no shutdown).
|
|
// The peer reads (a real probe) — the datagrams land.
|
|
//
|
|
// Host-notes (probed 2026-09-09): (a) a fresh-socket first
|
|
// `try_send` may report WouldBlock spuriously on this kernel
|
|
// (one readiness+retry cycle clears it, ~20% of runs) — the
|
|
// flush loop tolerates it, which also exercises the `sending`
|
|
// stash + re-offer path whenever it fires. (b) The test runs
|
|
// multi-thread: on a current-thread runtime the kernel's
|
|
// socket-registration timing left datagrams undelivered at
|
|
// bound-but-unread peers (tokio 1.53 probe result), so the
|
|
// delivery assertion is only trustworthy multi-threaded.
|
|
let peer = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
|
.await
|
|
.expect("bind peer");
|
|
let snd_sock = socket2::Socket::new(
|
|
socket2::Domain::IPV4,
|
|
socket2::Type::DGRAM,
|
|
Some(socket2::Protocol::UDP),
|
|
)
|
|
.expect("raw sender socket");
|
|
snd_sock.set_nonblocking(true).expect("nonblocking");
|
|
snd_sock
|
|
.bind(&socket2::SockAddr::from(
|
|
"127.0.0.1:0"
|
|
.parse::<std::net::SocketAddr>()
|
|
.expect("parse"),
|
|
))
|
|
.expect("bind sender");
|
|
let snd_std: std::net::UdpSocket = snd_sock.into();
|
|
let snd = tokio::net::UdpSocket::from_std(snd_std).expect("tokio sender");
|
|
snd.connect(peer.local_addr().expect("peer addr"))
|
|
.await
|
|
.expect("connect sender");
|
|
|
|
let (_read, mut write) = alktunnels::local::UdpHalf::split(snd);
|
|
|
|
// Accept three frames (the de-framer queue holds them).
|
|
for i in 0..3u8 {
|
|
let frame = [0, 3, i, i, i];
|
|
let res = poll_write_once(&mut write, &frame).await;
|
|
assert!(
|
|
matches!(res, Ok(5)),
|
|
"poll_write accepted the frame: {res:?}"
|
|
);
|
|
}
|
|
|
|
// Flush drains the queue onto the socket — bounded cycles: the
|
|
// quirk's stash+Pending resolves on the next flush (poll_send_ready
|
|
// is Ok), a saturated socket stays queued (not this host's case).
|
|
let mut flushed = false;
|
|
for _ in 0..8 {
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
poll_flush_once(&mut write),
|
|
)
|
|
.await
|
|
.expect("flush timed out")
|
|
{
|
|
Ok(()) => {
|
|
flushed = true;
|
|
break;
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
|
Err(e) => panic!("unexpected flush error: {e:?}"),
|
|
}
|
|
}
|
|
assert!(flushed, "flush drained the queue within 8 cycles");
|
|
|
|
// The peer saw all three datagrams (the accounting released).
|
|
let mut buf = vec![0u8; 65535];
|
|
let mut received = 0;
|
|
for _ in 0..50 {
|
|
match peer.try_recv(&mut buf) {
|
|
Ok(_) => received += 1,
|
|
Err(_) if received < 3 => {
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
assert_eq!(received, 3, "the peer saw the flushed datagrams");
|
|
|
|
// Shutdown: the best-effort drain (the queue is already empty) →
|
|
// Ready(Ok).
|
|
let res = poll_shutdown_once(&mut write).await;
|
|
assert!(res.is_ok(), "shutdown is Ready(Ok): {res:?}");
|
|
drop(peer);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn udp_half_write_error_maps_loud_on_unconnected_socket() {
|
|
// The ERROR arms: an unconnected socket's `try_send` fails
|
|
// deterministically (`EDESTADDRREQ`) — poll_write's drain maps
|
|
// the socket error loud (the `pump_queue_to_socket` Err arm),
|
|
// and flush re-raises it (the `poll_flush` error surface). The
|
|
// high-water backpressure and the WouldBlock stash remain
|
|
// host-dependent (see connected_udp_pair's note) — covered by the
|
|
// leftover-coverage task.
|
|
let mut write = unconnected_write_half();
|
|
|
|
let frame = [0, 3, b'x', b'y', b'z'];
|
|
let res = poll_write_once(&mut write, &frame).await;
|
|
match res {
|
|
// The opportunistic drain swallows the error on the accept
|
|
// path (`let _ =`); the queue still holds the datagram.
|
|
Ok(_) => {}
|
|
Err(e) => panic!("poll_write must ACCEPT (the drain error is swallowed): {e:?}"),
|
|
}
|
|
|
|
// Flush surfaces the socket error loud (the drain's Err arm).
|
|
let res = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
poll_flush_once(&mut write),
|
|
)
|
|
.await
|
|
.expect("flush timed out");
|
|
assert!(res.is_err(), "flush must surface the unconnected error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn udp_half_poll_shutdown_drains_best_effort_and_is_idempotent() {
|
|
// UDP has no shutdown: poll_shutdown drains the queue best-effort
|
|
// and reports Ready(Ok) — the drop-is-close posture. On a
|
|
// saturated socket the drain is best-effort too (the error is
|
|
// swallowed — shutdown never fails the pump).
|
|
let mut write = unconnected_write_half();
|
|
use tokio::io::AsyncWriteExt;
|
|
write
|
|
.write_all(&[0, 3, b'x', b'y', b'z'])
|
|
.await
|
|
.expect("accept");
|
|
let res = poll_shutdown_once(&mut write).await;
|
|
assert!(
|
|
res.is_ok(),
|
|
"shutdown is Ready(Ok) even when the drain errors: {res:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
|
|
// The flush loop's pump-again shape (tokio's copy polls flush
|
|
// after every window drain): each flush drains that window's
|
|
// queued datagrams; five write+flush cycles → five datagrams at
|
|
// the peer (the fresh-socket quirk tolerated per cycle — see the
|
|
// host-notes on the drain test; multi-thread runtime for the same
|
|
// delivery-timing reason).
|
|
let peer = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
|
.await
|
|
.expect("bind peer");
|
|
let snd_sock = socket2::Socket::new(
|
|
socket2::Domain::IPV4,
|
|
socket2::Type::DGRAM,
|
|
Some(socket2::Protocol::UDP),
|
|
)
|
|
.expect("raw sender socket");
|
|
snd_sock.set_nonblocking(true).expect("nonblocking");
|
|
snd_sock
|
|
.bind(&socket2::SockAddr::from(
|
|
"127.0.0.1:0"
|
|
.parse::<std::net::SocketAddr>()
|
|
.expect("parse"),
|
|
))
|
|
.expect("bind sender");
|
|
let snd_std: std::net::UdpSocket = snd_sock.into();
|
|
let snd = tokio::net::UdpSocket::from_std(snd_std).expect("tokio sender");
|
|
snd.connect(peer.local_addr().expect("peer addr"))
|
|
.await
|
|
.expect("connect sender");
|
|
|
|
let (_read, mut write) = alktunnels::local::UdpHalf::split(snd);
|
|
|
|
for i in 0..5u8 {
|
|
let frame = [0, 1, i];
|
|
poll_write_once(&mut write, &frame).await.expect("accept");
|
|
// Each cycle flushes (bounded retries — the fresh-socket
|
|
// quirk's stash clears on the second readiness cycle).
|
|
let mut flushed = false;
|
|
for _ in 0..8 {
|
|
match poll_flush_once(&mut write).await {
|
|
Ok(()) => {
|
|
flushed = true;
|
|
break;
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
|
Err(e) => panic!("unexpected flush {i} error: {e:?}"),
|
|
}
|
|
}
|
|
assert!(flushed, "flush cycle {i} drained");
|
|
}
|
|
|
|
let mut buf = vec![0u8; 65535];
|
|
let mut received = 0;
|
|
for _ in 0..50 {
|
|
match peer.try_recv(&mut buf) {
|
|
Ok(_) => received += 1,
|
|
Err(_) if received < 5 => {
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
assert_eq!(received, 5, "five flush cycles, five datagrams");
|
|
drop(peer);
|
|
}
|
|
|
|
/// The resolve arm (`connect_udp`'s no-addresses mapping): a target
|
|
/// whose name cannot be resolved (an RFC 6761 `.invalid` name —
|
|
/// guaranteed NXDOMAIN, resolved instantly and without network I/O)
|
|
/// maps to `dial_failed` (the resolve step's error posture, ADR-004's
|
|
/// `DialFn` contract). Rootless, deterministic.
|
|
#[tokio::test]
|
|
async fn connect_udp_unresolvable_target_maps_to_dial_failed() {
|
|
let err = match alktunnels::local::connect_udp("definitely-not-a-real-host-xyz.invalid:1").await
|
|
{
|
|
Ok(_) => panic!("an unresolvable target must fail"),
|
|
Err(e) => e,
|
|
};
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
alktunnels::producer::TunnelEstablishError::DialFailed(_)
|
|
),
|
|
"resolve failure maps to dial_failed: {err:?}"
|
|
);
|
|
}
|
|
|
|
// =====================================================================
|
|
// The sender-block machinery (the coverage-weak-spots task's option 1,
|
|
// CLOSED — the recipe was found): a veth fixture with the peer
|
|
// address UNASSIGNED and the neighbor table flushed puts the
|
|
// connected UDP sender into a PERMANENT WouldBlock/ENOSPC state
|
|
// (the unresolved neighbor never resolves; the blocked write path's
|
|
// skb stays charged to the socket's wmem with a shrunken SO_SNDBUF).
|
|
// Assigning the peer address + binding the peer resolves ARP; the
|
|
// queued/stashed datagrams drain and DELIVER. Validated on this host
|
|
// (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5) — 3/3
|
|
// deterministic probe runs, 2026-09-09.
|
|
//
|
|
// Root-gated (`ip link add`): auto-skipped without root. Never
|
|
// touches the default route or any real interface.
|
|
// =====================================================================
|
|
|
|
/// The veth fixture (created and torn down per run): `alktx/alktx-p`
|
|
/// with 10.240.0.1/24 (the sender side) and 10.240.0.9 (the peer
|
|
/// side, UNASSIGNED during the block phase — its absence is what
|
|
/// pins the sender). The peer address is assigned mid-test (the
|
|
/// resolve step). A dead-simple guard skips the test when `ip` is
|
|
/// unavailable or the command fails (non-root hosts).
|
|
struct VethFixture {
|
|
peer_addr_assigned: bool,
|
|
}
|
|
|
|
/// Run a privileged `ip` command: bare `ip` first (uid-0 test runs),
|
|
/// `sudo ip` second (the uid-1000 dev runs). `None` = unavailable.
|
|
async fn ip_cmd(args: &[&str]) -> Option<bool> {
|
|
let mut spawned = false;
|
|
for prog in ["ip", "sudo"] {
|
|
let mut argv = Vec::with_capacity(args.len() + 1);
|
|
if prog == "sudo" {
|
|
argv.push("ip");
|
|
}
|
|
argv.extend_from_slice(args);
|
|
match tokio::process::Command::new(prog)
|
|
.args(&argv)
|
|
.output()
|
|
.await
|
|
{
|
|
Ok(o) => {
|
|
spawned = true;
|
|
if o.status.success() {
|
|
return Some(true);
|
|
}
|
|
}
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
spawned.then_some(false)
|
|
}
|
|
|
|
impl VethFixture {
|
|
async fn try_create() -> Option<Self> {
|
|
// Skip loud-free: the coverage task documents the host boundary.
|
|
let created = ip_cmd(&[
|
|
"link", "add", "alktx", "type", "veth", "peer", "name", "alktx-p",
|
|
])
|
|
.await
|
|
.unwrap_or(false);
|
|
if !created {
|
|
eprintln!("skipping: veth fixture unavailable (non-root host)");
|
|
return None;
|
|
}
|
|
let _ = ip_cmd(&["addr", "add", "10.240.0.1/24", "dev", "alktx"]).await;
|
|
// A jumbo MTU (65536): a 60 KB datagram crosses as ONE frame —
|
|
// no IP fragmentation, so no fragment-reassembly races at the
|
|
// peer (fragment-ID collisions under the drain burst merged
|
|
// datagrams on an MTU-1500 veth during the probe runs).
|
|
let _ = ip_cmd(&["link", "set", "alktx", "mtu", "65535"]).await;
|
|
let _ = ip_cmd(&["link", "set", "alktx", "up"]).await;
|
|
let _ = ip_cmd(&["link", "set", "alktx-p", "up"]).await;
|
|
Some(Self {
|
|
peer_addr_assigned: false,
|
|
})
|
|
}
|
|
|
|
fn assign_peer(&mut self) -> bool {
|
|
if self.peer_addr_assigned {
|
|
return true;
|
|
}
|
|
let ok = std::process::Command::new("ip")
|
|
.args(["addr", "add", "10.240.0.9/24", "dev", "alktx-p"])
|
|
.output()
|
|
.ok()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
|| std::process::Command::new("sudo")
|
|
.args(["ip", "addr", "add", "10.240.0.9/24", "dev", "alktx-p"])
|
|
.output()
|
|
.ok()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false);
|
|
self.peer_addr_assigned = ok;
|
|
ok
|
|
}
|
|
}
|
|
|
|
impl Drop for VethFixture {
|
|
fn drop(&mut self) {
|
|
let _ = std::process::Command::new("ip")
|
|
.args(["link", "del", "alktx"])
|
|
.output();
|
|
let _ = std::process::Command::new("sudo")
|
|
.args(["ip", "link", "del", "alktx"])
|
|
.output();
|
|
}
|
|
}
|
|
|
|
/// A sender socket blocked on the unresolved neighbor: connect to the
|
|
/// UNASSIGNED peer address, flush the neighbor table, shrunken
|
|
/// SO_SNDBUF. `try_send` on a 60 KB datagram (truesize beyond the
|
|
/// buffer) then reports WouldBlock/ENOSPC persistently — the state
|
|
/// the write-side machinery is for. Returns the tokio socket ready
|
|
/// for `UdpHalf::split`.
|
|
async fn blocked_sender_socket(peer_port: u16) -> Option<tokio::net::UdpSocket> {
|
|
// The BLOCK-STATE PROBE runs on a THROWAWAY socket: an oversized
|
|
// probe datagram's partially-built skb chain stays corked in
|
|
// sk_write_queue on EAGAIN (the kernel flushes it on the next
|
|
// successful send) — poisoning the socket with a stale partial
|
|
// datagram. The test socket below is created fresh afterwards.
|
|
let probe = socket2::Socket::new(
|
|
socket2::Domain::IPV4,
|
|
socket2::Type::DGRAM,
|
|
Some(socket2::Protocol::UDP),
|
|
)
|
|
.ok()?;
|
|
probe.set_nonblocking(true).ok()?;
|
|
probe.set_send_buffer_size(32768).ok()?;
|
|
probe
|
|
.bind(&"10.240.0.1:0".parse::<std::net::SocketAddr>().ok()?.into())
|
|
.ok()?;
|
|
let probe_std: std::net::UdpSocket = probe.into();
|
|
let probe_sock = tokio::net::UdpSocket::from_std(probe_std).ok()?;
|
|
probe_sock.connect(("10.240.0.9", peer_port)).await.ok()?;
|
|
|
|
// Confirm the block actually holds (the host invariant): with a
|
|
// 64 KiB SO_SNDBUF, the FIRST 60 KB datagram queues on the
|
|
// unresolved neighbor (Ok — its skb is charged to wmem); every
|
|
// SUBSEQUENT send EAGAINs (the charge exceeds the buffer) until
|
|
// the neighbor resolves. That persistent EAGAIN/ENOBUFS pair IS
|
|
// the block state. A resolved neighbor would send Ok throughout.
|
|
let big = vec![0u8; 60000];
|
|
let mut blocked = 0;
|
|
for _ in 0..3 {
|
|
match probe_sock.try_send(&big) {
|
|
Ok(_) => {} // the first datagram queues; expected
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => blocked += 1,
|
|
Err(e) if e.raw_os_error() == Some(105) => blocked += 1,
|
|
Err(_) => return None,
|
|
}
|
|
}
|
|
if blocked < 1 {
|
|
return None;
|
|
}
|
|
drop(probe_sock);
|
|
|
|
// The test socket: fresh (no corked state), same posture.
|
|
let sock = socket2::Socket::new(
|
|
socket2::Domain::IPV4,
|
|
socket2::Type::DGRAM,
|
|
Some(socket2::Protocol::UDP),
|
|
)
|
|
.ok()?;
|
|
sock.set_nonblocking(true).ok()?;
|
|
sock.set_send_buffer_size(32768).ok()?;
|
|
sock.bind(&"10.240.0.1:0".parse::<std::net::SocketAddr>().ok()?.into())
|
|
.ok()?;
|
|
let snd_std: std::net::UdpSocket = sock.into();
|
|
let snd = tokio::net::UdpSocket::from_std(snd_std).ok()?;
|
|
snd.connect(("10.240.0.9", peer_port)).await.ok()?;
|
|
Some(snd)
|
|
}
|
|
|
|
/// THE sender-block machinery, end to end (option 1 — one test, the
|
|
/// coherent behavior): the WouldBlock stash (`poll_write`'s
|
|
/// opportunistic drain stashes the mid-send datagram and registers
|
|
/// the send waker — `pump_queue_to_socket`'s Pending arms), the
|
|
/// accepted-byte high-water backpressure (`poll_write` Pends when the
|
|
/// queue stays over [`alktunnels::local::WRITE_QUEUE_HIGH_WATER`]),
|
|
/// the flush re-pump (readiness → drain), and the post-resolve
|
|
/// delivery (the queued datagrams reach the peer).
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn udp_half_write_side_blocks_stashes_and_drains_on_resolve() {
|
|
let Some(mut veth) = VethFixture::try_create().await else {
|
|
eprintln!("skipping: veth fixture unavailable (non-root host)");
|
|
return;
|
|
};
|
|
|
|
// The peer port is EPHEMERAL (stale datagrams from earlier runs
|
|
// of this test family target any fixed port; an ephemeral port
|
|
// sidesteps the leftovers).
|
|
let probe = tokio::net::UdpSocket::bind(("10.240.0.1", 0))
|
|
.await
|
|
.expect("port probe bind");
|
|
let peer_port = probe.local_addr().expect("port probe addr").port();
|
|
drop(probe);
|
|
let snd = blocked_sender_socket(peer_port)
|
|
.await
|
|
.expect("blocked sender state");
|
|
let (_read, mut write) = alktunnels::local::UdpHalf::split(snd);
|
|
|
|
// Phase A: poll_write ACCEPTS everything (the accept-all
|
|
// contract) while the socket blocks; the queued bytes cross the
|
|
// high-water line and the next poll_write Pends.
|
|
// Drive single-shot poll_write polls with a noop waker (the
|
|
// pump's copy shape): each returns Ok (accepted) or Pending
|
|
// (the high-water). The neighbor's INCOMPLETE→FAILED cycle
|
|
// drops queued skbs periodically (charge released → sends
|
|
// succeed again), so the drive loops until a Pend is observed.
|
|
let mut body = vec![0xABu8; 60002];
|
|
body[0] = 0; // len = 60000 BE
|
|
body[1] = 0xea;
|
|
body[2] = 0x60;
|
|
|
|
let noop_waker = futures::task::noop_waker();
|
|
let mut cx = std::task::Context::from_waker(&noop_waker);
|
|
|
|
let mut pending_observed = false;
|
|
for _ in 0..60 {
|
|
let fut = std::future::poll_fn(|cx| {
|
|
tokio::io::AsyncWrite::poll_write(Pin::new(&mut write), cx, &body)
|
|
});
|
|
let mut fut = std::pin::pin!(fut);
|
|
match std::future::Future::poll(fut.as_mut(), &mut cx) {
|
|
std::task::Poll::Ready(Ok(60002)) => {} // accepted
|
|
std::task::Poll::Ready(Ok(n)) => panic!("poll_write accepted {n}?"),
|
|
std::task::Poll::Ready(Err(e)) => panic!("poll_write error: {e:?}"),
|
|
std::task::Poll::Pending => {
|
|
pending_observed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
pending_observed,
|
|
"the high-water Pends once the queue rides over the line"
|
|
);
|
|
|
|
// Flush: the blocked socket → the stash + readiness-Pending arms
|
|
// (bounded retries; the resolve below unblocks the LAST one).
|
|
let mut pending_flushes = 0usize;
|
|
for _ in 0..10 {
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_millis(300),
|
|
poll_flush_once(&mut write),
|
|
)
|
|
.await
|
|
{
|
|
Err(_elapsed) => {
|
|
pending_flushes += 1;
|
|
} // readiness stayed Pending (blocked socket)
|
|
Ok(Ok(())) => break, // drained (only possible after resolve)
|
|
Ok(Err(e)) => panic!("flush error: {e:?}"),
|
|
}
|
|
}
|
|
assert!(pending_flushes > 0, "the blocked socket Pends the flush");
|
|
|
|
// Phase B: resolve — assign the peer address (the neighbor
|
|
// completes), bind the peer, wait for ARP; the queued datagrams
|
|
// drain and DELIVER.
|
|
assert!(veth.assign_peer(), "assign the peer address");
|
|
let peer = tokio::net::UdpSocket::bind(("10.240.0.9", peer_port))
|
|
.await
|
|
.expect("bind peer (resolve)");
|
|
|
|
let mut flushed = false;
|
|
for _ in 0..20 {
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_millis(300),
|
|
poll_flush_once(&mut write),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(())) => {
|
|
flushed = true;
|
|
break;
|
|
}
|
|
Ok(Err(e)) => panic!("post-resolve flush error: {e:?}"),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
assert!(flushed, "the queued datagrams drained after the resolve");
|
|
|
|
// The peer received the queued datagrams (delivery across the
|
|
// blocked→resolved window — the stash/queue never lost them; at
|
|
// least the stashed tail must arrive — the periodic
|
|
// INCOMPLETE→FAILED drops may consume some queued ones, which
|
|
// is kernel-side loss, not adapter loss).
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
let mut got = 0usize;
|
|
let mut buf = vec![0u8; 65535];
|
|
while peer.try_recv(&mut buf).is_ok() {
|
|
got += 1;
|
|
}
|
|
assert!(
|
|
got >= 1,
|
|
"the stashed/queued datagrams delivered after resolve"
|
|
);
|
|
|
|
drop(peer);
|
|
}
|