- 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
7.9 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| implementation/core-producer-associate | Core: producer ASSOCIATE, the select!-driven datagram stage | completed |
|
moderate | medium | component | 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):
- The ASSOCIATE arm:
reply_success(sentinel), the sentinel127.0.0.1:0BND.ADDR (pure RFC-compatibility surface; the channel is the relay handle, ADR-003), then the datagram stage. - 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_replyisPendingwhen nothing is ready; an instantly-readyOk(None)starves a biased select into livelock. Write the contract test: an egress returning instantOk(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 → egresssendgets the payload) and writes frames viaframe()(egressnext_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).
- The
EgressFactorylifecycle: 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). - Missing-egress refusal: a registration without an
EgressFactoryrefuses ASSOCIATE RFC-faithfully (CommandNotSupported-class), never a panic (ADR-008). - 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
- The datagram stage per the three POC invariants (concurrent select, readiness contract, framing-once)
- Sentinel BND.ADDR in the reply; association lifetime = channel lifetime (EOF teardown tested)
- Readiness-contract test present (instant-Ok(None) egress does not starve the stage)
- Missing-egress refusal RFC-faithful
- CONNECT regression guard passes (one registration, both commands)
cargo testgreen; 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-pathframe()and the reply-pathframe_inner()produce identical wires, pinned byte-for-byte). Exported from lib.rs.DatagramReaderandframe()were already in place from the wire-codec task. - Stage shape (
src/producer.rs):serve_udp_associatedrives the typestate directly (POC #2's shape, norun_udp_proxy_custom), thenrun_datagram_stageis aselect!loop over channel reads ∥egress.next_reply(), returning a typedStageExitdiagnostic (ChannelEof / UndecodableFrame / EgressEnded / ReplyFrameRejected / Io). The egresssendis 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 egressErrend 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 (NetworkUnreachablefor the Unreachable variant), surfaced in-stream asSocksServerError::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
AssociateSessionmirror) is the consumer-associate task; itssend_datagramwill frame viaframe()and itsrecv_datagramwill decode via the persistentDatagramReader; 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:
- Concurrent select shape: both branches of the loop are awaited concurrently; no poll-then-block window (the reply-loss hazard the POC measured).
- 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-readyOk(None)egress must not starve the channel branch (the POC's load-bearing API finding, re-pinned by this suite). - Framing applied exactly once, by the wire layer: the stage
reads frames via
DatagramReader(egresssendgets the bare payload) and writes frames via the newframe_inner(egressnext_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.