docs(adr): 093 — channels pure channel multiplexing (8-byte header, no stream_type)
Prune the channels spec to reflect the stream-unification resolution (docs/research/stream-unification/findings.md): the channels wire format goes from 9 bytes to 8 bytes, the channels layer no longer carries a stream_type concept, into_sub_streams() is removed, and TTY always uses its 5-byte format (carried transparently in the channels payload). ADR-093 is the umbrella decision (the channels-layer consequence of ADR-092's BiStream handler leaf): every channel is a BiStream, the handler owns its sub-stream multiplexing, the channels layer routes by channel_id only. Amends ADR-071 (8-byte header, no stream_type), ADR-074 (into_sub_streams removed, accept_bi yields BiStream), reverses ADR-077 (TTY always 5-byte), and the channels-facing clauses of ADR-072/073/075/076/080/081. Adds ADR-092 forward-reference note (into_sub_streams preservation subsequently reversed by ADR-093) and the missing ADR-092 cross-reference on ADR-070. Adds OQ-68 (add/strip API shape — built-in vs utility; the contract is decided in ADR-093, the function surface is open; two-way door, low priority, decision-ready when the channels crate's implementation begins). Rewrites the 7 channels spec docs (README, overview, channels-wire, channels-connection, channels-adapter, channel-operations, channel-client) to describe the post-amendment shape as current, with the 8-byte header, the add/strip composition, single accept_bi accessor, BiStream per channel, and TTY-always-5-byte. Touch-up cross-references in hub README, client README, ADR-085, and the OQ-45/47/65 question files (TTY-internal stream_type 3 → STREAM_CTRL_IN; channels 9-byte → 8-byte).
This commit is contained in:
1 parent
c2b7055a64
commit
a3cb44968e
31 files changed
+1501
-519
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
||||
@@ -8,13 +8,15 @@ last_updated: 2026-07-12
|
||||
The two internal components of the channels crate: the read/demux half
|
||||
(`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`).
|
||||
ADR-075 is the decision; this doc specifies the contracts and the demux/mux
|
||||
invariants.
|
||||
invariants. The channels layer has no `stream_type` concept (ADR-093) —
|
||||
the demux routes by `channel_id` only, and the reassembly buffer is one
|
||||
per channel (not per `(channel_id, stream_type)`).
|
||||
|
||||
## The split
|
||||
|
||||
| Component | Role | What it knows |
|
||||
|-----------|------|---------------|
|
||||
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
|
||||
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 8-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-071 §substrate modes, as amended by ADR-093). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
|
||||
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over (in `channels-call`). | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). |
|
||||
|
||||
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
|
||||
@@ -34,33 +36,35 @@ impl ProtocolHandler for ChannelsAdapter {
|
||||
// 1. Channel 0 is pre-negotiated (ADR-072). The first bidi stream
|
||||
// the transport yields is channel 0. The consumer (channels-call)
|
||||
// installs the CallAdapter on it.
|
||||
let (send, recv) = connection.accept_bi().await?;
|
||||
self.manager.preinstall_channel_0(send, recv, auth).await?;
|
||||
let bidi = connection.accept_bi().await?;
|
||||
self.manager.preinstall_channel_0(bidi, auth).await?;
|
||||
|
||||
// 2. Accept remaining bidi streams and read 9-byte headers off each.
|
||||
// 2. Accept remaining bidi streams and read 8-byte headers off each.
|
||||
// On an in-line transport, accept_bi() yields once and the header
|
||||
// demuxes N channels from that stream. On QUIC native, accept_bi()
|
||||
// yields repeatedly — each stream carries one logical channel.
|
||||
// Same code path, same wire format (ADR-071 §substrate modes).
|
||||
// Same code path, same wire format (ADR-071 §substrate modes,
|
||||
// as amended by ADR-093).
|
||||
self.manager.run_demux_loop(connection).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `preinstall_channel_0` step (provided by `channels-call`, ADR-081)
|
||||
constructs the reassembly buffers for `channel_id = 0` using stream_types
|
||||
[0, 1] (ADR-072), wraps them as a `Connection` via `Connection::from_source`
|
||||
with a `ChannelBidiStreamSource` (ADR-074), and hands that `Connection` to
|
||||
the `CallAdapter`. The `ChannelsAdapter` in `channels-core` exposes the
|
||||
hook; `channels-call` provides the implementation.
|
||||
constructs the reassembly buffer for `channel_id = 0`, wraps it as a
|
||||
`Connection` via `Connection::from_source` with a
|
||||
`ChannelBidiStreamSource` (ADR-074, as amended by ADR-093 — `accept_bi`
|
||||
yields a `BiStream`), and hands that `Connection` to the `CallAdapter`.
|
||||
The `ChannelsAdapter` in `channels-core` exposes the hook; `channels-call`
|
||||
provides the implementation.
|
||||
|
||||
`run_demux_loop` continues accepting bidi streams from the transport. For
|
||||
each stream, it reads 9-byte headers and routes payloads to the matching
|
||||
`(channel_id, stream_type)` reassembly buffer. On an in-line transport,
|
||||
there is only one stream (channel 0 rides inside it via the header); the
|
||||
header demuxes all channels. On QUIC, each subsequent stream is a new
|
||||
channel; the header's `channel_id` correlates it. The loop is the same;
|
||||
only the transport's stream count differs.
|
||||
each stream, it reads 8-byte headers and routes payloads to the matching
|
||||
`channel_id`'s reassembly buffer. On an in-line transport, there is only
|
||||
one stream (channel 0 rides inside it via the header); the header demuxes
|
||||
all channels. On QUIC, each subsequent stream is a new channel; the
|
||||
header's `channel_id` correlates it. The loop is the same; only the
|
||||
transport's stream count differs.
|
||||
|
||||
## `ChannelManager`
|
||||
|
||||
@@ -79,9 +83,11 @@ pub struct ChannelManager {
|
||||
|
||||
struct ChannelState {
|
||||
alpn: String,
|
||||
streams: HashMap<u8, ReassemblyBuffer>,
|
||||
/// One reassembly buffer per channel (not per (channel_id, stream_type) —
|
||||
/// the channels layer has no stream_type concept per ADR-093). Yields
|
||||
/// a BiStream to the handler.
|
||||
reassembly: ReassemblyBuffer,
|
||||
handler_task: JoinHandle<()>,
|
||||
stream_types: Vec<u8>,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,13 +96,13 @@ struct ChannelState {
|
||||
all hold a handle.
|
||||
|
||||
> **Type-name convention:** `ChannelManager`, `ChannelsAdapter`,
|
||||
> `ChannelBidiStreamSource`, `ChannelSubStreams`, and `ChannelClient` are
|
||||
> the public API surface (contract). `ReassemblyBuffer`, `Demux`,
|
||||
> `MuxHandle`/`MuxRunner`, `MpscSendStream`/`MpscRecvStream`, and
|
||||
> `ChannelOperations` are illustrative internal type names — the channels
|
||||
> crate's implementation may name them differently. The contracts are the
|
||||
> invariants (REQ-CH-01..04, 06) and the public API; the internal names are
|
||||
> not contractual.
|
||||
> `ChannelBidiStreamSource`, and `ChannelClient` are the public API
|
||||
> surface (contract). `ReassemblyBuffer`, `Demux`, `MuxHandle`/`MuxRunner`,
|
||||
> `MpscSendStream`/`MpscRecvStream`, and `ChannelOperations` are
|
||||
> illustrative internal type names — the channels crate's implementation
|
||||
> may name them differently. The contracts are the invariants
|
||||
> (REQ-CH-01..04, 06) and the public API; the internal names are not
|
||||
> contractual.
|
||||
|
||||
### `ChannelManager` is ALPN-blind and auth-blind
|
||||
|
||||
@@ -107,8 +113,9 @@ The `ChannelManager` deliberately does **not** hold:
|
||||
their crates and register on the same registry.
|
||||
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
|
||||
SSH frames, or tunnel target strings. It hands `params` JSON to the
|
||||
handler and gets back a handler task; it hands `stream_type 3` JSON to the
|
||||
handler's control handle.
|
||||
handler and gets back a handler task. The channels layer carries the
|
||||
handler's framing transparently in the payload — it does not interpret
|
||||
the payload bytes.
|
||||
- **No auth state.** Auth lives in the `OperationContext` that the call
|
||||
protocol passes to `channel/open`. The `ChannelManager` doesn't check
|
||||
scopes or ownership — that's `AccessControl::check` in
|
||||
@@ -116,6 +123,10 @@ The `ChannelManager` deliberately does **not** hold:
|
||||
- **No transport coupling.** It talks to the transport only through the
|
||||
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of
|
||||
which use `AsyncRead + AsyncWrite`.
|
||||
- **No `stream_type` concept.** Per ADR-093, the channels layer routes by
|
||||
`channel_id` only. There is one reassembly buffer per channel (yielding
|
||||
a `BiStream`), not one per `(channel_id, stream_type)`. The handler
|
||||
owns its sub-stream multiplexing on the `BiStream` it receives.
|
||||
|
||||
This is what makes the channels layer WASM-compatible and transport-agnostic
|
||||
— the `ChannelManager` is pure byte routing with no platform or protocol
|
||||
@@ -139,8 +150,8 @@ The `channel/open` handler (ADR-073):
|
||||
missing.
|
||||
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
|
||||
server-assigned).
|
||||
4. Constructs the `ChannelBidiStreamSource` (ADR-074) for the negotiated
|
||||
`stream_types`.
|
||||
4. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
|
||||
ADR-093) — one reassembly buffer, yielding a `BiStream`.
|
||||
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
|
||||
Identical to what `TtyAdapter::handle` does today, but on a
|
||||
channels-backed `Connection`.
|
||||
@@ -152,7 +163,7 @@ The `channel/open` handler (ADR-073):
|
||||
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
|
||||
|
||||
On transport EOF, `run_demux_loop` clears the `channels` map, dropping all
|
||||
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
|
||||
`ReassemblyBuffer` senders. Every handler's reassembled `BiStream` sees
|
||||
EOF even without an explicit zero-length sentinel on the wire. Without this,
|
||||
`read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a
|
||||
sender that never drops. This is a teardown invariant of the
|
||||
@@ -160,11 +171,10 @@ sender that never drops. This is a teardown invariant of the
|
||||
|
||||
### REQ-CH-04: lenient unknown-`channel_id` handling
|
||||
|
||||
A chunk with an unallocated `channel_id` (or `stream_type`) is dropped with
|
||||
a debug log and an error counter (exposed via `Demux::stats()`), and the
|
||||
demux continues. This matches SSH's behavior and survives transient
|
||||
mis-ordering during teardown. Validated by the POC
|
||||
(`demux_unknown_channel_drops_lenient`).
|
||||
A chunk with an unallocated `channel_id` is dropped with a debug log and
|
||||
an error counter (exposed via `Demux::stats()`), and the demux continues.
|
||||
This matches SSH's behavior and survives transient mis-ordering during
|
||||
teardown. Validated by the POC (`demux_unknown_channel_drops_lenient`).
|
||||
|
||||
## Mux invariants (REQ-CH-03)
|
||||
|
||||
@@ -177,8 +187,8 @@ after the run loop starts.
|
||||
|
||||
The mux is split into:
|
||||
|
||||
- **`MuxHandle`** — clone-able, `register(channel_id, stream_type) ->
|
||||
Sender<Bytes>` callable at any time after the runner starts.
|
||||
- **`MuxHandle`** — clone-able, `register(channel_id) -> Sender<Bytes>`
|
||||
callable at any time after the runner starts.
|
||||
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
|
||||
and per-channel write pumps.
|
||||
|
||||
@@ -222,19 +232,20 @@ channels connections:
|
||||
```rust
|
||||
// For channel_id=7 on browser side, channel_id=12 on spoke side:
|
||||
tokio::spawn(async move {
|
||||
let (b_send, b_recv) = browser_mgr.open_channel_stream(7, stream_type).await;
|
||||
let (s_send, s_recv) = spoke_mgr.open_channel_stream(12, stream_type).await;
|
||||
let mut b_bidi = browser_mgr.open_channel_stream(7).await;
|
||||
let mut s_bidi = spoke_mgr.open_channel_stream(12).await;
|
||||
tokio::join!(
|
||||
pump(b_recv, s_send), // browser → spoke (with channel_id rewrite)
|
||||
pump(s_recv, b_send), // spoke → browser (with channel_id rewrite)
|
||||
pump(&mut b_bidi, &mut s_bidi), // browser → spoke (with channel_id rewrite)
|
||||
pump(&mut s_bidi, &mut b_bidi), // spoke → browser (with channel_id rewrite)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
The relay reads opaque bytes off one `ChannelManager`'s reassembled stream
|
||||
and writes them onto the other's write-half, which re-chunks them with the
|
||||
other leg's `channel_id`. The relay does not parse the bytes — it doesn't
|
||||
know if they're TTY chunks, SSH frames, or tunnel data. The hub translates
|
||||
The relay reads opaque bytes off one `ChannelManager`'s reassembled
|
||||
`BiStream` and writes them onto the other's write-half, which re-chunks
|
||||
them with the other leg's `channel_id` (a 4-byte rewrite within the
|
||||
8-byte header). The relay does not parse the payload — it doesn't know if
|
||||
the bytes are TTY chunks, SSH frames, or tunnel data. The hub translates
|
||||
`channel/open` on channel 0 (re-issues on the spoke leg with
|
||||
`forwarded_for`); data channels are byte-forwarded with `channel_id`
|
||||
rewrite. See ADR-079 for the full relay contract.
|
||||
@@ -246,6 +257,7 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
||||
@@ -253,9 +265,14 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
## References
|
||||
|
||||
- ADR-075: ChannelsAdapter and ChannelManager (the decision)
|
||||
- ADR-093: channels pure channel multiplexing (the umbrella decision that
|
||||
amends ADR-071/074/077)
|
||||
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
||||
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
|
||||
- ADR-074: ChannelBidiStreamSource (what the manager constructs per channel)
|
||||
- ADR-074: ChannelBidiStreamSource (what the manager constructs per
|
||||
channel, as amended by ADR-093)
|
||||
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
|
||||
(REQ-CH-01..04, the two-pump deadlock)
|
||||
(REQ-CH-01..04, the two-pump deadlock)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
surfaced the pure-multiplexing resolution
|
||||
Reference in new issue
Block a user