Files
alkcall/docs/architecture/decisions/009-bistream-as-the-handler-leaf.md
glm-5.2 cc470a363a 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).
2026-08-12 07:06:57 +00:00

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 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):

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:

#[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.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

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

  • ProtocolHandler trait shapehandle(&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