refactor(core,http,tty,call): unify stream leaf — BiStream as the handler leaf (Phase 6)

Implement ADR-092 across the workspace: accept_bi/open_bi return BiStream
(a concrete AsyncRead + AsyncWrite + Send + Unpin newtype), not the split
(SendStream, RecvStream) pair. The join moves into core's BidiStreamSource
impls (quinn/iroh via tokio::io::join, single-stream via boxed AsyncReadWrite);
handlers receive the joined BiStream and never see the pair.

Core (alknet-core/src/types.rs):
- Add concrete BiStream struct boxing Box<dyn AsyncReadWrite + Unpin>,
  with AsyncRead + AsyncWrite impls. from_joined (pub, for downstream
  crates that produce split halves naturally — channels reassembly, tests)
  and from_bidi (pub(crate), for Connection::from_bidi) constructors.
- Change BidiStreamSource::accept_bi/open_bi return types from
  (SendStream, RecvStream) to BiStream. Update QuinnBidiStreamSource,
  IrohBidiStreamSource, StreamBidiStreamSource impls to do the join once.
- Collapse SendStream/RecvStream to thin newtypes over
  Box<dyn Async* + Send + Unpin>. Remove SendStreamKind/RecvStreamKind
  enums and the quinn/iroh per-call dispatch (the join happens once in the
  BidiStreamSource impl now). Keep SendStream::from_stream /
  RecvStream::from_stream per-half boxing for into_sub_streams() (ADR-074)
  and the future channels reassembly path.
- Remove Connection::from_stream (split-pair constructor). Promote
  Connection::from_bidi to the only public stream constructor (the rule:
  the split never crosses a crate boundary as part of a constructor).
- Update Connection::accept_bi/open_bi to return BiStream. Update
  from_source_tests and tests modules to use from_bidi and BiStream;
  add a SinkEmpty test helper (AsyncRead EOF + AsyncWrite discard) for
  Connection-level-only test connections.

alknet-http (server/adapter.rs):
- Drop the 44-line QuicStream wrapper — accept_bi returns BiStream which
  is already AsyncRead + AsyncWrite. HttpAdapter::handle becomes 4 lines.
- Drop the 38-line QuicStreamDuplex test helper — tests use a single
  tokio::io::duplex whose ends are each AsyncRead + AsyncWrite natively.
- Remove unused std::io / std::pin::Pin imports.

alknet-tty (adapter.rs):
- TtyAdapter::handle splits the BiStream from accept_bi via
  tokio::io::split for drive_session's separate AsyncWrite/AsyncRead args
  (the stdlib idiom for TcpStream-style duplex streams).

