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.
This commit is contained in:
glm-5.3-flash committed 2026-09-14 11:03:24 +00:00
1 parent 921aaf3a9a
commit c77c28c28e
2 files changed
+232 -2

No files matched your search

+13 -2
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-09-13
last_updated: 2026-09-14
---
# alksocks — Phase 0 (Exploration)
@@ -777,6 +777,12 @@ promising approaches"):
`UdpSocket` (no flow table) and composed per-target alktunnels
udp-tunnels (lazy destination→tunnel table, LRU) — and measure the
per-destination open cost of the composed variant empirically.
**Run 2026-09-14 — passed** (`alksocks-udp-poc`, 11 tests; findings
in `poc-udp-associate-findings.md`: the handle-swap data plane
validated end to end, both egress variants implemented + composed
per-destination open cost measured at ~0.8 ms, front-door RFC-codec
composition confirmed, select!-driven stage with the egress
readiness contract as the load-bearing API finding).
3. **noq `AsyncUdpSocket` impl POC** — the client-side story against
noq 1.2: associate through a fast-socks5 server, wrap as
`noq::AsyncUdpSocket`, complete a QUIC handshake. Derisks OQ-SK-06
@@ -877,6 +883,10 @@ Candidate reading for the research specialist (to be expanded):
- [ ] OQ-SK-03 (UDP ASSOCIATE) — the shape space written (including
the phase-model-vs-alktty-demux analysis); resolve via research
+ POC #2, ADR before the first consumer
**(POC #2 run 2026-09-14 — passed; the handle-swap baseline is
POC-validated end to end, findings in
`poc-udp-associate-findings.md`; the datagram framing is
wire-stable once published — the Phase 1 ADR decision remains)**
- [ ] OQ-SK-04 (fast-socks5 wasm) — the decision tree is written
(Case 1: wasm wanted → fork-first; Case 2: wasm unwanted →
plain dep, router path); **POC #4 verified the fork is genuinely
@@ -889,6 +899,7 @@ Candidate reading for the research specialist (to be expanded):
dependency posture) + POC #3 if the research is not decisive
- [x] Targeted POC(s) run + summaries in `docs/research/`
(OQ-SK-07 #1 + #4 complete, `poc-connect-wasm-findings.md`;
#2 UDP ASSOCIATE and #3 noq remain)
#2 UDP ASSOCIATE complete, `poc-udp-associate-findings.md`;
#3 noq remains)
- [ ] Converge: recommended approach written up, ready to hand to the
Architect for Phase 1
+219
View File
@@ -0,0 +1,219 @@
---
status: complete
last_updated: 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.