Files
alksocks/docs/research/poc-udp-associate-findings.md
T
glm-5.3-flash c77c28c28e docs: POC #2 (channels-native UDP ASSOCIATE) findings
- alksocks-udp-poc (/workspace/alksocks-udp-poc/): 11 tests pass,
  clippy -D warnings + fmt clean; the handle-swap baseline validated
  end to end (one channel per association, [len][addr+data] frames
  in-band, RSV/FRAG only at front doors, channel EOF = association
  lifetime, per-datagram addressing without a flow table)
- Both egress variants implemented behind the EgressFactory callback;
  composed per-destination alktunnels udp-tunnel open cost measured at
  ~0.8 ms mean (N=20, in-process loopback) — comfortable for DNS +
  browsing
- Front-door RFC-codec composition confirmed (RFC UDP header codec at
  the vanilla boundary only); CONNECT regression-guarded on the same
  registration
- Load-bearing API findings for Phase 1: the select!-driven stage's
  egress readiness contract (Pending-when-idle; readable() not
  writable(); closed queues end, never spin) and the length framing
  applied exactly once by the wire layer (egress returns inner bytes)

Verification: cargo test (11 pass), cargo clippy --all-targets -- -D
warnings, cargo fmt --check, cargo doc --no-deps (all clean in the
POC crate); phase-0.md OQ-SK-03/07 statuses updated.
2026-09-14 11:03:24 +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 — 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 — 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, no flow table — 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 — 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.