- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
143 lines
7.2 KiB
Markdown
143 lines
7.2 KiB
Markdown
---
|
|
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. |