docs(research): clarify channel 0 is just alknet/call pre-negotiated
Channel 0 is not a special control plane with its own framing. It is simply the alknet/call ALPN, pre-negotiated so both sides route it to the CallAdapter without an explicit channel/open exchange. Every channel works the same way: reassemble chunks into a stream, look up the ALPN in the HandlerRegistry, hand off to the handler. Channel open is bidirectional — either side can initiate.
This commit is contained in:
1 file changed
+99
-59
@@ -36,8 +36,11 @@ generalization of two existing patterns:
|
|||||||
The generalization: a chunk header of `(channel_id: u32, stream_type: u8,
|
The generalization: a chunk header of `(channel_id: u32, stream_type: u8,
|
||||||
length: u32)` — 9 bytes — that multiplexes an arbitrary number of channels,
|
length: u32)` — 9 bytes — that multiplexes an arbitrary number of channels,
|
||||||
each with up to 256 sub-stream types, over a single transport. Channel 0 is
|
each with up to 256 sub-stream types, over a single transport. Channel 0 is
|
||||||
reserved for the call protocol (the control plane). All other channels are
|
pre-negotiated as `alknet/call` — the call protocol. Every other channel is
|
||||||
data channels opened via call operations on channel 0.
|
opened dynamically via call operations on channel 0, and channel open is
|
||||||
|
**bidirectional**: either side can open a channel to the other, just like
|
||||||
|
the call protocol's operation overlay where each side populates what
|
||||||
|
operations they can call.
|
||||||
|
|
||||||
The guiding insight:
|
The guiding insight:
|
||||||
|
|
||||||
@@ -46,8 +49,8 @@ The guiding insight:
|
|||||||
> handles. The differences are only in how they're *opened* (negotiation) and
|
> handles. The differences are only in how they're *opened* (negotiation) and
|
||||||
> what *multiplexing layer* carries them. Once you normalize to
|
> what *multiplexing layer* carries them. Once you normalize to
|
||||||
> `AsyncRead + AsyncWrite`, the architecture collapses into a simple shape:
|
> `AsyncRead + AsyncWrite`, the architecture collapses into a simple shape:
|
||||||
> the call protocol is the universal control plane, channels are the universal
|
> every channel is just an ALPN routed through the same `HandlerRegistry`,
|
||||||
> data plane, and ACL governs everything.
|
> channel 0 is just `alknet/call` pre-negotiated, and ACL governs everything.
|
||||||
|
|
||||||
## The Problem This Solves
|
## The Problem This Solves
|
||||||
|
|
||||||
@@ -80,15 +83,20 @@ can't even *reference* these other streams — there's no "stream token" or
|
|||||||
### What channels provides
|
### What channels provides
|
||||||
|
|
||||||
A single `alknet/channels` connection carries:
|
A single `alknet/channels` connection carries:
|
||||||
- Channel 0: the call protocol (JSON `EventEnvelope` frames)
|
- Channel 0: `alknet/call` (pre-negotiated — both sides route it to the `CallAdapter`)
|
||||||
- Channel N: a TTY session (raw bytes, stdin/stdout/stderr/control)
|
- Channel N: `alknet/tty` (opened dynamically via `channel/open`)
|
||||||
- Channel M: an SSH connection (SSH binary protocol)
|
- Channel M: `alknet/ssh` (opened dynamically via `channel/open`)
|
||||||
- Channel K: a tunnel (raw bytes, bidirectional proxy)
|
- Channel K: `alknet/tunnel` (opened dynamically via `channel/open`)
|
||||||
|
|
||||||
All on one QUIC connection (or one TCP connection, or one WebTransport
|
All on one QUIC connection (or one TCP connection, or one WebTransport
|
||||||
session). The call protocol on channel 0 orchestrates channel open/close and
|
session). Channel 0 is not a special "control plane" — it's just the
|
||||||
type-specific control. The data channels carry whatever bytes the target
|
`alknet/call` ALPN, pre-negotiated so both sides know to route it to the
|
||||||
protocol needs.
|
`CallAdapter` without an explicit `channel/open` exchange. Every other
|
||||||
|
channel works exactly the same way: reassemble chunks into a stream, look up
|
||||||
|
the ALPN in the `HandlerRegistry`, hand off to the handler. Channel open is
|
||||||
|
bidirectional — either side can open a channel to the other, just like the
|
||||||
|
call protocol's operation overlay where each side populates what operations
|
||||||
|
they can call.
|
||||||
|
|
||||||
## The Wire Format
|
## The Wire Format
|
||||||
|
|
||||||
@@ -102,25 +110,28 @@ protocol needs.
|
|||||||
[length: u32 be]`). The `channel_id` field is the addition — it's what turns
|
[length: u32 be]`). The `channel_id` field is the addition — it's what turns
|
||||||
a fixed 4-channel multiplexer into an arbitrary N-channel multiplexer.
|
a fixed 4-channel multiplexer into an arbitrary N-channel multiplexer.
|
||||||
|
|
||||||
### Channel 0: the control plane
|
### Channel 0: pre-negotiated as `alknet/call`
|
||||||
|
|
||||||
Channel 0 carries the call protocol. Its chunks use `stream_type` to
|
Channel 0 is not a special "control plane" with its own framing. It is
|
||||||
disambiguate framing:
|
simply the `alknet/call` ALPN, pre-negotiated: both sides know that channel
|
||||||
|
0 is routed to the `CallAdapter` without an explicit `channel/open`
|
||||||
|
exchange. The call protocol runs on channel 0 exactly as it runs on a
|
||||||
|
top-level `alknet/call` QUIC connection — `EventEnvelope` frames on a
|
||||||
|
bidirectional stream.
|
||||||
|
|
||||||
|
Channel 0's chunks use `stream_type` to disambiguate framing:
|
||||||
|
|
||||||
| stream_type | purpose |
|
| stream_type | purpose |
|
||||||
|-------------|---------|
|
|-------------|---------|
|
||||||
| 0 | `EventEnvelope` frame (JSON, length-prefixed — the call protocol wire format) |
|
| 0 | `EventEnvelope` frame (JSON, length-prefixed — the call protocol wire format) |
|
||||||
| 1-255 | reserved for future control-plane sub-streams |
|
| 1-255 | reserved for future sub-streams |
|
||||||
|
|
||||||
Channel 0 is opened implicitly when the `alknet/channels` connection is
|
|
||||||
established. No negotiation frame is needed — both sides know channel 0 is
|
|
||||||
the call protocol.
|
|
||||||
|
|
||||||
### Data channels (1..N)
|
### Data channels (1..N)
|
||||||
|
|
||||||
Data channels carry protocol-specific bytes. The `stream_type` byte
|
Every other channel works exactly the same way as channel 0: reassemble
|
||||||
decomposes the channel into sub-streams, following the TTY crate's proven
|
chunks into a stream, look up the ALPN in the `HandlerRegistry`, hand off to
|
||||||
model:
|
the handler. The `stream_type` byte decomposes the channel into sub-streams,
|
||||||
|
following the TTY crate's proven model:
|
||||||
|
|
||||||
| stream_type | direction | purpose |
|
| stream_type | direction | purpose |
|
||||||
|-------------|-----------|---------|
|
|-------------|-----------|---------|
|
||||||
@@ -137,11 +148,14 @@ open time.
|
|||||||
|
|
||||||
### Channel lifecycle
|
### Channel lifecycle
|
||||||
|
|
||||||
1. **Open**: client sends a `channel/open` call operation on channel 0 with
|
1. **Open**: either side sends a `channel/open` call operation on channel 0
|
||||||
`{ alpn, params }`. Server validates ACL, allocates the handler, returns
|
with `{ alpn, params }`. The receiving side validates ACL, looks up the
|
||||||
|
ALPN in the `HandlerRegistry`, allocates the handler, and returns
|
||||||
`{ channel_id, stream_types }`. The `stream_types` field declares which
|
`{ channel_id, stream_types }`. The `stream_types` field declares which
|
||||||
sub-streams are active for this channel (e.g., `[0, 1, 2, 3]` for TTY,
|
sub-streams are active for this channel (e.g., `[0, 1, 2, 3]` for TTY,
|
||||||
`[0, 1]` for a tunnel).
|
`[0, 1]` for a tunnel). Channel open is **bidirectional** — a hub can
|
||||||
|
expose a resource a worker consumes, or a worker can expose a resource
|
||||||
|
the hub consumes, just like the call protocol's operation overlay.
|
||||||
2. **Data**: both sides read/write chunks on the assigned `channel_id`. Each
|
2. **Data**: both sides read/write chunks on the assigned `channel_id`. Each
|
||||||
side reassembles chunks for a given `(channel_id, stream_type)` into a
|
side reassembles chunks for a given `(channel_id, stream_type)` into a
|
||||||
stream. The reassembled streams are presented to the handler as
|
stream. The reassembled streams are presented to the handler as
|
||||||
@@ -157,12 +171,12 @@ open time.
|
|||||||
|
|
||||||
### Framing disambiguation
|
### Framing disambiguation
|
||||||
|
|
||||||
The TTY crate's framing disambiguation trick (ADR-052 §5) carries forward:
|
The TTY crate's framing disambiguation trick (ADR-052 §5) carries forward.
|
||||||
error frames on channel 0 start with `0x00` (the high byte of a
|
Channel 0 is just another channel — its chunks have `channel_id=0` in the
|
||||||
length-prefixed JSON frame under 16 MiB). Data chunks on channels 1+ start
|
header. The disambiguation between channel 0 (call protocol) and data
|
||||||
with a non-zero `channel_id` high byte (channel 0 is reserved for control).
|
channels is by `channel_id`, not by a special first-byte trick. Within a
|
||||||
Within a data channel, `stream_type` 0 (stdin) from the server is invalid,
|
channel, `stream_type` 0 (stdin) from the server is invalid, so `0x00` as
|
||||||
so `0x00` as the first byte of a chunk from the server is unambiguous.
|
the first byte of a chunk payload from the server is unambiguous.
|
||||||
|
|
||||||
## The Channel Connection Abstraction
|
## The Channel Connection Abstraction
|
||||||
|
|
||||||
@@ -174,9 +188,15 @@ order-preserving within a `(channel_id, stream_type)` pair — chunks arrive
|
|||||||
in order because they ride on a single ordered transport (QUIC stream, TCP
|
in order because they ride on a single ordered transport (QUIC stream, TCP
|
||||||
connection).
|
connection).
|
||||||
|
|
||||||
The reassembled streams are presented through the same `Connection`
|
Channel 0 is reassembled exactly like any other channel. The reassembled
|
||||||
abstraction that alknet-core already defines (`crates/alknet-core/src/
|
stream for `(channel_id=0, stream_type=0)` is handed to the `CallAdapter`
|
||||||
types.rs`):
|
as a `Connection`. The `CallAdapter` doesn't know it's inside a channels
|
||||||
|
connection — it sees a `Connection` yielding bidi streams, same as if it
|
||||||
|
were a top-level QUIC connection.
|
||||||
|
|
||||||
|
For data channels (1..N), the reassembled streams are presented through the
|
||||||
|
same `Connection` abstraction that alknet-core already defines
|
||||||
|
(`crates/alknet-core/src/types.rs`):
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// A channels connection presents the same interface as a QUIC connection
|
// A channels connection presents the same interface as a QUIC connection
|
||||||
@@ -186,7 +206,7 @@ impl ChannelConnection {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Internally, `accept_bi` / `open_bi` map to channel open/close operations on
|
Internally, `accept_bi` / `open_bi` map to `channel/open` call operations on
|
||||||
channel 0, and the returned `SendStream` / `RecvStream` are backed by chunk
|
channel 0, and the returned `SendStream` / `RecvStream` are backed by chunk
|
||||||
reassembly on the assigned `channel_id`.
|
reassembly on the assigned `channel_id`.
|
||||||
|
|
||||||
@@ -195,14 +215,20 @@ reassembly on the assigned `channel_id`.
|
|||||||
A `ChannelConnection` holds a reference to the same `HandlerRegistry` that
|
A `ChannelConnection` holds a reference to the same `HandlerRegistry` that
|
||||||
the outer `AlknetEndpoint` uses. When a `channel/open` arrives with an ALPN
|
the outer `AlknetEndpoint` uses. When a `channel/open` arrives with an ALPN
|
||||||
string, the channels handler looks up the ALPN in the registry, validates
|
string, the channels handler looks up the ALPN in the registry, validates
|
||||||
ACL, and hands the reassembled streams to the handler. The handler doesn't
|
ACL, and hands the reassembled stream to the handler. The handler doesn't
|
||||||
know it's inside a channels connection — it sees a `Connection` yielding bidi
|
know it's inside a channels connection — it sees a `Connection` yielding bidi
|
||||||
streams, same as if it were a top-level QUIC connection.
|
streams, same as if it were a top-level QUIC connection.
|
||||||
|
|
||||||
|
Channel 0 is not special — it's just `alknet/call` pre-negotiated. Both
|
||||||
|
sides know to route channel 0 to the `CallAdapter` without an explicit
|
||||||
|
`channel/open` exchange. Every other channel is opened dynamically via
|
||||||
|
`channel/open` on channel 0, and the open is bidirectional: either side can
|
||||||
|
initiate.
|
||||||
|
|
||||||
This means every existing and future `ProtocolHandler` works inside channels
|
This means every existing and future `ProtocolHandler` works inside channels
|
||||||
without modification. The `TtyAdapter`, the future `SshAdapter`, a tunnel
|
without modification. The `CallAdapter`, `TtyAdapter`, the future
|
||||||
handler — they all just see a `Connection`. The channels layer is a
|
`SshAdapter`, a tunnel handler — they all just see a `Connection`. The
|
||||||
transparent proxy.
|
channels layer is a transparent proxy.
|
||||||
|
|
||||||
### Recursive composition
|
### Recursive composition
|
||||||
|
|
||||||
@@ -242,17 +268,25 @@ lifecycle.
|
|||||||
### alknet-call
|
### alknet-call
|
||||||
|
|
||||||
The call protocol is unchanged. It remains JSON-only, `EventEnvelope`-based.
|
The call protocol is unchanged. It remains JSON-only, `EventEnvelope`-based.
|
||||||
What changes is that it gains a new class of operations: channel lifecycle
|
It runs on channel 0 exactly as it runs on a top-level `alknet/call` QUIC
|
||||||
operations.
|
connection — the `CallAdapter` receives a `Connection` and dispatches
|
||||||
|
operations. The only difference is that the `Connection` is backed by chunk
|
||||||
|
reassembly on channel 0 rather than by a QUIC bidi stream.
|
||||||
|
|
||||||
|
What changes is that the call protocol gains a new class of operations:
|
||||||
|
channel lifecycle operations.
|
||||||
|
|
||||||
New call operations:
|
New call operations:
|
||||||
- `channel/open` — open a data channel with a given ALPN and params
|
- `channel/open` — open a data channel with a given ALPN and params.
|
||||||
|
Bidirectional: either side can initiate.
|
||||||
- `channel/close` — close a data channel
|
- `channel/close` — close a data channel
|
||||||
- `channel/control` — send a control message on a channel's stream_type 3
|
- `channel/control` — send a control message on a channel's stream_type 3
|
||||||
|
|
||||||
Existing call operations are unchanged. The call protocol doesn't need to
|
Existing call operations are unchanged. The call protocol doesn't need to
|
||||||
know about raw bytes — it just hands out channel tokens and dispatches
|
know about raw bytes — it just hands out channel tokens and dispatches
|
||||||
control operations.
|
control operations. The `from_call` operation overlay (where each side
|
||||||
|
populates what operations they can call) extends naturally to channels:
|
||||||
|
either side can expose resources the other can open channels to.
|
||||||
|
|
||||||
### alknet-ssh
|
### alknet-ssh
|
||||||
|
|
||||||
@@ -373,25 +407,31 @@ already makes for every operation. If zero-round-trip channel open becomes a
|
|||||||
performance requirement, option (c) can be added as an extension (two-way
|
performance requirement, option (c) can be added as an extension (two-way
|
||||||
door).
|
door).
|
||||||
|
|
||||||
### DP-2: Channel 0 framing — EventEnvelope on stream_type 0 vs raw call protocol
|
### DP-2: Channel 0 — pre-negotiated `alknet/call`, not a special control plane
|
||||||
|
|
||||||
*(Recommended: EventEnvelope frames on stream_type 0)*
|
*(Recommended: channel 0 is just `alknet/call` pre-negotiated — no special framing)*
|
||||||
|
|
||||||
Channel 0 carries the call protocol. Two framing options:
|
Channel 0 is not a "control plane" with its own wire format. It is simply
|
||||||
- **(a) EventEnvelope on stream_type 0**: each call protocol frame is a
|
the `alknet/call` ALPN, pre-negotiated: both sides know that channel 0 is
|
||||||
chunk with `channel_id=0, stream_type=0`. The payload is a
|
routed to the `CallAdapter` without an explicit `channel/open` exchange. The
|
||||||
length-prefixed JSON `EventEnvelope` (the existing call protocol wire
|
call protocol runs on channel 0 exactly as it runs on a top-level
|
||||||
format). This is a thin wrapper — the call protocol's `FrameFramedReader`
|
`alknet/call` QUIC connection — `EventEnvelope` frames on a bidirectional
|
||||||
/ `FrameFramedWriter` work unchanged, just over a chunk-demuxed stream.
|
stream backed by chunk reassembly.
|
||||||
- **(b) Raw call protocol**: channel 0 is not chunked; the call protocol
|
|
||||||
runs directly on the transport stream. This requires the channels layer to
|
|
||||||
peek at the first byte to disambiguate call protocol frames from data
|
|
||||||
channel chunks. More complex framing disambiguation.
|
|
||||||
|
|
||||||
**Recommendation**: **(a) EventEnvelope on stream_type 0**. It's a thin
|
This means:
|
||||||
wrapper that keeps the call protocol unchanged and the framing unambiguous.
|
- Channel 0 uses the same chunk format as every other channel
|
||||||
The overhead is 9 bytes per call frame — negligible for JSON control
|
(`channel_id=0` in the header)
|
||||||
messages.
|
- The `CallAdapter` receives a `Connection` and dispatches operations —
|
||||||
|
unchanged from today
|
||||||
|
- Channel lifecycle operations (`channel/open`, `channel/close`) are just
|
||||||
|
call operations on channel 0, dispatched through the `OperationRegistry`
|
||||||
|
- No special framing disambiguation is needed — `channel_id` already
|
||||||
|
distinguishes channel 0 from data channels
|
||||||
|
|
||||||
|
**Recommendation**: channel 0 is `alknet/call` pre-negotiated. No special
|
||||||
|
framing, no separate control-plane wire format. The channels layer is a
|
||||||
|
transparent proxy: every channel (including channel 0) is just an ALPN
|
||||||
|
routed through the `HandlerRegistry`.
|
||||||
|
|
||||||
### DP-3: TTY chunk format — absorb into channels vs keep separate
|
### DP-3: TTY chunk format — absorb into channels vs keep separate
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user