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.
This commit is contained in:
1 parent
2313c51f12
commit
fd83fc1685
14 files changed
+734
-251
No files matched your search
+32
-17
@@ -9,35 +9,43 @@ last_updated: 2026-07-12
|
||||
|
||||
**alknet-channels specs drafted.** The alknet-channels crate (multiplexing
|
||||
proxy — `ProtocolHandler` on `alknet/channels`, 9-byte chunk format, N
|
||||
channels over one transport stream, channel 0 pre-negotiated as
|
||||
channels over transport stream(s), channel 0 pre-negotiated as
|
||||
`alknet/call`) now has architecture specs:
|
||||
[crates/channels/](crates/channels/) (overview, channels-wire,
|
||||
channels-connection, channels-adapter, channel-operations, channel-client)
|
||||
and ten ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte
|
||||
chunk header), [ADR-072](decisions/072-channel-0-pre-negotiated-call.md)
|
||||
(channel 0 = `alknet/call` pre-negotiated, no special control plane),
|
||||
and eleven ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte
|
||||
chunk header; revised for substrate simplification — the header is used in
|
||||
all substrates including QUIC native, not just in-line; and stream_type
|
||||
decomposition — every stream_type is unidirectional, grouped in threes:
|
||||
0/1/2 = data write/read/err, 3/4/5 = control write/read/err, `% 3` formula;
|
||||
resolves the TTY control channel's "not actually bidirectional" flaw),
|
||||
[ADR-072](decisions/072-channel-0-pre-negotiated-call.md)
|
||||
(channel 0 = `alknet/call` pre-negotiated, stream_types [0,1] — call frames
|
||||
bidirectional via 0=in, 1=out),
|
||||
[ADR-073](decisions/073-channel-lifecycle-operations.md) (channel
|
||||
lifecycle operations on the call protocol — `channel/open`/`close`/
|
||||
`control`/`resources/subscribe`; `channel/resources/subscribe` is a
|
||||
`Subscription` operation using the already-implemented `StreamingHandler`
|
||||
machinery, not a polled `Query`; the `direction` field pins who is the
|
||||
ALPN-server; the control-message division is call-ops for orchestration,
|
||||
`stream_type 3` for data-ordered control),
|
||||
`stream_type 3`/`4` for data-ordered control),
|
||||
[ADR-074](decisions/074-channelconnection-bidistreamsource.md)
|
||||
(`ChannelBidiStreamSource` implements `BidiStreamSource` — ADR-070's
|
||||
extension point; `into_sub_streams()` typed accessor for handlers that need
|
||||
stderr/control; `accept_bi()` generic path for tunnel/SSH),
|
||||
extension point; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv
|
||||
per unidirectional stream_type); `accept_bi()` generic path for tunnel/SSH),
|
||||
[ADR-075](decisions/075-channelsadapter-and-channelmanager.md)
|
||||
(`ChannelsAdapter` read/demux + `ChannelManager` reassemble/allocate split;
|
||||
REQ-CH-01..04 wire-level invariants pinned: shutdown emits zero-length
|
||||
sentinel, transport close drops all senders, mux dynamic registration,
|
||||
lenient unknown-`channel_id`),
|
||||
(`ChannelsAdapter` substrate-agnostic demux loop (reads 9-byte headers off
|
||||
every bidi stream, regardless of substrate) + `ChannelManager`
|
||||
reassemble/allocate split; REQ-CH-01..04 wire-level invariants pinned:
|
||||
shutdown emits zero-length sentinel, transport close drops all senders, mux
|
||||
dynamic registration, lenient unknown-`channel_id`),
|
||||
[ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md)
|
||||
(bounded-buffer backpressure 1 MiB default, 256-channel cap, monotonic IDs
|
||||
with wrap-around),
|
||||
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels uses
|
||||
sub-streams, not its own 5-byte wire format; ADR-052's scope amended to
|
||||
direct-connect TTY only; `channels` feature on alknet-tty),
|
||||
sub-streams, not its own 5-byte wire format; 5 sub-streams [0,1,2,3,4]
|
||||
with control properly bidirectional via 3 (write) + 4 (read); ADR-052's
|
||||
scope amended to direct-connect TTY only; `channels` feature on alknet-tty),
|
||||
[ADR-078](decisions/078-two-pump-shutdown-on-completion.md) (two-pump
|
||||
handlers MUST shut down the opposite sink on pump completion — the
|
||||
deadlock contract the POC surfaced; handler-level, not channels-layer;
|
||||
@@ -46,10 +54,16 @@ core helper extraction deferred per OQ-57),
|
||||
translates `channel/open` on channel 0 with `forwarded_for` — ADR-032;
|
||||
data channels byte-forwarded with `channel_id` rewrite; the hub never runs
|
||||
protocol-specific handlers),
|
||||
[ADR-080](decisions/080-channelclient.md) (`ChannelClient` in
|
||||
alknet-channels, QUIC-only initially, bidirectionality preserved;
|
||||
[ADR-080](decisions/080-channelclient.md) (`ChannelClient`,
|
||||
QUIC-only initially, bidirectionality preserved;
|
||||
`AlknetClient` core extraction stays deferred per OQ-55 — blocked on a
|
||||
second *transport's* client, not a second client). The specs are grounded
|
||||
second *transport's* client, not a second client),
|
||||
[ADR-081](decisions/081-channels-subcrate-decomposition.md) (sub-crate
|
||||
decomposition — `channels-core` (pure multiplexer, depends on alknet-core
|
||||
only, no call dependency) / `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). The specs are grounded
|
||||
in the completed de-risk POC
|
||||
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three
|
||||
validated targets: chunk format + demux/mux, per-channel `Connection`
|
||||
@@ -64,7 +78,7 @@ blocking observation) and OQ-57 (two-pump helper extraction — blocked on a
|
||||
second two-pump handler). The TTY integration (ADR-077) amends ADR-052's
|
||||
scope — the 5-byte format is unchanged for direct `alknet/tty` connections;
|
||||
inside channels, TTY uses `into_sub_streams()` and the channels layer's
|
||||
de-chunking.
|
||||
de-chunking, with control properly bidirectional via stream_types 3/4.
|
||||
|
||||
**Pre-implementation of the storage/repo pattern.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains `alknet-vault` (stable — implementation complete and verified, local-only by construction per ADR-025, HD-derivation key model per ADR-026) and research/reference material. Foundational ADRs (001–035) are in place, with the call crate implemented and reviewed.
|
||||
|
||||
@@ -249,6 +263,7 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | Accepted |
|
||||
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted |
|
||||
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted |
|
||||
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -30,16 +30,17 @@ protocol work itself.
|
||||
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; one-way door |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol, no special control plane |
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; unidirectional stream_types in groups of 3; substrate-agnostic; one-way door |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol, stream_types [0,1]; no special control plane |
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` accessor |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 contracts |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv) |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel cap, monotonic IDs with wrap |
|
||||
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); amends ADR-052 scope |
|
||||
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); 5 sub-streams; control bidirectional via 3/4; amends ADR-052 scope |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient` in alknet-channels, QUIC-only; `AlknetClient` deferred (OQ-55) |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, QUIC-only; `AlknetClient` deferred (OQ-55) |
|
||||
| [081](../../decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling) / hub / worker |
|
||||
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
|
||||
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
||||
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format the 9-byte format generalizes (amended by ADR-077 — scoped to direct TTY) |
|
||||
|
||||
@@ -32,7 +32,7 @@ Request (on channel 0):
|
||||
| field | type | meaning |
|
||||
|-------|------|---------|
|
||||
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
|
||||
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3]` for TTY, `[0,1]` for a tunnel. |
|
||||
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3,4]` for TTY (data in/out/err + control in/out), `[0,1]` for a tunnel, `[0,1]` for channel 0. See ADR-071 §stream_type decomposition. |
|
||||
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
|
||||
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ invariants.
|
||||
|
||||
| Component | Role | What it knows |
|
||||
|-----------|------|---------------|
|
||||
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off the transport and routes to `ChannelManager` | The transport stream; the `ChannelManager` handle. ALPN-blind. |
|
||||
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`, `OperationRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over. | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). |
|
||||
| `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. |
|
||||
| `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
|
||||
pattern, generalized to N channels: the adapter drives N channels, and
|
||||
channel 0 is special only in that it's pre-allocated.
|
||||
channel 0 is special only in that it's pre-allocated (by `channels-call`).
|
||||
|
||||
## `ChannelsAdapter::handle`
|
||||
## `ChannelsAdapter::handle` (substrate-agnostic)
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
@@ -31,46 +31,47 @@ impl ProtocolHandler for ChannelsAdapter {
|
||||
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
// 1. One bidi stream carries all channels.
|
||||
// 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?;
|
||||
|
||||
// 2. Channel 0 is pre-negotiated as alknet/call (ADR-072).
|
||||
// Construct reassembly buffers, wrap as a Connection via
|
||||
// from_source(ChannelBidiStreamSource), hand to the CallAdapter.
|
||||
self.manager.preinstall_channel_0(send, recv, auth).await?;
|
||||
|
||||
// 3. Run the demux loop.
|
||||
self.manager.run_demux_loop(recv).await
|
||||
// 2. Accept remaining bidi streams and read 9-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).
|
||||
self.manager.run_demux_loop(connection).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `preinstall_channel_0`
|
||||
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.
|
||||
|
||||
The only special case: constructs the reassembly buffers for `channel_id =
|
||||
0`, wraps them as a `Connection` (via `Connection::from_source` with a
|
||||
`ChannelBidiStreamSource` — ADR-070/074), and hands that `Connection` to
|
||||
the `CallAdapter` — exactly as if `alknet/call` had been the top-level
|
||||
ALPN. The `CallAdapter` is looked up in the same `HandlerRegistry` as every
|
||||
other ALPN. The `CallAdapter` is none the wiser: it calls `accept_bi()`,
|
||||
gets one bidi stream (the channel-0 reassembled stream), and runs its
|
||||
dispatch loop. `EventEnvelope` frames ride on `stream_type = 0` of channel 0.
|
||||
|
||||
### `run_demux_loop`
|
||||
|
||||
Reads 9-byte headers off the transport, looks up `channel_id` in the
|
||||
`ChannelManager`'s `channels` map, and pushes the payload into the right
|
||||
`ReassemblyBuffer` for `(channel_id, stream_type)`. If the buffer is full
|
||||
(bounded-buffer backpressure, ADR-076), the loop stops reading that
|
||||
channel's chunks until the consumer drains — other channels keep flowing.
|
||||
`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.
|
||||
|
||||
## `ChannelManager`
|
||||
|
||||
```rust
|
||||
// In alknet-channels-core:
|
||||
pub struct ChannelManager {
|
||||
channels: Mutex<HashMap<u32, ChannelState>>,
|
||||
handlers: Arc<HandlerRegistry>,
|
||||
call_ops: Arc<OperationRegistry>,
|
||||
// Note: no call_ops field — the call-protocol coupling lives in
|
||||
// channels-call (ADR-081). The ChannelManager is ALPN-blind and
|
||||
// call-protocol-blind.
|
||||
next_id: AtomicU32, // monotonic; wraps at u32::MAX
|
||||
buffer_cap: usize, // default 1 MiB (ADR-076)
|
||||
max_channels: usize, // default 256 (ADR-076)
|
||||
|
||||
@@ -11,8 +11,13 @@ access paths.
|
||||
|
||||
## What
|
||||
|
||||
Each channel is reassembled into a set of `AsyncRead + AsyncWrite` handles
|
||||
—one per active `stream_type` (declared at `channel/open` time, ADR-073).
|
||||
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)`.
|
||||
@@ -21,9 +26,9 @@ 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, SendStream, RecvStream)`
|
||||
tuples — the typed handler path (TTY, which needs stdin/stdout/stderr/
|
||||
control).
|
||||
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.
|
||||
@@ -115,20 +120,24 @@ For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
|
||||
to 0/1:
|
||||
|
||||
```rust
|
||||
// In alknet-channels:
|
||||
// In alknet-channels-core:
|
||||
pub struct ChannelSubStreams {
|
||||
/// (stream_type, send_half, recv_half) for each active stream_type.
|
||||
pub streams: Vec<(u8, SendStream, RecvStream)>,
|
||||
/// (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)>,
|
||||
}
|
||||
|
||||
impl ChannelSubStreams {
|
||||
pub fn get(&self, stream_type: u8) -> Option<(&SendStream, &RecvStream)> { ... }
|
||||
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.
|
||||
/// direct access to stream_types 2/3/4.
|
||||
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||
}
|
||||
```
|
||||
@@ -138,15 +147,16 @@ 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(0).unwrap(); // SendStream
|
||||
let stdout = sub.get(1).unwrap(); // RecvStream
|
||||
let stderr = sub.get(2); // Option<&RecvStream>
|
||||
let control = sub.get(3).unwrap(); // RecvStream (JSON control)
|
||||
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)
|
||||
```
|
||||
|
||||
**The channels crate does not know about TTY's `stream_type` semantics.**
|
||||
It exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate
|
||||
maps stream_types to its typed names. This preserves ADR-003's
|
||||
**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.
|
||||
|
||||
@@ -159,7 +169,7 @@ include the 0/1 pair, so `into_sub_streams()` is the superset.
|
||||
| Handler shape | Path | Examples |
|
||||
|---------------|------|---------|
|
||||
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
|
||||
| Needs stderr/control (2/3) | `into_sub_streams()` | TTY (stdin/stdout/stderr/control) |
|
||||
| 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
|
||||
|
||||
@@ -37,26 +37,64 @@ stream — the demux drops the chunk and continues. The header is always
|
||||
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||
header.
|
||||
|
||||
## Stream types
|
||||
## Stream types — unidirectional, grouped in threes
|
||||
|
||||
| stream_type | direction | purpose |
|
||||
|-------------|-----------|---------|
|
||||
| 0 | write half | data flowing in (stdin equivalent) |
|
||||
| 1 | read half | data flowing out (stdout equivalent) |
|
||||
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) |
|
||||
| 3 | bidirectional | control messages (ALPN-specific JSON) |
|
||||
| 4-255 | reserved | future sub-stream types |
|
||||
**Every stream_type is unidirectional.** Bidirectionality is two
|
||||
stream_types (write + read), not one "bidirectional" stream_type. The
|
||||
stream_types are grouped in threes:
|
||||
|
||||
| Group | stream_type | direction | purpose |
|
||||
|-------|-------------|-----------|---------|
|
||||
| Data | 0 | write (client→server) | data in (stdin equivalent) |
|
||||
| | 1 | read (server→client) | data out (stdout equivalent) |
|
||||
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
|
||||
| Control | 3 | write (client→server) | control in (resize, signal, eof) |
|
||||
| | 4 | read (server→client) | control out (exit, keepalive response) |
|
||||
| | 5 | read (server→client) | control err (optional) |
|
||||
| Future | 6/7/8 | write/read/read | next group, same pattern |
|
||||
| | ... | | |
|
||||
|
||||
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
|
||||
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
|
||||
|
||||
256 values / 3 = 85 groups. The `u32` channel_id space combined with 85
|
||||
stream_type groups is effectively unlimited for the intended use cases.
|
||||
|
||||
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
|
||||
own flow control, its own EOF. Control is bidirectional via two halves
|
||||
(3 in, 4 out), not one shared stream both sides write to. This resolves the
|
||||
TTY control channel's "not actually bidirectional" flaw (ADR-077).
|
||||
|
||||
Not all channels use all sub-streams. The active set is declared at
|
||||
`channel/open` time (ADR-073 `stream_types` field) and fixed for the
|
||||
channel's lifetime.
|
||||
|
||||
| Channel ALPN | Active stream_types |
|
||||
|--------------|---------------------|
|
||||
| `alknet/call` (channel 0) | 0 (EventEnvelope frames) |
|
||||
| `alknet/tty` | 0, 1, 2, 3 (stdin, stdout, stderr, control) |
|
||||
| `alknet/tunnel` | 0, 1 (data-in, data-out) |
|
||||
| `alknet/ssh` | 0, 1 (data-in, data-out — SSH multiplexes internally) |
|
||||
| Channel ALPN | Active stream_types | Why |
|
||||
|--------------|---------------------|-----|
|
||||
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
|
||||
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
|
||||
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
|
||||
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
|
||||
|
||||
## Substrate modes — same wire format, different stream counts
|
||||
|
||||
The 9-byte header is used in all substrates, on every bidi stream. The
|
||||
difference between substrates is only **how many bidi streams the transport
|
||||
yields**:
|
||||
|
||||
| Substrate | Transport | Streams | Header role |
|
||||
|-----------|-----------|---------|--------------|
|
||||
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
|
||||
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
|
||||
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
|
||||
|
||||
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
|
||||
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
|
||||
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
|
||||
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
|
||||
each stream is a channel, and the header provides `stream_type` and
|
||||
`channel_id` correlation. Same code path, same wire format, same handler
|
||||
experience. See ADR-071 §substrate modes, ADR-075.
|
||||
|
||||
## Channel 0 — pre-negotiated `alknet/call`
|
||||
|
||||
@@ -64,9 +102,11 @@ Channel 0 is not a special "control plane" with its own framing. It is
|
||||
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0` is
|
||||
routed to the `CallAdapter` without an explicit `channel/open` exchange.
|
||||
|
||||
Channel 0 uses only `stream_type` 0 for `EventEnvelope` frames (JSON,
|
||||
length-prefixed — the call protocol wire format, ADR-064). `stream_type`
|
||||
1-255 on channel 0 are reserved for future call-protocol sub-streams.
|
||||
Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in
|
||||
(client→server), 1=out (server→client). The call protocol's `(SendStream,
|
||||
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
|
||||
`RecvStream` backed by stream_type 1. stream_types 2-255 on channel 0 are
|
||||
reserved for future call-protocol sub-streams.
|
||||
|
||||
Channel 0's chunks have `channel_id = 0` in the header — same format as
|
||||
every other channel. Disambiguation between channel 0 and data channels is
|
||||
@@ -80,9 +120,9 @@ The 9-byte header is always exactly 9 bytes. `length` is bounded by
|
||||
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header —
|
||||
the format is self-synchronizing.
|
||||
|
||||
Within a channel, `stream_type` 0 (stdin) from the server is invalid, so
|
||||
`0x00` as the first byte of a chunk payload from the server is unambiguous
|
||||
(carried from ADR-052 §5).
|
||||
Within a channel, `stream_type` 0 (write half) from the server is invalid,
|
||||
so `0x00` as the first byte of a chunk payload from the server is
|
||||
unambiguous (carried from ADR-052 §5).
|
||||
|
||||
## Zero-length sentinel = EOF
|
||||
|
||||
@@ -182,7 +222,7 @@ tokio-dependent shell. The POC validated the sync core compiles under
|
||||
|-------|-----------|-----------|
|
||||
| Open | `channel/open` call operation on channel 0; responder allocates `channel_id`, returns it | ADR-073 |
|
||||
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees `AsyncRead + AsyncWrite` | this doc, [channels-connection.md](channels-connection.md) |
|
||||
| Control (data-ordered) | `stream_type 3` chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 |
|
||||
| Control (data-ordered) | `stream_type 3` (write) and `stream_type 4` (read) chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 |
|
||||
| Control (out-of-band) | `channel/control` call operation on channel 0 | ADR-073 |
|
||||
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-073, REQ-CH-06 |
|
||||
|
||||
@@ -194,10 +234,10 @@ invariant: the side closing must observe the data-channel pump complete
|
||||
before issuing the call operation.
|
||||
|
||||
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward:
|
||||
the exit control message on `stream_type 3` is the last data before
|
||||
`channel/close`. For tunnels it is the last data byte before close. The
|
||||
channels layer's close handler observes the pump completion; the call
|
||||
operation is issued after.
|
||||
the exit control message on `stream_type 4` (read, server→client) is the
|
||||
last data before `channel/close`. For tunnels it is the last data byte
|
||||
before close. The channels layer's close handler observes the pump
|
||||
completion; the call operation is issued after.
|
||||
|
||||
This invariant crosses two channels (the data channel and channel 0), so
|
||||
the channels layer owns the ordering guarantee — it is not a handler
|
||||
|
||||
@@ -94,29 +94,48 @@ design.
|
||||
## Crate dependencies
|
||||
|
||||
```
|
||||
alknet-channels
|
||||
alknet-channels-core
|
||||
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
|
||||
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
|
||||
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
|
||||
│ make_streaming_handler, CallError, ResponseEnvelope)
|
||||
├── 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` depends on `alknet-call` because channel lifecycle
|
||||
operations (`channel/open`, `channel/close`, `channel/control`,
|
||||
`channel/resources/subscribe`) are registered on the call protocol's
|
||||
`OperationRegistry` (ADR-073). This is the one handler-crate →
|
||||
`alknet-call` dependency; it is sound because channels *is* a call-protocol
|
||||
extension (channel lifecycle is call operations), not a peer handler.
|
||||
`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.
|
||||
|
||||
The dependency direction is: handlers depend on `alknet-core`;
|
||||
`alknet-channels` depends on `alknet-core` and `alknet-call`; nothing
|
||||
depends on `alknet-channels` except the assembly layer and
|
||||
`alknet-tty`/`alknet-docker` behind their `channels`/`tty` feature gates.
|
||||
`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
|
||||
|
||||
@@ -220,16 +239,17 @@ 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; one-way door |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, no special control plane |
|
||||
| [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()` accessor |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 |
|
||||
| [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); ADR-052 scoped to direct |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (revised 2026-07-12: substrate simplification + stream_type
|
||||
decomposition)
|
||||
|
||||
## Context
|
||||
|
||||
`alknet-channels` is a multiplexing proxy: a `ProtocolHandler` on
|
||||
`alknet/channels` that decomposes a single bidirectional transport stream into
|
||||
N logical channels, each carrying a different ALPN. The wire format is the
|
||||
substrate that makes one transport stream carry many channels.
|
||||
`alknet/channels` that carries N logical channels, each with a different
|
||||
ALPN, over transport stream(s). The wire format is the substrate that makes
|
||||
this work.
|
||||
|
||||
Two prior formats inform this design:
|
||||
|
||||
@@ -18,16 +19,47 @@ Two prior formats inform this design:
|
||||
encrypted transport stream.
|
||||
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
|
||||
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
|
||||
within one bidi stream. Validated by two POCs (alknet-docker-poc,
|
||||
alknet-tty-poc) and in production code (`crates/alknet-tty/src/wire.rs`).
|
||||
within one bidi stream. Validated by two POCs and in production code.
|
||||
|
||||
The channels format is the generalization: add a `channel_id: u32` prefix to
|
||||
TTY's 5-byte header, turning a fixed 4-channel multiplexer into an arbitrary
|
||||
N-channel multiplexer. The de-risk POC (`docs/research/alknet-channels/poc-
|
||||
summary.md`, 28 tests) validated this is a clean generalization: the sync core
|
||||
(`parse_header`/`write_header`) is pure and WASM-compatible by construction;
|
||||
the mpsc-bridged async shell scales to N concurrent channels with per-channel
|
||||
order preservation and cross-channel isolation.
|
||||
N-channel multiplexer. The de-risk POC (28 tests) validated the core
|
||||
mechanics.
|
||||
|
||||
### The substrate simplification
|
||||
|
||||
Different transports have different native multiplexing capabilities. QUIC
|
||||
has native bidi streams; TCP+TLS does not. The initial framing of this ADR
|
||||
treated the 9-byte header as "the in-line substrate format" — used when the
|
||||
transport has no native multiplexing, with a separate substrate for QUIC
|
||||
native streams. That framing adds complexity (two substrates, a
|
||||
`channel_id`↔stream-ID mapping) for no benefit.
|
||||
|
||||
The simplification: **the 9-byte header is used in all substrates, on every
|
||||
bidi stream that carries channels.** The `channel_id` in the header is the
|
||||
logical correlation key. The transport's native multiplexing (when present)
|
||||
is a performance optimization (independent flow-control windows per
|
||||
stream), not a protocol change. The `ChannelsAdapter` reads the 9-byte
|
||||
header off every bidi stream it accepts — on a transport with one stream
|
||||
(in-line), the header demuxes N channels from that stream; on a transport
|
||||
with N streams (QUIC native), each stream carries one logical channel and
|
||||
the header provides `stream_type` and `channel_id` correlation. Same code
|
||||
path, same wire format, same handler experience.
|
||||
|
||||
### The stream_type decomposition
|
||||
|
||||
The initial framing had `stream_type` 3 as "bidirectional" (control
|
||||
messages). This is a design flaw: one stream_type both sides write to is not
|
||||
properly multiplexed — it loses independent flow control, independent EOF,
|
||||
and clean separation of concerns. The TTY crate's control channel already
|
||||
exhibits this problem ("the control channel isn't actually bidirectional…
|
||||
the adapter ignores Exit from the client" — phase-0 findings §Less
|
||||
Straightforward Parts).
|
||||
|
||||
The fix: **every stream_type is unidirectional.** Bidirectionality is
|
||||
achieved by having two stream_types (one write, one read), the same way
|
||||
QUIC bidi streams are two unidirectional halves. Control becomes 3 (write,
|
||||
client→server) and 4 (read, server→client), not one "bidirectional" 3.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -37,47 +69,119 @@ order preservation and cross-channel isolation.
|
||||
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
|
||||
```
|
||||
|
||||
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte format;
|
||||
`stream_type` and `length` are identical to TTY's fields (ADR-052), preserving
|
||||
the framing-disambiguation soundness property (§5 below).
|
||||
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
|
||||
format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
|
||||
preserving the framing-disambiguation soundness property.
|
||||
|
||||
| field | width | meaning |
|
||||
|-------|-------|---------|
|
||||
| `channel_id` | u32 BE | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Channels 1..N are opened dynamically via `channel/open`. |
|
||||
| `stream_type` | u8 | The sub-stream within the channel. 0=stdin/data-in, 1=stdout/data-out, 2=stderr/diagnostic (optional), 3=control (JSON), 4-255 reserved. |
|
||||
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
|
||||
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
|
||||
|
||||
### `MAX_CHUNK_LEN`
|
||||
|
||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
||||
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||
stream — the demux drops the chunk and continues. This preserves the framing-
|
||||
disambiguation soundness property: the header is always exactly 9 bytes, so
|
||||
the demux can always resync after a dropped chunk by reading the next 9-byte
|
||||
stream — the demux drops the chunk and continues. The header is always
|
||||
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||
header.
|
||||
|
||||
### Stream types (per channel)
|
||||
### Stream types — unidirectional, grouped in threes
|
||||
|
||||
| stream_type | direction | purpose |
|
||||
|-------------|-----------|---------|
|
||||
| 0 | write half | data flowing in (stdin equivalent) |
|
||||
| 1 | read half | data flowing out (stdout equivalent) |
|
||||
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) |
|
||||
| 3 | bidirectional | control messages (ALPN-specific JSON) |
|
||||
| 4-255 | reserved | future sub-stream types |
|
||||
**Every stream_type is unidirectional.** Bidirectionality is two
|
||||
stream_types (write + read), not one "bidirectional" stream_type. The
|
||||
stream_types are grouped in threes:
|
||||
|
||||
Not all channels use all sub-streams. A TTY session uses 0-3. A raw tunnel
|
||||
uses 0 and 1. An SSH connection uses 0 and 1 (SSH multiplexes internally).
|
||||
The active `stream_type` set is declared at `channel/open` time
|
||||
(ADR-073) and fixed for the channel's lifetime.
|
||||
| Group | stream_type | direction | purpose |
|
||||
|-------|-------------|-----------|---------|
|
||||
| Data | 0 | write (client→server) | data in (stdin equivalent) |
|
||||
| | 1 | read (server→client) | data out (stdout equivalent) |
|
||||
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
|
||||
| Control | 3 | write (client→server) | control in (resize, signal, eof) |
|
||||
| | 4 | read (server→client) | control out (exit, keepalive response) |
|
||||
| | 5 | read (server→client) | control err (optional) |
|
||||
| Future | 6/7/8 | write/read/read | next group, same pattern |
|
||||
| | 9/10/11 | write/read/read | next group |
|
||||
| | ... | | |
|
||||
|
||||
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
|
||||
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
|
||||
|
||||
256 values / 3 = 85 groups. The `u32` channel_id space (~4 billion channels
|
||||
before wrap, ADR-076) combined with 85 stream_type groups is effectively
|
||||
unlimited for the intended use cases.
|
||||
|
||||
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
|
||||
own flow control, its own EOF. The TTY control channel becomes *actually*
|
||||
bidirectional because there are two unidirectional streams (3 in, 4 out),
|
||||
not one stream both sides write to. This resolves the "control channel
|
||||
isn't actually bidirectional" problem the TTY crate has today. The same
|
||||
principle applies to any future channel type — control is two halves, not
|
||||
one shared stream.
|
||||
|
||||
### Per-ALPN stream_type sets
|
||||
|
||||
| ALPN | Active stream_types | Why |
|
||||
|------|---------------------|-----|
|
||||
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
|
||||
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
|
||||
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
|
||||
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
|
||||
|
||||
The active set is declared at `channel/open` time (ADR-073 `stream_types`
|
||||
field) and fixed for the channel's lifetime. A tunnel that wants keepalive
|
||||
could declare [0, 1, 3, 4].
|
||||
|
||||
### Substrate modes — same wire format, different stream counts
|
||||
|
||||
The 9-byte header is used in all substrates, on every bidi stream. The
|
||||
difference between substrates is only **how many bidi streams the transport
|
||||
yields**:
|
||||
|
||||
| Substrate | Transport | Streams | Header role |
|
||||
|-----------|-----------|---------|--------------|
|
||||
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
|
||||
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
|
||||
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
|
||||
|
||||
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
|
||||
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
|
||||
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
|
||||
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
|
||||
each stream is a channel, and the header provides `stream_type` and
|
||||
`channel_id` correlation. Same code path, same wire format, same handler
|
||||
experience.
|
||||
|
||||
**Why keep the header on QUIC when the stream already separates channels:**
|
||||
- **`stream_type` decomposition.** TTY needs 5 sub-streams. On QUIC, one
|
||||
stream per channel + the header carrying `stream_type` is simpler than 5
|
||||
streams per channel and matches the in-line case's shape. Per-stream-type
|
||||
flow control is handled at the reassembly buffer level (ADR-076)
|
||||
regardless of substrate.
|
||||
- **`channel_id` correlation.** The hub relay forwards a channel from the
|
||||
browser leg to the spoke leg, mapping `browser_id ↔ spoke_id`. If
|
||||
`channel_id` is in the header on every stream, the relay correlates by
|
||||
reading the header — regardless of substrate. If `channel_id` lived only
|
||||
in the `channel/open` response, the relay would need a stream-ID-to-
|
||||
`channel_id` mapping per leg.
|
||||
- **Uniform handler experience.** The handler receives a `Connection` and
|
||||
calls `accept_bi()` or `into_sub_streams()`. It doesn't know or care
|
||||
whether the substrate is in-line or native — the
|
||||
`ChannelBidiStreamSource` wraps the reassembled stream either way.
|
||||
|
||||
The "bloat" (9 bytes/chunk on transports that have native multiplexing) is
|
||||
the cost of uniformity. For the intended use cases (TTY, SSH, tunnels, call
|
||||
operations), this is noise. The high-throughput escape hatch is
|
||||
multi-connection, not stripping the header.
|
||||
|
||||
### Framing disambiguation (carried from ADR-052 §5)
|
||||
|
||||
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
||||
header. Disambiguation between channel 0 (call protocol) and data channels is
|
||||
by `channel_id`, not by a special first-byte trick. Within a channel,
|
||||
`stream_type` 0 (stdin) from the server is invalid, so `0x00` as the first
|
||||
byte of a chunk payload from the server is unambiguous.
|
||||
header. Disambiguation between channel 0 (call protocol) and data channels
|
||||
is by `channel_id`, not by a special first-byte trick. Within a channel,
|
||||
`stream_type` 0 (write half) from the server is invalid, so `0x00` as the
|
||||
first byte of a chunk payload from the server is unambiguous.
|
||||
|
||||
### Zero-length sentinel = EOF
|
||||
|
||||
@@ -87,61 +191,70 @@ is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
|
||||
|
||||
### Sync core / async shell split
|
||||
|
||||
The wire format's core is pure byte manipulation — `parse_header(&[u8; 9]) ->
|
||||
ChunkHeader` and `write_header(channel_id, stream_type, length, &mut [u8; 9])`.
|
||||
No async, no platform dependencies. Compiles under `wasm32-unknown-unknown`
|
||||
(validated by the POC). The async shell (demux/mux) wraps this core with
|
||||
`read_exact`/`write_all` on the transport and `mpsc` routing. This split is
|
||||
the same pattern as TTY's (REQ-TTY-01 generalization) and keeps the
|
||||
WASM-compatible core separate from the tokio-dependent shell.
|
||||
The wire format's core is pure byte manipulation — `parse_header(&[u8; 9])
|
||||
-> ChunkHeader` and `write_header(channel_id, stream_type, length, &mut
|
||||
[u8; 9])`. No async, no platform dependencies. Compiles under
|
||||
`wasm32-unknown-unknown` (validated by the POC). The async shell (demux/mux)
|
||||
wraps this core with `read_exact`/`write_all` on the transport and `mpsc`
|
||||
routing. This split keeps the WASM-compatible core separate from the
|
||||
tokio-dependent shell.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- One multiplexing model replaces three (connection-level ALPN, stream-level
|
||||
QUIC native, sub-stream-level TTY chunks). The hub's relay logic becomes
|
||||
channel-by-channel byte forwarding, not per-protocol framing parsers.
|
||||
- The 9-byte overhead is negligible for the intended use cases (TTY sessions,
|
||||
SSH, tunnels, call operations). The format is a 4-byte extension to a
|
||||
proven 5-byte format.
|
||||
- One wire format across all substrates. The `ChannelsAdapter` code path is
|
||||
the same regardless of transport; the transport's native multiplexing is a
|
||||
performance optimization, not a protocol change.
|
||||
- Every stream_type is unidirectional — proper multiplexing with independent
|
||||
flow control and EOF per half. The TTY control channel becomes actually
|
||||
bidirectional (3 in, 4 out), resolving the "not actually bidirectional"
|
||||
flaw.
|
||||
- The hub relay correlates by `channel_id` in the header, uniformly across
|
||||
substrates — no stream-ID-to-`channel_id` mapping per leg.
|
||||
- WASM-compatible by construction — the pure core has no platform deps.
|
||||
- The framing-disambiguation property from ADR-052 carries forward unchanged.
|
||||
|
||||
**Negative:**
|
||||
- All channels on one `alknet/channels` connection share one transport
|
||||
stream's flow-control window. A slow consumer on one channel can
|
||||
backpressure others. This is mitigated by bounded-buffer backpressure
|
||||
(ADR-076) but not eliminated. For high-throughput bulk transfer, the
|
||||
application uses N independent channels connections — same trade-off as
|
||||
HTTP/2-over-TLS vs HTTP/3-over-QUIC. This is a transport property, not a
|
||||
channels-format property.
|
||||
- 9 bytes per chunk is 4 bytes more than TTY's 5-byte format. For
|
||||
high-frequency small-chunk workloads (e.g., typing in a terminal), this is
|
||||
a 80% header overhead increase. In practice the chunk size is driven by
|
||||
the write pattern (a terminal sends a few bytes per keystroke regardless),
|
||||
and the 4-byte delta is noise next to the TLS/QUIC overhead.
|
||||
- 9 bytes per chunk on all substrates, including QUIC where the transport
|
||||
already separates streams. This is 4 bytes more than using the QUIC stream
|
||||
ID directly as the `channel_id`. For the intended use cases this is noise;
|
||||
for high-throughput bulk transfer, the answer is multi-connection, not
|
||||
stripping the header.
|
||||
- All channels on one in-line connection share one transport stream's
|
||||
flow-control window. A slow consumer on one channel can backpressure
|
||||
others. Mitigated by bounded-buffer backpressure (ADR-076), not
|
||||
eliminated. The native substrate (QUIC streams) avoids this — each
|
||||
channel gets its own flow-control window. For high-throughput, use native
|
||||
or multi-connection.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
|
||||
length:u32`) is a wire-format commitment. Changing field widths, order, or
|
||||
semantics after deployments exist requires a version migration. The
|
||||
`stream_type` assignments (0=stdin, 1=stdout, etc.) are one-way for the same
|
||||
reason — they are inherited from ADR-052 and preserved.
|
||||
length:u32`) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
|
||||
control, `% 3` formula) are wire-format commitments. Changing them after
|
||||
deployments exist requires a version migration.
|
||||
|
||||
The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail
|
||||
within the one-way format — it can be changed without a wire-format version
|
||||
bump as long as both ends agree (it's a validation threshold, not a field
|
||||
width).
|
||||
within the one-way format.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes)
|
||||
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection this
|
||||
format rides on)
|
||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
||||
amended by ADR-077 — scoped to direct TTY)
|
||||
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection)
|
||||
- ADR-070: `BidiStreamSource` trait (the extension point the channels
|
||||
connection implements)
|
||||
- `docs/research/alknet-channels/poc-summary.md` — the POC that validated the
|
||||
format (28 tests, WASM compile check)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §The Wire Format
|
||||
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
|
||||
connection implements; its docstring already anticipated per-channel
|
||||
streams)
|
||||
- ADR-072: channel 0 pre-negotiated (now uses stream_types [0, 1])
|
||||
- ADR-073: channel lifecycle operations (stream_types field examples
|
||||
updated)
|
||||
- ADR-074: ChannelBidiStreamSource (into_sub_streams returns unidirectional
|
||||
handles)
|
||||
- ADR-076: backpressure (bounded-buffer applies at reassembly regardless of
|
||||
substrate)
|
||||
- ADR-077: TTY inside channels (5 sub-streams; control properly
|
||||
bidirectional via 3/4)
|
||||
- `docs/research/alknet-channels/poc-summary.md` — the POC that validated
|
||||
the format (28 tests, WASM compile check)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §The Wire Format, §Less
|
||||
Straightforward Parts (the control-channel bidirectionality problem)
|
||||
@@ -64,14 +64,21 @@ exactly as it does on a top-level `alknet/call` connection.
|
||||
|
||||
### Channel 0's stream_type usage
|
||||
|
||||
| stream_type | purpose |
|
||||
|-------------|---------|
|
||||
| 0 | `EventEnvelope` frames (JSON, length-prefixed — the call protocol wire format) |
|
||||
| 1-255 | reserved for future call-protocol sub-streams |
|
||||
| stream_type | direction | purpose |
|
||||
|-------------|-----------|---------|
|
||||
| 0 | write (client→server) | `EventEnvelope` frames from the client (call.requested, call.aborted) |
|
||||
| 1 | read (server→client) | `EventEnvelope` frames from the server (call.responded, call.completed, call.error) |
|
||||
|
||||
Channel 0 uses only `stream_type` 0. The reservation of `stream_type`
|
||||
1-255 is a future-proofing detail, not a current commitment — the call
|
||||
protocol is JSON-only and single-stream by design (ADR-064).
|
||||
Channel 0 uses stream_types [0, 1] — the call protocol is bidirectional via
|
||||
two unidirectional halves, the same way every channel type works
|
||||
(ADR-071 §stream_type decomposition). The call protocol's `(SendStream,
|
||||
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
|
||||
`RecvStream` backed by stream_type 1. Both sides write to their write half
|
||||
and read from their read half — no shared stream_type both sides write to.
|
||||
|
||||
The call protocol is JSON-only and single-stream by design (ADR-064).
|
||||
stream_types 2-255 on channel 0 are reserved for future call-protocol
|
||||
sub-streams.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Request (`call.requested` on channel 0):
|
||||
"operation": "channel/open",
|
||||
"input": {
|
||||
"alpn": "alknet/tty",
|
||||
"stream_types": [0, 1, 2, 3],
|
||||
"stream_types": [0, 1, 2, 3, 4],
|
||||
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
|
||||
"direction": "initiator-to-responder"
|
||||
}
|
||||
@@ -60,8 +60,8 @@ Request (`call.requested` on channel 0):
|
||||
| field | type | meaning |
|
||||
|-------|------|---------|
|
||||
| `alpn` | string | The ALPN the channel will carry. The responder looks this up in its `HandlerRegistry`. |
|
||||
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. Declared at open time so both sides size reassembly. E.g. `[0,1,2,3]` for TTY, `[0,1]` for a tunnel. |
|
||||
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is the `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params` — it hands the JSON to the handler. |
|
||||
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3,4]` for TTY (data in/out/err + control in/out), `[0,1]` for a tunnel, `[0,1]` for channel 0 (call frames). See ADR-071 §stream_type decomposition. |
|
||||
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is the `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
|
||||
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
|
||||
|
||||
Response (`call.responded`):
|
||||
@@ -70,7 +70,7 @@ Response (`call.responded`):
|
||||
{
|
||||
"output": {
|
||||
"channel_id": 7,
|
||||
"stream_types": [0, 1, 2, 3]
|
||||
"stream_types": [0, 1, 2, 3, 4]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -230,14 +230,20 @@ alternative.
|
||||
| Control path | When | Examples |
|
||||
|--------------|------|----------|
|
||||
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
|
||||
| `stream_type 3` chunks on the data channel | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
||||
| `stream_type 3` (write, client→server) and `stream_type 4` (read, server→client) chunks on the data channel | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
||||
|
||||
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
|
||||
example of data-ordered control — it rides on `stream_type 3` because it
|
||||
must arrive after the last stdin chunk, guaranteed by chunk ordering within
|
||||
`(channel_id, stream_type)`, not by a call-protocol round-trip. The
|
||||
`channel/close` operation that follows is on channel 0 and is ordered after
|
||||
the data pump completes (REQ-CH-06).
|
||||
example of data-ordered control — the exit message rides on `stream_type 4`
|
||||
(read, server→client) because it must arrive after the last data on
|
||||
`stream_type 1`, guaranteed by per-stream_type ordering. The client's EOF
|
||||
signal rides on `stream_type 3` (write, client→server), ordered after the
|
||||
last data on `stream_type 0`. The `channel/close` operation that follows is
|
||||
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
|
||||
|
||||
**Every stream_type is unidirectional** (ADR-071 §stream_type decomposition).
|
||||
Control is bidirectional via two halves (3 in, 4 out), not one shared
|
||||
stream_type both sides write to. This resolves the TTY control channel's
|
||||
"not actually bidirectional" flaw.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -74,21 +74,30 @@ session — exactly as the POC's `EchoHandler` and `TtyAdapter` do today.
|
||||
|
||||
### Sub-stream accessor for typed destructure (OQ-CH-10)
|
||||
|
||||
Some handlers need access to `stream_type` 2 (stderr) and 3 (control) in
|
||||
addition to the main 0/1 pair. The `Connection` interface alone (accept_bi)
|
||||
only exposes the 0/1 pair. The channels crate provides a typed-accessor
|
||||
extension:
|
||||
Some handlers need access to `stream_type` 2 (stderr), 3 (control in), and
|
||||
4 (control out) in addition to the main 0/1 pair. The `Connection`
|
||||
interface alone (accept_bi) only exposes the 0/1 pair. The channels crate
|
||||
provides a typed-accessor extension:
|
||||
|
||||
```rust
|
||||
// In alknet-channels:
|
||||
pub struct ChannelSubStreams {
|
||||
pub streams: Vec<(u8, SendStream, RecvStream)>,
|
||||
/// (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 the typed sub-streams for this channel, keyed by stream_type.
|
||||
/// Consumes the source — call this instead of accept_bi() if the handler
|
||||
/// needs direct access to stream_types 2/3. For handlers that only need
|
||||
/// needs direct access to stream_types 2/3/4. For handlers that only need
|
||||
/// the main 0/1 pair, accept_bi() is the path (and sub_streams() is not
|
||||
/// called).
|
||||
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||
@@ -101,15 +110,24 @@ its typed names:
|
||||
```rust
|
||||
// In alknet-tty (inside-channels mode, ADR-077):
|
||||
let sub = channel_source.into_sub_streams();
|
||||
let mut stdin = sub.get(0); // SendStream
|
||||
let mut stdout = sub.get(1); // RecvStream
|
||||
let mut stderr = sub.get(2); // RecvStream (optional)
|
||||
let mut control = sub.get(3); // RecvStream (JSON control)
|
||||
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)
|
||||
```
|
||||
|
||||
**The channels crate does not know about TTY's `stream_type` semantics.** It
|
||||
exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate
|
||||
maps stream_types to its typed names. This preserves ADR-003's
|
||||
**Every stream_type is unidirectional** (ADR-071). Write stream_types
|
||||
(`% 3 == 0`) carry a `SendStream`; read stream_types (`% 3 == 1 or 2`) carry
|
||||
a `RecvStream`. There is no "bidirectional" stream_type — bidirectionality
|
||||
is two halves (e.g., control is 3 write + 4 read). This resolves the TTY
|
||||
control channel's "not actually bidirectional" flaw: the TTY adapter reads
|
||||
exit/keepalive from `ctrl_out` (stream_type 4) and writes resize/signal/eof
|
||||
to `ctrl_in` (stream_type 3), each with its own flow control and EOF.
|
||||
|
||||
**The channels crate does not know about TTY's `stream_type` semantics.**
|
||||
It 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.
|
||||
|
||||
|
||||
@@ -39,27 +39,37 @@ impl ProtocolHandler for ChannelsAdapter {
|
||||
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
// 1. One bidi stream carries all channels.
|
||||
// 1. Channel 0 is pre-negotiated as alknet/call (ADR-072).
|
||||
// The first bidi stream the transport yields is channel 0.
|
||||
let (send, recv) = connection.accept_bi().await?;
|
||||
|
||||
// 2. Channel 0 is pre-negotiated as alknet/call (ADR-072).
|
||||
// Construct its reassembly buffers, wrap as a Connection via
|
||||
// from_source(ChannelBidiStreamSource), and hand to the
|
||||
// CallAdapter (looked up in the registry, same as every ALPN).
|
||||
self.manager.preinstall_channel_0(send, recv, auth).await?;
|
||||
|
||||
// 3. Run the demux loop: read 9-byte headers, route payloads to
|
||||
// per-(channel_id, stream_type) reassembly buffers.
|
||||
self.manager.run_demux_loop(recv).await
|
||||
// 2. Accept remaining bidi streams and read 9-byte headers off each.
|
||||
// On an in-line transport (TCP+TLS, WebTransport), 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, and the header provides
|
||||
// stream_type + channel_id correlation. Same code path, same
|
||||
// wire format (ADR-071 §substrate modes).
|
||||
self.manager.run_demux_loop(connection).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `preinstall_channel_0` step is the only special case: it constructs the
|
||||
reassembly buffers for `channel_id = 0`, wraps them as a `Connection` (via
|
||||
`Connection::from_source` with a `ChannelBidiStreamSource` — ADR-074), and
|
||||
hands that `Connection` to the `CallAdapter` — exactly as if `alknet/call`
|
||||
had been the top-level ALPN. The `CallAdapter` is none the wiser.
|
||||
The `preinstall_channel_0` step 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` — exactly as if
|
||||
`alknet/call` had been the top-level ALPN. The `CallAdapter` is looked up in
|
||||
the same `HandlerRegistry` as every other ALPN.
|
||||
|
||||
`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.
|
||||
|
||||
### `ChannelManager` — the shared state
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ The `TtyAdapter` operates in two modes, determined by how it receives its
|
||||
|
||||
| Mode | When | Wire format | How the adapter gets sub-streams |
|
||||
|------|------|-------------|---------------------------------|
|
||||
| **Direct (`alknet/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alknet/tty` | TTY's 5-byte format (ADR-052) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-3 |
|
||||
| **Inside channels** | `channel/open` with ALPN `alknet/tty` on a channels connection | Channels' 9-byte format (ADR-071) — the channels layer de-chunks | `into_sub_streams()` (ADR-074) → four named `SendStream`/`RecvStream` pairs for stream_types 0-3 |
|
||||
| **Direct (`alknet/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alknet/tty` | TTY's 5-byte format (ADR-052, amended — see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 |
|
||||
| **Inside channels** | `channel/open` with ALPN `alknet/tty` on a channels connection | Channels' 9-byte format (ADR-071) — the channels layer de-chunks | `into_sub_streams()` (ADR-074) → five named handles for stream_types 0-4 |
|
||||
|
||||
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
|
||||
(ADR-053). The backend allocates a PTY and returns a `TtyHandle`; the
|
||||
@@ -45,16 +45,44 @@ adapter pumps data between the handle and the sub-streams. The difference is
|
||||
only in how the adapter gets the sub-streams — 5-byte chunk parsing (direct)
|
||||
vs. `into_sub_streams()` (channels).
|
||||
|
||||
### The control channel is now properly bidirectional
|
||||
|
||||
The phase-0 findings flagged that the TTY control channel "isn't actually
|
||||
bidirectional… the adapter ignores Exit from the client." The root cause:
|
||||
`stream_type 3` was "bidirectional" — one stream both sides wrote to, which
|
||||
is not properly multiplexed.
|
||||
|
||||
ADR-071's stream_type decomposition fixes this: **every stream_type is
|
||||
unidirectional.** Control is now two halves:
|
||||
|
||||
| stream_type | direction | purpose |
|
||||
|-------------|-----------|---------|
|
||||
| 3 | write (client→server) | control in: resize, signal, eof |
|
||||
| 4 | read (server→client) | control out: exit, keepalive response |
|
||||
|
||||
The TTY adapter writes resize/signal/eof to `ctrl_in` (stream_type 3) and
|
||||
reads exit/keepalive from `ctrl_out` (stream_type 4). Each has its own
|
||||
reassembly buffer, its own flow control, its own EOF. The control channel
|
||||
is *actually* bidirectional — two unidirectional streams, not one shared
|
||||
stream both sides write to.
|
||||
|
||||
This amends ADR-052's stream_type assignments for direct mode too: direct
|
||||
`alknet/tty` connections now use stream_types [0, 1, 2, 3, 4] (data in/out/
|
||||
err + control in/out), not [0, 1, 2, 3]. The 5-byte format's `stream_type`
|
||||
field gains value 4; the `ControlMessage` enum is unchanged (the JSON shape
|
||||
is the same; the stream_type it rides on splits from 3 into 3+4).
|
||||
|
||||
### What changes in alknet-tty
|
||||
|
||||
1. **The adapter's session-driving code splits into two entry points:**
|
||||
- `drive_session_direct(send, recv, backends, ...)` — the existing path:
|
||||
parse 5-byte chunks, split into stream_types, pump. Used for direct
|
||||
parse 5-byte chunks, split into stream_types 0-4, pump. Used for direct
|
||||
`alknet/tty` connections.
|
||||
- `drive_session_channels(sub_streams, backends, ...)` — the new path:
|
||||
receive `ChannelSubStreams` (four named `SendStream`/`RecvStream`
|
||||
pairs), pump directly without chunk parsing. Used when the channel's
|
||||
`Connection` is backed by `ChannelBidiStreamSource`.
|
||||
receive `ChannelSubStreams` (five named handles: stdin=SendStream,
|
||||
stdout=RecvStream, stderr=Option<RecvStream>, ctrl_in=SendStream,
|
||||
ctrl_out=RecvStream), pump directly without chunk parsing. Used when
|
||||
the channel's `Connection` is backed by `ChannelBidiStreamSource`.
|
||||
|
||||
2. **The `TtyAdapter::handle()` branches on the `Connection`'s source type.**
|
||||
The channels crate's `ChannelBidiStreamSource` is a `BidiStreamSource`
|
||||
@@ -73,20 +101,24 @@ vs. `into_sub_streams()` (channels).
|
||||
connections." The channels path does not use it. This amends ADR-052's
|
||||
scope — the format is not replaced, it's scoped.
|
||||
|
||||
4. **The control channel (stream_type 3) works the same in both modes.** In
|
||||
direct mode, control JSON rides in 5-byte chunks with `stream_type=3`. In
|
||||
channels mode, control JSON rides in 9-byte chunks with `stream_type=3`
|
||||
— but the channels layer de-chunks it, so the adapter reads raw JSON
|
||||
bytes from its `control` `RecvStream` in both cases. The
|
||||
`ControlMessage` enum (resize, signal, eof, exit) is unchanged.
|
||||
4. **The control channel works the same in both modes, now properly
|
||||
bidirectional.** In direct mode, control-in JSON rides in 5-byte chunks
|
||||
with `stream_type=3` and control-out rides with `stream_type=4`. In
|
||||
channels mode, control-in rides in 9-byte chunks with `stream_type=3`
|
||||
(write) and control-out with `stream_type=4` (read) — but the channels
|
||||
layer de-chunks them, so the adapter reads raw JSON bytes from
|
||||
`ctrl_in`/`ctrl_out` in both cases. The `ControlMessage` enum (resize,
|
||||
signal, eof, exit) is unchanged — the JSON shape is the same; only the
|
||||
stream_type assignments change (3 splits into 3+4).
|
||||
|
||||
5. **The exit-chunk-is-last invariant (ADR-055) generalizes.** In direct
|
||||
mode, the exit chunk is the last 5-byte chunk before stream close
|
||||
(ADR-055). In channels mode, the exit control message is the last data on
|
||||
`stream_type 3` before `channel/close` is sent on channel 0
|
||||
(ADR-073 §channel/close). The ordering invariant is the same — exit
|
||||
before close — but the mechanism differs: 5-byte chunk ordering (direct)
|
||||
vs. `stream_type 3` ordering + `channel/close` after pump completion
|
||||
mode, the exit chunk is the last 5-byte chunk on `stream_type=4` (read,
|
||||
server→client) before stream close (ADR-055, amended). In channels mode,
|
||||
the exit control message is the last data on `stream_type=4` before
|
||||
`channel/close` is sent on channel 0 (ADR-073 §channel/close). The
|
||||
ordering invariant is the same — exit before close — but the mechanism
|
||||
differs: 5-byte chunk ordering on stream_type 4 (direct) vs.
|
||||
`stream_type 4` ordering + `channel/close` after pump completion
|
||||
(channels, REQ-CH-06).
|
||||
|
||||
### What does NOT change
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# ADR-081: alknet-channels Sub-Crate Decomposition
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The initial channels spec (ADR-071-080) framed `alknet-channels` as a
|
||||
single crate depending on both `alknet-core` and `alknet-call`. The
|
||||
dependency on `alknet-call` arises because channel lifecycle operations
|
||||
(`channel/open`, `channel/close`, `channel/control`,
|
||||
`channel/resources/subscribe`) register on the call protocol's
|
||||
`OperationRegistry`, and channel 0 is pre-negotiated as `alknet/call`
|
||||
(ADR-072).
|
||||
|
||||
This creates two issues:
|
||||
|
||||
1. **The dependency graph conflates the pure multiplexer with the call-
|
||||
protocol coupling.** The wire format, demux/mux, and
|
||||
`ChannelBidiStreamSource` are ALPN-blind and call-protocol-blind — they
|
||||
depend on `alknet-core` only. The channel 0 pre-negotiation and lifecycle
|
||||
op registrations are the call-protocol coupling. Baking both into one
|
||||
crate means any consumer that wants the multiplexer also pulls in the
|
||||
call-protocol coupling, even if they don't need channel 0 to be
|
||||
`alknet/call`.
|
||||
|
||||
2. **The "no special-casing for downstream crates" principle is
|
||||
violated.** The user's constraint: "we don't want to be doing anything
|
||||
special for downstream crates unless it's needed/useful to the point of
|
||||
they all kind of benefit." The call-protocol coupling is a channels-crate
|
||||
concern (channels needs call for orchestration), not a downstream-crate
|
||||
concern. Separating them makes the dependency graph honest: the pure
|
||||
multiplexer has no opinion about channel 0; the call-protocol coupling
|
||||
is isolated where it belongs.
|
||||
|
||||
The substrate simplification (ADR-071 revised) strengthened this: the wire
|
||||
format is the same across all substrates, and the `ChannelsAdapter` is
|
||||
substrate-agnostic. The pure multiplexer (`channels-core`) is cleanly
|
||||
separable from the call-protocol orchestration (`channels-call`).
|
||||
|
||||
## Decision
|
||||
|
||||
### Three crates
|
||||
|
||||
```
|
||||
alknet-channels-core — the pure multiplexer (wire format, demux/mux,
|
||||
│ ChannelBidiStreamSource, ChannelManager). Depends
|
||||
│ on alknet-core only. ALPN-blind, call-protocol-blind,
|
||||
│ transport-blind.
|
||||
├── alknet-channels-call — channel 0 pre-negotiation + lifecycle op
|
||||
│ │ registrations on the call protocol's
|
||||
│ │ OperationRegistry. Depends on channels-core +
|
||||
│ │ alknet-call.
|
||||
│ ├── alknet-channels-hub — the relay (ADR-079). Depends on
|
||||
│ │ channels-call. The hub-side role.
|
||||
│ └── alknet-channels-worker — ChannelClient (ADR-080). Depends on
|
||||
│ channels-call. The client/worker-side role.
|
||||
```
|
||||
|
||||
### `alknet-channels-core`
|
||||
|
||||
The pure multiplexer. Contains:
|
||||
|
||||
- The 9-byte chunk wire format (`parse_header` / `write_header` — ADR-071)
|
||||
- The demux/mux (`Demux`, `MuxHandle`/`MuxRunner` — ADR-075)
|
||||
- `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-074)
|
||||
- `ChannelSubStreams` / `SubStreamHandle` (the typed destructure accessor)
|
||||
- `ChannelManager` (the shared state — channel_id → ChannelState, but
|
||||
**without** the `call_ops: Arc<OperationRegistry>` field; the manager is
|
||||
ALPN-blind and call-protocol-blind)
|
||||
- `ChannelsAdapter` (the `ProtocolHandler` on `alknet/channels` — the
|
||||
read/demux loop, substrate-agnostic per ADR-071 revised)
|
||||
|
||||
Depends on `alknet-core` only. No `alknet-call` dependency. No opinion about
|
||||
what channel 0 carries — that's the consumer's concern.
|
||||
|
||||
The `ChannelsAdapter::handle` in `channels-core` does NOT preinstall channel
|
||||
0 as `alknet/call`. It runs the demux loop and routes chunks by
|
||||
`channel_id`. Channel 0 is just another channel; what ALPN it carries is
|
||||
determined by the consumer (the `channels-call` crate pre-negotiates it as
|
||||
`alknet/call`; a hypothetical other consumer could pre-negotiate it
|
||||
differently).
|
||||
|
||||
### `alknet-channels-call`
|
||||
|
||||
The call-protocol coupling. Contains:
|
||||
|
||||
- Channel 0 pre-negotiation as `alknet/call` (ADR-072) — the
|
||||
`preinstall_channel_0` logic that constructs channel 0's reassembly
|
||||
buffers with stream_types [0, 1] and hands the `Connection` to the
|
||||
`CallAdapter`.
|
||||
- The four lifecycle operations (ADR-073): `channel/open`,
|
||||
`channel/close`, `channel/control`, `channel/resources/subscribe` —
|
||||
registered on the call protocol's `OperationRegistry` at assembly time.
|
||||
- `ChannelOperations` (the registration helper that closes over a
|
||||
`ChannelManager` clone).
|
||||
|
||||
Depends on `channels-core` + `alknet-call`. This is where the
|
||||
call-protocol coupling lives, isolated from the pure multiplexer.
|
||||
|
||||
### `alknet-channels-hub` and `alknet-channels-worker`
|
||||
|
||||
The two roles. These may be sub-crates or feature-gated modules within
|
||||
`channels-call`; the exact packaging is a two-way-door implementation
|
||||
detail. The contract is:
|
||||
|
||||
- **`channels-hub`** (or the hub role): the relay (ADR-079). Translates
|
||||
`channel/open` on channel 0, byte-forwards data channels with
|
||||
`channel_id` rewrite. Depends on `channels-call` (for the call-protocol
|
||||
translation) + the hub's own `CallAdapter` / `from_call` machinery.
|
||||
- **`channels-worker`** (or the worker/client role): `ChannelClient`
|
||||
(ADR-080). Dials a transport, establishes the channels connection, runs
|
||||
the demux/mux, exposes `open_channel(alpn, params) -> Channel`. Depends
|
||||
on `channels-call` (for channel 0 pre-negotiation) + `alknet-call`'s
|
||||
`CallClient`-equivalent.
|
||||
|
||||
The naming (hub/worker vs server/client) is a two-way-door detail. The
|
||||
user noted "server/client" is muddy because a hub/worker can act as both
|
||||
depending on the use case (bidirectionality — ADR-073 §direction
|
||||
semantics). The roles are "the side that relays" (hub) and "the side that
|
||||
dials" (worker/client), but both can open channels in either direction.
|
||||
|
||||
### What moves where
|
||||
|
||||
| Component | Original (ADR-071-080) | Now |
|
||||
|-----------|------------------------|-----|
|
||||
| 9-byte wire format | `alknet-channels` | `channels-core` |
|
||||
| Demux/Mux | `alknet-channels` | `channels-core` |
|
||||
| `ChannelBidiStreamSource` | `alknet-channels` | `channels-core` |
|
||||
| `ChannelSubStreams` | `alknet-channels` | `channels-core` |
|
||||
| `ChannelManager` (without `call_ops`) | `alknet-channels` | `channels-core` |
|
||||
| `ChannelsAdapter` (demux loop only) | `alknet-channels` | `channels-core` |
|
||||
| Channel 0 pre-negotiation | `alknet-channels` (ADR-072) | `channels-call` |
|
||||
| `channel/open`/`close`/`control`/`resources/subscribe` ops | `alknet-channels` (ADR-073) | `channels-call` |
|
||||
| `ChannelOperations` registration helper | `alknet-channels` | `channels-call` |
|
||||
| Hub relay (ADR-079) | `alknet-channels` (spec) / `alknet-hub` (impl) | `channels-hub` (or `alknet-hub` — see below) |
|
||||
| `ChannelClient` (ADR-080) | `alknet-channels` | `channels-worker` |
|
||||
|
||||
### Relationship to `alknet-hub`
|
||||
|
||||
The existing `alknet-hub` crate (`docs/architecture/crates/hub/README.md`)
|
||||
is the hub pattern: peer lifecycle, aggregated env, service discovery. The
|
||||
channels hub relay (ADR-079) is a channels-specific concern that the hub
|
||||
crate consumes. The split: `channels-hub` (or the hub role within
|
||||
`channels-call`) provides the relay logic; `alknet-hub` wires it into the
|
||||
hub runtime alongside the call-protocol peer management. Whether the relay
|
||||
lives in a `channels-hub` sub-crate or directly in `alknet-hub` is a
|
||||
packaging decision — the contract (ADR-079) is the same either way. The
|
||||
user's preference for decomposing channels into core/hub/worker suggests a
|
||||
`channels-hub` sub-crate that `alknet-hub` depends on, but this is not
|
||||
one-way and can be revisited during implementation.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- The dependency graph is honest: `channels-core` is the pure multiplexer
|
||||
with no call dependency; the call-protocol coupling is isolated in
|
||||
`channels-call`. A consumer that wants the multiplexer without the
|
||||
call-protocol orchestration can depend on `channels-core` only.
|
||||
- The "no special-casing for downstream crates" principle is preserved:
|
||||
`channels-core` doesn't know about `alknet-call`, `alknet-tty`, or any
|
||||
handler crate. The call-protocol coupling is a channels-crate concern,
|
||||
not a downstream-crate concern.
|
||||
- The sub-crate split matches the existing pattern (`alknet-tty` +
|
||||
`alknet-tty-local`, `alknet-docker` + its `tty` feature) — core in one
|
||||
crate, consumer-specific wiring in another.
|
||||
- The hub and worker roles are separated, matching the user's
|
||||
decomposition preference and the bidirectionality of the channels
|
||||
protocol (both sides can open channels; the roles are about who relays
|
||||
vs who dials, not about request/response direction).
|
||||
|
||||
**Negative:**
|
||||
- Three crates instead of one. The assembly layer must depend on
|
||||
`channels-core` + `channels-call` (and optionally `channels-hub` or
|
||||
`channels-worker`) instead of one `alknet-channels`. This is the cost of
|
||||
the clean separation; the assembly layer already wires multiple crates,
|
||||
so this is consistent with the existing pattern.
|
||||
- The `ChannelsAdapter` in `channels-core` doesn't preinstall channel 0 —
|
||||
the consumer does. This means `channels-core`'s `ChannelsAdapter::handle`
|
||||
exposes a hook (callback or trait method) for the consumer to install
|
||||
channel 0. `channels-call` provides the `preinstall_channel_0`
|
||||
implementation; a different consumer could provide a different one. This
|
||||
is a slightly more complex adapter shape than "channel 0 is always call,"
|
||||
but it's the cost of the clean separation.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way (crate structure) + two-way (role packaging).** The three-crate
|
||||
split (`channels-core` / `channels-call` / roles) is one-way — once
|
||||
consumers depend on `channels-core` without `channels-call`, re-merging
|
||||
them is a breaking change. The hub/worker packaging (separate sub-crates
|
||||
vs feature-gated modules within `channels-call`) is two-way — an
|
||||
implementation detail that can change without breaking the contract.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-003: crate decomposition (no-handler-depends-on-another-handler —
|
||||
preserved; the channels sub-crates depend on core/call, not on handlers)
|
||||
- ADR-071: channels wire format (revised — substrate simplification; the
|
||||
wire format is in `channels-core`)
|
||||
- ADR-072: channel 0 pre-negotiated (moves to `channels-call`)
|
||||
- ADR-073: channel lifecycle operations (move to `channels-call`)
|
||||
- ADR-074: ChannelBidiStreamSource (in `channels-core`)
|
||||
- ADR-075: ChannelsAdapter and ChannelManager (split: core demux in
|
||||
`channels-core`, call coupling in `channels-call`)
|
||||
- ADR-079: hub relay (in `channels-hub` or `alknet-hub`)
|
||||
- ADR-080: ChannelClient (in `channels-worker`)
|
||||
- `docs/architecture/crates/hub/README.md` — the existing hub crate (the
|
||||
relay's consumer)
|
||||
Reference in new issue
Block a user