Files
alktunnels/docs/architecture/decisions/003-codec-and-udp-framing.md
T
glm-5.3-flash 8cf4cc8bb5 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.
2026-09-08 21:22:52 +00:00

8.6 KiB

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)