Files
alknet/docs/architecture/crates/channels/channels-connection.md
T
glm-5.2 2313c51f12 docs(arch): add alknet-channels specs — ADRs 071-080, 7 spec docs, OQ-56/57
Phase 1 architecture for alknet-channels (multiplexing proxy on
alknet/channels). Grounded in the completed de-risk POC (28 tests) and the
landed ADR-070 (BidiStreamSource trait + Connection::from_source).

ADRs:
- 071: 9-byte chunk wire format (generalizes TTY's 5-byte)
- 072: channel 0 pre-negotiated as alknet/call (no special control plane)
- 073: channel lifecycle operations on the call protocol — channel/open,
  close, control, resources/subscribe; direction field pinned; subscribe
  from day one (not poll-for-v1 — StreamingHandler machinery exists)
- 074: ChannelBidiStreamSource implements BidiStreamSource (ADR-070);
  into_sub_streams() typed accessor for TTY; accept_bi() generic path
- 075: ChannelsAdapter + ChannelManager; REQ-CH-01..04 wire invariants
- 076: bounded-buffer backpressure (1 MiB), 256-channel cap, monotonic IDs
- 077: TTY inside channels uses sub-streams, not own wire format;
  amends ADR-052 scope to direct-connect TTY; channels feature on tty
- 078: two-pump shutdown-on-completion contract (handler-level)
- 079: hub relay translates channel 0, byte-forwards data channels
- 080: ChannelClient (QUIC-only); AlknetClient core extraction deferred (OQ-55)

Spec docs: overview, channels-wire, channels-connection, channels-adapter,
channel-operations, channel-client.

OQ-56 (full windowing) and OQ-57 (two-pump helper extraction) are genuine
deferred(scope) deferrals with concrete blocking conditions; the contracts
are decided, only the extensions are deferred.

Hedging audit converted three research hedges into decisions:
resources/subscribe (not poll), server-assigned IDs (not if-zero-RTT),
bounded-buffer (not if-HOL-becomes-a-problem).
2026-07-12 12:57:18 +00:00

8.2 KiB

status, last_updated
status last_updated
draft 2026-07-12

channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access

How a reassembled channel is presented to its handler as a Connection. ADR-074 is the decision; this doc specifies the API shape and the two access paths.

What

Each channel is reassembled into a set of AsyncRead + AsyncWrite handles —one per active stream_type (declared at channel/open time, ADR-073). These handles are wrapped as a ChannelBidiStreamSource that implements alknet-core's BidiStreamSource trait (ADR-070), and a Connection is constructed from it via Connection::from_source(source, alpn).

The handler receives a Connection and can either:

  1. Call accept_bi() once to get the main data pair (stream_type 0/1) — the generic handler path (tunnel, SSH).
  2. Call into_sub_streams() on the ChannelBidiStreamSource to get all active sub-streams as typed (stream_type, SendStream, RecvStream) tuples — the typed handler path (TTY, which needs stdin/stdout/stderr/ control).

Both paths operate on the same reassembly buffers; the difference is how the handler accesses them.

ChannelBidiStreamSource

// In alknet-channels:

pub struct ChannelBidiStreamSource {
    // The reassembly buffers for this channel's active stream_types,
    // plus the mux handle for writing back onto the transport.
    // Constructed by ChannelManager::build_channel_connection (ADR-075).
    ...
}

#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
    async fn accept_bi(&self)
        -> Result<(SendStream, RecvStream), StreamError>
    {
        // Yields the (stream_type 0, stream_type 1) pair on first call,
        // ConnectionClosed on subsequent calls. Yield-once per channel,
        // matching the POC's validated shape.
    }

    async fn open_bi(&self)
        -> Result<(SendStream, RecvStream), StreamError>
    {
        // StreamClosed — a single channel cannot open new application
        // streams (same as ADR-065's Stream backend). Additional sub-streams
        // (stream_type 2, 3) are accessed via into_sub_streams(), not
        // open_bi().
    }

    fn remote_addr(&self) -> Option<SocketAddr> { ... }

    fn close(&self, _code: u32, _reason: &str) { ... }
}

One ChannelBidiStreamSource instance represents one channel (not the whole channels connection). The ChannelManager (ADR-075) constructs one per channel at channel/open time and wraps it in a Connection via from_source.

The generic path: accept_bi()

For handlers that only need the main data pair (stream_type 0 = data-in, stream_type 1 = data-out):

