Files
alknet/docs/architecture/crates/channels/overview.md
T
glm-5.2 fd83fc1685 docs(arch): channels substrate simplification + stream_type decomposition + sub-crate split
Three simplifications to the channels spec, all flowing from the review:

1. Substrate simplification (ADR-071 revised): the 9-byte chunk header is
   used in ALL substrates — in-line (TCP+TLS, WebTransport), native (QUIC
   bidi streams), and multi-connection. The ChannelsAdapter reads headers
   off every bidi stream it accepts; the transport's native multiplexing
   is a performance optimization (independent flow-control windows), not a
   protocol change. One wire format, one code path, one handler experience.
   The channel_id in the header is the correlation key across substrates.

2. Stream_type decomposition (ADR-071 revised): every stream_type is
   unidirectional. Bidirectionality is two stream_types (write + read), not
   one 'bidirectional' stream_type. Grouped in threes: 0/1/2 = data
   write/read/err, 3/4/5 = control write/read/err, % 3 formula. This
   resolves the TTY control channel's 'not actually bidirectional' flaw —
   control is now 3 (write, client→server) + 4 (read, server→client), each
   with its own reassembly buffer and EOF. Channel 0 uses [0,1] (call frames
   bidirectional via 0=in, 1=out). TTY uses [0,1,2,3,4]. ADR-072, 073, 074,
   077 updated for the new stream_type assignments.

3. Sub-crate decomposition (ADR-081 new): channels-core (pure multiplexer —
   wire format, demux/mux, ChannelBidiStreamSource, ChannelManager; depends
   on alknet-core only, no call dependency, ALPN-blind) / channels-call
   (channel 0 pre-negotiation + lifecycle op registrations; depends on
   channels-core + alknet-call) / channels-hub (relay) / channels-worker
   (ChannelClient). Isolates the call-protocol coupling from the pure
   multiplexer so the dependency graph is honest.

ADR-077 (TTY inside channels) updated: TTY now uses 5 sub-streams [0,1,2,3,4]
with control properly bidirectional via 3/4; amends ADR-052's stream_type
assignments for direct mode too (direct alknet/tty now uses 0-4, not 0-3).

Spec docs updated: channels-wire, channels-connection, channels-adapter,
channel-operations, overview, README.
2026-07-12 15:47:42 +00:00

13 KiB
Raw Blame History

status, last_updated
status last_updated
draft 2026-07-12

alknet-channels — Overview

What

alknet-channels is a multiplexing proxy crate. It implements ProtocolHandler for the alknet/channels ALPN: it receives one bidirectional transport stream, reads 9-byte chunk headers, and routes each chunk's payload to the right logical channel. Each channel is reassembled into an AsyncRead + AsyncWrite pair and presented to its handler as a Connection — the handler doesn't know it's inside a channels connection.

Channel 0 is pre-negotiated as alknet/call (ADR-072). Every other channel is opened dynamically via channel/open on channel 0 (ADR-073) and routed through the same HandlerRegistry as top-level connections. The channels layer does no protocol work itself — it is a re-framing proxy that converts between "one transport stream carrying N channels" (the wire) and "N independent stream handles" (what handlers see).

Why

The problem: three multiplexing models that don't compose

Before channels, alknet had three multiplexing models:

Model Where Mechanism
Connection-level ALPN router One ALPN per QUIC connection
Stream-level QUIC native Many bidi streams per connection
Sub-stream-level TTY chunk format 4 logical channels within one bidi stream

A docker client needing both JSON call operations and raw TTY sessions required two separate QUIC connections with different ALPNs. The call protocol can't say "for this operation, open a TTY stream." The hub, bridging browsers and spokes over multiple transports, faced an O(protocols × transports × spokes) matrix of per-protocol framing parsers and per-ALPN connection management.

The collapse: one multiplexing model, one connection per leg

With alknet/channels, one connection carries everything:

Browser ──WebTransport──► Hub ──QUIC──► Spoke
          alknet/channels         alknet/channels
          ┌─────────────┐         ┌─────────────┐
          │ ch0: call   │         │ ch0: call   │
          │ ch1: tty    │  relay  │ ch1: tty    │
          │ ch2: ssh    │ ◄─────► │ ch2: ssh    │
          │ ch3: tunnel │         │ ch3: tunnel │
          └─────────────┘         └─────────────┘

