Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
626 lines
32 KiB
Markdown
626 lines
32 KiB
Markdown
# ADR-009: `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi`
|
|
|
|
## Status
|
|
|
|
Proposed (amends ADR-008's `BidiStreamSource::accept_bi` return type;
|
|
amends ADR-007's `from_stream` / `from_bidi` constructors; amends
|
|
ADR-038's `ChannelBidiStreamSource::accept_bi` return type;
|
|
resurrects ADR-005's `BiStream` trait as the handler-facing leaf type;
|
|
supersedes the "two Phase 6 issues" framing in
|
|
`docs/research/alknet-crate-extraction/findings.md`; **`into_sub_streams()`
|
|
preservation subsequently reversed by ADR-035 (2026-07-18) — see the
|
|
note at the bottom of this ADR**)
|
|
|
|
> **Note on `into_sub_streams()` (added 2026-07-18, ADR-035):** This ADR's
|
|
> body states `into_sub_streams()` (ADR-038) is "preserved" as the
|
|
> second accessor alongside `accept_bi`, because TTY's named
|
|
> unidirectional sub-streams are the case that justifies keeping
|
|
> `SendStream` / `RecvStream`. ADR-035 reverses that preservation: the
|
|
> channels layer has no `stream_type` concept, `into_sub_streams()` is
|
|
> removed, and TTY sub-demuxes its `BiStream` via its own 5-byte format
|
|
> (the same code TTY runs in direct mode). `SendStream` / `RecvStream`
|
|
> collapse to thin newtypes over `Box<dyn Async* + Send + Unpin>` as
|
|
> this ADR specifies, but their only consumer is the channels
|
|
> reassembly path's internal join (constructing a `BiStream` from split
|
|
> halves), not `into_sub_streams()`. See ADR-035 for the resolution
|
|
> rationale (the channels layer is pure channel multiplexing; the
|
|
> handler owns its sub-stream multiplexing on the `BiStream`).
|
|
|
|
## Context
|
|
|
|
The crate-extraction findings doc
|
|
(`docs/research/alknet-crate-extraction/findings.md` Phase 6) deferred
|
|
the `alknet-http` rework on the grounds that the `QuicStream` wrapper
|
|
(44 lines, `crates/alknet-http/src/server/adapter.rs:271-314`) is a
|
|
*necessary* adapter — `accept_bi()` returns a split
|
|
`(SendStream, RecvStream)` pair, `SendStream` implements only
|
|
`AsyncWrite`, `RecvStream` implements only `AsyncRead`, and
|
|
`HttpAdapter::serve_io` needs a single `AsyncRead + AsyncWrite`. The
|
|
finding was correct about the symptom and wrong about the cause. This
|
|
ADR untangles the cause.
|
|
|
|
### The tangle: five abstractions for "a bidirectional byte stream"
|
|
|
|
Today the codebase has five abstractions for the same concept, and every
|
|
handler picks a joining strategy per-handler:
|
|
|
|
| # | Abstraction | Where | Notes |
|
|
|---|-------------|-------|-------|
|
|
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-005, named in ADR-008 as "a client-side / test convenience trait," but grep across the workspace finds **zero** consumers — no `impl BiStream`, no `dyn BiStream`, no `Box<dyn BiStream>`. The trait is the ecosystem convention (`tokio::net::TcpStream`, `TlsStream<TcpStream>`, `russh::Channel::into_stream()` all satisfy it natively) but it was never wired in. |
|
|
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | The handler-facing abstraction. Leaf is split. |
|
|
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | The actual leaves handlers receive. Each carries a quinn/iroh/generic enum (`SendStreamKind` / `RecvStreamKind`) and dispatches per-call. |
|
|
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. ADR-044/048 already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
|
|
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpsc-backed halves fed to `Connection::from_stream`. The channels POC's `TunnelHandler` consumes them directly as two `tokio::io::copy` pumps — the split shape is right for the tunnel, wrong for HTTP. |
|
|
|
|
The two Phase 6 issues are symptoms of one root: **the leaf type is
|
|
split, so every consumer either re-joins it (HTTP's `QuicStream`,
|
|
`QuicStreamDuplex` test helper) or bypasses `Connection` entirely
|
|
(WS's `WsStream` + bespoke dispatch loop).**
|
|
|
|
### What ADR-008 left half-finished
|
|
|
|
ADR-008 extracted `BidiStreamSource` as the connection-level extension
|
|
point and kept `accept_bi` returning `(SendStream, RecvStream)`:
|
|
|
|
```rust
|
|
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
|
```
|
|
|
|
This preserved the existing `Connection` API verbatim (the right call
|
|
for ADR-008's scope — the trait extraction was the one-way door; the
|
|
return shape was a known leftover). But it left the join *per-handler*:
|
|
every handler that wants a single duplex stream re-implements the same
|
|
`AsyncRead + AsyncWrite` wrapper. The wrapper is small (44 lines) and
|
|
correct, but it is duplicated per-handler, and the duplication is what
|
|
forces the WS path into a bespoke `WsStream` trait instead of running
|
|
through `Connection::from_bidi` like every other transport.
|
|
|
|
### What ADR-005 already specified
|
|
|
|
ADR-005 defined `BiStream: AsyncRead + AsyncWrite + Send + Unpin` as
|
|
the leaf, and the ADR's "Why BiStream is still defined as a trait"
|
|
section (lines 86-94) lists three uses: WASM door, testing,
|
|
portability. The trait was placed in `alknet-core` and then not used
|
|
as the handler leaf — ADR-002's `handle` signature takes `Connection`
|
|
(correctly, for multi-stream handlers like TTY that loop `accept_bi`),
|
|
and `Connection::accept_bi` returns the split pair. `BiStream` became
|
|
"the trait that would have been the leaf if handlers received a single
|
|
stream." This ADR makes it the actual leaf — not by changing the
|
|
handler signature (still `Connection`), but by changing what
|
|
`accept_bi` yields.
|
|
|
|
### The ecosystem convention
|
|
|
|
`AsyncRead + AsyncWrite + Send + Unpin` (or close variants) is the
|
|
Rust ecosystem's standard "bidirectional byte stream" shape:
|
|
|
|
- `tokio::net::TcpStream`, `tokio::net::UdpSocket`
|
|
- `tokio_rustls::server::TlsStream<TcpStream>`
|
|
- `russh::Channel::into_stream()` — "Consume the Channel to produce a
|
|
bidirectional stream, sending and receiving `ChannelMsg::Data` as
|
|
`AsyncRead + AsyncWrite`"
|
|
- `tokio::io::DuplexStream`
|
|
- A WS-message adapter (the one place real adapter work is required)
|
|
|
|
All satisfy `BiStream` natively. Making `BiStream` the handler leaf
|
|
aligns alknet with the convention: `Connection::from_bidi(stream)`
|
|
accepts any of these directly, no per-handler wrapper.
|
|
|
|
### The two-pump shape is unaffected
|
|
|
|
The tunnel handler (ADR-078) and the SSH `direct-tcpip` handler
|
|
(future) use the split shape — two `tokio::io::copy` pumps, one per
|
|
direction. With `BiStream` as the leaf, these handlers call
|
|
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
|
|
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
|
|
`TlsStream<TcpStream>`. The split is a stdlib call at the handler
|
|
boundary, not a per-handler trait wrapper. ADR-078's
|
|
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
|
|
unchanged.
|
|
|
|
### The TTY named-sub-streams case (ADR-038, ADR-077)
|
|
|
|
ADR-038 specifies `into_sub_streams()` returning
|
|
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
|
|
`Send(SendStream) | Recv(RecvStream)`. ADR-077's TTY-inside-channels
|
|
mode destructures into five named handles (`stdin`, `stdout`,
|
|
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
|
|
unidirectional** (ADR-034) — the typed-sub-stream leaves are
|
|
unidirectional by design, and the join is wrong for them.
|
|
|
|
This means `SendStream` and `RecvStream` cannot fully go away. They
|
|
remain as the typed-sub-stream leaves for the channels-inside-TTY
|
|
case (and any future handler that destructures a `ChannelSubStreams`).
|
|
What goes away is the *quinn-welding* in them: today `SendStreamKind`
|
|
/ `RecvStreamKind` are enums with `Quinn` / `Iroh` / `Stream` variants
|
|
that dispatch per-call. Once `accept_bi` returns a joined `BiStream`,
|
|
the quinn/iroh `accept_bi` impls do the join *once* (via
|
|
`tokio::io::join`) and yield a `BiStream`. The `SendStream` /
|
|
`RecvStream` types collapse to thin newtypes over
|
|
`Box<dyn AsyncWrite + Send + Unpin>` / `Box<dyn AsyncRead + Send +
|
|
Unpin>` — used only by `into_sub_streams()` and the channels reassembly
|
|
path, never by a top-level handler's `accept_bi` call.
|
|
|
|
## Decision
|
|
|
|
### `accept_bi` returns `BiStream`
|
|
|
|
`BidiStreamSource::accept_bi` returns a single `BiStream`, not a split
|
|
pair:
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait BidiStreamSource: Send + Sync + 'static {
|
|
async fn accept_bi(&self) -> Result<BidiStream, StreamError>;
|
|
async fn open_bi(&self) -> Result<BidiStream, StreamError>;
|
|
fn remote_addr(&self) -> Option<SocketAddr>;
|
|
fn close(&self, code: u32, reason: &str);
|
|
}
|
|
```
|
|
|
|
`Connection::accept_bi` / `open_bi` delegate verbatim. The public
|
|
`Connection` API is preserved except for the return type — which is a
|
|
type change every caller sees, addressed below.
|
|
|
|
### `BiStream` is a concrete newtype, not a bare trait
|
|
|
|
A bare `dyn BiStream` won't work: `AsyncRead` / `AsyncWrite` methods
|
|
take `Pin<&mut Self>`, and trait objects need `Pin<Box<dyn ...>>` or a
|
|
newtype that owns the inner stream and re-projects. The clean shape is
|
|
a concrete struct that boxes the inner joined stream:
|
|
|
|
```rust
|
|
pub struct BiStream {
|
|
inner: Box<dyn AsyncReadWrite + Send + Unpin>,
|
|
}
|
|
|
|
// Internal helper trait — the union of AsyncRead + AsyncWrite + Send +
|
|
// Unpin. Not public; exists only to give BiStream a single boxed field.
|
|
trait AsyncReadWrite: AsyncRead + AsyncWrite {}
|
|
impl<T: AsyncRead + AsyncWrite> AsyncReadWrite for T {}
|
|
|
|
impl AsyncRead for BiStream { /* delegate to self.inner */ }
|
|
impl AsyncWrite for BiStream { /* delegate to self.inner */ }
|
|
```
|
|
|
|
`BiStream: AsyncRead + AsyncWrite + Send + Unpin` by construction. The
|
|
old `pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}`
|
|
(ADR-005, `types.rs:226`) is removed — the trait was never consumed,
|
|
and the concrete struct carries the same trait bounds forward as
|
|
implied bounds, not a marker trait. This is the ADR-005 resurrection:
|
|
the name and the bounds survive, the shape becomes a concrete leaf.
|
|
|
|
### The join moves into core's quinn/iroh impls (once)
|
|
|
|
```rust
|
|
#[cfg(feature = "quinn")]
|
|
async fn accept_bi(&self) -> Result<BidiStream, StreamError> {
|
|
let (send, recv) = self.conn.accept_bi().await
|
|
.map_err(map_quinn_connection_error)?;
|
|
Ok(BiStream::from_joined(send, recv)) // tokio::io::join internally
|
|
}
|
|
```
|
|
|
|
The `QuicStream` wrapper (`adapter.rs:271-314`, 44 lines) becomes
|
|
`BiStream::from_joined(send, recv)` — one line, in core, invisible to
|
|
handlers. The same applies to iroh. The join is no longer per-handler.
|
|
|
|
### `Connection::from_bidi` is the only public stream constructor;
|
|
`from_stream` is removed
|
|
|
|
Today `from_bidi` is a convenience wrapper that calls
|
|
`tokio::io::split(stream)` then `from_stream(send, recv)`, and
|
|
`from_stream` bakes the split into the constructor API — the same
|
|
split-leaf shape pushed one step earlier. With `BiStream` as the leaf,
|
|
`from_bidi` is the only public constructor that takes a joined stream.
|
|
`Connection::from_stream(send, recv, ...)` is **removed**.
|
|
|
|
The rule this normalizes: **the split never crosses a crate boundary
|
|
as part of a constructor.** A crate that produces split halves
|
|
naturally (the channels reassembly path, which produces
|
|
`MpscSendStream` / `MpscRecvStream` as distinct async types) joins
|
|
them *itself* via `tokio::io::join(send, recv)` (one line) and calls
|
|
`from_bidi`. A crate that has a joined stream (`TcpStream`,
|
|
`TlsStream<TcpStream>`, `russh::Channel::into_stream()`,
|
|
`WsBidiStream`, even a test `DuplexStream`) calls `from_bidi` directly.
|
|
`Connection` only ever holds a `BiStream`. The split is a crate-internal
|
|
concern of wherever it naturally arises.
|
|
|
|
The existing `from_stream` call sites update mechanically:
|
|
|
|
- `crates/alknet-client/src/dial/tcp_tls.rs` already uses `from_bidi`
|
|
(no change).
|
|
- `crates/alknet-endpoint/src/accept/tcp_tls.rs` already uses
|
|
`from_bidi` (no change).
|
|
- The call crate's test stubs
|
|
(`call_client.rs:91`, `protocol/connection.rs:465`,
|
|
`protocol/dispatch.rs:465`, `protocol/adapter.rs:294`,
|
|
`client/from_call.rs:428`) today do
|
|
`tokio::io::split(x)` then `from_stream(send, recv, ...)` — they
|
|
become `from_bidi(x, ...)` directly, one call, no split.
|
|
- The channels reassembly path (per ADR-038, the future
|
|
`ChannelBidiStreamSource::accept_bi` impl) joins its
|
|
`MpscSendStream` / `MpscRecvStream` via `tokio::io::join` and calls
|
|
`from_bidi` — the join is in the channels crate (where the split
|
|
exists), not in the core constructor API.
|
|
- The core test at `types.rs:768` and the `from_source_tests` helper
|
|
become `from_bidi` calls (or construct `BiStream` directly via
|
|
`BiStream::from_joined`).
|
|
|
|
`SendStream::from_stream` / `RecvStream::from_stream` (the per-half
|
|
constructors, `types.rs:267` / `types.rs:289`) are **retained** — they
|
|
are the per-half boxing for `into_sub_streams()` (ADR-038) and the
|
|
channels reassembly path's `SubStreamHandle` leaves, not constructors
|
|
that feed `Connection`. The split lives where it is natural (channels
|
|
reassembly → `SubStreamHandle`), doesn't leak into `Connection`'s API.
|
|
|
|
### `SendStream` / `RecvStream` collapse to thin newtypes
|
|
|
|
```rust
|
|
pub struct SendStream { inner: Box<dyn AsyncWrite + Send + Unpin> }
|
|
pub struct RecvStream { inner: Box<dyn AsyncRead + Send + Unpin> }
|
|
```
|
|
|
|
Used by `into_sub_streams()` (ADR-038) and the channels reassembly
|
|
path. No `SendStreamKind` / `RecvStreamKind` enum — the quinn/iroh
|
|
dispatch is gone, the join happens once in the `BidiStreamSource` impl.
|
|
`SendStream::from_quinn` / `from_iroh` (crate-private) become the
|
|
thin-boxing constructors used only by the channels reassembly path
|
|
when it needs to expose unidirectional sub-streams. The
|
|
`from_stream(impl AsyncWrite + Send + Unpin)` / `from_stream(impl
|
|
AsyncRead + Send + Unpin)` public constructors are retained.
|
|
|
|
### `HttpAdapter` drops `QuicStream`
|
|
|
|
```rust
|
|
async fn handle(&self, connection: Connection, auth: &AuthContext)
|
|
-> Result<(), HandlerError>
|
|
{
|
|
if let Some(identity) = auth.identity.clone() {
|
|
let _ = connection.set_identity(identity);
|
|
}
|
|
let stream = connection.accept_bi().await
|
|
.map_err(stream_error_to_handler)?;
|
|
self.serve_io(stream).await // BiStream: AsyncRead + AsyncWrite + Unpin
|
|
}
|
|
```
|
|
|
|
`QuicStream` (44 lines) and `QuicStreamDuplex` (test helper, 38 lines)
|
|
are removed. `serve_io<I: AsyncRead + AsyncWrite + Send + Unpin>` is
|
|
unchanged — `BiStream` satisfies the bounds by construction.
|
|
|
|
### WebSocket runs through `Connection::from_bidi`
|
|
|
|
`WsBidiStream` (new, ~50-80 lines) implements `AsyncRead` / `AsyncWrite`
|
|
over `axum::extract::ws::WebSocket` binary messages: `AsyncRead`
|
|
consumes `Message::Binary` payloads (text messages close with a
|
|
protocol error, matching the current `drive_ws_session` behavior);
|
|
`AsyncWrite` frames each write as a `Message::Binary`; `poll_shutdown`
|
|
emits `Message::Close`. The WS session then runs through
|
|
`Connection::from_bidi(WsBidiStream::new(socket), alpn, addr)` +
|
|
`CallAdapter::handle` (or whatever the call-protocol's
|
|
`ProtocolHandler` is at the assembly layer) — the same path as any
|
|
other transport.
|
|
|
|
The `WsStream` trait (`upgrade.rs:44-49`), the bespoke `drive_ws_session`
|
|
loop, the `handle_inbound_envelope` / `dispatch_envelope_to_pending`
|
|
helpers, and the `run_ws_session` glue are removed. The session's
|
|
wire-level invariants (binary-only, protocol-level close on text,
|
|
`fail_all` pending on disconnect, ADR-048's `EventEnvelope` framing)
|
|
move into `WsBidiStream`'s `AsyncRead` / `AsyncWrite` / `poll_shutdown`
|
|
impls and the standard call-protocol dispatch path.
|
|
|
|
`CallConnection::new_overlay_only` stays — it's the
|
|
connection-local-overlay construction for non-peer clients
|
|
(ADR-034 §4, ADR-044 §5), orthogonal to the transport seam. What
|
|
changes is that the WS session feeds it through a `Connection` rather
|
|
than a parallel `WsStream` trait.
|
|
|
|
### Channels spec updates
|
|
|
|
ADR-038's `ChannelBidiStreamSource::accept_bi` returns `BiStream`:
|
|
|
|
```rust
|
|
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
|
|
// Yields the joined (stream_type 0, stream_type 1) pair on first
|
|
// call, ConnectionClosed on subsequent calls.
|
|
}
|
|
```
|
|
|
|
`into_sub_streams()` is unchanged in shape — it still returns
|
|
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
|
|
Recv(RecvStream)`, because TTY's named sub-streams are unidirectional
|
|
(ADR-034, ADR-077). The two paths (`accept_bi` for handlers that want
|
|
the joined pair, `into_sub_streams` for handlers that want the typed
|
|
unidirectional sub-streams) are preserved per ADR-038.
|
|
|
|
The channels POC's `MpscSendStream` / `MpscRecvStream` feed
|
|
`BiStream::from_joined(send, recv)` (or `from_stream` if the channels
|
|
crate prefers to construct the joined leaf directly from the mux
|
|
handle) — the `ChannelBidiStreamSource::accept_bi` impl does the join
|
|
once, and the per-channel `Connection::accept_bi` yields a `BiStream`.
|
|
The POC's `TunnelHandler` calls `tokio::io::split(bidi)` to get its two
|
|
pump halves, the same idiom it would use over `TcpStream`.
|
|
|
|
### `BiStream` over WebSocket enables "VPN-like without being a VPN" in v1
|
|
|
|
The `webtransport.md` spec describes the "VPN-like without being a VPN"
|
|
path: a browser opens a WebTransport session to `/alknet/ssh`, the h3
|
|
handler hands each bidi stream to `SshAdapter::handle` as a
|
|
`Connection`, the browser's WASM SSH parser speaks SSH over the
|
|
stream. WebTransport is deferred per ADR-044.
|
|
|
|
With `BiStream` as the leaf, the same path exists over WebSocket in
|
|
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
|
|
`BiStream`, `Connection::from_bidi` wraps it, `ChannelsAdapter::handle`
|
|
runs the channels demux over it, each channel's `accept_bi` yields a
|
|
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
|
|
runs over a `BiStream`-over-WS-message adapter on the browser side
|
|
(the same `WsBidiStream` shape, browser-implemented). ADR-044/048's
|
|
"WS message stream is another `BiStream`-satisfying transport" becomes
|
|
literal — the code does what the spec said.
|
|
|
|
The channels POC's sync core already compiles under
|
|
`wasm32-unknown-unknown`; a `BiStream`-over-WS adapter would too. The
|
|
WASM-clean property is preserved by the unification, not blocked by
|
|
it.
|
|
|
|
### WebTransport is a channels concern, not an alknet-http concern
|
|
|
|
The `h3` handler as specified in `webtransport.md` does exactly the
|
|
channels shape: one connection, N bidi streams inside, each routed to
|
|
an ALPN by the CONNECT path. That's `ChannelsAdapter::handle` with a
|
|
different wire format (HTTP/3 extended CONNECT vs the 9-byte chunk
|
|
header). When WebTransport revives, the h3 multi-stream demux leaves
|
|
`alknet-http` and becomes a channels-variant ALPN — the `alknet-http`
|
|
h3 path becomes "register an ALPN handler that gets one `BiStream` and
|
|
serves it as HTTP/3," same as `h2`/`http/1.1`. The
|
|
ALPN-stream-proxy (ADR-040) is the channels-over-WebTransport shape,
|
|
not an `alknet-http` shape.
|
|
|
|
This is out of scope for this ADR (WebTransport is deferred per
|
|
ADR-044). It is recorded here because the unification is what makes
|
|
the future extraction clean: once `accept_bi` returns `BiStream`, the
|
|
`h3` handler's "accept a WebTransport session, yield each stream as a
|
|
`BiStream` to the ALPN handler" shape is the same code as
|
|
`ChannelsAdapter::handle`, and the extraction is a move, not a
|
|
redesign.
|
|
|
|
## What does NOT change
|
|
|
|
- **`ProtocolHandler` trait shape** — `handle(&self, connection:
|
|
Connection, auth: &AuthContext)` stays. This is an internal
|
|
`Connection` refactor; the handler trait is the ADR-032 one-way door.
|
|
- **`HandlerRegistry`** — unchanged.
|
|
- **`Connection::remote_alpn` / `set_identity` / `identity` / `close`**
|
|
— unchanged. These are `Connection`-level, not transport-level.
|
|
- **`BidiStreamSource` trait** (ADR-008) — preserved. Three signatures
|
|
change return type (`accept_bi`, `open_bi`, and the implied
|
|
`Connection::accept_bi` / `open_bi`); the trait shape and the
|
|
extension-point model are preserved.
|
|
- **`from_source` constructor** (ADR-008) — preserved. Downstream
|
|
crates implement `BidiStreamSource` and construct via `from_source`;
|
|
their `accept_bi` impls return `BiStream`.
|
|
- **`into_sub_streams()`** (ADR-038) — preserved. TTY's named
|
|
unidirectional sub-streams are the case that justifies keeping
|
|
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
|
|
enums).
|
|
- **The two-pump pattern** (ADR-078) — preserved. Tunnel/SSH handlers
|
|
call `tokio::io::split(bidi)` for their two pump halves; the
|
|
shutdown-on-completion contract applies to the `ReadHalf` /
|
|
`WriteHalf` unchanged.
|
|
- **Yield-once contract** (ADR-007) — preserved.
|
|
`StreamBidiStreamSource::accept_bi` yields the `BiStream` once then
|
|
returns `ConnectionClosed`. The contract is about *how many times*
|
|
`accept_bi` yields, not *what shape* it yields.
|
|
- **`Connection::from_quinn` / `from_iroh`** — preserved as
|
|
convenience wrappers; internally wrap the `QuinnBidiStreamSource` /
|
|
`IrohBidiStreamSource` whose `accept_bi` does the join.
|
|
- **`Connection::from_bidi`** — promoted to the only public stream
|
|
constructor. `Connection::from_stream` is removed (the split no
|
|
longer crosses a crate boundary as part of a constructor).
|
|
- **`SendStream::from_stream` / `RecvStream::from_stream`** (per-half
|
|
constructors) — retained, but only as the boxing for
|
|
`into_sub_streams()` and the channels reassembly path's
|
|
`SubStreamHandle` leaves. Not constructors that feed `Connection`.
|
|
- **The endpoint's accept loops** (quinn/iroh) — unchanged.
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
|
|
- The `QuicStream` wrapper (44 lines) and `QuicStreamDuplex` test
|
|
helper (38 lines) are removed from `alknet-http`. `HttpAdapter::handle`
|
|
becomes 4 lines. `serve_io`'s signature is unchanged.
|
|
- The `WsStream` trait, the bespoke `drive_ws_session` loop, and ~150
|
|
lines of WS-specific dispatch glue are removed from
|
|
`alknet-http/websocket/upgrade.rs`. The WS session runs through
|
|
`Connection::from_bidi` + the call-protocol handler like any other
|
|
transport. ADR-044/048's "WS message stream is `BiStream`-satisfying"
|
|
becomes literal.
|
|
- One abstraction (`BiStream`) replaces five. The leaf type matches the
|
|
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
|
|
`TlsStream<TcpStream>`, `DuplexStream`).
|
|
- "VPN-like without being a VPN" over WS in v1 becomes real: the same
|
|
path `webtransport.md` specified, over WS, now. The browser's WASM
|
|
parser implements `BiStream` over a WS-message adapter; the server
|
|
wraps it via `Connection::from_bidi`; `ChannelsAdapter::handle` runs
|
|
the demux; each channel's `BiStream` reaches `SshAdapter::handle`
|
|
unchanged.
|
|
- The quinn-welding in `SendStream` / `RecvStream` (the
|
|
`SendStreamKind` / `RecvStreamKind` enums and their per-call
|
|
dispatch) is gone. `SendStream` / `RecvStream` become thin newtypes
|
|
used only by the channels reassembly path and `into_sub_streams()`.
|
|
- The future WebTransport extraction is a move (h3 demux → a
|
|
channels-variant ALPN), not a redesign. The unification is what
|
|
makes it clean.
|
|
- ADR-005's `BiStream` is resurrected as the actual leaf, matching the
|
|
original intent the code never delivered.
|
|
|
|
**Negative:**
|
|
|
|
- Every `accept_bi().await` caller sees a return-type change from
|
|
`(SendStream, RecvStream)` to `BiStream`. Callers that want the
|
|
split pair call `tokio::io::split(bidi)`. The call-site change is
|
|
mechanical (`let (send, recv) = ...` → `let bidi = ...; let (recv,
|
|
send) = tokio::io::split(bidi)`), but it touches every handler. This
|
|
is the one-time cost of the unification; the alternative is
|
|
per-handler wrappers forever.
|
|
- Every `Connection::from_stream(send, recv, ...)` call site is
|
|
removed. The call crate's test stubs (5 sites) become `from_bidi`
|
|
calls. The channels reassembly path gains a one-line
|
|
`tokio::io::join` before `from_bidi`. No caller outside core and
|
|
the channels reassembly path was ever doing anything other than
|
|
`tokio::io::split` then `from_stream` — the split was always
|
|
gratuitous at the call site.
|
|
- ADR-008's `accept_bi` return shape is amended. ADR-008 explicitly
|
|
preserved the split-pair shape to keep the `Connection` API verbatim;
|
|
this ADR reverses that preservation. The trade is: one type change
|
|
across the codebase now, vs. one wrapper per handler forever.
|
|
ADR-008's trait-extraction (the one-way door) is preserved; the
|
|
return-shape is the amended part.
|
|
- ADR-038's `ChannelBidiStreamSource::accept_bi` return shape is
|
|
amended (same change, same rationale). `into_sub_streams()` is
|
|
unchanged.
|
|
- `BiStream` becomes a concrete struct (with an internal boxed
|
|
`dyn AsyncReadWrite`), not a bare trait object. This is the
|
|
`Pin<&mut Self>` projection requirement — a bare `dyn BiStream` is
|
|
not ergonomic for `AsyncRead` / `AsyncWrite` impls. The ADR-005
|
|
trait is removed; the bounds survive as implied bounds on the
|
|
concrete struct. The name and the convention are preserved; the
|
|
shape becomes a concrete leaf.
|
|
- `WsBidiStream` is real new code (~50-80 lines). The WS-message ↔
|
|
byte-stream adapter is the one place the unification requires
|
|
non-trivial work — WS messages are framed, not a byte stream, so
|
|
the adapter owns the framing. This is the same work the current
|
|
`drive_ws_session` loop does, just relocated from a bespoke loop
|
|
into the `AsyncRead` / `AsyncWrite` impls.
|
|
- The `alknet-http` crate gains a dependency on whatever crate
|
|
owns `WsBidiStream` (likely `alknet-http` itself, or a small
|
|
`alknet-ws` crate if WASM-targetability is a goal — the browser side
|
|
needs the same adapter). This is a packaging decision, not a
|
|
design one — recorded as an open question below.
|
|
|
|
## Door type
|
|
|
|
**One-way.** The `accept_bi` return shape is the handler-facing API
|
|
surface. Once handlers are written against `BiStream`, reversing to
|
|
the split-pair shape is a rewrite of every handler's call site. The
|
|
trade is one type change across the codebase now vs. one wrapper per
|
|
handler forever — this ADR takes the one-time cost.
|
|
|
|
The `BiStream` concrete-struct shape (internal `Box<dyn
|
|
AsyncReadWrite>`, `Pin` projection) is a two-way-door implementation
|
|
detail — the internal representation can change without breaking the
|
|
public `AsyncRead + AsyncWrite + Send + Unpin` bounds.
|
|
|
|
## Migration
|
|
|
|
The migration is mechanical and can be ordered to keep the workspace
|
|
compilable:
|
|
|
|
1. **Core: introduce `BiStream` as the concrete leaf.** Add the
|
|
struct, the `AsyncRead` / `AsyncWrite` impls, the `from_joined`
|
|
constructor. Change `BidiStreamSource::accept_bi` / `open_bi` return
|
|
types to `BiStream`. Update `QuinnBidiStreamSource` /
|
|
`IrohBidiStreamSource` / `StreamBidiStreamSource` impls to do the
|
|
join. `Connection::accept_bi` / `open_bi` delegate verbatim. Remove
|
|
`Connection::from_stream` (the split-pair constructor); promote
|
|
`Connection::from_bidi` to the only public stream constructor.
|
|
This is a single-crate change; every `accept_bi` and `from_stream`
|
|
caller breaks mechanically.
|
|
2. **Update every handler's `accept_bi` call sites and every
|
|
`from_stream` call site.** `HttpAdapter::handle` becomes 4 lines
|
|
(drop `QuicStream`). `TtyAdapter::handle` calls
|
|
`tokio::io::split(bidi)` for its pump halves (or uses
|
|
`into_sub_streams()` in channels mode — unchanged). The channels
|
|
POC's `TunnelHandler` and `EchoHandler` get the same
|
|
`tokio::io::split` treatment. `CallAdapter::handle` (wherever it
|
|
consumes `accept_bi`) gets the same. The call crate's test stubs
|
|
(5 `from_stream` sites) become `from_bidi` calls (drop the
|
|
`tokio::io::split` they were doing immediately before). The channels
|
|
reassembly path gains a one-line `tokio::io::join` before `from_bidi`.
|
|
3. **Collapse `SendStream` / `RecvStream` to thin newtypes.** Remove
|
|
`SendStreamKind` / `RecvStreamKind` enums; the quinn/iroh
|
|
constructors become thin-boxing. Used only by the channels
|
|
reassembly path and `into_sub_streams()`.
|
|
4. **`alknet-http`: rewrite WS through `Connection::from_bidi`.** Add
|
|
`WsBidiStream`; remove `WsStream` trait, `drive_ws_session` loop,
|
|
and the dispatch glue. The WS session runs through
|
|
`Connection::from_bidi` + the call-protocol handler. This is the
|
|
largest single change and can land after (1)-(3) — the WS path is
|
|
independent of the handler call-site updates.
|
|
5. **Update ADR-007, ADR-008, ADR-038, ADR-077** to reflect the
|
|
`BiStream` return shape. ADR-007's `from_stream` constructor is
|
|
removed; `from_bidi` is the only public stream constructor (the
|
|
rule: the split never crosses a crate boundary as part of a
|
|
constructor). ADR-008's `accept_bi` return type is amended. ADR-038's
|
|
`ChannelBidiStreamSource::accept_bi` return type is amended;
|
|
`into_sub_streams()` is unchanged. ADR-077's two-mode TTY design is
|
|
unchanged (the modes differ in *how* the adapter gets sub-streams,
|
|
not in the leaf type).
|
|
6. **Update `findings.md` Phase 6.** The "deferred" status is
|
|
replaced: the `QuicStream` wrapper is removed (not because
|
|
`accept_bi` returns streams that are already duplex, but because
|
|
`accept_bi` now returns a `BiStream`); the WS path is unified; the
|
|
h3/WebTransport extraction is recorded as a future channels-variant
|
|
move enabled by this ADR.
|
|
|
|
The Phase 6 deferral in `findings.md` is resolved by this ADR — not by
|
|
the original plan (drop the wrapper as redundant) but by the actual
|
|
fix (unify the leaf so the wrapper moves into core).
|
|
|
|
## Open questions
|
|
|
|
- **Where does `WsBidiStream` live?** If WASM-targetability is a goal
|
|
(the browser side needs the same adapter), it may want to live
|
|
somewhere a WASM client can reach — `alknet-core` (no, HTTP deps
|
|
don't belong in core), a small `alknet-ws` crate, or
|
|
`alknet-http` with the browser-side adapter extracted separately.
|
|
Default: `alknet-http` owns the server-side `WsBidiStream`; the
|
|
browser-side adapter is a separate concern (the WASM SDK, not
|
|
alknet-http). Resolved at implementation time.
|
|
- **`SendStream` / `RecvStream` long-term home.** With the quinn
|
|
enums gone, these are thin newtypes over
|
|
`Box<dyn Async* + Send + Unpin>`. They could move out of
|
|
`alknet-core` into `alknet-channels-core` (their only consumer is
|
|
`into_sub_streams()`). Default: stay in `alknet-core` for now (the
|
|
channels crate is not yet extracted); revisit at the channels
|
|
extraction.
|
|
|
|
## References
|
|
|
|
- ADR-005: `BiStream` type definition (resurrected by this ADR — the
|
|
trait is removed, the bounds survive as implied bounds on the
|
|
concrete struct)
|
|
- ADR-007: `Connection::from_stream` / `from_bidi` (amended —
|
|
`from_stream` is removed; `from_bidi` is the only public stream
|
|
constructor; the split never crosses a crate boundary as part of a
|
|
constructor)
|
|
- ADR-008: `BidiStreamSource` trait (amended — `accept_bi` / `open_bi`
|
|
return `BiStream`, not the split pair; the trait shape and the
|
|
`from_source` extension point are preserved)
|
|
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
|
|
`BiStream`; `into_sub_streams()` is unchanged)
|
|
- ADR-077: TTY inside channels (unchanged — the two-mode design is
|
|
preserved; the modes differ in how the adapter gets sub-streams,
|
|
not in the leaf type)
|
|
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
|
|
applies to `tokio::io::split(bidi)` halves)
|
|
- ADR-044, ADR-048: WebSocket is the v1 browser bidirectional path
|
|
(this ADR makes the "WS message stream is `BiStream`-satisfying"
|
|
claim literal)
|
|
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
|
|
deferred `alknet-http` rework; this ADR resolves the deferral by
|
|
unifying the leaf rather than by dropping the wrapper as redundant
|
|
- `docs/architecture/crates/http/webtransport.md` — the deferred h3
|
|
handler; this ADR records the future extraction as a
|
|
channels-variant move, enabled by the unification
|
|
- `crates/alknet-core/src/types.rs:226` — the vestigial `BiStream`
|
|
trait this ADR resurrects as the concrete leaf
|
|
- `crates/alknet-http/src/server/adapter.rs:271-314` — the `QuicStream`
|
|
wrapper this ADR removes
|
|
- `crates/alknet-http/src/websocket/upgrade.rs:44-49` — the `WsStream`
|
|
trait this ADR removes
|
|
- `russh::Channel::into_stream()` — the ecosystem convention this ADR
|
|
aligns with |