17 tests pass (7 codec + 10 integration). The producer/consumer shape, ADR-049 establishment integration, codec decision, and typed establishment errors all validated over alkcall 0.5.0 channels. Key findings folded into the ledger: - OQ-TN-05 resolved for stream substrates: no backend trait — pump_halves generic over boxed halves (TCP into_split, UDP UdpHalf adapter); remaining thread is only the hub re-produce composition question - OQ-TN-06 convergence test satisfied: producer pump and consumer pump are the same shape modulo channel side; extraction decidable in Phase 1 - New finding for the alkcall ledger: OpenHandler JoinHandle semantics are load-bearing and undocumented — a handler that returns before the data plane starts tears the channel down at birth (the POC found this by hanging; the pump must be awaited inline). Plus: Establishment should carry the channel plan (the reserved extension point) to kill the establisher handoff side-channel - UdpHalf truncation semantics flagged for the Phase 1 ADR (recommend fail-loud over silent truncation) POC crate: /workspace/alktunnels-udp-poc (db224cc).
318 lines
16 KiB
Markdown
318 lines
16 KiB
Markdown
---
|
|
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<HashMap<resource, SubstrateHandle>>` + 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<Value> }` 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). |