The hub's relay is channel-by-channel byte forwarding (with channel_id rewrite — ADR-079), not per-protocol framing parsers. The hub's complexity collapses from O(protocols × transports × spokes) to O(channels).

The collapse is at three levels:

  1. One connection per leg, not one per protocol. All needs (call, TTY, SSH, tunnel) ride as channels on one connection per leg.
  2. One multiplexing model, not three. Connection-level, stream-level, and sub-stream-level all become channels chunks.
  3. The call protocol orchestrates from inside. Channel 0 is alknet/call on both legs. The call protocol's OperationRegistry, AccessControl, and forwarded_for machinery govern channel lifecycle with no new auth.

Architecture

The crate has two internal components (ADR-075):

  • ChannelsAdapter — implements ProtocolHandler for alknet/channels. Its handle() receives one Connection, reads 9-byte chunk headers, and routes chunks to the ChannelManager. The read/demux half.
  • ChannelManager — the shared state. Holds channel_id → ChannelState, the HandlerRegistry reference, and the OperationRegistry reference. The reassemble/allocate half. What the channel/open operation handler closes over.

Each channel is presented to its handler as a Connection constructed via Connection::from_source(ChannelBidiStreamSource, alpn) (ADR-070/074). The handler calls accept_bi() once (yield-once per channel) and drives its session — identical to how it works on a top-level QUIC connection.

See channels-adapter.md for the full adapter/manager design.

Crate dependencies

alknet-channels-core
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
│                BidiStreamSource, SendStream, RecvStream, AuthContext)
├── tokio (spawn, mpsc, io)
├── bytes (Bytes for chunk payloads)
├── async-trait
├── thiserror
└── tracing

alknet-channels-call
├── alknet-channels-core (ChannelManager, ChannelsAdapter,
│                         ChannelBidiStreamSource)
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
│                make_streaming_handler, CallError, ResponseEnvelope)
└── tokio

alknet-channels-hub (or the hub role within alknet-hub)
├── alknet-channels-call
└── alknet-call (from_call, CallAdapter, forwarded_for — ADR-079)

alknet-channels-worker (or the worker/client role)
├── alknet-channels-call
└── alknet-call (CallClient-equivalent)

alknet-channels-core is the pure multiplexer — wire format, demux/mux, ChannelBidiStreamSource, ChannelManager. It depends on alknet-core only. No alknet-call dependency. ALPN-blind, call-protocol-blind, transport-blind. This is where the "streams are streams" insight lives.

alknet-channels-call is the call-protocol coupling — channel 0 pre-negotiation as alknet/call (ADR-072) and the four lifecycle operations (ADR-073) registered on the call protocol's OperationRegistry. This is where the call-protocol coupling lives, isolated from the pure multiplexer.

alknet-channels-hub and alknet-channels-worker (or the hub/worker roles within channels-call) are the two roles: the relay (ADR-079) and the ChannelClient (ADR-080). Whether these are separate sub-crates or feature-gated modules within channels-call is a two-way-door packaging decision (ADR-081).

See ADR-081 for the full decomposition rationale.

ALPN

alknet/channels — the ALPN the ChannelsAdapter registers on. One ALPN per channels connection; the connection carries N logical channels, each with its own ALPN (negotiated via channel/open).

Transport agnosticism

The channels wire format works over any ordered, reliable bidirectional byte stream:

Transport How
QUIC bidi stream alknet/channels ALPN on a QUIC connection; one bidi stream carries all channels
TCP+TLS alknet/channels ALPN on a TLS connection; the TCP stream carries all channels
WebTransport alknet/channels session (deferred per ADR-044; the browser path uses WebSocket carrying alknet/channels)
SSH channel channels connection riding inside an SSH direct-tcpip channel (channels-over-SSH)
Another channels connection recursive composition (channel type alknet/channels inside alknet/channels)

The same wire format, the same chunk reassembly, the same Connection abstraction. The transport is a parameter, not a design constraint. Connection::from_stream / from_source (ADR-065/070) handles the transport-agnostic Connection construction.

WASM compatibility

The wire format's core is pure byte manipulation — parse_header / write_header are pure functions with no platform dependencies. The de-risk POC validated the sync core compiles under wasm32-unknown-unknown. The async shell (demux/mux) wraps this core with read_exact/write_all and mpsc routing.

