- 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
161 lines
7.9 KiB
Markdown
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. |