Compare commits
2
Commits
0bf4da3bb6
...
8f14fd1913
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f14fd1913 | ||
|
|
4014ba66a9 |
Generated
+1
@@ -55,6 +55,7 @@ dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -31,3 +31,4 @@ thiserror = "2"
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util", "macros"] }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
socket2 = "0.6"
|
||||
|
||||
+14
-15
@@ -317,21 +317,20 @@ impl AsyncRead for UdpHalf {
|
||||
.expect("scratch allocated above");
|
||||
match this.sock.try_recv(scratch) {
|
||||
Ok(n) => {
|
||||
let framed = frame_datagram(&scratch[..n]).map_err(|e| match e {
|
||||
// A >u16 datagram cannot exist on a well-formed
|
||||
// wire (the IP theoretical max is 65507); treat it
|
||||
// as the fail-loud framing violation it is.
|
||||
DatagramCodecError::Oversize(len, max) => std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"udp datagram {len} bytes exceeds the codec frame bound {max} \
|
||||
(truncation fails loud — OQ-TN-13)"
|
||||
),
|
||||
),
|
||||
DatagramCodecError::InvalidLength { .. } => {
|
||||
unreachable!("frame_datagram never yields InvalidLength")
|
||||
}
|
||||
})?;
|
||||
let framed = frame_datagram(&scratch[..n]).map_err(
|
||||
|DatagramCodecError::Oversize(len, max)| {
|
||||
// A >u16 datagram cannot exist on a well-formed
|
||||
// wire (the IP theoretical max is 65507); treat
|
||||
// it as the fail-loud framing violation it is.
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"udp datagram {len} bytes exceeds the codec frame bound {max} \
|
||||
(truncation fails loud — OQ-TN-13)"
|
||||
),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
if framed.len() <= buf.remaining() {
|
||||
buf.put_slice(&framed);
|
||||
} else {
|
||||
|
||||
@@ -188,6 +188,24 @@ mod tests {
|
||||
assert!(!jsonschema_valid(&schema, &substrate_bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn establishment_reason_reads_the_typed_surface() {
|
||||
use alkcall::protocol::wire::CallError;
|
||||
let open_failed = ChannelOpenError::CallFailed {
|
||||
error: CallError::new("channel:open_failed", "establishment failed", false)
|
||||
.with_details(json!({"reason": "dial_failed"})),
|
||||
};
|
||||
assert_eq!(establishment_reason(&open_failed), Some("dial_failed"));
|
||||
let non_establishment = ChannelOpenError::CallFailed {
|
||||
error: CallError::internal("forbidden"),
|
||||
};
|
||||
assert_eq!(establishment_reason(&non_establishment), None);
|
||||
let local_only = ChannelOpenError::AdoptFailed(
|
||||
alkcall::channels::manager::ManagerError::ChannelExists(7),
|
||||
);
|
||||
assert_eq!(establishment_reason(&local_only), None);
|
||||
}
|
||||
|
||||
fn jsonschema_valid(_schema: &serde_json::Value, value: &serde_json::Value) -> bool {
|
||||
let Ok(params) = serde_json::from_value::<TunnelParams>(value.clone()) else {
|
||||
return false;
|
||||
|
||||
+7
-10
@@ -32,20 +32,17 @@ pub const MAX_DATAGRAM_LEN: usize = u16::MAX as usize;
|
||||
|
||||
const DATAGRAM_LEN_FIELD: usize = 2;
|
||||
|
||||
/// Codec errors: oversize at frame time, and the truncated-stream
|
||||
/// error a mid-datagram stream end maps to (the fail-loud posture,
|
||||
/// OQ-TN-13 — never a silent partial datagram).
|
||||
/// Codec errors: oversize at frame time. The truncated-stream case is
|
||||
/// not a codec error — the decoder buffers partial frames indefinitely
|
||||
/// (`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).
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatagramCodecError {
|
||||
/// A datagram larger than [`MAX_DATAGRAM_LEN`] was framed — the
|
||||
/// u16 length field would wrap. Rejected at frame time.
|
||||
#[error("datagram too large for u16 length prefix: {0} bytes (max {1})")]
|
||||
Oversize(usize, usize),
|
||||
/// The stream ended mid-datagram (declared length not satisfied).
|
||||
/// A clean peer never ends the stream mid-datagram; this is a
|
||||
/// truncated-stream error, not a partial datagram.
|
||||
#[error("unexpected end of datagram buffer: declared {declared}, have {have}")]
|
||||
InvalidLength { declared: usize, have: usize },
|
||||
}
|
||||
|
||||
/// Frame one datagram into the chunk stream: `[len: u16 BE][payload]`.
|
||||
@@ -131,8 +128,8 @@ impl DatagramReader {
|
||||
|
||||
/// `true` while a partial datagram is in flight (for teardown
|
||||
/// diagnostics — a clean peer never ends the stream mid-datagram;
|
||||
/// an end while this is set is [`DatagramCodecError::InvalidLength`]'s
|
||||
/// condition).
|
||||
/// an end while this is set is the session's
|
||||
/// `TruncatedDatagram` condition).
|
||||
pub fn is_mid_datagram(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
id: tunnels/coverage-weak-spots
|
||||
name: "Coverage weak spots — the UdpHalf sender-blocking paths + defensive telemetry arms (2026-09-09 review residue)"
|
||||
status: pending
|
||||
depends_on: [tunnels/review-impl]
|
||||
scope: single
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [tests, coverage, udp, review-remediation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
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 host-blocking finding (why the UdpHalf paths stay uncovered)
|
||||
|
||||
`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):
|
||||
|
||||
- **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.
|
||||
|
||||
Unreachable as a result (the WouldBlock stash, the 128 KiB
|
||||
high-water `Pending`, the `poll_recv_ready`/`poll_send_ready`
|
||||
Pending arms):
|
||||
|
||||
- `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.
|
||||
- `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/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).
|
||||
- `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.
|
||||
- `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
|
||||
|
||||
- `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).
|
||||
@@ -23,7 +23,7 @@ pin.
|
||||
```rust
|
||||
pub const MAX_DATAGRAM_LEN: usize = u16::MAX as usize; // 65535
|
||||
|
||||
pub struct DatagramCodecError { .. } // thiserror: Oversize(usize), InvalidLength(...)
|
||||
pub struct DatagramCodecError { .. } // thiserror: Oversize(usize, usize) (planned shape; InvalidLength removed post-review — see Notes)
|
||||
|
||||
pub fn frame_datagram(payload: &[u8]) -> Result<Bytes, DatagramCodecError>
|
||||
// [len: u16 BE][payload]; len=0 is a legal empty datagram; >65535 = Oversize at frame time
|
||||
@@ -90,9 +90,11 @@ the POC proved no collision).
|
||||
invariant is load-bearing, so the module doc states why raw
|
||||
pass-through is structurally broken for UDP).
|
||||
- Error naming per the task's API: `Oversize(usize, usize)` (was
|
||||
POC's `TooLarge`) + `InvalidLength {declared, have}` (the
|
||||
truncated-stream shape — the OQ-TN-13 fail-loud posture's codec-side
|
||||
anchor).
|
||||
POC's `TooLarge`). (2026-09-09: the `InvalidLength` variant was
|
||||
REMOVED post-review — `feed` never returned it; the truncated-stream
|
||||
contract is `is_mid_datagram` + the session's `TruncatedDatagram`
|
||||
instead, per the coverage-review commit. A completed task doc
|
||||
records its planned shape, not corrections made after.)
|
||||
- `feed` returns `Vec<Bytes>` per the task API (the POC's
|
||||
`Vec<Datagram>` wrapper dropped — `Bytes` is the payload; the mux
|
||||
handoff is zero-copy via `BytesMut::freeze`).
|
||||
@@ -113,7 +115,8 @@ the POC proved no collision).
|
||||
|
||||
`src/wire.rs` complete: `frame_datagram` (Oversize at frame time,
|
||||
`len=0` legal), `DatagramReader` (incremental decoder, `is_mid_datagram`
|
||||
teardown diagnostic), `DatagramCodecError {Oversize, InvalidLength}` —
|
||||
teardown diagnostic), `DatagramCodecError {Oversize}` (originally
|
||||
sketched `{Oversize, InvalidLength}`; see the 2026-09-09 note above) —
|
||||
the ADR-003/BAST contract in executable form. 8 codec tests + 6 params
|
||||
tests = 14 passing. Verified: cargo test, clippy --all-targets -D
|
||||
warnings (native + wasm32), fmt --check, wasm32 check — all clean.
|
||||
+196
-1
@@ -17,7 +17,10 @@ use alktunnels::params::{Substrate, TunnelParams};
|
||||
use alktunnels::producer::ResourceRegistry;
|
||||
use alktunnels::{open_reverse_channel, TunnelSession};
|
||||
|
||||
use harness::{all_substrate_dial, echo_dial, framed_udp_echo_dial, wire, wire_forward, Topology};
|
||||
use harness::{
|
||||
all_substrate_dial, closing_udp_echo_dial, echo_dial, framed_udp_echo_dial, wire, wire_forward,
|
||||
Topology,
|
||||
};
|
||||
|
||||
fn params(resource: &str, substrate: Substrate) -> TunnelParams {
|
||||
TunnelParams {
|
||||
@@ -450,6 +453,198 @@ async fn oversize_datagram_rejected_at_frame_time() {
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn datagram_clean_eof_is_none_not_error() {
|
||||
// consumer.md's EOF contract for pump-less datagram sessions: a
|
||||
// clean stream EOF (the channels-level sentinel, mid-frame
|
||||
// state clear) resolves `Ok(None)` — the caller's shutdown
|
||||
// signal, distinct from both an empty datagram (`Some(b"")`,
|
||||
// F-2) and a truncated stream (`TruncatedDatagram`).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry.register("dns", Substrate::Udp, "in-process").await;
|
||||
let topo = wire_forward(registry, closing_udp_echo_dial(true)).await;
|
||||
|
||||
let mut session = TunnelSession::open(&topo.consumer, params("dns", Substrate::Udp))
|
||||
.await
|
||||
.expect("udp open");
|
||||
session.send_datagram(b"last").await.expect("send");
|
||||
|
||||
// The echo's far end drops after replying: the channel's read
|
||||
// half EOFs AFTER the datagram (clean boundary).
|
||||
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("io ok")
|
||||
.expect("the echoed datagram");
|
||||
assert_eq!(&dg[..], b"last");
|
||||
|
||||
let eof = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("eof recv timed out")
|
||||
.expect("clean EOF is not an io error");
|
||||
assert!(eof.is_none(), "clean EOF is Ok(None), got {eof:?}");
|
||||
|
||||
let (_, _, reaped) = session.join().await;
|
||||
assert!(reaped);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn datagram_mid_frame_eof_fails_loud_truncated() {
|
||||
// The fail-loud posture (OQ-TN-13): a stream end mid-datagram
|
||||
// (declared length not satisfied) is `TruncatedDatagram`, never a
|
||||
// silent partial datagram and never a clean `None`.
|
||||
let registry = ResourceRegistry::new();
|
||||
registry.register("dns", Substrate::Udp, "in-process").await;
|
||||
let topo = wire_forward(registry, closing_udp_echo_dial(false)).await;
|
||||
|
||||
let mut session = TunnelSession::open(&topo.consumer, params("dns", Substrate::Udp))
|
||||
.await
|
||||
.expect("udp open");
|
||||
session.send_datagram(b"cut").await.expect("send");
|
||||
|
||||
// The echo's far end forwards the frame but drops the connection
|
||||
// BEFORE the payload completes (the 2-byte length prefix rides;
|
||||
// the payload is cut — mid-datagram EOF).
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect_err("mid-datagram EOF must fail loud");
|
||||
assert!(
|
||||
matches!(err, alktunnels::TunnelIoError::TruncatedDatagram),
|
||||
"mid-frame EOF is TruncatedDatagram, got {err:?}"
|
||||
);
|
||||
|
||||
session.close().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn datagram_data_plane_after_take_halves_is_channel_taken() {
|
||||
// After the halves were taken, the data plane is gone: datagram
|
||||
// accessors fail with `ChannelTaken` (the pinned `ChannelTaken`
|
||||
// surface — the session is still the channel-entry owner).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry.register("dns", Substrate::Udp, "in-process").await;
|
||||
let topo = wire_forward(registry, framed_udp_echo_dial()).await;
|
||||
|
||||
let session = TunnelSession::open(&topo.consumer, params("dns", Substrate::Udp))
|
||||
.await
|
||||
.expect("udp open");
|
||||
let taken = session.take_halves();
|
||||
let alktunnels::TakenHalves {
|
||||
session: mut taken_session,
|
||||
mut read,
|
||||
mut write,
|
||||
} = taken;
|
||||
|
||||
let err = taken_session
|
||||
.send_datagram(b"late")
|
||||
.await
|
||||
.expect_err("send after take");
|
||||
assert!(matches!(err, alktunnels::TunnelIoError::ChannelTaken));
|
||||
let err = taken_session
|
||||
.recv_datagram()
|
||||
.await
|
||||
.expect_err("recv after take");
|
||||
assert!(matches!(err, alktunnels::TunnelIoError::ChannelTaken));
|
||||
|
||||
// The taken halves carry the WIRE framing (ADR-005's N-3 pin):
|
||||
// the raw bytes are `[len: u16 BE][payload]` — framing duty
|
||||
// transferred to the caller, visible on the wire.
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
write
|
||||
.write_all(&[0, 3, b'a', b'b', b'c'])
|
||||
.await
|
||||
.expect("write a raw frame");
|
||||
let mut buf = vec![0u8; 5];
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("raw frame round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, &[0, 3, b'a', b'b', b'c']);
|
||||
|
||||
// Drop the halves: both pump directions EOF on the producer side
|
||||
// (drop of the channel send/recv streams IS the EOF propagation).
|
||||
drop(read);
|
||||
drop(write);
|
||||
let (c2p, p2c, reaped) = taken_session.join().await;
|
||||
assert_eq!((c2p, p2c), (0, 0), "pump-less join after take_halves");
|
||||
assert!(reaped);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverse_udp_pump_against_framed_adapter_round_trips() {
|
||||
// The reverse UDP shape: the session's codec data plane is
|
||||
// consumed by `pump_against` and the accepted local half speaks
|
||||
// the FRAMED wire (the N-11 contract — the pump copies raw bytes,
|
||||
// so the `[len: u16 BE]` framing lives at the boundary; the test's
|
||||
// local end stands in for the framed adapter, e.g. `local::UdpHalf`).
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let registry = ResourceRegistry::new();
|
||||
registry.register("dns", Substrate::Udp, "in-process").await;
|
||||
let topo = wire(registry, framed_udp_echo_dial()).await;
|
||||
|
||||
let channel_id =
|
||||
open_reverse_channel(&topo.consumer_call, ¶ms("dns", Substrate::Udp), None)
|
||||
.await
|
||||
.expect("reverse udp open");
|
||||
let session = TunnelSession::adopt(
|
||||
&topo.consumer_manager,
|
||||
channel_id,
|
||||
Substrate::Udp,
|
||||
alktunnels::TUNNEL_ALPN,
|
||||
)
|
||||
.await
|
||||
.expect("adopt");
|
||||
|
||||
let (accepted_end, local_end) = tokio::io::duplex(64 * 1024);
|
||||
let session = session.pump_against(accepted_end).await;
|
||||
let mut local_end = local_end;
|
||||
|
||||
// The local end speaks FRAMED bytes end-to-end: a payload frame
|
||||
// crosses the pump untouched and echoes back framed — decode
|
||||
// locally with the codec (boundary preserved, F-2 shape intact).
|
||||
let sent = alktunnels::wire::frame_datagram(b"rev-udp").expect("frame");
|
||||
local_end.write_all(&sent).await.expect("write frame");
|
||||
let mut framed_back = vec![0u8; sent.len()];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
local_end.read_exact(&mut framed_back),
|
||||
)
|
||||
.await
|
||||
.expect("round trip timed out")
|
||||
.expect("read");
|
||||
assert_eq!(framed_back, &sent[..], "framed bytes pass through");
|
||||
|
||||
// An EMPTY datagram (`len=0`) also round-trips — never collapsed
|
||||
// to EOF across the pump (the F-2 invariant, reverse direction).
|
||||
local_end
|
||||
.write_all(&[0, 0])
|
||||
.await
|
||||
.expect("write empty frame");
|
||||
let mut empty_back = vec![0u8; 2];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
local_end.read_exact(&mut empty_back),
|
||||
)
|
||||
.await
|
||||
.expect("empty round trip timed out")
|
||||
.expect("read");
|
||||
assert_eq!(empty_back, &[0, 0], "empty datagram frame survives");
|
||||
|
||||
// Drop the local end: both pump directions EOF; join completes.
|
||||
drop(local_end);
|
||||
let (_, _, reaped) = session.join().await;
|
||||
assert!(reaped);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_open_never_yields_a_session() {
|
||||
// No phantom session: unknown resource → typed error, no channel
|
||||
|
||||
@@ -499,6 +499,76 @@ pub fn all_substrate_dial() -> alktunnels::producer::DialFn {
|
||||
})
|
||||
}
|
||||
|
||||
/// A framed UDP echo dial whose target end CLOSES after the first
|
||||
/// echoed datagram — the EOF-path harness. `payload_completes`
|
||||
/// controls the truncation shape: `true` forwards the whole frame
|
||||
/// (echo + payload intact) then drops → the clean-EOF path;
|
||||
/// `false` forwards only the 2-byte length prefix then drops → the
|
||||
/// mid-datagram EOF path (`TruncatedDatagram`).
|
||||
pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::DialFn {
|
||||
Arc::new(move |substrate: Substrate, backing: &str| {
|
||||
let backing = backing.to_string();
|
||||
Box::pin(async move {
|
||||
match substrate {
|
||||
Substrate::Udp => {
|
||||
use alktunnels::wire::DatagramReader;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
||||
tokio::spawn(async move {
|
||||
let (mut t_read, mut t_write) = tokio::io::split(target_side);
|
||||
let mut reader = DatagramReader::new();
|
||||
let mut buf = vec![0u8; 16 * 1024];
|
||||
// Read until the first complete frame (the
|
||||
// echo-closes-after-first shape): forward it
|
||||
// whole (clean-EOF shape) or forward only its
|
||||
// length prefix (truncated shape), then drop
|
||||
// the far end — the channel read half EOFs.
|
||||
let n = match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => n,
|
||||
};
|
||||
let dgs = match reader.feed(&buf[..n]) {
|
||||
Ok(dgs) => dgs,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Some(dg) = dgs.into_iter().next() {
|
||||
let framed = match alktunnels::wire::frame_datagram(&dg) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
if payload_completes {
|
||||
if t_write.write_all(&framed).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = t_write.flush().await;
|
||||
} else {
|
||||
// The truncated shape: forward ONLY
|
||||
// the 2-byte length prefix, then drop —
|
||||
// the declared payload never arrives.
|
||||
if t_write.write_all(&framed[..2]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = t_write.flush().await;
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = &backing;
|
||||
let (c_read, c_write) = tokio::io::split(consumer_side);
|
||||
Ok(alktunnels::producer::TargetHandle {
|
||||
read: Box::new(c_read),
|
||||
write: Box::new(c_write),
|
||||
})
|
||||
}
|
||||
Substrate::Tcp | Substrate::Unix => {
|
||||
Err(alktunnels::producer::TunnelEstablishError::DialFailed(
|
||||
format!("stream dial not wired in the udp harness: {backing}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A failing dial closure (the `dial_failed` stand-in).
|
||||
pub fn failing_dial(message: &'static str) -> alktunnels::producer::DialFn {
|
||||
Arc::new(move |_substrate: Substrate, _backing: &str| {
|
||||
|
||||
@@ -615,3 +615,259 @@ async fn listen_producer_over_a_real_tcp_listener() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -321,6 +321,69 @@ async fn accept_wait_resolves_when_push_arrives_late() {
|
||||
assert!(reaped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_after_close_returns_the_handle_and_pop_resolves_none() {
|
||||
// The queue contract: a push after close is `Err(handle)` — the
|
||||
// accept loop's signal to stop feeding (the accepted connection is
|
||||
// returned untouched; nothing is queued); a closed-and-empty pop
|
||||
// resolves `None` immediately.
|
||||
let queue = AcceptQueue::new();
|
||||
queue.close().await;
|
||||
|
||||
let (consumer_side, target_side) = tokio::io::duplex(1024);
|
||||
let (c_read, c_write) = tokio::io::split(consumer_side);
|
||||
let handle = TargetHandle {
|
||||
read: Box::new(c_read),
|
||||
write: Box::new(c_write),
|
||||
};
|
||||
let dropped = queue
|
||||
.push(handle)
|
||||
.await
|
||||
.expect_err("push after close must be Err");
|
||||
// The handle comes back (the caller drops it — the accepted
|
||||
// connection closes; nothing leaks into the queue).
|
||||
drop(dropped);
|
||||
drop(target_side);
|
||||
|
||||
assert!(
|
||||
queue.pop().await.is_none(),
|
||||
"closed-and-empty pop resolves None"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "local")]
|
||||
async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() {
|
||||
// The accept loop's queue-closed mapping (the `local` feature's
|
||||
// shape): a `push` after close errors the loop with the handler
|
||||
// error — the assembly layer aborts the loop task on teardown;
|
||||
// here the loop itself exits on the mapped error. The unaccepted
|
||||
// far end is dropped (the loop accepted it; nothing pumps it once
|
||||
// the queue is closed).
|
||||
let queue = AcceptQueue::new();
|
||||
queue.close().await;
|
||||
|
||||
let listener = alktunnels::local::bind_tcp("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let loop_queue = queue.clone();
|
||||
let outcome = tokio::spawn(async move { listener.accept_loop(loop_queue).await });
|
||||
let _ = tokio::net::TcpStream::connect(addr).await.expect("connect");
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(5), outcome)
|
||||
.await
|
||||
.expect("accept loop timed out (did it keep feeding a closed queue?)")
|
||||
.expect("accept loop task")
|
||||
.expect_err("closed queue must end the accept loop");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(_)
|
||||
),
|
||||
"queue-closed push maps to the handler error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lost-wakeup regression (review 001 C-1): a `push` landing in
|
||||
/// the wall-clock window between a parked `pop`'s state check and its
|
||||
/// `Notified` registration must still wake the pop. The window only
|
||||
|
||||
@@ -180,6 +180,26 @@ async fn dial_failure_is_typed_and_leaves_no_channel() {
|
||||
assert!(no_data_channels(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_gate_rejects_missing_substrate_before_the_establisher() {
|
||||
// The registry's input-schema gate runs before the establisher:
|
||||
// a missing substrate is `INVALID_INPUT` with no reason code —
|
||||
// the establisher's own `handler_error` parse branch is
|
||||
// unreachable past this gate (the schema pins both fields).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
.await;
|
||||
let topo = wire(registry, echo_dial()).await;
|
||||
|
||||
let err = call_open(&topo, serde_json::json!({"resource": "echo"}), None)
|
||||
.await
|
||||
.expect_err("missing substrate must fail the schema gate");
|
||||
assert_eq!(err.code, "INVALID_INPUT");
|
||||
assert!(establishment_reason_of(&err).is_none());
|
||||
assert!(no_data_channels(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_denied_without_any_identity() {
|
||||
let registry = ResourceRegistry::new();
|
||||
|
||||
Reference in New Issue
Block a user