- CHANNELS_ALPN: b"alknet/channels" → b"alk/channels" - CallAdapter::alpn(): b"alknet/call" → b"alk/call" - derive_alpn_from_op_name: alknet/ prefix → alk/ prefix - All ALPN string literals in src/ and docs/ updated - ADR-004 amended with prefix rename rationale - AGENTS.md, README.md updated - Version bumped to 0.1.1 Review: docs/reviews/003-alpn-prefix-rename.md Verification: - cargo test: 542 passed, 0 failed - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: clean
287 lines
15 KiB
Markdown
287 lines
15 KiB
Markdown
---
|
||
status: draft
|
||
last_updated: 2026-07-18
|
||
---
|
||
|
||
# alknet-channels — Overview
|
||
|
||
## What
|
||
|
||
`alknet-channels` is a multiplexing proxy crate. It implements
|
||
`ProtocolHandler` for the `alk/channels` ALPN: it receives one
|
||
bidirectional transport stream, reads 8-byte chunk headers, and routes each
|
||
chunk's payload to the right logical channel. Each channel is reassembled
|
||
into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per
|
||
ADR-009) 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 `alk/call` (ADR-036). Every other channel
|
||
is opened dynamically via `channel/open` on channel 0 (ADR-037) 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 `BiStream` handles" (what handlers see). The channels layer has
|
||
no `stream_type` concept (ADR-035) — the handler owns its sub-stream
|
||
multiplexing on the `BiStream` it receives.
|
||
|
||
## 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 `alk/channels`, one connection carries everything:
|
||
|
||
```
|
||
Browser ──WebTransport──► Hub ──QUIC──► Spoke
|
||
alk/channels alk/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-042), 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
|
||
`alk/call` on both legs. The call protocol's `OperationRegistry`,
|
||
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle
|
||
with no new auth.
|
||
|
||
### The separation: channels layer is pure channel multiplexing
|
||
|
||
The channels layer's job is "one connection carries N channels, routed by
|
||
`channel_id`." It does not know about TTY's sub-streams, SSH's channel
|
||
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
|
||
on the `BiStream` the channels layer gives them (ADR-035).
|
||
|
||
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
|
||
per channel (per ADR-009). The handler sub-multiplexes it however it
|
||
wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw
|
||
bytes, SSH's channel protocol.
|
||
- **The channels layer has no `stream_type` concept.** Not in its 8-byte
|
||
header, not in its code, not in its mental model. `stream_type` is the
|
||
inner layer's framing byte, carried transparently in the payload.
|
||
- **The control channel is handler-internal.** TTY sub-demuxes control
|
||
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
|
||
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
|
||
doesn't carry control.
|
||
- **Recursive composition is literal.** A channel with ALPN
|
||
`alk/channels` runs another channels demux on its `BiStream`. The
|
||
outer layer strips its 8-byte header; the inner layer parses its own
|
||
8-byte header from the payload.
|
||
|
||
## Architecture
|
||
|
||
The crate has two internal components (ADR-039):
|
||
|
||
- **`ChannelsAdapter`** — implements `ProtocolHandler` for
|
||
`alk/channels`. Its `handle()` receives one `Connection`, reads 8-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-008/074, as
|
||
amended by ADR-035). The handler calls `accept_bi()` once (yield-once per
|
||
channel) and gets a `BiStream` — 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
|
||
|
||
alkcall is a single crate with two internal subsystems (call + channels).
|
||
The channels subsystem is split into two modules:
|
||
|
||
- **`channels` (core)** — the pure multiplexer: wire format, demux/mux,
|
||
`ChannelBidiStreamSource`, `ChannelManager`. ALPN-blind,
|
||
call-protocol-blind, transport-blind. This is where the "streams are
|
||
streams" insight lives.
|
||
- **`channels` (call coupling)** — channel 0 pre-negotiation as
|
||
`alk/call` (ADR-036), the lifecycle operations (ADR-037) registered
|
||
on the call protocol's `OperationRegistry`, `ChannelCore` (ADR-047 §3),
|
||
and `ChannelClient` (ADR-043). This is where the call-protocol coupling
|
||
lives, isolated from the pure multiplexer.
|
||
|
||
Downstream crates depend on alkcall as a single dependency:
|
||
|
||
```
|
||
alktty / alktunnels / alktrader (protocol crates)
|
||
└── alkcall (call + channels protocols, no networking)
|
||
|
||
alknet / alknode (networking + composition)
|
||
├── alkcall
|
||
├── alktty (optional — whichever protocol crates are wired in)
|
||
├── alktunnels (optional)
|
||
└── quinn / tokio-rustls (transport)
|
||
```
|
||
|
||
The hub and worker are **consumers** of alkcall, not sub-crates. The
|
||
downstream alknet crate IS the hub — it depends on alkcall and uses
|
||
channels as its substrate, with the relay logic (ADR-042) living
|
||
alongside its peer lifecycle and service discovery responsibilities.
|
||
A worker is any crate that uses `ChannelClient` to dial. There are no
|
||
`channels-hub` or `channels-worker` sub-crates.
|
||
|
||
## ALPN
|
||
|
||
`alk/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 | `alk/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
|
||
| TCP+TLS | `alk/channels` ALPN on a TLS connection; the TCP stream carries all channels |
|
||
| WebTransport | `alk/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alk/channels`) |
|
||
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
|
||
| Another channels connection | recursive composition (channel type `alk/channels` inside `alk/channels`) |
|
||
|
||
The same wire format, the same chunk reassembly, the same `Connection`
|
||
abstraction. The transport is a parameter, not a design constraint.
|
||
`Connection::from_bidi` / `from_source` (ADR-007/070/092) 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 downstream crates
|
||
|
||
alkcall is a pure protocol crate — no networking, no transport
|
||
dependencies. Downstream crates compose on top of it. See
|
||
[README.md](README.md) §"Roles and Composition" for the full dependency
|
||
layering and the producer/consumer/hub/spoke role definitions.
|
||
|
||
### Protocol crates (alktty, alktunnels, etc.)
|
||
|
||
Protocol crates depend only on alkcall. They provide:
|
||
|
||
- **Producer half** — a `register_*()` function that takes an
|
||
`&mut OperationRegistry` and registers ops with their handlers. For
|
||
channels-based protocols, an `OpenHandler` factory registered via
|
||
`ChannelCore::register_openable`.
|
||
- **Consumer half** — a typed client wrapper around `CallConnection`
|
||
(or `ChannelClient`) that exposes the crate's ops as async methods.
|
||
|
||
A protocol crate can support two paths for its wire format:
|
||
|
||
| Path | How it connects | Wire format |
|
||
|------|----------------|-------------|
|
||
| Direct ALPN | `ProtocolHandler` on its own ALPN (e.g. `alk/tty`), gets a `Connection`, loops `accept_bi` | Own wire format (e.g. TTY's 5-byte header) |
|
||
| Through channels | Registered via `ChannelCore::register_openable`, gets a `BiStream` per session | Own wire format rides inside channels 8-byte payload |
|
||
|
||
The protocol crate doesn't know which path it's on — it takes a
|
||
`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The
|
||
two adapters are thin and live either in the protocol crate (behind
|
||
feature flags) or in the downstream alknet crate.
|
||
|
||
### alknet-call
|
||
|
||
The call protocol runs on channel 0 exactly as on a top-level
|
||
`alk/call` connection. The `Dispatcher` receives a `Connection`
|
||
backed by channel-0 chunk reassembly and dispatches operations — it
|
||
doesn't know it's inside channels. The call protocol's `EventEnvelope`
|
||
framing (ADR-014) is the channels payload; the channels layer carries
|
||
it transparently.
|
||
|
||
### alknet-hub / alknode
|
||
|
||
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 per-ALPN open ops on channel
|
||
0 (re-issues on the spoke leg with `forwarded_for` — ADR-042) 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 |
|
||
|-----|----------|---------|
|
||
| [034](decisions/034-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-035); channels layer has no `stream_type` concept; one-way door |
|
||
| [035](decisions/035-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
|
||
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alk/call` |
|
||
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
|
||
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-035 — `into_sub_streams` removed) |
|
||
| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
|
||
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
|
||
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound |
|
||
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
|
||
| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
|
||
| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
|
||
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` |
|
||
|
||
## Open Questions
|
||
|
||
Open questions are tracked in [open-questions.md](open-questions.md).
|
||
Key questions affecting this crate:
|
||
|
||
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
|
||
— extracted as `alknet-client` with three dial methods.
|
||
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
|
||
dial is the shared seam, now extracted. See
|
||
[ADR-045](decisions/045-alknetclient-native-dial-seam.md).
|
||
- **OQ-56** (deferred(scope)): Full channel-level flow-control windowing —
|
||
bounded-buffer is decided (ADR-040); 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.
|
||
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
|
||
add/strip is built into the channels read/write path or exposed as a
|
||
standalone utility. The *contract* (channels strips, handler parses
|
||
payload) is decided (ADR-035); the *function surface* is not. |