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).
236 lines
12 KiB
Markdown
236 lines
12 KiB
Markdown
# 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`
|
|
|
|
```rust
|
|
/// 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
|
|
|
|
- `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 `HandlerRegistry` — `HttpAdapter` 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`](../../research/transport-generalization/findings.md)
|
|
- Implementation commit: `865fef6` (2026-07-09) |