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

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
implementation/core-wire-codec
implementation/core-producer-connect
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):
  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

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