docs: port architecture specs + 45 ADRs from alknet, renumbered
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).
This commit is contained in:
626
docs/architecture/decisions/009-bistream-as-the-handler-leaf.md
Normal file
626
docs/architecture/decisions/009-bistream-as-the-handler-leaf.md
Normal file
@@ -0,0 +1,626 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user