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).
15 KiB
ADR-008: BidiStreamSource Trait — Open Connection for Extension
Status
Accepted
Context
ADR-007 generalized Connection beyond QUIC by adding
ConnectionKind::Stream (a yield-once read/write pair) and the
Connection::from_stream / from_bidi constructors. That closed the
server-side "QUIC-only" gap: TCP+TLS, SSH channels, WebTransport streams,
and wasm streams now dispatch through the same HandlerRegistry as QUIC
connections, unchanged.
What ADR-007 did not change is the shape of Connection itself. It
remains a closed enum:
enum ConnectionKind {
#[cfg(feature = "quinn")] Quinn(quinn::Connection),
#[cfg(feature = "iroh")] Iroh(iroh::endpoint::Connection),
Stream(StreamConn), // yield-once — ADR-007
}
Adding a new connection type today requires editing alknet-core — adding a
variant to ConnectionKind, adding match arms to accept_bi / open_bi /
remote_addr / close. Every downstream crate that introduces a new
connection shape (channels, a future transport, a test double beyond the
from_stream case) forces a core change. Connection is closed for
extension.
The channels crate is the first crate that needs to extend it
The alknet-channels POC (docs/research/alknet-channels/poc-summary.md)
validated the channels multiplexer and surfaced the concrete blocker. A
channels connection carries N logical channels over one transport stream;
each channel is a bidirectional byte stream presented to a ProtocolHandler
as a Connection. With the ADR-007 shape, each channel becomes a fresh
yield-once Connection::from_stream, and the channels endpoint holds a bag
of these connections (one per channel) rather than one ChannelConnection
that yields N streams.
The POC confirmed this is sufficient (the yield-once path works — handlers
run unchanged) but awkward: the channels layer wants to expose a single
ChannelConnection that is a first-class peer of QUIC (many bidi streams),
not a collection of yield-once Connections. The clean shape is for the
channels crate to implement the stream-yield interface itself, in its own
crate, without a core edit.
The extension point is narrow and already implied by ADR-007
Connection's public surface is four operations: accept_bi, open_bi,
remote_addr, close. remote_alpn / set_identity / identity are
Connection-level (not transport-level) and stay on Connection itself.
The four transport-level operations are the seam. Extracting them into a
trait that downstream crates can implement turns Connection from a closed
enum into an open trait object — the same extensibility ProtocolHandler
already gives handlers, applied to the connection.
What the POC de-risked
The channels POC (28 tests passing) validated that:
- The yield-once
Connection::from_streampath is sufficient for per-channel presentation — an echoProtocolHandlerruns through the full demux→Connection→handler→mux path with zero channels-layer awareness (poc-summary.md§"POC Target 2"). - The
BidiStreamSourcetrait is additive — existing callers keep working via afrom_stream-backed implementation of the trait, and the trait cleanly supports aChannelConnectionthat yields N streams (poc-summary.md§"Issues Surfaced" #1). - The trait does not touch the
ProtocolHandlertrait shape (ADR-002) — handlers continue to receive aConnectionand callaccept_bi/open_bion it. This is aConnectioninternal refactor, not a handler API change (poc-summary.md§"POC Target 2").
The remaining unknowns are spec-scope (the channels crate's API), not feasibility. This ADR makes the core-side extension point available so the channels spec can build on it.
Decision
Extract BidiStreamSource trait
#[async_trait]
pub trait BidiStreamSource: Send + Sync + 'static {
/// Yield the next bidirectional stream this connection provides.
///
/// Transport semantics (carried from ADR-007):
/// - QUIC (quinn/iroh): returns a new bidi stream on each call,
/// `ConnectionClosed` when the underlying connection closes.
/// - Single-stream (TCP+TLS, SSH channel, WebTransport stream, wasm):
/// yields the underlying stream on the first call, then
/// `ConnectionClosed` on all subsequent calls.
/// - Channels: yields one bidi stream per channel, `ConnectionClosed`
/// when the channels connection closes.
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
/// Open a bidirectional stream to the peer.
///
/// Single-stream sources return `StreamClosed` (a single stream cannot
/// open new application streams — ADR-007). QUIC and channels sources
/// open new streams.
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
/// The peer's address, if available. Informational (NAT/proxy).
fn remote_addr(&self) -> Option<SocketAddr>;
/// Close the connection. The `code`/`reason` args are QUIC application-
/// level close codes; non-QUIC sources ignore them (the drop is the
/// close — ADR-007 §"Negative"). See REQ-CORE-02 below for the
/// rationale for keeping the QUIC-shaped signature on the trait.
fn close(&self, code: u32, reason: &str);
}
Connection holds Box<dyn BidiStreamSource>
pub struct Connection {
source: Box<dyn BidiStreamSource>,
alpn: Vec<u8>,
identity: OnceLock<Identity>,
}
ConnectionKind (the private enum) is replaced by the trait object. The
public Connection API (accept_bi, open_bi, remote_alpn, remote_addr,
close, set_identity, identity) is preserved verbatim — each method
delegates to self.source. remote_alpn reads self.alpn (unchanged).
set_identity / identity read/write self.identity (unchanged).
Constructors stay; each wraps a BidiStreamSource impl
| Constructor | Wraps |
|---|---|
from_quinn / from_quinn_with_alpn (feature quinn) |
QuinnBidiStreamSource (crate-private) |
from_iroh (feature iroh) |
IrohBidiStreamSource (crate-private) |
from_stream / from_bidi (no feature gate) |
StreamBidiStreamSource (crate-private, yield-once) |
from_source (no feature gate) |
caller-supplied impl BidiStreamSource — the extension point for downstream crates |
from_source(source: impl BidiStreamSource, alpn: Vec<u8>) -> Self is the
constructor that makes the trait the extension point. A downstream crate
implements BidiStreamSource (e.g. the channels crate's
ChannelBidiStreamSource) and constructs a Connection from it via
from_source — no core edit. The built-in impls (QuinnBidiStreamSource,
IrohBidiStreamSource, StreamBidiStreamSource) are crate-private;
from_source is the only path a downstream crate uses to wrap its own
impl. The from_quinn / from_iroh / from_stream / from_bidi
constructors are convenience wrappers for the three built-in impls.
The Stream-backend implementations are crate-private; downstream crates
do not implement BidiStreamSource by wrapping from_stream. They
implement the trait directly (channels: ChannelBidiStreamSource) and
construct the Connection via from_source.
from_stream-backed default impl is the compatibility path
The yield-once StreamBidiStreamSource is the implementation that keeps
existing callers working: Connection::from_stream(send, recv, alpn, addr)
constructs a Connection backed by a StreamBidiStreamSource whose
accept_bi yields once then returns ConnectionClosed, whose open_bi
returns StreamClosed, whose close drops the stream. Behaviorally identical
to the ADR-007 ConnectionKind::Stream variant. No caller change.
REQ-CORE-02: close() keeps the QUIC-shaped signature on the trait
The close(&self, code: u32, reason: &str) signature is preserved on the
trait, rather than being split into transport-specific close methods. This
resolves the ADR-007 leftover: the Stream backend's close(code, reason)
currently takes both args and uses neither, which clippy flags under
--no-default-features (the channels POC's build mode) as two unused
variable warnings on crates/alknet-core/src/types.rs:500.
Two options were considered:
- (a) Split
close:trait BidiStreamSource { fn close(&self); }plus a separatefn close_with_code(&self, code: u32, reason: &str)default- implemented to callclose(). Non-QUIC impls implement onlyclose(); QUIC impls overrideclose_with_code. This moves the QUIC-shaped args off the common method. - (b) Keep the QUIC-shaped signature on the trait:
fn close(&self, code: u32, reason: &str). Non-QUIC impls prefix the args with_and document why they're ignored (the drop is the close — ADR-007). The trait method matches the existing publicConnection::closesignature verbatim — no caller change, noConnectionAPI split.
Decision: (b). Rationale:
- No caller breakage.
Connection::close(code, reason)is the existing public signature; every caller passes both args. Option (a) would force either aConnection::closethat always takescode/reasonand dispatches to the right trait method (which means the trait still has the QUIC-shaped method, just renamed — no actual improvement), or aConnection::closethat drops the args (which breaks every caller). - The args are not QUIC-only in principle. WebTransport has
application-level close codes; a future transport may as well. The
signature
close(code, reason)is a reasonable "close with diagnostic" shape that multiple transports can use. Only raw-stream backends (the ADR-007Streamcase) have nothing to do with the args, and they're the degenerate case. - The clippy warning is fixed by the trait, not by renaming. Under the
trait, the
StreamBidiStreamSource::closeimpl prefixes the args with_code/_reasonand carries a doc comment stating they're ignored because the drop is the close. The warning disappears; the signature matches the public API.
The trait method's doc comment carries the "QUIC application-level close codes; non-QUIC sources ignore them" note from ADR-007, so implementers know the args are optional for their transport.
What does NOT change
ProtocolHandlertrait shape —handle(&self, connection: Connection, auth: &AuthContext)stays. This is an internalConnectionrefactor, not a handler API change (ADR-032: the handler trait is a one-way door).HandlerRegistry— unchanged.- All handler code (
HttpAdapter,TtyAdapter,CallAdapter,ChannelsAdapter) — unchanged. They receive aConnectionand callaccept_bi/open_bion it. The dispatch throughBox<dyn BidiStreamSource>is transparent to them. SendStream/RecvStream— unchanged. They continue to wrap quinn/iroh/generic-stream sources via their own internal enum dispatch.BidiStreamSourceimplementations constructSendStream/RecvStreamvia the existingfrom_quinn/from_iroh/from_streamconstructors.BiStreamtrait — unchanged (ADR-005).BidiStreamSourceis the server-side / connection-level seam;BiStreamis a client-side / test convenience trait. Complementary, not competing.- The endpoint's accept loops (quinn/iroh) — unchanged. They construct
Connection::from_quinn/from_iroh, which now internally wrap aQuinnBidiStreamSource/IrohBidiStreamSource. The accept loops themselves don't touch the trait. Connection::remote_alpn/set_identity/identity— unchanged. These areConnection-level (thealpnfield and theidentityOnceLock), not transport-level. They stay onConnectionand do not appear onBidiStreamSource.
Consequences
Positive:
Connectionis open for extension. The channels crate implementsChannelBidiStreamSourcein its own crate and constructsConnectionfrom it viafrom_source— no core edit. A future transport, test double, or relay connection follows the same path. This is the structural payoff: the connection type is no longer a closed enum that every new connection shape must edit.- A channels connection is a first-class peer of QUIC: one
ChannelConnectionthat yields N streams, rather than a bag of yield- onceConnections. The channels layer's API matches its actual shape. - The ADR-007 leftover clippy warning (unused
code/reasonon theStreambackend under--no-default-features) is resolved — theStreamBidiStreamSource::closeimpl documents why the args are ignored, and the_prefix is intentional, not a missing fix. - Existing callers, handlers, and tests are unchanged. The public
ConnectionAPI is preserved verbatim; the refactor is internal. - No new deps.
async_traitis already a core dep (used byProtocolHandler).
Negative:
- One dyn-dispatch indirection per
accept_bi/open_bi/close/remote_addrcall. The previous enum match was also a branch, so the cost is roughly oneBox<dyn>method call per stream operation — negligible next to the async I/O those operations perform. Thealpn/identityfields stay onConnection(not behind the dyn), soremote_alpn/set_identity/identityhave no new indirection. BidiStreamSource: Send + Sync + 'staticis object-safe. This constrains implementations toSend + Sync + 'static, matchingProtocolHandler— consistent with the existing handler model.- The
Box<dyn BidiStreamSource>is one allocation perConnection. The enum was stack-allocated (except theStreamConn's innerMutex). Negligible per-connection cost; only matters if connections are constructed in a hot loop, which they are not.
References
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
- ADR-005: BiStream type definition (amended by ADR-007; this ADR does not
touch
BiStream; amended by ADR-009 —BiStreamis the concrete handler leaf, not a bare trait) - ADR-009:
BiStreamas the handler leaf (amends this ADR'saccept_bireturn type —(SendStream, RecvStream)→BiStream; the trait shape and thefrom_sourceextension point are preserved) - ADR-032: One-way door decision framework (why
ProtocolHandleris not changed — this ADR is additive toConnection, not a trait revision) - ADR-010: ALPN router and endpoint (the endpoint constructs
Connections viafrom_quinn/from_iroh; those now wrap aBidiStreamSourceimpl, transparently) - ADR-007:
Connection::from_stream— generic single-stream (this ADR generalizesConnectionto hold a trait object; thefrom_stream/from_bidiconstructors and the yield-once contract are preserved viaStreamBidiStreamSource) - Channels POC summary:
docs/research/alknet-channels/poc-summary.md§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02) - Channels Phase 0 findings:
docs/research/alknet-channels/phase-0-findings.md§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02