test: coverage weak spots — the UdpHalf sender-block recipe found (option 1)

- DatagramReader::feed is infallible (the oversize bound is enforced
  at frame time; the same zombie category as 4014ba6's InvalidLength).
  The Vec<Result> shape, the local adapter's dead InvalidData mapping,
  and consumer.rs's `?` drop.
- The sender-block machinery test (root-gated veth fixture, auto-skip
  without root): the peer address unassigned + the neighbor table
  flushed pins the connected sender in a persistent WouldBlock/ENOBUFS
  state; the test drives the WouldBlock stash, the readiness-Pending
  arms, AND the accepted-byte high-water Pend in one behavior, then
  resolves (addr assign + peer bind) and asserts the queued datagrams
  drain and deliver. Jumbo MTU (65535) keeps IP fragmentation out of
  the way; the block-state probe runs on a throwaway socket (an
  oversized probe's partially-built skb chain stays corked on EAGAIN
  and poisons the socket).
- pump_queue_to_socket's stash arm now matches ENOBUFS(105) alongside
  WouldBlock — the blocked-neighbor state reports 105, and both mean
  "the socket cannot take this datagram now".
- connect_udp's resolve-failure arm covered (an RFC 6761 .invalid
  target maps to dial_failed; rootless).
- The no-witness tunnel_establisher wrapper covered (a harness
  DialNoWitness mode + a full open round-trip through the wrapper).

Negative result pinned in the task doc: tokio 1.53 structurally masks
UDP ICMP errors out of the poll drive (poll_recv_ready/try_io mask
ERROR out of the read direction), so the recv-side ECONNREFUSED
mapping arm is unreachable through the pump's drive — it joins the
defensive list.

Coverage: 92.86% -> 93.91% lines (50 -> 41 uncovered, all deliberate).

Verification: cargo test + cargo test --all-features (91 tests),
clippy --all-targets --all-features, fmt, wasm32 check+clippy,
cargo doc — all clean.
This commit is contained in:
2026-09-09 08:52:23 +00:00
parent 8f14fd1913
commit 63dbdb64a5
8 changed files with 538 additions and 152 deletions

View File

@@ -381,7 +381,7 @@ async fn read_one_datagram<R: AsyncRead + Unpin>(
Ok(None)
};
}
let mut dgs = reader.feed(&chunk[..n])?.into_iter();
let mut dgs = reader.feed(&chunk[..n]).into_iter();
if let Some(first) = dgs.next() {
return Ok(Some((first, dgs.collect())));
}

View File

