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.
165 lines
8.6 KiB
Markdown
165 lines
8.6 KiB
Markdown
# ADR-003: Data-Plane Codec — Raw Pass-Through (Stream) / Mandatory Length Framing (UDP)
|
|
|
|
## Status
|
|
|
|
Accepted (2026-09-07; supersedes the Phase 0 "codec direction" hunch
|
|
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
|
|
|
|
The tunnel data plane rides a channels data channel: raw bytes inside
|
|
the channel `BiStream` (the channels layer strips its 8-byte header
|
|
transparently — alknet ADR-093/ADR-071; AGENTS.md convention 5). The
|
|
tunnel protocol owns whatever framing it puts inside the `BiStream`.
|
|
A tunnel has one data stream per direction, so there is no
|
|
sub-demux key — a 5-byte header like alktty's (with `stream_type`)
|
|
would be pure overhead here.
|
|
|
|
The Phase 0 direction (`phase-0-findings.md` checklist, 2026-09-06):
|
|
raw pass-through for stream substrates (0 B tunnel overhead),
|
|
`[len: u16 BE]` per datagram for UDP (2 B/datagram), with the
|
|
empty-datagram (`len=0`) semantic left as a Phase 1 residue.
|
|
|
|
Two validation passes sharpened this:
|
|
|
|
1. **Forward POC (2026-09-06, 17 tests):** the codec round-trips
|
|
datagrams split across chunks, batched in one chunk, partial
|
|
headers, and empty datagrams (`len=0` is a legal payload, NOT EOF
|
|
— EOF is the channels-level `length=0` chunk sentinel; the two
|
|
coexist at different layers). The 1400-byte (max ethernet MTU
|
|
payload) datagram rides the bounded-buffer path; >65535 is
|
|
rejected at frame time.
|
|
2. **Reverse POC finding F-2 (2026-09-07, executable-pinned):** in
|
|
the RAW pass-through pump shape, an empty UDP datagram is a
|
|
zero-byte read from the substrate adapter — `tokio::io::copy`
|
|
treats `Ok(0)` as end-of-stream and shuts the pump down. An empty
|
|
datagram and the zero-length EOF sentinel are the SAME wire shape
|
|
at the pump level. The test pins the non-round-trip so the
|
|
conclusion is executable, not anecdotal.
|
|
|
|
## Decision
|
|
|
|
The data plane inside a `alk/tunnel` channel is:
|
|
|
|
- **Stream substrates (`tcp`, `unix`): raw pass-through.** The halves
|
|
are the tunnel; zero tunnel-level framing. The only wire overhead
|
|
is the channels 8-byte chunk header. A zero-length read on a stream
|
|
substrate is genuinely EOF (stream semantics) — no collision. Both
|
|
pumps run `alkcall::channels::pump_bidi` (ADR-050) over the raw
|
|
halves.
|
|
- **Datagram substrate (`udp`): mandatory `[len: u16 BE][datagram]`
|
|
framing, applied at the substrate/pump boundary.** One datagram per
|
|
length-prefixed frame, on BOTH directions:
|
|
- The **establisher** wraps the dialed UDP socket in the framed
|
|
adapter BEFORE returning the plan (the plan payload is the
|
|
adapter, not a raw socket) — the pump handler stays
|
|
substrate-agnostic (ADR-004's invariant; producer.md's placement).
|
|
- Consumer side: the `TunnelSession`'s datagram variant does the
|
|
same over the adopted halves.
|
|
- **`len=0` is a legal empty datagram** (DNS-over-TCP-style
|
|
zero-payload probes); it round-trips under the codec (forward
|
|
POC). EOF is exclusively the channels-level sentinel; the codec
|
|
never emits zero-length reads, so the layers never collide. This
|
|
is the load-bearing property F-2 validated from a second angle:
|
|
the length prefix makes an empty datagram two bytes — unambiguous
|
|
with EOF.
|
|
- **MTU discipline:** the codec rejects framing a >65535-byte
|
|
datagram (`Oversize` error at frame time, never a wire overflow —
|
|
the u16 length field would wrap). A truncated receive (caller
|
|
buffer smaller than the datagram) fails loudly at the adapter
|
|
level — resolved 2026-09-07 (OQ-TN-13, fail-loud): silent
|
|
truncation would corrupt the framing invariants (the same
|
|
invariant class F-2 protects).
|
|
- **Chunking is transparent.** Datagrams split across channel chunks,
|
|
batch into single chunks, and survive partial headers — the
|
|
incremental decoder is the only consumer-visible decode path
|
|
(forward POC: maximally awkward 7-byte chunk splits verified).
|
|
|
|
## 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
|
|
`direct-tcpip` experience (the payload is the bytes).
|
|
- **UDP correctness requires the codec** — raw pass-through over UDP
|
|
is structurally broken for empty datagrams (F-2). The substrate
|
|
discriminator (ADR-001) is what tells both sides to engage the
|
|
codec; a consumer bypassing it for UDP is a spec violation, not a
|
|
degraded mode.
|
|
- **`tokio::io::copy`-shaped pumps fit UDP only through the framed
|
|
adapter** (the `UdpHalf` + codec wrapper presents
|
|
`AsyncRead`/`AsyncWrite` whose zero-length read never occurs for a
|
|
well-formed stream — `len=0` yields a 2-byte wire frame).
|
|
- **The BAST document exists** (AGENTS.md convention 12 — the
|
|
mandatory framing IS binary framing beyond pass-through, so the
|
|
trigger fired): `docs/architecture/bast.md` describes the
|
|
`[len: u16 BE]` datagram frame per the BAST meta-schema; the
|
|
hand-rolled codec remains the runtime implementation.
|
|
- **No mid-stream control frames.** Pump-phase failures are
|
|
EOF-shaped by design (alkcall ADR-049 §6); there is no tunnel-level
|
|
error frame, no KEEPALIVE (channels transport liveness is
|
|
upstream's), no flow-expiry frame (producer-side bookkeeping).
|
|
|
|
## References
|
|
|
|
- OQ-TN-02 (promoted), OQ-TN-13 (resolved by this ADR's fail-loud
|
|
posture)
|
|
- Forward POC: `docs/research/poc-summary.md` §3 (codec decision +
|
|
sentinel layering + MTU); reverse POC:
|
|
`docs/research/reverse-poc-summary.md` §F-2 (the mandate)
|
|
- `docs/research/ssh-socks5-survey.md` §UDP (tun2proxy udpgw framing
|
|
precedent — no CONN_ID, no FLAGS; boundary framing only)
|
|
- alknet ADR-071/093 (channels wire format; the zero-length sentinel)
|
|
- alkcall `channels::pump_bidi` (ADR-050) |