// Tunnel handler — ~15 lines, zero channels-layer awareness
async fn handle(&self, connection: Connection, _auth: &AuthContext)
    -> Result<(), HandlerError>
{
    let (mut send, mut recv) = connection.accept_bi().await?;
    let mut tcp = TcpStream::connect(target).await?;
    let (mut tcp_read, mut tcp_write) = tcp.into_split();

    // Two-pump with shutdown-on-completion (ADR-078)
    let c2t = async {
        tokio::io::copy(&mut recv, &mut tcp_write).await?;
        tcp_write.shutdown().await.ok();
        Ok::<_, std::io::Error>(())
    };
    let t2c = async {
        tokio::io::copy(&mut tcp_read, &mut send).await?;
        send.shutdown().await.ok();  // emits zero-length sentinel (REQ-CH-01)
        Ok::<_, std::io::Error>(())
    };
    tokio::try_join!(c2t, t2c)?;
    Ok(())
}

The handler calls accept_bi() once, gets the (SendStream, RecvStream) pair, and pumps. It does not know it's inside a channels connection — the Connection looks like any other. This is the path the POC's EchoHandler and TunnelHandler validated.

accept_bi() is yield-once: the first call returns the 0/1 pair; subsequent calls return ConnectionClosed. This matches the POC's validated shape and the StreamBidiStreamSource yield-once contract (ADR-070).

The typed path: into_sub_streams()

For handlers that need stream_type 2 (stderr) or 3 (control) in addition to 0/1:

// In alknet-channels:
pub struct ChannelSubStreams {
    /// (stream_type, send_half, recv_half) for each active stream_type.
    pub streams: Vec<(u8, SendStream, RecvStream)>,
}

impl ChannelSubStreams {
    pub fn get(&self, stream_type: u8) -> Option<(&SendStream, &RecvStream)> { ... }
}

impl ChannelBidiStreamSource {
    /// Returns all active sub-streams, keyed by stream_type. Consumes the
    /// source — call this instead of accept_bi() if the handler needs
    /// direct access to stream_types 2/3.
    pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
}

The handler crate destructures ChannelSubStreams into its typed names:

// In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams();
let stdin = sub.get(0).unwrap();    // SendStream
let stdout = sub.get(1).unwrap();   // RecvStream
let stderr = sub.get(2);            // Option<&RecvStream>
let control = sub.get(3).unwrap();  // RecvStream (JSON control)

The channels crate does not know about TTY's stream_type semantics. It exposes (stream_type, SendStream, RecvStream) tuples. The handler crate maps stream_types to its typed names. This preserves ADR-003's no-handler-depends-on-another-handler rule and keeps the channels crate ALPN-blind.

into_sub_streams() consumes the source — a handler can't call both accept_bi() and into_sub_streams(). This is by design: the sub-streams include the 0/1 pair, so into_sub_streams() is the superset.

Choosing the path

Handler shape Path Examples
Main data pair only (0/1) accept_bi() tunnel, SSH (SSH multiplexes internally)
Needs stderr/control (2/3) into_sub_streams() TTY (stdin/stdout/stderr/control)

The handler chooses based on its ALPN's stream_type set (declared at channel/open time). The ChannelsAdapter (ADR-075) passes the handler a Connection (via from_source); handlers that need sub-streams access the ChannelBidiStreamSource via a channels-crate extension trait or downcast (exact ergonomics are an implementation detail for the channels crate; the contract is that both paths are available and the handler crate chooses).

Recursive composition

A ChannelBidiStreamSource is a BidiStreamSource, and Connection:: from_source wraps it. A handler that is itself alknet/channels can open a sub-channels connection on a data channel — alknet/channels inside alknet/channels. This is allowed (the Connection abstraction permits it) but not a feature designed for. The primary use case is one level of multiplexing. Recursive composition is a natural consequence of the abstraction, not a goal.

What does NOT change

  • ProtocolHandler trait (ADR-002) — handlers still receive a Connection and call accept_bi(). The ChannelBidiStreamSource is internal to the channels crate; handlers see a Connection.
  • SendStream / RecvStream (ADR-007) — unchanged. They continue to wrap their internal sources. ChannelBidiStreamSource constructs them via the existing from_stream constructors, backed by mpsc reassembly buffers.
  • HandlerRegistry — unchanged. The channels layer looks up ALPNs in the same registry as top-level connections.

Design Decisions

All design decisions are documented as ADRs in decisions/.

ADR Decision Summary
074 ChannelConnection Per-channel BidiStreamSource; yield-once accept_bi; into_sub_streams() accessor
070 BidiStreamSource Trait The extension point ChannelBidiStreamSource implements
065 Connection::from_stream The yield-once path generalized for channels

References

  • ADR-074: ChannelConnection (the decision)
  • ADR-070: BidiStreamSource trait
  • ADR-065: Connection::from_stream
  • ADR-077: TTY inside channels (the primary consumer of into_sub_streams)
  • docs/research/alknet-channels/poc-summary.md §POC Target 2 (the yield-once Connection::from_stream validation)