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

267 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 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](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/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; unidirectional stream_types in groups of 3; one-way door |
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, stream_types [0,1] |
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; `into_sub_streams()` with `SubStreamHandle` enum |
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels | Two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4 |
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; QUIC-only; `AlknetClient` deferred (OQ-55) |
| [081](../../decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling) / hub / worker |
## Open Questions
Open questions are tracked in [open-questions.md](../../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.