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,37 +1,31 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access
|
||||
# channels-connection.md — ChannelBidiStreamSource and `BiStream` 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.
|
||||
ADR-074 (amended by ADR-093) is the decision; this doc specifies the API
|
||||
shape — one accessor, one `BiStream` per channel.
|
||||
|
||||
## What
|
||||
|
||||
Each channel is reassembled into a set of **unidirectional** handles — one
|
||||
per active `stream_type` (declared at `channel/open` time, ADR-073). Every
|
||||
stream_type is unidirectional (ADR-071 §stream_type decomposition);
|
||||
bidirectionality is two stream_types (write + read), not one shared
|
||||
"bidirectional" stream. Write stream_types (`% 3 == 0`) carry a
|
||||
`SendStream`; read stream_types (`% 3 == 1 or 2`) carry a `RecvStream`.
|
||||
Each channel is reassembled into a `BiStream` — a single duplex
|
||||
(`AsyncRead + AsyncWrite`) byte stream. The channels layer strips its
|
||||
8-byte header (`channel_id` + `length`) on read, hands the payload to the
|
||||
reassembled `BiStream`, and the handler parses its own framing from the
|
||||
payload. The handler sub-multiplexes its `BiStream` however it wants —
|
||||
TTY sub-demuxes `stream_type` from its `BiStream` via its 5-byte format,
|
||||
tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
|
||||
runs its own channel protocol.
|
||||
|
||||
These handles are wrapped as a `ChannelBidiStreamSource` that implements
|
||||
The `BiStream` is wrapped in 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, SubStreamHandle)` tuples —
|
||||
the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
|
||||
control-out).
|
||||
|
||||
Both paths operate on the same reassembly buffers; the difference is how the
|
||||
handler accesses them.
|
||||
constructed from it via `Connection::from_source(source, alpn)`. The
|
||||
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
|
||||
channel), gets a `BiStream`, and drives its session — identical to how it
|
||||
works on a top-level QUIC connection.
|
||||
|
||||
## `ChannelBidiStreamSource`
|
||||
|
||||
@@ -39,29 +33,30 @@ handler accesses them.
|
||||
// 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).
|
||||
// The reassembly buffer for this channel's payload bytes (one per
|
||||
// channel_id, not per (channel_id, stream_type) — the channels layer
|
||||
// has no stream_type concept), 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>
|
||||
-> Result<BiStream, StreamError>
|
||||
{
|
||||
// Yields the (stream_type 0, stream_type 1) pair on first call,
|
||||
// Yields the channel's BiStream 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>
|
||||
-> Result<BiStream, 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().
|
||||
// streams (same as ADR-065's Stream backend). The handler owns
|
||||
// its sub-stream multiplexing on the BiStream it received.
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> Option<SocketAddr> { ... }
|
||||
@@ -75,19 +70,23 @@ 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()`
|
||||
## The single path: `accept_bi()`
|
||||
|
||||
For handlers that only need the main data pair (`stream_type` 0 = data-in,
|
||||
`stream_type` 1 = data-out):
|
||||
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
|
||||
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
|
||||
wants. There is one accessor; the two-accessor design
|
||||
(`accept_bi` vs `into_sub_streams`) from ADR-074's original shape is
|
||||
removed by ADR-093.
|
||||
|
||||
```rust
|
||||
// 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 bidi = connection.accept_bi().await?;
|
||||
let mut tcp = TcpStream::connect(target).await?;
|
||||
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||
let (mut recv, mut send) = tokio::io::split(&mut bidi);
|
||||
|
||||
// Two-pump with shutdown-on-completion (ADR-078)
|
||||
let c2t = async {
|
||||
@@ -105,98 +104,53 @@ async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```rust
|
||||
// In alknet-channels-core:
|
||||
pub struct ChannelSubStreams {
|
||||
/// (stream_type, handle) for each active stream_type. Each handle is
|
||||
/// unidirectional: write stream_types (0, 3, 6, ...) carry a SendStream;
|
||||
/// read stream_types (1, 2, 4, 5, 7, ...) carry a RecvStream.
|
||||
/// See ADR-071 §stream_type decomposition.
|
||||
pub streams: Vec<(u8, SubStreamHandle)>,
|
||||
}
|
||||
|
||||
pub enum SubStreamHandle {
|
||||
Send(SendStream), // write half (stream_type % 3 == 0)
|
||||
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
|
||||
}
|
||||
|
||||
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/4.
|
||||
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||
// TTY handler (inside-channels mode, ADR-077 reversed by ADR-093) —
|
||||
// the SAME code as direct mode, just a different BiStream source.
|
||||
async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
let mut bidi = connection.accept_bi().await?;
|
||||
// drive_session reads the 5-byte TTY chunks off `bidi` — the same
|
||||
// code as direct mode. The channels layer stripped its 8-byte
|
||||
// header; TTY's 5-byte format is the payload.
|
||||
drive_session(bidi, backends, ownership, identity).await
|
||||
}
|
||||
```
|
||||
|
||||
The handler crate destructures `ChannelSubStreams` into its typed names:
|
||||
The handler calls `accept_bi()` once, gets a `BiStream`, 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.
|
||||
|
||||
```rust
|
||||
// In alknet-tty (inside-channels mode, ADR-077):
|
||||
let sub = channel_source.into_sub_streams();
|
||||
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
|
||||
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
|
||||
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
|
||||
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
|
||||
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
|
||||
```
|
||||
|
||||
**Every stream_type is unidirectional** (ADR-071). The channels crate
|
||||
exposes `(stream_type, SubStreamHandle)` 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/4) | `into_sub_streams()` | TTY (stdin/stdout/stderr/ctrl-in/ctrl-out) |
|
||||
|
||||
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).
|
||||
`accept_bi()` is yield-once: the first call returns the `BiStream`;
|
||||
subsequent calls return `ConnectionClosed`. This matches the POC's
|
||||
validated shape and the `StreamBidiStreamSource` yield-once contract
|
||||
(ADR-070, ADR-092).
|
||||
|
||||
## 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.
|
||||
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`. The outer layer strips its
|
||||
8-byte header; the inner layer parses its own 8-byte header from the
|
||||
payload. Each level is the same shape: `BiStream → accept_bi → N
|
||||
BiStreams`. The recursion is unbounded and uniform at every level.
|
||||
|
||||
This is a property, not a feature. The primary use case is one level of
|
||||
multiplexing. But the add/strip composition makes it cleaner than
|
||||
ADR-071's group framing did — the recursion is the same operation
|
||||
(strip an 8-byte header) at every level, not a different framing per
|
||||
level.
|
||||
|
||||
## 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.
|
||||
- **`BiStream`** (ADR-092) — the leaf type `accept_bi` returns. The
|
||||
channels layer yields `BiStream`s; handlers parse them per their ALPN.
|
||||
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
|
||||
the same registry as top-level connections.
|
||||
|
||||
@@ -206,15 +160,22 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi`; `into_sub_streams()` accessor |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-093 — `into_sub_streams` removed, `accept_bi` is the only accessor) |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only |
|
||||
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
||||
| [092](../../decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
|
||||
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-074: ChannelConnection (the decision)
|
||||
- ADR-074: ChannelConnection (the decision, amended by ADR-093)
|
||||
- ADR-093: channels pure channel multiplexing (the umbrella decision)
|
||||
- ADR-070: BidiStreamSource trait
|
||||
- ADR-065: `Connection::from_stream`
|
||||
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
|
||||
- ADR-092: `BiStream` as the handler leaf
|
||||
- ADR-065: `Connection::from_stream` (the yield-once path generalized)
|
||||
- ADR-077: TTY inside channels (reversed by ADR-093 — TTY always uses
|
||||
its 5-byte format, carried transparently in the channels payload)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
|
||||
yield-once `Connection::from_stream` validation)
|
||||
yield-once `Connection::from_stream` validation)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
surfaced the single-accessor resolution
|
||||
Reference in new issue
Block a user