docs(research): add detailed channels POC plan; add BidiStreamSource + client endpoint open questions
Adds docs/research/alknet-channels/poc-plan.md — a standalone three-step POC plan that derisks the channels layer in isolation (no call protocol, no real transport, no real adapters): - Step 1: chunk format + N-channel demux/mux — generalizes TTY's 5-byte ChunkReader/ChunkWriter to the 9-byte format, validates decompose→stream→ recompose for 3+ concurrent channels with the sync-core/async-shell split pattern from TTY's REQ-TTY-01. - Step 2: per-channel Connection presentation — wraps each reassembled channel as Connection::from_stream and runs a minimal echo ProtocolHandler through the full path. Validates the existing Connection abstraction is sufficient; no core changes needed for the POC. - Step 3: tunnel handler — opens a TcpStream and pumps bidirectionally, reusing TTY's pump_session shape with two pumps. Validates the same concepts behind the TTY crate work as a generic port proxy. Stretch goals: WASM build of the sync core, mixed channel types on one connection, hub relay sketch with channel_id remapping. Adds three open questions to phase-0-findings.md: - OQ-CH-12: unknown channel_id on demux (lenient drop vs strict error). - OQ-CH-13: core BidiStreamSource trait — additive refactor to make ChannelConnection a first-class peer of QUIC (many bidi streams) rather than a bag of yield-once Connections. Likely +EV; not needed for the POC but should be evaluated in Phase 1. - OQ-CH-14: client-side channels endpoint — the symmetric ChannelClient type (analogue of AlknetEndpoint vs CallClient). After the POC we're probably going to need some light refactoring to the core to make these easier; mostly additive and not breaking in major/pita ways. Updates phase-0-findings.md De-risk POC section to point at the detailed plan and adds the poc-plan to References.
This commit is contained in:
1 parent
12cf8aa0bd
commit
5e07203b86
2 files changed
+576
-17
No files matched your search
@@ -1427,6 +1427,55 @@ same as any multiplexer.
|
||||
side. Phase 1 must specify whether the hub translates or transparently
|
||||
forwards, and how the `channel_id` mapping is maintained.
|
||||
|
||||
- **OQ-CH-12 (unknown channel_id on demux)**: when the demux receives a
|
||||
chunk with a `channel_id` it has not allocated, what does it do? Options:
|
||||
(a) drop with a debug log (lenient — survives transient mis-ordering
|
||||
during channel teardown), (b) return a protocol error and close the
|
||||
transport (strict — catches bugs but is fragile during teardown). SSH
|
||||
is lenient. Recommendation: lenient for v1, with an error counter for
|
||||
observability. See `poc-plan.md` §Step 1.
|
||||
|
||||
- **OQ-CH-13 (core trait for bidi-stream sources)**: the POC
|
||||
(`poc-plan.md`) uses `Connection::from_stream` per channel, which is
|
||||
yield-once. A Phase 1 refactor may add a `BidiStreamSource` trait to
|
||||
`alknet-core` so `ChannelConnection` (many channels, each a bidi stream)
|
||||
is a first-class peer of QUIC (many bidi streams) rather than a bag of
|
||||
yield-once Connections:
|
||||
|
||||
```rust
|
||||
trait BidiStreamSource: Send + Sync {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
}
|
||||
```
|
||||
|
||||
with `Connection` holding `Box<dyn BidiStreamSource>`, and QUIC/Iroh/
|
||||
Stream/Channels all implementing it. This is **additive** (existing
|
||||
callers keep working via a blanket impl or a `from_stream`-backed
|
||||
default) and not a major/pita break — it mostly adds a new variant and
|
||||
trait-ifies the existing `accept_bi`/`open_bi` methods behind a trait
|
||||
object. The POC does NOT need this — it validates the yield-once path is
|
||||
sufficient — but Phase 1 should evaluate whether the trait makes the
|
||||
channels layer and the client-side endpoint (OQ-CH-14) cleaner. The
|
||||
expectation is that this route is +EV: it's not a massive change and
|
||||
will make things a lot easier downstream.
|
||||
|
||||
- **OQ-CH-14 (client-side channels endpoint)**: both sides of a channels
|
||||
connection do the demux/mux work. The server side is a `ProtocolHandler`
|
||||
(`ChannelsAdapter::handle`). The client side needs a symmetric type —
|
||||
something like `ChannelClient` that opens a transport, runs the demux/
|
||||
mux, and exposes `open_channel(alpn, params) -> Channel` to the
|
||||
application. This is the channels analogue of `AlknetEndpoint` (server)
|
||||
vs `CallClient` (client) in the call protocol. The POC uses a POC-local
|
||||
client type; Phase 1 must decide whether this lives in `alknet-channels`
|
||||
or is a thin wrapper over `alknet-core`'s endpoint types. The
|
||||
`BidiStreamSource` trait (OQ-CH-13) may factor into this — if
|
||||
`AlknetEndpoint` and `ChannelClient` both produce `BidiStreamSource`s,
|
||||
the client/server symmetry is cleaner. After the POC we're probably
|
||||
going to need some light refactoring to the core to make these easier;
|
||||
the good news is it should mostly be additive and not breaking in
|
||||
major/pita ways.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
### Crate
|
||||
@@ -1485,29 +1534,46 @@ on `alknet-channels` except the assembly layer that registers it on the
|
||||
|
||||
### De-risk POC
|
||||
|
||||
A Phase 0 POC should validate the core claim: **one connection, multiple
|
||||
channel types, orchestrated by the call protocol.** Minimum scope:
|
||||
A detailed POC plan lives in `docs/research/alknet-channels/poc-plan.md`.
|
||||
Summary: a standalone POC at `/workspace/@alkdev/alknet-channels-poc/` that
|
||||
validates the three highest-leverage unknowns in three independently-runnable
|
||||
steps:
|
||||
|
||||
1. Chunk format round-trip with 3+ concurrent channels
|
||||
2. Channel open/close via call protocol operations
|
||||
3. TTY session inside a channel (reuse the existing `alknet-tty-poc`
|
||||
test infrastructure)
|
||||
4. Bidirectional byte pumping on a data channel
|
||||
1. **Chunk format + N-channel demux/mux** — generalize TTY's 5-byte
|
||||
`ChunkReader`/`ChunkWriter` to the 9-byte format, build a demux that
|
||||
routes chunks to per-channel `mpsc` channels and a mux that frames
|
||||
per-channel bytes back onto the transport. Validates the
|
||||
decompose→stream→recompose round-trip for 3+ concurrent channels.
|
||||
2. **Per-channel `Connection` presentation** — wrap each reassembled
|
||||
channel as `Connection::from_stream` and run a minimal `ProtocolHandler`
|
||||
(echo) through the full demux→Connection→handler→mux path. Validates
|
||||
that the existing `Connection` abstraction (from transport-generalization)
|
||||
is sufficient; no core changes needed for the POC.
|
||||
3. **Tunnel handler** — a `channel/open` with a target address opens a
|
||||
`TcpStream` and pumps bidirectionally. Validates that the same concepts
|
||||
behind the TTY crate work as a generic port proxy.
|
||||
|
||||
Stretch goals:
|
||||
5. Two different channel types (TTY + tunnel) on the same connection
|
||||
6. WASM build of the chunk splitting/recombining logic
|
||||
7. Hub relay: two `ChannelManager` instances bridged by a byte pump,
|
||||
validating that `channel/open` on leg A translates to `channel/open` on
|
||||
leg B and the `channel_id` remapping works end-to-end (OQ-CH-11)
|
||||
8. `channel/resources` discovery — one side lists its exposed ALPNs and the
|
||||
other opens a channel based on the response
|
||||
Stretch goals: WASM build of the sync core; two different channel types
|
||||
(TTY-shaped 4-stream + tunnel 2-stream) on one connection; hub relay sketch
|
||||
with `channel_id` remapping (OQ-CH-11).
|
||||
|
||||
The POC can be built as an extension to the existing `alknet-tty-poc` or as
|
||||
a standalone POC in `/workspace/alknet-channels-poc/`.
|
||||
The POC deliberately does NOT do: the call protocol (channel/open etc. are
|
||||
Phase 1's concern, already in production), real transport (uses
|
||||
`tokio::io::duplex`), ACL, real adapters, or recursive composition.
|
||||
|
||||
The POC surfaces three open questions carried into Phase 1: OQ-CH-12
|
||||
(unknown channel_id on demux), OQ-CH-13 (core `BidiStreamSource` trait —
|
||||
likely +EV additive refactor after the POC), and OQ-CH-14 (client-side
|
||||
channels endpoint — the symmetric `ChannelClient` type).
|
||||
|
||||
The POC lives at `/workspace/@alkdev/alknet-channels-poc/` (mirroring the
|
||||
`alknet-tty-poc` convention), depends on `alknet-core` only.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-channels/poc-plan.md` — the detailed de-risk POC
|
||||
plan (Step 1: chunk format + demux/mux, Step 2: per-channel Connection,
|
||||
Step 3: tunnel handler).
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
||||
format, control channel, and backend trait. The seed of the channels
|
||||
generalization.
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
---
|
||||
status: plan
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# alknet-channels: De-Risk POC Plan
|
||||
|
||||
**Status:** Plan. The POC has not been built. This document specifies what to
|
||||
build, in what order, and what each step must demonstrate before proceeding.
|
||||
It is the concrete answer to OQ-CH-07 in `phase-0-findings.md`.
|
||||
**Date:** 2026-07-12
|
||||
**Scope:** A standalone POC that validates the three highest-leverage
|
||||
unknowns of the channels layer: (1) the 9-byte chunk format decomposes and
|
||||
recombines N concurrent streams correctly, (2) each reassembled channel
|
||||
presents as a `Connection` that a `ProtocolHandler` can drive unchanged,
|
||||
(3) the same shape works as a tunnel proxy for a local TCP port. The POC
|
||||
deliberately avoids the call protocol and the real adapters — it isolates
|
||||
the channels layer's core mechanics.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The POC is structured as three independently-runnable steps, each building
|
||||
on the previous, each with a clear pass/fail criterion. All three run over
|
||||
a `tokio::io::duplex` stand-in transport (no QUIC, no TLS) to keep the POC
|
||||
self-contained and WASM-buildable.
|
||||
|
||||
1. **Chunk format + N-channel demux/mux** — generalize TTY's
|
||||
`ChunkReader`/`ChunkWriter` to the 9-byte format, build a demux that
|
||||
routes chunks to per-channel `mpsc` channels and a mux that frames
|
||||
per-channel bytes back onto the transport. Validates the
|
||||
decompose→stream→recompose round-trip for 3+ concurrent channels.
|
||||
2. **Per-channel `Connection` presentation** — wrap each reassembled
|
||||
channel as `Connection::from_stream(send, recv, alpn, addr)` and run a
|
||||
minimal `ProtocolHandler` (echo) through the full demux→Connection→
|
||||
handler→mux path. Validates that the existing `Connection` abstraction
|
||||
(landed in the transport-generalization work) is sufficient; no core
|
||||
changes needed for the POC.
|
||||
3. **Tunnel handler** — a `channel/open` with a target address opens a
|
||||
`TcpStream` and pumps bidirectionally between the channel and the TCP
|
||||
socket. Validates that the bidirectional pump pattern (same as TTY's
|
||||
`pump_session`) works as a generic port proxy and that the handler sees
|
||||
a `Connection` without knowing it's inside channels.
|
||||
|
||||
**Stretch goal:** run the demux/mux sync core under wasm32-unknown-unknown
|
||||
to validate the "no platform dependencies" claim (WASM compatibility by
|
||||
construction).
|
||||
|
||||
The POC lives at `/workspace/@alkdev/alknet-channels-poc/` (mirroring the
|
||||
`alknet-tty-poc` convention), depends on `alknet-core` for `Connection`/
|
||||
`SendStream`/`RecvStream`/`ProtocolHandler`, and does NOT depend on
|
||||
`alknet-tty`, `alknet-call`, or any other handler crate. The echo handler
|
||||
(Step 2) and tunnel handler (Step 3) are POC-local stubs, not the real
|
||||
adapters.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles (carried from the TTY POC)
|
||||
|
||||
### The sync core / async shell split
|
||||
|
||||
The TTY POC's REQ-TTY-01 insight (`docs/research/alknet-tty/phase-0-findings.md`
|
||||
§REQ-TTY-01) was that `portable_pty` is blocking `std::io`, and the POC
|
||||
bridged it via std threads + tokio mpsc. The channels demux has an analogous
|
||||
split, but simpler — the "sync" part is pure byte manipulation, not
|
||||
blocking I/O:
|
||||
|
||||
- **Sync core (WASM-compatible, no async)**: `parse_chunk_header(&[u8; 9])
|
||||
-> (channel_id, stream_type, length)` and `frame_chunk(channel_id,
|
||||
stream_type, payload) -> Vec<u8>`. Pure functions. These are the
|
||||
generalization of TTY's `ChunkReader::read_chunk` / `ChunkWriter::
|
||||
write_chunk` with the `channel_id` field added.
|
||||
- **Async shell**: one `read_exact` on the transport to get the 9-byte
|
||||
header, one `read_exact` for the payload, then route the payload to the
|
||||
right per-channel `mpsc::Sender<Bytes>`. That's the demux loop — one
|
||||
async task per transport.
|
||||
- **Per-channel reassembly**: `mpsc::Receiver<Bytes>` wrapped as
|
||||
`AsyncRead` (small adapter, ~30 lines), fed to the handler as a
|
||||
`RecvStream` via `RecvStream::from_stream`. The handler's writes go to
|
||||
an `mpsc::Sender<Bytes>` wrapped as `AsyncWrite`, which the channels
|
||||
layer's per-channel write pump reads from and frames back onto the
|
||||
transport.
|
||||
|
||||
The channels layer is structured exactly like the TTY POC's bridge: sync
|
||||
byte manipulation at the core, async shell around it, mpsc channels
|
||||
connecting the two worlds. The POC validates this is as clean as it was
|
||||
for TTY.
|
||||
|
||||
### Streams are streams
|
||||
|
||||
The guiding insight from `phase-0-findings.md`: a TTY session, an SSH
|
||||
channel, a forwarded TCP connection — they're all just
|
||||
`AsyncRead + AsyncWrite` handles. The differences are only in how they're
|
||||
*opened* and what *multiplexing layer* carries them. The POC validates the
|
||||
multiplexing-layer half; the open-negotiation half is validated by the call
|
||||
protocol (already in production) and is out of scope for this POC.
|
||||
|
||||
### The tunnel reuses the TTY shape, trivially
|
||||
|
||||
A tunnel handler receives a channel (a `Connection::from_stream` yielding
|
||||
one bidi stream), calls `accept_bi()`, gets the `SendStream`/`RecvStream`
|
||||
pair, opens a `TcpStream` to the local port, and runs two pumps:
|
||||
|
||||
- `RecvStream` → `TcpStream` write half (client → target)
|
||||
- `TcpStream` read half → `SendStream` (target → client)
|
||||
|
||||
That's the TTY `pump_session` pattern with two pumps instead of three (no
|
||||
control, no stderr). The chunk format is the channels layer's concern, not
|
||||
the tunnel handler's — the handler just sees opaque bytes on its
|
||||
`SendStream`/`RecvStream`. `TcpStream` is natively async (tokio), so no
|
||||
blocking→async bridge is needed for the tunnel itself. The "don't have to
|
||||
async" pattern lives in the channels demux, not in the tunnel handler.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Chunk Format + N-Channel Demux/Mux
|
||||
|
||||
**Goal:** validate the decompose→stream→recompose round-trip for N
|
||||
concurrent channels.
|
||||
|
||||
### What to build
|
||||
|
||||
```rust
|
||||
// wire.rs — pure sync core, no async, no platform deps
|
||||
|
||||
const CHUNK_HEADER_LEN: usize = 9; // channel_id:u32 + stream_type:u8 + length:u32
|
||||
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||
|
||||
pub struct ChunkHeader {
|
||||
pub channel_id: u32,
|
||||
pub stream_type: u8,
|
||||
pub length: u32,
|
||||
}
|
||||
|
||||
pub fn parse_header(buf: &[u8; 9]) -> Result<ChunkHeader, ChunkError> { ... }
|
||||
pub fn write_header(channel_id: u32, stream_type: u8, length: u32, out: &mut [u8; 9]) { ... }
|
||||
|
||||
// demux.rs — async shell, one task per transport
|
||||
|
||||
pub struct Demux {
|
||||
// channel_id → (tx per stream_type)
|
||||
channels: HashMap<u32, HashMap<u8, mpsc::Sender<Bytes>>>,
|
||||
}
|
||||
|
||||
impl Demux {
|
||||
pub async fn run<R: AsyncRead + Unpin>(&self, transport: R) { ... }
|
||||
}
|
||||
|
||||
pub struct Mux {
|
||||
// reads from per-channel mpsc::Receiver<Bytes>, frames chunks onto transport
|
||||
}
|
||||
|
||||
impl Mux {
|
||||
pub async fn run<W: AsyncWrite + Unpin>(&self, transport: W) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The `Demux` reads 9-byte headers, looks up `(channel_id, stream_type)` in
|
||||
its map, and sends the payload to the matching `mpsc::Sender`. The `Mux`
|
||||
reads from per-channel `mpsc::Receiver`s and writes framed chunks onto the
|
||||
transport. Both sides use `tokio::io::duplex` as the stand-in transport.
|
||||
|
||||
Per-channel reassembly: `mpsc::Receiver<Bytes>` wrapped as `AsyncRead`
|
||||
(`MpscRecvStream` — ~30 lines, mirrors the TTY POC's channel-bridge
|
||||
adapter). The write side: `mpsc::Sender<Bytes>` wrapped as `AsyncWrite`
|
||||
(`MpscSendStream` — ~30 lines). These are the POC's `RecvStream`/`SendStream`
|
||||
backings, constructed via `RecvStream::from_stream` / `SendStream::from_stream`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `round_trip_single_channel`: one channel, write distinct data on each
|
||||
`stream_type`, read back in order, assert integrity.
|
||||
- `round_trip_three_concurrent_channels`: three channels, each with
|
||||
different `stream_type` sets, concurrent writers and readers, assert no
|
||||
cross-channel contamination and per-channel order preservation.
|
||||
- `zero_length_sentinel`: a zero-length chunk is delivered as an empty
|
||||
`Bytes` (the EOF sentinel, same as TTY).
|
||||
- `chunk_too_large`: a chunk with `length > MAX_CHUNK_LEN` returns
|
||||
`ChunkTooLarge`, doesn't corrupt the stream.
|
||||
- `unknown_channel_id`: a chunk with an unallocated `channel_id` is
|
||||
dropped with a debug log (or returned as an error — decide in the POC;
|
||||
see OQ-CH-12 below).
|
||||
- `backpressure`: a slow reader on channel A does not block channel B's
|
||||
reads (the bounded-buffer property from DP-5).
|
||||
|
||||
### Pass criterion
|
||||
|
||||
All six tests pass. The sync core (`parse_header`/`write_header`) compiles
|
||||
under `wasm32-unknown-unknown` (stretch: run the same tests in a WASM test
|
||||
harness).
|
||||
|
||||
### What this validates
|
||||
|
||||
- The 9-byte format is a clean generalization of TTY's 5-byte format.
|
||||
- The sync core is pure and platform-independent.
|
||||
- The mpsc-bridged async shell scales to N concurrent channels with
|
||||
per-channel order preservation and cross-channel isolation.
|
||||
- The bounded-buffer backpressure (DP-5 option c) works for the simple
|
||||
case.
|
||||
|
||||
### What this does NOT validate
|
||||
|
||||
- `Connection` presentation (Step 2).
|
||||
- Handler integration (Step 2).
|
||||
- Real transport (QUIC/TCP/WebTransport).
|
||||
- Call-protocol orchestration (channel/open etc.).
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Per-Channel `Connection` Presentation
|
||||
|
||||
**Goal:** validate that the existing `Connection::from_stream` is
|
||||
sufficient to present each channel as a `Connection` a `ProtocolHandler`
|
||||
can drive unchanged.
|
||||
|
||||
### What to build
|
||||
|
||||
```rust
|
||||
// echo_handler.rs — a minimal ProtocolHandler
|
||||
pub struct EchoHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ProtocolHandler for EchoHandler {
|
||||
fn alpn(&self) -> &'static [u8] { b"alknet/echo" }
|
||||
|
||||
async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
// echo: read from recv, write to send
|
||||
tokio::io::copy(&mut recv, &mut send).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The demux loop, on allocating a new channel, constructs a
|
||||
`Connection::from_stream(send, recv, alpn, remote_addr)` — where `send`
|
||||
and `recv` are the `MpscSendStream`/`MpscRecvStream` from Step 1 — and
|
||||
hands it to `EchoHandler::handle`. The handler calls `accept_bi()` once
|
||||
(yield-once contract), gets the stream pair, and pumps. The handler does
|
||||
not know it's inside a channels connection.
|
||||
|
||||
The test wires both sides: a client-side `Mux`/`Demux` pair and a
|
||||
server-side `Mux`/`Demux` pair, connected by two `tokio::io::duplex`
|
||||
pipes (one for each direction). The client writes to a channel's
|
||||
`SendStream`; the server's `EchoHandler` reads from its `RecvStream` and
|
||||
writes back to its `SendStream`; the client reads the echo from its
|
||||
`RecvStream`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `echo_single_channel`: write 1 KiB to channel 1's `SendStream`, read
|
||||
the echo back from channel 1's `RecvStream`, assert equality.
|
||||
- `echo_three_concurrent_channels`: three channels, each echoing
|
||||
concurrently, assert each gets its own data back.
|
||||
- `handler_sees_alpn`: the `Connection`'s `remote_alpn()` returns the
|
||||
ALPN the channel was opened with (validates `from_stream`'s `alpn`
|
||||
parameter threads through correctly).
|
||||
- `handler_loops_accept_bi_gets_one_session`: a handler that loops
|
||||
`accept_bi` (like `TtyAdapter`) gets exactly one session per channel,
|
||||
then `ConnectionClosed` — the yield-once contract.
|
||||
|
||||
### Pass criterion
|
||||
|
||||
All four tests pass. The `EchoHandler` is transport-agnostic — it compiles
|
||||
and runs unchanged whether the `Connection` is a real QUIC connection or a
|
||||
channels reassembled stream.
|
||||
|
||||
### What this validates
|
||||
|
||||
- `Connection::from_stream` (landed in transport-generalization) is
|
||||
sufficient for per-channel presentation. No new `ConnectionKind` is
|
||||
needed for the POC.
|
||||
- The yield-once contract composes correctly for handlers that loop
|
||||
`accept_bi`.
|
||||
- The `Connection` abstraction's `alpn` and `remote_addr` fields thread
|
||||
through correctly for channels.
|
||||
|
||||
### What this does NOT validate
|
||||
|
||||
- The tunnel proxy (Step 3).
|
||||
- Real transport.
|
||||
- Multiple channels of different ALPN types on one connection (that's a
|
||||
Step 3 / stretch concern).
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Tunnel Handler
|
||||
|
||||
**Goal:** validate that the bidirectional pump pattern (same as TTY's
|
||||
`pump_session`) works as a generic port proxy through channels.
|
||||
|
||||
### What to build
|
||||
|
||||
```rust
|
||||
// tunnel_handler.rs — a ProtocolHandler that proxies to a TCP target
|
||||
pub struct TunnelHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ProtocolHandler for TunnelHandler {
|
||||
fn alpn(&self) -> &'static [u8] { b"alknet/tunnel" }
|
||||
|
||||
async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
// In the POC, the target address is fixed (or passed via a
|
||||
// POC-local mechanism — NOT the real channel/open params, which
|
||||
// are a call-protocol concern and out of scope).
|
||||
let target = "127.0.0.1:0"; // the test's echo server
|
||||
let mut tcp = TcpStream::connect(target).await?;
|
||||
// Two pumps:
|
||||
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||
let c2t = tokio::io::copy(&mut recv, &mut tcp_write);
|
||||
let t2c = tokio::io::copy(&mut tcp_read, &mut send);
|
||||
tokio::try_join!(c2t, t2c)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The test spins up a local TCP echo server (`tokio::net::TcpListener` with
|
||||
a per-connection echo task), opens a channel with ALPN `alknet/tunnel`,
|
||||
writes data to the channel's `SendStream`, and reads the echo back from
|
||||
the channel's `RecvStream`. The tunnel handler forwards bytes between the
|
||||
channel and the `TcpStream` — two `tokio::io::copy` pumps, no chunk-format
|
||||
awareness, no channels-layer awareness.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tunnel_echo_round_trip`: write 1 KiB to channel's `SendStream` →
|
||||
tunnel handler forwards to TCP echo server → response flows back through
|
||||
channel to `RecvStream` → assert equality.
|
||||
- `tunnel_large_payload`: write 1 MiB, assert it round-trips without
|
||||
corruption (exercises the bounded-buffer backpressure path — the tunnel
|
||||
handler's `AsyncRead` side and the channels demux must not deadlock when
|
||||
the TCP echo server is slower than the channel writer).
|
||||
- `tunnel_concurrent_with_echo_channel`: one tunnel channel and one echo
|
||||
channel on the same channels connection, both running concurrently,
|
||||
assert neither blocks the other.
|
||||
|
||||
### Pass criterion
|
||||
|
||||
All three tests pass. The `TunnelHandler` is ~15 lines of handler code and
|
||||
contains zero chunk-format or channels-layer awareness — it only sees
|
||||
`Connection`/`SendStream`/`RecvStream`.
|
||||
|
||||
### What this validates
|
||||
|
||||
- The same concepts behind the TTY crate work as a generic port proxy.
|
||||
- The bidirectional pump pattern (`pump_session` shape) reuses cleanly
|
||||
with two pumps instead of three.
|
||||
- The handler sees a `Connection` and doesn't know it's inside channels.
|
||||
- A tunnel channel and an echo channel coexist on one channels connection
|
||||
without interference — the multiplexing is transparent.
|
||||
|
||||
### What this does NOT validate
|
||||
|
||||
- Real `channel/open` with `params: { target: "..." }` — the POC uses a
|
||||
fixed target or a POC-local mechanism, not the call-protocol operation
|
||||
(which is out of scope).
|
||||
- TLS, SSH, or WebTransport transports.
|
||||
- ACL gating (the call protocol's `AccessControl::check` — out of scope).
|
||||
|
||||
---
|
||||
|
||||
## Stretch Goals
|
||||
|
||||
### WASM build of the sync core
|
||||
|
||||
`parse_header` / `write_header` compile under `wasm32-unknown-unknown`.
|
||||
The demux/mux async shell compiles under WASM with `wasm-bindgen-futures`.
|
||||
This validates the "WASM compatibility by construction" claim — the core
|
||||
byte manipulation has no platform dependencies, and a browser can run a
|
||||
channels client in WASM over WebTransport.
|
||||
|
||||
### Two different channel types on one connection
|
||||
|
||||
Step 3's `tunnel_concurrent_with_echo_channel` already does this
|
||||
partially. The stretch is to run a TTY-shaped channel (4 stream_types:
|
||||
0/1/2/3) alongside a tunnel channel (2 stream_types: 0/1) on the same
|
||||
channels connection, validating that the `stream_types` field at open
|
||||
time correctly sizes the reassembly and that mixed stream_type sets
|
||||
coexist.
|
||||
|
||||
### Hub relay sketch
|
||||
|
||||
Two `Demux`/`Mux` pairs bridged by a byte pump, validating that a chunk
|
||||
arriving on channel `N` on leg A is re-framed with `channel_id = M` on
|
||||
leg B (OQ-CH-11). This is a POC-level validation of the hub relay concept
|
||||
from `phase-0-findings.md` §"The hub relay: ChannelManager-to-
|
||||
ChannelManager". The POC version does not run the call protocol — it just
|
||||
validates the byte-pump relay works with `channel_id` remapping.
|
||||
|
||||
---
|
||||
|
||||
## What the POC Deliberately Does NOT Do
|
||||
|
||||
To keep scope de-risked and bounded:
|
||||
|
||||
- **No call protocol.** `channel/open`, `channel/close`,
|
||||
`channel/control`, `channel/resources` are call-protocol operations
|
||||
(`phase-0-findings.md` §Channel Open Negotiation). The POC uses a
|
||||
POC-local channel-allocation mechanism (direct `Demux::allocate_channel`
|
||||
calls), not the real `OperationRegistry` path. The call protocol is
|
||||
already in production; its integration with channels is Phase 1's
|
||||
concern, not a POC unknown.
|
||||
- **No real transport.** `tokio::io::duplex` stands in for QUIC/TCP/
|
||||
WebTransport. The transport-generalization work already validated
|
||||
`Connection::from_stream` over real transports; the POC reuses that.
|
||||
- **No ACL.** The call protocol's `AccessControl::check` gates
|
||||
`channel/open` in the real system; the POC has no auth.
|
||||
- **No real adapters.** `EchoHandler` and `TunnelHandler` are POC-local
|
||||
stubs. The real `TtyAdapter` / `SshAdapter` / `DockerTtyBackend` are
|
||||
unchanged and integrate in Phase 1.
|
||||
- **No recursive composition.** `alknet/channels` inside
|
||||
`alknet/channels` is a natural consequence of the `Connection`
|
||||
abstraction but is not a POC goal. The POC validates one level of
|
||||
multiplexing.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Surfaced by the POC Plan
|
||||
|
||||
These are added to `phase-0-findings.md` §Open Questions (OQ-CH-12,
|
||||
OQ-CH-13, OQ-CH-14):
|
||||
|
||||
- **OQ-CH-12 (unknown channel_id on demux)**: when the demux receives a
|
||||
chunk with a `channel_id` it has not allocated, what does it do? Options:
|
||||
(a) drop with a debug log (lenient — survives transient mis-ordering
|
||||
during channel teardown), (b) return a protocol error and close the
|
||||
transport (strict — catches bugs but is fragile during teardown). SSH
|
||||
is lenient. Recommendation: lenient for v1, with an error counter for
|
||||
observability.
|
||||
|
||||
- **OQ-CH-13 (core trait for bidi-stream sources)**: the POC uses
|
||||
`Connection::from_stream` per channel, which is yield-once. A Phase 1
|
||||
refactor may add a `BidiStreamSource` trait to `alknet-core` so
|
||||
`ChannelConnection` (many channels, each a bidi stream) is a first-class
|
||||
peer of QUIC (many bidi streams) rather than a bag of yield-once
|
||||
Connections:
|
||||
|
||||
```rust
|
||||
trait BidiStreamSource: Send + Sync {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
}
|
||||
```
|
||||
|
||||
with `Connection` holding `Box<dyn BidiStreamSource>`, and QUIC/Iroh/
|
||||
Stream/Channels all implementing it. This is additive (existing callers
|
||||
keep working via a blanket impl or a `from_stream`-backed default) and
|
||||
not a major/pita break. The POC does NOT need this — it validates the
|
||||
yield-once path is sufficient — but Phase 1 should evaluate whether the
|
||||
trait makes the channels layer and the client-side endpoint (OQ-CH-14)
|
||||
cleaner.
|
||||
|
||||
- **OQ-CH-14 (client-side channels endpoint)**: both sides of a channels
|
||||
connection do the demux/mux work. The server side is a `ProtocolHandler`
|
||||
(`ChannelsAdapter::handle`). The client side needs a symmetric type —
|
||||
something like `ChannelClient` that opens a transport, runs the demux/
|
||||
mux, and exposes `open_channel(alpn, params) -> Channel` to the
|
||||
application. This is the channels analogue of `AlknetEndpoint` (server)
|
||||
vs `CallClient` (client) in the call protocol. The POC uses a
|
||||
POC-local client type; Phase 1 must decide whether this lives in
|
||||
`alknet-channels` or is a thin wrapper over `alknet-core`'s endpoint
|
||||
types. The `BidiStreamSource` trait (OQ-CH-13) may factor into this —
|
||||
if `AlknetEndpoint` and `ChannelClient` both produce `BidiStreamSource`s,
|
||||
the client/server symmetry is cleaner.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` — the research doc
|
||||
this POC derisks.
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` §REQ-TTY-01 — the sync
|
||||
core / async shell split pattern this POC generalizes.
|
||||
- `docs/research/transport-generalization/findings.md` — the
|
||||
`Connection::from_stream` / `Connection::from_bidi` work this POC
|
||||
builds on.
|
||||
- `crates/alknet-tty/src/wire.rs` — the 5-byte chunk format
|
||||
(`ChunkReader`/`ChunkWriter`) this POC generalizes to 9 bytes.
|
||||
- `crates/alknet-tty/src/adapter.rs` — `TtyAdapter::handle` and
|
||||
`drive_session`/`pump_session` — the per-stream dispatch and
|
||||
bidirectional pump pattern the tunnel handler reuses.
|
||||
- `crates/alknet-core/src/types.rs` — `Connection::from_stream`,
|
||||
`SendStream::from_stream`, `RecvStream::from_stream`, `ProtocolHandler`
|
||||
— the integration points the POC validates.
|
||||
- `docs/research/alknet-docker/poc-summary.md` — the docker POC this doc
|
||||
mirrors in structure and tone.
|
||||
Reference in new issue
Block a user