fix: UdpHalf is the FRAMED adapter (U-1) — U-2 bind posture, U-3 lazy scratch
Review 001 remediation (tasks/tunnels/fix-udp-framed-adapter): - U-1 [major]: UdpHalf now composes the ADR-003 codec at the substrate boundary — poll_write de-frames channel bytes (incremental DatagramReader) and sends each payload as one raw datagram; poll_read frames raw target datagrams and hands frame bytes to the pump. Targets see payload-only bytes; empty datagrams round-trip (the F-2 fix end-to-end); >8 KiB datagrams arrive as ONE datagram. Mechanics the verification gates forced beyond the sketch: poll_write accepts all offered bytes (tokio copy re-offers a full window unsatisfied — partial-frame holds deadlock >8 KiB frames), backpressure is an accepted-byte high-water mark, socket drain pumps on write/flush/shutdown; >caller-buffer frames stage across reads (the truncation probe's assertion surface updated per the re-validation correction). - U-2 [minor]: connect_udp resolves the target and binds the unspecified address of the matching family (v6 targets dial from [::]:0); bind_tcp gains the wildcard-exposure doc warning. - U-3 [minor]: the 65535 recv scratch allocates lazily on first recv (full-size read kept — try_recv truncates silently; the MTU-shrink idea confirmed unsafe in re-validation). - N-1: the module/connect_udp/UdpHalf docs now describe the composed codec truthfully. Gates (permanent tests in tests/local_halves.rs): recording-target suite (payload-only at the target, empty-datagram round-trip, 9000- byte single datagram, unframed-target resolution), the 65507-byte max-datagram staging probe, and the v6 dial gate. Docs: ADR-003 §Amendment (the boundary mechanics), wire.md truncation bullet, consumer.md adopt sketch (substrate param). Review 001 resolution notes on U-1/U-2/U-3/N-1; task marked completed. Verified: 63 default / 76 local tests ×3 repeat runs; clippy -D warnings (all-targets + wasm32), fmt --check, doc --no-deps (warning-free), wasm check, publish dry-run.
This commit is contained in:
1 parent
aa7b5b7169
commit
8cf4cc8bb5
7 files changed
+765
-106
No files matched your search
+308
-23
@@ -20,13 +20,6 @@ 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))
|
||||
@@ -190,10 +183,17 @@ async fn unix_dial_round_trips() {
|
||||
|
||||
#[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.
|
||||
// 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");
|
||||
@@ -210,24 +210,309 @@ async fn truncation_fails_loud_oq_tn_13() {
|
||||
.expect("connect b");
|
||||
|
||||
// The producer-side adapter (what the establisher dials): sock_a
|
||||
// wrapped as halves. Send a large datagram from the "wire" side.
|
||||
// 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![0u8; 60000]; // well-formed for UDP but big
|
||||
sock_b.send(&payload).await.expect("send big");
|
||||
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) — smaller
|
||||
// than 60000: the adapter must FAIL LOUD, not truncate.
|
||||
// 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];
|
||||
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:?}"),
|
||||
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]
|
||||
|
||||
Reference in new issue
Block a user