Files
alksocks/docs/research/poc-udp-associate-findings.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

12 KiB
Raw Blame History

status, last_updated
status last_updated
complete 2026-09-14

alksocks POC findings: channels-native UDP ASSOCIATE

POC #2 from phase-0 §OQ-SK-07, run 2026-09-14. Crate: alksocks-udp-poc (standalone, /workspace/alksocks-udp-poc/), against alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, local feature), fast-socks5 1.0.0 (crates.io). 11 tests, all pass; cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.

Validates OQ-SK-03's design baseline (the handle swap, settled 2026-09-13): one channel per association; [len: u16 BE][addr + data] frames in-band; channel EOF ends the association; the RFC UDP header codec lives only at vanilla-client front doors. Both producer egress variants are implemented behind the egress callback, and the composed variant's per-destination open cost is measured.

What was validated

  1. The handle-swap data plane was confirmed end to end. The ASSOCIATE conversation and the datagram stage ride the same channel BiStream. The producer drives the typestate directly (accept_no_auth → read_command → reply_success(sentinel)); run_udp_proxy_custom is not used (its unconditional udp_bind_random_port makes it unusable on channels, per the corrected §Prior art caveat; driving the typestate directly is the workaround the upstream ask would retire).
  2. The in-band frame codec is confirmed. The wire is alktunnels' boundary-preserving length codec (frame_datagram/DatagramReader; empty payload legal, never EOF; frames split/batch across channel chunks transparently) over the RFC address encoding (TargetAddr::to_be_bytes(): ATYP + addr + port). RSV/FRAG do not ride the tunnel wire: fragmentation cannot even be expressed in-band; it is refused at front doors (FRAG ≠ 0 → refuse, the iroh-socks5 v1 posture). The RFC-header codecs (new_udp_header/parse_udp_request) are used only at the front-door boundary, exactly as the baseline specifies.
  3. Per-datagram addressing without a flow table was confirmed. One relay loop per association; each frame carries its destination; the local-egress variant relays through one real UdpSocket with per-datagram send_to/recv_from (iroh-socks5's cut). Five in-order datagrams round-trip over one association (udp_multiple_datagrams_one_association).
  4. Association lifetime = channel lifetime, confirmed. The consumer's shutdown() half-closes the stream; the mux's implicit-EOF sentinel (REQ-CH-01) reaches the producer's stage read as EOF; the stage exits and the connection stays healthy (a fresh associate succeeds immediately after). No extra control vocabulary needed; the channels-level EOF is the association's end-of-life signal, as the iroh-socks5 eval predicted.
  5. Control silence is structurally enforced, confirmed. The frame's first byte is ATYP, so any post-reply control byte parses as a garbage ATYP and fails the association loud (udp_undecodable_frame_ends_association, with the unassigned ATYP 0x7F). The phase model needs no explicit mode byte: the reply itself synchronizes the mode switch.
  6. Both egress variants: implemented and passing.
    • Local egress (LocalEgress): one real UDP socket per association; per-datagram addressing; no flow table. Domain-form destinations resolve at the egress (producer-side DNS, the ssh -D semantic, POC-resolved as "the egress owns resolution").
    • Composed egress (ComposedEgress): a lazy destination→alktunnels-udp-tunnel table (each new destination opens one alk/tunnel channel with substrate: udp), LRU-evicted at a cap; a relay task per tunnel drains replies into the association's shared inbound queue. Per-target ACL comes free (the tunnel resource's ACL governs each destination's egress). The full stack round-trips: SOCKS5 consumer → SOCKS5 producer → composed egress → alk/tunnel channel → tunnel producer → local UDP echo (udp_composed_egress_round_trip).
  7. The open-cost measurement (the empirical OQ-SK-03 input). Per-destination tunnel open, N=20 fresh associations, in-process loopback transport: mean ≈ 810–825 µs, min ≈ 765–800 µs, p95 ≤ ~1.0 ms (three runs: 825/807/819 µs mean). Sub-millisecond; comfortable for DNS + browsing. The 256-channel default cap (ADR-040/041, configurable) bounds any single association's destination fan-out; the wire space itself is u32. The LRU cap + the dedup assertion (open_count == 1 for repeated same-resource destinations) are pinned by udp_composed_egress_lazy_table_lru_and_open_cost.
  8. The front door: RFC codec composition at the vanilla boundary, confirmed. An in-process bridge exposes the association on a real UDP socket; the door's pump decodes RFC-header'd datagrams (parse_udp_request), sends {addr, data} frames in-band, and returns replies RFC-header'd (new_udp_header). An RFC datagram (the shape curl/tun2proxy present) round-trips through the door (udp_front_door_vanilla_client_round_trip). The full raw-pipe + reply-rewrite control door is the crate's component-2 assembly shape (the CONNECT front door in POC #1 already validated that pipe shape); this test pins the UDP-specific claim: RFC codec at the door, frames in-band, zero payload translation.
  9. CONNECT on the same registration, regression-guarded. One ALPN (alk/socks5), one op (channels/socks5/sub), trivial params; the CONNECT path is the POC #1 data plane unchanged (connect_still_served_on_the_same_registration).
  10. The identity-seam wiring requirement re-confirmed for the composed path: the tunnel producer's channel-0 accept hook must set the opener identity (set_identity), or scope-gated tunnel opens fail closed with FORBIDDEN "authentication required"; the same POC #1 finding, now witnessed from the consumer side of the composed stack.

Structural findings (the load-bearing discoveries)

The datagram stage is select!-driven, and the egress reply future's readiness contract is load-bearing. The stage runs channel reads and the egress's reply stream concurrently (tokio::select!). Three readiness hazards were hit and fixed in this POC, all of them generalizable API-design notes for the crate's Phase 1:

  1. The poll-then-block window loses replies. The first iteration interleaved "drain egress replies (non-blocking), then block on the channel read"; the echo's reply arriving during the channel block was never picked up. The fix is the concurrent select shape, which is also the honest full-duplex shape (an egress may produce unsolicited inbound datagrams at any time).
  2. An instantly-ready Ok(None) starves the other branch with biased. A next_reply that returns Ok(None) immediately (drained) makes the biased select take it every iteration, a livelock that starves the mux/demux on a current-thread runtime. The egress contract is therefore: next_reply must be Pending when no reply is ready (Ok(None) is a spurious wakeup, not a hot loop). The LocalEgress parks on readable(); note writable() is the wrong readiness for UDP (always ready, the send-readiness, spins forever); readable() is the datagram-arrival readiness. The ComposedEgress parks on the shared inbound queue's recv(); a closed queue (all relay tasks gone) must end the association (an error), never spin.
  3. The length framing is applied exactly once, by the wire layer. Egress::next_reply returns the inner bytes (addr + data); the producer's write_frame adds the single length prefix. (The first iteration framed in the egress and prefixed in the producer, double framing; the client parsed the length field as ATYP 0x00.)

Double framing is the one wire bug the POC caught, and it localizes a Phase 1 API decision: the egress (dial-callback) layer speaks inner bytes; the length codec is the wire layer's. The consumer side mirrors it: UdpAssociation::send_datagram frames once, its DatagramReader state (mid-frame position + batched-datagram pending queue) persists across recv_datagram calls; a datagram split across channel chunks survives a caller-driven receive loop (the first cut dropped the reader state per call; the echo test's 2-byte-prefix chunks exposed it).

The per-association egress instance. EgressFactory is called once per ASSOCIATE; the datagram stage owns the egress; stage exit drops it (composed tunnels' write halves EOF their channels; the adopted channel entries are reaped by the sessions' Drop, alktunnels ADR-005). One structural bug here: the first cut bound the opened session to a local let _session = ... binding, which dropped at the end of the insert block, tearing the channel down at birth (the relay task saw EOF instantly). The session must live inside the table entry. The crate's Phase 1 equivalent: the egress owns its tunnels end-to-end, and the compiler should enforce it (no _ bindings).

Wire/API notes for the wrapper

  • TargetAddr::to_be_bytes() is the in-band address encoding (ATYP + addr + port), the RFC UDP header minus RSV/FRAG. unframe decodes it by hand (synchronously over a borrowed slice); fast_socks5::util::target_addr::read_address is the async in-stream equivalent. frame/inner/unframe in the POC's datagram.rs are the Phase 1 codec's direct sketch.
  • alktunnels::wire::frame_datagram/DatagramReader are the length codec, reused unchanged, the shared framing story with alktunnels' udp substrate (per the user's codec decision: depend on alktunnels, do not hand-roll).
  • The ASSOCIATE reply's BND.ADDR is the sentinel 127.0.0.1:0 on the channels path (the handle is the channel; the address is pure RFC-compatibility surface). The consumer session receives it (relay_addr) and never uses it for addressing. Only the front door has a real address to report.
  • LocalEgress::next_reply frames replies with the sender's address (the RFC's per-datagram source semantics, recv_from's from), not the original destination. For echo targets the two coincide; a real producer would attribute by egress socket (the composed variant's table entry carries the destination explicitly; its replies are attributed correctly by construction).
  • The composed egress's per-destination attribution: the table's reply_addr is recorded at tunnel-open time (the datagram's original destination); the tunnel IS the per-destination association, the eval's noted advantage of the composed shape.
  • parse_udp_request is async (though it completes immediately over a borrowed slice); a sync mirror (unframe) is used where the decode must not be async. Phase 1 may want an upstream ask: sync pure address-decode helpers (the codec is pure; only its signature is async).

OQ status after this POC

  • OQ-SK-03 (UDP ASSOCIATE): the handle-swap baseline is POC-validated end to end: one channel per association, {addr, data} frames in-band (RSV/FRAG only at front doors), channel EOF = association lifetime, per-datagram addressing without a producer flow table. The datagram framing is wire-stable once published, and the Phase 1 ADR has empirical ground under it. The demux-vs-phase question (the one-way-door extensibility argument) stays an ADR decision for Phase 1 with the first consumer in sight, but the POC adds evidence for the phase model: no in-band control vocabulary was needed, and post-reply garbage is refused structurally.
  • OQ-SK-01 (dial policy): the egress seam is the dial-callback shape extended to the datagram stage (EgressFactory, the DialFn analogue). Both egress variants behind the callback, the protocol layer sees neither; the no-trait, boxed-halves precedent now holds for the datagram plane too.
  • OQ-SK-05 (target policy): the composed egress's per-target-ACL- for-free claim is validated (each destination rides its own tunnel channel, governed by the tunnel resource's ACL). The egress-owns-DNS resolution posture is POC-resolved for the local variant.
  • OQ-SK-07: #1, #2, #4 complete; #3 (noq AsyncUdpSocket) remains.

POC placement

Standalone crate (global workspace, /workspace/alksocks-udp-poc/) per the placement conventions; it composes three published crates (alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code from this repo. Findings land here; the crate is the artifact.