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:
1 parent
921aaf3a9a
commit
c77c28c28e
2 files changed
+232
-2
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user