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.
220 lines
8.8 KiB
Markdown
220 lines
8.8 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-07-12
|
|
---
|
|
|
|
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream 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.
|
|
|
|
## 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`.
|
|
|
|
These handles are wrapped as 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.
|
|
|
|
## `ChannelBidiStreamSource`
|
|
|
|
```rust
|
|
// 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).
|
|
...
|
|
}
|
|
|
|
#[async_trait]
|
|
impl BidiStreamSource for ChannelBidiStreamSource {
|
|
async fn accept_bi(&self)
|
|
-> Result<(SendStream, RecvStream), StreamError>
|
|
{
|
|
// Yields the (stream_type 0, stream_type 1) pair 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>
|
|
{
|
|
// 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().
|
|
}
|
|
|
|
fn remote_addr(&self) -> Option<SocketAddr> { ... }
|
|
|
|
fn close(&self, _code: u32, _reason: &str) { ... }
|
|
}
|
|
```
|
|
|
|
One `ChannelBidiStreamSource` instance represents **one channel** (not the
|
|
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()`
|
|
|
|
For handlers that only need the main data pair (`stream_type` 0 = data-in,
|
|
`stream_type` 1 = data-out):
|
|
|
|
```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 tcp = TcpStream::connect(target).await?;
|
|
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
|
|
|
// Two-pump with shutdown-on-completion (ADR-078)
|
|
let c2t = async {
|
|
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
|
tcp_write.shutdown().await.ok();
|
|
Ok::<_, std::io::Error>(())
|
|
};
|
|
let t2c = async {
|
|
tokio::io::copy(&mut tcp_read, &mut send).await?;
|
|
send.shutdown().await.ok(); // emits zero-length sentinel (REQ-CH-01)
|
|
Ok::<_, std::io::Error>(())
|
|
};
|
|
tokio::try_join!(c2t, t2c)?;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
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 { ... }
|
|
}
|
|
```
|
|
|
|
The handler crate destructures `ChannelSubStreams` into its typed names:
|
|
|
|
```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).
|
|
|
|
## 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.
|
|
|
|
## 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.
|
|
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
|
|
the same registry as top-level connections.
|
|
|
|
## Design Decisions
|
|
|
|
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 |
|
|
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
|
| [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-070: BidiStreamSource trait
|
|
- ADR-065: `Connection::from_stream`
|
|
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
|
|
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
|
|
yield-once `Connection::from_stream` validation) |