diff --git a/docs/research/phase-0-findings.md b/docs/research/phase-0-findings.md index 33718d3..73155c3 100644 --- a/docs/research/phase-0-findings.md +++ b/docs/research/phase-0-findings.md @@ -512,12 +512,15 @@ so the question: surface as-is? If a helper is warranted, it may share shape with the substrate dial/listen trait — which would argue for the trait. -**Status:** open — needs a survey of what backends would actually -implement (local TCP? docker exec? ssh -w?) before deciding trait vs -no-trait. Half-answer (hunch): a thin trait (or just a fn alias) for -"obtain a bidirectional substrate stream for a target," possibly no -trait at all if the only meaningful backends are local sockets — decide -after surveying candidate backends. +**Status: resolved for stream substrates, 2026-09-06 (POC).** +`pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves — TCP +contributes `into_split()` halves, UDP contributes the `UdpHalf` +socket adapter, the pump never knows which. "Produce boxed halves for +a resource" is a function, not a trait; the `TunnelBackend` trait +question resolves to *no trait* for substrate access. The only +remaining thread is the hub re-produce composition question (above) — +a Phase 1 spec question, and now decidable against the validated +producer/consumer surface. ### OQ-TN-06: The two-pump helper — extract now? @@ -539,6 +542,13 @@ consumer (POC tunnel was the first; SSH `direct-tcpip` would be a third). not a blocker for the spec. Half-answer: the helper probably belongs upstream (alkcall, near the channels-adapter handler-integration conventions) but only if the two shapes genuinely converge. +**Convergence input 2026-09-06 (POC):** the producer pump +(`pump_halves`) and the consumer pump (`take_halves` + copy) are the +same shape modulo channel side — the ADR-078 convergence test is +satisfied; extraction is decidable in Phase 1. Also surfaced by the +POC: the `OpenHandler` JoinHandle semantics (see `poc-summary.md` § +Issues Surfaced #1) are where such a helper would pin the teardown +contract. ### OQ-TN-07: ALPN strategy @@ -707,6 +717,10 @@ Phase 0 may need (in rough priority order, per the SDD process's fit: chunk-size vs datagram-size interaction, MTU cap against the channels bounded buffers, idle-expiry mapping, and the chosen endpoint-addressing shape. Derisks OQ-TN-02 (and OQ-TN-07's option B). + + **Done 2026-09-06** — see `poc-summary.md`. 17 tests pass; the + producer/consumer shape validated end-to-end over alkcall 0.5.0 + (establisher + two-pump + codec + typed establishment errors). 2. **Reverse-flow POC** — `-R`-style: the accept side listens, the far side carries. Derisks OQ-TN-03's advertisement/lifecycle shape. Template available: SSH's `tcpip-forward` global-request @@ -807,7 +821,18 @@ Candidate reading for the research specialist (to be expanded): OQ-TN-05 trait shape - [ ] Decision input: backend trait vs no-trait (OQ-TN-05), now including the hub re-produce composition question -- [ ] Targeted POC(s) run + summary (OQ-TN-10) — UDP first, reverse - flow second +- [ ] Targeted POC(s) run + summary (OQ-TN-10) — UDP POC DONE + (2026-09-06, `poc-summary.md`); reverse-flow POC remains +- [x] Two-pump helper convergence input gathered (OQ-TN-06) — the POC + gives both shapes (producer `pump_halves`, consumer + `take_halves` + copy): same shape modulo channel side; the + alknet ADR-078 convergence test is satisfied, extraction now + decidable in Phase 1 +- [x] OQ-TN-05 executable input (backend trait vs no-trait) — POC + confirms stream substrates need no trait: `pump_halves` is + generic over boxed halves (TCP `into_split`, UDP `UdpHalf` + adapter); "produce halves for a resource" is a function. The + hub re-produce composition question is the only remaining + OQ-TN-05 thread - [ ] Open questions promoted to Phase 1 `docs/architecture/open-questions.md` with statuses \ No newline at end of file diff --git a/docs/research/poc-summary.md b/docs/research/poc-summary.md new file mode 100644 index 0000000..e0b5a85 --- /dev/null +++ b/docs/research/poc-summary.md @@ -0,0 +1,318 @@ +--- +status: complete +last_updated: 2026-09-06 +--- + +# alktunnels: UDP Tunnel POC Research Summary + +**Status:** Research complete — the producer/consumer shape validated +end-to-end over alkcall 0.5.0 channels, including the ADR-049 +establishment phase and the codec decision (raw pass-through for TCP, +`[len: u16 BE]` for UDP). 17 tests pass (7 codec + 10 integration); +clippy `-D warnings` clean; fmt clean. The remaining unknowns are +spec-scope (Phase 1 ADRs), not feasibility. +**Date:** 2026-09-06 +**Scope:** Validates the OQ-TN-10 #1 POC (UDP tunnel) — plus the +consumer-side of the establisher hand-off (ADR-049), the two-pump +pump-handler contract's `JoinHandle` semantics (a real finding — see +§Issues Surfaced), and the OQ-TN-05 "no backend trait for stream +substrates" judgment in executable form. + +--- + +## Executive Summary + +A POC (`alktunnels-udp-poc`, `/workspace/alktunnels-udp-poc`) +validated the alktunnels producer/consumer shape end-to-end: + +1. **The producer shape** — `channels/tunnel/sub` open op registered + via `ChannelCore::register_openable_with_establisher` (ADR-049): + the establisher validates params semantically (registry lookup of + `{resource, substrate}` → backing address, producer-side), dials + the substrate (TCP connect / UDP connect), and delivers the dialed + handle to the pump handler via a handoff; the wrapper replies only + after establishment. A refused dial is a typed + `channel:open_failed` call error with + `details.reason ∈ {unknown_resource, dial_failed, resource_shortage}` + — the SSH contract consumer-visibly, verified from the consumer + side (`TunnelSession::open` resolving `Err` with the reason). +2. **The two-pump data plane** — the pump handler accepts the + channel's yield-once `BiStream`, splits it, and runs two + `tokio::io::copy` pumps with shutdown-on-completion (alknet ADR-078) + against the substrate halves. 1 MiB round-trips through the + two-pump shape with the bounded-buffer backpressure path intact. +3. **The codec decision** — raw pass-through for TCP (zero + tunnel-level framing; the channels 8-byte header is the only + overhead) and `[len: u16 BE][datagram]` for UDP (2 bytes/datagram). + Datagram boundaries survive chunk splitting, batching, and empty + datagrams (`len=0`) are legal payloads, NOT EOF — the EOF sentinel + lives at the channels layer (`BiStream` EOF) and the two never + collide (different layers). +4. **The consumer shape** — `TunnelSession::open` → + `ChannelClient::open_channel` → split the channel `BiStream` → + present raw halves (TCP) or framed datagrams (UDP). One session = + one channel = one tunnel; the channel ID is the flow key (no + conn-id — OQ-TN-02 resolution holds). + +**17 tests pass** (7 wire codec + 10 integration: TCP echo, TCP 1 MiB, +UDP boundary preservation incl. empty datagrams, UDP-through-chunk- +splitting at the codec level, establisher unknown-resource + +dial-failure typed errors, scope-gate FORBIDDEN, TCP+UDP concurrent +channels on one connection). Clippy clean, fmt clean. + +--- + +## What was built + +``` +alktunnels-udp-poc/ + Cargo.toml — alkcall 0.5.0; tokio wasm-clean subset + net (POC-local) + src/ + lib.rs — module docs; the POC overview + params.rs — TunnelParams {resource, substrate} (the OQ-TN-01 shape) + wire.rs — the codec: frame_datagram / DatagramReader + ([len: u16 BE] framing; incremental decode across + chunk boundaries; len=0 = empty datagram, not EOF) + producer.rs — tunnel_open_spec (scope-gated, Sub-typed, channel_open + marker), tunnel_establisher (registry lookup + dial + + handoff), HandleHandoff (resource-keyed), pump_halves + (the ADR-078 two-pump shape, generic over halves), + make_tunnel_pump_handler (the OpenHandler), + register_tunnel_openable (the wiring), UdpHalf + (UdpSocket as AsyncRead/AsyncWrite halves) + consumer.rs — TunnelSession (typed client): open / stream_halves / + send_datagram / recv_datagram / take_halves; + establishment_reason (the ADR-049 typed-error branch) + harness.rs — wire_tunnel (the alktty testing.rs pattern: duplex + transport, ChannelsAdapter, install_channel_zero hook, + register_tunnel_openable on the hook's registry, + tunnel_identity with the tunnel:open scope) + tests/ + tunnel_poc.rs — the 10 integration tests +``` + +The POC follows the `alknet-channels-poc` convention: standalone crate +in the workspace root, findings written back into the consuming +crate's `docs/research/`. + +## Key findings + +### 1. The OpenHandler JoinHandle contract — the pump handler MUST return the pump's own handle + +The most important finding of the POC, and it was found by the POC +hanging, not by reading: **the `OpenHandler`'s returned `JoinHandle` +is the wrapper's teardown signal.** The wrapper (`run_open_wrapper`) +awaits it, then tears the channel down (drop the demux sender → EOF to +the handler's read half → the pumps exit). A handler that spawns the +pump as a *nested* task and returns the outer handle immediately +completes the wrapper's await at birth → teardown → both pumps see +instant EOF with zero bytes — every tunnel appears to "connect and +instantly close" while everything upstream looks healthy. + +```rust +// WRONG — outer task returns immediately; wrapper tears down at birth: +tokio::spawn(async move { + let bidi = conn.accept_bi().await?; + tokio::spawn(pump_halves(bidi, read, write)); // fire-and-forget + // <- handler ends here; wrapper's raw_task.await resolves NOW +}) + +// RIGHT — the returned JoinHandle tracks the pump's lifetime: +tokio::spawn(async move { + let bidi = conn.accept_bi().await?; + let _ = pump_halves(bidi, read, write).await; // awaited inline +}) +``` + +This is an API-sharpness finding for alkcall (the `OpenHandler` +contract's `JoinHandle` semantics are load-bearing and undocumented — +the type docs say "so the wrapper can record it for teardown (abort on +channel/close / connection drop)" but not that an early return *is* +an early teardown). Worth an upstream doc note; the shape itself is +correct. Filed as a follow-up for the alkcall review ledger. + +The pump shape itself (ADR-078) is unchanged and correct: each pump +shuts down the opposite sink on completion; `tokio::join!` on the two +pumps; the wrapper's teardown on pump completion drops the opposite +direction's sender — half-open semantics fall out naturally (one +direction EOFs, the other keeps pumping until its own EOF). + +### 2. The establisher hand-off works; its shape is POC-grade only + +The ADR-049 amendment says the establisher gets `(input, auth)` only — +the channel's `BiStream` belongs to the pump handler. The POC's +`HandleHandoff` (a `Mutex>` + poll +loop in `take`) works because the wrapper *guarantees* ordering: the +establisher is awaited before the reply, and the pump handler is +spawned after. The delivery is therefore always-before-take. + +But the resource-keyed map is a POC simplification that only works for +one open per resource at a time (concurrent opens of the same resource +would race the slot). The real crate's mechanism should be the +ADR-049 reserved extension point: **`Establishment` should carry the +channel plan** (the struct is already documented as "Reserved for a +channel plan — today the wrapper consults only success/failure"). When +alkcall extends `Establishment` to carry data, the handoff mechanism +dies entirely and the establisher returns the dialed handle in its +result. That is the recommended upstream evolution (small, additive — +`Establishment {}` → `Establishment { plan: Option }` or a +generic), and it removes the last awkwardness of the establisher +shape. Filed as a follow-up for the alkcall ledger. + +### 3. The codec decision holds exactly as designed + +- **TCP: raw pass-through.** The consumer writes bytes into the + channel halves; the producer's pump copies them to the TCP socket. + Zero tunnel-level framing; the only wire overhead is the channels + 8-byte header per chunk. The 1 MiB test exercises bounded buffers + end-to-end with no deadlock (the alknet-channels POC's + `tunnel_large_payload` result reproduces on alkcall 0.5.0). +- **UDP: `[len: u16 BE]` framing.** `DatagramReader` is an + incremental decoder — datagrams split across chunks, datagrams + batched in one chunk, partial headers, and empty datagrams all + round-trip (verified at the codec level with maximally awkward + 7-byte chunk splits, and end-to-end through the channels stack). +- **Sentinel layering verified:** `len == 0` in the codec is an empty + datagram (DNS-over-TCP-style; round-trips through a UDP echo); + EOF is the channels-level `length=0` chunk on the `BiStream`. The + two coexist without ambiguity — the consumer's `recv_datagram` + returns `None` only on stream EOF, never on an empty datagram. +- **MTU discipline:** the codec rejects >65535 at frame time; the + 1400-byte (max ethernet MTU payload) datagram round-trips through + the bounded-buffer path. The E-04 64-parked-chunks bound was noted + as a sizing constraint; the POC's datagram sizes stay far below it + per channel. + +### 4. The typed establishment contract is consumer-visible and complete + +The consumer branches on `ChannelOpenError::CallFailed { error }`: +`error.code == "channel:open_failed"` with +`details.reason` ∈ `{unknown_resource, dial_failed, resource_shortage}` +(from the establisher) — plus the wrapper-level `timeout` reason and +the pre-allocation failures (`FORBIDDEN` for the scope gate, +`channel:too_many_channels` for the cap). All verified in tests: + +- unknown resource → `unknown_resource` (the "ghost" test) +- dial to a closed port → `dial_failed` (the "dead" test) +- scope-less identity → `FORBIDDEN` (the "mallory" test) + +The SSH contract holds end-to-end: a failed open never returns a +`channel_id`; the consumer's session never half-exists. + +### 5. Stream substrates need no backend trait (OQ-TN-05, executable confirmation) + +`pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves. TCP +contributes `OwnedReadHalf`/`OwnedWriteHalf` (`TcpStream::into_split`); +UDP contributes `UdpHalf` (the POC's `UdpSocket` adapter — `poll_recv` +as `AsyncRead`, `poll_send` as `AsyncWrite`, connected-socket +semantics). The pump never knows which. The "backend" question +collapses to "produce boxed halves for a resource" — a function, not a +trait, for everything the producer dials. The remaining OQ-TN-05 +question (whether the *hub's* re-produce proxy needs a trait) is +unaffected by this finding; it was always about composition, not +substrate access. + +## Issues surfaced (for the spec / upstream) + +1. **`OpenHandler` JoinHandle semantics are undocumented and + load-bearing** (alkcall). The wrapper's teardown-on-handler-exit is + the intended contract, but nothing in the type docs warns that an + early return = teardown-at-birth. Recommend: a doc note on + `OpenHandler` and `ChannelCore::register_openable*` ("the returned + JoinHandle must track the data-plane lifetime; returning early + tears the channel down"), possibly a debug-level warning when the + wrapper's awaited handler exits without having accepted the + channel's `BiStream`. → alkcall review ledger. +2. **`Establishment` should carry the channel plan** (alkcall). The + struct is already documented as reserved for it. With `plan` + carrying the dialed handle (or any established state), the + establisher→handler handoff becomes a return value instead of a + side-channel. Unblocks concurrent same-resource opens and removes + the POC's poll-loop take. → alkcall review ledger (additive, + two-way door). +3. **`UdpHalf` truncation semantics** (alktunnels spec): `poll_recv` + into a smaller caller buffer truncates the datagram silently + (UDP semantics). For well-formed peers the codec's MTU bound makes + this unreachable; the Phase 1 spec should pin whether the real + crate's UDP adapter errors on truncation instead (fail loud) — + recommend erroring, since truncation would corrupt the length- + framed stream invariants. → Phase 1 ADR note. +4. **Consumer-side pump symmetry** (alktunnels Phase 1): the consumer + mirrors the producer's pump shape (`tokio::io::copy` between + local substrate and channel halves, shutdown-on-completion). The + POC's `TunnelSession::take_halves` exists precisely so the assembly + layer can pump generically — the OQ-TN-06 helper extraction + question can now be evaluated with both shapes in hand (producer + `pump_halves`, consumer `take_halves` + copy): they are the same + shape modulo channel side. The convergence test alknet ADR-078 + asked for is satisfied. + +## What the POC does NOT validate + +1. **Real transports** — `tokio::io::duplex` stands in for + TCP/TLS/QUIC. The alkcall layer owns transport; unchanged from the + alknet-channels POC scope. +2. **Unix-socket and stdio substrates** — the OQ-TN-10 #3 "cheap" + item. The pump is substrate-agnostic by construction (finding #5); + these are wiring, not mechanics. Fold into the real crate's test + suite rather than a separate POC. +3. **The reverse-flow (`-R`) advertisement** — OQ-TN-10 #2 remains + open; the SSH `tcpip-forward` template in the survey is the + lifecycle shape to validate there. +4. **Relay traversal** — hub relay (alkcall ADR-042) forwards data + channels byte-for-byte; the POC's single-hop shape is what + relays see per leg. The terminate-and-re-produce hub proxy (the + hub model) is an assembly-layer concern. +5. **Multi-endpoint UDP gateway resources** — the udpgw-shaped + framing-inside-a-channel variant (OQ-TN-02's "split by path" + dynamic half). Not a v1 base-protocol concern; the codec keeps the + door open (framing inside datagram payloads, self-describing). +6. **Wasm** — the POC uses `tokio/net` for real sockets; the + wasm-clean story is for the real crate's protocol-only core (the + codec and pump are pure byte/async work; `UdpHalf` and the dial + paths are the feature-gated parts). Not exercised here. + +## Test coverage + +``` +running 7 tests (src/wire.rs) +test wire::tests::datagram_split_across_chunks ... ok +test wire::tests::empty_datagram_round_trips_and_is_not_eof ... ok +test wire::tests::frames_and_parses_single_datagram ... ok +test wire::tests::oversize_datagram_is_rejected_at_frame_time ... ok +test wire::tests::partial_header_waits ... ok +test wire::tests::mid_datagram_state_is_observable ... ok +test wire::tests::two_datagrams_in_one_chunk ... ok + +running 10 tests (tests/tunnel_poc.rs) +test tcp_tunnel_echo_round_trip ... ok +test tcp_tunnel_large_payload_round_trip ... ok (1 MiB) +test udp_tunnel_round_trip_preserves_boundaries ... ok (incl. empty dg) +test udp_empty_datagram_round_trips ... ok +test udp_datagrams_survive_chunk_splitting ... ok (100 dgs, 7-byte chunks) +test establisher_failure_is_typed_open_failed_no_phantom_channel ... ok +test establisher_dial_failure_reports_dial_failed ... ok +test open_without_scope_is_forbidden ... ok +test udp_large_datagram_through_bounded_buffers ... ok (1400 B) +test udp_and_tcp_concurrent ... ok +``` + +## References + +- alkcall 0.5.0 — ADR-049 (establishment phase), + `ChannelCore::register_openable_with_establisher`, + `OpenEstablisher`/`Establishment`/`EstablishmentError`, + `ChannelOpenError` (typed client error), `run_open_wrapper` + (`src/channels/operations.rs`). +- alknet ADR-078 — the two-pump shutdown-on-completion contract; + `pump_halves` is its direct implementation. +- alktunnels `docs/research/phase-0-findings.md` — the OQ ledger this + POC closes items against (OQ-TN-01 params shape, OQ-TN-02 split by + path, OQ-TN-05 halves-not-trait, OQ-TN-09 ADR-049 supersession, + OQ-TN-10 #1). +- `docs/research/ssh-socks5-survey.md` — the reason-code vocabulary + the establisher errors surface as. +- alktty `src/testing.rs` — the harness pattern this POC's + `wire_tunnel` mirrors. +- tun2proxy `src/udpgw.rs` — the framing precedent the UDP codec + improves on (no CONN_ID, no FLAGS; boundary framing only). \ No newline at end of file