POC at /workspace/alktunnels-reverse-poc (14 tests, clippy/fmt clean, repeat-run stable) over alkcall 0.6.0: - The review 007 non-finding trace confirmed by execution: the hub (accept side) opens tunnel channels toward a connect-side worker serving its own open op (from_connection_with_serving + ChannelOperations::register_on + post-hoc openable registration) - ADR-047 §5 exercised in the Pub-like orientation: the worker (connect side) allocates odd IDs; the hub adopts - R-01 plan flow under concurrency: two same-resource opens, distinct channels, distinct dialed handles — the forward POC's handoff race is structurally gone - OQ-TN-03's last open thread closed: no advertisement op needed — the listener is the initiator's own local resource; a far-side listener is a producer-side listen establisher over the same open op - W4 EOF sentinel propagation validated (half-close semantics); W3 adopter self-reaping pinned as a TunnelSession spec requirement; W2 post-hoc registration viable Findings: - F-1: ChannelPlan's Send+Sync bound constrains plan payloads (socket halves carry + Sync; spec should pin the constraint) - F-2: empty UDP datagrams collide with the EOF sentinel in raw pass-through (pinned by an executable test) — the length-framed codec is mandatory for UDP; raw pass-through stays stream-substrate-only. Second validation of the codec decision; OQ-TN-02 mandate strengthened W1 (connect-side serving path resolves caller identity only via payload auth_token) filed upstream as alkcall consumer-findings ledger CF-005 (alkcall 0d287a9). Verification: cargo test 14/14, clippy --all-targets -D warnings, fmt --check — all clean, repeat-run stable
18 KiB
status, last_updated
| status | last_updated |
|---|---|
| complete | 2026-09-07 |
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. Both upstream findings
below landed in alkcall 0.6.0 (review 007) — see §Resolutions
2026-09-07.
Date: 2026-09-06 (resolutions added 2026-09-07)
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:
- The producer shape —
channels/tunnel/subopen op registered viaChannelCore::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 typedchannel:open_failedcall error withdetails.reason ∈ {unknown_resource, dial_failed, resource_shortage}— the SSH contract consumer-visibly, verified from the consumer side (TunnelSession::openresolvingErrwith the reason). - The two-pump data plane — the pump handler accepts the
channel's yield-once
BiStream, splits it, and runs twotokio::io::copypumps 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. - 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 (BiStreamEOF) and the two never collide (different layers). - The consumer shape —
TunnelSession::open→ChannelClient::open_channel→ split the channelBiStream→ 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.
// 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_payloadresult reproduces on alkcall 0.5.0). - UDP:
[len: u16 BE]framing.DatagramReaderis 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 == 0in the codec is an empty datagram (DNS-over-TCP-style; round-trips through a UDP echo); EOF is the channels-levellength=0chunk on theBiStream. The two coexist without ambiguity — the consumer'srecv_datagramreturnsNoneonly on stream EOF, never on an empty datagram. Mandate strengthened 2026-09-07 (reverse POC F-2): without the codec (raw pass-through), an empty datagram IS a zero-byte read — colliding with EOF at the pump level. UDP must ride the codec. - 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)
OpenHandlerJoinHandle 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 onOpenHandlerandChannelCore::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'sBiStream. → alkcall review ledger.Establishmentshould carry the channel plan (alkcall). The struct is already documented as reserved for it. Withplancarrying 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).UdpHalftruncation semantics (alktunnels spec):poll_recvinto 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.- Consumer-side pump symmetry (alktunnels Phase 1): the consumer
mirrors the producer's pump shape (
tokio::io::copybetween local substrate and channel halves, shutdown-on-completion). The POC'sTunnelSession::take_halvesexists precisely so the assembly layer can pump generically — the OQ-TN-06 helper extraction question can now be evaluated with both shapes in hand (producerpump_halves, consumertake_halves+ copy): they are the same shape modulo channel side. The convergence test alknet ADR-078 asked for is satisfied.
Resolutions 2026-09-07 (alkcall 0.6.0, review 007)
Both upstream findings above landed, with deviations recorded in the review and ADRs:
- JoinHandle semantics (→ R-02) — documented on the
OpenHandlertype and bothregister_openable*methods: the returnedJoinHandlemust track the data-plane lifetime; the wrapper awaits it and its completion triggers channel teardown. Plus the POC's second ask: a yield-once acceptance flag inChannelBidiStreamSourceand adebug!birth-teardown hint in the teardown task, so the hang-this-POC-found shape is diagnosable at runtime, not just in docs. Establishmentcarries the plan (→ R-01) — filled asEstablishment { plan: Option<ChannelPlan> }withEstablishment::new(plan); typed-opaque (ChannelPlan = Arc<dyn Any + Send + Sync>), not theOption<Value>sketch here — the payloads are live handles (dialed sockets, TTY handles) with no JSON representation. TheOpenHandlergains aplanparameter and the wrapper threads it. The POC'sHandleHandoffside-channel and its same-resource race are dead: the establisher returns the dialed handle directly (Ok(Establishment::new(dialed))). Confirmed under concurrency 2026-09-07 by the reverse-flow POC (reverse-poc-summary.md): two concurrent opens of the same resource, distinct channels, distinct dialed handles — the race this handoff had is structurally gone. (F-1 there: plan payloads must beSend + Sync.)- Two-pump helper (→ R-03, bonus) —
alkcall::channels::pump_bidipinned upstream (ADR-050), with(u64, u64)copy counts (noio::Result— errors are EOF-shaped per the ADR-078 contract). The POC'spump_halvesshape converged and became the helper; Phase 1 consumes it instead of hand-rolling.
What the POC does NOT validate
- Real transports —
tokio::io::duplexstands in for TCP/TLS/QUIC. The alkcall layer owns transport; unchanged from the alknet-channels POC scope. - 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.
- The reverse-flow (
-R) advertisement — OQ-TN-10 #2 remains open; the SSHtcpip-forwardtemplate in the survey is the lifecycle shape to validate there. - 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.
- 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).
- Wasm — the POC uses
tokio/netfor real sockets; the wasm-clean story is for the real crate's protocol-only core (the codec and pump are pure byte/async work;UdpHalfand 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). 0.6.0 supersedes parts: ADR-049 amendment 2 (Establishment.plan), ADR-050 (pump_bidi), review 007 (filed from this POC, all units landed). - alknet ADR-078 — the two-pump shutdown-on-completion contract;
pump_halvesis 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'swire_tunnelmirrors. - tun2proxy
src/udpgw.rs— the framing precedent the UDP codec improves on (no CONN_ID, no FLAGS; boundary framing only).