The ChannelManager is ALPN-blind, auth-blind, and transport-blind (ADR- 075) — pure byte routing with no platform or protocol dependencies. A WASM build can read chunks from a WebTransport BiStream, reassemble them, and present AsyncRead + AsyncWrite handles to WASM-compatible handlers. The handlers themselves may or may not be WASM-compatible (russh's client is; portable_pty is not), but the channels layer is WASM-compatible by construction.

The async shell and alknet-core dep graph are not fully WASM-clean yet (transitive getrandom/rand deps) — this is an implementation concern, not an architecture concern. The sync core's WASM compatibility is validated.

Relationship to existing crates

alknet-call

Unchanged. The call protocol remains JSON-only, EventEnvelope-based. It runs on channel 0 exactly as on a top-level alknet/call connection. The CallAdapter receives a Connection backed by channel-0 chunk reassembly and dispatches operations — it doesn't know it's inside channels.

What changes: the call protocol gains a new class of operations — channel lifecycle (ADR-073). These are registered on the OperationRegistry at assembly time and dispatched through the existing OperationContext / AccessControl::check path.

alknet-tty

The TTY crate gains a channels feature (ADR-077) that enables inside-channels mode. In direct mode (alknet/tty ALPN on a top-level connection), the TTY adapter uses its own 5-byte wire format (ADR-052, unchanged). In channels mode (channel/open with ALPN alknet/tty), the adapter receives ChannelSubStreams (ADR-074) — four named SendStream/RecvStream pairs for stream_types 0-3 — and pumps without chunk parsing. The TtyBackend trait and TtyHandle are unchanged; backends don't know which mode the adapter is in.

alknet-ssh (future)

SSH as a channel type: an alknet/ssh channel carries the SSH binary protocol over stream_types 0 and 1. The channels layer hands the reassembled stream to SshAdapter, which feeds it to russh. SSH as a channels transport: an SSH direct-tcpip channel could carry a channels connection (channels-over-SSH). The SSH crate doesn't need to know about channels — it implements ProtocolHandler for alknet/ssh and accepts a Connection.

alknet-docker

Docker lifecycle operations are call operations on channel 0 (unchanged from ADR-058). Interactive exec/attach opens a TTY channel via channel/open with ALPN alknet/tty and backend docker. No separate alknet/tty connection needed — one alknet/channels connection handles both JSON operations and raw TTY sessions.

alknet-hub

The hub is the primary consumer. With channels, the hub holds one channels connection per leg (browser↔hub, hub↔spoke) and relays channels between them. The hub translates channel/open on channel 0 (re-issues on the spoke leg with forwarded_for — ADR-079) and byte-forwards data channels with channel_id rewrite. The hub's complexity collapses from O(protocols × transports × spokes) to O(channels).

Design Decisions

All design decisions are documented as ADRs in decisions/.

ADR Decision Summary
071 channels Wire Format 9-byte chunk header; unidirectional stream_types in groups of 3; one-way door
072 Channel 0 Pre-Negotiated Channel 0 = alknet/call, stream_types [0,1]
073 Channel Lifecycle Operations channel/open/close/control/resources/subscribe; subscribe not poll; direction pinned
074 ChannelConnection Per-channel BidiStreamSource; into_sub_streams() with SubStreamHandle enum
075 ChannelsAdapter and ChannelManager Substrate-agnostic demux loop; REQ-CH-01..04
076 Backpressure, Limits, ID Reuse Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs
077 TTY Inside Channels Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4
078 Two-Pump Pattern Shutdown-on-completion contract; handler-level
079 Hub Relay Translate channel 0, byte-forward data channels with ID rewrite
080 ChannelClient Client side; QUIC-only; AlknetClient deferred (OQ-55)
081 Sub-Crate Decomposition channels-core (pure multiplexer) / channels-call (call coupling) / hub / worker

Open Questions

Open questions are tracked in open-questions.md. Key questions affecting this crate:

  • OQ-55 (deferred(scope)): AlknetClient core extraction — blocked on a second transport's client, not a second client. ChannelClient is decided (ADR-080).
  • OQ-56 (deferred(scope)): Full channel-level flow-control windowing — bounded-buffer is decided (ADR-076); full windowing is an extension blocked on a real HOL-blocking deployment observation.
  • OQ-57 (deferred(scope)): Two-pump helper extraction to alknet-core — the contract is decided (ADR-078); the helper is blocked on a second two-pump handler existing.