--- id: implementation/door-udp name: "Front doors: the UDP door (the RFC codec boundary)" status: completed depends_on: [implementation/door-tcp] scope: narrow risk: medium impact: component level: implementation --- ## Description The consumer's UDP front door (feature `local`): a real UDP socket exposing an ASSOCIATE association to vanilla clients, the POC #2 test-8 shape. The RFC UDP-header codec lives ONLY at this boundary (ADR-003); the in-band wire carries `{addr, data}` frames. Deliverables: 1. **`src/door.rs` additions** (feature `local`), the UDP door: - Bind a real UDP socket (the relay socket, the only place a real address exists; the vanilla client `sendto()`s it). - Open the association in-band via `AssociateSession::associate` (the reply's sentinel BND.ADDR is received and never used, as the handle is the channel, ADR-003). - **The datagram pump** (two pumps, joined, per the ADR-050 two-pump contract): socket→channel decodes the RFC header (`parse_udp_request`), and **FRAG ≠ 0 → refuse** (drops the datagram; the v1 posture, since fragmentation cannot be expressed in-band), sends `{addr, data}` in-band (`send_datagram`); channel→socket encodes the reply header (`new_udp_header`) and sends the RFC-header'd datagram back to the client's source address. Zero payload translation. - **Association lifetime = channel lifetime** (ADR-003): the door's association ends when the channel ends; the door's UDP socket closes with it. A per-client idle bound (no datagrams within a window → close the association) is the door-side defense (ADR-014), constructor config, mirroring the TCP door's knobs. - The door's bind address is constructor config (consumer.md: "the UDP door's bind address is assembly-layer configuration"). - Surface shape: follow the TCP door's builder (`FrontDoor` additions or a sibling `UdpFrontDoor`; decide at implementation against the landed `door.rs` shape and record in Notes; the spec leaves the exact constructor shape open). 2. **Tests** (`tests/door-udp.rs`): the POC #2 test-8 shape end to end: a real UDP echo target, the producer with `local::udp_ egress()`, the door, a vanilla RFC-header'd datagram in → the echo back RFC-header'd (the vanilla client simulated at the RFC boundary); FRAG ≠ 0 refused (dropped, association stays healthy); the empty-datagram invariant through the door (a zero-length payload round-trips, never EOF, F-2); the idle bound closes an idle association; a fresh association succeeds after teardown. ## Acceptance Criteria - [ ] `cargo test` passes; `cargo test --all-features` passes - [ ] `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check` clean - [ ] Default wasm checks stay green (door code is `local`-gated) - [ ] The RFC UDP-header codec appears only at the door boundary (in-band stays `{addr, data}` frames), pinned by test - [ ] FRAG ≠ 0 refused at the door, pinned by test - [ ] The empty datagram (zero-length payload) round-trips through the door, never EOF, pinned by test (the F-2 invariant at the vanilla boundary) - [ ] Association lifetime = channel lifetime; teardown leaves the connection healthy, pinned by test - [ ] The idle bound is constructor config, pinned by test ## References - docs/architecture/decisions/003-udp-handle-swap.md (the RFC codec at doors only), 014-idle-timeout-posture.md - docs/architecture/consumer.md §The Front Doors (the UDP door), §Wiring - POC reference: `/workspace/alksocks-udp-poc/tests/udp_poc.rs` (`udp_front_door_vanilla_client_round_trip`, the pump shape) - Landed surfaces: `src/consumer.rs` (`AssociateSession`, `send_datagram`/`recv_datagram`/`split`), `src/rfc1928/mod.rs` (`new_udp_header`/`parse_udp_request`), `src/wire.rs` (the frame codec) ## Notes - **Surface shape** (the spec left the constructor open): a **sibling** `UdpFrontDoor` with its own builder (`bind` + `idle`), not `FrontDoor` additions: the UDP door shares no accept loop with the TCP door (UDP has no connections; one relay socket = one association), and the shared shape would have forced a mode split on every knob. `serve(channel_client)` + `serve_on(socket, client)` (the test/assembly seam), mirroring the TCP door. - **The pump shape** (an implementation finding worth recording): the POC #2 shape (both pumps sharing one `AssociateSession` behind a mutex) deadlocks in this landed surface: the channel read parks holding the lock while the socket side's send needs it. The door instead **splits the association's stream** (`AssociateSession::split`, the door bridge seam): one pump owns the in-band write half (socket→channel, framing via the wire codec), the other the read half (channel→socket, the incremental `DatagramReader` decode). The two pumps join via `select!` (whichever ends first closes the door); the pump is inlined in `serve_on` because the split halves borrow the session (the borrow must end before the teardown's `shutdown`). The read half's decode carries a fresh per-call `DatagramReader`, because the door serves one association per socket and the relay loop drains one frame per call; the mid-frame persistence case is the session's own `recv_datagram` contract (tested in consumer-associate). - **The client's source address** is learned from the first inbound datagram (std mutex, held never across an await) and reused for every reply, since the relay's shape is single-client. A reply before the first inbound datagram is parked (none exists in practice). - **FRAG ≠ 0** drops the datagram and keeps the association (the v1 posture); a malformed RFC header also drops (garbage never goes in-band; the in-band refusal shape is the producer's `UndecodableFrame` exit, which the door never triggers by construction). - **The idle bound** covers the socket→channel pump's `recv_from` only (the client's silence is the defense target); the channel→socket pump has no window (its park IS the association's aliveness signal). ## Summary Landed the UDP front door (`src/door.rs`, feature `local`): `UdpFrontDoor`, a real relay UDP socket (constructor-config bind address) bridged onto one in-band association (`AssociateSession::associate`; the sentinel BND.ADDR received, never used). The RFC UDP-header codec appears only at the door boundary (`parse_udp_request` in / `new_udp_header` out); the in-band wire carries `{addr, data}` frames (framed once via the wire codec). FRAG ≠ 0 refused (dropped, association healthy); the empty-datagram invariant round-trips (F-2); association lifetime = channel lifetime (the door's socket closes with the pump; teardown reaps the channel); the client idle bound is constructor config and closes an idle association door-side. Two-pump shape with the split-halves correction recorded in Notes. Tests: `tests/door-udp.rs` (4 tests: the round-trip + empty datagram, FRAG refusal + health, idle bound + fresh-association health, fresh-association-after-teardown). Verification: `cargo test` green, `cargo test --all-features` green, clippy `-D warnings` clean (default + `local` + wasm target), `cargo fmt --check` clean, `cargo doc --no-deps` clean.