fix: UdpHalf is the FRAMED adapter (U-1) — U-2 bind posture, U-3 lazy scratch
Review 001 remediation (tasks/tunnels/fix-udp-framed-adapter): - U-1 [major]: UdpHalf now composes the ADR-003 codec at the substrate boundary — poll_write de-frames channel bytes (incremental DatagramReader) and sends each payload as one raw datagram; poll_read frames raw target datagrams and hands frame bytes to the pump. Targets see payload-only bytes; empty datagrams round-trip (the F-2 fix end-to-end); >8 KiB datagrams arrive as ONE datagram. Mechanics the verification gates forced beyond the sketch: poll_write accepts all offered bytes (tokio copy re-offers a full window unsatisfied — partial-frame holds deadlock >8 KiB frames), backpressure is an accepted-byte high-water mark, socket drain pumps on write/flush/shutdown; >caller-buffer frames stage across reads (the truncation probe's assertion surface updated per the re-validation correction). - U-2 [minor]: connect_udp resolves the target and binds the unspecified address of the matching family (v6 targets dial from [::]:0); bind_tcp gains the wildcard-exposure doc warning. - U-3 [minor]: the 65535 recv scratch allocates lazily on first recv (full-size read kept — try_recv truncates silently; the MTU-shrink idea confirmed unsafe in re-validation). - N-1: the module/connect_udp/UdpHalf docs now describe the composed codec truthfully. Gates (permanent tests in tests/local_halves.rs): recording-target suite (payload-only at the target, empty-datagram round-trip, 9000- byte single datagram, unframed-target resolution), the 65507-byte max-datagram staging probe, and the v6 dial gate. Docs: ADR-003 §Amendment (the boundary mechanics), wire.md truncation bullet, consumer.md adopt sketch (substrate param). Review 001 resolution notes on U-1/U-2/U-3/N-1; task marked completed. Verified: 63 default / 76 local tests ×3 repeat runs; clippy -D warnings (all-targets + wasm32), fmt --check, doc --no-deps (warning-free), wasm check, publish dry-run.
This commit is contained in:
1 parent
aa7b5b7169
commit
8cf4cc8bb5
7 files changed
+765
-106
No files matched your search
@@ -46,8 +46,8 @@ are identical:
|
|||||||
```rust
|
```rust
|
||||||
// Per local accept (assembly-owned listener):
|
// Per local accept (assembly-owned listener):
|
||||||
let channel_id = open_reverse_channel(&hub_call, ¶ms, auth_token).await?;
|
let channel_id = open_reverse_channel(&hub_call, ¶ms, auth_token).await?;
|
||||||
let session = TunnelSession::adopt(hub_manager, channel_id, TUNNEL_ALPN).await?;
|
let session = TunnelSession::adopt(hub_manager, channel_id, substrate, TUNNEL_ALPN).await?;
|
||||||
let pump_handle = session.pump_against(accepted_halves).await; // or drive the halves directly
|
let pumped = session.pump_against(accepted_halves).await; // or drive the halves directly
|
||||||
```
|
```
|
||||||
|
|
||||||
- `open_reverse_channel` — call the worker's open op on channel 0
|
- `open_reverse_channel` — call the worker's open op on channel 0
|
||||||
@@ -57,11 +57,13 @@ let pump_handle = session.pump_against(accepted_halves).await; // or drive the h
|
|||||||
before dialing); the payload `auth_token` is the optional
|
before dialing); the payload `auth_token` is the optional
|
||||||
hub-forwarding path (precedence: token > `ServingConfig.identity` >
|
hub-forwarding path (precedence: token > `ServingConfig.identity` >
|
||||||
transport).
|
transport).
|
||||||
- `TunnelSession::adopt(manager, channel_id, alpn)` — adopt the
|
- `TunnelSession::adopt(manager, channel_id, substrate, alpn)` —
|
||||||
worker-allocated ID (connection-owner rule, ADR-047 §5 — the
|
adopt the worker-allocated ID (connection-owner rule, ADR-047 §5 —
|
||||||
SERVING side allocates; the initiator adopts; early-arrival parking
|
the SERVING side allocates; the initiator adopts; early-arrival
|
||||||
covers the adoption race), install the session's data plane from
|
parking covers the adoption race), install the session's data plane
|
||||||
the adopted halves. `adopt` takes the same typed-error surface as
|
from the adopted halves. The substrate parameter is required — a
|
||||||
|
datagram session cannot exist without it (the data plane variant is
|
||||||
|
substrate-shaped). `adopt` takes the same typed-error surface as
|
||||||
`open` (an adopt failure is `AdoptFailed`-class, not an
|
`open` (an adopt failure is `AdoptFailed`-class, not an
|
||||||
establishment error).
|
establishment error).
|
||||||
- `pump_against(accepted_halves)` — spawn `pump_bidi` against the
|
- `pump_against(accepted_halves)` — spawn `pump_bidi` against the
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (2026-09-07; supersedes the Phase 0 "codec direction" hunch
|
Accepted (2026-09-07; supersedes the Phase 0 "codec direction" hunch
|
||||||
by making the UDP framing mandatory — reverse POC finding F-2)
|
by making the UDP framing mandatory — reverse POC finding F-2).
|
||||||
|
Amended (2026-09-08, review 001 U-1 remediation): the substrate-
|
||||||
|
boundary placement is pinned mechanically — the `local` feature's
|
||||||
|
`UdpHalf` composes the codec at the boundary (channel side framed,
|
||||||
|
target side raw), with the pump-window and recv-scratch disciplines
|
||||||
|
the composition requires. See §Amendment (2026-09-08).
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -77,6 +82,55 @@ The data plane inside a `alk/tunnel` channel is:
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
### Amendment (2026-09-08 — the boundary mechanics, from review 001 U-1's remediation)
|
||||||
|
|
||||||
|
The placement sentence ("the establisher wraps the dialed UDP socket
|
||||||
|
in the framed adapter before returning the plan") leaves the adapter's
|
||||||
|
*mechanics* open; review 001 found `UdpHalf` shipping as a raw
|
||||||
|
boundary-preserving adapter (the codec on one end only — real targets
|
||||||
|
saw the tunnel's length prefixes; empty datagrams collapsed to EOF at
|
||||||
|
the pump's zero-read check; >8 KiB datagrams split at the copy
|
||||||
|
buffer). The remediation pins the mechanics:
|
||||||
|
|
||||||
|
- **Orientation is channel-relative.** The adapter's caller is the
|
||||||
|
pump; the CHANNEL side is the framed side. `poll_write` (fed
|
||||||
|
channel bytes) **de-frames** (an incremental `DatagramReader`) and
|
||||||
|
sends each decoded payload as one raw datagram; `poll_read` **frames**
|
||||||
|
(`frame_datagram`) each raw target datagram and hands frame bytes to
|
||||||
|
the pump. "Frame on write, de-frame on read" describes the TARGET's
|
||||||
|
face and is the inverted (wrong) reading — the review's original
|
||||||
|
fix sketch had it inverted and was corrected in re-validation.
|
||||||
|
- **The write half accepts all offered bytes and reports them
|
||||||
|
consumed.** The pump's copy engine re-offers a *full* window
|
||||||
|
unsatisfied until it drains (tokio `copy`'s `read_done` path never
|
||||||
|
tops the window up), so a partial-frame tail could sit forever in a
|
||||||
|
full window if the adapter accepted only complete frames — the
|
||||||
|
>8 KiB frame would deadlock the pump. Completed datagrams queue for
|
||||||
|
the socket; a trailing partial frame waits in the `DatagramReader`
|
||||||
|
(its state is bounded by construction — the u16 cap, review 001
|
||||||
|
N-2). Backpressure is an accepted-byte high-water mark on
|
||||||
|
`poll_write`, and the socket drain pumps opportunistically on
|
||||||
|
write/flush/shutdown (tokio's copy polls flush after every window
|
||||||
|
drain).
|
||||||
|
- **The recv scratch is full-size (65535) and lazy.** `try_recv`
|
||||||
|
truncates SILENTLY at the buffer size — a smaller scratch corrupts
|
||||||
|
the framing before any size check could reject it (the review's
|
||||||
|
"size to the MTU" suggestion is unsafe as written). The 64 KiB
|
||||||
|
eager allocation is instead lazy (first recv), the U-3 mitigation.
|
||||||
|
- **A >caller-buffer frame is staged, not an error.** The frame's
|
||||||
|
length prefix reaches the pump on the first read; the remainder
|
||||||
|
hands out across reads. The pump's far-side de-framer reassembles
|
||||||
|
the true length — the raw adapter's read-level `InvalidData` is
|
||||||
|
replaced by staging (the fail-loud posture now protects the *frame
|
||||||
|
stream*, and a wire-level over-bound datagram remains a loud
|
||||||
|
`Oversize`-class error at the frame path).
|
||||||
|
|
||||||
|
The v1 verification gates (review 001 §Verification gates) pin these
|
||||||
|
end-to-end over real sockets in `tests/local_halves.rs` (the
|
||||||
|
recording-target suite).
|
||||||
|
|
||||||
|
## Consequences (original)
|
||||||
|
|
||||||
- **TCP/unix tunnels have 0 B tunnel overhead** — the SSH
|
- **TCP/unix tunnels have 0 B tunnel overhead** — the SSH
|
||||||
`direct-tcpip` experience (the payload is the bytes).
|
`direct-tcpip` experience (the payload is the bytes).
|
||||||
- **UDP correctness requires the codec** — raw pass-through over UDP
|
- **UDP correctness requires the codec** — raw pass-through over UDP
|
||||||
|
|||||||
@@ -111,9 +111,14 @@ the codec:
|
|||||||
payload, not the theoretical 1472 max) ride the bounded-buffer path;
|
payload, not the theoretical 1472 max) ride the bounded-buffer path;
|
||||||
the 64-parked-chunks bound (alkcall early-arrival cap) is upstream
|
the 64-parked-chunks bound (alkcall early-arrival cap) is upstream
|
||||||
sizing, not tunnel policy.
|
sizing, not tunnel policy.
|
||||||
- **Truncation:** a receive buffer smaller than the datagram fails
|
- **Truncation:** silent truncation never happens (OQ-TN-13, resolved
|
||||||
loudly at the adapter level (OQ-TN-13, resolved fail-loud per
|
fail-loud per ADR-003): the `local` adapter recvs on a full
|
||||||
ADR-003); silent truncation would corrupt the framing invariants.
|
65535-byte scratch (UDP `try_recv` truncates silently at the buffer
|
||||||
|
size — a smaller scratch would corrupt the framing) and a
|
||||||
|
>caller-buffer frame is staged across reads, the length prefix
|
||||||
|
intact, so the far-side de-framer reassembles the true length. A
|
||||||
|
wire-level over-bound datagram (>65535 — impossible for a
|
||||||
|
well-formed peer) is a loud error at the frame path.
|
||||||
|
|
||||||
### Sentinels and lifecycle (both codecs)
|
### Sentinels and lifecycle (both codecs)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ consolidated here per the review-impl deliverable).
|
|||||||
|
|
||||||
**U-1 is load-bearing for the UDP wire promise and is NOT yet fixed** —
|
**U-1 is load-bearing for the UDP wire promise and is NOT yet fixed** —
|
||||||
see the remediation section (it owns the remaining remediation task).
|
see the remediation section (it owns the remaining remediation task).
|
||||||
|
**FIXED 2026-09-08** — see §Remediation resolution below.
|
||||||
**C-1 (`AcceptQueue` lost wakeup) is fixed with this review's commit**,
|
**C-1 (`AcceptQueue` lost wakeup) is fixed with this review's commit**,
|
||||||
including its regression test. The trivial minors (N-4, N-5, N-7, N-8,
|
including its regression test. The trivial minors (N-4, N-5, N-7, N-8,
|
||||||
N-9, N-10) are fixed with the same commit — the review's "trivial
|
N-9, N-10) are fixed with the same commit — the review's "trivial
|
||||||
@@ -263,6 +264,30 @@ the codec path.
|
|||||||
**Remediation task:** `tasks/tunnels/fix-udp-framed-adapter.md`
|
**Remediation task:** `tasks/tunnels/fix-udp-framed-adapter.md`
|
||||||
(created with this review).
|
(created with this review).
|
||||||
|
|
||||||
|
**RESOLUTION (2026-09-08, remediation commit):** `UdpHalf` is the
|
||||||
|
framed adapter — de-frames on `poll_write` (channel bytes → raw
|
||||||
|
datagrams, via an incremental `DatagramReader`), frames on `poll_read`
|
||||||
|
(raw datagrams → channel frames, via `frame_datagram`). All five
|
||||||
|
verification gates landed as permanent tests in `tests/local_halves.rs`
|
||||||
|
(the recording-target suite: payload-only at the target, empty
|
||||||
|
round-trip, 9000-byte single datagram, unframed-target resolution,
|
||||||
|
staging-not-truncation at 65507) plus a v6-dial gate for U-2. Two
|
||||||
|
mechanics the gates forced beyond the sketch (pinned in ADR-003's
|
||||||
|
amendment):
|
||||||
|
|
||||||
|
1. The write half ACCEPTS ALL offered bytes (reports them consumed)
|
||||||
|
and queues completed datagrams for the socket — tokio `copy`
|
||||||
|
re-offers a FULL window unsatisfied until drained, so accepting
|
||||||
|
only complete frames deadlocks a >8 KiB frame (its tail sits in a
|
||||||
|
full window forever). Backpressure is an accepted-byte high-water
|
||||||
|
mark; the socket drain pumps on write/flush/shutdown (copy polls
|
||||||
|
flush after every window).
|
||||||
|
2. A >caller-buffer frame is STAGED across reads (length prefix
|
||||||
|
first) rather than the raw adapter's read-level `InvalidData` —
|
||||||
|
gate 4's predicted assertion-surface change; the 65507-byte
|
||||||
|
max-datagram round-trip through 16 KiB reads is the new fail-loud
|
||||||
|
probe (silent truncation would still corrupt the framing).
|
||||||
|
|
||||||
## U-2 [minor] — `connect_udp` binds `0.0.0.0` unconditionally — no v6 or dual-stack posture
|
## U-2 [minor] — `connect_udp` binds `0.0.0.0` unconditionally — no v6 or dual-stack posture
|
||||||
|
|
||||||
`src/local/mod.rs:116`: `UdpSocket::bind(("0.0.0.0", 0))`. A UDP
|
`src/local/mod.rs:116`: `UdpSocket::bind(("0.0.0.0", 0))`. A UDP
|
||||||
@@ -306,6 +331,14 @@ The rule the remediation implements:
|
|||||||
Recorded here so the posture survives the implementation; the
|
Recorded here so the posture survives the implementation; the
|
||||||
executable rider lives on `tunnels/fix-udp-framed-adapter.md` §Riders.
|
executable rider lives on `tunnels/fix-udp-framed-adapter.md` §Riders.
|
||||||
|
|
||||||
|
**RESOLUTION (2026-09-08, remediation commit):** `connect_udp`
|
||||||
|
resolves the target first and binds the UNSPECIFIED address of the
|
||||||
|
matching family (`0.0.0.0:0` for v4 targets, `[::]:0` for v6 — no
|
||||||
|
implicit wildcard mismatch, no loopback default); no bind-address
|
||||||
|
parameter added. `bind_tcp` gained the wildcard-exposure doc warning.
|
||||||
|
Pinned by `connect_udp_binds_the_matching_family_v6_targets_dial` (a
|
||||||
|
real `::1` echo round-trip through the full path).
|
||||||
|
|
||||||
## U-3 [minor] — `UdpHalf` recv scratch allocates 65535 bytes per read half
|
## U-3 [minor] — `UdpHalf` recv scratch allocates 65535 bytes per read half
|
||||||
|
|
||||||
`src/local/mod.rs:164`: `recv_scratch: vec![0u8; 65535]` — a 64 KiB
|
`src/local/mod.rs:164`: `recv_scratch: vec![0u8; 65535]` — a 64 KiB
|
||||||
@@ -318,6 +351,13 @@ the codec's MTU discipline (1400) with an oversized-datagram
|
|||||||
the fail-loud check (a >MTU datagram currently *is* received then
|
the fail-loud check (a >MTU datagram currently *is* received then
|
||||||
error-matched against the caller buffer). Fold into the U-1 task.
|
error-matched against the caller buffer). Fold into the U-1 task.
|
||||||
|
|
||||||
|
**RESOLUTION (2026-09-08, remediation commit):** the scratch is
|
||||||
|
allocated LAZILY on the first recv (the re-validation's mitigation —
|
||||||
|
the MTU-shrink idea was unsafe as written; the full-size read is
|
||||||
|
correctness-load-bearing on the de-frame path). The write half
|
||||||
|
carries no scratch. Memory posture: one 64 KiB allocation per
|
||||||
|
*receiving* UDP tunnel, on first recv.
|
||||||
|
|
||||||
## C-1 [major] — `AcceptQueue::pop` can miss the final `push` (lost wakeup in the check→register window) — FIXED in-review
|
## C-1 [major] — `AcceptQueue::pop` can miss the final `push` (lost wakeup in the check→register window) — FIXED in-review
|
||||||
|
|
||||||
**Status: FIXED during the review** (commit with this doc).
|
**Status: FIXED during the review** (commit with this doc).
|
||||||
@@ -374,6 +414,11 @@ Filed separately from U-1 because even if U-1's fix is deferred, the
|
|||||||
docs currently describe behavior that does not exist — a conformance
|
docs currently describe behavior that does not exist — a conformance
|
||||||
review must not leave that standing.
|
review must not leave that standing.
|
||||||
|
|
||||||
|
**RESOLUTION (2026-09-08, remediation commit):** U-1's fix landed —
|
||||||
|
the docs are now true. The module header, `connect_udp`, and
|
||||||
|
`UdpHalf` docs describe the composed codec (channel side framed,
|
||||||
|
target side raw) with the ADR-003 amendment's mechanics.
|
||||||
|
|
||||||
## N-2 — NON-FINDING (retracted during the review): `DatagramReader` buffering is bounded by construction
|
## N-2 — NON-FINDING (retracted during the review): `DatagramReader` buffering is bounded by construction
|
||||||
|
|
||||||
Filed as a suspect ("a lying `len` header buffers without bound"),
|
Filed as a suspect ("a lying `len` header buffers without bound"),
|
||||||
@@ -588,6 +633,13 @@ U-1 remediation docs.
|
|||||||
|
|
||||||
# Verification gates for the U-1 remediation
|
# Verification gates for the U-1 remediation
|
||||||
|
|
||||||
|
(All five landed 2026-09-08 as permanent tests in
|
||||||
|
`tests/local_halves.rs` — see §U-1's resolution note. Gates 1–3 are
|
||||||
|
the `gate_*` recording-target suite; gate 4's truncation-surface
|
||||||
|
update is `truncation_fails_loud_oq_tn_13` (rewritten for the staged
|
||||||
|
frame) + `oversized_frame_stages_across_reads`; gate 5 is the standing
|
||||||
|
battery.)
|
||||||
|
|
||||||
1. A real-socket probe test (the review's probe shape, made permanent
|
1. A real-socket probe test (the review's probe shape, made permanent
|
||||||
in `tests/local_halves.rs`): a recording UDP echo server asserts
|
in `tests/local_halves.rs`): a recording UDP echo server asserts
|
||||||
the datagrams it receives are **payload bytes only** (no `[len]`
|
the datagrams it receives are **payload bytes only** (no `[len]`
|
||||||
|
|||||||
+293
-67
@@ -10,8 +10,9 @@
|
|||||||
//! [`TcpListenerHalves`] accept half for listen producers.
|
//! [`TcpListenerHalves`] accept half for listen producers.
|
||||||
//! - **UDP** — [`connect_udp`]: a connected `UdpSocket` wrapped in the
|
//! - **UDP** — [`connect_udp`]: a connected `UdpSocket` wrapped in the
|
||||||
//! FRAMED adapter ([`UdpHalf`], ADR-003 — the codec composes at this
|
//! FRAMED adapter ([`UdpHalf`], ADR-003 — the codec composes at this
|
||||||
//! boundary; the pump never sees UDP specifics). Truncation fails
|
//! boundary: the channel side speaks the `[len: u16 BE]` frames, the
|
||||||
//! loud (OQ-TN-13's resolved posture).
|
//! target side sees payload-only datagrams; the pump never sees UDP
|
||||||
|
//! specifics). Truncation fails loud (OQ-TN-13's resolved posture).
|
||||||
//! - **Unix** — [`dial_unix`], in v1 per OQ-TN-14 (same halves shape
|
//! - **Unix** — [`dial_unix`], in v1 per OQ-TN-14 (same halves shape
|
||||||
//! as TCP). **Stdio is NOT here** — a spawned process's stdio is
|
//! as TCP). **Stdio is NOT here** — a spawned process's stdio is
|
||||||
//! alktty's pipe mode (exit codes + signals are tty semantics);
|
//! alktty's pipe mode (exit codes + signals are tty semantics);
|
||||||
@@ -29,6 +30,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::params::Substrate;
|
use crate::params::Substrate;
|
||||||
use crate::producer::{AcceptQueue, DialFn, TargetHandle, TunnelEstablishError};
|
use crate::producer::{AcceptQueue, DialFn, TargetHandle, TunnelEstablishError};
|
||||||
|
use crate::wire::{frame_datagram, DatagramCodecError, DatagramReader, MAX_DATAGRAM_LEN};
|
||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
|
|
||||||
/// The dial/connect timeout (the POCs' 5s posture).
|
/// The dial/connect timeout (the POCs' 5s posture).
|
||||||
@@ -54,6 +56,12 @@ pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError
|
|||||||
/// Bind a TCP listener (the listen shape's assembly-side half).
|
/// Bind a TCP listener (the listen shape's assembly-side half).
|
||||||
/// The binding decision belongs to the caller (OQ-TN-04) — this is a
|
/// The binding decision belongs to the caller (OQ-TN-04) — this is a
|
||||||
/// `local`-feature convenience, not protocol behavior.
|
/// `local`-feature convenience, not protocol behavior.
|
||||||
|
///
|
||||||
|
/// **Bind posture (review 001 U-2):** the caller-supplied address is
|
||||||
|
/// deliberate — a wildcard bind (`0.0.0.0` or `::`; note `::1` is
|
||||||
|
/// loopback, not a wildcard) is the caller's explicit choice and
|
||||||
|
/// exposes the listener per the host's firewall. There is no implicit
|
||||||
|
/// wildcard default here.
|
||||||
pub async fn bind_tcp(addr: &str) -> Result<TcpListenerHalves, TunnelEstablishError> {
|
pub async fn bind_tcp(addr: &str) -> Result<TcpListenerHalves, TunnelEstablishError> {
|
||||||
let listener = tokio::net::TcpListener::bind(addr)
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
.await
|
.await
|
||||||
@@ -106,14 +114,36 @@ impl TcpListenerHalves {
|
|||||||
|
|
||||||
// --- UDP ----------------------------------------------------------------
|
// --- UDP ----------------------------------------------------------------
|
||||||
|
|
||||||
/// Connect a UDP "tunnel" to a target: bind an ephemeral socket,
|
/// Connect a UDP "tunnel" to a target: resolve the target, bind an
|
||||||
/// `connect(target)` (the associated-socket posture — one peer), and
|
/// ephemeral socket on the MATCHING address family (never an implicit
|
||||||
/// wrap it in the FRAMED adapter (ADR-003): `UdpHalf` enforces one
|
/// wildcard mismatch — a v6-only target dials from a v6-bound socket;
|
||||||
/// datagram per read/write at the substrate boundary; the `[len: u16
|
/// review 001 U-2's posture), `connect(target)` (the
|
||||||
/// BE]` codec composes at this boundary — the pump never sees UDP
|
/// associated-socket posture — one peer), and wrap it in the FRAMED
|
||||||
|
/// adapter (ADR-003): `UdpHalf` composes the `[len: u16 BE]` codec at
|
||||||
|
/// this boundary — the channel side speaks framed bytes, the target
|
||||||
|
/// side sees payload-only datagrams; the pump never sees UDP
|
||||||
/// specifics.
|
/// specifics.
|
||||||
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
||||||
let sock = tokio::net::UdpSocket::bind(("0.0.0.0", 0))
|
use std::net::ToSocketAddrs;
|
||||||
|
// Same posture as tokio's own `UdpSocket::connect(target: &str)` —
|
||||||
|
// it resolves inline too; numeric targets (the registry's shape)
|
||||||
|
// make this a non-blocking parse.
|
||||||
|
let resolved = target
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| TunnelEstablishError::DialFailed(format!("udp resolve `{target}`: {e}")))?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TunnelEstablishError::DialFailed(format!("udp resolve `{target}`: no addresses"))
|
||||||
|
})?;
|
||||||
|
let bind_addr = match resolved {
|
||||||
|
std::net::SocketAddr::V4(_) => {
|
||||||
|
std::net::SocketAddr::new(std::net::Ipv4Addr::UNSPECIFIED.into(), 0)
|
||||||
|
}
|
||||||
|
std::net::SocketAddr::V6(_) => {
|
||||||
|
std::net::SocketAddr::new(std::net::Ipv6Addr::UNSPECIFIED.into(), 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let sock = tokio::net::UdpSocket::bind(bind_addr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("udp bind failed: {e}")))?;
|
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("udp bind failed: {e}")))?;
|
||||||
sock.connect(target)
|
sock.connect(target)
|
||||||
@@ -123,24 +153,55 @@ pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishEr
|
|||||||
Ok(TargetHandle { read, write })
|
Ok(TargetHandle { read, write })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A UDP socket presented as `AsyncRead`/`AsyncWrite` halves: reads
|
/// The FRAMED UDP adapter (ADR-003's substrate-boundary codec): the
|
||||||
/// yield whole datagrams (one `poll_recv` per read); writes send one
|
/// pump-facing face speaks the tunnel's `[len: u16 BE][payload]`
|
||||||
/// datagram per `poll_write` buffer. The FRAMED adapter — the codec
|
/// channel framing; the socket face speaks whole raw datagrams.
|
||||||
/// wraps at this boundary (ADR-003); the pump never sees UDP
|
/// Orientation (review 001 §Re-validation — the fix sketch's caution):
|
||||||
/// specifics.
|
/// `UdpHalf`'s caller is the pump, and the CHANNEL side is the framed
|
||||||
|
/// side —
|
||||||
///
|
///
|
||||||
/// **Truncation fails loud** (OQ-TN-13's resolved posture, ADR-003):
|
/// - `poll_write` is fed CHANNEL bytes (already-framed frames, from
|
||||||
/// `poll_recv` into a too-small caller buffer returns
|
/// the session's codec) → it **de-frames** (an incremental
|
||||||
/// `io::ErrorKind::InvalidData` (the datagram is dropped with the
|
/// [`DatagramReader`]) and sends each decoded payload to the target
|
||||||
/// error — silent truncation would corrupt the framing invariants).
|
/// as one raw datagram. A frame may straddle successive poll_write
|
||||||
/// The codec's u16 bound keeps well-formed peers from ever hitting
|
/// buffers (the pump's 8 KiB copy chunks split large frames); the
|
||||||
/// this path; only a mis-framed wire does.
|
/// decoder carries the partial state until the frame completes.
|
||||||
|
/// `len=0` decodes to a zero-payload datagram — a real empty
|
||||||
|
/// datagram, NOT EOF (the F-2 fix end-to-end).
|
||||||
|
/// - `poll_read` yields bytes destined for the CHANNEL (the session's
|
||||||
|
/// codec decodes there) → it **frames** ([`frame_datagram`]) each
|
||||||
|
/// raw target datagram and hands the frame bytes to the pump. A
|
||||||
|
/// frame larger than the caller's buffer is staged and handed out
|
||||||
|
/// across successive reads — a partial frame is never misread as a
|
||||||
|
/// shorter one.
|
||||||
|
///
|
||||||
|
/// The target sees payload-only bytes; an empty datagram crosses the
|
||||||
|
/// pump as its 2-byte frame (unambiguous with EOF).
|
||||||
|
///
|
||||||
|
/// **Truncation never happens silently** (OQ-TN-13's fail-loud
|
||||||
|
/// posture, ADR-003): the socket-facing recv uses a full 65535-byte
|
||||||
|
/// scratch (`try_recv` truncates at the buffer size — a smaller
|
||||||
|
/// scratch would corrupt the framing before any size check runs), and
|
||||||
|
/// a datagram beyond [`MAX_DATAGRAM_LEN`] (impossible for a
|
||||||
|
/// well-formed peer — the u16 frame bound) errors loudly instead of
|
||||||
|
/// truncating.
|
||||||
|
///
|
||||||
|
/// The scratch is allocated lazily on the first recv (review 001 U-3:
|
||||||
|
/// a 64 KiB eager allocation per tunnel is needless for the write
|
||||||
|
/// half and for tunnels that never recv).
|
||||||
pub struct UdpHalf {
|
pub struct UdpHalf {
|
||||||
sock: Arc<tokio::net::UdpSocket>,
|
sock: Arc<tokio::net::UdpSocket>,
|
||||||
kind: UdpHalfKind,
|
kind: UdpHalfKind,
|
||||||
/// The recv scratch buffer (the fail-loud truncation check reads
|
/// Read-half state: the recv scratch (lazily grown to the full
|
||||||
/// whole datagrams before copying into the caller's buffer).
|
/// 65535 on first use — `try_recv` truncates silently at the
|
||||||
recv_scratch: Vec<u8>,
|
/// buffer size, so the full-size read is correctness-load-bearing)
|
||||||
|
/// plus the frame-staging state for handing a >caller-buffer
|
||||||
|
/// frame out across reads.
|
||||||
|
stage: UdpReadStage,
|
||||||
|
/// Write-half state: the incremental de-framer fed by successive
|
||||||
|
/// poll_write buffers, the decoded datagrams queued for the
|
||||||
|
/// socket, and the accepted-byte backpressure accounting.
|
||||||
|
deframe: UdpWriteDeFrame,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
@@ -149,6 +210,40 @@ enum UdpHalfKind {
|
|||||||
Write,
|
Write,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-half staging: framed bytes awaiting the pump, chunked to the
|
||||||
|
/// caller's buffer.
|
||||||
|
struct UdpReadStage {
|
||||||
|
/// The frame bytes not yet handed out (a full frame when `None`
|
||||||
|
/// staged-partial state exists; empty between datagrams).
|
||||||
|
pending: bytes::Bytes,
|
||||||
|
/// Lazily allocated recv scratch (full 65535 on first recv).
|
||||||
|
recv_scratch: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write-half staging: the incremental de-framer, the decoded
|
||||||
|
/// datagrams queued for the socket, and the datagram mid-send (blocked
|
||||||
|
/// on socket send readiness).
|
||||||
|
struct UdpWriteDeFrame {
|
||||||
|
reader: DatagramReader,
|
||||||
|
/// Decoded datagrams awaiting send (FIFO). poll_write accepts all
|
||||||
|
/// offered bytes into the de-framer — the pump's copy window
|
||||||
|
/// cannot be held closed for later top-up (a full window is
|
||||||
|
/// re-offered unsatisfied until drained); backpressure is the
|
||||||
|
/// accepted-byte high-water mark instead.
|
||||||
|
queue: std::collections::VecDeque<bytes::Bytes>,
|
||||||
|
/// The datagram mid-send (the socket returned WouldBlock under
|
||||||
|
/// it — retried first on every poll).
|
||||||
|
sending: Option<bytes::Bytes>,
|
||||||
|
/// Accepted-byte accounting for the high-water mark (the bytes
|
||||||
|
/// accepted but not yet sent on the socket).
|
||||||
|
accepted: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accepted-byte bound before poll_write applies backpressure
|
||||||
|
/// (Pending). The pump's copy window is 8 KiB; this bounds the queue
|
||||||
|
/// at ~16 full-size frames' worth of accepted-but-unsent bytes.
|
||||||
|
const WRITE_QUEUE_HIGH_WATER: usize = 128 * 1024;
|
||||||
|
|
||||||
impl UdpHalf {
|
impl UdpHalf {
|
||||||
pub fn split(
|
pub fn split(
|
||||||
sock: tokio::net::UdpSocket,
|
sock: tokio::net::UdpSocket,
|
||||||
@@ -157,18 +252,35 @@ impl UdpHalf {
|
|||||||
Box<dyn AsyncWrite + Send + Sync + Unpin>,
|
Box<dyn AsyncWrite + Send + Sync + Unpin>,
|
||||||
) {
|
) {
|
||||||
let sock = Arc::new(sock);
|
let sock = Arc::new(sock);
|
||||||
(
|
let read_half = UdpHalf {
|
||||||
Box::new(UdpHalf {
|
sock: Arc::clone(&sock),
|
||||||
sock: Arc::clone(&sock),
|
kind: UdpHalfKind::Read,
|
||||||
kind: UdpHalfKind::Read,
|
stage: UdpReadStage {
|
||||||
recv_scratch: vec![0u8; 65535],
|
pending: bytes::Bytes::new(),
|
||||||
}),
|
recv_scratch: None,
|
||||||
Box::new(UdpHalf {
|
},
|
||||||
sock,
|
deframe: UdpWriteDeFrame {
|
||||||
kind: UdpHalfKind::Write,
|
reader: DatagramReader::new(),
|
||||||
recv_scratch: Vec::new(),
|
queue: std::collections::VecDeque::new(),
|
||||||
}),
|
sending: None,
|
||||||
)
|
accepted: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let write_half = UdpHalf {
|
||||||
|
sock,
|
||||||
|
kind: UdpHalfKind::Write,
|
||||||
|
stage: UdpReadStage {
|
||||||
|
pending: bytes::Bytes::new(),
|
||||||
|
recv_scratch: None,
|
||||||
|
},
|
||||||
|
deframe: UdpWriteDeFrame {
|
||||||
|
reader: DatagramReader::new(),
|
||||||
|
queue: std::collections::VecDeque::new(),
|
||||||
|
sending: None,
|
||||||
|
accepted: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(Box::new(read_half), Box::new(write_half))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,40 +290,71 @@ impl AsyncRead for UdpHalf {
|
|||||||
cx: &mut std::task::Context<'_>,
|
cx: &mut std::task::Context<'_>,
|
||||||
buf: &mut tokio::io::ReadBuf<'_>,
|
buf: &mut tokio::io::ReadBuf<'_>,
|
||||||
) -> std::task::Poll<std::io::Result<()>> {
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
use std::task::Poll;
|
||||||
let this = self.get_mut();
|
let this = self.get_mut();
|
||||||
debug_assert_eq!(this.kind, UdpHalfKind::Read);
|
debug_assert_eq!(this.kind, UdpHalfKind::Read);
|
||||||
// One recv yields exactly one datagram into the caller's
|
|
||||||
// buffer. The fail-loud posture (OQ-TN-13, ADR-003): a
|
// Hand out staged frame bytes first (a frame larger than the
|
||||||
// datagram LARGER than the caller's buffer is a framing
|
// caller's buffer was split across reads; the frame's length
|
||||||
// violation — reported as an error, never silently truncated
|
// prefix already crossed, so the pump's de-framer on the far
|
||||||
// (`poll_recv`-into-ReadBuf would truncate; the scratch-buffer
|
// side reassembles — no shorter-datagram misread is possible).
|
||||||
// recv + size check keeps the invariant observable). The
|
if !this.stage.pending.is_empty() {
|
||||||
// codec's u16 bound keeps well-formed peers off this path.
|
let n = this.stage.pending.len().min(buf.remaining());
|
||||||
let scratch = &mut this.recv_scratch;
|
buf.put_slice(&this.stage.pending.split_to(n));
|
||||||
|
return Poll::Ready(Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recv one raw datagram (full-size scratch — try_recv
|
||||||
|
// truncates silently at the buffer size, so the scratch must
|
||||||
|
// hold the largest possible datagram before any size check).
|
||||||
|
if this.stage.recv_scratch.is_none() {
|
||||||
|
this.stage.recv_scratch = Some(vec![0u8; MAX_DATAGRAM_LEN]);
|
||||||
|
}
|
||||||
|
let scratch = this
|
||||||
|
.stage
|
||||||
|
.recv_scratch
|
||||||
|
.as_deref_mut()
|
||||||
|
.expect("scratch allocated above");
|
||||||
match this.sock.try_recv(scratch) {
|
match this.sock.try_recv(scratch) {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
if n > buf.remaining() {
|
let framed = frame_datagram(&scratch[..n]).map_err(|e| match e {
|
||||||
return std::task::Poll::Ready(Err(std::io::Error::new(
|
// 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,
|
std::io::ErrorKind::InvalidData,
|
||||||
format!(
|
format!(
|
||||||
"udp datagram {n} bytes exceeds caller buffer {} (truncation fails loud — OQ-TN-13)",
|
"udp datagram {len} bytes exceeds the codec frame bound {max} \
|
||||||
buf.remaining()
|
(truncation fails loud — OQ-TN-13)"
|
||||||
),
|
),
|
||||||
)));
|
),
|
||||||
|
DatagramCodecError::InvalidLength { .. } => {
|
||||||
|
unreachable!("frame_datagram never yields InvalidLength")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if framed.len() <= buf.remaining() {
|
||||||
|
buf.put_slice(&framed);
|
||||||
|
} else {
|
||||||
|
// The frame exceeds the caller's buffer: hand out
|
||||||
|
// the first slice, stage the rest (the length
|
||||||
|
// prefix rode along — the far-side de-framer
|
||||||
|
// reassembles across reads).
|
||||||
|
let n = buf.remaining();
|
||||||
|
buf.put_slice(&framed[..n]);
|
||||||
|
this.stage.pending = framed.slice(n..);
|
||||||
}
|
}
|
||||||
buf.put_slice(&scratch[..n]);
|
Poll::Ready(Ok(()))
|
||||||
std::task::Poll::Ready(Ok(()))
|
|
||||||
}
|
}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
// Not ready: poll readiness to register the waker,
|
// Not ready: poll readiness to register the waker,
|
||||||
// then Pending (the next wake retries the recv).
|
// then Pending (the next wake retries the recv).
|
||||||
match this.sock.poll_recv_ready(cx) {
|
match this.sock.poll_recv_ready(cx) {
|
||||||
std::task::Poll::Ready(Ok(())) => std::task::Poll::Pending,
|
Poll::Ready(Ok(())) => Poll::Pending,
|
||||||
std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Err(e)),
|
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
||||||
std::task::Poll::Pending => std::task::Poll::Pending,
|
Poll::Pending => Poll::Pending,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => std::task::Poll::Ready(Err(e)),
|
Err(e) => Poll::Ready(Err(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,39 +365,122 @@ impl tokio::io::AsyncWrite for UdpHalf {
|
|||||||
cx: &mut std::task::Context<'_>,
|
cx: &mut std::task::Context<'_>,
|
||||||
buf: &[u8],
|
buf: &[u8],
|
||||||
) -> std::task::Poll<std::io::Result<usize>> {
|
) -> std::task::Poll<std::io::Result<usize>> {
|
||||||
|
use std::task::Poll;
|
||||||
let this = self.get_mut();
|
let this = self.get_mut();
|
||||||
debug_assert_eq!(this.kind, UdpHalfKind::Write);
|
debug_assert_eq!(this.kind, UdpHalfKind::Write);
|
||||||
match this.sock.try_send(buf) {
|
let offered = buf.len();
|
||||||
Ok(n) => std::task::Poll::Ready(Ok(n)),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
// Backpressure: if too many accepted bytes are still queued
|
||||||
// Poll send-readiness to register the waker, then
|
// for the socket, try to drain first; if the queue is still
|
||||||
// Pending (the next wake retries the send).
|
// over the line, return Pending (the pump's copy re-offers
|
||||||
match this.sock.poll_send_ready(cx) {
|
// its window unsatisfied — safe, nothing is lost).
|
||||||
std::task::Poll::Ready(Ok(())) => std::task::Poll::Pending,
|
if this.deframe.accepted > WRITE_QUEUE_HIGH_WATER {
|
||||||
std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Err(e)),
|
match this.pump_queue_to_socket(cx) {
|
||||||
std::task::Poll::Pending => std::task::Poll::Pending,
|
Poll::Ready(Ok(_)) => {}
|
||||||
|
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
||||||
|
Poll::Pending => {
|
||||||
|
if this.deframe.accepted > WRITE_QUEUE_HIGH_WATER {
|
||||||
|
return Poll::Pending;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => std::task::Poll::Ready(Err(e)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accept ALL offered bytes into the incremental de-framer and
|
||||||
|
// report them all consumed. The pump's copy window cannot be
|
||||||
|
// held closed for a later top-up (a full window is re-offered
|
||||||
|
// unsatisfied until drained — a partial-frame tail could sit
|
||||||
|
// forever in a full window), and reporting fewer than all
|
||||||
|
// bytes risks the copy's `WriteZero` on an empty re-offer.
|
||||||
|
// The DatagramReader's partial-frame state is bounded by
|
||||||
|
// 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 {
|
||||||
|
this.deframe.accepted += dg.len() + 2;
|
||||||
|
this.deframe.queue.push_back(dg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opportunistic send: drain whatever the socket accepts now.
|
||||||
|
// The waker is registered on WouldBlock (the next
|
||||||
|
// poll_send_ready wake retries); the queue never blocks
|
||||||
|
// poll_write's accept path unless the high-water mark is hit.
|
||||||
|
let _ = this.pump_queue_to_socket(cx);
|
||||||
|
|
||||||
|
Poll::Ready(Ok(offered))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll_flush(
|
fn poll_flush(
|
||||||
self: std::pin::Pin<&mut Self>,
|
self: std::pin::Pin<&mut Self>,
|
||||||
_cx: &mut std::task::Context<'_>,
|
cx: &mut std::task::Context<'_>,
|
||||||
) -> std::task::Poll<std::io::Result<()>> {
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
std::task::Poll::Ready(Ok(()))
|
// The send pump: tokio's copy polls flush after draining its
|
||||||
|
// window, so the queued datagrams drain here whenever the
|
||||||
|
// pump cycles — and a flush-resolved Ready(Ok(())) tells the
|
||||||
|
// copy the bytes are on the wire. UDP sends are all-or-nothing
|
||||||
|
// per datagram; a WouldBlock keeps the datagram queued (the
|
||||||
|
// send waker is registered) and the queue persists until it
|
||||||
|
// drains.
|
||||||
|
self.get_mut().pump_queue_to_socket(cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll_shutdown(
|
fn poll_shutdown(
|
||||||
self: std::pin::Pin<&mut Self>,
|
self: std::pin::Pin<&mut Self>,
|
||||||
_cx: &mut std::task::Context<'_>,
|
cx: &mut std::task::Context<'_>,
|
||||||
) -> std::task::Poll<std::io::Result<()>> {
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
// UDP has no shutdown; drop is the close.
|
// UDP has no shutdown; drop is the close. Drain the queue
|
||||||
|
// best-effort on the way out (readiness is already there if
|
||||||
|
// the datagrams were sendable).
|
||||||
|
let _ = self.get_mut().pump_queue_to_socket(cx);
|
||||||
std::task::Poll::Ready(Ok(()))
|
std::task::Poll::Ready(Ok(()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl UdpHalf {
|
||||||
|
/// Send as many queued datagrams as the socket accepts. Returns
|
||||||
|
/// `Ready(Ok(()))` when the queue is empty; WouldBlock registers
|
||||||
|
/// the send waker (via `poll_send_ready`) and keeps the remainder
|
||||||
|
/// queued for the next flush/wake.
|
||||||
|
fn pump_queue_to_socket(
|
||||||
|
&mut self,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
use std::task::Poll;
|
||||||
|
loop {
|
||||||
|
let dg = match self
|
||||||
|
.deframe
|
||||||
|
.sending
|
||||||
|
.take()
|
||||||
|
.or_else(|| self.deframe.queue.pop_front())
|
||||||
|
{
|
||||||
|
Some(dg) => dg,
|
||||||
|
None => return Poll::Ready(Ok(())), // queue drained
|
||||||
|
};
|
||||||
|
match self.sock.try_send(&dg) {
|
||||||
|
Ok(_) => {
|
||||||
|
self.deframe.accepted -= dg.len() + 2;
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
self.deframe.sending = Some(dg);
|
||||||
|
// Register the waker for the retry.
|
||||||
|
return match self.sock.poll_send_ready(cx) {
|
||||||
|
Poll::Ready(Ok(())) => Poll::Pending,
|
||||||
|
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => return Poll::Ready(Err(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Unix ---------------------------------------------------------------
|
// --- Unix ---------------------------------------------------------------
|
||||||
|
|
||||||
/// Dial a unix-socket target (OQ-TN-14 — ships in v1, the same halves
|
/// Dial a unix-socket target (OQ-TN-14 — ships in v1, the same halves
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
---
|
---
|
||||||
id: tunnels/fix-udp-framed-adapter
|
id: tunnels/fix-udp-framed-adapter
|
||||||
name: "U-1 remediation — make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)"
|
name: "U-1 remediation — make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)"
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [tunnels/review-impl]
|
depends_on: [tunnels/review-impl]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: medium
|
risk: medium
|
||||||
impact: component
|
impact: component
|
||||||
level: implementation
|
level: implementation
|
||||||
tags: [local, udp, codec, adr-003, review-remediation]
|
tags: [local, udp, codec, adr-003, review-remediation]
|
||||||
|
completed: 2026-09-08
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
@@ -126,11 +127,45 @@ mitigation must keep the full-size read underneath; see U-3 below.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `UdpHalf` frames on write and de-frames on read (ADR-003's
|
- [x] `UdpHalf` frames on write and de-frames on read (ADR-003's
|
||||||
placement; the pump stays raw)
|
placement; the pump stays raw)
|
||||||
- [ ] The 5 verification gates pass; suites green 3× in both configs
|
- [x] The 5 verification gates pass; suites green 3× in both configs
|
||||||
- [ ] U-2/U-3 riders resolved or explicitly deferred with rationale
|
- [x] U-2/U-3 riders resolved or explicitly deferred with rationale
|
||||||
- [ ] Review 001's U-1/U-2/U-3/N-1 sections get resolution notes
|
- [x] Review 001's U-1/U-2/U-3/N-1 sections get resolution notes
|
||||||
|
|
||||||
|
## Resolution (2026-09-08)
|
||||||
|
|
||||||
|
All acceptance criteria met. Key implementation notes beyond the
|
||||||
|
sketch (pinned in ADR-003's §Amendment):
|
||||||
|
|
||||||
|
- **Orientation** (the caution held): de-frame on write, frame on
|
||||||
|
read — the channel side is the framed side.
|
||||||
|
- **The write half accepts ALL offered bytes** and queues completed
|
||||||
|
datagrams for the socket: tokio's `copy` re-offers a FULL window
|
||||||
|
unsatisfied until drained (its `read_done` path never tops up), so
|
||||||
|
the "hold the partial tail, re-feed" shape deadlocked gate 2 (the
|
||||||
|
9000-byte test) and `Ready(Ok(0))` on a partial-only feed tripped
|
||||||
|
the copy's `WriteZero` abrupt-close. The `DatagramReader`'s
|
||||||
|
partial-frame state is bounded by construction (u16 cap — review
|
||||||
|
001 N-2); backpressure is an accepted-byte high-water mark
|
||||||
|
(128 KiB), and the socket drain pumps on write/flush/shutdown
|
||||||
|
(copy polls flush after every window drain).
|
||||||
|
- **A >caller-buffer frame stages across reads** (length prefix
|
||||||
|
first, remainder on the next read) — gate 4's predicted
|
||||||
|
assertion-surface change. The fail-loud probe is now the 65507-byte
|
||||||
|
max datagram reassembling through 16 KiB reads
|
||||||
|
(`truncation_fails_loud_oq_tn_13`, rewritten) plus the direct
|
||||||
|
staging test (`oversized_frame_stages_across_reads`).
|
||||||
|
- **U-2**: `connect_udp` resolves the target and binds the
|
||||||
|
unspecified address of the MATCHING family; `bind_tcp` gained the
|
||||||
|
wildcard-exposure doc warning. Pinned by a real `::1` round-trip
|
||||||
|
(`connect_udp_binds_the_matching_family_v6_targets_dial`).
|
||||||
|
- **U-3**: the 65535 scratch allocates lazily on first recv (the
|
||||||
|
full-size read kept — the MTU-shrink idea confirmed unsafe).
|
||||||
|
|
||||||
|
Verification: 63 default / 76 `local` tests green ×3 repeat runs;
|
||||||
|
clippy `-D warnings` (all-targets + wasm32), fmt, doc, publish
|
||||||
|
dry-run, wasm check all clean.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
+308
-23
@@ -20,13 +20,6 @@ use alktunnels::TunnelSession;
|
|||||||
|
|
||||||
use harness::wire_forward;
|
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.
|
/// A TCP echo server on an ephemeral port.
|
||||||
async fn tcp_echo_server() -> std::net::SocketAddr {
|
async fn tcp_echo_server() -> std::net::SocketAddr {
|
||||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
@@ -190,10 +183,17 @@ async fn unix_dial_round_trips() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn truncation_fails_loud_oq_tn_13() {
|
async fn truncation_fails_loud_oq_tn_13() {
|
||||||
// A receive buffer smaller than the datagram surfaces as an error
|
// The fail-loud posture (OQ-TN-13) at the FRAMED adapter: the
|
||||||
// (never a silent truncation). Probed at the adapter level: feed a
|
// socket-facing recv uses the full 65535-byte scratch (try_recv
|
||||||
// >buffer datagram directly into a UdpHalf pair and read with a
|
// truncates silently at the buffer size), so a well-formed
|
||||||
// small buffer.
|
// >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))
|
let sock_a = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
||||||
.await
|
.await
|
||||||
.expect("bind a");
|
.expect("bind a");
|
||||||
@@ -210,24 +210,309 @@ async fn truncation_fails_loud_oq_tn_13() {
|
|||||||
.expect("connect b");
|
.expect("connect b");
|
||||||
|
|
||||||
// The producer-side adapter (what the establisher dials): sock_a
|
// 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 (read, _write) = alktunnels::local::UdpHalf::split(sock_a);
|
||||||
let mut read = read;
|
let mut read = read;
|
||||||
let payload = vec![0u8; 60000]; // well-formed for UDP but big
|
let payload: Vec<u8> = (0..65507u32).map(|i| (i % 251) as u8).collect();
|
||||||
sock_b.send(&payload).await.expect("send big");
|
sock_b.send(&payload).await.expect("send max-size");
|
||||||
|
|
||||||
// The pump reads with the codec's chunk size (16 KiB) — smaller
|
// The pump reads with the codec's chunk size (16 KiB) — far
|
||||||
// than 60000: the adapter must FAIL LOUD, not truncate.
|
// smaller than the datagram: the adapter must STAGE, never
|
||||||
|
// truncate. Drain through small reads and reassemble the frame.
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
let mut got = Vec::new();
|
||||||
let mut chunk = vec![0u8; 16 * 1024];
|
let mut chunk = vec![0u8; 16 * 1024];
|
||||||
let result =
|
while got.len() < payload.len() + 2 {
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read(&mut chunk)).await;
|
let n = tokio::time::timeout(std::time::Duration::from_secs(5), read.read(&mut chunk))
|
||||||
match result {
|
.await
|
||||||
Ok(Err(e)) => {
|
.expect("read timed out (datagram truncated?)")
|
||||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
.expect("read ok");
|
||||||
}
|
assert!(n > 0, "zero-length read before the frame completed");
|
||||||
other => panic!("expected InvalidData error, got {other:?}"),
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in new issue
Block a user