Files
alknet/docs/research/alknet-channels/poc-plan.md
T
glm-5.2 5e07203b86 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.
2026-07-12 06:35:50 +00:00

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.

  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

// 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 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

// 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

// 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:

    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 BidiStreamSources, 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.