- 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
8.0 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| implementation/core-consumer-associate | Core: consumer ASSOCIATE, AssociateSession + e2e tests | completed |
|
moderate | medium | component | 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):
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).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.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).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).- Teardown ownership: the session owns the channel; on Drop it
closes/joins (alktunnels ADR-005 posture). No
Clone(compile-asserted). - 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
AssociateSessionwithassociate/send_datagram/recv_datagram/shutdownper the POC shape- Reader state persists across
recv_datagramcalls (the split-datagram test) - Framing-once mirror (send frames exactly once; recv decodes incrementally)
- Teardown ownership on Drop; no
Clone(compile-asserted) - E2E tests through the harness (round-trips, shutdown, Drop teardown, regression guard)
cargo testgreen; 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_datagramtakesimpl 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 asconnect. AToTargetAddr for &TargetAddrimpl was added to the forked address codec (our owned code, ADR-013) so callers can reuse aTargetAddrvalue 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;Dropcannot await) + the data plane EOFs structurally (the mux's implicit-EOF sentinel);shutdown()half-closes (the graceful path, returning the reap result);close()is theshutdown+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_datagramreturnsResult<Option<(TargetAddr, Bytes)>>(the POC's shape):Ok(None)= channel EOF (the association's end); a stream end mid-frame is fail-loud (anUnexpectedEofI/O error throughSocksSessionError::Rfc; never a silent partial datagram); an undecodable frame (garbage ATYP) errors loud through the wire codec'sWireError→ RFC mapping (AddrErrorclass for address-encoding refusals,UdpHeaderErrorfor the garbage shapes, the consumer-side mirror of the producer stage'sUndecodableFrameexit).- 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, sincedecode_innerstays private). - Unit tests ride a duplex-backed session (
skip_auth's handshake-freeuse_streampath over aBiStream::from_joinedduplex, 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'secho_egress/refusing_egressclosures cover every test. - The lib.rs re-exports gained
AssociateSession+TakenAssociateInner; the forkedtarget_addr.rsgained the&TargetAddrToTargetAddrimpl (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.