Files
alksocks/tasks/implementation/core-producer-associate.md
T
glm-5.3-flash e196ed43e3 docs: remove em dashes from repo prose and forbid them going forward
- 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
2026-11-03 19:31:53 +00:00

161 lines
7.9 KiB
Markdown

---
id: implementation/core-producer-associate
name: "Core: producer ASSOCIATE, the select!-driven datagram stage"
status: completed
depends_on: [implementation/core-wire-codec, implementation/core-producer-connect]
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Implement the UDP ASSOCIATE producer path in `src/producer.rs` per
wire.md §UDP ASSOCIATE and ADR-003: replace the dispatch arm's
refusal with the datagram stage (POC #2's validated scope).
Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
+ `egress.rs`, adapted to the crate's wire codec and seam types):
1. **The ASSOCIATE arm**: `reply_success(sentinel)`, the sentinel
`127.0.0.1:0` BND.ADDR (pure RFC-compatibility surface; the
channel is the relay handle, ADR-003), then the datagram stage.
2. **The datagram stage**, `select!`-driven (channel reads ∥ egress
replies), with the POC's three load-bearing findings as
invariants:
- **The concurrent select shape** (not poll-then-block, since the
poll-then-block window loses replies; the concurrent shape is
also the honest full-duplex shape).
- **The egress readiness contract**: `next_reply` is `Pending`
when nothing is ready; an instantly-ready `Ok(None)` starves a
biased select into livelock. **Write the contract test**: an
egress returning instant `Ok(None)` must not starve the stage's
channel branch (the POC's load-bearing API finding, re-pinned
by this suite per ADR-004's "re-pinned by the crate's test
suite").
- **Framing applied exactly once, by the wire layer**: the stage
reads frames via `DatagramReader` (channel → egress `send`
gets the payload) and writes frames via `frame()` (egress
`next_reply`'s inner bytes → the channel). The egress never
sees a length prefix.
- **Association lifetime = channel lifetime**: channel EOF ends
the stage; no extra control vocabulary. A undecodable frame
(garbage ATYP) fails the association loud
(`udp_undecodable_frame_ends_association`).
3. **The `EgressFactory` lifecycle**: called once per ASSOCIATE; the
stage owns the egress; stage exit drops it. Ownership rides the
handle structurally: no `_` bindings of owning values (the POC's
teardown-at-birth witness).
4. **Missing-egress refusal**: a registration without an
`EgressFactory` refuses ASSOCIATE RFC-faithfully
(`CommandNotSupported`-class), never a panic (ADR-008).
5. **Tests** (via the harness + raw RFC client): associate →
sentinel reply → datagram round-trip through the echo egress
(in-order, multiple datagrams, one association); empty-payload
datagram legal (not EOF); undecodable frame ends the association
loud; channel EOF ends the association and the connection stays
healthy (a fresh associate succeeds immediately after, the POC's
test 4); the readiness-contract test; missing-egress refusal;
CONNECT still served on the same registration (the regression
guard).
## Acceptance Criteria
- [x] The datagram stage per the three POC invariants (concurrent
select, readiness contract, framing-once)
- [x] Sentinel BND.ADDR in the reply; association lifetime = channel
lifetime (EOF teardown tested)
- [x] Readiness-contract test present (instant-Ok(None) egress does
not starve the stage)
- [x] Missing-egress refusal RFC-faithful
- [x] CONNECT regression guard passes (one registration, both
commands)
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
## References
- docs/architecture/wire.md §UDP ASSOCIATE (the normative stage)
- docs/architecture/decisions/003-udp-handle-swap.md (the handle
swap; the readiness + framing invariants)
- POC sources: `/workspace/alksocks-udp-poc/src/producer.rs`
(`serve_udp_associate`), `/workspace/alksocks-udp-poc/src/
egress.rs` (the readiness findings' origin)
- docs/architecture/decisions/008-provider-defaults.md (the
missing-callback posture)
## Notes
- **Wire codec addition** (`src/wire.rs`): `frame_inner()`, the
reply-direction half of the framing-once invariant (frames
reply-direction half of the framing-once invariant (frames
pre-encoded inner bytes from the egress; the forward-path
`frame()` and the reply-path `frame_inner()` produce identical
wires, pinned byte-for-byte). Exported from lib.rs. `DatagramReader`
and `frame()` were already in place from the wire-codec task.
- **Stage shape** (`src/producer.rs`): `serve_udp_associate` drives
the typestate directly (POC #2's shape, no `run_udp_proxy_custom`),
then `run_datagram_stage` is a `select!` loop over channel reads ∥
`egress.next_reply()`, returning a typed `StageExit` diagnostic
(ChannelEof / UndecodableFrame / EgressEnded / ReplyFrameRejected /
Io). The egress `send` is awaited inline before the next select
iteration (the POC's measured interleaved shape); no
spawn-per-datagram, no flow table.
- **Exit semantics**: channel EOF is the clean exit (the association
lifetime); undecodable frames (garbage ATYP, `len == 0`) and
egress `Err` end the association loud; a reply the joint bound
rejects (`ReplyFrameRejected`) is its own exit (never a wire
overflow). The stage owns the egress structurally: the egress
binding is moved into the stage call; every exit drops it
(drop-witness test pins the teardown-at-birth discipline).
- **Missing-egress refusal**: `CommandNotSupported` (0x07), the
ADR-008 missing-callback class, same shape as CONNECT's
missing-dial refusal. Egress-factory failure (post-command,
pre-reply) → reply#1 with the mapped RFC code
(`NetworkUnreachable` for the Unreachable variant), surfaced
in-stream as `SocksServerError::Bug("egress refused in-stream")`.
- The stale ASSOCIATE-refuses tests (`associate_arm_refuses_…`
in producer.rs, `producer_associate_refuses_0x07_…` in the
harness) were re-pinned to the missing-egress semantics; the
harness three-command regression guard now drives all three
commands serving (CONNECT + BIND + a datagram round-trip).
- The consumer side (the `AssociateSession` mirror) is the
consumer-associate task; its `send_datagram` will frame via
`frame()` and its `recv_datagram` will decode via the persistent
`DatagramReader`; both are already exported.
## Summary
The producer ASSOCIATE path is implemented and verified. The
dispatch arm in `serve_socks5_over` now branches to the datagram
stage: sentinel reply (`127.0.0.1:0`, ADR-003's RFC-compatibility
surface) → the `select!`-driven stage (channel reads ∥ egress
replies) with the three POC invariants as code:
1. **Concurrent select shape**: both branches of the loop are
awaited concurrently; no poll-then-block window (the reply-loss
hazard the POC measured).
2. **The egress readiness contract**: spurious `Ok(None)`s are
tolerated by re-parking; the contract test (`udp_instant_ok_none_egress_does_not_starve_the_stage`)
pins that an instantly-ready `Ok(None)` egress must not starve
the channel branch (the POC's load-bearing API finding,
re-pinned by this suite).
3. **Framing applied exactly once, by the wire layer**: the stage
reads frames via `DatagramReader` (egress `send` gets the bare
payload) and writes frames via the new `frame_inner` (egress
`next_reply`'s inner bytes → the channel); the egress never sees
a length prefix.
Association lifetime = channel lifetime (EOF is the clean exit;
tested in-process and over channels; a fresh associate succeeds
immediately after, the POC's test 4). Missing egress refuses
RFC-faithfully (0x07); egress-factory failure maps to reply#1's
code. CONNECT still serves on the same registration (regression
guards at both layers).
Verification: `cargo test` 174/174 (107 lib + 30 harness + 37
differential), `cargo clippy --all-targets -- -D warnings` clean,
`cargo fmt --check` clean, `cargo check --target
wasm32-unknown-unknown` + `cargo clippy --target
wasm32-unknown-unknown -- -D warnings` clean, `cargo doc --no-deps`
clean.