Files
alktunnels/tasks/tunnels/fix-udp-framed-adapter.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

179 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: tunnels/fix-udp-framed-adapter
name: "U-1 remediation — make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)"
status: completed
depends_on: [tunnels/review-impl]
scope: moderate
risk: medium
impact: component
level: implementation
tags: [local, udp, codec, adr-003, review-remediation]
completed: 2026-09-08
---
## Description
Review 001 finding U-1 (`docs/reviews/001-implementation-review.md`):
the `local` feature's `UdpHalf` (`src/local/mod.rs`) is NOT the framed
adapter ADR-003 mandates at the substrate boundary — it is
boundary-preserving (one datagram per poll_read/poll_write) but raw.
The `[len: u16 BE]` codec exists only on the session side, so
UDP-over-`local` tunnels:
1. deliver codec-framed bytes to real targets (the target sees
`[len]payload`, not payload — executable-pinned in the review
probes);
2. tear the tunnel down on an empty datagram (a raw zero-length
datagram is `Ok(())` with 0 bytes filled = EOF-shaped to
`tokio::io::copy` — the F-2 violation end-to-end; the session-side
codec exists precisely to prevent this);
3. split datagrams > ~8 KiB at the target (`tokio::io::copy`'s
`DEFAULT_BUF_SIZE` bounds each read; the raw adapter sends each
chunk as its own datagram);
4. silently drop unframed target-initiated datagrams (the session
codec parses a raw datagram's first two bytes as a length header).
The session-side tests pass because the harness's
`framed_udp_echo_dial` frames symmetrically at the test boundary — a
correct-shape stand-in for what `connect_udp` itself must do.
### The fix
Apply ADR-003's placement: `UdpHalf` becomes the framed adapter at the
substrate boundary — **as seen from the pump (the channel side)**:
- `poll_write` is fed CHANNEL bytes (already `[len: u16 BE][payload]`
frames from the session's codec) → **de-frame** (an incremental
`DatagramReader` held on the write half) and send each decoded
payload to the target as one raw datagram. `len=0` decodes to a
zero-payload datagram — a real empty datagram, NOT EOF.
- `poll_read` yields bytes destined for the CHANNEL (the session's
codec decodes there) → **frame** (`frame_datagram`) each raw target
datagram and hand the frame bytes to the pump.
Both halves of the association then speak the same framing on both
faces; the target sees payload-only bytes, and an empty datagram
crosses the pump as its 2-byte frame (unambiguous with EOF — the F-2
fix end-to-end).
**Orientation caution** (the review's §U-1 sketch had this inverted
and was corrected in re-validation — read the review's §Re-validation
before implementing): "frame on write, de-frame on read" describes
the TARGET's perspective. `UdpHalf`'s caller is the pump; the channel
side is the framed side. Getting the orientation wrong double-frames
the channel→target direction and leaves unframed target→channel
traffic undecodable — the U-1 defects survive the wrong fix.
The recv scratch (U-3 interplay): the de-frame path needs a FULL
65535-byte recv (tokio's `try_recv` truncates silently — a smaller
scratch corrupts the framing before any size check runs). Any memory
mitigation must keep the full-size read underneath; see U-3 below.
### Verification gates (from the review)
1. A recording UDP echo server asserts the datagrams it receives are
**payload bytes only** and an **empty datagram round-trips**
(session `recv_datagram` yields `Some(b"")`, channel alive).
2. A >8 KiB datagram round-trips as ONE datagram at the target.
3. An unframed target-initiated datagram resolves at the session.
4. The existing `local_halves` suite stays green — EXCEPT
`truncation_fails_loud_oq_tn_13`, whose assertion surface may
legitimately change: once framing composes, a >caller-buffer frame
is a partial-drain read (or an equivalent typed failure) rather
than the raw adapter's exact `InvalidData` at that call. The
fail-loud posture (OQ-TN-13) must hold; the assertion surface may
be updated to match. (Re-validation correction to the review's
"stays green unchanged" wording.)
5. `cargo test` + `--features local` + wasm checks as usual; 3×
repeat-run stable.
### Riders (from the same review, same file)
- U-2 (bind posture, sharpened during review follow-up — read the
whole bullet before implementing): the crate must NOT wildcard-bind
implicitly. Two distinct cases:
- `connect_udp` binds `("0.0.0.0", 0)` — this is a CLIENT socket
(bind-ephemeral → `connect(target)`; the kernel delivers only
datagrams from the connected peer), so its internet-exposure
profile is small — but the v4 hardcoded bind breaks v6-only
targets at connect, and wildcard binds are a bad default
regardless. Fix: resolve the target first, bind the UNSPECIFIED
address of the MATCHING family (`0.0.0.0:0` for v4 targets,
`[::]:0` for v6). Do NOT bind loopback (`127.0.0.1:0`) — that
would break every non-loopback target (a query to `8.8.8.8` can't
route from a loopback-bound socket). No user-supplied bind address
here unless a caller actually needs source-address selection —
keep the signature unless that need exists.
- `bind_tcp` (the listener half) already REQUIRES the caller to
supply the address — that is the correct posture (the assembly
layer decides loopback vs a public interface). Keep it; add a doc
warning that a wildcard bind (`0.0.0.0` / `::`) is the caller's
explicit choice and exposes the listener per the host's firewall.
- Note: `::1` is loopback (the v6 `127.0.0.1`), NOT a wildcard —
the risky v6 address is `::`. The rule is "no implicit wildcard
binds", not "no v6 loopback".
- U-3: the per-tunnel 65535-byte recv scratch is correctness-
load-bearing ON THE DE-FRAME PATH — tokio's `try_recv` truncates
SILENTLY, so a scratch smaller than the largest in-flight datagram
would corrupt the framing before any size check could reject it
(a truncated frame decodes as a shorter declared length,
desynchronizing the framed stream). The review's "size to the MTU
discipline (1400)" suggestion is therefore NOT safe as written
(re-validation correction). Mitigate memory instead: lazy
allocation (allocate on first recv), a shared/pooled scratch
across halves, or justify keeping the eager 65535 allocation.
An explicit oversize-rejection design may layer on top, but only
with a full-size read underneath.
## Acceptance Criteria
- [x] `UdpHalf` frames on write and de-frames on read (ADR-003's
placement; the pump stays raw)
- [x] The 5 verification gates pass; suites green 3× in both configs
- [x] U-2/U-3 riders resolved or explicitly deferred with rationale
- [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
- `docs/reviews/001-implementation-review.md` §U-1 (executable-pinned
probe evidence), §U-2, §U-3, §N-1 — and §Re-validation (2026-09-08:
findings re-confirmed by fresh probes; the fix orientation
corrected; gates 4 and U-3 adjusted as above)
- `docs/architecture/decisions/003-codec-and-udp-framing.md` (the
framed-adapter mandate + F-2), `docs/architecture/wire.md`
§Datagram substrate
- `src/local/mod.rs` (`UdpHalf`), `src/wire.rs` (the codec to compose)