Files
alkcall/docs/architecture/decisions/008-bidistreamsource-trait.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

302 lines
15 KiB
Markdown

# 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:
```rust
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 `Connection`s. 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:
1. The yield-once `Connection::from_stream` path is sufficient for per-channel
presentation — an echo `ProtocolHandler` runs through the full
demux→Connection→handler→mux path with zero channels-layer awareness
(`poc-summary.md` §"POC Target 2").
2. The `BidiStreamSource` trait is **additive** — existing callers keep working
via a `from_stream`-backed implementation of the trait, and the trait
cleanly supports a `ChannelConnection` that yields N streams
(`poc-summary.md` §"Issues Surfaced" #1).
3. The trait does not touch the `ProtocolHandler` trait shape (ADR-002) —
handlers continue to receive a `Connection` and call `accept_bi` /
`open_bi` on it. This is a `Connection` internal 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
```rust
#[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>`
```rust
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
separate `fn close_with_code(&self, code: u32, reason: &str)` default-
implemented to call `close()`. Non-QUIC impls implement only `close()`;
QUIC impls override `close_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 public `Connection::close` signature verbatim — no
caller change, no `Connection` API split.
**Decision: (b).** Rationale:
1. **No caller breakage.** `Connection::close(code, reason)` is the existing
public signature; every caller passes both args. Option (a) would force
either a `Connection::close` that *always* takes `code`/`reason` and
dispatches to the right trait method (which means the trait still has the
QUIC-shaped method, just renamed — no actual improvement), or a
`Connection::close` that drops the args (which breaks every caller).
2. **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-007 `Stream` case) have nothing to do with the args, and they're the
degenerate case.
3. **The clippy warning is fixed by the trait, not by renaming.** Under the
trait, the `StreamBidiStreamSource::close` impl prefixes the args with
`_code`/`_reason` and 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
- **`ProtocolHandler` trait shape** — `handle(&self, connection: Connection,
auth: &AuthContext)` stays. This is an internal `Connection` refactor, 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 a `Connection` and call
`accept_bi` / `open_bi` on it. The dispatch through `Box<dyn
BidiStreamSource>` is transparent to them.
- **`SendStream` / `RecvStream`** — unchanged. They continue to wrap
quinn/iroh/generic-stream sources via their own internal enum dispatch.
`BidiStreamSource` implementations construct `SendStream` / `RecvStream`
via the existing `from_quinn` / `from_iroh` / `from_stream` constructors.
- **`BiStream` trait** — unchanged (ADR-005). `BidiStreamSource` is the
server-side / connection-level seam; `BiStream` is 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 a
`QuinnBidiStreamSource` / `IrohBidiStreamSource`. The accept loops
themselves don't touch the trait.
- **`Connection::remote_alpn` / `set_identity` / `identity`** — unchanged.
These are `Connection`-level (the `alpn` field and the `identity` OnceLock),
not transport-level. They stay on `Connection` and do not appear on
`BidiStreamSource`.
## Consequences
**Positive:**
- `Connection` is open for extension. The channels crate implements
`ChannelBidiStreamSource` in its own crate and constructs `Connection`
from it via `from_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
`ChannelConnection` that yields N streams, rather than a bag of yield-
once `Connection`s. The channels layer's API matches its actual shape.
- The ADR-007 leftover clippy warning (unused `code`/`reason` on the
`Stream` backend under `--no-default-features`) is resolved — the
`StreamBidiStreamSource::close` impl documents why the args are ignored,
and the `_` prefix is intentional, not a missing fix.
- Existing callers, handlers, and tests are unchanged. The public
`Connection` API is preserved verbatim; the refactor is internal.
- No new deps. `async_trait` is already a core dep (used by
`ProtocolHandler`).
**Negative:**
- One dyn-dispatch indirection per `accept_bi` / `open_bi` / `close` /
`remote_addr` call. The previous enum match was also a branch, so the cost
is roughly one `Box<dyn>` method call per stream operation — negligible
next to the async I/O those operations perform. The `alpn` / `identity`
fields stay on `Connection` (not behind the dyn), so `remote_alpn` /
`set_identity` / `identity` have no new indirection.
- `BidiStreamSource: Send + Sync + 'static` is object-safe. This constrains
implementations to `Send + Sync + 'static`, matching `ProtocolHandler` —
consistent with the existing handler model.
- The `Box<dyn BidiStreamSource>` is one allocation per `Connection`. The
enum was stack-allocated (except the `StreamConn`'s inner `Mutex`).
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 — `BiStream` is the concrete handler
leaf, not a bare trait)
- ADR-009: `BiStream` as the handler leaf (amends this ADR's `accept_bi`
return type — `(SendStream, RecvStream)` → `BiStream`; the trait shape
and the `from_source` extension point are preserved)
- ADR-032: One-way door decision framework (why `ProtocolHandler` is not
changed — this ADR is additive to `Connection`, not a trait revision)
- ADR-010: ALPN router and endpoint (the endpoint constructs `Connection`s
via `from_quinn` / `from_iroh`; those now wrap a `BidiStreamSource` impl,
transparently)
- ADR-007: `Connection::from_stream` — generic single-stream (this ADR
generalizes `Connection` to hold a trait object; the `from_stream` /
`from_bidi` constructors and the yield-once contract are preserved via
`StreamBidiStreamSource`)
- Channels POC summary:
[`docs/research/alknet-channels/poc-summary.md`](../../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`](../../research/alknet-channels/phase-0-findings.md)
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02