docs(arch): add alknet-channels specs — ADRs 071-080, 7 spec docs, OQ-56/57
Phase 1 architecture for alknet-channels (multiplexing proxy on alknet/channels). Grounded in the completed de-risk POC (28 tests) and the landed ADR-070 (BidiStreamSource trait + Connection::from_source). ADRs: - 071: 9-byte chunk wire format (generalizes TTY's 5-byte) - 072: channel 0 pre-negotiated as alknet/call (no special control plane) - 073: channel lifecycle operations on the call protocol — channel/open, close, control, resources/subscribe; direction field pinned; subscribe from day one (not poll-for-v1 — StreamingHandler machinery exists) - 074: ChannelBidiStreamSource implements BidiStreamSource (ADR-070); into_sub_streams() typed accessor for TTY; accept_bi() generic path - 075: ChannelsAdapter + ChannelManager; REQ-CH-01..04 wire invariants - 076: bounded-buffer backpressure (1 MiB), 256-channel cap, monotonic IDs - 077: TTY inside channels uses sub-streams, not own wire format; amends ADR-052 scope to direct-connect TTY; channels feature on tty - 078: two-pump shutdown-on-completion contract (handler-level) - 079: hub relay translates channel 0, byte-forwards data channels - 080: ChannelClient (QUIC-only); AlknetClient core extraction deferred (OQ-55) Spec docs: overview, channels-wire, channels-connection, channels-adapter, channel-operations, channel-client. OQ-56 (full windowing) and OQ-57 (two-pump helper extraction) are genuine deferred(scope) deferrals with concrete blocking conditions; the contracts are decided, only the extensions are deferred. Hedging audit converted three research hedges into decisions: resources/subscribe (not poll), server-assigned IDs (not if-zero-RTT), bounded-buffer (not if-HOL-becomes-a-problem).
This commit is contained in:
1 parent
f997c81d2f
commit
2313c51f12
21 files changed
+3411
No files matched your search
@@ -0,0 +1,123 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# alknet-channels
|
||||
|
||||
A multiplexing proxy: a `ProtocolHandler` on `alknet/channels` that
|
||||
decomposes a single bidirectional transport stream into N logical channels,
|
||||
each carrying a different ALPN. Channel 0 is pre-negotiated as `alknet/call`
|
||||
(ADR-072); every other channel is opened dynamically via call operations on
|
||||
channel 0 and routed through the same `HandlerRegistry` as top-level
|
||||
connections. The channels layer is a re-framing proxy — it converts between
|
||||
"one transport stream carrying N channels" (the wire) and "N independent
|
||||
`AsyncRead + AsyncWrite` handles" (what handlers see) — and it does no
|
||||
protocol work itself.
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
|
||||
| [channels-wire.md](channels-wire.md) | draft | The 9-byte chunk format (`[channel_id:u32 be][stream_type:u8][length:u32 be][payload]`), stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-070/074), `into_sub_streams()` typed destructure, recursive composition |
|
||||
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
|
||||
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-079) |
|
||||
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection, QUIC-only initially, bidirectionality preserved |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format — 9-Byte Chunk Header | The chunk format; one-way door |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol, no special control plane |
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `into_sub_streams()` accessor |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 contracts |
|
||||
| [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 |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient` in alknet-channels, QUIC-only; `AlknetClient` deferred (OQ-55) |
|
||||
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
|
||||
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
|
||||
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format the 9-byte format generalizes (amended by ADR-077 — scoped to direct TTY) |
|
||||
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
|
||||
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-55 | AlknetClient / Client Establishment Extraction | deferred(scope) | `ChannelClient` is decided (ADR-080); `AlknetClient` core extraction stays deferred — blocked on a second *transport's* client, not a second client |
|
||||
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-076); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
|
||||
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
|
||||
connection, a QUIC bidi stream — they're all `AsyncRead + AsyncWrite`
|
||||
handles. The differences are only in how they're *opened* (negotiation
|
||||
via `channel/open` on channel 0) and what *multiplexing layer* carries
|
||||
them (the 9-byte chunk format). Once normalized, every channel is an
|
||||
ALPN routed through the same `HandlerRegistry`. See
|
||||
[overview.md](overview.md) and ADR-071.
|
||||
|
||||
2. **Channel 0 is `alknet/call` pre-negotiated, not a special control
|
||||
plane.** The call protocol runs on channel 0 exactly as on a top-level
|
||||
`alknet/call` connection. Channel lifecycle operations
|
||||
(`channel/open`, `channel/close`, `channel/control`,
|
||||
`channel/resources/subscribe`) are call operations on channel 0's
|
||||
`OperationRegistry`, gated by the existing `AccessControl::check`. No
|
||||
new auth machinery, no new framing. See ADR-072, ADR-073.
|
||||
|
||||
3. **The channels layer is a re-framing proxy, not a protocol engine.** It
|
||||
converts between "one transport stream carrying N channels" (the wire)
|
||||
and "N independent `AsyncRead + AsyncWrite` handles" (what handlers
|
||||
see). It does no ALPN-specific parsing, no auth, no transport coupling.
|
||||
This makes it WASM-compatible and transport-agnostic by construction.
|
||||
See [channels-adapter.md](channels-adapter.md) and ADR-075.
|
||||
|
||||
4. **`channel/resources/subscribe` is a `Subscription`, not a polled
|
||||
`Query`.** The call protocol has `StreamingHandler` / `invoke_streaming`
|
||||
(ADR-049, implemented and tested). The first consumer (the hub
|
||||
aggregating worker resources) needs live updates. Polling would be built
|
||||
and immediately reworked. See ADR-073.
|
||||
|
||||
5. **Bidirectional open.** Either side can open a channel to the other,
|
||||
just like the call protocol's operation overlay. The `direction` field
|
||||
on `channel/open` pins who is the ALPN-server vs ALPN-client. See
|
||||
ADR-073 §Direction semantics.
|
||||
|
||||
6. **Wire-level invariants are contracts, not implementation details.**
|
||||
The POC surfaced five invariants (REQ-CH-01..04, plus REQ-CH-06 for
|
||||
close ordering) that hang channels silently if underspecified: shutdown
|
||||
emits a zero-length sentinel; transport close drops all senders; the mux
|
||||
supports dynamic registration; unknown `channel_id` is lenient-dropped;
|
||||
bounded-buffer backpressure doesn't deadlock; data chunks flush before
|
||||
`channel/close`. See [channels-wire.md](channels-wire.md) and
|
||||
[channels-adapter.md](channels-adapter.md).
|
||||
|
||||
7. **The hub translates, not transparently forwards.** The hub terminates
|
||||
channel 0 on both legs, runs `AccessControl::check`, and re-issues
|
||||
`channel/open` on the spoke leg with `forwarded_for` (ADR-032). Data
|
||||
channels are byte-forwarded with `channel_id` rewrite. This preserves
|
||||
the auth model. See ADR-079.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
|
||||
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
|
||||
- `docs/research/alknet-channels/poc-summary.md` — the de-risk POC (28
|
||||
tests, three validated targets, REQ-CH-01..06 wire-level invariants
|
||||
surfaced; REQ-CH-07 is a cosmetic clippy item, not a wire invariant)
|
||||
- `docs/research/alknet-channels/poc-plan.md` — the POC plan
|
||||
- `/workspace/alknet-channels-poc/` — the POC codebase
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
||||
format (the seed of the channels generalization)
|
||||
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
|
||||
multiplexer (the prior art for N-channel multiplexing)
|
||||
- `docs/architecture/crates/hub/README.md` — the hub crate (the primary
|
||||
consumer; the relay implementation's home)
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# channel-client.md — ChannelClient
|
||||
|
||||
The client side of a channels connection. ADR-080 is the decision; this doc
|
||||
specifies the API.
|
||||
|
||||
## What
|
||||
|
||||
`ChannelClient` is the symmetric counterpart to `ChannelsAdapter` (ADR-075).
|
||||
The server side is a `ProtocolHandler` (`ChannelsAdapter::handle`); the
|
||||
client side dials a transport, establishes the channels connection, runs the
|
||||
demux/mux, and exposes `open_channel(alpn, params) -> Channel` to the
|
||||
application.
|
||||
|
||||
This is the channels analogue of `CallClient` (server: `CallAdapter`;
|
||||
client: `CallClient`) in the call protocol.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
pub struct ChannelClient {
|
||||
manager: ChannelManager,
|
||||
// The transport-side demux/mux, running in a background task.
|
||||
...
|
||||
}
|
||||
|
||||
impl ChannelClient {
|
||||
/// Open a channels connection to a peer. Dials the transport (QUIC
|
||||
/// initially), establishes the channels connection, preinstalls
|
||||
/// channel 0 (alknet/call), and returns the client.
|
||||
pub async fn connect(addr: SocketAddr, credentials: CallCredentials)
|
||||
-> Result<Self, ChannelError>;
|
||||
|
||||
/// Open a data channel with the given ALPN and params. Sends
|
||||
/// `channel/open` on channel 0, waits for the response, and returns
|
||||
/// the channel.
|
||||
pub async fn open_channel(
|
||||
&self,
|
||||
alpn: &str,
|
||||
stream_types: &[u8],
|
||||
params: Value,
|
||||
direction: ChannelDirection,
|
||||
) -> Result<Channel, ChannelError>;
|
||||
|
||||
/// Subscribe to the peer's resource updates. Returns a stream of
|
||||
/// resource-set events (ADR-073 channel/resources/subscribe). Each
|
||||
/// event carries the JSON `output.resources` array from ADR-073's
|
||||
/// `channel/resources/subscribe` response shape.
|
||||
pub async fn subscribe_resources(&self)
|
||||
-> Result<BoxStream<ResourceEvent>, ChannelError>;
|
||||
|
||||
/// The call-protocol connection on channel 0, for invoking channel
|
||||
/// lifecycle operations and any other call ops the peer exposes.
|
||||
pub fn call(&self) -> &CallConnection;
|
||||
}
|
||||
|
||||
pub enum ChannelDirection {
|
||||
InitiatorToResponder,
|
||||
ResponderToInitiator,
|
||||
}
|
||||
|
||||
pub struct Channel {
|
||||
pub channel_id: u32,
|
||||
pub stream_types: Vec<u8>,
|
||||
/// The sub-streams, accessible via accept_bi() (ADR-074 generic path)
|
||||
/// or into_sub_streams() (ADR-074 typed path).
|
||||
pub source: ChannelBidiStreamSource,
|
||||
}
|
||||
|
||||
/// One event from `channel/resources/subscribe`. Wraps the JSON `output`
|
||||
/// object from ADR-073's subscribe response — the `resources` array
|
||||
/// describing what ALPNs the peer exposes and with what `access` preview.
|
||||
/// The channels crate maps the JSON to this typed struct; the fields mirror
|
||||
/// ADR-073's response shape.
|
||||
pub struct ResourceEvent {
|
||||
pub resources: Vec<ResourceEntry>,
|
||||
}
|
||||
|
||||
pub struct ResourceEntry {
|
||||
pub alpn: String,
|
||||
pub backends_or_targets: Vec<String>, // ALPN-specific enumeration
|
||||
pub access: Value, // preview of AccessControl (advisory)
|
||||
}
|
||||
```
|
||||
|
||||
## QUIC-only initially
|
||||
|
||||
`ChannelClient::connect` dials a QUIC connection (via the same `quinn`
|
||||
endpoint `CallClient` uses) and wraps it as a channels connection. This is
|
||||
the same transport shape as `CallClient`. When a second transport's client
|
||||
exists (HTTP, TCP+TLS, WebTransport — per OQ-55), the dial can be
|
||||
generalized. Until then, `ChannelClient` is QUIC-only — the same posture as
|
||||
`CallClient`.
|
||||
|
||||
## Bidirectionality preserved
|
||||
|
||||
The channels protocol is bidirectional — either side can open a channel
|
||||
(ADR-073 §direction semantics). `ChannelClient::open_channel` supports both
|
||||
`ChannelDirection::InitiatorToResponder` and
|
||||
`ChannelDirection::ResponderToInitiator`. The client is not "the client
|
||||
side" in the request/response sense — it can also receive `channel/open`
|
||||
requests from the peer (the peer initiates, the client's `ChannelManager`
|
||||
responds). This mirrors the call protocol's operation overlay (each side
|
||||
populates what operations they expose).
|
||||
|
||||
`ChannelClient` is one endpoint of a bidirectional channels connection. The
|
||||
name follows the `CallClient` convention (the side that dialed), not a
|
||||
request/response role.
|
||||
|
||||
## Relationship to `AlknetClient` (OQ-55 — deferred)
|
||||
|
||||
`ChannelClient` is a standalone client, not a specialization of a core
|
||||
`AlknetClient`. The `AlknetClient` extraction (OQ-55) is genuinely deferred:
|
||||
blocked on a second *transport's* client existing, not on a second client
|
||||
existing. `ChannelClient` over QUIC is a second client but the same
|
||||
transport shape as `CallClient` — it doesn't give enough information to
|
||||
extract the transport-polymorphic dial seam.
|
||||
|
||||
When `AlknetClient` is eventually extracted (after a second transport's
|
||||
client exists), `ChannelClient` and `CallClient` both refactor onto it.
|
||||
Until then, they are independent clients with duplicated boilerplate (each
|
||||
rebuilds verifier selection — ~20 lines). The friction is duplicated
|
||||
boilerplate, not a missing capability.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; QUIC-only; `AlknetClient` deferred (OQ-55) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-55** (deferred(scope)): `AlknetClient` core extraction — blocked on
|
||||
a second *transport's* client. `ChannelClient` does not unblock it.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-080: ChannelClient (the decision)
|
||||
- ADR-073: channel lifecycle operations (`open_channel` sends `channel/open`)
|
||||
- ADR-074: ChannelBidiStreamSource (what `Channel.source` wraps)
|
||||
- ADR-075: ChannelManager (the shared state `ChannelClient` holds)
|
||||
- OQ-55: AlknetClient / client establishment extraction
|
||||
- `docs/architecture/crates/call/client-and-adapters.md` — `CallClient` (the
|
||||
shape `ChannelClient` mirrors)
|
||||
@@ -0,0 +1,271 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# channel-operations.md — Channel Lifecycle on the Call Protocol
|
||||
|
||||
Channel lifecycle is orchestrated by the call protocol on channel 0
|
||||
(ADR-072). Four operations on channel 0's `OperationRegistry` (ADR-073)
|
||||
handle open, close, control, and resource discovery. All four go through
|
||||
the existing `OperationContext` / `AccessControl::check` path — no new auth
|
||||
machinery, no new framing.
|
||||
|
||||
## The four operations
|
||||
|
||||
### `channel/open` — open a data channel
|
||||
|
||||
Request (on channel 0):
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "channel/open",
|
||||
"input": {
|
||||
"alpn": "alknet/tty",
|
||||
"stream_types": [0, 1, 2, 3],
|
||||
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
|
||||
"direction": "initiator-to-responder"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| field | type | meaning |
|
||||
|-------|------|---------|
|
||||
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
|
||||
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3]` for TTY, `[0,1]` for a tunnel. |
|
||||
| `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. |
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": {
|
||||
"channel_id": 7,
|
||||
"stream_types": [0, 1, 2, 3]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| field | type | meaning |
|
||||
|-------|------|---------|
|
||||
| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. |
|
||||
| `stream_types` | `[u8]` | The negotiated set — the responder may narrow the initiator's requested set. |
|
||||
|
||||
**Channel ID allocation: server-assigned (DP-1).** One round-trip before
|
||||
data flows — the same round-trip the call protocol makes for every
|
||||
operation. All current channel types (TTY, tunnel, SSH) already require a
|
||||
negotiation round-trip, so the open round-trip is not additive latency.
|
||||
|
||||
**Error codes** (new `CallError.code` strings, not new framing):
|
||||
|
||||
| code | meaning | retryable |
|
||||
|------|---------|-----------|
|
||||
| `channel:unknown_alpn` | ALPN not in responder's `HandlerRegistry` | false |
|
||||
| `channel:forbidden` | `AccessControl::check` denied the open | false |
|
||||
| `channel:allocation_failed` | Handler allocate failed | true (often transient) |
|
||||
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
|
||||
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-076) | false |
|
||||
| `channel:stream_type_unavailable` | Responder can't provide a requested `stream_type` | false |
|
||||
|
||||
### `channel/close` — tear down a channel
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "channel/close",
|
||||
"input": { "channel_id": 7, "reason": "exit" }
|
||||
}
|
||||
```
|
||||
|
||||
The responder (the side that didn't send the close) drains its reassembled
|
||||
streams for `channel_id`, signals EOF to the handler, and returns
|
||||
`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain
|
||||
completes (ADR-076 — monotonic IDs with wrap-around, not a free-list).
|
||||
`reason` is free-form for observability — not semantically required.
|
||||
|
||||
**REQ-CH-06: exit-chunk-before-close ordering.** The channel's data chunks
|
||||
MUST be written and flushed before the `channel/close` operation is sent on
|
||||
channel 0. The side closing must observe the data-channel pump complete
|
||||
before issuing the call operation. For TTY this is the exit-chunk-is-last
|
||||
invariant (ADR-055) carried forward; for tunnels it is the last data byte
|
||||
before close. This invariant crosses two channels (the data channel and
|
||||
channel 0), so the channels layer owns the ordering guarantee.
|
||||
|
||||
### `channel/control` — out-of-band control on channel 0
|
||||
|
||||
For control that doesn't need ordering relative to data (resize, signal,
|
||||
keepalive):
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "channel/control",
|
||||
"input": {
|
||||
"channel_id": 7,
|
||||
"stream_type": 3,
|
||||
"message": { "type": "resize", "cols": 80, "rows": 24 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The channels layer routes `message` to the handler's control handle for
|
||||
`channel_id`. The `message` JSON is ALPN-specific; the channels layer does
|
||||
not interpret it.
|
||||
|
||||
### `channel/resources/subscribe` — live resource discovery
|
||||
|
||||
**This is a `Subscription` operation (ADR-049), not a polled `Query`.** The
|
||||
call protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
||||
tested). The first consumer (the hub aggregating worker resources) needs
|
||||
live updates when workers connect/disconnect or containers start/stop.
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "channel/resources/subscribe",
|
||||
"input": {}
|
||||
}
|
||||
```
|
||||
|
||||
The responder registers a `StreamingHandler` that emits a `ResponseEnvelope`
|
||||
whenever the resource set changes. Each event:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": {
|
||||
"resources": [
|
||||
{
|
||||
"alpn": "alknet/tty",
|
||||
"backends": ["docker", "local"],
|
||||
"access": { "required_scopes": ["tty:open"] }
|
||||
},
|
||||
{
|
||||
"alpn": "alknet/tunnel",
|
||||
"targets": ["container:*", "service:postgres"],
|
||||
"access": { "required_scopes_any": ["tunnel:open", "admin"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| field | type | meaning |
|
||||
|-------|------|---------|
|
||||
| `alpn` | string | The ALPN this side accepts `channel/open` for. |
|
||||
| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available. The channels layer doesn't interpret these. |
|
||||
| `access` | object | A preview of the `AccessControl` that `channel/open` will check. Advisory — lets the initiator fail fast. The real check happens on `channel/open`. |
|
||||
|
||||
The stream emits an initial snapshot immediately, then subsequent events on
|
||||
any change. The stream is long-lived; the subscriber cancels by dropping the
|
||||
subscription (ADR-016 abort cascade applies).
|
||||
|
||||
A `channel/resources` (non-subscribe, `Query`) operation is NOT provided.
|
||||
The subscription's initial snapshot serves the poll use case (subscribe,
|
||||
read the first event, cancel). Providing both would be redundant and would
|
||||
pressure consumers toward the stale-poll path.
|
||||
|
||||
## Direction semantics (OQ-CH-09 — pinned)
|
||||
|
||||
Channel open is **bidirectional** — either side can initiate. The
|
||||
`direction` field determines who is the ALPN-server (allocates the handler,
|
||||
writes the negotiation response) vs the ALPN-client (writes the first
|
||||
request).
|
||||
|
||||
| `direction` | Initiator role | Responder role | Who writes first |
|
||||
|-------------|----------------|----------------|-------------------|
|
||||
| `initiator-to-responder` | ALPN-client | ALPN-server | Initiator writes first (the request data); responder's handler is the server side. The common case: "open me a TTY on your docker container." |
|
||||
| `responder-to-initiator` | ALPN-server | ALPN-client | Responder writes first (the negotiation response); initiator's handler is the client side. The "worker exposes, hub consumes" case: the worker initiates the open to make itself available; the hub is the client. |
|
||||
|
||||
**The channels layer does not enforce write order.** Write order is
|
||||
ALPN-specific, determined by which side is the ALPN-server. The channels
|
||||
layer routes chunks; the handlers negotiate who writes first via their
|
||||
ALPN's `params` contract.
|
||||
|
||||
**`channel_id` allocation is always by the responder** (DP-1), regardless of
|
||||
`direction`. The responder is the side that receives the `channel/open` call
|
||||
operation; it allocates the ID and returns it. In the `responder-to-
|
||||
initiator` case, the initiator (worker) sends the `channel/open`, so the
|
||||
responder (hub) allocates the ID — even though the worker is the ALPN-server
|
||||
for the channel's data. This keeps ID allocation in one place and avoids the
|
||||
collision-prone client-assigned alternative.
|
||||
|
||||
## Control-message division (DP-4 — pinned)
|
||||
|
||||
| Control path | When | Examples |
|
||||
|--------------|------|----------|
|
||||
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
|
||||
| `stream_type 3` chunks on the data channel | Control that MUST be ordered relative to data | EOF before exit, flush before close |
|
||||
|
||||
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
|
||||
example of data-ordered control — it rides on `stream_type 3` because it
|
||||
must arrive after the last stdin chunk, guaranteed by chunk ordering within
|
||||
`(channel_id, stream_type)`, not by a call-protocol round-trip. The
|
||||
`channel/close` operation that follows is on channel 0 and is ordered after
|
||||
the data pump completes (REQ-CH-06).
|
||||
|
||||
## ACL flow (end-to-end)
|
||||
|
||||
A browser opening a TTY channel to a spoke through a hub (ADR-079):
|
||||
|
||||
1. Browser's channel 0 → hub's channel 0: `channel/open`
|
||||
`{ alpn: "alknet/tty", params: { backend: "docker", cmd: ["bash"], container: "abc123" } }`.
|
||||
The browser's identity is a bearer token (ADR-034).
|
||||
2. Hub's `CallAdapter` runs `AccessControl::check` on `channel/open` with
|
||||
the browser's identity. If denied → `channel:forbidden`.
|
||||
3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for` handler
|
||||
constructs a `call.requested` with the hub as caller and the browser as
|
||||
`forwarded_for` (ADR-032 §3). The spoke receives `channel/open` with
|
||||
`caller = hub`, `forwarded_for = browser`.
|
||||
4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as caller
|
||||
(the spoke authorizes the hub — ADR-050). The spoke's ownership store
|
||||
verifies the hub (or the `forwarded_for` browser, per policy) owns
|
||||
`container:abc123`.
|
||||
5. Spoke allocates the channel via `TtyAdapter` / `DockerTtyBackend`,
|
||||
returns `channel_id`.
|
||||
6. Hub opens a matching channel on the browser's side and bridges them
|
||||
(byte-forward with `channel_id` rewrite — ADR-079).
|
||||
|
||||
The hub ran **zero** protocol-specific auth. It ran `channel/open`'s
|
||||
`AccessControl::check` (call-protocol machinery) and forwarded. The channels
|
||||
layer inherited the auth model by being a call-protocol operation.
|
||||
|
||||
## Hub relay contract (ADR-079 — summary)
|
||||
|
||||
The hub **translates**, not transparently forwards:
|
||||
|
||||
1. **Call-protocol layer (channel 0): translate.** The hub terminates
|
||||
channel 0 on both legs. `channel/open` from the browser → hub's
|
||||
`AccessControl::check` → hub re-issues `channel/open` on the spoke leg
|
||||
with `forwarded_for` → spoke returns its `channel_id` → hub maps
|
||||
browser-id ↔ spoke-id.
|
||||
2. **Data-channel layer: byte-forward with `channel_id` rewrite.** The
|
||||
relay reads chunks for `browser_id`, rewrites the `channel_id` field to
|
||||
`spoke_id`, writes onto the spoke's channels connection — and vice versa.
|
||||
The relay does not parse the payload.
|
||||
|
||||
`channel/control` operations on channel 0 carry `channel_id` in their JSON
|
||||
payload; the hub's `CallAdapter` translates these too (rewrites
|
||||
`channel_id` in the payload). The relay does not touch `channel/control` —
|
||||
it's a call operation, translated, not byte-forwarded.
|
||||
|
||||
The hub never runs a handler for `alknet/tty`, `alknet/ssh`, or
|
||||
`alknet/tunnel`. It runs `alknet/channels` (the relay) and `alknet/call`
|
||||
(for its own hub-level operations + translation).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
||||
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens |
|
||||
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-073: channel lifecycle operations (the decision)
|
||||
- ADR-079: hub relay (the translate contract)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
|
||||
Negotiation, §ACL and Security Model
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# channels-adapter.md — ChannelsAdapter and ChannelManager
|
||||
|
||||
The two internal components of the channels crate: the read/demux half
|
||||
(`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`).
|
||||
ADR-075 is the decision; this doc specifies the contracts and the demux/mux
|
||||
invariants.
|
||||
|
||||
## The split
|
||||
|
||||
| Component | Role | What it knows |
|
||||
|-----------|------|---------------|
|
||||
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 9-byte chunk headers off the transport and routes to `ChannelManager` | The transport stream; the `ChannelManager` handle. ALPN-blind. |
|
||||
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`, `OperationRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over. | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). |
|
||||
|
||||
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
|
||||
pattern, generalized to N channels: the adapter drives N channels, and
|
||||
channel 0 is special only in that it's pre-allocated.
|
||||
|
||||
## `ChannelsAdapter::handle`
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl ProtocolHandler for ChannelsAdapter {
|
||||
fn alpn(&self) -> &'static [u8] { b"alknet/channels" }
|
||||
|
||||
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
// 1. One bidi stream carries all channels.
|
||||
let (send, recv) = connection.accept_bi().await?;
|
||||
|
||||
// 2. Channel 0 is pre-negotiated as alknet/call (ADR-072).
|
||||
// Construct reassembly buffers, wrap as a Connection via
|
||||
// from_source(ChannelBidiStreamSource), hand to the CallAdapter.
|
||||
self.manager.preinstall_channel_0(send, recv, auth).await?;
|
||||
|
||||
// 3. Run the demux loop.
|
||||
self.manager.run_demux_loop(recv).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `preinstall_channel_0`
|
||||
|
||||
The only special case: constructs the reassembly buffers for `channel_id =
|
||||
0`, wraps them as a `Connection` (via `Connection::from_source` with a
|
||||
`ChannelBidiStreamSource` — ADR-070/074), and hands that `Connection` to
|
||||
the `CallAdapter` — exactly as if `alknet/call` had been the top-level
|
||||
ALPN. The `CallAdapter` is looked up in the same `HandlerRegistry` as every
|
||||
other ALPN. The `CallAdapter` is none the wiser: it calls `accept_bi()`,
|
||||
gets one bidi stream (the channel-0 reassembled stream), and runs its
|
||||
dispatch loop. `EventEnvelope` frames ride on `stream_type = 0` of channel 0.
|
||||
|
||||
### `run_demux_loop`
|
||||
|
||||
Reads 9-byte headers off the transport, looks up `channel_id` in the
|
||||
`ChannelManager`'s `channels` map, and pushes the payload into the right
|
||||
`ReassemblyBuffer` for `(channel_id, stream_type)`. If the buffer is full
|
||||
(bounded-buffer backpressure, ADR-076), the loop stops reading that
|
||||
channel's chunks until the consumer drains — other channels keep flowing.
|
||||
|
||||
## `ChannelManager`
|
||||
|
||||
```rust
|
||||
pub struct ChannelManager {
|
||||
channels: Mutex<HashMap<u32, ChannelState>>,
|
||||
handlers: Arc<HandlerRegistry>,
|
||||
call_ops: Arc<OperationRegistry>,
|
||||
next_id: AtomicU32, // monotonic; wraps at u32::MAX
|
||||
buffer_cap: usize, // default 1 MiB (ADR-076)
|
||||
max_channels: usize, // default 256 (ADR-076)
|
||||
}
|
||||
|
||||
struct ChannelState {
|
||||
alpn: String,
|
||||
streams: HashMap<u8, ReassemblyBuffer>,
|
||||
handler_task: JoinHandle<()>,
|
||||
stream_types: Vec<u8>,
|
||||
}
|
||||
```
|
||||
|
||||
`ChannelManager` is `Clone` (cheap — `Arc` internally) so the
|
||||
`ChannelsAdapter`, the `channel/open` operation handler, and relay logic can
|
||||
all hold a handle.
|
||||
|
||||
> **Type-name convention:** `ChannelManager`, `ChannelsAdapter`,
|
||||
> `ChannelBidiStreamSource`, `ChannelSubStreams`, and `ChannelClient` are
|
||||
> the public API surface (contract). `ReassemblyBuffer`, `Demux`,
|
||||
> `MuxHandle`/`MuxRunner`, `MpscSendStream`/`MpscRecvStream`, and
|
||||
> `ChannelOperations` are illustrative internal type names — the channels
|
||||
> crate's implementation may name them differently. The contracts are the
|
||||
> invariants (REQ-CH-01..04, 06) and the public API; the internal names are
|
||||
> not contractual.
|
||||
|
||||
### `ChannelManager` is ALPN-blind and auth-blind
|
||||
|
||||
The `ChannelManager` deliberately does **not** hold:
|
||||
|
||||
- **No `ProtocolHandler` implementations.** It holds a `HandlerRegistry`
|
||||
reference for ALPN lookup, but it doesn't *be* a handler. Handlers live in
|
||||
their crates and register on the same registry.
|
||||
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
|
||||
SSH frames, or tunnel target strings. It hands `params` JSON to the
|
||||
handler and gets back a handler task; it hands `stream_type 3` JSON to the
|
||||
handler's control handle.
|
||||
- **No auth state.** Auth lives in the `OperationContext` that the call
|
||||
protocol passes to `channel/open`. The `ChannelManager` doesn't check
|
||||
scopes or ownership — that's `AccessControl::check` in
|
||||
`OperationRegistry::invoke`, run before the `channel/open` handler.
|
||||
- **No transport coupling.** It talks to the transport only through the
|
||||
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of
|
||||
which use `AsyncRead + AsyncWrite`.
|
||||
|
||||
This is what makes the channels layer WASM-compatible and transport-agnostic
|
||||
— the `ChannelManager` is pure byte routing with no platform or protocol
|
||||
dependencies.
|
||||
|
||||
## The `channel/open` handler
|
||||
|
||||
The `channel/open` (and `channel/close`, `channel/control`,
|
||||
`channel/resources/subscribe`) operations are registered on the call
|
||||
protocol's `OperationRegistry` at assembly time:
|
||||
|
||||
```rust
|
||||
let channel_ops = ChannelOperations::new(manager.clone());
|
||||
channel_ops.register_on(&mut call_registry)?;
|
||||
```
|
||||
|
||||
The `channel/open` handler (ADR-073):
|
||||
1. ACL is already checked by `OperationRegistry::invoke` before this handler
|
||||
runs.
|
||||
2. Looks up the ALPN in `HandlerRegistry` → `channel:unknown_alpn` if
|
||||
missing.
|
||||
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
|
||||
server-assigned).
|
||||
4. Constructs the `ChannelBidiStreamSource` (ADR-074) for the negotiated
|
||||
`stream_types`.
|
||||
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
|
||||
Identical to what `TtyAdapter::handle` does today, but on a
|
||||
channels-backed `Connection`.
|
||||
6. Records the `ChannelState`.
|
||||
7. Returns the `channel_id`.
|
||||
|
||||
## Demux invariants (REQ-CH-02, 04)
|
||||
|
||||
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
|
||||
|
||||
On transport EOF, `run_demux_loop` clears the `channels` map, dropping all
|
||||
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
|
||||
EOF even without an explicit zero-length sentinel on the wire. Without this,
|
||||
`read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a
|
||||
sender that never drops. This is a teardown invariant of the
|
||||
`ChannelsAdapter::handle` contract.
|
||||
|
||||
### REQ-CH-04: lenient unknown-`channel_id` handling
|
||||
|
||||
A chunk with an unallocated `channel_id` (or `stream_type`) is dropped with
|
||||
a debug log and an error counter (exposed via `Demux::stats()`), and the
|
||||
demux continues. This matches SSH's behavior and survives transient
|
||||
mis-ordering during teardown. Validated by the POC
|
||||
(`demux_unknown_channel_drops_lenient`).
|
||||
|
||||
## Mux invariants (REQ-CH-03)
|
||||
|
||||
### REQ-CH-03: dynamic registration (handle/runner split)
|
||||
|
||||
The mux frames per-channel bytes back onto the transport. The POC surfaced
|
||||
that `Mux::run(self, transport)` (consume, run pre-registered pumps) does
|
||||
not compose with the dynamic `channel/open` model — channels are opened
|
||||
after the run loop starts.
|
||||
|
||||
The mux is split into:
|
||||
|
||||
- **`MuxHandle`** — clone-able, `register(channel_id, stream_type) ->
|
||||
Sender<Bytes>` callable at any time after the runner starts.
|
||||
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
|
||||
and per-channel write pumps.
|
||||
|
||||
The runner's `select!` loop exits when all `MuxHandle` clones drop (the
|
||||
`new_pumps` sender closes) — the natural shutdown signal. This matches the
|
||||
dynamic `channel/open` model.
|
||||
|
||||
## The two-pump pattern (ADR-078 — documented here for handler authors)
|
||||
|
||||
Handlers with a two-pump shape (two `tokio::io::copy` pumps, one per
|
||||
direction — tunnel, SSH `direct-tcpip`) MUST shut down the opposite sink
|
||||
when one pump completes. `tokio::try_join!` alone deadlocks: each pump
|
||||
waits for the other's EOF, which only comes after the opposite pump shuts
|
||||
down its sink.
|
||||
|
||||
```rust
|
||||
let c2t = async {
|
||||
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
||||
tcp_write.shutdown().await.ok(); // shut down the peer's sink
|
||||
Ok::<_, std::io::Error>(())
|
||||
};
|
||||
let t2c = async {
|
||||
tokio::io::copy(&mut tcp_read, &mut send).await?;
|
||||
send.shutdown().await.ok(); // shut down the peer's sink (emits sentinel — REQ-CH-01)
|
||||
Ok::<_, std::io::Error>(())
|
||||
};
|
||||
tokio::try_join!(c2t, t2c)?;
|
||||
```
|
||||
|
||||
The three-pump pattern (TTY's `pump_session`, coordinating via the
|
||||
`exit_code` future) does not have this deadlock — the `exit_code` future is
|
||||
the third signal. The two-pump pattern is documented in ADR-078; the
|
||||
shutdown-on-completion contract is a handler-level concern, not a
|
||||
channels-layer one.
|
||||
|
||||
## The hub relay interface
|
||||
|
||||
The hub relay (ADR-079) uses the `ChannelManager`'s interface to bridge two
|
||||
channels connections:
|
||||
|
||||
```rust
|
||||
// For channel_id=7 on browser side, channel_id=12 on spoke side:
|
||||
tokio::spawn(async move {
|
||||
let (b_send, b_recv) = browser_mgr.open_channel_stream(7, stream_type).await;
|
||||
let (s_send, s_recv) = spoke_mgr.open_channel_stream(12, stream_type).await;
|
||||
tokio::join!(
|
||||
pump(b_recv, s_send), // browser → spoke (with channel_id rewrite)
|
||||
pump(s_recv, b_send), // spoke → browser (with channel_id rewrite)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
The relay reads opaque bytes off one `ChannelManager`'s reassembled stream
|
||||
and writes them onto the other's write-half, which re-chunks them with the
|
||||
other leg's `channel_id`. The relay does not parse the bytes — it doesn't
|
||||
know if they're TTY chunks, SSH frames, or tunnel data. The hub translates
|
||||
`channel/open` on channel 0 (re-issues on the spoke leg with
|
||||
`forwarded_for`); data channels are byte-forwarded with `channel_id`
|
||||
rewrite. See ADR-079 for the full relay contract.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-075: ChannelsAdapter and ChannelManager (the decision)
|
||||
- ADR-072: channel 0 pre-negotiated (the `preinstall_channel_0` step)
|
||||
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
|
||||
- ADR-074: ChannelBidiStreamSource (what the manager constructs per channel)
|
||||
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
|
||||
(REQ-CH-01..04, the two-pump deadlock)
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# channels-connection.md — ChannelBidiStreamSource and Sub-Stream Access
|
||||
|
||||
How a reassembled channel is presented to its handler as a `Connection`.
|
||||
ADR-074 is the decision; this doc specifies the API shape and the two
|
||||
access paths.
|
||||
|
||||
## What
|
||||
|
||||
Each channel is reassembled into a set of `AsyncRead + AsyncWrite` handles
|
||||
—one per active `stream_type` (declared at `channel/open` time, ADR-073).
|
||||
These handles are wrapped as a `ChannelBidiStreamSource` that implements
|
||||
`alknet-core`'s `BidiStreamSource` trait (ADR-070), and a `Connection` is
|
||||
constructed from it via `Connection::from_source(source, alpn)`.
|
||||
|
||||
The handler receives a `Connection` and can either:
|
||||
1. Call `accept_bi()` once to get the main data pair (`stream_type` 0/1) —
|
||||
the generic handler path (tunnel, SSH).
|
||||
2. Call `into_sub_streams()` on the `ChannelBidiStreamSource` to get all
|
||||
active sub-streams as typed `(stream_type, SendStream, RecvStream)`
|
||||
tuples — the typed handler path (TTY, which needs stdin/stdout/stderr/
|
||||
control).
|
||||
|
||||
Both paths operate on the same reassembly buffers; the difference is how the
|
||||
handler accesses them.
|
||||
|
||||
## `ChannelBidiStreamSource`
|
||||
|
||||
```rust
|
||||
// In alknet-channels:
|
||||
|
||||
pub struct ChannelBidiStreamSource {
|
||||
// The reassembly buffers for this channel's active stream_types,
|
||||
// plus the mux handle for writing back onto the transport.
|
||||
// Constructed by ChannelManager::build_channel_connection (ADR-075).
|
||||
...
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BidiStreamSource for ChannelBidiStreamSource {
|
||||
async fn accept_bi(&self)
|
||||
-> Result<(SendStream, RecvStream), StreamError>
|
||||
{
|
||||
// Yields the (stream_type 0, stream_type 1) pair on first call,
|
||||
// ConnectionClosed on subsequent calls. Yield-once per channel,
|
||||
// matching the POC's validated shape.
|
||||
}
|
||||
|
||||
async fn open_bi(&self)
|
||||
-> Result<(SendStream, RecvStream), StreamError>
|
||||
{
|
||||
// StreamClosed — a single channel cannot open new application
|
||||
// streams (same as ADR-065's Stream backend). Additional sub-streams
|
||||
// (stream_type 2, 3) are accessed via into_sub_streams(), not
|
||||
// open_bi().
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> Option<SocketAddr> { ... }
|
||||
|
||||
fn close(&self, _code: u32, _reason: &str) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
One `ChannelBidiStreamSource` instance represents **one channel** (not the
|
||||
whole channels connection). The `ChannelManager` (ADR-075) constructs one
|
||||
per channel at `channel/open` time and wraps it in a `Connection` via
|
||||
`from_source`.
|
||||
|
||||
## The generic path: `accept_bi()`
|
||||
|
||||
For handlers that only need the main data pair (`stream_type` 0 = data-in,
|
||||
`stream_type` 1 = data-out):
|
||||
|
||||
```rust
|
||||
// Tunnel handler — ~15 lines, zero channels-layer awareness
|
||||
async fn handle(&self, connection: Connection, _auth: &AuthContext)
|
||||
-> Result<(), HandlerError>
|
||||
{
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
let mut tcp = TcpStream::connect(target).await?;
|
||||
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||
|
||||
// Two-pump with shutdown-on-completion (ADR-078)
|
||||
let c2t = async {
|
||||
tokio::io::copy(&mut recv, &mut tcp_write).await?;
|
||||
tcp_write.shutdown().await.ok();
|
||||
Ok::<_, std::io::Error>(())
|
||||
};
|
||||
let t2c = async {
|
||||
tokio::io::copy(&mut tcp_read, &mut send).await?;
|
||||
send.shutdown().await.ok(); // emits zero-length sentinel (REQ-CH-01)
|
||||
Ok::<_, std::io::Error>(())
|
||||
};
|
||||
tokio::try_join!(c2t, t2c)?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
The handler calls `accept_bi()` once, gets the `(SendStream, RecvStream)`
|
||||
pair, and pumps. It does not know it's inside a channels connection — the
|
||||
`Connection` looks like any other. This is the path the POC's `EchoHandler`
|
||||
and `TunnelHandler` validated.
|
||||
|
||||
`accept_bi()` is yield-once: the first call returns the 0/1 pair; subsequent
|
||||
calls return `ConnectionClosed`. This matches the POC's validated shape and
|
||||
the `StreamBidiStreamSource` yield-once contract (ADR-070).
|
||||
|
||||
## The typed path: `into_sub_streams()`
|
||||
|
||||
For handlers that need `stream_type` 2 (stderr) or 3 (control) in addition
|
||||
to 0/1:
|
||||
|
||||
```rust
|
||||
// In alknet-channels:
|
||||
pub struct ChannelSubStreams {
|
||||
/// (stream_type, send_half, recv_half) for each active stream_type.
|
||||
pub streams: Vec<(u8, SendStream, RecvStream)>,
|
||||
}
|
||||
|
||||
impl ChannelSubStreams {
|
||||
pub fn get(&self, stream_type: u8) -> Option<(&SendStream, &RecvStream)> { ... }
|
||||
}
|
||||
|
||||
impl ChannelBidiStreamSource {
|
||||
/// Returns all active sub-streams, keyed by stream_type. Consumes the
|
||||
/// source — call this instead of accept_bi() if the handler needs
|
||||
/// direct access to stream_types 2/3.
|
||||
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The handler crate destructures `ChannelSubStreams` into its typed names:
|
||||
|
||||
```rust
|
||||
// In alknet-tty (inside-channels mode, ADR-077):
|
||||
let sub = channel_source.into_sub_streams();
|
||||
let stdin = sub.get(0).unwrap(); // SendStream
|
||||
let stdout = sub.get(1).unwrap(); // RecvStream
|
||||
let stderr = sub.get(2); // Option<&RecvStream>
|
||||
let control = sub.get(3).unwrap(); // RecvStream (JSON control)
|
||||
```
|
||||
|
||||
**The channels crate does not know about TTY's `stream_type` semantics.**
|
||||
It exposes `(stream_type, SendStream, RecvStream)` tuples. The handler crate
|
||||
maps stream_types to its typed names. This preserves ADR-003's
|
||||
no-handler-depends-on-another-handler rule and keeps the channels crate
|
||||
ALPN-blind.
|
||||
|
||||
`into_sub_streams()` consumes the source — a handler can't call both
|
||||
`accept_bi()` and `into_sub_streams()`. This is by design: the sub-streams
|
||||
include the 0/1 pair, so `into_sub_streams()` is the superset.
|
||||
|
||||
## Choosing the path
|
||||
|
||||
| Handler shape | Path | Examples |
|
||||
|---------------|------|---------|
|
||||
| Main data pair only (0/1) | `accept_bi()` | tunnel, SSH (SSH multiplexes internally) |
|
||||
| Needs stderr/control (2/3) | `into_sub_streams()` | TTY (stdin/stdout/stderr/control) |
|
||||
|
||||
The handler chooses based on its ALPN's `stream_type` set (declared at
|
||||
`channel/open` time). The `ChannelsAdapter` (ADR-075) passes the handler a
|
||||
`Connection` (via `from_source`); handlers that need sub-streams access the
|
||||
`ChannelBidiStreamSource` via a channels-crate extension trait or downcast
|
||||
(exact ergonomics are an implementation detail for the channels crate; the
|
||||
contract is that both paths are available and the handler crate chooses).
|
||||
|
||||
## Recursive composition
|
||||
|
||||
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and `Connection::
|
||||
from_source` wraps it. A handler that is itself `alknet/channels` can open a
|
||||
sub-channels connection on a data channel — `alknet/channels` inside
|
||||
`alknet/channels`. This is allowed (the `Connection` abstraction permits it)
|
||||
but not a feature designed for. The primary use case is one level of
|
||||
multiplexing. Recursive composition is a natural consequence of the
|
||||
abstraction, not a goal.
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- **`ProtocolHandler` trait** (ADR-002) — handlers still receive a
|
||||
`Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is
|
||||
internal to the channels crate; handlers see a `Connection`.
|
||||
- **`SendStream` / `RecvStream`** (ADR-007) — unchanged. They continue to
|
||||
wrap their internal sources. `ChannelBidiStreamSource` constructs them via
|
||||
the existing `from_stream` constructors, backed by mpsc reassembly
|
||||
buffers.
|
||||
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
|
||||
the same registry as top-level connections.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi`; `into_sub_streams()` accessor |
|
||||
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
|
||||
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-074: ChannelConnection (the decision)
|
||||
- ADR-070: BidiStreamSource trait
|
||||
- ADR-065: `Connection::from_stream`
|
||||
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
|
||||
yield-once `Connection::from_stream` validation)
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# channels-wire.md — The 9-Byte Chunk Format
|
||||
|
||||
The wire format for `alknet/channels`: a 9-byte chunk header that
|
||||
multiplexes N logical channels, each with up to 256 sub-stream types, over
|
||||
a single ordered, reliable bidirectional transport stream. ADR-071 is the
|
||||
decision; this doc specifies the format and the wire-level invariants.
|
||||
|
||||
## Chunk header
|
||||
|
||||
```
|
||||
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
|
||||
```
|
||||
|
||||
9 bytes of header, followed by `length` bytes of payload.
|
||||
|
||||
| field | offset | width | meaning |
|
||||
|-------|--------|-------|---------|
|
||||
| `channel_id` | 0 | 4 (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` (ADR-073). |
|
||||
| `stream_type` | 4 | 1 | The sub-stream within the channel. See "Stream types" below. |
|
||||
| `length` | 5 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
|
||||
|
||||
This is a 4-byte extension of alknet-tty's 5-byte format (ADR-052): the
|
||||
`channel_id` prefix is added; `stream_type` and `length` are identical. The
|
||||
`ChunkReader` / `ChunkWriter` pattern, the framing-disambiguation trick,
|
||||
and the zero-length sentinel convention all carry forward from TTY.
|
||||
|
||||
## `MAX_CHUNK_LEN`
|
||||
|
||||
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
|
||||
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
|
||||
stream — the demux drops the chunk and continues. The header is always
|
||||
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
|
||||
header.
|
||||
|
||||
## Stream types
|
||||
|
||||
| stream_type | direction | purpose |
|
||||
|-------------|-----------|---------|
|
||||
| 0 | write half | data flowing in (stdin equivalent) |
|
||||
| 1 | read half | data flowing out (stdout equivalent) |
|
||||
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) |
|
||||
| 3 | bidirectional | control messages (ALPN-specific JSON) |
|
||||
| 4-255 | reserved | future sub-stream types |
|
||||
|
||||
Not all channels use all sub-streams. The active set is declared at
|
||||
`channel/open` time (ADR-073 `stream_types` field) and fixed for the
|
||||
channel's lifetime.
|
||||
|
||||
| Channel ALPN | Active stream_types |
|
||||
|--------------|---------------------|
|
||||
| `alknet/call` (channel 0) | 0 (EventEnvelope frames) |
|
||||
| `alknet/tty` | 0, 1, 2, 3 (stdin, stdout, stderr, control) |
|
||||
| `alknet/tunnel` | 0, 1 (data-in, data-out) |
|
||||
| `alknet/ssh` | 0, 1 (data-in, data-out — SSH multiplexes internally) |
|
||||
|
||||
## Channel 0 — pre-negotiated `alknet/call`
|
||||
|
||||
Channel 0 is not a special "control plane" with its own framing. It is
|
||||
`alknet/call` pre-negotiated (ADR-072): both sides know `channel_id = 0` is
|
||||
routed to the `CallAdapter` without an explicit `channel/open` exchange.
|
||||
|
||||
Channel 0 uses only `stream_type` 0 for `EventEnvelope` frames (JSON,
|
||||
length-prefixed — the call protocol wire format, ADR-064). `stream_type`
|
||||
1-255 on channel 0 are reserved for future call-protocol sub-streams.
|
||||
|
||||
Channel 0's chunks have `channel_id = 0` in the header — same format as
|
||||
every other channel. Disambiguation between channel 0 and data channels is
|
||||
by `channel_id`, not by a special first-byte trick.
|
||||
|
||||
## Framing disambiguation (from ADR-052 §5)
|
||||
|
||||
The 9-byte header is always exactly 9 bytes. `length` is bounded by
|
||||
`MAX_CHUNK_LEN`. The demux reads 9 bytes, parses the header, reads
|
||||
`length` bytes of payload, and routes. If a chunk is dropped (e.g.,
|
||||
`ChunkTooLarge`), the demux resyncs by reading the next 9-byte header —
|
||||
the format is self-synchronizing.
|
||||
|
||||
Within a channel, `stream_type` 0 (stdin) from the server is invalid, so
|
||||
`0x00` as the first byte of a chunk payload from the server is unambiguous
|
||||
(carried from ADR-052 §5).
|
||||
|
||||
## Zero-length sentinel = EOF
|
||||
|
||||
A zero-length chunk (`length = 0`) is delivered as an empty `Bytes`, which
|
||||
the reassembled stream interprets as EOF. This is the clean-shutdown signal
|
||||
for a `(channel_id, stream_type)` pair — same convention as TTY (ADR-052
|
||||
§Sentinels).
|
||||
|
||||
The sentinel is emitted by the write side's `AsyncWrite::shutdown` (see
|
||||
REQ-CH-01 below) and consumed by the read side's `AsyncRead::poll_read` as
|
||||
EOF.
|
||||
|
||||
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
|
||||
|
||||
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
|
||||
Surfaced) surfaced invariants that hang channels silently if underspecified.
|
||||
These are **contracts**, not implementation details — both sides must agree.
|
||||
|
||||
### REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel
|
||||
|
||||
The reassembled stream's write half (`MpscSendStream` or equivalent) MUST
|
||||
send an empty `Bytes` (the EOF sentinel) before dropping the sender on
|
||||
`AsyncWrite::shutdown`. Without this, the demux never sees EOF on the
|
||||
channel's `stream_type`, and `tokio::io::copy` in the handler never
|
||||
completes — the session hangs.
|
||||
|
||||
The TTY crate's `pump_session` emits the zero-length stdout sentinel
|
||||
explicitly via `Chunk::stdout(Bytes::new())`; the channels layer's
|
||||
per-channel write pump does NOT forward a sentinel on sender-drop, so the
|
||||
send adapter must. Both sides must agree on this convention, or channels
|
||||
hang on clean shutdown.
|
||||
|
||||
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
|
||||
|
||||
The demux loop MUST clear its `channels` map on transport EOF, dropping all
|
||||
`ReassemblyBuffer` senders. Every handler's reassembled `RecvStream` sees
|
||||
EOF even without an explicit zero-length sentinel arriving on the wire.
|
||||
|
||||
Without this, `read_to_end` / `tokio::io::copy` in handlers hangs forever
|
||||
waiting for a sender that never drops because the demux task is holding the
|
||||
map. This is a teardown invariant of the `ChannelsAdapter::handle` contract.
|
||||
|
||||
### REQ-CH-04: lenient unknown-`channel_id` handling with error counter
|
||||
|
||||
A chunk with an unallocated `channel_id` (or `stream_type` on an allocated
|
||||
channel) is dropped with a debug log and an error counter (exposed via
|
||||
`Demux::stats()`), and the demux continues. This matches SSH's behavior and
|
||||
survives transient mis-ordering during teardown (a chunk for a channel that
|
||||
was just closed may arrive after the close is processed).
|
||||
|
||||
The alternative (strict — close the transport on unknown `channel_id`) is
|
||||
fragile during teardown and catches bugs at the cost of reliability. The
|
||||
lenient approach with an error counter provides observability without
|
||||
fragility.
|
||||
|
||||
### REQ-CH-05: bounded-buffer backpressure does not deadlock
|
||||
|
||||
Each `(channel_id, stream_type)` has an independent bounded `mpsc` buffer
|
||||
(default 1 MiB — ADR-076). A slow reader on one channel does not block
|
||||
another channel's reads — the demux's per-chunk route awaits the matching
|
||||
sender without holding a global lock.
|
||||
|
||||
The 1 MiB `tunnel_large_payload` POC test exercised this end-to-end: a
|
||||
channel writer faster than the TCP echo server consumer, with no deadlock
|
||||
and no cross-channel blocking. This invariant must hold for all transport
|
||||
shapes — the bounded-buffer approach is the decision (ADR-076).
|
||||
|
||||
## Sync core / async shell split
|
||||
|
||||
The wire format's core is pure byte manipulation:
|
||||
|
||||
```rust
|
||||
// wire.rs — sync core, no async, no platform deps, WASM-clean
|
||||
|
||||
const CHUNK_HEADER_LEN: usize = 9;
|
||||
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||
|
||||
pub struct ChunkHeader {
|
||||
pub channel_id: u32,
|
||||
pub stream_type: u8,
|
||||
pub length: u32,
|
||||
}
|
||||
|
||||
pub fn parse_header(buf: &[u8; 9]) -> Result<ChunkHeader, ChunkError> { ... }
|
||||
pub fn write_header(channel_id: u32, stream_type: u8, length: u32, out: &mut [u8; 9]) { ... }
|
||||
```
|
||||
|
||||
The async shell (demux/mux — see [channels-adapter.md](channels-adapter.md))
|
||||
wraps this core with `read_exact` / `write_all` on the transport and `mpsc`
|
||||
routing. The split keeps the WASM-compatible core separate from the
|
||||
tokio-dependent shell. The POC validated the sync core compiles under
|
||||
`wasm32-unknown-unknown`.
|
||||
|
||||
## Channel lifecycle (summary)
|
||||
|
||||
| Phase | Mechanism | Reference |
|
||||
|-------|-----------|-----------|
|
||||
| Open | `channel/open` call operation on channel 0; responder allocates `channel_id`, returns it | ADR-073 |
|
||||
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees `AsyncRead + AsyncWrite` | this doc, [channels-connection.md](channels-connection.md) |
|
||||
| Control (data-ordered) | `stream_type 3` chunks on the data channel (JSON, in-order with data) | ADR-073 §DP-4 |
|
||||
| Control (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 |
|
||||
|
||||
### REQ-CH-06: exit-chunk-before-close ordering (generalizes ADR-055)
|
||||
|
||||
The channel's data chunks MUST be written and flushed before the
|
||||
`channel/close` operation is sent on channel 0. This is a wire-level
|
||||
invariant: the side closing must observe the data-channel pump complete
|
||||
before issuing the call operation.
|
||||
|
||||
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward:
|
||||
the exit control message on `stream_type 3` is the last data before
|
||||
`channel/close`. For tunnels it is the last data byte before close. The
|
||||
channels layer's close handler observes the pump completion; the call
|
||||
operation is issued after.
|
||||
|
||||
This invariant crosses two channels (the data channel and channel 0), so
|
||||
the channels layer owns the ordering guarantee — it is not a handler
|
||||
concern.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-071: channels wire format (the decision)
|
||||
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
|
||||
amended by ADR-077 — scoped to direct TTY)
|
||||
- ADR-072: channel 0 pre-negotiated
|
||||
- ADR-073: channel lifecycle operations
|
||||
- ADR-076: backpressure, channel limits, ID reuse
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
|
||||
Surfaced #4-#6 (REQ-CH-01, 02, 04)
|
||||
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-12
|
||||
---
|
||||
|
||||
# alknet-channels — Overview
|
||||
|
||||
## What
|
||||
|
||||
`alknet-channels` is a multiplexing proxy crate. It implements
|
||||
`ProtocolHandler` for the `alknet/channels` ALPN: it receives one
|
||||
bidirectional transport stream, reads 9-byte chunk headers, and routes each
|
||||
chunk's payload to the right logical channel. Each channel is reassembled
|
||||
into an `AsyncRead + AsyncWrite` pair and presented to its handler as a
|
||||
`Connection` — the handler doesn't know it's inside a channels connection.
|
||||
|
||||
Channel 0 is pre-negotiated as `alknet/call` (ADR-072). Every other channel
|
||||
is opened dynamically via `channel/open` on channel 0 (ADR-073) and routed
|
||||
through the same `HandlerRegistry` as top-level connections. The channels
|
||||
layer does no protocol work itself — it is a re-framing proxy that converts
|
||||
between "one transport stream carrying N channels" (the wire) and "N
|
||||
independent stream handles" (what handlers see).
|
||||
|
||||
## Why
|
||||
|
||||
### The problem: three multiplexing models that don't compose
|
||||
|
||||
Before channels, alknet had three multiplexing models:
|
||||
|
||||
| Model | Where | Mechanism |
|
||||
|-------|-------|-----------|
|
||||
| Connection-level | ALPN router | One ALPN per QUIC connection |
|
||||
| Stream-level | QUIC native | Many bidi streams per connection |
|
||||
| Sub-stream-level | TTY chunk format | 4 logical channels within one bidi stream |
|
||||
|
||||
A docker client needing both JSON call operations and raw TTY sessions
|
||||
required **two separate QUIC connections** with different ALPNs. The call
|
||||
protocol can't say "for this operation, open a TTY stream." The hub,
|
||||
bridging browsers and spokes over multiple transports, faced an
|
||||
O(protocols × transports × spokes) matrix of per-protocol framing parsers
|
||||
and per-ALPN connection management.
|
||||
|
||||
### The collapse: one multiplexing model, one connection per leg
|
||||
|
||||
With `alknet/channels`, one connection carries everything:
|
||||
|
||||
```
|
||||
Browser ──WebTransport──► Hub ──QUIC──► Spoke
|
||||
alknet/channels alknet/channels
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ ch0: call │ │ ch0: call │
|
||||
│ ch1: tty │ relay │ ch1: tty │
|
||||
│ ch2: ssh │ ◄─────► │ ch2: ssh │
|
||||
│ ch3: tunnel │ │ ch3: tunnel │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
The hub's relay is channel-by-channel byte forwarding (with `channel_id`
|
||||
rewrite — ADR-079), not per-protocol framing parsers. The hub's complexity
|
||||
collapses from O(protocols × transports × spokes) to O(channels).
|
||||
|
||||
The collapse is at three levels:
|
||||
|
||||
1. **One connection per leg, not one per protocol.** All needs (call, TTY,
|
||||
SSH, tunnel) ride as channels on one connection per leg.
|
||||
2. **One multiplexing model, not three.** Connection-level, stream-level,
|
||||
and sub-stream-level all become channels chunks.
|
||||
3. **The call protocol orchestrates from inside.** Channel 0 is
|
||||
`alknet/call` on both legs. The call protocol's `OperationRegistry`,
|
||||
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle
|
||||
with no new auth.
|
||||
|
||||
## Architecture
|
||||
|
||||
The crate has two internal components (ADR-075):
|
||||
|
||||
- **`ChannelsAdapter`** — implements `ProtocolHandler` for
|
||||
`alknet/channels`. Its `handle()` receives one `Connection`, reads 9-byte
|
||||
chunk headers, and routes chunks to the `ChannelManager`. The read/demux
|
||||
half.
|
||||
- **`ChannelManager`** — the shared state. Holds `channel_id →
|
||||
ChannelState`, the `HandlerRegistry` reference, and the
|
||||
`OperationRegistry` reference. The reassemble/allocate half. What the
|
||||
`channel/open` operation handler closes over.
|
||||
|
||||
Each channel is presented to its handler as a `Connection` constructed via
|
||||
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-070/074). The
|
||||
handler calls `accept_bi()` once (yield-once per channel) and drives its
|
||||
session — identical to how it works on a top-level QUIC connection.
|
||||
|
||||
See [channels-adapter.md](channels-adapter.md) for the full adapter/manager
|
||||
design.
|
||||
|
||||
## Crate dependencies
|
||||
|
||||
```
|
||||
alknet-channels
|
||||
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
|
||||
│ BidiStreamSource, SendStream, RecvStream, AuthContext)
|
||||
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
|
||||
│ make_streaming_handler, CallError, ResponseEnvelope)
|
||||
├── tokio (spawn, mpsc, io)
|
||||
├── bytes (Bytes for chunk payloads)
|
||||
├── async-trait
|
||||
├── thiserror
|
||||
└── tracing
|
||||
```
|
||||
|
||||
`alknet-channels` depends on `alknet-call` because channel lifecycle
|
||||
operations (`channel/open`, `channel/close`, `channel/control`,
|
||||
`channel/resources/subscribe`) are registered on the call protocol's
|
||||
`OperationRegistry` (ADR-073). This is the one handler-crate →
|
||||
`alknet-call` dependency; it is sound because channels *is* a call-protocol
|
||||
extension (channel lifecycle is call operations), not a peer handler.
|
||||
|
||||
The dependency direction is: handlers depend on `alknet-core`;
|
||||
`alknet-channels` depends on `alknet-core` and `alknet-call`; nothing
|
||||
depends on `alknet-channels` except the assembly layer and
|
||||
`alknet-tty`/`alknet-docker` behind their `channels`/`tty` feature gates.
|
||||
|
||||
## ALPN
|
||||
|
||||
`alknet/channels` — the ALPN the `ChannelsAdapter` registers on. One ALPN
|
||||
per channels connection; the connection carries N logical channels, each
|
||||
with its own ALPN (negotiated via `channel/open`).
|
||||
|
||||
## Transport agnosticism
|
||||
|
||||
The channels wire format works over any ordered, reliable bidirectional byte
|
||||
stream:
|
||||
|
||||
| Transport | How |
|
||||
|-----------|-----|
|
||||
| QUIC bidi stream | `alknet/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
|
||||
| TCP+TLS | `alknet/channels` ALPN on a TLS connection; the TCP stream carries all channels |
|
||||
| WebTransport | `alknet/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alknet/channels`) |
|
||||
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
|
||||
| Another channels connection | recursive composition (channel type `alknet/channels` inside `alknet/channels`) |
|
||||
|
||||
The same wire format, the same chunk reassembly, the same `Connection`
|
||||
abstraction. The transport is a parameter, not a design constraint.
|
||||
`Connection::from_stream` / `from_source` (ADR-065/070) handles the
|
||||
transport-agnostic `Connection` construction.
|
||||
|
||||
## WASM compatibility
|
||||
|
||||
The wire format's core is pure byte manipulation — `parse_header` /
|
||||
`write_header` are pure functions with no platform dependencies. The de-risk
|
||||
POC validated the sync core compiles under `wasm32-unknown-unknown`. The
|
||||
async shell (demux/mux) wraps this core with `read_exact`/`write_all` and
|
||||
`mpsc` routing.
|
||||
|
||||
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
|
||||
075) — pure byte routing with no platform or protocol dependencies. A WASM
|
||||
build can read chunks from a WebTransport `BiStream`, reassemble them, and
|
||||
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
|
||||
handlers themselves may or may not be WASM-compatible (russh's client is;
|
||||
`portable_pty` is not), but the channels layer is WASM-compatible by
|
||||
construction.
|
||||
|
||||
The async shell and `alknet-core` dep graph are not fully WASM-clean yet
|
||||
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
|
||||
not an architecture concern. The sync core's WASM compatibility is validated.
|
||||
|
||||
## Relationship to existing crates
|
||||
|
||||
### alknet-call
|
||||
|
||||
Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It
|
||||
runs on channel 0 exactly as on a top-level `alknet/call` connection. The
|
||||
`CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly
|
||||
and dispatches operations — it doesn't know it's inside channels.
|
||||
|
||||
What changes: the call protocol gains a new class of operations — channel
|
||||
lifecycle (ADR-073). These are registered on the `OperationRegistry` at
|
||||
assembly time and dispatched through the existing `OperationContext` /
|
||||
`AccessControl::check` path.
|
||||
|
||||
### alknet-tty
|
||||
|
||||
The TTY crate gains a `channels` feature (ADR-077) that enables
|
||||
inside-channels mode. In direct mode (`alknet/tty` ALPN on a top-level
|
||||
connection), the TTY adapter uses its own 5-byte wire format (ADR-052,
|
||||
unchanged). In channels mode (`channel/open` with ALPN `alknet/tty`), the
|
||||
adapter receives `ChannelSubStreams` (ADR-074) — four named
|
||||
`SendStream`/`RecvStream` pairs for stream_types 0-3 — and pumps without
|
||||
chunk parsing. The `TtyBackend` trait and `TtyHandle` are unchanged;
|
||||
backends don't know which mode the adapter is in.
|
||||
|
||||
### alknet-ssh (future)
|
||||
|
||||
SSH as a channel type: an `alknet/ssh` channel carries the SSH binary
|
||||
protocol over stream_types 0 and 1. The channels layer hands the
|
||||
reassembled stream to `SshAdapter`, which feeds it to russh. SSH as a
|
||||
channels transport: an SSH `direct-tcpip` channel could carry a channels
|
||||
connection (channels-over-SSH). The SSH crate doesn't need to know about
|
||||
channels — it implements `ProtocolHandler` for `alknet/ssh` and accepts a
|
||||
`Connection`.
|
||||
|
||||
### alknet-docker
|
||||
|
||||
Docker lifecycle operations are call operations on channel 0 (unchanged
|
||||
from ADR-058). Interactive exec/attach opens a TTY channel via
|
||||
`channel/open` with ALPN `alknet/tty` and backend `docker`. No separate
|
||||
`alknet/tty` connection needed — one `alknet/channels` connection handles
|
||||
both JSON operations and raw TTY sessions.
|
||||
|
||||
### alknet-hub
|
||||
|
||||
The hub is the primary consumer. With channels, the hub holds one channels
|
||||
connection per leg (browser↔hub, hub↔spoke) and relays channels between
|
||||
them. The hub translates `channel/open` on channel 0 (re-issues on the
|
||||
spoke leg with `forwarded_for` — ADR-079) and byte-forwards data channels
|
||||
with `channel_id` rewrite. The hub's complexity collapses from
|
||||
O(protocols × transports × spokes) to O(channels).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [071](../../decisions/071-channels-wire-format.md) | channels Wire Format | 9-byte chunk header; one-way door |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call`, no special control plane |
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; `into_sub_streams()` accessor |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The read/demux + reassemble/allocate split; REQ-CH-01..04 |
|
||||
| [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 |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
|
||||
| [080](../../decisions/080-channelclient.md) | ChannelClient | Client side; QUIC-only; `AlknetClient` deferred (OQ-55) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
Open questions are tracked in [open-questions.md](../../open-questions.md).
|
||||
Key questions affecting this crate:
|
||||
|
||||
- **OQ-55** (deferred(scope)): `AlknetClient` core extraction — blocked on
|
||||
a second *transport's* client, not a second client. `ChannelClient` is
|
||||
decided (ADR-080).
|
||||
- **OQ-56** (deferred(scope)): Full channel-level flow-control windowing —
|
||||
bounded-buffer is decided (ADR-076); full windowing is an extension
|
||||
blocked on a real HOL-blocking deployment observation.
|
||||
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
|
||||
the *contract* is decided (ADR-078); the *helper* is blocked on a second
|
||||
two-pump handler existing.
|
||||
Reference in new issue
Block a user