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.
20 KiB
status, last_updated
| status | last_updated |
|---|---|
| plan | 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.
- Chunk format + N-channel demux/mux — generalize TTY's
ChunkReader/ChunkWriterto the 9-byte format, build a demux that routes chunks to per-channelmpscchannels and a mux that frames per-channel bytes back onto the transport. Validates the decompose→stream→recompose round-trip for 3+ concurrent channels. - Per-channel
Connectionpresentation — wrap each reassembled channel asConnection::from_stream(send, recv, alpn, addr)and run a minimalProtocolHandler(echo) through the full demux→Connection→ handler→mux path. Validates that the existingConnectionabstraction (landed in the transport-generalization work) is sufficient; no core changes needed for the POC. - Tunnel handler — a
channel/openwith a target address opens aTcpStreamand pumps bidirectionally between the channel and the TCP socket. Validates that the bidirectional pump pattern (same as TTY'spump_session) works as a generic port proxy and that the handler sees aConnectionwithout 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)andframe_chunk(channel_id, stream_type, payload) -> Vec<u8>. Pure functions. These are the generalization of TTY'sChunkReader::read_chunk/ChunkWriter:: write_chunkwith thechannel_idfield added. - Async shell: one
read_exacton the transport to get the 9-byte header, oneread_exactfor the payload, then route the payload to the right per-channelmpsc::Sender<Bytes>. That's the demux loop — one async task per transport. - Per-channel reassembly:
mpsc::Receiver<Bytes>wrapped asAsyncRead(small adapter, ~30 lines), fed to the handler as aRecvStreamviaRecvStream::from_stream. The handler's writes go to anmpsc::Sender<Bytes>wrapped asAsyncWrite, 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→TcpStreamwrite half (client → target)TcpStreamread 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
// 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::Receivers 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 eachstream_type, read back in order, assert integrity.round_trip_three_concurrent_channels: three channels, each with differentstream_typesets, 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 emptyBytes(the EOF sentinel, same as TTY).chunk_too_large: a chunk withlength > MAX_CHUNK_LENreturnsChunkTooLarge, doesn't corrupt the stream.unknown_channel_id: a chunk with an unallocatedchannel_idis 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
Connectionpresentation (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
// 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'sSendStream, read the echo back from channel 1'sRecvStream, assert equality.echo_three_concurrent_channels: three channels, each echoing concurrently, assert each gets its own data back.handler_sees_alpn: theConnection'sremote_alpn()returns the ALPN the channel was opened with (validatesfrom_stream'salpnparameter threads through correctly).handler_loops_accept_bi_gets_one_session: a handler that loopsaccept_bi(likeTtyAdapter) gets exactly one session per channel, thenConnectionClosed— 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 newConnectionKindis needed for the POC.- The yield-once contract composes correctly for handlers that loop
accept_bi. - The
Connectionabstraction'salpnandremote_addrfields 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
// 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'sSendStream→ tunnel handler forwards to TCP echo server → response flows back through channel toRecvStream→ assert equality.tunnel_large_payload: write 1 MiB, assert it round-trips without corruption (exercises the bounded-buffer backpressure path — the tunnel handler'sAsyncReadside 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_sessionshape) reuses cleanly with two pumps instead of three. - The handler sees a
Connectionand 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/openwithparams: { 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/resourcesare call-protocol operations (phase-0-findings.md§Channel Open Negotiation). The POC uses a POC-local channel-allocation mechanism (directDemux::allocate_channelcalls), not the realOperationRegistrypath. 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::duplexstands in for QUIC/TCP/ WebTransport. The transport-generalization work already validatedConnection::from_streamover real transports; the POC reuses that. - No ACL. The call protocol's
AccessControl::checkgateschannel/openin the real system; the POC has no auth. - No real adapters.
EchoHandlerandTunnelHandlerare POC-local stubs. The realTtyAdapter/SshAdapter/DockerTtyBackendare unchanged and integrate in Phase 1. - No recursive composition.
alknet/channelsinsidealknet/channelsis a natural consequence of theConnectionabstraction 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_idit 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_streamper channel, which is yield-once. A Phase 1 refactor may add aBidiStreamSourcetrait toalknet-coresoChannelConnection(many channels, each a bidi stream) is a first-class peer of QUIC (many bidi streams) rather than a bag of yield-once Connections:trait BidiStreamSource: Send + Sync { async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>; async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>; }with
ConnectionholdingBox<dyn BidiStreamSource>, and QUIC/Iroh/ Stream/Channels all implementing it. This is additive (existing callers keep working via a blanket impl or afrom_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 likeChannelClientthat opens a transport, runs the demux/ mux, and exposesopen_channel(alpn, params) -> Channelto the application. This is the channels analogue ofAlknetEndpoint(server) vsCallClient(client) in the call protocol. The POC uses a POC-local client type; Phase 1 must decide whether this lives inalknet-channelsor is a thin wrapper overalknet-core's endpoint types. TheBidiStreamSourcetrait (OQ-CH-13) may factor into this — ifAlknetEndpointandChannelClientboth produceBidiStreamSources, 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— theConnection::from_stream/Connection::from_bidiwork 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::handleanddrive_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.