--- 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.