Files
alkcall/docs/architecture/decisions/007-connection-from-stream-generic-single-stream.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

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, then ConnectionClosed breaks the loop. Correct.
  • Handlers that call accept_bi once (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:

  1. TtyAdapter already establishes the pattern. The handler loops accept_bi and dispatches each stream internally. SSH does the same — parse channels, dispatch each. The multiplexing loop belongs in the handler, not the endpoint.
  2. 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_stream is additive — it extends Connection without touching the trait. If a trait change is ever warranted, it can come later; from_stream doesn'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 MockStream

SendStreamKind::MockSendStreamKind::Stream, RecvStreamKind::MockRecvStreamKind::Stream. SendStream::from_mockfrom_stream, RecvStream::from_mockfrom_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

  • ProtocolHandler trait shape — handle(&self, connection: Connection, auth: &AuthContext) stays. This is an additive change to Connection, not a trait revision (ADR-032: the trait is a one-way door).
  • HandlerRegistry — unchanged.
  • All handler code (HttpAdapter, TtyAdapter, CallAdapter) — unchanged. HttpAdapter is one accept_bi call away from transport-agnostic (it already is — the call works over from_stream).
  • BiStream trait — unchanged (ADR-005). from_stream is a server-side connection constructor; BiStream is 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_stream is a follow-up, not this ADR.

Consequences

Positive:

  • Every existing ProtocolHandler works over TCP+TLS, SSH channels, WebTransport streams, and wasm streams unchanged — dispatch through the same HandlerRegistry by ALPN string, no handler code changes.
  • api.alk.dev's HTTP blocker is resolved: a TCP+TLS accept loop can call Connection::from_bidi(tls_stream, alpn, remote_addr) and dispatch through HandlerRegistryHttpAdapter works unchanged over the single stream. (The accept loop itself is a follow-up commit; the primitive it needs is now in place.)
  • alknet-ssh is unblocked: the SSH handler wraps each russh channel via from_stream and dispatches by channel-type (treated as the ALPN string) through HandlerRegistry. 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_stream and dispatches through HandlerRegistry (the primitive exists; WT itself is parked per ADR-044).
  • The server-side WASM door (OQ-09) is no longer closed by Connection being QUIC-bound — from_stream accepts any AsyncRead + 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 MockConnection trait) — simpler type, shorter test stubs.
  • No new deps, no Cargo.toml change — tokio::io::split is already available via the existing tokio dep.

Negative:

  • open_bi on a single-stream connection returns StreamClosed — 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 need open_bi should run over QUIC, or multiplex their own sub-streams within the one stream (as the call protocol does over a single WebTransport stream — the EventEnvelope framing is stream-agnostic, ADR-015).
  • The close() method's code/reason args 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 semantic close already had for the removed Mock variant.
  • A Mutex on the StreamConn — a single lock per accept_bi / close call. Negligible cost (one take()), 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 — Connection is 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_stream is additive)
  • ADR-010: ALPN router and endpoint (amended by this ADR — "TCP is not an endpoint concern" is revised; from_stream lets TCP+TLS participate in ALPN dispatch via a handler-internal accept loop)
  • ADR-015: Call protocol stream model (the EventEnvelope framing is stream-agnostic — composes over from_stream)
  • OQ-09: WASM target boundaries (resolution amended — the server-side dispatch door is no longer closed by Connection being QUIC-bound)
  • Transport generalization findings: docs/research/transport-generalization/findings.md
  • Implementation commit: 865fef6 (2026-07-09)