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:
glm-5.3-flash committed 2026-09-08 21:22:52 +00:00
1 parent aa7b5b7169
commit 8cf4cc8bb5
7 files changed
+765 -106

No files matched your search

+308 -23
View File
@@ -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]