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).
32 KiB
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 statesinto_sub_streams()(ADR-038) is "preserved" as the second accessor alongsideaccept_bi, because TTY's named unidirectional sub-streams are the case that justifies keepingSendStream/RecvStream. ADR-035 reverses that preservation: the channels layer has nostream_typeconcept,into_sub_streams()is removed, and TTY sub-demuxes itsBiStreamvia its own 5-byte format (the same code TTY runs in direct mode).SendStream/RecvStreamcollapse to thin newtypes overBox<dyn Async* + Send + Unpin>as this ADR specifies, but their only consumer is the channels reassembly path's internal join (constructing aBiStreamfrom split halves), notinto_sub_streams(). See ADR-035 for the resolution rationale (the channels layer is pure channel multiplexing; the handler owns its sub-stream multiplexing on theBiStream).
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):
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::UdpSockettokio_rustls::server::TlsStream<TcpStream>russh::Channel::into_stream()— "Consume the Channel to produce a bidirectional stream, sending and receivingChannelMsg::DataasAsyncRead + 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:
#[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:
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)
#[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.rsalready usesfrom_bidi(no change).crates/alknet-endpoint/src/accept/tcp_tls.rsalready usesfrom_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 dotokio::io::split(x)thenfrom_stream(send, recv, ...)— they becomefrom_bidi(x, ...)directly, one call, no split. - The channels reassembly path (per ADR-038, the future
ChannelBidiStreamSource::accept_biimpl) joins itsMpscSendStream/MpscRecvStreamviatokio::io::joinand callsfrom_bidi— the join is in the channels crate (where the split exists), not in the core constructor API. - The core test at
types.rs:768and thefrom_source_testshelper becomefrom_bidicalls (or constructBiStreamdirectly viaBiStream::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
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
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:
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
ProtocolHandlertrait shape —handle(&self, connection: Connection, auth: &AuthContext)stays. This is an internalConnectionrefactor; the handler trait is the ADR-032 one-way door.HandlerRegistry— unchanged.Connection::remote_alpn/set_identity/identity/close— unchanged. These areConnection-level, not transport-level.BidiStreamSourcetrait (ADR-008) — preserved. Three signatures change return type (accept_bi,open_bi, and the impliedConnection::accept_bi/open_bi); the trait shape and the extension-point model are preserved.from_sourceconstructor (ADR-008) — preserved. Downstream crates implementBidiStreamSourceand construct viafrom_source; theiraccept_biimpls returnBiStream.into_sub_streams()(ADR-038) — preserved. TTY's named unidirectional sub-streams are the case that justifies keepingSendStream/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 theReadHalf/WriteHalfunchanged. - Yield-once contract (ADR-007) — preserved.
StreamBidiStreamSource::accept_biyields theBiStreamonce then returnsConnectionClosed. The contract is about how many timesaccept_biyields, not what shape it yields. Connection::from_quinn/from_iroh— preserved as convenience wrappers; internally wrap theQuinnBidiStreamSource/IrohBidiStreamSourcewhoseaccept_bidoes the join.Connection::from_bidi— promoted to the only public stream constructor.Connection::from_streamis 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 forinto_sub_streams()and the channels reassembly path'sSubStreamHandleleaves. Not constructors that feedConnection.- The endpoint's accept loops (quinn/iroh) — unchanged.
Consequences
Positive:
- The
QuicStreamwrapper (44 lines) andQuicStreamDuplextest helper (38 lines) are removed fromalknet-http.HttpAdapter::handlebecomes 4 lines.serve_io's signature is unchanged. - The
WsStreamtrait, the bespokedrive_ws_sessionloop, and ~150 lines of WS-specific dispatch glue are removed fromalknet-http/websocket/upgrade.rs. The WS session runs throughConnection::from_bidi+ the call-protocol handler like any other transport. ADR-044/048's "WS message stream isBiStream-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.mdspecified, over WS, now. The browser's WASM parser implementsBiStreamover a WS-message adapter; the server wraps it viaConnection::from_bidi;ChannelsAdapter::handleruns the demux; each channel'sBiStreamreachesSshAdapter::handleunchanged. - The quinn-welding in
SendStream/RecvStream(theSendStreamKind/RecvStreamKindenums and their per-call dispatch) is gone.SendStream/RecvStreambecome thin newtypes used only by the channels reassembly path andinto_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
BiStreamis resurrected as the actual leaf, matching the original intent the code never delivered.
Negative:
- Every
accept_bi().awaitcaller sees a return-type change from(SendStream, RecvStream)toBiStream. Callers that want the split pair calltokio::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) becomefrom_bidicalls. The channels reassembly path gains a one-linetokio::io::joinbeforefrom_bidi. No caller outside core and the channels reassembly path was ever doing anything other thantokio::io::splitthenfrom_stream— the split was always gratuitous at the call site. - ADR-008's
accept_bireturn shape is amended. ADR-008 explicitly preserved the split-pair shape to keep theConnectionAPI 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_bireturn shape is amended (same change, same rationale).into_sub_streams()is unchanged. BiStreambecomes a concrete struct (with an internal boxeddyn AsyncReadWrite), not a bare trait object. This is thePin<&mut Self>projection requirement — a baredyn BiStreamis not ergonomic forAsyncRead/AsyncWriteimpls. 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.WsBidiStreamis 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 currentdrive_ws_sessionloop does, just relocated from a bespoke loop into theAsyncRead/AsyncWriteimpls.- The
alknet-httpcrate gains a dependency on whatever crate ownsWsBidiStream(likelyalknet-httpitself, or a smallalknet-wscrate 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:
- Core: introduce
BiStreamas the concrete leaf. Add the struct, theAsyncRead/AsyncWriteimpls, thefrom_joinedconstructor. ChangeBidiStreamSource::accept_bi/open_bireturn types toBiStream. UpdateQuinnBidiStreamSource/IrohBidiStreamSource/StreamBidiStreamSourceimpls to do the join.Connection::accept_bi/open_bidelegate verbatim. RemoveConnection::from_stream(the split-pair constructor); promoteConnection::from_bidito the only public stream constructor. This is a single-crate change; everyaccept_biandfrom_streamcaller breaks mechanically. - Update every handler's
accept_bicall sites and everyfrom_streamcall site.HttpAdapter::handlebecomes 4 lines (dropQuicStream).TtyAdapter::handlecallstokio::io::split(bidi)for its pump halves (or usesinto_sub_streams()in channels mode — unchanged). The channels POC'sTunnelHandlerandEchoHandlerget the sametokio::io::splittreatment.CallAdapter::handle(wherever it consumesaccept_bi) gets the same. The call crate's test stubs (5from_streamsites) becomefrom_bidicalls (drop thetokio::io::splitthey were doing immediately before). The channels reassembly path gains a one-linetokio::io::joinbeforefrom_bidi. - Collapse
SendStream/RecvStreamto thin newtypes. RemoveSendStreamKind/RecvStreamKindenums; the quinn/iroh constructors become thin-boxing. Used only by the channels reassembly path andinto_sub_streams(). alknet-http: rewrite WS throughConnection::from_bidi. AddWsBidiStream; removeWsStreamtrait,drive_ws_sessionloop, and the dispatch glue. The WS session runs throughConnection::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.- Update ADR-007, ADR-008, ADR-038, ADR-077 to reflect the
BiStreamreturn shape. ADR-007'sfrom_streamconstructor is removed;from_bidiis the only public stream constructor (the rule: the split never crosses a crate boundary as part of a constructor). ADR-008'saccept_bireturn type is amended. ADR-038'sChannelBidiStreamSource::accept_bireturn 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). - Update
findings.mdPhase 6. The "deferred" status is replaced: theQuicStreamwrapper is removed (not becauseaccept_bireturns streams that are already duplex, but becauseaccept_binow returns aBiStream); 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
WsBidiStreamlive? 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 smallalknet-wscrate, oralknet-httpwith the browser-side adapter extracted separately. Default:alknet-httpowns the server-sideWsBidiStream; the browser-side adapter is a separate concern (the WASM SDK, not alknet-http). Resolved at implementation time. SendStream/RecvStreamlong-term home. With the quinn enums gone, these are thin newtypes overBox<dyn Async* + Send + Unpin>. They could move out ofalknet-coreintoalknet-channels-core(their only consumer isinto_sub_streams()). Default: stay inalknet-corefor now (the channels crate is not yet extracted); revisit at the channels extraction.
References
- ADR-005:
BiStreamtype 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_streamis removed;from_bidiis the only public stream constructor; the split never crosses a crate boundary as part of a constructor) - ADR-008:
BidiStreamSourcetrait (amended —accept_bi/open_bireturnBiStream, not the split pair; the trait shape and thefrom_sourceextension point are preserved) - ADR-038:
ChannelBidiStreamSource(amended —accept_bireturnsBiStream;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.mdPhase 6 — the deferredalknet-httprework; this ADR resolves the deferral by unifying the leaf rather than by dropping the wrapper as redundantdocs/architecture/crates/http/webtransport.md— the deferred h3 handler; this ADR records the future extraction as a channels-variant move, enabled by the unificationcrates/alknet-core/src/types.rs:226— the vestigialBiStreamtrait this ADR resurrects as the concrete leafcrates/alknet-http/src/server/adapter.rs:271-314— theQuicStreamwrapper this ADR removescrates/alknet-http/src/websocket/upgrade.rs:44-49— theWsStreamtrait this ADR removesrussh::Channel::into_stream()— the ecosystem convention this ADR aligns with