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

+30 -141
View File
@@ -8,9 +8,7 @@
//! from `gateway_routes`; `/openapi.json` serves the `to_openapi` projection
//! of the registry.
use std::io;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
@@ -229,12 +227,14 @@ impl ProtocolHandler for HttpAdapter {
let _ = connection.set_identity(identity);
}
let (send, recv) = connection
// `accept_bi` returns a `BiStream` (ADR-092) — already
// `AsyncRead + AsyncWrite + Send + Unpin`. No wrapper needed; pass
// it directly to `serve_io` via `TokioIo::new`.
let stream = connection
.accept_bi()
.await
.map_err(stream_error_to_handler)?;
let io = QuicStream::new(send, recv);
self.serve_io(io).await
self.serve_io(stream).await
}
}
@@ -268,51 +268,6 @@ fn stream_error_to_handler(e: StreamError) -> HandlerError {
HandlerError::from(e)
}
struct QuicStream {
send: alknet_core::types::SendStream,
recv: alknet_core::types::RecvStream,
}
impl QuicStream {
fn new(send: alknet_core::types::SendStream, recv: alknet_core::types::RecvStream) -> Self {
Self { send, recv }
}
}
impl AsyncRead for QuicStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.recv).poll_read(cx, buf)
}
}
impl AsyncWrite for QuicStream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
Pin::new(&mut self.send).poll_write(cx, buf)
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.send).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.send).poll_shutdown(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -418,29 +373,26 @@ mod tests {
async fn send_request_and_read_response(
request: &[u8],
) -> (String, tokio::task::JoinHandle<()>) {
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
// One duplex: `server_io` is the server's end (passed to `serve_io`);
// `client_io` is the client's end (writes requests, reads responses).
// `tokio::io::duplex` yields two `DuplexStream`s, each
// `AsyncRead + AsyncWrite + Send + Unpin` — the same bounds `BiStream`
// exposes (ADR-092), so no wrapper is needed.
let (server_io, mut client_io) = duplex(8 * 1024);
let adapter = HttpAdapter::new(provider(), empty_registry());
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
@@ -453,45 +405,6 @@ mod tests {
(response_str, handle)
}
struct QuicStreamDuplex {
read: tokio::io::DuplexStream,
write: tokio::io::DuplexStream,
}
impl AsyncRead for QuicStreamDuplex {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.read).poll_read(cx, buf)
}
}
impl AsyncWrite for QuicStreamDuplex {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
Pin::new(&mut self.write).poll_write(cx, buf)
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.write).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
Pin::new(&mut self.write).poll_shutdown(cx)
}
}
#[tokio::test]
async fn handle_serves_http_request_over_mock_quic_stream() {
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
@@ -509,29 +422,21 @@ mod tests {
let extra = Router::new().route("/v1/foo", get(|| async { (StatusCode::OK, "foo-body") }));
let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra);
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let (server_io, mut client_io) = duplex(8 * 1024);
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
let request = b"GET /v1/foo HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
@@ -556,29 +461,21 @@ mod tests {
);
let adapter = HttpAdapter::new(provider(), empty_registry()).with_extra_routes(extra);
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let (server_io, mut client_io) = duplex(8 * 1024);
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
let request = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
@@ -597,25 +494,17 @@ mod tests {
}
async fn serve_and_read(adapter: HttpAdapter, request: &[u8]) -> String {
let (mut client_send, server_recv) = duplex(8 * 1024);
let (server_send, mut client_recv) = duplex(8 * 1024);
let server_io = QuicStreamDuplex {
read: server_recv,
write: server_send,
};
let (server_io, mut client_io) = duplex(8 * 1024);
let handle = tokio::spawn(async move {
adapter.serve_io(server_io).await.ok();
});
client_send.write_all(request).await.unwrap();
client_send.flush().await.unwrap();
client_io.write_all(request).await.unwrap();
client_io.flush().await.unwrap();
let mut response = Vec::new();
let mut buf = [0u8; 4096];
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client_recv.read(&mut buf),
)
.await
match tokio::time::timeout(std::time::Duration::from_secs(5), client_io.read(&mut buf))
.await
{
Ok(Ok(0)) => break,
Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),