- 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
159 lines
8.0 KiB
Markdown
159 lines
8.0 KiB
Markdown
---
|
|
id: implementation/core-consumer-associate
|
|
name: "Core: consumer ASSOCIATE, AssociateSession + e2e tests"
|
|
status: completed
|
|
depends_on: [implementation/core-consumer-connect, implementation/core-producer-associate]
|
|
scope: moderate
|
|
risk: medium
|
|
impact: component
|
|
level: implementation
|
|
---
|
|
|
|
## Description
|
|
|
|
Implement the UDP ASSOCIATE consumer session in `src/consumer.rs`
|
|
per consumer.md §The associate session (POC #2's shape): the typed
|
|
client that opens the channel, receives the sentinel, and speaks the
|
|
framed datagram stage.
|
|
|
|
Deliverables (port from `/workspace/alksocks-udp-poc/src/consumer.rs`,
|
|
adapted to the crate's wire codec):
|
|
|
|
1. **`AssociateSession::associate(client)`**: opens the channel,
|
|
negotiates (NoAuth), sends ASSOCIATE, reads the reply;
|
|
**receives the sentinel BND.ADDR and never uses it for
|
|
addressing** (the handle is the channel; ADR-003).
|
|
2. **`send_datagram(dst, bytes)`**: frames once
|
|
(`[len: u16 BE][addr + data]` via the crate's wire codec, the
|
|
framing-once mirror) and writes the frame.
|
|
3. **`recv_datagram()`** returns `(source_addr, bytes)` per
|
|
datagram, and **the incremental reader state (mid-frame position +
|
|
batched-datagram pending queue) persists across calls**: a
|
|
datagram split across channel chunks survives a caller-driven
|
|
receive loop (POC #2's finding: the first cut dropped the reader
|
|
state per call; the 2-byte-prefix chunk test exposed it). Write
|
|
the persistence test (the POC's chunk-splitting echo shape).
|
|
4. **`shutdown()`** half-closes the stream: the mux's implicit-EOF
|
|
sentinel reaches the producer's stage as EOF and the association
|
|
ends (association lifetime = channel lifetime).
|
|
5. **Teardown ownership**: the session owns the channel; on Drop it
|
|
closes/joins (alktunnels ADR-005 posture). No `Clone`
|
|
(compile-asserted).
|
|
6. **Tests** (end-to-end through the harness): associate →
|
|
send/recv round-trips (multiple in-order datagrams, one
|
|
association); the split-datagram persistence test; empty-payload
|
|
datagram round-trip; shutdown → the producer's stage ends and a
|
|
fresh associate succeeds immediately (the POC's test 4, from the
|
|
consumer side); Drop teardown (drop mid-stream → the producer's
|
|
stage sees EOF, the connection stays healthy); CONNECT +
|
|
ASSOCIATE on the same registration (the regression guard
|
|
extends).
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [x] `AssociateSession` with `associate`/`send_datagram`/
|
|
`recv_datagram`/`shutdown` per the POC shape
|
|
- [x] Reader state persists across `recv_datagram` calls (the
|
|
split-datagram test)
|
|
- [x] Framing-once mirror (send frames exactly once; recv decodes
|
|
incrementally)
|
|
- [x] Teardown ownership on Drop; no `Clone` (compile-asserted)
|
|
- [x] E2E tests through the harness (round-trips, shutdown, Drop
|
|
teardown, regression guard)
|
|
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
|
|
|
|
## References
|
|
|
|
- docs/architecture/consumer.md §The associate session
|
|
- docs/architecture/decisions/003-udp-handle-swap.md (the consumer
|
|
mirror invariants)
|
|
- POC source: `/workspace/alksocks-udp-poc/src/consumer.rs`
|
|
(`UdpAssociation`, the reader-state finding's origin)
|
|
|
|
## Notes
|
|
|
|
- **`send_datagram` takes `impl ToTargetAddr`** (the CONNECT
|
|
session's target-construction posture; under ADR-012, IP-string forms
|
|
parse, domains pass through unresolved), not the POC's
|
|
`&TargetAddr`: the same entry shape as `connect`. A
|
|
`ToTargetAddr for &TargetAddr` impl was added to the forked
|
|
address codec (our owned code, ADR-013) so callers can reuse a
|
|
`TargetAddr` value across sends.
|
|
- **Teardown ownership matches the CONNECT/BIND sessions** (the
|
|
crate's posture, strengthened from the POC's "close/join"): the
|
|
session owns the adopted channel entry; Drop sync-reaps
|
|
(`teardown_channel`, best-effort; `Drop` cannot await) + the data
|
|
plane EOFs structurally (the mux's implicit-EOF sentinel);
|
|
`shutdown()` half-closes (the graceful path, returning the reap
|
|
result); `close()` is the `shutdown`+reap alias-shaped variant
|
|
kept for the connect/bind symmetry; teardown-at-birth covers the
|
|
in-stream refusal path (a refused ASSOCIATE never leaves a
|
|
phantom channel).
|
|
- **`recv_datagram` returns `Result<Option<(TargetAddr, Bytes)>>`**
|
|
(the POC's shape): `Ok(None)` = channel EOF (the association's
|
|
end); a stream end mid-frame is fail-loud (an `UnexpectedEof`
|
|
I/O error through `SocksSessionError::Rfc`; never a silent
|
|
partial datagram); an undecodable frame (garbage ATYP) errors
|
|
loud through the wire codec's `WireError` → RFC mapping
|
|
(`AddrError` class for address-encoding refusals,
|
|
`UdpHeaderError` for the garbage shapes, the consumer-side
|
|
mirror of the producer stage's `UndecodableFrame` exit).
|
|
- **The pending queue stores decoded datagrams** (`(TargetAddr,
|
|
Bytes)`) rather than the POC's pre-decode inner bytes; decoded
|
|
storage keeps the delivery shape uniform (decode once per frame
|
|
either way; no wire.rs change needed, since `decode_inner` stays
|
|
private).
|
|
- **Unit tests ride a duplex-backed session** (`skip_auth`'s
|
|
handshake-free `use_stream` path over a `BiStream::from_joined`
|
|
duplex, no channels needed) for the reader-internals probes:
|
|
reader-state persistence (the 2-byte-prefix chunk shape, the POC
|
|
finding's trigger), mid-frame EOF fail-loud, garbage-ATYP fail-
|
|
loud, and the wire-error mapping table.
|
|
- **The e2e split-datagram test uses a 60 KiB payload** (60007
|
|
inner bytes, legal under the u16 joint bound, well over the 16
|
|
KB read buffer): first → big → third, in order, with the big
|
|
datagram reassembling across the call boundary and the third
|
|
drained through the pending queue; both halves of the
|
|
persistence invariant pinned end to end.
|
|
- **No harness changes**: the consumer-side topology's
|
|
`wire_consumer_side_with` + the producer-associate task's
|
|
`echo_egress`/`refusing_egress` closures cover every test.
|
|
- The lib.rs re-exports gained `AssociateSession` +
|
|
`TakenAssociateInner`; the forked `target_addr.rs` gained the
|
|
`&TargetAddr` `ToTargetAddr` impl (ergonomics; owned code,
|
|
ADR-013).
|
|
|
|
## Summary
|
|
|
|
Implemented the UDP ASSOCIATE consumer session in `src/consumer.rs`
|
|
(POC #2's shape, adapted to the crate's teardown-owning session
|
|
posture): `AssociateSession::associate` (open → no-auth → ASSOCIATE
|
|
→ reply, capturing the sentinel BND.ADDR as `relay_addr()`,
|
|
received but never used for addressing; the ASSOCIATE DST.ADDR is the
|
|
vanilla-mirror unspecified address), `send_datagram` (frames once
|
|
via the crate's wire `frame()` (the framing-once mirror, the joint
|
|
bound enforced at frame time), `recv_datagram` (the incremental
|
|
`DatagramReader` + batched-datagram pending queue, **state that
|
|
persists across calls**; that is the POC's reader-state finding, with the
|
|
fail-loud shapes: mid-frame EOF and undecodable frames), `shutdown`
|
|
(the half-close; the mux's implicit-EOF sentinel reaches the
|
|
producer's stage as EOF; association lifetime = channel lifetime),
|
|
plus `split`/`into_inner` accessors and the CONNECT sessions'
|
|
teardown ownership (Drop sync-reap + structural EOF, `close()`
|
|
graceful, teardown-at-birth on in-stream refusals, no `Clone`,
|
|
since `compile_fail` is asserted there). Typed error surface throughout: open
|
|
failures are `SocksSessionError::Open` (ADR-049 reasons); the
|
|
missing-egress 0x07 and factory-failure codes surface from
|
|
`associate` as `Rfc`. E2E suite (`tests/consumer-associate.rs`, 8
|
|
tests over the harness): the round-trip (sentinel relay address →
|
|
multiple in-order datagrams, one association), the split-datagram
|
|
persistence test (60 KiB datagram across the call boundary +
|
|
pending-queue drain), empty-payload round-trip (F-2, never EOF),
|
|
shutdown → fresh associate succeeds immediately (the POC's test 4),
|
|
Drop teardown + connection health, the missing-egress refusal
|
|
(typed 0x07, no phantom channel), the egress-factory failure
|
|
mapping, and the CONNECT+ASSOCIATE regression guard on one
|
|
registration (interleaved, both serving). Verification: `cargo
|
|
test` 305 (incl. the 3 doctests; the compile-fail assertions ride
|
|
among them), clippy `-D warnings` (all targets), fmt, `cargo doc
|
|
--no-deps`, and both wasm checks clean. |