@@ -395,13 +395,10 @@ impl tokio::io::AsyncWrite for UdpHalf {
// construction (the u16 cap — review 001 N-2), so accepting
// everything is bounded memory; socket-side backpressure is
// the accepted-byte high-water mark above.
let dgs = match this.deframe.reader.feed(buf) {
Ok(dgs) => dgs,
Err(e) => {
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
}
};
for dg in dgs {
// The de-framer's feed is infallible (the oversize bound is
// enforced at frame time — see the DatagramCodecError docs):
// accept everything, bounded by the u16 cap (review 001 N-2).
for dg in this.deframe.reader.feed(buf) {
this.deframe.accepted += dg.len() + 2;
this.deframe.queue.push_back(dg);
}
@@ -465,7 +462,14 @@ impl UdpHalf {
Ok(_) => {
self.deframe.accepted -= dg.len() + 2;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
// WouldBlock (EAGAIN) and ENOBUFS both mean "the socket
// cannot take this datagram now" — the unresolved-
// neighbor + shrunken-SNDBUF state reports ENOBUFS(105);
// the stash + waker retry is the same behavior for both.
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.raw_os_error() == Some(105) =>
{
self.deframe.sending = Some(dg);
// Register the waker for the retry.
return match self.sock.poll_send_ready(cx) {

View File

@@ -37,6 +37,11 @@ const DATAGRAM_LEN_FIELD: usize = 2;
/// (`is_mid_datagram` is the diagnostic) and the SESSION surfaces a
/// stream end mid-datagram as `TunnelIoError::TruncatedDatagram` (the
/// fail-loud posture, OQ-TN-13 — never a silent partial datagram).
///
/// The decode direction (`DatagramReader::feed`) is infallible: with
/// the oversize bound enforced at frame time, a declared length can
/// never exceed what the frame itself carries, so the incremental
/// decode cannot fail — only buffer and complete.
#[derive(Debug, Error)]
pub enum DatagramCodecError {
/// A datagram larger than [`MAX_DATAGRAM_LEN`] was framed — the
@@ -89,8 +94,10 @@ impl DatagramReader {
/// Feed raw bytes from the read half. Returns every datagram that
/// completed with this feed, in order — zero or more per chunk.
/// Buffers partial frames across chunk boundaries.
pub fn feed(&mut self, chunk: &[u8]) -> Result<Vec<Bytes>, DatagramCodecError> {
/// Buffers partial frames across chunk boundaries. Infallible: the
/// decode direction cannot fail (see the [`DatagramCodecError`]
/// docs — the oversize bound is enforced at frame time).
pub fn feed(&mut self, chunk: &[u8]) -> Vec<Bytes> {
self.buf.extend_from_slice(chunk);
let mut out = Vec::new();
loop {
@@ -123,7 +130,7 @@ impl DatagramReader {
break;
}
}
Ok(out)
out
}
/// `true` while a partial datagram is in flight (for teardown
@@ -140,7 +147,7 @@ mod tests {
use super::*;
fn feed_one(reader: &mut DatagramReader, chunk: &[u8]) -> Option<Bytes> {
reader.feed(chunk).unwrap().into_iter().next()
reader.feed(chunk).into_iter().next()
}
#[test]
@@ -148,7 +155,7 @@ mod tests {
let framed = frame_datagram(b"hello").unwrap();
assert_eq!(&framed[..2], &[0, 5]);
let mut r = DatagramReader::new();
let dgs = r.feed(&framed).unwrap();
let dgs = r.feed(&framed);
assert_eq!(dgs.len(), 1);
assert_eq!(&dgs[0][..], b"hello");
}
@@ -158,7 +165,7 @@ mod tests {
let framed = frame_datagram(b"").unwrap();
assert_eq!(framed.len(), 2);
let mut r = DatagramReader::new();
let dgs = r.feed(&framed).unwrap();
let dgs = r.feed(&framed);
assert_eq!(dgs.len(), 1);
assert!(dgs[0].is_empty());
}
@@ -168,7 +175,7 @@ mod tests {
let framed = frame_datagram(b"0123456789").unwrap();
let mut r = DatagramReader::new();
for piece in framed.chunks(7) {
let dgs = r.feed(piece).unwrap();
let dgs = r.feed(piece);
if !dgs.is_empty() {
assert_eq!(&dgs[0][..], b"0123456789");
return;
@@ -183,7 +190,7 @@ mod tests {
buf.extend_from_slice(&frame_datagram(b"abc").unwrap());
buf.extend_from_slice(&frame_datagram(b"de").unwrap());
let mut r = DatagramReader::new();
let dgs = r.feed(&buf.freeze()).unwrap();
let dgs = r.feed(&buf.freeze());
assert_eq!(dgs.len(), 2);
assert_eq!(&dgs[0][..], b"abc");
assert_eq!(&dgs[1][..], b"de");
@@ -202,9 +209,9 @@ mod tests {
fn mid_datagram_state_is_observable() {
let framed = frame_datagram(b"0123").unwrap();
let mut r = DatagramReader::new();
r.feed(&framed[..3]).unwrap();
r.feed(&framed[..3]);
assert!(r.is_mid_datagram());
r.feed(&framed[3..]).unwrap();
r.feed(&framed[3..]);
assert!(!r.is_mid_datagram());
}
@@ -218,12 +225,12 @@ mod tests {
fn truncated_stream_never_yields_partial_datagram() {
let framed = frame_datagram(b"payload").unwrap();
let mut r = DatagramReader::new();
r.feed(&framed[..3]).unwrap();
r.feed(&framed[..3]);
assert!(r.is_mid_datagram());
assert!(feed_one(&mut r, &[]).is_none());
assert!(r.is_mid_datagram());
assert_eq!(
r.feed(&framed[3..framed.len() - 1]).unwrap().len(),
r.feed(&framed[3..framed.len() - 1]).len(),
0,
"one byte short must stay buffered, not yield a partial datagram"
);

View File

@@ -1,7 +1,7 @@
---
id: tunnels/coverage-weak-spots
name: "Coverage weak spots — the UdpHalf sender-blocking paths + defensive telemetry arms (2026-09-09 review residue)"
status: pending
name: "Coverage weak spots — CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered"
status: done
depends_on: [tunnels/review-impl]
scope: single
risk: low
@@ -10,139 +10,127 @@ level: implementation
tags: [tests, coverage, udp, review-remediation]
---
## Description
## Resolution (2026-09-09, second pass)
The 2026-09-09 coverage review (cargo-llvm-cov 0.8.4, `--all-features`)
started the session at 88.28% line coverage. The inline remediation
(commit with this task file) closed the behavioral gaps — datagram
EOF paths, UDP `take_halves` framing, reverse UDP `pump_against`,
`AcceptQueue` push-after-close, the `establishment_reason` wrapper —
and removed the zombie `DatagramCodecError::InvalidLength` variant.
Coverage is now **92.86% lines / 93 regions missed → 93**; the
remaining 50 uncovered lines are inventoried below. None are
behavioral gaps reachable through a normal peer — they split into
(a) paths this host's kernel cannot reach, (b) defensive arms that
are structurally unreachable through the real dispatch, and (c) two
thin public-API wrappers.
The 2026-09-09 first pass (commits `4014ba6`, `8f14fd1`) closed the
behavioral gaps and left this task with the option-1-vs-3 decision.
The second pass found the sender-block recipe, took **option 1**, and
coverage moved **92.86% → 93.91% lines** (50 → 41 uncovered). Three
commits:
## The host-blocking finding (why the UdpHalf paths stay uncovered)
1. **`DatagramReader::feed` is infallible** — the same zombie category
as `4014ba6`'s `InvalidLength`: with the oversize bound enforced at
frame time, the incremental decode cannot fail, so the `Vec<Result>`
shape and `local/mod.rs`'s dead `InvalidData` mapping were removed
(pre-consumer window open per ADR-005's N-3).
2. **The sender-block machinery test** (`udp_half_write_side_blocks_
stashes_and_drains_on_resolve`) — the recipe that reached the
WouldBlock stash, the readiness-Pending arms, AND the high-water
backpressure in one coherent behavior (option 1).
3. **The thin wrappers** — `connect_udp`'s resolve-failure arm
(`.invalid` NXDOMAIN → `dial_failed`) and the no-witness
`tunnel_establisher` (a harness `DialNoWitness` mode + a full
open round-trip through the wrapper).
`UdpHalf`'s write-side sender-blocking machinery needs a UDP socket
whose `try_send` returns `WouldBlock`. Probed exhaustively
2026-09-09 (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5):
## The sender-block recipe (the host-blocking finding, RESOLVED)
- **Loopback / veth (even with netem `limit 10` qdisc):** a
non-reading peer's full receive queue DROPS datagrams silently
(freeing the sender's skb), so `sk_wmem_alloc` never fills and the
sender never blocks. SO_SNDBUF shrinking (min floor 4608) does not
change this — the send path frees the skb synchronously.
- **`socket2` SO_SNDBUF:** std has no UDP buffer setters; socket2
works but the accounting issue above makes it moot.
- **The "fresh-socket quirk":** a socket's FIRST `try_send` on a
freshly registered tokio socket reports `WouldBlock`
spuriously (~20% of runs, multi-thread runtime); exactly ONE
`poll_send_ready` + retry clears it (100% of 45/200 observed
firings). Nondeterministic across runs — used only as a tolerated
timing quirk in the flush tests, never asserted.
- **current-thread runtime + socket2 socket:** datagrams sent `Ok`
were not delivered to a bound peer (kernel probe showed
`NoPorts` increments). Delivery assertions are only trustworthy on
a multi-thread runtime — the two flush/drain tests pin that flavor
explicitly.
`UdpHalf`'s write-side machinery needed a UDP socket whose `try_send`
persistently reports `WouldBlock`/`ENOBUFS`. The recipe (validated
3/3 deterministic on this host, kernel 6.8.0-110-generic, tokio
1.53.1, socket2 0.6.5):
Unreachable as a result (the WouldBlock stash, the 128 KiB
high-water `Pending`, the `poll_recv_ready`/`poll_send_ready`
Pending arms):
- **A veth pair with the peer address UNASSIGNED + a flushed
neighbor table.** The connected sender's datagrams queue on the
unresolved neighbor while charged to `sk_wmem_alloc`; with a
shrunken `SO_SNDBUF` the charge exceeds the buffer → persistent
`EAGAIN`/`ENOBUFS` (the drain reports 105=ENOBUFS alongside
EAGAIN — both mean "the socket cannot take this datagram now",
which the `pump_queue_to_socket` stash arm now matches too).
- **Jumbo MTU (65535)** on the fixture: 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; the peer reassembled 5-6 datagrams
into single ~43947-byte payloads).
- **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 and flushes on the next successful send —
poisoning the socket with a stale partial datagram. The test's
real socket is created fresh after the probe.
- **Assigning the peer address + binding the peer** resolves ARP; the
queued/stashed datagrams drain and DELIVER (the resolve step).
- Root-gated (`ip link add`): the fixture tries bare `ip` then
`sudo ip` and auto-skips loud-free without either.
- `src/local/mod.rs:377-383` — the accepted-byte high-water
backpressure (`WRITE_QUEUE_HIGH_WATER`): `poll_write` drains
first and Pends when the queue stays over the line. Needs a
genuinely blocking sender.
- `src/local/mod.rs:351,472` — the readiness-Pending arms inside the
recv/send WouldBlock handlers.
The first pass's kernel findings still hold and are why the loopback
recipes failed there: a non-reading peer's full receive queue DROPS
datagrams silently (loopback/veth/netem — the sender never blocks);
netem with `limit` still did not block (2M sends Ok in a probe).
## Negative result: the ICMP ECONNREFUSED arm is unreachable through the poll drive
A connected UDP socket + a closed peer port elicits an ICMP port
unreachable that `ECONNREFUSED`s the next `recvfrom` (a plain std
nonblocking recv surfaces it deterministically — probed rootless).
But **tokio 1.53 structurally masks it out of the poll drive** (the
adapter's shape): the `EPOLLOUT|EPOLLERR` event maps to
`WRITABLE|ERROR|WRITE_CLOSED`; `poll_recv_ready`'s Direction::Read
mask is `READABLE|READ_CLOSED` (ERROR excluded) → Pending forever;
`try_io(READABLE)` intersects empty → WouldBlock. Only the async
`recv()` (`async_io` with `READABLE|ERROR`) surfaces `ECONNREFUSED`
— and `UdpHalf::poll_read` is a poll impl. Verified by probes
(`poll_recv`, `poll_recv_ready`, `try_recv` all Elapsed; the async
`recv` surfaced it exactly once, racing the waiter registration —
nondeterministic). `local/mod.rs:356` therefore joins the defensive
list: no deterministic test can reach it through the pump's drive on
tokio 1.53.
## The final uncovered-line inventory (41 lines, all deliberate)
- `src/local/mod.rs:136-137` — the no-addresses resolve arm: a name
that RESOLVES to zero addresses is unreachable via getaddrinfo
(NXDOMAIN errors first — the `.invalid` test hits the error arm).
- `src/local/mod.rs:321-333` — the `Oversize` mapping at the recv
scratch (a >65535-byte datagram cannot exist on a real wire — the
IP theoretical max is 65507).
## Options to close them (when picked up)
1. **Netns + real qdisc** (needs root): a network namespace with a
veth pair whose egress qdisc is netem/tbf with a small `limit`
gives a genuine holding queue. Probe result 2026-09-09: netem on
veth egress still did NOT block the sender (the qdisc's skb hold
freed fast enough) — try `tbf` with a tiny rate + burst, or
`fq_codel` with `memory_limit`, or the loopback peer with a
shrunken SO_RCVBUF AND `net.core.rmem_max` raised so drops turn
into queueing. Requires root (available on this host).
2. **LD_PRELOAD fault injector / libc shim** on `sendto` returning
`EWOULDBLOCK` once — deterministic, but test-infra heavy and
platform-specific.
3. **Accept the gap + document** (current posture): the invariants
the paths implement are pinned by review 001's U-1 remediation
and ADR-003's amendment; a reviewer reading
`src/local/mod.rs` can verify the code by inspection, and the
host probe results above are recorded here. The `sending` stash
gets partial exercise whenever the fresh-socket quirk fires in
the flush tests (not asserted).
## The defensive arms (deliberately uncovered)
- `src/local/mod.rs:351-352,356` — the recv-side readiness-Pending
arms (genuine WouldBlock) + the ICMP error mapping (the negative
result above).
- `src/local/mod.rs:378-379,383` — the high-water drain's
Ready(Ok)/Ready(Err) sub-arms (the observed over-line poll rides
the Pending sub-arm; the Ok sub-arm needs the drain to succeed
within that single poll — the FAILED-cycle release timing).
- `src/local/mod.rs:476-477` — `pump_queue_to_socket`'s
`poll_send_ready` Ready(Ok)/Err sub-arms (the Pend sub-arm is the
covered one).
- `src/consumer.rs:91-94,405-406` — `serde_json::to_value` failures
on `TunnelParams` (impossible by construction; the error mapping
exists for the type system).
- `src/consumer.rs:205,290``unreachable!` guards on spent
sessions (typed-API invariants).
- `src/consumer.rs:285` — the stale-pump abort in `pump_against`
(data-plane XOR pump invariant; defensive only).
on `TunnelParams` (impossible by construction).
- `src/consumer.rs:205,290` — `unreachable!` guards on spent sessions.
- `src/consumer.rs:285` — the stale-pump abort (defensive only).
- `src/consumer.rs:325-330` — the pump `JoinError` telemetry arm
(needs the spawned pump task to panic/abort — not producible
through the session API).
- `src/producer.rs:338-347` — the pump-handler telemetry
early-returns (`accept_bi` failure needs a dead channel; the plan
is always `TargetHandle` by construction; `Arc::try_unwrap` failure
needs a second clone).
- `src/producer.rs:85-86``TunnelEstablishError::HandlerError`
`EstablishmentError::HandlerError` mapping: `parse_params` is the
only producer of `HandlerError`, and the registry's input-schema
gate (pinned by `schema_gate_rejects_missing_substrate_…`) rejects
malformed params before the establisher runs. The mapping is
spec-surface (ADR-049 §3's vocabulary) kept for API completeness.
- `src/wire.rs:77-79` — the `Default` impl shim (clippy
`new_without_default` requirement).
- `src/wire.rs:106` — the `unreachable!` invariant in `feed`'s
decode loop.
(needs the spawned pump to panic/abort — not producible through
the session API).
- `src/producer.rs:85-86` — the `HandlerError` mapping (the schema
gate preempts it; pinned by `schema_gate_rejects_missing_substrate_…`).
- `src/producer.rs:338-347` — the pump-handler telemetry early-returns
(`accept_bi` failure needs a dead channel; the plan is always
`TargetHandle`; `Arc::try_unwrap` needs a second clone).
- `src/wire.rs:77-79` — the `Default` impl shim (clippy's
`new_without_default`).
- `src/wire.rs:106` — the `unreachable!` invariant in `feed`'s loop.
- `src/wire.rs:177` — a unit test's own panic line (the negative
branch of `datagram_split_across_awkward_chunks_reassembles`).
## Thin public-API wrappers (trivial to cover if wanted)
- `src/producer.rs:152-154``tunnel_establisher` (the
no-witness wrapper; tests use `tunnel_establisher_with_witness`
directly).
- `src/producer.rs:170` — the `witness: None` closure arm of
`tunnel_establisher_with_witness` (the witness-less establisher
body). One registration + open through `tunnel_establisher`
covers both.
## Work
- Decide: chase the sender-blocking paths with netns/tbf/qdisc
experiments (option 1) or accept the host-boundary limitation
(option 3, current posture).
- If option 1: one test forcing a real `WouldBlock` on a UDP sender
covers lines 351, 377-383, 472 in one go (the stash + high-water +
readiness-Pending machinery is one coherent behavior).
- Optionally cover the two thin wrappers (one test each, or fold
into existing suites).
- The defensive arms stay uncovered by design — do not contort the
tests to reach them; re-check this inventory if the API shapes
change.
## Verification
## Verification (2026-09-09, second pass)
- `cargo llvm-cov --all-features --workspace --summary-only`
the post-remediation baseline (92.86% lines) should not regress.
- `cargo test --all-features`88 tests green as of 2026-09-09
(15 lib + 16 consumer_session + 18 end_to_end + 13 doc/lib +
17 local_halves + 9 producer_listen + 12 producer_open_op + 1
doc-test compile_fail).
**93.91% lines** (from 92.86%; 50 → 41 uncovered, all deliberate).
- `cargo test --all-features` — **91 tests green** (15 lib + 16
consumer_session + 18 end_to_end + 13 doc/lib + 19 local_halves
+ 9 producer_listen + 13 producer_open_op + 1 doc-test compile_fail).
- `cargo test` (default) — green; the root-gated veth test skips
loud-free without root.
- `cargo clippy --all-targets --all-features -- -D warnings` and
`cargo fmt --check` — clean.
- `cargo check --target wasm32-unknown-unknown` +
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` —
clean (the default crate stays wasm-clean).

View File

@@ -714,7 +714,7 @@ async fn datagrams_survive_chunk_splitting_codec_level() {
let mut reader = alktunnels::wire::DatagramReader::new();
let mut recovered = Vec::new();
for chunk in stream.chunks(7) {
for dg in reader.feed(chunk).expect("decode") {
for dg in reader.feed(chunk) {
recovered.push(dg.to_vec());
}
}

View File

@@ -102,6 +102,9 @@ pub enum RegistrationMode {
/// probe — ADR-049 §2's bound override; the open fails with reason
/// `timeout` and no channel survives).
Timeout(std::time::Duration),
/// The no-witness dial establisher (`tunnel_establisher` — the
/// public default shape, no CF-006 probe seam).
DialNoWitness(alktunnels::producer::DialFn),
}
pub async fn wire_with(
@@ -219,6 +222,27 @@ pub async fn wire_with(
)
.expect("register tunnel listen openable");
}
RegistrationMode::DialNoWitness(dial) => {
// The public no-witness wrapper (tunnel_establisher) —
// registration + establisher body identical to the
// witnessed variant minus the CF-006 seam.
let core = alkcall::channels::operations::ChannelCore::new(
producer_client.manager().clone(),
alkcall::channels::policy::default_policy(),
);
core.register_openable_with_establisher(
alktunnels::params::tunnel_open_spec(),
Some(alktunnels::producer::tunnel_establisher(
registry.clone(),
dial,
)),
alktunnels::producer::make_tunnel_pump_handler(),
&producer_op_registry,
AuthContext::anonymous(b"alk/tunnel"),
None,
)
.expect("register the no-witness wrapper establisher");
}
RegistrationMode::Timeout(timeout) => {
// The deadline-expiry probe's registration shape: a hanging
// establisher (never resolves) with an explicit per-
@@ -527,10 +551,7 @@ pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::D
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let dgs = match reader.feed(&buf[..n]) {
Ok(dgs) => dgs,
Err(_) => return,
};
let dgs = reader.feed(&buf[..n]);
if let Some(dg) = dgs.into_iter().next() {
let framed = match alktunnels::wire::frame_datagram(&dg) {
Ok(f) => f,
@@ -604,10 +625,7 @@ pub fn framed_udp_echo_dial() -> alktunnels::producer::DialFn {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let dgs = match reader.feed(&buf[..n]) {
Ok(dgs) => dgs,
Err(_) => return,
};
let dgs = reader.feed(&buf[..n]);
for dg in dgs {
let framed = match alktunnels::wire::frame_datagram(&dg) {
Ok(f) => f,

View File

@@ -871,3 +871,328 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
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);
}

View File

@@ -384,6 +384,50 @@ async fn registry_lookup_respects_substrate_key() {
assert_eq!(establishment_reason_of(&err), Some("unknown_resource"));
}
#[tokio::test]
async fn tunnel_establisher_no_witness_wrapper_opens() {
// The no-witness wrapper (`tunnel_establisher` — the public
// default shape, producer.rs's witness-less establisher body):
// a full open through the wrapper round-trips. The wrapper
// differs from `tunnel_establisher_with_witness` ONLY in the
// witness (the CF-006 probe seam), so the default-registration
// shape is exercised end to end here.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire_with(
registry,
harness::RegistrationMode::DialNoWitness(echo_dial()),
Some(harness::consumer_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let out = call_open_params(&topo, &params("echo"), None)
.await
.expect("the wrapper establisher opens");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
let (mut read, mut write, pump) = adopt_and_pump(&topo, channel_id).await;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
write.write_all(b"no-witness").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"no-witness");
drop(write);
drop(read);
tokio::time::timeout(std::time::Duration::from_secs(5), pump)
.await
.expect("pump completion")
.expect("pump task");
}
/// Type-shape assertions kept for harness parity.
#[allow(dead_code)]
fn type_assertions() {