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).
12 KiB
ADR-007: Connection::from_stream — Generic Single-Stream Connections
Status
Accepted
Context
ADR-005 defines Connection as a concrete type wrapping a QUIC connection
(quinn or iroh). ADR-010 establishes the endpoint as a multi-connectivity
QUIC acceptor — quinn and iroh, both producing QUIC connections dispatched by
ALPN. The ProtocolHandler trait (ADR-002) receives a Connection, and
handlers call accept_bi() / open_bi() to get bidirectional streams.
This design is welded to QUIC. Both real ConnectionKind variants
(Quinn, Iroh) are QUIC. The HttpAdapter::handle method calls
connection.accept_bi().await to get a bidi stream and serves HTTP over it
— "HTTP over QUIC," not "HTTP over TCP+TLS." There is no way to serve the
standard HTTP interface that api.alk.dev (an external app being built
against the crates) requires without either bypassing the
HandlerRegistry (a parallel listener, defeating the ALPN-router design)
or generalizing Connection to accept a non-QUIC stream.
The same welding blocks alknet-ssh (needs to dispatch SSH channels —
each channel is a read/write pair — through the same HandlerRegistry as
QUIC connections) and WebTransport stream dispatch (each WT stream is a
read/write pair). The TtyAdapter and CallAdapter dispatch loops are
already transport-agnostic in their inner logic — only the
connection.accept_bi() call is QUIC-coupled, because accept_bi only
works when Connection is QUIC-kind.
The yield-once contract composes
QUIC's accept_bi returns a new bidi stream per call (many). A generic
single-stream connection's accept_bi returns the underlying stream on the
first call, then ConnectionClosed on all subsequent calls. This is the
contract that makes the abstraction compose:
- Handlers that loop
accept_bi(TtyAdapter) get one session per single-stream connection — the loop body runs once, thenConnectionClosedbreaks the loop. Correct. - Handlers that call
accept_bionce (HttpAdapter) get the stream directly. Correct.
No branching on transport. The handler code is unchanged across QUIC
(many streams) and TCP+TLS / SSH channels / WebTransport streams (one
stream). The ProtocolHandler trait shape is not touched — this is an
additive change to Connection, not a trait revision.
The stream-level Mock variants were already generic
SendStreamKind::Mock(Box<dyn AsyncWrite>) and RecvStreamKind::Mock(Box<dyn AsyncRead>) were already generic stream holders — the name was wrong
(carried over from a test-only context). The generalization renames them
to Stream and makes them load-bearing: Connection::from_stream calls
SendStream::from_stream / RecvStream::from_stream to wrap the halves of
the single stream.
The connection-level Mock is removed
The findings doc (docs/research/transport-generalization/findings.md)
proposed keeping ConnectionKind::Mock / MockConnection for test-only
full-connection mocks. The implementation went further: MockConnection
and ConnectionKind::Mock are removed entirely. Test stubs that
previously used Connection::from_mock(Arc<StubConnection>) now use
Connection::from_stream(tokio::io::sink(), tokio::io::empty(), alpn, addr) — tokio::io::empty() yields immediate EOF on the read side,
causing the handler's handle_stream to exit cleanly, and accept_bi
returns ConnectionClosed after the first take (driving the run loop to
exit). This is simpler (one connection kind, not two) and the test stubs
are shorter. from_stream subsumes the test-mock use case because a test
connection is just a single-stream connection with EOF-on-read.
Why not change the ProtocolHandler trait
An earlier analysis proposed changing ProtocolHandler::handle to take a
single Channel instead of a Connection, moving the multiplexing loop
from the handler to the endpoint. This ADR does not do that:
- TtyAdapter already establishes the pattern. The handler loops
accept_biand dispatches each stream internally. SSH does the same — parse channels, dispatch each. The multiplexing loop belongs in the handler, not the endpoint. - The trait shape is a one-way door (ADR-032). Changing
handle(Connection)→handle(Channel)would require migrating every handler and would lock in a specific multiplexing model.from_streamis additive — it extendsConnectionwithout touching the trait. If a trait change is ever warranted, it can come later;from_streamdoesn't preclude it.
See docs/research/transport-generalization/findings.md §6 for the full
argument against the trait shape change.
Decision
Add ConnectionKind::Stream
A new variant holding a single read/write pair behind a
Mutex<Option<(SendStream, RecvStream)>> — the yield-once semantic. No
feature gate (generic, no transport deps). StreamConn is always
available; the quinn/iroh variants remain feature-gated.
Add Connection::from_stream and Connection::from_bidi
/// Construct a Connection from a pre-split read/write pair.
/// `accept_bi()` yields this pair once, then returns `ConnectionClosed`.
/// `open_bi()` returns `StreamClosed` (a single stream can't open new streams).
pub fn from_stream(
send: impl AsyncWrite + Send + Unpin + 'static,
recv: impl AsyncRead + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
/// Convenience for a single bidirectional stream (e.g. TlsStream<TcpStream>).
/// Splits internally via tokio::io::split.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
Make accept_bi's yield-once contract explicit
The accept_bi doc comment now states the transport semantics: QUIC yields
many streams, single-stream yields once then ConnectionClosed. This is
the contract that makes the abstraction compose — handlers don't branch
on transport.
Rename stream-level Mock → Stream
SendStreamKind::Mock → SendStreamKind::Stream,
RecvStreamKind::Mock → RecvStreamKind::Stream.
SendStream::from_mock → from_stream, RecvStream::from_mock →
from_stream. The variants were already generic stream holders; the name
was wrong. Drop the #[allow(dead_code)] — from_stream is now
load-bearing.
Remove MockConnection / ConnectionKind::Mock
The connection-level test mock trait and variant are removed. Test stubs
use Connection::from_stream with tokio::io::sink() / tokio::io::empty()
(immediate EOF on read → handler exits cleanly → accept_bi returns
ConnectionClosed → run loop exits). One connection kind for both
production and tests, not two.
open_bi on Stream returns StreamClosed
A single stream cannot open new application streams. open_bi on
ConnectionKind::Stream returns StreamError::StreamClosed. Handlers that
call open_bi (the call protocol's server→client direction) work over
QUIC but not over a single-stream connection — this is inherent to the
transport, not a flaw. A handler that needs open_bi should not be
dispatched over a single-stream connection (or should multiplex its own
sub-streams within the one stream, as the call protocol does over a single
WebTransport stream).
What does NOT change
ProtocolHandlertrait shape —handle(&self, connection: Connection, auth: &AuthContext)stays. This is an additive change toConnection, not a trait revision (ADR-032: the trait is a one-way door).HandlerRegistry— unchanged.- All handler code (
HttpAdapter,TtyAdapter,CallAdapter) — unchanged.HttpAdapteris oneaccept_bicall away from transport-agnostic (it already is — the call works overfrom_stream). BiStreamtrait — unchanged (ADR-005).from_streamis a server-side connection constructor;BiStreamis a client-side/test convenience trait. They're complementary, not competing.- The endpoint's accept loops (quinn/iroh) — unchanged. The TCP+TLS accept
loop that uses
from_streamis a follow-up, not this ADR.
Consequences
Positive:
- Every existing
ProtocolHandlerworks over TCP+TLS, SSH channels, WebTransport streams, and wasm streams unchanged — dispatch through the sameHandlerRegistryby ALPN string, no handler code changes. api.alk.dev's HTTP blocker is resolved: a TCP+TLS accept loop can callConnection::from_bidi(tls_stream, alpn, remote_addr)and dispatch throughHandlerRegistry—HttpAdapterworks unchanged over the single stream. (The accept loop itself is a follow-up commit; the primitive it needs is now in place.)alknet-sshis unblocked: the SSH handler wraps each russh channel viafrom_streamand dispatches by channel-type (treated as the ALPN string) throughHandlerRegistry. One SSH connection carries heterogeneous channels (alknet/tty,alknet/call,h2, ...) — a multiplexing power QUIC's per-connection ALPN doesn't give natively.- WebTransport stream dispatch is unblocked: the WT handler wraps each WT
stream via
from_streamand dispatches throughHandlerRegistry(the primitive exists; WT itself is parked per ADR-044). - The server-side WASM door (OQ-09) is no longer closed by
Connectionbeing QUIC-bound —from_streamaccepts anyAsyncRead + AsyncWrite, including wasm-compatible streams. (The accept-loop runtime remains tokio-bound; the connection door is now open.) - One connection kind for production and tests (no
MockConnectiontrait) — simpler type, shorter test stubs. - No new deps, no
Cargo.tomlchange —tokio::io::splitis already available via the existing tokio dep.
Negative:
open_bion a single-stream connection returnsStreamClosed— handlers that need server→client stream initiation (the call protocol's bidirectional call direction) don't work over a single-stream connection. This is inherent to the transport, not a design flaw: a single TCP+TLS stream is not a multiplexed transport. Handlers that needopen_bishould run over QUIC, or multiplex their own sub-streams within the one stream (as the call protocol does over a single WebTransport stream — theEventEnvelopeframing is stream-agnostic, ADR-015).- The
close()method'scode/reasonargs are QUIC-specific (application-level close codes). For a raw stream they're ignored — the drop is the close. This is the same best-effort semanticclosealready had for the removedMockvariant. - A
Mutexon theStreamConn— a single lock peraccept_bi/closecall. Negligible cost (onetake()), but it is a lock where the QUIC variants have none.
References
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
- ADR-005: BiStream type definition (amended by this ADR —
Connectionis no longer QUIC-only; the server-side WASM door is open) - ADR-032: One-way door decision framework (why the trait shape is not
changed —
from_streamis additive) - ADR-010: ALPN router and endpoint (amended by this ADR — "TCP is not an
endpoint concern" is revised;
from_streamlets TCP+TLS participate in ALPN dispatch via a handler-internal accept loop) - ADR-015: Call protocol stream model (the
EventEnvelopeframing is stream-agnostic — composes overfrom_stream) - OQ-09: WASM target boundaries (resolution amended — the server-side
dispatch door is no longer closed by
Connectionbeing QUIC-bound) - Transport generalization findings:
docs/research/transport-generalization/findings.md - Implementation commit:
865fef6(2026-07-09)