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
|
**alknet-channels specs drafted.** The alknet-channels crate (multiplexing
|
||||||
proxy — `ProtocolHandler` on `alknet/channels`, 9-byte chunk format, N
|
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:
|
`alknet/call`) now has architecture specs:
|
||||||
[crates/channels/](crates/channels/) (overview, channels-wire,
|
[crates/channels/](crates/channels/) (overview, channels-wire,
|
||||||
channels-connection, channels-adapter, channel-operations, channel-client)
|
channels-connection, channels-adapter, channel-operations, channel-client)
|
||||||
and ten ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte
|
and eleven ADRs — [ADR-071](decisions/071-channels-wire-format.md) (9-byte
|
||||||
chunk header), [ADR-072](decisions/072-channel-0-pre-negotiated-call.md)
|
chunk header; revised for substrate simplification — the header is used in
|
||||||
(channel 0 = `alknet/call` pre-negotiated, no special control plane),
|
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
|
[ADR-073](decisions/073-channel-lifecycle-operations.md) (channel
|
||||||
lifecycle operations on the call protocol — `channel/open`/`close`/
|
lifecycle operations on the call protocol — `channel/open`/`close`/
|
||||||
`control`/`resources/subscribe`; `channel/resources/subscribe` is a
|
`control`/`resources/subscribe`; `channel/resources/subscribe` is a
|
||||||
`Subscription` operation using the already-implemented `StreamingHandler`
|
`Subscription` operation using the already-implemented `StreamingHandler`
|
||||||
machinery, not a polled `Query`; the `direction` field pins who is the
|
machinery, not a polled `Query`; the `direction` field pins who is the
|
||||||
ALPN-server; the control-message division is call-ops for orchestration,
|
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)
|
[ADR-074](decisions/074-channelconnection-bidistreamsource.md)
|
||||||
(`ChannelBidiStreamSource` implements `BidiStreamSource` — ADR-070's
|
(`ChannelBidiStreamSource` implements `BidiStreamSource` — ADR-070's
|
||||||
extension point; `into_sub_streams()` typed accessor for handlers that need
|
extension point; `into_sub_streams()` with `SubStreamHandle` enum (Send/Recv
|
||||||
stderr/control; `accept_bi()` generic path for tunnel/SSH),
|
per unidirectional stream_type); `accept_bi()` generic path for tunnel/SSH),
|
||||||
[ADR-075](decisions/075-channelsadapter-and-channelmanager.md)
|
[ADR-075](decisions/075-channelsadapter-and-channelmanager.md)
|
||||||
(`ChannelsAdapter` read/demux + `ChannelManager` reassemble/allocate split;
|
(`ChannelsAdapter` substrate-agnostic demux loop (reads 9-byte headers off
|
||||||
REQ-CH-01..04 wire-level invariants pinned: shutdown emits zero-length
|
every bidi stream, regardless of substrate) + `ChannelManager`
|
||||||
sentinel, transport close drops all senders, mux dynamic registration,
|
reassemble/allocate split; REQ-CH-01..04 wire-level invariants pinned:
|
||||||
lenient unknown-`channel_id`),
|
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)
|
[ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md)
|
||||||
(bounded-buffer backpressure 1 MiB default, 256-channel cap, monotonic IDs
|
(bounded-buffer backpressure 1 MiB default, 256-channel cap, monotonic IDs
|
||||||
with wrap-around),
|
with wrap-around),
|
||||||
[ADR-077](decisions/077-tty-inside-channels.md) (TTY inside channels uses
|
[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
|
sub-streams, not its own 5-byte wire format; 5 sub-streams [0,1,2,3,4]
|
||||||
direct-connect TTY only; `channels` feature on alknet-tty),
|
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
|
[ADR-078](decisions/078-two-pump-shutdown-on-completion.md) (two-pump
|
||||||
handlers MUST shut down the opposite sink on pump completion — the
|
handlers MUST shut down the opposite sink on pump completion — the
|
||||||
deadlock contract the POC surfaced; handler-level, not channels-layer;
|
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;
|
translates `channel/open` on channel 0 with `forwarded_for` — ADR-032;
|
||||||
data channels byte-forwarded with `channel_id` rewrite; the hub never runs
|
data channels byte-forwarded with `channel_id` rewrite; the hub never runs
|
||||||
protocol-specific handlers),
|
protocol-specific handlers),
|
||||||
[ADR-080](decisions/080-channelclient.md) (`ChannelClient` in
|
[ADR-080](decisions/080-channelclient.md) (`ChannelClient`,
|
||||||
alknet-channels, QUIC-only initially, bidirectionality preserved;
|
QUIC-only initially, bidirectionality preserved;
|
||||||
`AlknetClient` core extraction stays deferred per OQ-55 — blocked on a
|
`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
|
in the completed de-risk POC
|
||||||
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three
|
(`docs/research/alknet-channels/poc-summary.md`, 28 tests passing, three
|
||||||
validated targets: chunk format + demux/mux, per-channel `Connection`
|
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
|
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;
|
scope — the 5-byte format is unchanged for direct `alknet/tty` connections;
|
||||||
inside channels, TTY uses `into_sub_streams()` and the channels layer's
|
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.
|
**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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -30,16 +30,17 @@ protocol work itself.
|
|||||||
|
|
||||||
| ADR | Title | Relevance |
|
| ADR | Title | Relevance |
|
||||||
|-----|-------|-----------|
|
|-----|-------|-----------|
|
||||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; one-way door |
|
| [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, no special control plane |
|
| [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 |
|
| [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 |
|
| [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 | The read/demux + reassemble/allocate split; REQ-CH-01..04 contracts |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) |
|
| [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 |
|
| field | type | meaning |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
|
| `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`. |
|
| `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. |
|
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ invariants.
|
|||||||
|
|
||||||
| Component | Role | What it knows |
|
| 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. |
|
| `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`, `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). |
|
| `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
|
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
|
||||||
pattern, generalized to N channels: the adapter drives N channels, and
|
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
|
```rust
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -31,46 +31,47 @@ impl ProtocolHandler for ChannelsAdapter {
|
|||||||
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
||||||
-> Result<(), HandlerError>
|
-> 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?;
|
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?;
|
self.manager.preinstall_channel_0(send, recv, auth).await?;
|
||||||
|
|
||||||
// 3. Run the demux loop.
|
// 2. Accept remaining bidi streams and read 9-byte headers off each.
|
||||||
self.manager.run_demux_loop(recv).await
|
// 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 =
|
`run_demux_loop` continues accepting bidi streams from the transport. For
|
||||||
0`, wraps them as a `Connection` (via `Connection::from_source` with a
|
each stream, it reads 9-byte headers and routes payloads to the matching
|
||||||
`ChannelBidiStreamSource` — ADR-070/074), and hands that `Connection` to
|
`(channel_id, stream_type)` reassembly buffer. On an in-line transport,
|
||||||
the `CallAdapter` — exactly as if `alknet/call` had been the top-level
|
there is only one stream (channel 0 rides inside it via the header); the
|
||||||
ALPN. The `CallAdapter` is looked up in the same `HandlerRegistry` as every
|
header demuxes all channels. On QUIC, each subsequent stream is a new
|
||||||
other ALPN. The `CallAdapter` is none the wiser: it calls `accept_bi()`,
|
channel; the header's `channel_id` correlates it. The loop is the same;
|
||||||
gets one bidi stream (the channel-0 reassembled stream), and runs its
|
only the transport's stream count differs.
|
||||||
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.
|
|
||||||
|
|
||||||
## `ChannelManager`
|
## `ChannelManager`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
// In alknet-channels-core:
|
||||||
pub struct ChannelManager {
|
pub struct ChannelManager {
|
||||||
channels: Mutex<HashMap<u32, ChannelState>>,
|
channels: Mutex<HashMap<u32, ChannelState>>,
|
||||||
handlers: Arc<HandlerRegistry>,
|
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
|
next_id: AtomicU32, // monotonic; wraps at u32::MAX
|
||||||
buffer_cap: usize, // default 1 MiB (ADR-076)
|
buffer_cap: usize, // default 1 MiB (ADR-076)
|
||||||
max_channels: usize, // default 256 (ADR-076)
|
max_channels: usize, // default 256 (ADR-076)
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ access paths.
|
|||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
Each channel is reassembled into a set of `AsyncRead + AsyncWrite` handles
|
Each channel is reassembled into a set of **unidirectional** handles — one
|
||||||
—one per active `stream_type` (declared at `channel/open` time, ADR-073).
|
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
|
These handles are wrapped as a `ChannelBidiStreamSource` that implements
|
||||||
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
|
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
|
||||||
constructed from it via `Connection::from_source(source, alpn)`.
|
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) —
|
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) —
|
||||||
the generic handler path (tunnel, SSH).
|
the generic handler path (tunnel, SSH).
|
||||||
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
|
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
|
||||||
active sub-streams as typed `(stream_type, SendStream, RecvStream)`
|
active sub-streams as typed `(stream_type, SubStreamHandle)` tuples —
|
||||||
tuples — the typed handler path (TTY, which needs stdin/stdout/stderr/
|
the typed handler path (TTY, which needs stdin/stdout/stderr/control-in/
|
||||||
control).
|
control-out).
|
||||||
|
|
||||||
Both paths operate on the same reassembly buffers; the difference is how the
|
Both paths operate on the same reassembly buffers; the difference is how the
|
||||||
handler accesses them.
|
handler accesses them.
|
||||||
@@ -115,20 +120,24 @@ For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
|
|||||||
to 0/1:
|
to 0/1:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// In alknet-channels:
|
// In alknet-channels-core:
|
||||||
pub struct ChannelSubStreams {
|
pub struct ChannelSubStreams {
|
||||||
/// (stream_type, send_half, recv_half) for each active stream_type.
|
/// (stream_type, handle) for each active stream_type. Each handle is
|
||||||
pub streams: Vec<(u8, SendStream, RecvStream)>,
|
/// 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 enum SubStreamHandle {
|
||||||
pub fn get(&self, stream_type: u8) -> Option<(&SendStream, &RecvStream)> { ... }
|
Send(SendStream), // write half (stream_type % 3 == 0)
|
||||||
|
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelBidiStreamSource {
|
impl ChannelBidiStreamSource {
|
||||||
/// Returns all active sub-streams, keyed by stream_type. Consumes the
|
/// Returns all active sub-streams, keyed by stream_type. Consumes the
|
||||||
/// source — call this instead of accept_bi() if the handler needs
|
/// 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 { ... }
|
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -138,15 +147,16 @@ The handler crate destructures `ChannelSubStreams` into its typed names:
|
|||||||
```rust
|
```rust
|
||||||
// In alknet-tty (inside-channels mode, ADR-077):
|
// In alknet-tty (inside-channels mode, ADR-077):
|
||||||
let sub = channel_source.into_sub_streams();
|
let sub = channel_source.into_sub_streams();
|
||||||
let stdin = sub.get(0).unwrap(); // SendStream
|
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
|
||||||
let stdout = sub.get(1).unwrap(); // RecvStream
|
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
|
||||||
let stderr = sub.get(2); // Option<&RecvStream>
|
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
|
||||||
let control = sub.get(3).unwrap(); // RecvStream (JSON control)
|
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.**
|
**Every stream_type is unidirectional** (ADR-071). The channels crate
|
||||||
It exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate
|
exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
|
||||||
maps stream_types to its typed names. This preserves ADR-003's
|
stream_types to its typed names. This preserves ADR-003's
|
||||||
no-handler-depends-on-another-handler rule and keeps the channels crate
|
no-handler-depends-on-another-handler rule and keeps the channels crate
|
||||||
ALPN-blind.
|
ALPN-blind.
|
||||||
|
|
||||||
@@ -159,7 +169,7 @@ include the 0/1 pair, so `into_sub_streams()` is the superset.
|
|||||||
| Handler shape | Path | Examples |
|
| Handler shape | Path | Examples |
|
||||||
|---------------|------|---------|
|
|---------------|------|---------|
|
||||||
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
|
| 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
|
The handler chooses based on its ALPN's `stream_type` set (declared at
|
||||||
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
|
`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
|
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||||
header.
|
header.
|
||||||
|
|
||||||
## Stream types
|
## Stream types — unidirectional, grouped in threes
|
||||||
|
|
||||||
| stream_type | direction | purpose |
|
**Every stream_type is unidirectional.** Bidirectionality is two
|
||||||
|-------------|-----------|---------|
|
stream_types (write + read), not one "bidirectional" stream_type. The
|
||||||
| 0 | write half | data flowing in (stdin equivalent) |
|
stream_types are grouped in threes:
|
||||||
| 1 | read half | data flowing out (stdout equivalent) |
|
|
||||||
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) |
|
| Group | stream_type | direction | purpose |
|
||||||
| 3 | bidirectional | control messages (ALPN-specific JSON) |
|
|-------|-------------|-----------|---------|
|
||||||
| 4-255 | reserved | future sub-stream types |
|
| 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
|
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/open` time (ADR-073 `stream_types` field) and fixed for the
|
||||||
channel's lifetime.
|
channel's lifetime.
|
||||||
|
|
||||||
| Channel ALPN | Active stream_types |
|
| Channel ALPN | Active stream_types | Why |
|
||||||
|--------------|---------------------|
|
|--------------|---------------------|-----|
|
||||||
| `alknet/call` (channel 0) | 0 (EventEnvelope frames) |
|
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
|
||||||
| `alknet/tty` | 0, 1, 2, 3 (stdin, stdout, stderr, control) |
|
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
|
||||||
| `alknet/tunnel` | 0, 1 (data-in, data-out) |
|
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
|
||||||
| `alknet/ssh` | 0, 1 (data-in, data-out — SSH multiplexes internally) |
|
| `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`
|
## 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
|
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0` is
|
||||||
routed to the `CallAdapter` without an explicit `channel/open` exchange.
|
routed to the `CallAdapter` without an explicit `channel/open` exchange.
|
||||||
|
|
||||||
Channel 0 uses only `stream_type` 0 for `EventEnvelope` frames (JSON,
|
Channel 0 uses stream_types [0, 1] — call frames bidirectional via 0=in
|
||||||
length-prefixed — the call protocol wire format, ADR-064). `stream_type`
|
(client→server), 1=out (server→client). The call protocol's `(SendStream,
|
||||||
1-255 on channel 0 are reserved for future call-protocol sub-streams.
|
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
|
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
|
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 —
|
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header —
|
||||||
the format is self-synchronizing.
|
the format is self-synchronizing.
|
||||||
|
|
||||||
Within a channel, `stream_type` 0 (stdin) from the server is invalid, so
|
Within a channel, `stream_type` 0 (write half) from the server is invalid,
|
||||||
`0x00` as the first byte of a chunk payload from the server is unambiguous
|
so `0x00` as the first byte of a chunk payload from the server is
|
||||||
(carried from ADR-052 §5).
|
unambiguous (carried from ADR-052 §5).
|
||||||
|
|
||||||
## Zero-length sentinel = EOF
|
## 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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.
|
before issuing the call operation.
|
||||||
|
|
||||||
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward:
|
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
|
the exit control message on `stream_type 4` (read, server→client) is the
|
||||||
`channel/close`. For tunnels it is the last data byte before close. The
|
last data before `channel/close`. For tunnels it is the last data byte
|
||||||
channels layer's close handler observes the pump completion; the call
|
before close. The channels layer's close handler observes the pump
|
||||||
operation is issued after.
|
completion; the call operation is issued after.
|
||||||
|
|
||||||
This invariant crosses two channels (the data channel and channel 0), so
|
This invariant crosses two channels (the data channel and channel 0), so
|
||||||
the channels layer owns the ordering guarantee — it is not a handler
|
the channels layer owns the ordering guarantee — it is not a handler
|
||||||
|
|||||||
@@ -94,29 +94,48 @@ design.
|
|||||||
## Crate dependencies
|
## Crate dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
alknet-channels
|
alknet-channels-core
|
||||||
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
|
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
|
||||||
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
|
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
|
||||||
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
|
|
||||||
│ make_streaming_handler, CallError, ResponseEnvelope)
|
|
||||||
├── tokio (spawn, mpsc, io)
|
├── tokio (spawn, mpsc, io)
|
||||||
├── bytes (Bytes for chunk payloads)
|
├── bytes (Bytes for chunk payloads)
|
||||||
├── async-trait
|
├── async-trait
|
||||||
├── thiserror
|
├── thiserror
|
||||||
└── tracing
|
└── 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
|
`alknet-channels-core` is the pure multiplexer — wire format, demux/mux,
|
||||||
operations (`channel/open`, `channel/close`, `channel/control`,
|
`ChannelBidiStreamSource`, `ChannelManager`. It depends on `alknet-core`
|
||||||
`channel/resources/subscribe`) are registered on the call protocol's
|
only. No `alknet-call` dependency. ALPN-blind, call-protocol-blind,
|
||||||
`OperationRegistry` (ADR-073). This is the one handler-crate →
|
transport-blind. This is where the "streams are streams" insight lives.
|
||||||
`alknet-call` dependency; it is sound because channels *is* a call-protocol
|
|
||||||
extension (channel lifecycle is call operations), not a peer handler.
|
|
||||||
|
|
||||||
The dependency direction is: handlers depend on `alknet-core`;
|
`alknet-channels-call` is the call-protocol coupling — channel 0
|
||||||
`alknet-channels` depends on `alknet-core` and `alknet-call`; nothing
|
pre-negotiation as `alknet/call` (ADR-072) and the four lifecycle operations
|
||||||
depends on `alknet-channels` except the assembly layer and
|
(ADR-073) registered on the call protocol's `OperationRegistry`. This is
|
||||||
`alknet-tty`/`alknet-docker` behind their `channels`/`tty` feature gates.
|
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
|
## ALPN
|
||||||
|
|
||||||
@@ -220,16 +239,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
|||||||
|
|
||||||
| ADR | Decision | Summary |
|
| ADR | Decision | Summary |
|
||||||
|-----|----------|---------|
|
|-----|----------|---------|
|
||||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; one-way door |
|
| [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`, no special control plane |
|
| [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 |
|
| [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 |
|
| [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 | The read/demux + reassemble/allocate split; REQ-CH-01..04 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) |
|
| [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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted
|
Accepted (revised 2026-07-12: substrate simplification + stream_type
|
||||||
|
decomposition)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
`alknet-channels` is a multiplexing proxy: a `ProtocolHandler` on
|
`alknet-channels` is a multiplexing proxy: a `ProtocolHandler` on
|
||||||
`alknet/channels` that decomposes a single bidirectional transport stream into
|
`alknet/channels` that carries N logical channels, each with a different
|
||||||
N logical channels, each carrying a different ALPN. The wire format is the
|
ALPN, over transport stream(s). The wire format is the substrate that makes
|
||||||
substrate that makes one transport stream carry many channels.
|
this work.
|
||||||
|
|
||||||
Two prior formats inform this design:
|
Two prior formats inform this design:
|
||||||
|
|
||||||
@@ -18,16 +19,47 @@ Two prior formats inform this design:
|
|||||||
encrypted transport stream.
|
encrypted transport stream.
|
||||||
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
|
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)
|
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
|
||||||
within one bidi stream. Validated by two POCs (alknet-docker-poc,
|
within one bidi stream. Validated by two POCs and in production code.
|
||||||
alknet-tty-poc) and in production code (`crates/alknet-tty/src/wire.rs`).
|
|
||||||
|
|
||||||
The channels format is the generalization: add a `channel_id: u32` prefix to
|
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
|
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-
|
N-channel multiplexer. The de-risk POC (28 tests) validated the core
|
||||||
summary.md`, 28 tests) validated this is a clean generalization: the sync core
|
mechanics.
|
||||||
(`parse_header`/`write_header`) is pure and WASM-compatible by construction;
|
|
||||||
the mpsc-bridged async shell scales to N concurrent channels with per-channel
|
### The substrate simplification
|
||||||
order preservation and cross-channel isolation.
|
|
||||||
|
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
|
## Decision
|
||||||
|
|
||||||
@@ -37,47 +69,119 @@ order preservation and cross-channel isolation.
|
|||||||
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
|
[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;
|
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
|
||||||
`stream_type` and `length` are identical to TTY's fields (ADR-052), preserving
|
format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
|
||||||
the framing-disambiguation soundness property (§5 below).
|
preserving the framing-disambiguation soundness property.
|
||||||
|
|
||||||
| field | width | meaning |
|
| 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`. |
|
| `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). |
|
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
|
||||||
|
|
||||||
### `MAX_CHUNK_LEN`
|
### `MAX_CHUNK_LEN`
|
||||||
|
|
||||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
`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
|
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||||
stream — the demux drops the chunk and continues. This preserves the framing-
|
stream — the demux drops the chunk and continues. The header is always
|
||||||
disambiguation soundness property: the header is always exactly 9 bytes, so
|
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||||
the demux can always resync after a dropped chunk by reading the next 9-byte
|
|
||||||
header.
|
header.
|
||||||
|
|
||||||
### Stream types (per channel)
|
### Stream types — unidirectional, grouped in threes
|
||||||
|
|
||||||
| stream_type | direction | purpose |
|
**Every stream_type is unidirectional.** Bidirectionality is two
|
||||||
|-------------|-----------|---------|
|
stream_types (write + read), not one "bidirectional" stream_type. The
|
||||||
| 0 | write half | data flowing in (stdin equivalent) |
|
stream_types are grouped in threes:
|
||||||
| 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 |
|
|
||||||
|
|
||||||
Not all channels use all sub-streams. A TTY session uses 0-3. A raw tunnel
|
| Group | stream_type | direction | purpose |
|
||||||
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
|
| Data | 0 | write (client→server) | data in (stdin equivalent) |
|
||||||
(ADR-073) and fixed for the channel's lifetime.
|
| | 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)
|
### Framing disambiguation (carried from ADR-052 §5)
|
||||||
|
|
||||||
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
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
|
header. Disambiguation between channel 0 (call protocol) and data channels
|
||||||
by `channel_id`, not by a special first-byte trick. Within a channel,
|
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
|
`stream_type` 0 (write half) from the server is invalid, so `0x00` as the
|
||||||
byte of a chunk payload from the server is unambiguous.
|
first byte of a chunk payload from the server is unambiguous.
|
||||||
|
|
||||||
### Zero-length sentinel = EOF
|
### 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
|
### Sync core / async shell split
|
||||||
|
|
||||||
The wire format's core is pure byte manipulation — `parse_header(&[u8; 9]) ->
|
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])`.
|
-> ChunkHeader` and `write_header(channel_id, stream_type, length, &mut
|
||||||
No async, no platform dependencies. Compiles under `wasm32-unknown-unknown`
|
[u8; 9])`. No async, no platform dependencies. Compiles under
|
||||||
(validated by the POC). The async shell (demux/mux) wraps this core with
|
`wasm32-unknown-unknown` (validated by the POC). The async shell (demux/mux)
|
||||||
`read_exact`/`write_all` on the transport and `mpsc` routing. This split is
|
wraps this core with `read_exact`/`write_all` on the transport and `mpsc`
|
||||||
the same pattern as TTY's (REQ-TTY-01 generalization) and keeps the
|
routing. This split keeps the WASM-compatible core separate from the
|
||||||
WASM-compatible core separate from the tokio-dependent shell.
|
tokio-dependent shell.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**Positive:**
|
**Positive:**
|
||||||
- One multiplexing model replaces three (connection-level ALPN, stream-level
|
- One wire format across all substrates. The `ChannelsAdapter` code path is
|
||||||
QUIC native, sub-stream-level TTY chunks). The hub's relay logic becomes
|
the same regardless of transport; the transport's native multiplexing is a
|
||||||
channel-by-channel byte forwarding, not per-protocol framing parsers.
|
performance optimization, not a protocol change.
|
||||||
- The 9-byte overhead is negligible for the intended use cases (TTY sessions,
|
- Every stream_type is unidirectional — proper multiplexing with independent
|
||||||
SSH, tunnels, call operations). The format is a 4-byte extension to a
|
flow control and EOF per half. The TTY control channel becomes actually
|
||||||
proven 5-byte format.
|
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.
|
- WASM-compatible by construction — the pure core has no platform deps.
|
||||||
- The framing-disambiguation property from ADR-052 carries forward unchanged.
|
- The framing-disambiguation property from ADR-052 carries forward unchanged.
|
||||||
|
|
||||||
**Negative:**
|
**Negative:**
|
||||||
- All channels on one `alknet/channels` connection share one transport
|
- 9 bytes per chunk on all substrates, including QUIC where the transport
|
||||||
stream's flow-control window. A slow consumer on one channel can
|
already separates streams. This is 4 bytes more than using the QUIC stream
|
||||||
backpressure others. This is mitigated by bounded-buffer backpressure
|
ID directly as the `channel_id`. For the intended use cases this is noise;
|
||||||
(ADR-076) but not eliminated. For high-throughput bulk transfer, the
|
for high-throughput bulk transfer, the answer is multi-connection, not
|
||||||
application uses N independent channels connections — same trade-off as
|
stripping the header.
|
||||||
HTTP/2-over-TLS vs HTTP/3-over-QUIC. This is a transport property, not a
|
- All channels on one in-line connection share one transport stream's
|
||||||
channels-format property.
|
flow-control window. A slow consumer on one channel can backpressure
|
||||||
- 9 bytes per chunk is 4 bytes more than TTY's 5-byte format. For
|
others. Mitigated by bounded-buffer backpressure (ADR-076), not
|
||||||
high-frequency small-chunk workloads (e.g., typing in a terminal), this is
|
eliminated. The native substrate (QUIC streams) avoids this — each
|
||||||
a 80% header overhead increase. In practice the chunk size is driven by
|
channel gets its own flow-control window. For high-throughput, use native
|
||||||
the write pattern (a terminal sends a few bytes per keystroke regardless),
|
or multi-connection.
|
||||||
and the 4-byte delta is noise next to the TLS/QUIC overhead.
|
|
||||||
|
|
||||||
## Door type
|
## Door type
|
||||||
|
|
||||||
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
|
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
|
||||||
length:u32`) is a wire-format commitment. Changing field widths, order, or
|
length:u32`) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
|
||||||
semantics after deployments exist requires a version migration. The
|
control, `% 3` formula) are wire-format commitments. Changing them after
|
||||||
`stream_type` assignments (0=stdin, 1=stdout, etc.) are one-way for the same
|
deployments exist requires a version migration.
|
||||||
reason — they are inherited from ADR-052 and preserved.
|
|
||||||
|
|
||||||
The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail
|
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
|
within the one-way format.
|
||||||
bump as long as both ends agree (it's a validation threshold, not a field
|
|
||||||
width).
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes)
|
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
||||||
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection this
|
amended by ADR-077 — scoped to direct TTY)
|
||||||
format rides on)
|
- ADR-065: `Connection::from_stream` (the transport-agnostic Connection)
|
||||||
- ADR-070: `BidiStreamSource` trait (the extension point the channels
|
- ADR-070: `BidiStreamSource` trait (the extension point the channels
|
||||||
connection implements)
|
connection implements; its docstring already anticipated per-channel
|
||||||
- `docs/research/alknet-channels/poc-summary.md` — the POC that validated the
|
streams)
|
||||||
format (28 tests, WASM compile check)
|
- ADR-072: channel 0 pre-negotiated (now uses stream_types [0, 1])
|
||||||
- `docs/research/alknet-channels/phase-0-findings.md` §The Wire Format
|
- ADR-073: channel lifecycle operations (stream_types field examples
|
||||||
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
|
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
|
### Channel 0's stream_type usage
|
||||||
|
|
||||||
| stream_type | purpose |
|
| stream_type | direction | purpose |
|
||||||
|-------------|---------|
|
|-------------|-----------|---------|
|
||||||
| 0 | `EventEnvelope` frames (JSON, length-prefixed — the call protocol wire format) |
|
| 0 | write (client→server) | `EventEnvelope` frames from the client (call.requested, call.aborted) |
|
||||||
| 1-255 | reserved for future call-protocol sub-streams |
|
| 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`
|
Channel 0 uses stream_types [0, 1] — the call protocol is bidirectional via
|
||||||
1-255 is a future-proofing detail, not a current commitment — the call
|
two unidirectional halves, the same way every channel type works
|
||||||
protocol is JSON-only and single-stream by design (ADR-064).
|
(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
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Request (`call.requested` on channel 0):
|
|||||||
"operation": "channel/open",
|
"operation": "channel/open",
|
||||||
"input": {
|
"input": {
|
||||||
"alpn": "alknet/tty",
|
"alpn": "alknet/tty",
|
||||||
"stream_types": [0, 1, 2, 3],
|
"stream_types": [0, 1, 2, 3, 4],
|
||||||
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
|
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
|
||||||
"direction": "initiator-to-responder"
|
"direction": "initiator-to-responder"
|
||||||
}
|
}
|
||||||
@@ -60,8 +60,8 @@ Request (`call.requested` on channel 0):
|
|||||||
| field | type | meaning |
|
| field | type | meaning |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `alpn` | string | The ALPN the channel will carry. The responder looks this up in its `HandlerRegistry`. |
|
| `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. |
|
| `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` — it hands the JSON to the handler. |
|
| `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. |
|
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
|
||||||
|
|
||||||
Response (`call.responded`):
|
Response (`call.responded`):
|
||||||
@@ -70,7 +70,7 @@ Response (`call.responded`):
|
|||||||
{
|
{
|
||||||
"output": {
|
"output": {
|
||||||
"channel_id": 7,
|
"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 |
|
| 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 |
|
| 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
|
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
|
example of data-ordered control — the exit message rides on `stream_type 4`
|
||||||
must arrive after the last stdin chunk, guaranteed by chunk ordering within
|
(read, server→client) because it must arrive after the last data on
|
||||||
`(channel_id, stream_type)`, not by a call-protocol round-trip. The
|
`stream_type 1`, guaranteed by per-stream_type ordering. The client's EOF
|
||||||
`channel/close` operation that follows is on channel 0 and is ordered after
|
signal rides on `stream_type 3` (write, client→server), ordered after the
|
||||||
the data pump completes (REQ-CH-06).
|
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
|
## 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)
|
### Sub-stream accessor for typed destructure (OQ-CH-10)
|
||||||
|
|
||||||
Some handlers need access to `stream_type` 2 (stderr) and 3 (control) in
|
Some handlers need access to `stream_type` 2 (stderr), 3 (control in), and
|
||||||
addition to the main 0/1 pair. The `Connection` interface alone (accept_bi)
|
4 (control out) in addition to the main 0/1 pair. The `Connection`
|
||||||
only exposes the 0/1 pair. The channels crate provides a typed-accessor
|
interface alone (accept_bi) only exposes the 0/1 pair. The channels crate
|
||||||
extension:
|
provides a typed-accessor extension:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// In alknet-channels:
|
// In alknet-channels:
|
||||||
pub struct ChannelSubStreams {
|
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 {
|
impl ChannelBidiStreamSource {
|
||||||
/// Returns the typed sub-streams for this channel, keyed by stream_type.
|
/// Returns the typed sub-streams for this channel, keyed by stream_type.
|
||||||
/// Consumes the source — call this instead of accept_bi() if the handler
|
/// 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
|
/// the main 0/1 pair, accept_bi() is the path (and sub_streams() is not
|
||||||
/// called).
|
/// called).
|
||||||
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||||
@@ -101,15 +110,24 @@ its typed names:
|
|||||||
```rust
|
```rust
|
||||||
// In alknet-tty (inside-channels mode, ADR-077):
|
// In alknet-tty (inside-channels mode, ADR-077):
|
||||||
let sub = channel_source.into_sub_streams();
|
let sub = channel_source.into_sub_streams();
|
||||||
let mut stdin = sub.get(0); // SendStream
|
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
|
||||||
let mut stdout = sub.get(1); // RecvStream
|
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
|
||||||
let mut stderr = sub.get(2); // RecvStream (optional)
|
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
|
||||||
let mut control = sub.get(3); // RecvStream (JSON control)
|
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
|
**Every stream_type is unidirectional** (ADR-071). Write stream_types
|
||||||
exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate
|
(`% 3 == 0`) carry a `SendStream`; read stream_types (`% 3 == 1 or 2`) carry
|
||||||
maps stream_types to its typed names. This preserves ADR-003's
|
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
|
no-handler-depends-on-another-handler rule and keeps the channels crate
|
||||||
ALPN-blind.
|
ALPN-blind.
|
||||||
|
|
||||||
|
|||||||
@@ -39,27 +39,37 @@ impl ProtocolHandler for ChannelsAdapter {
|
|||||||
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
||||||
-> Result<(), HandlerError>
|
-> 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?;
|
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?;
|
self.manager.preinstall_channel_0(send, recv, auth).await?;
|
||||||
|
|
||||||
// 3. Run the demux loop: read 9-byte headers, route payloads to
|
// 2. Accept remaining bidi streams and read 9-byte headers off each.
|
||||||
// per-(channel_id, stream_type) reassembly buffers.
|
// On an in-line transport (TCP+TLS, WebTransport), accept_bi()
|
||||||
self.manager.run_demux_loop(recv).await
|
// 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
|
The `preinstall_channel_0` step constructs the reassembly buffers for
|
||||||
reassembly buffers for `channel_id = 0`, wraps them as a `Connection` (via
|
`channel_id = 0` using stream_types [0, 1] (ADR-072), wraps them as a
|
||||||
`Connection::from_source` with a `ChannelBidiStreamSource` — ADR-074), and
|
`Connection` (via `Connection::from_source` with a `ChannelBidiStreamSource`
|
||||||
hands that `Connection` to the `CallAdapter` — exactly as if `alknet/call`
|
— ADR-074), and hands that `Connection` to the `CallAdapter` — exactly as if
|
||||||
had been the top-level ALPN. The `CallAdapter` is none the wiser.
|
`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
|
### `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 |
|
| 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 |
|
| **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) → four named `SendStream`/`RecvStream` pairs for 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) → five named handles for stream_types 0-4 |
|
||||||
|
|
||||||
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
|
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
|
||||||
(ADR-053). The backend allocates a PTY and returns a `TtyHandle`; the
|
(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)
|
only in how the adapter gets the sub-streams — 5-byte chunk parsing (direct)
|
||||||
vs. `into_sub_streams()` (channels).
|
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
|
### What changes in alknet-tty
|
||||||
|
|
||||||
1. **The adapter's session-driving code splits into two entry points:**
|
1. **The adapter's session-driving code splits into two entry points:**
|
||||||
- `drive_session_direct(send, recv, backends, ...)` — the existing path:
|
- `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.
|
`alknet/tty` connections.
|
||||||
- `drive_session_channels(sub_streams, backends, ...)` — the new path:
|
- `drive_session_channels(sub_streams, backends, ...)` — the new path:
|
||||||
receive `ChannelSubStreams` (four named `SendStream`/`RecvStream`
|
receive `ChannelSubStreams` (five named handles: stdin=SendStream,
|
||||||
pairs), pump directly without chunk parsing. Used when the channel's
|
stdout=RecvStream, stderr=Option<RecvStream>, ctrl_in=SendStream,
|
||||||
`Connection` is backed by `ChannelBidiStreamSource`.
|
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.**
|
2. **The `TtyAdapter::handle()` branches on the `Connection`'s source type.**
|
||||||
The channels crate's `ChannelBidiStreamSource` is a `BidiStreamSource`
|
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
|
connections." The channels path does not use it. This amends ADR-052's
|
||||||
scope — the format is not replaced, it's scoped.
|
scope — the format is not replaced, it's scoped.
|
||||||
|
|
||||||
4. **The control channel (stream_type 3) works the same in both modes.** In
|
4. **The control channel works the same in both modes, now properly
|
||||||
direct mode, control JSON rides in 5-byte chunks with `stream_type=3`. In
|
bidirectional.** In direct mode, control-in JSON rides in 5-byte chunks
|
||||||
channels mode, control JSON rides in 9-byte chunks with `stream_type=3`
|
with `stream_type=3` and control-out rides with `stream_type=4`. In
|
||||||
— but the channels layer de-chunks it, so the adapter reads raw JSON
|
channels mode, control-in rides in 9-byte chunks with `stream_type=3`
|
||||||
bytes from its `control` `RecvStream` in both cases. The
|
(write) and control-out with `stream_type=4` (read) — but the channels
|
||||||
`ControlMessage` enum (resize, signal, eof, exit) is unchanged.
|
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
|
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
|
mode, the exit chunk is the last 5-byte chunk on `stream_type=4` (read,
|
||||||
(ADR-055). In channels mode, the exit control message is the last data on
|
server→client) before stream close (ADR-055, amended). In channels mode,
|
||||||
`stream_type 3` before `channel/close` is sent on channel 0
|
the exit control message is the last data on `stream_type=4` before
|
||||||
(ADR-073 §channel/close). The ordering invariant is the same — exit
|
`channel/close` is sent on channel 0 (ADR-073 §channel/close). The
|
||||||
before close — but the mechanism differs: 5-byte chunk ordering (direct)
|
ordering invariant is the same — exit before close — but the mechanism
|
||||||
vs. `stream_type 3` ordering + `channel/close` after pump completion
|
differs: 5-byte chunk ordering on stream_type 4 (direct) vs.
|
||||||
|
`stream_type 4` ordering + `channel/close` after pump completion
|
||||||
(channels, REQ-CH-06).
|
(channels, REQ-CH-06).
|
||||||
|
|
||||||
### What does NOT change
|
### 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