alknet-call (protocol/*, client/*):
- Dispatcher::run_loop accept_bi site: take BiStream, pass to handle_stream.
- Dispatcher::handle_stream signature: take BiStream, split internally via
  tokio::io::split (was: take SendStream + RecvStream separately).
- CallConnection::call_with_payload / subscribe_with_payload / write_envelope:
  split the BiStream from open_bi via tokio::io::split at the call site.
- write_request / read_stream_until_closed: generic over AsyncWrite/AsyncRead
  (were: concrete SendStream/RecvStream) — accepts the ReadHalf/WriteHalf
  from tokio::io::split directly.
- Add protocol/test_support.rs with sink_empty_connection() (replaces the
  5 duplicated stub_connection() fns that used Connection::from_stream).
- Update all test stubs (call_client.rs, protocol/connection.rs,
  protocol/dispatch.rs, protocol/adapter.rs, client/from_call.rs) to use
  Connection::from_bidi + the shared sink_empty_connection() helper.
- Test handle_stream call sites: build BiStream::from_joined(recv, send)
  from the existing BufReader<Cursor> + duplex pair.

Workspace test status: all 9 crates pass (116 + 307 + 18 + 3 + 17 + 301 +
34 + 61 + 23 + 5 + 6 + 8 + 82 + 4 + 3 + 6 + 12 + 1 = 1007 tests pass). One
pre-existing failure remains in alknet-http
(adapters::to_mcp::tests::search_returns_access_control_filtered_ops_excluding_subscriptions
— handler kind mismatch, unrelated to Phase 6, fails on develop baseline).
This commit is contained in:
glm-5.2 committed 2026-07-18 15:59:01 +00:00
1 parent 249370345f
commit b60a5844ba
10 files changed
+412 -403

No files matched your search

+233 -150
View File
@@ -223,143 +223,193 @@ pub trait ProtocolHandler: Send + Sync + 'static {
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
// --- BiStream: the handler leaf (ADR-092) ---------------------------------
//
// `accept_bi`/`open_bi` yield `BiStream`, a concrete newtype that boxes the
// joined inner transport. The join happens once in the `BidiStreamSource`
// impl (quinn/iroh via `tokio::io::join`, single-stream via the input
// `AsyncRead + AsyncWrite` boxed directly). The split never crosses a crate
// boundary as part of a constructor: `Connection::from_bidi` is the only
// public stream constructor; `Connection::from_stream` is removed.
enum SendStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::SendStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::SendStream),
Stream(Box<dyn AsyncWrite + Send + Unpin>),
/// Internal helper trait — the union of `AsyncRead + AsyncWrite + Send +
/// Unpin`. Not public; exists only to give `BiStream` a single boxed field.
trait AsyncReadWrite: AsyncRead + AsyncWrite + Send {}
impl<T: AsyncRead + AsyncWrite + Send> AsyncReadWrite for T {}
/// The handler leaf — a bidirectional byte stream (ADR-092).
///
/// `accept_bi`/`open_bi` return a `BiStream`, not a split
/// `(SendStream, RecvStream)` pair. Handlers that want the split halves call
/// `tokio::io::split(&mut *stream)` (the stdlib idiom `tokio::io::split`
/// already provides for `TcpStream` and `TlsStream<TcpStream>`). The
/// split is a stdlib call at the handler boundary, not a per-handler trait
/// wrapper.
///
/// `BiStream: AsyncRead + AsyncWrite + Send + Unpin` by construction. The
/// old `pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}`
/// (ADR-007) is removed — the trait was never consumed, and the concrete
/// struct carries the same trait bounds forward as implied bounds, not a
/// marker trait. The name and the bounds survive; the shape becomes a
/// concrete leaf.
pub struct BiStream {
inner: Box<dyn AsyncReadWrite + Unpin>,
}
enum RecvStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::RecvStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::RecvStream),
Stream(Box<dyn AsyncRead + Send + Unpin>),
impl BiStream {
/// Join a read half and a write half into a single `BiStream`. The join
/// happens once, in the `BidiStreamSource` impl — handlers receive the
/// joined `BiStream` and never see the pair.
///
/// Public so that downstream crates (the channels reassembly path, tests
/// that construct a `BiStream` from independent halves) can join their
/// own halves. The rule this normalizes: **the split never crosses a
/// crate boundary as part of a constructor** — `Connection::from_bidi`
/// takes a joined `BiStream`, and `BiStream::from_joined` is the join.
/// A crate that produces split halves naturally (channels reassembly)
/// joins them itself via this constructor, then hands the `BiStream` to
/// `Connection::from_bidi` (or yields it from its own
/// `BidiStreamSource::accept_bi` impl).
pub fn from_joined<R, W>(reader: R, writer: W) -> Self
where
R: AsyncRead + Send + Unpin + 'static,
W: AsyncWrite + Send + Unpin + 'static,
{
Self {
inner: Box::new(tokio::io::join(reader, writer)),
}
}
/// Wrap a single value that is already `AsyncRead + AsyncWrite` (e.g.
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
/// `russh::Channel::into_stream()`). Used by the single-stream
/// `BidiStreamSource` impl and by `Connection::from_bidi`.
pub(crate) fn from_bidi<S>(stream: S) -> Self
where
S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
{
Self {
inner: Box::new(stream),
}
}
}
impl AsyncRead for BiStream {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf)
}
}
impl AsyncWrite for BiStream {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf)
}
fn poll_flush(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx)
}
}
// --- SendStream / RecvStream: thin newtypes (ADR-092) ---------------------
//
// These remain as the typed-sub-stream leaves for `into_sub_streams()`
// (ADR-074) and the channels reassembly path's `SubStreamHandle` leaves
// (future). They never cross a crate boundary as part of a `Connection`
// constructor — `Connection::from_bidi` is the only public constructor and
// takes a joined `BiStream`. The quinn-welded `SendStreamKind` /
// `RecvStreamKind` enums are gone; the quinn/iroh dispatch moves into the
// `BidiStreamSource` impls (the join happens once, there).
pub struct SendStream {
kind: SendStreamKind,
inner: Box<dyn AsyncWrite + Send + Unpin>,
}
pub struct RecvStream {
kind: RecvStreamKind,
inner: Box<dyn AsyncRead + Send + Unpin>,
}
impl SendStream {
#[cfg(feature = "quinn")]
fn from_quinn(stream: quinn::SendStream) -> Self {
Self {
kind: SendStreamKind::Quinn(stream),
}
}
#[cfg(feature = "iroh")]
fn from_iroh(stream: iroh::endpoint::SendStream) -> Self {
Self {
kind: SendStreamKind::Iroh(stream),
}
}
/// Box a write half into the thin `SendStream` newtype. Used by
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
/// Not a constructor that feeds `Connection` — the split never crosses
/// a crate boundary as part of a constructor (ADR-092).
pub fn from_stream(stream: impl AsyncWrite + Send + Unpin + 'static) -> Self {
Self {
kind: SendStreamKind::Stream(Box::new(stream)),
inner: Box::new(stream),
}
}
}
impl RecvStream {
#[cfg(feature = "quinn")]
fn from_quinn(stream: quinn::RecvStream) -> Self {
Self {
kind: RecvStreamKind::Quinn(stream),
}
}
#[cfg(feature = "iroh")]
fn from_iroh(stream: iroh::endpoint::RecvStream) -> Self {
Self {
kind: RecvStreamKind::Iroh(stream),
}
}
/// Box a read half into the thin `RecvStream` newtype. Used by
/// `into_sub_streams()` (ADR-074) and the channels reassembly path.
/// Not a constructor that feeds `Connection` — the split never crosses
/// a crate boundary as part of a constructor (ADR-092).
pub fn from_stream(stream: impl AsyncRead + Send + Unpin + 'static) -> Self {
Self {
kind: RecvStreamKind::Stream(Box::new(stream)),
inner: Box::new(stream),
}
}
}
impl AsyncWrite for SendStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
SendStreamKind::Stream(s) => {
AsyncWrite::poll_write(std::pin::Pin::new(s.as_mut()), cx, buf)
}
}
std::pin::Pin::new(self.inner.as_mut()).poll_write(cx, buf)
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
SendStreamKind::Stream(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s.as_mut()), cx),
}
std::pin::Pin::new(self.inner.as_mut()).poll_flush(cx)
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
SendStreamKind::Stream(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
}
std::pin::Pin::new(self.inner.as_mut()).poll_shutdown(cx)
}
}
impl AsyncRead for RecvStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
RecvStreamKind::Quinn(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
#[cfg(feature = "iroh")]
RecvStreamKind::Iroh(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
RecvStreamKind::Stream(s) => {
AsyncRead::poll_read(std::pin::Pin::new(s.as_mut()), cx, buf)
}
}
std::pin::Pin::new(self.inner.as_mut()).poll_read(cx, buf)
}
}
/// Yield bidirectional streams to a `Connection`. Downstream crates implement
/// this trait to add connection shapes (channels, a future transport, a test
/// double beyond the `from_stream` case) without editing `alknet-core`. See
/// double beyond the single-stream case) without editing `alknet-core`. See
/// ADR-070 for the full rationale and ADR-065 for the yield-once contract the
/// `StreamBidiStreamSource` impl preserves.
/// `StreamBidiStreamSource` impl preserves. The return type is `BiStream`
/// (ADR-092) — the join happens once, in the impl, not per-handler.
#[async_trait]
pub trait BidiStreamSource: Send + Sync + 'static {
/// Yield the next bidirectional stream this connection provides.
@@ -372,14 +422,14 @@ pub trait BidiStreamSource: Send + Sync + 'static {
/// `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>;
async fn accept_bi(&self) -> Result<BiStream, StreamError>;
/// Open a bidirectional stream to the peer.
///
/// Single-stream sources return `StreamClosed` (a single stream cannot
/// open new application streams — ADR-065). QUIC and channels sources
/// open new streams.
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
async fn open_bi(&self) -> Result<BiStream, StreamError>;
/// The peer's address, if available. Informational (NAT/proxy).
fn remote_addr(&self) -> Option<SocketAddr>;
@@ -401,22 +451,22 @@ struct QuinnBidiStreamSource {
#[cfg(feature = "quinn")]
#[async_trait]
impl BidiStreamSource for QuinnBidiStreamSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.accept_bi()
.await
.map_err(map_quinn_connection_error)?;
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
Ok(BiStream::from_joined(recv, send))
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.open_bi()
.await
.map_err(map_quinn_connection_error)?;
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
Ok(BiStream::from_joined(recv, send))
}
fn remote_addr(&self) -> Option<SocketAddr> {
@@ -439,22 +489,22 @@ struct IrohBidiStreamSource {
#[cfg(feature = "iroh")]
#[async_trait]
impl BidiStreamSource for IrohBidiStreamSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.accept_bi()
.await
.map_err(map_iroh_connection_error)?;
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
Ok(BiStream::from_joined(recv, send))
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
let (send, recv) = self
.conn
.open_bi()
.await
.map_err(map_iroh_connection_error)?;
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
Ok(BiStream::from_joined(recv, send))
}
fn remote_addr(&self) -> Option<SocketAddr> {
@@ -469,25 +519,25 @@ impl BidiStreamSource for IrohBidiStreamSource {
/// Single-stream `BidiStreamSource` (TCP+TLS, SSH channel, WebTransport
/// stream, wasm stream — ADR-065). Crate-private; constructed via
/// `Connection::from_stream` / `from_bidi` (no feature gate). `accept_bi`
/// yields the underlying stream once, then `ConnectionClosed`; `open_bi`
/// returns `StreamClosed`.
/// `Connection::from_bidi` (no feature gate). `accept_bi` yields the
/// underlying `BiStream` once, then `ConnectionClosed`; `open_bi` returns
/// `StreamClosed`.
struct StreamBidiStreamSource {
stream: Mutex<Option<(SendStream, RecvStream)>>,
stream: Mutex<Option<BiStream>>,
remote_addr: Option<SocketAddr>,
}
#[async_trait]
impl BidiStreamSource for StreamBidiStreamSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let mut guard = self.stream.lock().expect("stream mutex poisoned");
match guard.take() {
Some(pair) => Ok(pair),
Some(stream) => Ok(stream),
None => Err(StreamError::ConnectionClosed),
}
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
@@ -535,37 +585,30 @@ impl Connection {
}
}
/// 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 {
Self {
source: Box::new(StreamBidiStreamSource {
stream: Mutex::new(Some((
SendStream::from_stream(send),
RecvStream::from_stream(recv),
))),
remote_addr,
}),
alpn,
identity: OnceLock::new(),
}
}
/// Convenience for a single bidirectional stream (e.g. `TlsStream<TcpStream>`).
/// Splits internally via `tokio::io::split`.
/// Construct a `Connection` from a single bidirectional stream (e.g.
/// `tokio::io::DuplexStream`, `TlsStream<TcpStream>`,
/// `russh::Channel::into_stream()`). The stream is wrapped in a
/// `BiStream` (ADR-092) and yielded by `accept_bi` once, then
/// `ConnectionClosed`. `open_bi` returns `StreamClosed` (a single
/// stream can't open new application streams — ADR-065).
///
/// This is the only public stream constructor (ADR-092): the split
/// never crosses a crate boundary as part of a constructor. Handlers
/// that want the split halves call `tokio::io::split(&mut *stream)` on
/// the `BiStream` they receive from `accept_bi`.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self {
let (recv, send) = tokio::io::split(stream);
Self::from_stream(send, recv, alpn, remote_addr)
Self {
source: Box::new(StreamBidiStreamSource {
stream: Mutex::new(Some(BiStream::from_bidi(stream))),
remote_addr,
}),
alpn,
identity: OnceLock::new(),
}
}
/// Construct from a caller-supplied `BidiStreamSource` impl. The
@@ -591,12 +634,14 @@ impl Connection {
///
/// Handlers that loop `accept_bi` (e.g. `TtyAdapter`) get one session
/// per single-stream connection; handlers that call once (e.g.
/// `HttpAdapter`) get the stream directly. Both are correct.
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
/// `HttpAdapter`) get the stream directly. Both are correct. The
/// return type is `BiStream` (ADR-092); handlers that want the split
/// halves call `tokio::io::split` on the `BiStream`.
pub async fn accept_bi(&self) -> Result<BiStream, StreamError> {
self.source.accept_bi().await
}
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
pub async fn open_bi(&self) -> Result<BiStream, StreamError> {
self.source.open_bi().await
}
@@ -657,21 +702,21 @@ mod from_source_tests {
/// delegates to a caller-supplied impl. Not a built-in — the whole
/// point of `from_source` is that a non-core type can drive `Connection`.
struct RecordingSource {
stream: Mutex<Option<(SendStream, RecvStream)>>,
stream: Mutex<Option<BiStream>>,
addr: Option<SocketAddr>,
closed: Arc<Mutex<Option<(u32, String)>>>,
}
#[async_trait]
impl BidiStreamSource for RecordingSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
match self.stream.lock().expect("mock mutex poisoned").take() {
Some(pair) => Ok(pair),
Some(stream) => Ok(stream),
None => Err(StreamError::ConnectionClosed),
}
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
@@ -693,19 +738,16 @@ mod from_source_tests {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
// One duplex: the mock holds end `a` (split into send_a/recv_a); the
// test driver holds end `b` (split into send_b/recv_b) to echo back.
// One duplex: the mock holds end `a`; the test driver holds end `b`
// (split into send_b/recv_b) to echo back. The mock's `accept_bi`
// yields end `a` as a `BiStream`; the driver reads/writes end `b`.
let (a, b) = tokio::io::duplex(64);
let (recv_a, send_a) = tokio::io::split(a);
let (mut recv_b, mut send_b) = tokio::io::split(b);
let addr = Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7777));
let recorded = Arc::new(Mutex::new(None));
let conn = Connection::from_source(
RecordingSource {
stream: Mutex::new(Some((
SendStream::from_stream(send_a),
RecvStream::from_stream(recv_a),
))),
stream: Mutex::new(Some(BiStream::from_bidi(a))),
addr,
closed: Arc::clone(&recorded),
},
@@ -718,22 +760,22 @@ mod from_source_tests {
// remote_addr delegates to RecordingSource::remote_addr.
assert_eq!(conn.remote_addr(), addr);
// accept_bi delegates to RecordingSource::accept_bi and yields the pair.
let (mut send, mut recv) = conn.accept_bi().await.expect("first accept_bi yields");
// accept_bi delegates to RecordingSource::accept_bi and yields a BiStream.
let mut stream = conn.accept_bi().await.expect("first accept_bi yields");
// Write via the mock's SendStream -> arrives at the driver's recv_b.
send.write_all(b"hello").await.expect("write round-trips");
// Write via the mock's BiStream -> arrives at the driver's recv_b.
stream.write_all(b"hello").await.expect("write round-trips");
let mut buf = [0u8; 5];
recv_b.read_exact(&mut buf).await.expect("driver reads");
assert_eq!(&buf, b"hello");
// Driver writes back -> arrives at the mock's RecvStream.
// Driver writes back -> arrives at the mock's BiStream.
send_b
.write_all(b"world")
.await
.expect("driver writes back");
let mut buf = [0u8; 5];
recv.read_exact(&mut buf).await.expect("read round-trips");
stream.read_exact(&mut buf).await.expect("read round-trips");
assert_eq!(&buf, b"world");
// Second accept_bi delegates to RecordingSource::accept_bi -> ConnectionClosed.
@@ -763,11 +805,52 @@ mod from_source_tests {
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::pin::Pin;
use std::task::{Context, Poll};
/// A test-only `AsyncRead + AsyncWrite` pair equivalent to
/// `tokio::io::sink()` + `tokio::io::empty()`: reads yield EOF
/// immediately (zero bytes), writes discard. Exists because
/// `Connection::from_bidi` requires a single value that implements
/// both traits (ADR-092 — the split-pair `from_stream` constructor is
/// removed). Used only to construct a `Connection` for tests that
/// exercise `Connection`-level state (alpn, addr, identity) without
/// ever reading or writing the stream.
struct SinkEmpty;
impl AsyncRead for SinkEmpty {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
// EOF immediately — mirrors `tokio::io::empty()`.
Poll::Ready(Ok(()))
}
}
impl AsyncWrite for SinkEmpty {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
// Discard — mirrors `tokio::io::sink()`.
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
fn test_connection() -> Connection {
Connection::from_stream(
tokio::io::sink(),
tokio::io::empty(),
Connection::from_bidi(
SinkEmpty,
b"alknet/test".to_vec(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234)),
)
@@ -863,7 +946,7 @@ mod tests {
}
#[test]
fn connection_remote_alpn_and_addr_from_stream() {
fn connection_remote_alpn_and_addr_from_bidi() {
let conn = test_connection();
assert_eq!(conn.remote_alpn(), b"alknet/test");
assert_eq!(