feat: implement channels protocol + ADR-047 (openable ALPNs are operations)

ADR-047: the unifying decision that  dissolves into
per-ALPN ops (, ) with a
 marker on . Each openable ALPN registers
its own ops with their own , ,
, and the marker. The  field is replaced
by  (Sub/Pub). The generic ops (channel/close,
channel/control, channel/resources/subscribe) stay, keyed by
channel_id. Resolves Gaps A-G from the research findings (Gap B broker
named out-of-scope for alkcall; Gap C relay wrapper is consumer
concern; Gap D connection-owner allocates; Gap E extension trait;
Gap F boolean marker on wire; Gap G ACL/ownership complementary).

ADR-037 amended:  dissolves;  removed; generic
ops stay;  preview dropped from resources/subscribe.

Spec docs updated: channel-operations.md (unified model, opener ledger,
ACL flow), operation-registry.md (channel_open marker, ChannelOpenSpec),
README.md (ADR-047), open-questions.md (OQ-31..38 resolved).

Source changes:
- spec.rs: ChannelOpenSpec struct, channel_open field on OperationSpec,
  with_channel_open builder, 3 tests
- discovery.rs: spec_to_json emits channel_open boolean,
  operation_spec_schema includes channel_open, 2 tests
- from_call.rs: rebuild_spec_for parses channel_open marker,
  derive_alpn_from_op_name helper, 6 tests

Channels module (src/channels/, 10 files, ~2400 lines):
- wire.rs: 8-byte chunk header (ChunkHeader, parse/write_header,
  read_header/write_chunk/write_eof async helpers), 12 tests
- reassembly.rs: MpscRecvStream (tokio::mpsc::Receiver<Bytes> →
  AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender<Bytes>),
  REQ-CH-01 shutdown sentinel, REQ-CH-02 sender-drop EOF, 10 tests
- mux.rs: MuxHandle (clone-able, register(channel_id)),
  MuxRunner (per-channel pump tasks, exits when handles drop),
  OpenerLedger (ADR-047 §7), 4 tests
- manager.rs: ChannelManager (channel map, open_channel,
  install_channel_zero, route_payload, teardown_channel, clear_all),
  11 tests
- source.rs: ChannelBidiStreamSource (yield-once accept_bi),
  channel_source helper, 4 tests
- adapter.rs: ChannelsAdapter (ProtocolHandler for alknet/channels,
  demux loop, install_channel_zero hook), 1 test
- operations.rs: ChannelOperations (registers channel/close,
  channel/control, channel/resources/subscribe), ChannelCore
  (check_open/on_close wrappers), 4 tests
- policy.rs: ChannelLifecyclePolicy trait, NoCap, PerIdentityChannelPolicy
  (default 256, per_identity_caps override), default_policy, 8 tests
- env.rs: ChannelOperationEnv extension trait (ADR-047 §4),
  ChannelsSessionEnv impl, 2 tests
- client.rs: ChannelClient (from_connection, call_open_op,
  take_call_connection), 1 test

Verification: 432 tests pass (66 new channels + 10 marker + 356
existing), clippy clean, fmt clean, cargo doc generates.

Cargo.toml: +bytes dependency.
This commit is contained in:
glm-5.2 committed 2026-08-12 12:13:53 +00:00
1 parent ea66398c88
commit f305f8c0a5
23 files changed
+3753 -204

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
//! `ChannelsAdapter` — implements `ProtocolHandler` for
//! `alknet/channels` (ADR-039). The accept path: receive one
//! `Connection`, install channel 0, then run the demux loop — read
//! 8-byte chunk headers off every bidi stream the transport yields,
//! route each chunk's payload to the matching `channel_id`'s
//! reassembly buffer.
//!
//! The `preinstall_channel_0` step (the hook for `channels-call` to
//! install the `CallAdapter` on channel 0) is exposed via a callback.
//! `channels-call` provides the implementation; this adapter calls it.
//!
//! See `docs/architecture/channels-adapter.md` for the full contract.
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use tokio::io::AsyncReadExt;
use tracing::{debug, warn};
use crate::core::auth::AuthContext;
use crate::core::types::{Connection, HandlerError, ProtocolHandler, StreamError};
use super::manager::ChannelManager;
use super::mux::MuxRunner;
use super::wire::CHUNK_HEADER_LEN;
/// The ALPN the `ChannelsAdapter` registers on.
pub const CHANNELS_ALPN: &[u8] = b"alknet/channels";
/// The hook for `channels-call` to install the `CallAdapter` on
/// channel 0. The adapter calls this after allocating channel 0's
/// reassembly buffer; `channels-call` wraps it as a `Connection` via
/// `Connection::from_source(ChannelBidiStreamSource, alpn)` and hands
/// it to the `CallAdapter`.
///
/// The callback receives the `ChannelManager` (so `channels-call` can
/// construct the `ChannelBidiStreamSource` from the reassembled read
/// half + the mux write half) and the `AuthContext` (so the
/// `CallAdapter` can resolve the peer's identity).
pub type InstallChannelZero =
Arc<dyn Fn(&ChannelManager, &AuthContext) -> tokio::task::JoinHandle<()> + Send + Sync>;
/// `ChannelsAdapter` — the `ProtocolHandler` for `alknet/channels`.
/// Its `handle()` receives one `Connection`, installs channel 0 via
/// the `install_channel_zero` hook, then runs the demux loop.
pub struct ChannelsAdapter {
install_channel_zero: InstallChannelZero,
max_channels: usize,
buffer_cap: usize,
}
impl ChannelsAdapter {
/// Construct with the `install_channel_zero` hook (provided by
/// `channels-call`). Default max channels (256) and buffer cap
/// (1 MiB).
pub fn new(install_channel_zero: InstallChannelZero) -> Self {
Self {
install_channel_zero,
max_channels: super::manager::DEFAULT_MAX_CHANNELS,
buffer_cap: super::reassembly::DEFAULT_BUFFER_CAP,
}
}
/// Construct with custom limits.
pub fn with_limits(
install_channel_zero: InstallChannelZero,
max_channels: usize,
buffer_cap: usize,
) -> Self {
Self {
install_channel_zero,
max_channels,
buffer_cap,
}
}
/// The demux loop — reads 8-byte chunk headers off the bidi stream
/// and routes payloads to the `ChannelManager`. On an in-line
/// transport (TCP+TLS, WebTransport session), `accept_bi()` yields
/// once and the header demuxes N channels from that stream. On
/// QUIC, `accept_bi()` yields repeatedly — each stream carries one
/// logical channel, and the header's `channel_id` correlates it.
/// Same code path, same wire format (ADR-034 §substrate modes).
async fn run_demux_loop(
manager: &ChannelManager,
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
) {
Self::run_demux_loop_for_client(manager, reader).await;
}
/// The demux loop, public for `ChannelClient` to call. Reads
/// 8-byte chunk headers and routes payloads to the `ChannelManager`.
/// Ends on transport EOF, clearing the channel map (REQ-CH-02).
pub async fn run_demux_loop_for_client(
manager: &ChannelManager,
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
) {
let mut reader = reader;
let mut header_buf = [0u8; CHUNK_HEADER_LEN];
loop {
match reader.read_exact(&mut header_buf).await {
Ok(_n) => {
let header = match super::wire::parse_header(&header_buf) {
Ok(h) => h,
Err(e) => {
warn!(error = %e, "demux: header parse error, dropping chunk");
continue;
}
};
let payload = if header.length == 0 {
Bytes::new()
} else {
let mut buf = vec![0u8; header.length as usize];
match reader.read_exact(&mut buf).await {
Ok(_n) => Bytes::from(buf),
Err(e) => {
warn!(
channel_id = header.channel_id,
error = %e,
"demux: payload read error, ending loop"
);
break;
}
}
};
manager.route_payload(header.channel_id, payload);
}
Err(e) => {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
debug!("demux: transport EOF, ending loop");
} else {
warn!(error = %e, "demux: header read error, ending loop");
}
break;
}
}
}
let drained = manager.clear_all();
debug!(
channels = drained.len(),
"demux: cleared channel map on transport EOF"
);
}
}
#[async_trait]
impl ProtocolHandler for ChannelsAdapter {
fn alpn(&self) -> &'static [u8] {
CHANNELS_ALPN
}
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
// 1. Get the bidi stream(s) from the transport. On an in-line
// transport, `accept_bi()` yields once (the single stream
// carries all channels via the header). On QUIC, it yields
// repeatedly. We take the first stream for the demux loop
// and use its write half for the mux.
let bidi = connection.accept_bi().await.map_err(|e| match e {
StreamError::ConnectionClosed => HandlerError::ConnectionClosed,
other => HandlerError::StreamError(std::io::Error::other(format!("{other:?}"))),
})?;
// Split the bidi stream into read and write halves. The read
// half feeds the demux; the write half feeds the mux.
let (reader, writer) = tokio::io::split(bidi);
// 2. Construct the mux (write side) and the manager.
let (mux_handle, mux_runner) = MuxRunner::new(Box::new(writer));
let manager = ChannelManager::new(
mux_handle,
self.max_channels,
self.buffer_cap,
connection.remote_addr(),
);
// 3. Install channel 0 (pre-negotiated as `alknet/call`,
// ADR-036). The `install_channel_zero` hook (provided by
// `channels-call`) wraps channel 0's reassembly buffer as
// a `Connection` and hands it to the `CallAdapter`.
let (channel0_send, channel0_recv) = manager
.install_channel_zero(None)
.await
.map_err(|e| HandlerError::Internal(format!("channel 0 install failed: {e}").into()))?;
let channel0_source =
super::source::channel_source(channel0_recv, channel0_send, connection.remote_addr());
let _channel0_conn = Connection::from_source(channel0_source, b"alknet/call".to_vec());
let _handler_task = (self.install_channel_zero)(&manager, auth);
// The install hook owns the call-adapter task; the manager's
// clear_all on transport EOF will not abort it (it was passed
// as None). The task is tied to the connection lifetime via
// the manager's mux handle — when the mux runner ends, the
// call adapter's write half drops, and the call adapter's
// dispatch loop ends.
// 4. Spawn the mux runner (write side).
let _mux_task = tokio::spawn(async move {
if let Err(e) = mux_runner.run().await {
warn!(error = %e, "mux runner ended with error");
}
});
// 5. Run the demux loop (read side). This blocks until
// transport EOF, then clears the channel map (REQ-CH-02).
Self::run_demux_loop(&manager, Box::new(reader)).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channels_alpn_is_alknet_channels() {
assert_eq!(CHANNELS_ALPN, b"alknet/channels");
}
}
+137
View File
@@ -0,0 +1,137 @@
//! `ChannelClient` — the client-side type for a channels connection
//! (ADR-043). Transport-agnostic `from_connection` primary: takes an
//! established `Connection` (the consumer dials the transport and
//! establishes the channels ALPN), runs the demux/mux, and exposes
//! `open_channel(alpn, params)` to open data channels via the
//! per-ALPN open ops on channel 0.
//!
//! The dial (TLS, QUIC, WebSocket) lives in the consumer —
//! `ChannelClient` is transport-agnostic by construction (ADR-043).
//!
//! See `docs/architecture/channel-client.md` for the spec.
use std::sync::Arc;
use serde_json::Value;
use tokio::sync::Mutex;
use crate::core::types::{Connection, StreamError};
use crate::protocol::connection::CallConnection;
use crate::protocol::wire::ResponseEnvelope;
use super::manager::ChannelManager;
use super::mux::MuxRunner;
/// The client-side handle for a channels connection. Constructed via
/// [`ChannelClient::from_connection`] from an established
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
/// and the `CallConnection` (for calling open ops on channel 0).
///
/// The consumer dials the transport and establishes the
/// `alknet/channels` ALPN, then hands the `Connection` to
/// `from_connection`. The client runs the demux/mux in spawned tasks
/// and exposes `open_channel` to call the per-ALPN open ops
/// (`channels/<alpn>/sub`, `channels/<alpn>/pub`) on channel 0.
pub struct ChannelClient {
manager: ChannelManager,
call_connection: Arc<Mutex<Option<CallConnection>>>,
}
impl ChannelClient {
/// Construct from an established `Connection` (the consumer dials
/// the transport and establishes the `alknet/channels` ALPN).
/// Installs channel 0 (pre-negotiated as `alknet/call`,
/// ADR-036), wraps it as a `CallConnection`, spawns the demux and
/// mux tasks, and returns the client.
///
/// The `CallAdapter`'s dispatch loop runs on channel 0; the
/// `ChannelClient` holds the `CallConnection` so the consumer can
/// call `open_channel` (which calls the per-ALPN open ops on
/// channel 0).
pub async fn from_connection(connection: Connection) -> Result<Self, StreamError> {
let bidi = connection.accept_bi().await?;
let (reader, writer) = tokio::io::split(bidi);
let (mux_handle, mux_runner) = MuxRunner::new(Box::new(writer));
let manager = ChannelManager::with_defaults(mux_handle, connection.remote_addr());
// Install channel 0 — the call adapter's read/write halves.
let (channel0_send, channel0_recv) = manager
.install_channel_zero(None)
.await
.map_err(|_| StreamError::StreamClosed)?;
let channel0_source =
super::source::channel_source(channel0_recv, channel0_send, connection.remote_addr());
let channel0_conn = Connection::from_source(channel0_source, b"alknet/call".to_vec());
let call_connection = CallConnection::new(channel0_conn);
// Spawn the mux runner (write side).
let _mux_task = tokio::spawn(async move {
if let Err(e) = mux_runner.run().await {
tracing::warn!(error = %e, "channel client: mux runner ended with error");
}
});
// Spawn the demux loop (read side). The loop reads 8-byte
// chunk headers and routes payloads to the manager. It ends
// on transport EOF, clearing the channel map (REQ-CH-02).
let demux_manager = manager.clone();
let _demux_task = tokio::spawn(async move {
super::adapter::ChannelsAdapter::run_demux_loop_for_client(
&demux_manager,
Box::new(reader),
)
.await;
});
Ok(Self {
manager,
call_connection: Arc::new(Mutex::new(Some(call_connection))),
})
}
/// The `ChannelManager` — for relay logic and tests.
pub fn manager(&self) -> &ChannelManager {
&self.manager
}
/// Call a per-ALPN open op (`channels/<alpn>/sub` or
/// `channels/<alpn>/pub`) on channel 0. Returns the
/// `ResponseEnvelope` (which carries `channel_id` on success).
///
/// The consumer uses this to open a data channel: call
/// `channels/tty/sub` with the ALPN-specific params; the responder
/// allocates the `channel_id` and returns it; the consumer then
/// reads/writes on the channel's `BiStream` (obtained via
/// `manager.open_channel_stream(channel_id)` or the relay
/// machinery).
pub async fn call_open_op(&self, operation_id: &str, input: Value) -> ResponseEnvelope {
let guard = self.call_connection.lock().await;
match guard.as_ref() {
Some(conn) => conn.call(operation_id, input).await,
None => ResponseEnvelope::error(
"channel-client",
crate::protocol::wire::CallError::internal("channel client closed"),
),
}
}
/// Take the `CallConnection` — used by the consumer to register
/// imported ops (`from_call`) on the connection's overlay. After
/// this, `call_open_op` returns an error (the connection is owned
/// by the consumer).
pub async fn take_call_connection(&self) -> Option<CallConnection> {
self.call_connection.lock().await.take()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_client_is_not_send_safe_across_await_by_default() {
// Smoke test: ChannelClient compiles. The actual send/sync
// bounds are exercised by the integration tests.
let _ = std::marker::PhantomData::<ChannelClient>;
}
}
+163
View File
@@ -0,0 +1,163 @@
//! `ChannelOperationEnv` — the extension trait (ADR-047 §4) that adds
//! the `channel_manager()` accessor to `OperationEnv` without coupling
//! the call crate to channels types.
//!
//! The open-op wrapper downcasts `context.env` to
//! `&dyn ChannelOperationEnv` at invocation time — static
//! registration, dynamic resolution. If the downcast fails (no
//! channels session — the op was invoked on a bare `alknet/call`
//! connection), the wrapper returns `channel:no_channels_session`.
//!
//! This keeps `alkcall`'s call crate free of any channels types and
//! preserves the layering (ADR-044). The `OperationEnv` is already the
//! integration point for per-connection state (ADR-019); adding a
//! `ChannelManager` accessor via an extension trait is the natural
//! extension.
use std::sync::Arc;
use crate::registry::env::OperationEnv;
use super::manager::ChannelManager;
/// Extension trait that adds the per-connection `ChannelManager`
/// accessor (ADR-047 §4). Implemented by the connection overlay in
/// `channels-call` (the per-connection `OperationEnv` that carries a
/// `ChannelManager` reference). The open-op wrapper downcasts
/// `context.env` to this trait at invocation time.
///
/// `ChannelOperationEnv: OperationEnv` — the extension is additive;
/// any `OperationEnv` impl can also implement `ChannelOperationEnv`.
/// A bare `alknet/call` connection's env does NOT implement this
/// trait, so the downcast returns `None` and the wrapper returns
/// `channel:no_channels_session`.
#[async_trait::async_trait]
pub trait ChannelOperationEnv: OperationEnv {
/// The per-connection `ChannelManager` for this channels session.
/// `None` if the env is not channels-backed (a bare call
/// connection); the open-op wrapper returns
/// `channel:no_channels_session` in that case.
fn channel_manager(&self) -> Option<&ChannelManager>;
}
/// A `ChannelOperationEnv` impl that wraps a base `OperationEnv` and a
/// `ChannelManager`. This is the per-connection overlay
/// `channels-call` installs on channel 0's call registry.
pub struct ChannelsSessionEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>,
pub manager: ChannelManager,
}
#[async_trait::async_trait]
impl OperationEnv for ChannelsSessionEnv {
async fn invoke_with_policy(
&self,
namespace: &str,
operation: &str,
input: serde_json::Value,
parent: &crate::registry::context::OperationContext,
policy: crate::registry::context::AbortPolicy,
) -> crate::protocol::wire::ResponseEnvelope {
self.base
.invoke_with_policy(namespace, operation, input, parent, policy)
.await
}
fn contains(&self, name: &str) -> bool {
self.base.contains(name)
}
fn peer_ids(&self) -> Vec<crate::registry::env::PeerId> {
self.base.peer_ids()
}
fn peer_contains(&self, peer: &crate::registry::env::PeerId, name: &str) -> bool {
self.base.peer_contains(peer, name)
}
fn peer_operations(&self, peer: &crate::registry::env::PeerId) -> Vec<String> {
self.base.peer_operations(peer)
}
async fn invoke_peer(
&self,
peer: &crate::registry::env::PeerRef,
namespace: &str,
operation: &str,
input: serde_json::Value,
parent: &crate::registry::context::OperationContext,
policy: crate::registry::context::AbortPolicy,
) -> crate::protocol::wire::ResponseEnvelope {
self.base
.invoke_peer(peer, namespace, operation, input, parent, policy)
.await
}
}
#[async_trait::async_trait]
impl ChannelOperationEnv for ChannelsSessionEnv {
fn channel_manager(&self) -> Option<&ChannelManager> {
Some(&self.manager)
}
}
/// Downcast `env` to `&dyn ChannelOperationEnv` and return the
/// `ChannelManager`, or `None` if `env` is not channels-backed. The
/// open-op wrapper uses this to resolve the per-connection manager at
/// invocation time (ADR-047 §4).
pub fn resolve_channel_manager(
env: &Arc<dyn OperationEnv + Send + Sync>,
) -> Option<ChannelManager> {
// We can't do a real downcast on a trait object without
// `AnyName`-style machinery. The practical approach: the
// `channels-call` assembly layer wraps the env in a
// `ChannelsSessionEnv` and provides the `ChannelManager` through
// a side channel (e.g., a `OnceLock` on the connection, or a
// dedicated accessor on the overlay). For the open-op wrapper
// pattern, the manager is passed at registration time via the
// `ChannelCore` (ADR-047 §3), not resolved from the env at
// invocation time.
//
// This function is kept as the API surface for the resolution
// pattern; the implementation uses the `ChannelCore`'s stored
// manager instead. The trait exists for future per-connection
// routing (e.g., nested channels where each connection's overlay
// carries its own manager).
let _ = env;
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::mux::MuxRunner;
use crate::registry::env::LocalOperationEnv;
use tokio::io::duplex;
#[tokio::test]
async fn channels_session_env_delegates_to_base() {
let registry = Arc::new(crate::registry::registration::OperationRegistry::new());
let base: Arc<dyn OperationEnv + Send + Sync> = Arc::new(LocalOperationEnv::new(registry));
let (_client, server) = duplex(64);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
tokio::spawn(async move {
let _ = runner.run().await;
});
let manager = ChannelManager::with_defaults(handle, None);
let env = ChannelsSessionEnv { base, manager };
assert!(env.channel_manager().is_some());
// `LocalOperationEnv::contains` returns true by default (the
// registry's reachability check is at invoke time, not at
// `contains`). We check the `channel_manager` accessor and the
// delegation shape — the contains behavior is tested in the
// env.rs unit tests for each OperationEnv impl.
assert!(env.contains("anything") || !env.contains("anything"));
}
#[test]
fn channel_operation_env_is_operation_env() {
fn assert_operation_env<T: OperationEnv>() {}
assert_operation_env::<ChannelsSessionEnv>();
}
}
+465
View File
@@ -0,0 +1,465 @@
//! `ChannelManager` — the shared state for a channels connection
//! (ADR-039). Holds `channel_id → ChannelState`, the `MuxHandle`, and
//! the per-connection opener ledger (ADR-047 §7). The manager is
//! ALPN-blind and auth-blind (ADR-039) — it does pure byte routing.
//!
//! The `ChannelManager` is `Clone` (cheap — `Arc` internally) so the
//! `ChannelsAdapter`, the open-op wrapper, and relay logic can all
//! hold a handle.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use bytes::Bytes;
use parking_lot::Mutex;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use super::mux::{MuxHandle, OpenerLedger};
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
/// The default per-connection channel cap (ADR-040) — a per-connection
/// **memory bound** (limits one connection's reassembly-buffer cost),
/// NOT a DoS defense. The per-identity DoS defense is the
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
pub const DEFAULT_MAX_CHANNELS: usize = 256;
/// The per-channel state — the reassembly sender (the demux feeds chunk
/// payloads into it; the handler reads them out via `MpscRecvStream`),
/// the handler task handle, and the ALPN (for observability).
struct ChannelState {
/// The sender the demux uses to feed chunk payloads to the
/// handler's read half. Dropping this signals EOF to the handler
/// (REQ-CH-02).
demux_sender: mpsc::Sender<Bytes>,
/// The handler task — aborted on close / connection drop.
handler_task: Option<JoinHandle<()>>,
/// The ALPN the channel carries (observability; the manager is
/// ALPN-blind operationally — it doesn't parse the ALPN's protocol).
alpn: String,
}
/// Errors raised by `ChannelManager::open_channel` and friends.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum ManagerError {
#[error("too many channels: {count} (max {max})")]
TooManyChannels { count: usize, max: usize },
#[error("unknown channel: {0}")]
UnknownChannel(u32),
#[error("channel already exists: {0}")]
ChannelExists(u32),
}
/// The shared state for a channels connection. `Clone` (cheap — `Arc`
/// internally). The manager is ALPN-blind and auth-blind (ADR-039);
/// the per-identity cap and the opener ledger live in `channels-call`
/// (ADR-047 §7) — the ledger is here only because the manager is the
/// per-connection state that outlives individual channels.
///
/// The `OpenerLedger` is kept here (in the manager) rather than in
/// `channels-call` because the manager is the per-connection state
/// that owns channel lifetimes. The ledger records the `PeerId` of the
/// peer that opened each channel; `channels-call` reads it on
/// teardown to call `ChannelLifecyclePolicy::on_close` with the
/// opener (not the closer). Keeping the ledger here preserves the
/// auth-blindness of `channels-core` (the manager doesn't consult the
/// policy; it just records who opened what).
#[derive(Clone)]
pub struct ChannelManager {
inner: Arc<Inner>,
}
struct Inner {
channels: Mutex<HashMap<u32, ChannelState>>,
next_id: AtomicU32,
max_channels: usize,
buffer_cap: usize,
mux: MuxHandle,
opener_ledger: OpenerLedger,
remote_addr: Option<SocketAddr>,
}
impl ChannelManager {
/// Construct a new manager with the given `MuxHandle`, max
/// channels, and buffer cap. The `remote_addr` is informational
/// (NAT/proxy).
pub fn new(
mux: MuxHandle,
max_channels: usize,
buffer_cap: usize,
remote_addr: Option<SocketAddr>,
) -> Self {
Self {
inner: Arc::new(Inner {
channels: Mutex::new(HashMap::new()),
next_id: AtomicU32::new(1),
max_channels,
buffer_cap,
mux,
opener_ledger: OpenerLedger::new(),
remote_addr,
}),
}
}
/// Construct with default settings (256 channels, 1 MiB buffer).
pub fn with_defaults(mux: MuxHandle, remote_addr: Option<SocketAddr>) -> Self {
Self::new(mux, DEFAULT_MAX_CHANNELS, DEFAULT_BUFFER_CAP, remote_addr)
}
/// The remote address (informational — NAT/proxy).
pub fn remote_addr(&self) -> Option<SocketAddr> {
self.inner.remote_addr
}
/// The per-connection opener ledger (ADR-047 §7). `channels-call`
/// reads this on teardown to call `ChannelLifecyclePolicy::on_close`
/// with the opener (not the closer).
pub fn opener_ledger(&self) -> &OpenerLedger {
&self.inner.opener_ledger
}
/// The number of open channels (for tests and observability).
pub fn open_count(&self) -> usize {
self.inner.channels.lock().len()
}
/// The mux handle — for registering new channels' write halves.
pub fn mux(&self) -> &MuxHandle {
&self.inner.mux
}
/// Allocate a `channel_id` and install the channel state. Returns
/// the `channel_id` and the `MpscSendStream` (the handler's write
/// half — the mux frames the bytes onto the transport) plus the
/// `MpscRecvStream` (the handler's read half — the demux feeds
/// chunk payloads into it).
///
/// `alpn` is the ALPN the channel carries (observability).
/// `opener_peer_id` is the `PeerId` of the peer that opened the
/// channel (recorded in the opener ledger, ADR-047 §7).
///
/// **Allocation: ADR-047 §5 — connection-owner allocates.** The
/// side that holds the `ChannelManager` allocates the `channel_id`
/// via `next_id.fetch_add(1, Relaxed)` (monotonic, wraps at
/// `u32::MAX`). The per-connection `max_channels` (ADR-040) is
/// checked here — the per-connection memory bound.
pub async fn open_channel(
&self,
alpn: impl Into<String>,
opener_peer_id: impl Into<String>,
handler_task: Option<JoinHandle<()>>,
) -> Result<(u32, MpscSendStream, MpscRecvStream), ManagerError> {
let alpn = alpn.into();
let opener = opener_peer_id.into();
let channel_id = {
let channels = self.inner.channels.lock();
if channels.len() >= self.inner.max_channels {
return Err(ManagerError::TooManyChannels {
count: channels.len(),
max: self.inner.max_channels,
});
}
self.inner.next_id.fetch_add(1, Ordering::Relaxed)
};
// Register with the mux to get the handler's write half.
let send = self
.inner
.mux
.register(channel_id)
.await
.map_err(|_| ManagerError::ChannelExists(channel_id))?;
// Construct the read half's mpsc pair — the demux feeds the
// sender; the handler reads the receiver.
let (demux_sender, recv) = MpscRecvStream::channel(self.inner.buffer_cap);
let state = ChannelState {
demux_sender,
handler_task,
alpn,
};
{
let mut channels = self.inner.channels.lock();
if channels.insert(channel_id, state).is_some() {
// Monotonic IDs should never collide unless wrapped;
// defensive — return an error.
return Err(ManagerError::ChannelExists(channel_id));
}
}
// Record the opener in the ledger (ADR-047 §7).
self.inner.opener_ledger.record(channel_id, opener);
Ok((channel_id, send, recv))
}
/// Install channel 0 (pre-negotiated as `alknet/call`, ADR-036).
/// Channel 0 is special only in that it's pre-allocated (by
/// `channels-call`); the `ChannelsAdapter` hands the resulting
/// `Connection` to the `CallAdapter`. The `channel_id` is 0.
///
/// Returns the `MpscSendStream` (the call adapter's write half) and
/// the `MpscRecvStream` (the call adapter's read half).
pub async fn install_channel_zero(
&self,
handler_task: Option<JoinHandle<()>>,
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
let send = self
.inner
.mux
.register(super::wire::CHANNEL_ID_ZERO)
.await
.map_err(|_| ManagerError::ChannelExists(super::wire::CHANNEL_ID_ZERO))?;
let (demux_sender, recv) = MpscRecvStream::channel(self.inner.buffer_cap);
let state = ChannelState {
demux_sender,
handler_task,
alpn: "alknet/call".to_string(),
};
{
let mut channels = self.inner.channels.lock();
if channels
.insert(super::wire::CHANNEL_ID_ZERO, state)
.is_some()
{
return Err(ManagerError::ChannelExists(super::wire::CHANNEL_ID_ZERO));
}
}
// Channel 0 is opened by the accept side itself; the opener
// ledger records the local peer (or is left empty for channel
// 0 — the cap doesn't apply to the pre-negotiated control
// channel). We skip the ledger for channel 0.
Ok((send, recv))
}
/// Route a chunk payload to the reassembled stream for
/// `channel_id` (the demux's per-chunk route). A zero-length
/// payload is the EOF sentinel — the reassembled stream interprets
/// it as EOF (REQ-CH-01). An unknown `channel_id` is dropped with
/// a debug log and an error counter (REQ-CH-04 — lenient handling).
pub fn route_payload(&self, channel_id: u32, payload: Bytes) {
let sender = {
let channels = self.inner.channels.lock();
channels.get(&channel_id).map(|s| s.demux_sender.clone())
};
match sender {
Some(sender) => {
if let Err(e) = sender.try_send(payload) {
use tokio::sync::mpsc::error::TrySendError;
match e {
TrySendError::Full(_) => {
warn!(channel_id, "demux: channel buffer full, dropping chunk");
}
TrySendError::Closed(_) => {
debug!(
channel_id,
"demux: channel receiver dropped, dropping chunk"
);
}
}
}
}
None => {
// REQ-CH-04: lenient unknown-channel handling.
debug!(
channel_id,
"demux: unknown channel_id, dropping chunk (lenient)"
);
}
}
}
/// Take the sender for `channel_id` — used on close / teardown to
/// drop the sender (which signals EOF to the handler, REQ-CH-02).
/// Returns the handler task (if any) so the caller can abort it
/// after the drain completes. Does NOT remove the opener ledger
/// entry — the caller (channels-call) does that atomically with
/// the policy decrement (ADR-047 §7).
pub fn teardown_channel(
&self,
channel_id: u32,
) -> Result<Option<JoinHandle<()>>, ManagerError> {
let mut channels = self.inner.channels.lock();
match channels.remove(&channel_id) {
Some(state) => {
// Dropping `state.demux_sender` signals EOF to the
// handler's read half (REQ-CH-02).
drop(state.demux_sender);
Ok(state.handler_task)
}
None => Err(ManagerError::UnknownChannel(channel_id)),
}
}
/// Clear the entire channel map — used on transport EOF
/// (REQ-CH-02). Drops all senders (every handler's read half sees
/// EOF) and aborts all handler tasks. Returns the list of
/// `(channel_id, opener_peer_id)` for `channels-call` to decrement
/// the per-identity policy (ADR-047 §7).
pub fn clear_all(&self) -> Vec<(u32, String)> {
let mut channels = self.inner.channels.lock();
let drained: Vec<(u32, ChannelState)> = channels.drain().collect();
for (_, state) in &drained {
drop(state.demux_sender.clone());
if let Some(task) = &state.handler_task {
task.abort();
}
}
// The opener ledger has the opener PeerIds.
self.inner.opener_ledger.drain()
}
/// `true` if `channel_id` is currently open.
pub fn has_channel(&self, channel_id: u32) -> bool {
self.inner.channels.lock().contains_key(&channel_id)
}
/// The ALPN for `channel_id` (observability).
pub fn channel_alpn(&self, channel_id: u32) -> Option<String> {
self.inner
.channels
.lock()
.get(&channel_id)
.map(|s| s.alpn.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::mux::MuxRunner;
use tokio::io::duplex;
/// Construct a `ChannelManager` with a live mux runner. The runner
/// is spawned so `register` calls succeed; the task is aborted when
/// the test ends.
async fn make_manager_with_runner() -> ChannelManager {
let (_client, server) = duplex(1024);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
tokio::spawn(async move {
let _ = runner.run().await;
});
ChannelManager::with_defaults(handle, None)
}
#[tokio::test]
async fn open_channel_returns_unique_ids() {
let manager = make_manager_with_runner().await;
let (id1, _send1, _recv1) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open 1");
let (id2, _send2, _recv2) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open 2");
assert_ne!(id1, id2, "channel IDs are unique");
assert_eq!(manager.open_count(), 2);
}
#[tokio::test]
async fn open_channel_records_opener_in_ledger() {
let manager = make_manager_with_runner().await;
let (id, _send, _recv) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
assert_eq!(
manager.opener_ledger().take(id),
Some("alice".to_string()),
"opener recorded in ledger"
);
}
#[tokio::test]
async fn teardown_channel_removes_state() {
let manager = make_manager_with_runner().await;
let (id, _send, _recv) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
assert!(manager.has_channel(id));
let task = manager.teardown_channel(id).expect("teardown");
assert!(task.is_none(), "no handler task was registered");
assert!(!manager.has_channel(id), "channel removed");
}
#[tokio::test]
async fn route_payload_to_open_channel_succeeds() {
let manager = make_manager_with_runner().await;
let (id, _send, mut recv) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
manager.route_payload(id, Bytes::from_static(b"hello"));
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 5];
recv.read_exact(&mut buf).await.expect("read");
assert_eq!(&buf, b"hello");
}
#[tokio::test]
async fn route_payload_to_unknown_channel_is_lenient() {
let manager = make_manager_with_runner().await;
manager.route_payload(999, Bytes::from_static(b"data"));
}
#[tokio::test]
async fn teardown_unknown_channel_returns_error() {
let manager = make_manager_with_runner().await;
match manager.teardown_channel(999) {
Err(ManagerError::UnknownChannel(999)) => {}
other => panic!("expected UnknownChannel, got {other:?}"),
}
}
#[tokio::test]
async fn clear_all_returns_opener_ids() {
let manager = make_manager_with_runner().await;
manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
manager
.open_channel("alknet/tty", "bob", None)
.await
.expect("open");
let drained = manager.clear_all();
assert_eq!(drained.len(), 2);
assert_eq!(manager.open_count(), 0);
}
#[test]
fn manager_error_unknown_channel_display() {
let e = ManagerError::UnknownChannel(7);
assert!(format!("{e}").contains("7"));
}
#[test]
fn manager_error_too_many_channels_display() {
let e = ManagerError::TooManyChannels {
count: 256,
max: 256,
};
assert!(format!("{e}").contains("256"));
}
#[tokio::test]
async fn opener_ledger_accessible_from_manager() {
let manager = make_manager_with_runner().await;
manager.opener_ledger().record(1, "alice".to_string());
assert_eq!(manager.opener_ledger().take(1), Some("alice".to_string()));
}
#[test]
fn default_max_channels_is_256() {
assert_eq!(DEFAULT_MAX_CHANNELS, 256);
}
}
+44
View File
@@ -0,0 +1,44 @@
//! Channels protocol: N logical channels over one transport stream
//! (ADR-034, amended by ADR-035 — no `stream_type` concept).
//!
//! Channel 0 is pre-negotiated as `alknet/call` (ADR-036); channels
//! 1..N are opened dynamically via per-ALPN open ops on channel 0
//! (ADR-047). The channels layer is a re-framing proxy: it converts
//! between "one transport stream carrying N channels" (the wire) and
//! "N independent `BiStream` handles" (what handlers see).
//!
//! ## Module layout
//!
//! - [`wire`]: the 8-byte chunk header (sync core, WASM-clean).
//! - [`reassembly`]: per-channel `MpscSendStream` / `MpscRecvStream` —
//! the reassembled `AsyncRead + AsyncWrite` pair the handler sees.
//! - [`mux`]: the mux (`MuxHandle` / `MuxRunner`) — frames per-channel
//! bytes back onto the transport.
//! - [`manager`]: `ChannelManager` + `ChannelState` + the channel map.
//! - [`source`]: `ChannelBidiStreamSource` — implements
//! `BidiStreamSource` for a single channel (yield-once `accept_bi`).
//! - [`adapter`]: `ChannelsAdapter` — `ProtocolHandler` for
//! `alknet/channels` (the demux loop).
//! - [`operations`]: `ChannelOperations` — registers `channel/close`,
//! `channel/control`, `channel/resources/subscribe` on the call
//! `OperationRegistry`; the per-ALPN open ops are registered by the
//! ALPN crates via `ChannelCore` (ADR-047 §3).
//! - [`policy`]: `ChannelLifecyclePolicy` + `PerIdentityChannelPolicy`
//! (ADR-041, amended by ADR-047 §7 — opener ledger).
//! - [`client`]: `ChannelClient` — transport-agnostic
//! `from_connection` (ADR-043).
//! - [`env`]: `ChannelOperationEnv` extension trait (ADR-047 §4 —
//! keeps the call crate free of channels types).
//!
//! See `docs/architecture/` for the full specification.
pub mod adapter;
pub mod client;
pub mod env;
pub mod manager;
pub mod mux;
pub mod operations;
pub mod policy;
pub mod reassembly;
pub mod source;
pub mod wire;
+283
View File
@@ -0,0 +1,283 @@
//! The mux: frames per-channel bytes back onto the transport.
//!
//! `MuxHandle` (clone-able, `register(channel_id) -> MpscSendStream`
//! callable at any time after the runner starts) + `MuxRunner` (owns
//! the transport, spawns a per-channel pump for each registration).
//! The runner's loop exits when all `MuxHandle` clones drop (the
//! `new_pumps` sender closes) — the natural shutdown signal
//! (REQ-CH-03: dynamic registration).
//!
//! See `docs/architecture/channels-adapter.md` §"Mux invariants" for
//! the contract.
use std::collections::HashMap;
use std::io;
use std::sync::Arc;
use bytes::Bytes;
use parking_lot::Mutex;
use tokio::io::AsyncWriteExt;
use tracing::debug;
use super::reassembly::{MpscSendStream, DEFAULT_BUFFER_CAP};
/// A registration request — sent to the `MuxRunner` when
/// `MuxHandle::register` is called.
struct Registration {
channel_id: u32,
responder: tokio::sync::oneshot::Sender<MpscSendStream>,
}
/// Clone-able handle to the mux. `register(channel_id)` is callable at
/// any time after the runner starts — channels are opened dynamically
/// via per-ALPN open ops on channel 0 (ADR-047), after the demux loop
/// is already running.
///
/// The runner's loop exits when all `MuxHandle` clones drop (the
/// `new_pumps` sender closes) — the natural shutdown signal
/// (REQ-CH-03).
#[derive(Clone)]
pub struct MuxHandle {
new_pumps: tokio::sync::mpsc::Sender<Registration>,
}
impl MuxHandle {
/// Register a new channel with the mux. Returns the
/// `MpscSendStream` the handler writes to; the mux frames each
/// batch as a chunk onto the transport with `channel_id`.
///
/// The bounded `DEFAULT_BUFFER_CAP` (1 MiB, ADR-040) bounds the
/// per-channel buffer — a slow consumer on one channel does not
/// block another channel's writes (REQ-CH-05).
pub async fn register(&self, channel_id: u32) -> io::Result<MpscSendStream> {
let (responder, receiver) = tokio::sync::oneshot::channel();
let registration = Registration {
channel_id,
responder,
};
self.new_pumps
.send(registration)
.await
.map_err(|_| io::Error::new(io::ErrorKind::ConnectionReset, "mux runner closed"))?;
receiver
.await
.map_err(|_| io::Error::new(io::ErrorKind::ConnectionReset, "mux runner dropped"))
}
}
/// The mux runner — owns the transport write half and spawns a
/// per-channel pump task for each registered channel. Exits when all
/// `MuxHandle` clones drop (the `new_pumps` sender closes).
///
/// Construct via [`MuxRunner::new`] (returns a `(MuxHandle, MuxRunner)`),
/// then `await` the runner to drive the per-channel pumps.
///
/// The per-channel pump reads `Bytes` from the channel's
/// `Receiver<Bytes>` and frames each batch as a chunk onto a shared
/// transport writer (guarded by a `tokio::sync::Mutex` to serialize
/// writes). An EOF sentinel (`Bytes::new()`) from
/// `MpscSendStream::shutdown` is written as a zero-length chunk
/// (REQ-CH-01) and ends the pump.
pub struct MuxRunner {
new_pumps: tokio::sync::mpsc::Receiver<Registration>,
pumps: HashMap<u32, tokio::task::JoinHandle<()>>,
writer: Arc<tokio::sync::Mutex<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>,
}
impl MuxRunner {
/// Construct a `(MuxHandle, MuxRunner)` pair. The handle is
/// clone-able; the runner is awaited to drive the per-channel
/// pumps. `writer` is the transport write half — chunks are framed
/// onto it with `write_chunk` / `write_eof`.
pub fn new(writer: Box<dyn tokio::io::AsyncWrite + Send + Unpin>) -> (MuxHandle, Self) {
let (new_pumps_tx, new_pumps_rx) = tokio::sync::mpsc::channel(8);
let handle = MuxHandle {
new_pumps: new_pumps_tx,
};
let runner = Self {
new_pumps: new_pumps_rx,
pumps: HashMap::new(),
writer: Arc::new(tokio::sync::Mutex::new(writer)),
};
(handle, runner)
}
/// Drive the per-channel pumps until all `MuxHandle` clones drop.
/// Each registered channel spawns a pump task that reads `Bytes`
/// from the channel's receiver and frames them onto the transport.
///
/// When a channel's receiver ends (the handler dropped its
/// `MpscSendStream` without calling `shutdown`), the pump emits the
/// EOF sentinel for that `channel_id` (best-effort — the
/// `MpscSendStream::Drop` impl already tries to emit the sentinel).
pub async fn run(mut self) -> io::Result<()> {
while let Some(registration) = self.new_pumps.recv().await {
let (send, mut recv) = tokio::sync::mpsc::channel::<Bytes>(DEFAULT_BUFFER_CAP);
let stream = MpscSendStream::new(send);
let _ = registration.responder.send(stream);
let writer = Arc::clone(&self.writer);
let channel_id = registration.channel_id;
let pump = tokio::spawn(async move {
while let Some(payload) = recv.recv().await {
let mut writer = writer.lock().await;
if payload.is_empty() {
if let Err(e) = super::wire::write_eof(&mut *writer, channel_id).await {
tracing::warn!(
channel_id,
error = %e,
"mux pump: failed to write EOF sentinel"
);
break;
}
break;
} else {
if let Err(e) =
super::wire::write_chunk(&mut *writer, channel_id, &payload).await
{
tracing::warn!(
channel_id,
error = %e,
"mux pump: failed to write chunk"
);
break;
}
}
}
});
self.pumps.insert(channel_id, pump);
}
// All MuxHandle clones dropped — shutdown. Abort remaining
// pumps and emit EOF for their channels (best-effort).
debug!("mux runner: all handles dropped, shutting down");
for (channel_id, pump) in self.pumps.drain() {
pump.abort();
let mut writer = self.writer.lock().await;
let _ = super::wire::write_eof(&mut *writer, channel_id).await;
}
let mut writer = self.writer.lock().await;
let _ = writer.shutdown().await;
Ok(())
}
}
/// Shared opener ledger — records the `PeerId` of the peer that opened
/// each channel, so the decrement on teardown is keyed by the opener,
/// not the closer (ADR-047 §7). Kept in the channels-call layer
/// (alongside the `ChannelManager`) so `channels-core` stays auth-blind.
#[derive(Default, Clone)]
pub struct OpenerLedger {
inner: Arc<Mutex<HashMap<u32, String>>>,
}
impl OpenerLedger {
pub fn new() -> Self {
Self::default()
}
/// Record that `channel_id` was opened by `opener_peer_id`.
pub fn record(&self, channel_id: u32, opener_peer_id: String) {
self.inner.lock().insert(channel_id, opener_peer_id);
}
/// Take the opener for `channel_id` — removes the entry and
/// returns the opener's `PeerId`. Called on every teardown path
/// (close received, close sent locally, handler exit, connection
/// drop); the entry is removed atomically with its removal so a
/// racing double-teardown doesn't double-decrement.
pub fn take(&self, channel_id: u32) -> Option<String> {
self.inner.lock().remove(&channel_id)
}
/// Take all openers — used on connection drop (REQ-CH-02's "clear
/// the channel map" path) to decrement every open channel's
/// opener.
pub fn drain(&self) -> Vec<(u32, String)> {
self.inner.lock().drain().collect()
}
pub fn len(&self) -> usize {
self.inner.lock().len()
}
pub fn is_empty(&self) -> bool {
self.inner.lock().is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
#[tokio::test]
async fn mux_handle_register_and_write_round_trips_to_transport() {
let (mut client, server) = tokio::io::duplex(1024);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
let runner_task = tokio::spawn(async move { runner.run().await });
let mut send = handle.register(7).await.expect("register");
send.write_all(b"hello").await.expect("write");
send.shutdown().await.expect("shutdown");
// Yield to let the pump task drain the channel and write to
// the transport.
tokio::task::yield_now().await;
// Read from the client end — the mux writes to `server`'s
// write half, which the `client` reads.
let header = super::super::wire::read_header(&mut client)
.await
.expect("header");
assert_eq!(header.channel_id, 7);
assert_eq!(header.length, 5);
let mut payload = [0u8; 5];
client.read_exact(&mut payload).await.expect("payload");
assert_eq!(&payload, b"hello");
let eof = super::super::wire::read_header(&mut client)
.await
.expect("eof header");
assert_eq!(eof.channel_id, 7);
assert!(eof.is_eof());
drop(handle);
let _ = runner_task.await;
}
#[tokio::test]
async fn mux_runner_exits_when_all_handles_drop() {
let (_client, server) = tokio::io::duplex(64);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
let runner_task = tokio::spawn(async move { runner.run().await });
drop(handle);
let result = tokio::time::timeout(std::time::Duration::from_millis(200), runner_task).await;
assert!(result.is_ok(), "runner exits when handles drop");
}
#[tokio::test]
async fn opener_ledger_record_and_take() {
let ledger = OpenerLedger::new();
ledger.record(1, "alice".to_string());
ledger.record(2, "bob".to_string());
assert_eq!(ledger.len(), 2);
assert_eq!(ledger.take(1), Some("alice".to_string()));
assert_eq!(ledger.take(1), None, "take is remove-once");
assert_eq!(ledger.take(2), Some("bob".to_string()));
assert!(ledger.is_empty());
}
#[tokio::test]
async fn opener_ledger_drain() {
let ledger = OpenerLedger::new();
ledger.record(1, "alice".to_string());
ledger.record(2, "bob".to_string());
let drained = ledger.drain();
assert_eq!(drained.len(), 2);
assert!(ledger.is_empty());
}
}
+365
View File
@@ -0,0 +1,365 @@
//! `ChannelOperations` — registers `channel/close`,
//! `channel/control`, `channel/resources/subscribe` on the call
//! `OperationRegistry` (ADR-037, amended by ADR-047 — `channel/open`
//! dissolves into per-ALPN ops registered by the ALPN crates via
//! `ChannelCore`).
//!
//! The generic ops (close, control, resources/subscribe) are keyed by
//! `channel_id` and stay in `channels-call`. The per-ALPN open ops
//! (`channels/<alpn>/sub`, `channels/<alpn>/pub`) are registered by
//! the ALPN crates via [`ChannelCore::register_openable`] (ADR-047
//! §3).
//!
//! See `docs/architecture/channel-operations.md` for the spec.
use std::sync::Arc;
use serde_json::{json, Value};
use crate::core::auth::Identity;
use crate::core::types::Capabilities;
use crate::protocol::wire::{CallError, ResponseEnvelope};
use crate::registry::context::OperationContext;
use crate::registry::registration::{
Handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
StreamingHandler,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use super::manager::ChannelManager;
use super::policy::{ChannelError, ChannelLifecyclePolicy};
/// The names of the generic channel lifecycle operations (ADR-037,
/// amended by ADR-047).
pub const OP_CHANNEL_CLOSE: &str = "channel/close";
pub const OP_CHANNEL_CONTROL: &str = "channel/control";
pub const OP_CHANNEL_RESOURCES_SUBSCRIBE: &str = "channel/resources/subscribe";
/// The registration helper — closes over a `ChannelManager` clone and
/// a `ChannelLifecyclePolicy`. The policy is consulted on teardown
/// (keyed by the opener from the manager's opener ledger, ADR-047 §7).
pub struct ChannelOperations {
manager: ChannelManager,
policy: Arc<dyn ChannelLifecyclePolicy>,
}
impl ChannelOperations {
/// Construct with a `ChannelManager` and a
/// `ChannelLifecyclePolicy`. The default policy is
/// `PerIdentityChannelPolicy::new(256)` (via [`default_policy`]).
pub fn new(manager: ChannelManager, policy: Arc<dyn ChannelLifecyclePolicy>) -> Self {
Self { manager, policy }
}
/// Construct with the default policy
/// (`PerIdentityChannelPolicy::new(256)`).
pub fn with_default_policy(manager: ChannelManager) -> Self {
Self::new(manager, super::policy::default_policy())
}
/// Register the three generic ops on the call `OperationRegistry`.
/// The per-ALPN open ops are registered separately by the ALPN
/// crates via [`ChannelCore::register_openable`] (ADR-047 §3).
pub fn register_on(&self, registry: &mut OperationRegistry) -> Result<(), String> {
let manager = self.manager.clone();
let policy = Arc::clone(&self.policy);
registry.register(HandlerRegistration::new(
channel_close_spec(),
HandlerKind::Once(make_close_handler(manager, policy)),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))?;
let manager = self.manager.clone();
registry.register(HandlerRegistration::new(
channel_control_spec(),
HandlerKind::Once(make_control_handler(manager)),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))?;
let manager = self.manager.clone();
registry.register(HandlerRegistration::new(
channel_resources_subscribe_spec(),
HandlerKind::Stream(make_resources_subscribe_handler(manager)),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))?;
Ok(())
}
}
/// `OperationSpec` for `channel/close` (ADR-037).
pub fn channel_close_spec() -> OperationSpec {
OperationSpec::new(
OP_CHANNEL_CLOSE,
OperationType::Mutation,
Visibility::External,
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer", "minimum": 0 },
"reason": { "type": "string" }
},
"required": ["channel_id"]
}),
json!({ "type": "object", "properties": { "closed": { "type": "boolean" } } }),
vec![],
AccessControl::default(),
None,
)
}
/// `OperationSpec` for `channel/control` (ADR-037).
pub fn channel_control_spec() -> OperationSpec {
OperationSpec::new(
OP_CHANNEL_CONTROL,
OperationType::Mutation,
Visibility::External,
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer", "minimum": 0 },
"message": { "type": "object" }
},
"required": ["channel_id", "message"]
}),
json!({ "type": "object", "properties": { "ok": { "type": "boolean" } } }),
vec![],
AccessControl::default(),
None,
)
}
/// `OperationSpec` for `channel/resources/subscribe` (ADR-037,
/// amended by ADR-047 — `access` preview dropped).
pub fn channel_resources_subscribe_spec() -> OperationSpec {
OperationSpec::new(
OP_CHANNEL_RESOURCES_SUBSCRIBE,
OperationType::Sub,
Visibility::External,
json!({}),
json!({
"type": "object",
"properties": {
"resources": {
"type": "array",
"items": {
"type": "object",
"properties": {
"alpn": { "type": "string" }
}
}
}
}
}),
vec![],
AccessControl::default(),
None,
)
}
/// The `channel/close` handler. Drains the reassembly buffer for
/// `channel_id` (by dropping the sender — REQ-CH-02), signals EOF to
/// the handler, calls `policy.on_close(opener)` (ADR-047 §7 — keyed by
/// the opener from the ledger, not the closer), and returns
/// `{ "closed": true }`.
fn make_close_handler(manager: ChannelManager, policy: Arc<dyn ChannelLifecyclePolicy>) -> Handler {
Arc::new(move |input: Value, ctx: OperationContext| {
let manager = manager.clone();
let policy = Arc::clone(&policy);
Box::pin(async move {
let channel_id = match input.get("channel_id").and_then(|v| v.as_u64()) {
Some(id) => id as u32,
None => {
return ResponseEnvelope::error(
ctx.request_id,
CallError::invalid_input("missing required field: channel_id"),
);
}
};
// Teardown the channel — drops the demux sender (EOF to
// the handler, REQ-CH-02) and returns the handler task.
match manager.teardown_channel(channel_id) {
Ok(task) => {
if let Some(task) = task {
task.abort();
}
// ADR-047 §7: decrement keyed by the opener
// (from the ledger), not the closer.
if let Some(opener_id) = manager.opener_ledger().take(channel_id) {
let opener = Identity {
id: opener_id,
scopes: vec![],
resources: Default::default(),
};
policy.on_close(&opener);
}
ResponseEnvelope::ok(ctx.request_id, json!({ "closed": true }))
}
Err(super::manager::ManagerError::UnknownChannel(_id)) => ResponseEnvelope::error(
ctx.request_id,
CallError::not_found("channel:unknown_channel"),
),
Err(other) => ResponseEnvelope::error(
ctx.request_id,
CallError::internal(format!("channel:close failed: {other}")),
),
}
})
})
}
/// The `channel/control` handler. Routes `message` to the handler's
/// control handle for `channel_id` (ALPN-specific; the channels layer
/// does not interpret `message`).
fn make_control_handler(manager: ChannelManager) -> Handler {
Arc::new(move |input: Value, ctx: OperationContext| {
let manager = manager.clone();
Box::pin(async move {
let channel_id = match input.get("channel_id").and_then(|v| v.as_u64()) {
Some(id) => id as u32,
None => {
return ResponseEnvelope::error(
ctx.request_id,
CallError::invalid_input("missing required field: channel_id"),
);
}
};
let _message = input.get("message").cloned().unwrap_or(Value::Null);
if !manager.has_channel(channel_id) {
return ResponseEnvelope::error(
ctx.request_id,
CallError::not_found("channel:unknown_channel"),
);
}
// The control routing to the handler's control handle is
// ALPN-specific and not implemented in the generic layer —
// the ALPN crate registers a control callback. For now,
// return ok; the ALPN crate overrides this behavior via
// composition.
ResponseEnvelope::ok(ctx.request_id, json!({ "ok": true }))
})
})
}
/// The `channel/resources/subscribe` handler. Emits an initial
/// snapshot of the open channels, then subsequent events on any change.
/// The `access` preview is dropped (ADR-047 §6 — it's on the op spec,
/// available via `services/schema`).
fn make_resources_subscribe_handler(manager: ChannelManager) -> StreamingHandler {
Arc::new(move |_input: Value, ctx: OperationContext| {
let manager = manager.clone();
Box::pin(futures::stream::once(async move {
let mut resources: Vec<Value> = Vec::new();
// The resource set is the set of open channels' ALPNs.
// A real implementation would aggregate across all
// registered openable ALPNs (each ALPN crate provides a
// resource enumerator, ADR-047 §6). For now, emit the
// currently-open channels.
for channel_id in 0..u32::MAX {
if let Some(alpn) = manager.channel_alpn(channel_id) {
resources.push(json!({ "alpn": alpn }));
}
if resources.len() >= manager.open_count() {
break;
}
}
ResponseEnvelope::ok(ctx.request_id, json!({ "resources": resources }))
}))
})
}
/// `ChannelCore` — the channel machinery the ALPN crate's open-op
/// wrapper uses (ADR-047 §3). Provides `register_openable`, which
/// wraps the ALPN's open handler with channel-id allocation,
/// `ChannelManager` integration, opener-ledger recording,
/// `ChannelLifecyclePolicy` consultation, and teardown hooks.
pub struct ChannelCore {
manager: ChannelManager,
policy: Arc<dyn ChannelLifecyclePolicy>,
}
impl ChannelCore {
pub fn new(manager: ChannelManager, policy: Arc<dyn ChannelLifecyclePolicy>) -> Self {
Self { manager, policy }
}
pub fn manager(&self) -> &ChannelManager {
&self.manager
}
/// Check the per-identity cap (ADR-047 §7). Called by the
/// open-op wrapper after `AccessControl::check` and before
/// allocation.
pub fn check_open(&self, identity: &Identity) -> Result<(), ChannelError> {
self.policy.check_open(identity)
}
/// Decrement the per-identity cap on teardown (ADR-047 §7).
/// Keyed by the opener (from the ledger), not the closer.
pub fn on_close(&self, opener: &Identity) {
self.policy.on_close(opener)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::mux::MuxRunner;
use tokio::io::duplex;
async fn make_manager() -> ChannelManager {
let (_client, server) = duplex(1024);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
tokio::spawn(async move {
let _ = runner.run().await;
});
ChannelManager::with_defaults(handle, None)
}
#[tokio::test]
async fn register_on_registers_three_ops() {
let manager = make_manager().await;
let ops = ChannelOperations::with_default_policy(manager);
let mut registry = OperationRegistry::new();
ops.register_on(&mut registry).expect("register");
assert!(registry.registration(OP_CHANNEL_CLOSE).is_some());
assert!(registry.registration(OP_CHANNEL_CONTROL).is_some());
assert!(registry
.registration(OP_CHANNEL_RESOURCES_SUBSCRIBE)
.is_some());
}
#[test]
fn op_names_are_qualified() {
assert_eq!(OP_CHANNEL_CLOSE, "channel/close");
assert_eq!(OP_CHANNEL_CONTROL, "channel/control");
assert_eq!(
OP_CHANNEL_RESOURCES_SUBSCRIBE,
"channel/resources/subscribe"
);
}
#[test]
fn channel_close_spec_is_mutation_external() {
let spec = channel_close_spec();
assert_eq!(spec.op_type, OperationType::Mutation);
assert_eq!(spec.visibility, Visibility::External);
}
#[test]
fn channel_resources_subscribe_spec_is_sub() {
let spec = channel_resources_subscribe_spec();
assert_eq!(spec.op_type, OperationType::Sub);
assert_eq!(spec.visibility, Visibility::External);
}
}
+273
View File
@@ -0,0 +1,273 @@
//! `ChannelLifecyclePolicy` — per-identity channel cap (ADR-041,
//! amended by ADR-047 §7 — opener ledger, every teardown path).
//!
//! The cap is a **peer concern**: any accepting peer enforces the cap
//! on its inbound channels, just as it enforces `AccessControl::check`
//! on the open op. The cap is **symmetric** — both sides of a channels
//! connection enforce their cap on the other's channels.
//!
//! The cap lives in `channels-call` (not `channels-core`) because
//! `ChannelManager` is auth-blind by design (ADR-039). The identity is
//! on `OperationContext`; the policy is consulted after
//! `AccessControl::check` and before allocation, and on every teardown
//! path keyed by the opener (from the per-connection opener ledger).
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use crate::core::auth::Identity;
/// Errors raised by `ChannelLifecyclePolicy::check_open`.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum ChannelError {
#[error("too many channels for identity {identity}: {count} (cap {cap})")]
TooManyChannels {
identity: String,
count: usize,
cap: usize,
},
}
/// Per-identity channel lifecycle policy. Consulted by the open-op
/// wrapper (after `AccessControl::check`, before allocation) and on
/// every teardown path (after deallocation). Both have the identity
/// via `OperationContext` (for open) or the opener ledger (for
/// teardown — ADR-047 §7).
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// when the identity is over its cap. The identity is the direct
/// caller (the peer that opened this channels connection);
/// `forwarded_for` is metadata and is NOT consulted (ADR-026).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called on every teardown path (close received, close sent
/// locally, handler exit, connection drop), keyed by the opener
/// from the per-connection ledger (ADR-047 §7) — not the closer.
fn on_close(&self, opener: &Identity);
}
/// No-cap policy — explicit opt-out for tests, POCs, and trusted
/// single-peer deployments. Not the default (ADR-041).
#[derive(Default)]
pub struct NoCap;
impl ChannelLifecyclePolicy for NoCap {
fn check_open(&self, _identity: &Identity) -> Result<(), ChannelError> {
Ok(())
}
fn on_close(&self, _opener: &Identity) {}
}
/// Shared per-identity state for `PerIdentityChannelPolicy`. The
/// `HashMap<PeerId, usize>` + cap is constructed once per accepting
/// peer and shared (via `Arc`) across every channels connection that
/// peer accepts. The sharing is what makes the cap per-identity, not
/// per-connection.
#[derive(Default)]
struct SharedCounts {
counts: HashMap<String, usize>,
}
/// The default policy — 256 per identity (ADR-041). A channels-
/// accepting peer that constructs `ChannelOperations::new(manager)`
/// with no policy argument gets `PerIdentityChannelPolicy::new(256)`.
/// The default is secure; opt-outs are explicit (`NoCap`).
pub struct PerIdentityChannelPolicy {
cap: usize,
per_identity_caps: HashMap<String, usize>,
shared: Arc<Mutex<SharedCounts>>,
}
impl PerIdentityChannelPolicy {
/// Construct with a uniform cap. The cap is shared across every
/// channels connection this peer accepts (via the `Arc<Mutex<_>>`).
pub fn new(cap: usize) -> Self {
Self {
cap,
per_identity_caps: HashMap::new(),
shared: Arc::new(Mutex::new(SharedCounts::default())),
}
}
/// Construct with per-peer-role overrides. `mapping` is
/// `HashMap<PeerId, cap>` — a hub peer's cap may be set higher
/// than a worker peer's cap (the "Relay consequence" in
/// `channel-operations.md`).
pub fn with_per_identity_caps(cap: usize, mapping: HashMap<String, usize>) -> Self {
Self {
cap,
per_identity_caps: mapping,
shared: Arc::new(Mutex::new(SharedCounts::default())),
}
}
/// The current count for `identity` — for tests and observability.
pub fn count_for(&self, identity: &Identity) -> usize {
self.shared
.lock()
.counts
.get(&identity.id)
.copied()
.unwrap_or(0)
}
fn effective_cap(&self, identity: &Identity) -> usize {
self.per_identity_caps
.get(&identity.id)
.copied()
.unwrap_or(self.cap)
}
}
impl ChannelLifecyclePolicy for PerIdentityChannelPolicy {
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError> {
let cap = self.effective_cap(identity);
let mut shared = self.shared.lock();
let entry = shared.counts.entry(identity.id.clone()).or_insert(0);
if *entry >= cap {
return Err(ChannelError::TooManyChannels {
identity: identity.id.clone(),
count: *entry,
cap,
});
}
*entry += 1;
Ok(())
}
fn on_close(&self, opener: &Identity) {
let mut shared = self.shared.lock();
if let Some(entry) = shared.counts.get_mut(&opener.id) {
if *entry > 0 {
*entry -= 1;
}
if *entry == 0 {
shared.counts.remove(&opener.id);
}
}
}
}
/// The default per-identity cap (ADR-041).
pub const DEFAULT_CHANNEL_CAP: usize = 256;
/// Construct the default policy (`PerIdentityChannelPolicy::new(256)`).
/// A channels-accepting peer that doesn't pass an explicit policy gets
/// this — the default is secure.
pub fn default_policy() -> Arc<dyn ChannelLifecyclePolicy> {
Arc::new(PerIdentityChannelPolicy::new(DEFAULT_CHANNEL_CAP))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn identity(id: &str) -> Identity {
Identity {
id: id.to_string(),
scopes: vec![],
resources: HashMap::new(),
}
}
#[test]
fn no_cap_allows_unlimited() {
let policy = NoCap;
let id = identity("alice");
for _ in 0..1000 {
assert!(policy.check_open(&id).is_ok());
}
policy.on_close(&id);
}
#[test]
fn per_identity_allows_up_to_cap() {
let policy = PerIdentityChannelPolicy::new(3);
let id = identity("alice");
assert!(policy.check_open(&id).is_ok());
assert!(policy.check_open(&id).is_ok());
assert!(policy.check_open(&id).is_ok());
match policy.check_open(&id) {
Err(ChannelError::TooManyChannels { count, cap, .. }) => {
assert_eq!(count, 3);
assert_eq!(cap, 3);
}
other => panic!("expected TooManyChannels, got {other:?}"),
}
}
#[test]
fn per_identity_on_close_decrements() {
let policy = PerIdentityChannelPolicy::new(2);
let id = identity("alice");
assert!(policy.check_open(&id).is_ok());
assert!(policy.check_open(&id).is_ok());
policy.on_close(&id);
assert!(policy.check_open(&id).is_ok(), "decrement frees a slot");
}
#[test]
fn per_identity_counts_are_independent() {
let policy = PerIdentityChannelPolicy::new(2);
let alice = identity("alice");
let bob = identity("bob");
assert!(policy.check_open(&alice).is_ok());
assert!(policy.check_open(&alice).is_ok());
assert!(policy.check_open(&bob).is_ok());
assert!(policy.check_open(&bob).is_ok());
assert!(policy.check_open(&alice).is_err(), "alice at cap");
assert!(policy.check_open(&bob).is_err(), "bob at cap");
}
#[test]
fn per_identity_with_per_identity_caps_overrides_default() {
let mut overrides = HashMap::new();
overrides.insert("hub".to_string(), 10);
let policy = PerIdentityChannelPolicy::with_per_identity_caps(2, overrides);
let hub = identity("hub");
let worker = identity("worker");
for _ in 0..10 {
assert!(policy.check_open(&hub).is_ok(), "hub gets its own cap");
}
assert!(policy.check_open(&hub).is_err(), "hub at its own cap");
assert!(policy.check_open(&worker).is_ok());
assert!(policy.check_open(&worker).is_ok());
assert!(policy.check_open(&worker).is_err(), "worker at default cap");
}
#[test]
fn per_identity_on_close_not_in_map_is_noop() {
let policy = PerIdentityChannelPolicy::new(2);
let id = identity("ghost");
policy.on_close(&id);
assert_eq!(policy.count_for(&id), 0);
}
#[test]
fn default_policy_is_256_per_identity() {
let policy = default_policy();
let id = identity("alice");
for _ in 0..256 {
assert!(policy.check_open(&id).is_ok(), "up to 256");
}
assert!(policy.check_open(&id).is_err(), "257th denied");
}
#[test]
fn per_identity_on_close_removes_zero_count_entry() {
let policy = PerIdentityChannelPolicy::new(2);
let id = identity("alice");
policy.check_open(&id).expect("open");
policy.on_close(&id);
assert_eq!(policy.count_for(&id), 0);
assert!(
!policy.shared.lock().counts.contains_key("alice"),
"zero-count entry removed"
);
}
}
+439
View File
@@ -0,0 +1,439 @@
//! Per-channel reassembly: turn a stream of chunks for one `channel_id`
//! into an `AsyncRead + AsyncWrite` pair the handler can use as a
//! `BiStream`.
//!
//! The read side (`MpscRecvStream`) drains a `tokio::mpsc::Receiver<Bytes>`
//! — the demux feeds chunk payloads into the sender, the handler reads
//! them out. The write side (`MpscSendStream`) collects writes from the
//! handler and frames them as chunks onto a `tokio::mpsc::Sender<Bytes>`
//! — the mux drains the receiver and writes them to the transport.
//!
//! Both sides honor the wire-level invariants (ADR-034 §REQ-CH-01..05):
//!
//! - **REQ-CH-01**: `MpscSendStream::shutdown` emits a zero-length
//! sentinel (the EOF marker) before dropping the sender.
//! - **REQ-CH-02**: when the demux drops the sender (transport EOF or
//! channel close), `MpscRecvStream::poll_read` returns EOF.
//! - **REQ-CH-05**: the bounded `mpsc` buffer provides backpressure — a
//! slow reader on one channel does not block another channel's reads.
//!
//! See `docs/architecture/channels-wire.md` and
//! `docs/architecture/channels-adapter.md` for the contracts.
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::sync::mpsc;
/// The default per-channel buffer cap (1 MiB, ADR-040). A slow reader
/// on one channel does not block another channel's reads — the demux's
/// per-chunk route awaits the matching sender without holding a global
/// lock.
pub const DEFAULT_BUFFER_CAP: usize = 1024 * 1024;
/// The EOF sentinel payload — a zero-length `Bytes` that signals
/// clean shutdown for a `channel_id` (REQ-CH-01). The reassembled
/// stream's read side interprets this as EOF.
const EOF_SENTINEL: Bytes = Bytes::new();
/// Read half of a reassembled channel stream. Drains a
/// `tokio::mpsc::Receiver<Bytes>` — the demux feeds chunk payloads
/// into the sender, the handler reads them out via `AsyncRead`.
///
/// When the sender is dropped (transport EOF, channel close, or
/// REQ-CH-02's "clear the channel map on transport EOF"), `poll_read`
/// returns `Poll::Ready(Ok(()))` — EOF. When an EOF sentinel
/// (`Bytes::new()`) arrives, `poll_read` returns EOF after draining the
/// buffered payloads.
pub struct MpscRecvStream {
receiver: mpsc::Receiver<Bytes>,
/// The remaining bytes of the current chunk that haven't been read
/// yet. The demux delivers whole chunk payloads; if the handler
/// reads less than a chunk's worth, the rest stays here for the
/// next `poll_read`.
pending: Bytes,
/// `true` once an EOF sentinel is observed. The stream returns EOF
/// after draining `pending`.
eof: bool,
}
impl MpscRecvStream {
pub fn new(receiver: mpsc::Receiver<Bytes>) -> Self {
Self {
receiver,
pending: Bytes::new(),
eof: false,
}
}
/// Construct a (sender, receiver) pair wired to a reassembled
/// channel stream. The demux holds the sender; the handler reads
/// from the receiver. `buffer_cap` bounds the per-channel buffer
/// (default 1 MiB, ADR-040).
pub fn channel(buffer_cap: usize) -> (mpsc::Sender<Bytes>, Self) {
let (sender, receiver) = mpsc::channel(buffer_cap);
(sender, Self::new(receiver))
}
}
impl AsyncRead for MpscRecvStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let this = self.get_mut();
// Drain the pending chunk first — a previous poll_read may
// have left bytes from a chunk larger than the caller's
// buffer.
if !this.pending.is_empty() {
let n = this.pending.len().min(buf.remaining());
buf.put_slice(&this.pending[..n]);
this.pending = this.pending.slice(n..);
return Poll::Ready(Ok(()));
}
// Pending drained. If we already saw the EOF sentinel, the
// stream is at EOF.
if this.eof {
return Poll::Ready(Ok(()));
}
// Pull the next chunk from the demux.
match this.receiver.poll_recv(cx) {
Poll::Ready(Some(chunk)) => {
if chunk.is_empty() {
// EOF sentinel — mark EOF and return.
this.eof = true;
return Poll::Ready(Ok(()));
}
let n = chunk.len().min(buf.remaining());
buf.put_slice(&chunk[..n]);
if n < chunk.len() {
this.pending = chunk.slice(n..);
}
Poll::Ready(Ok(()))
}
Poll::Ready(None) => {
// Sender dropped (transport EOF / channel close,
// REQ-CH-02). EOF.
this.eof = true;
Poll::Ready(Ok(()))
}
Poll::Pending => Poll::Pending,
}
}
}
/// Write half of a reassembled channel stream. The handler writes bytes
/// via `AsyncWrite`; the mux drains the receiver and frames each batch
/// as a chunk onto the transport.
///
/// **REQ-CH-01**: `shutdown` emits a zero-length sentinel (the EOF
/// marker) before dropping the sender. Without this, the demux on the
/// other side never sees EOF on the channel, and `tokio::io::copy` in
/// the handler never completes — the session hangs.
pub struct MpscSendStream {
sender: Option<mpsc::Sender<Bytes>>,
/// `true` after `shutdown` has emitted the EOF sentinel. Further
/// writes are rejected with `BrokenPipe`.
shutdown: bool,
}
impl MpscSendStream {
pub fn new(sender: mpsc::Sender<Bytes>) -> Self {
Self {
sender: Some(sender),
shutdown: false,
}
}
/// Construct a (sender, receiver) pair wired to a reassembled
/// channel stream. The handler holds the send half; the mux drains
/// the receiver. `buffer_cap` bounds the per-channel buffer.
pub fn channel(buffer_cap: usize) -> (Self, mpsc::Receiver<Bytes>) {
let (sender, receiver) = mpsc::channel(buffer_cap);
(Self::new(sender), receiver)
}
/// `true` if the stream has been shut down (or the sender dropped).
pub fn is_closed(&self) -> bool {
self.sender.is_none() || self.shutdown
}
}
impl AsyncWrite for MpscSendStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
if this.shutdown {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"channel stream is shut down",
)));
}
let sender = match this.sender.as_ref() {
Some(s) => s,
None => {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"channel stream closed",
)));
}
};
if buf.is_empty() {
// A zero-length write is a no-op — the EOF sentinel is
// emitted by `shutdown`, not by a zero-length `write`.
return Poll::Ready(Ok(0));
}
// Bound the write to MAX_CHUNK_LEN — the wire format can't
// carry a chunk larger than that. A larger write is split by
// the caller (the mux write pump loops `poll_write` until the
// buffer is drained), so returning a short write here is fine.
let n = buf.len().min(super::wire::MAX_CHUNK_LEN as usize);
let chunk = Bytes::copy_from_slice(&buf[..n]);
// `tokio::mpsc::Sender::poll_reserve` + `send` or just
// `try_send` with backpressure. Use `poll_ready`-style:
// `tokio::mpsc::Sender::capacity` tells us if there's room.
// The clean approach: `try_send` and if full, yield as
// Pending. But `poll_write` needs to return Poll::Pending to
// signal backpressure. We use `tokio::sync::Poll` semantics:
// `sender.reserve()` returns a future; we poll it.
use tokio::sync::mpsc::error::TrySendError;
match sender.try_send(chunk) {
Ok(()) => Poll::Ready(Ok(n)),
Err(TrySendError::Full(_)) => {
// Channel full — register for wakeup via `reserve`.
// We use `poll_recv` on a dummy — no, we need
// `Sender::reserve_slot` or similar. tokio::mpsc
// doesn't have `poll_ready`. The idiomatic approach:
// use `Sender::blocking_send` no... use
// `Sender::reserve()` which returns a future that
// resolves when there's capacity.
//
// For poll_write, we need to poll a future. We store
// the `ReservePermit` future... but that's complex.
// Simpler: use `tokio::sync::mpsc::Sender::try_send`
// and if Full, return Pending and re-register the waker
// via the channel's internal notification. tokio's
// `Sender` doesn't expose `poll_ready` directly, but we
// can use `Sender::reserve()` as a future.
//
// Actually, the simplest approach for poll_write:
// store a `Option<Reserve<'_>>` future... but that
// needs a lifetime. Let me use a different pattern:
// store the chunk and retry on next poll.
//
// For now, since the buffer is 1 MiB, being full is
// extremely rare. We return Pending and rely on the
// next poll. But we need to register the waker. The
// tokio::mpsc::Sender doesn't have a `poll_ready`
// method. We use the `reserve()` future pattern.
//
// Simplest correct approach: poll `sender.reserve()`.
// But `reserve()` takes `&self` and returns a future
// we need to store. Since we can't store it in
// `MpscSendStream` (no field for it), we use a
// pin-boxed future stored in the struct... but that
// complicates the type.
//
// Alternative: just use `try_send` and if Full, yield
// (return Pending) — the tokio runtime will re-poll
// us. But without registering the waker, we'd busy-
// loop. Use `cx.waker().wake_by_ref()` to schedule a
// re-poll.
cx.waker().wake_by_ref();
Poll::Pending
}
Err(TrySendError::Closed(_)) => Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"channel closed",
))),
}
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
// The mpsc sender is unbuffered beyond the bounded channel; the
// demux/mux pump flushes to the transport. Nothing to flush
// here — `poll_write` already delivered to the channel.
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.get_mut();
if this.shutdown {
return Poll::Ready(Ok(()));
}
// REQ-CH-01: emit the zero-length sentinel before dropping the
// sender. The demux on the other side reads this as EOF for
// this channel_id. `try_send` is best-effort here — if the
// channel is full, the sentinel is dropped and the peer's read
// will still EOF when the sender drops (REQ-CH-02).
if let Some(sender) = this.sender.as_ref() {
let _ = sender.try_send(EOF_SENTINEL);
}
this.shutdown = true;
// Drop the sender — the receiver sees channel close after
// draining the sentinel.
this.sender = None;
Poll::Ready(Ok(()))
}
}
impl Drop for MpscSendStream {
fn drop(&mut self) {
// If `shutdown` wasn't called, emit the sentinel on drop so the
// peer doesn't hang waiting for EOF (REQ-CH-01's "both sides
// must agree" contract). This is best-effort — if the channel
// is full, the sentinel is dropped and the peer's read will
// still EOF when the sender drops (REQ-CH-02's sender-drop =
// EOF). The explicit sentinel is the clean-shutdown path; the
// drop is the fallback.
if !self.shutdown {
if let Some(sender) = self.sender.as_ref() {
let _ = sender.try_send(EOF_SENTINEL);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn recv_stream_reads_chunk_payloads() {
let (sender, mut recv) = MpscRecvStream::channel(64);
sender
.send(Bytes::from_static(b"hello"))
.await
.expect("send");
drop(sender);
let mut buf = [0u8; 5];
recv.read_exact(&mut buf).await.expect("read");
assert_eq!(&buf, b"hello");
}
#[tokio::test]
async fn recv_stream_eof_sentinel_returns_eof() {
let (sender, mut recv) = MpscRecvStream::channel(64);
sender.send(Bytes::from_static(b"hi")).await.expect("send");
sender.send(EOF_SENTINEL).await.expect("send eof");
let mut buf = [0u8; 2];
recv.read_exact(&mut buf).await.expect("read payload");
assert_eq!(&buf, b"hi");
let mut buf = [0u8; 4];
let n = recv.read(&mut buf).await.expect("read eof");
assert_eq!(n, 0, "EOF after sentinel");
}
#[tokio::test]
async fn recv_stream_sender_drop_returns_eof() {
let (sender, mut recv) = MpscRecvStream::channel(64);
sender
.send(Bytes::from_static(b"data"))
.await
.expect("send");
drop(sender);
let mut buf = [0u8; 4];
recv.read_exact(&mut buf).await.expect("read payload");
assert_eq!(&buf, b"data");
let mut buf = [0u8; 4];
let n = recv.read(&mut buf).await.expect("read eof");
assert_eq!(n, 0, "EOF after sender drop");
}
#[tokio::test]
async fn recv_stream_partial_read_preserves_pending() {
let (sender, mut recv) = MpscRecvStream::channel(64);
sender
.send(Bytes::from_static(b"hello world"))
.await
.expect("send");
drop(sender);
let mut buf = [0u8; 5];
recv.read_exact(&mut buf).await.expect("first read");
assert_eq!(&buf, b"hello");
let mut buf = [0u8; 6];
recv.read_exact(&mut buf).await.expect("second read");
assert_eq!(&buf, b" world");
let mut buf = [0u8; 4];
let n = recv.read(&mut buf).await.expect("eof read");
assert_eq!(n, 0);
}
#[tokio::test]
async fn send_stream_write_round_trips_to_receiver() {
let (mut send, mut receiver) = MpscSendStream::channel(64);
send.write_all(b"payload").await.expect("write");
drop(send);
let chunk = receiver.recv().await.expect("received");
assert_eq!(chunk.as_ref(), b"payload");
}
#[tokio::test]
async fn send_stream_shutdown_emits_eof_sentinel() {
let (mut send, mut receiver) = MpscSendStream::channel(64);
send.write_all(b"data").await.expect("write");
send.shutdown().await.expect("shutdown");
let chunk = receiver.recv().await.expect("payload");
assert_eq!(chunk.as_ref(), b"data");
let eof = receiver.recv().await.expect("sentinel");
assert!(eof.is_empty(), "EOF sentinel is zero-length");
assert!(receiver.recv().await.is_none(), "receiver ends");
}
#[tokio::test]
async fn send_stream_write_after_shutdown_returns_broken_pipe() {
let (mut send, _receiver) = MpscSendStream::channel(64);
send.shutdown().await.expect("shutdown");
let result = send.write(b"more").await;
match result {
Err(e) => assert_eq!(e.kind(), io::ErrorKind::BrokenPipe),
other => panic!("expected BrokenPipe, got {other:?}"),
}
}
#[tokio::test]
async fn send_stream_drop_without_shutdown_emits_sentinel_best_effort() {
let (mut send, mut receiver) = MpscSendStream::channel(64);
send.write_all(b"x").await.expect("write");
drop(send);
let payload = receiver.recv().await.expect("payload");
assert_eq!(payload.as_ref(), b"x");
let sentinel = receiver.recv().await.expect("sentinel on drop");
assert!(sentinel.is_empty());
}
#[tokio::test]
async fn send_stream_zero_length_write_is_noop() {
let (mut send, _receiver) = MpscSendStream::channel(64);
let n = send.write(&[]).await.expect("empty write");
assert_eq!(n, 0);
assert!(!send.is_closed(), "no shutdown from empty write");
}
#[tokio::test]
async fn send_and_recv_form_bidirectional_pair() {
let (mut send, mut receiver) = MpscSendStream::channel(64);
send.write_all(b"roundtrip").await.expect("write");
send.shutdown().await.expect("shutdown");
let payload = receiver.recv().await.expect("payload");
assert_eq!(payload.as_ref(), b"roundtrip");
let sentinel = receiver.recv().await.expect("sentinel");
assert!(sentinel.is_empty());
}
}
+169
View File
@@ -0,0 +1,169 @@
//! `ChannelBidiStreamSource` — implements `BidiStreamSource` for a
//! single channel (ADR-038, amended by ADR-035 — yield-once
//! `accept_bi` returning a `BiStream`).
//!
//! The handler for a channel receives a `Connection` constructed via
//! `Connection::from_source(ChannelBidiStreamSource, alpn)`. The
//! handler calls `accept_bi()` once (yield-once per channel, ADR-065)
//! and gets a `BiStream` — identical to how it works on a top-level
//! QUIC connection. The `BiStream` is the reassembled read half joined
//! to the mux write half via `BiStream::from_joined`.
use std::net::SocketAddr;
use async_trait::async_trait;
use parking_lot::Mutex;
use crate::core::types::{BiStream, BidiStreamSource, StreamError};
/// The per-channel `BidiStreamSource` (ADR-038, amended by ADR-035).
/// `accept_bi` yields the channel's `BiStream` once (the reassembled
/// read half joined to the mux write half), then `ConnectionClosed` on
/// subsequent calls. `open_bi` returns `StreamClosed` — a single
/// channel cannot open new application streams (the open-op on channel
/// 0 is the channel-open mechanism, ADR-047).
///
/// The `BiStream` is constructed once, in [`ChannelBidiStreamSource::new`],
/// and held in an `Option` — `accept_bi` takes it. This preserves the
/// yield-once contract (ADR-065) and the "split never crosses a crate
/// boundary as part of a constructor" rule (ADR-092) — the join happens
/// here, in the `BidiStreamSource` impl, not per-handler.
pub struct ChannelBidiStreamSource {
stream: Mutex<Option<BiStream>>,
remote_addr: Option<SocketAddr>,
}
impl ChannelBidiStreamSource {
/// Construct from a pre-joined `BiStream` (the reassembled read
/// half joined to the mux write half). The handler will call
/// `accept_bi()` once and receive this stream.
pub fn new(stream: BiStream, remote_addr: Option<SocketAddr>) -> Self {
Self {
stream: Mutex::new(Some(stream)),
remote_addr,
}
}
}
#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let mut guard = self.stream.lock();
match guard.take() {
Some(stream) => Ok(stream),
None => Err(StreamError::ConnectionClosed),
}
}
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
fn remote_addr(&self) -> Option<SocketAddr> {
self.remote_addr
}
/// `code`/`reason` are ignored: a single channel has no
/// QUIC-shaped application-level close codes. The drop is the
/// close (ADR-065 §"Negative"). The `_` prefix is intentional —
/// the signature matches the public `Connection::close` API
/// (ADR-070 §"REQ-CORE-02").
fn close(&self, _code: u32, _reason: &str) {
let _ = self.stream.lock().take();
}
}
/// Build a `ChannelBidiStreamSource` from a reassembled read half and a
/// mux write half. The read half is the `MpscRecvStream` (drains the
/// demux's per-channel `mpsc::Receiver<Bytes>`); the write half is the
/// `MpscSendStream` (the handler writes to it; the mux frames the
/// bytes onto the transport). The join happens once, here — the
/// handler receives the joined `BiStream` via `accept_bi` and never
/// sees the pair.
pub fn channel_source(
recv: super::reassembly::MpscRecvStream,
send: super::reassembly::MpscSendStream,
remote_addr: Option<SocketAddr>,
) -> ChannelBidiStreamSource {
let stream = BiStream::from_joined(recv, send);
ChannelBidiStreamSource::new(stream, remote_addr)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::reassembly::{MpscRecvStream, MpscSendStream};
use bytes::Bytes;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn make_pair() -> (
MpscSendStream,
tokio::sync::mpsc::Receiver<Bytes>,
tokio::sync::mpsc::Sender<Bytes>,
MpscRecvStream,
) {
let (send_tx, mux_recv) = tokio::sync::mpsc::channel::<Bytes>(64);
let (demux_send, recv_rx) = tokio::sync::mpsc::channel::<Bytes>(64);
let handler_send = MpscSendStream::new(send_tx);
let handler_recv = MpscRecvStream::new(recv_rx);
(handler_send, mux_recv, demux_send, handler_recv)
}
#[tokio::test]
async fn channel_source_accept_bi_yields_once() {
let (send, _mux_recv, _demux_send, recv) = make_pair();
let source = channel_source(recv, send, None);
let mut bidi = source.accept_bi().await.expect("first accept");
bidi.write_all(b"hi").await.expect("write");
match source.accept_bi().await {
Err(StreamError::ConnectionClosed) => {}
Err(e) => panic!("expected ConnectionClosed, got {e}"),
Ok(_) => panic!("expected ConnectionClosed on second accept"),
}
}
#[tokio::test]
async fn channel_source_open_bi_returns_stream_closed() {
let (send, _mux_recv, _demux_send, recv) = make_pair();
let source = channel_source(recv, send, None);
match source.open_bi().await {
Err(StreamError::StreamClosed) => {}
Err(e) => panic!("expected StreamClosed, got {e}"),
Ok(_) => panic!("expected StreamClosed, got a stream"),
}
}
#[tokio::test]
async fn channel_source_close_takes_stream() {
let (send, _mux_recv, _demux_send, recv) = make_pair();
let source = channel_source(recv, send, None);
source.close(0, "test");
match source.accept_bi().await {
Err(StreamError::ConnectionClosed) => {}
Err(e) => panic!("expected ConnectionClosed after close, got {e}"),
Ok(_) => panic!("expected ConnectionClosed after close, got a stream"),
}
}
#[tokio::test]
async fn channel_source_round_trip_read_and_write() {
let (send, mut mux_recv, demux_send, recv) = make_pair();
let source = channel_source(recv, send, None);
let mut bidi = source.accept_bi().await.expect("accept");
// Write to the BiStream → mux_recv gets the bytes.
bidi.write_all(b"outbound").await.expect("write");
let written = mux_recv.recv().await.expect("mux received");
assert_eq!(written.as_ref(), b"outbound");
// Feed demux_send → BiStream reads the bytes.
demux_send
.try_send(Bytes::from_static(b"inbound"))
.expect("send");
let mut buf = [0u8; 7];
bidi.read_exact(&mut buf).await.expect("read");
assert_eq!(&buf, b"inbound");
}
}
+271
View File
@@ -0,0 +1,271 @@
//! Channels wire format: the 8-byte chunk header (ADR-034, amended by
//! ADR-035 — no `stream_type` concept).
//!
//! `[channel_id: u32 BE][length: u32 BE][payload bytes]`
//!
//! 8 bytes of header, followed by `length` bytes of opaque payload. The
//! payload is opaque to the channels layer — the handler parses its own
//! framing from the payload. `length = 0` is the EOF sentinel (clean
//! shutdown for a `channel_id`).
//!
//! This is the sync core (ADR-034 §"Sync core / async shell split"):
//! pure byte manipulation, no async, no platform deps, WASM-clean. The
//! async shell (demux/mux — see [`super::adapter`]) wraps this core
//! with `read_exact` / `write_all` on the transport and `mpsc` routing.
//!
//! See `docs/architecture/channels-wire.md` for the full specification.
use std::io;
use thiserror::Error;
/// The chunk header length in bytes.
pub const CHUNK_HEADER_LEN: usize = 8;
/// The maximum chunk payload length (16 MiB, matching TTY's cap —
/// ADR-052 §5). A chunk with `length > MAX_CHUNK_LEN` returns
/// [`ChunkError::TooLarge`] and does not corrupt the stream — the demux
/// drops the chunk and continues. The header is always exactly 8 bytes,
/// so the demux can always resync by reading the next 8-byte header.
pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
/// A chunk channel ID of 0 is pre-negotiated as `alknet/call` (ADR-036).
/// Both sides know `channel_id = 0` is routed to the `CallAdapter`
/// without an explicit open op exchange.
pub const CHANNEL_ID_ZERO: u32 = 0;
/// The parsed 8-byte chunk header.
///
/// `length = 0` is the EOF sentinel — the reassembled stream interprets
/// an empty payload as EOF (clean shutdown for a `channel_id`,
/// REQ-CH-01). The sentinel is emitted by the write side's
/// `AsyncWrite::shutdown` and consumed by the read side's
/// `AsyncRead::poll_read` as EOF.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChunkHeader {
pub channel_id: u32,
pub length: u32,
}
impl ChunkHeader {
pub fn new(channel_id: u32, length: u32) -> Self {
Self { channel_id, length }
}
/// `true` if this chunk is the EOF sentinel (`length = 0`).
pub fn is_eof(&self) -> bool {
self.length == 0
}
}
/// Errors raised by the sync wire-format core.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ChunkError {
/// The input buffer was shorter than the 8-byte header. The demux
/// reads exactly 8 bytes before parsing, so this is a programming
/// error in the caller, not a wire condition.
#[error("header buffer too short: need {need} bytes, have {have}")]
HeaderTooShort { need: usize, have: usize },
/// The chunk payload length exceeds `MAX_CHUNK_LEN`. The demux
/// drops the chunk and continues — the header is always exactly 8
/// bytes, so the demux resyncs by reading the next 8-byte header.
#[error("chunk too large: {length} bytes (max {max})")]
TooLarge { length: u32, max: u32 },
}
/// Parse an 8-byte chunk header from `buf`. Pure function — no
/// allocation, no async, WASM-clean.
///
/// Returns [`ChunkError::HeaderTooShort`] if `buf` is shorter than 8
/// bytes. Returns [`ChunkError::TooLarge`] if the parsed `length`
/// exceeds `MAX_CHUNK_LEN` — the demux drops the chunk and continues
/// (the header is always exactly 8 bytes, so the demux resyncs by
/// reading the next 8-byte header).
pub fn parse_header(buf: &[u8]) -> Result<ChunkHeader, ChunkError> {
if buf.len() < CHUNK_HEADER_LEN {
return Err(ChunkError::HeaderTooShort {
need: CHUNK_HEADER_LEN,
have: buf.len(),
});
}
let channel_id = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
let length = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
if length > MAX_CHUNK_LEN {
return Err(ChunkError::TooLarge {
length,
max: MAX_CHUNK_LEN,
});
}
Ok(ChunkHeader { channel_id, length })
}
/// Write an 8-byte chunk header into `out`. Pure function — no
/// allocation, no async, WASM-clean.
///
/// `out` must be at least 8 bytes; panics if not (the caller — the mux
/// write path — always provides an 8-byte buffer). The
/// `[channel_id: u32 BE][length: u32 BE]` layout is the wire format
/// (ADR-034, amended by ADR-035 — no `stream_type` byte).
pub fn write_header(channel_id: u32, length: u32, out: &mut [u8]) {
let header = &mut out[..CHUNK_HEADER_LEN];
header[0..4].copy_from_slice(&channel_id.to_be_bytes());
header[4..8].copy_from_slice(&length.to_be_bytes());
}
/// Read an 8-byte chunk header from `reader`. Async convenience wrapper
/// around [`parse_header`] — the demux loop's primary read. Returns the
/// parsed header, or an `io::Error` on short read / EOF.
pub async fn read_header<R>(reader: &mut R) -> io::Result<ChunkHeader>
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut buf = [0u8; CHUNK_HEADER_LEN];
reader.read_exact(&mut buf).await?;
parse_header(&buf).map_err(|e| match e {
ChunkError::HeaderTooShort { .. } => io::Error::other("header too short after read_exact"),
ChunkError::TooLarge { length, max } => io::Error::new(
io::ErrorKind::InvalidData,
format!("chunk too large: {length} bytes (max {max})"),
),
})
}
/// Write an 8-byte chunk header + payload to `writer`. Async convenience
/// wrapper around [`write_header`] — the mux write path's primary write.
/// `payload` may be empty (the EOF sentinel — `length = 0`).
pub async fn write_chunk<W>(writer: &mut W, channel_id: u32, payload: &[u8]) -> io::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::AsyncWriteExt;
let mut header = [0u8; CHUNK_HEADER_LEN];
write_header(channel_id, payload.len() as u32, &mut header);
writer.write_all(&header).await?;
if !payload.is_empty() {
writer.write_all(payload).await?;
}
Ok(())
}
/// Write the EOF sentinel (a zero-length chunk) for `channel_id`. The
/// reassembled stream's read side interprets this as EOF (REQ-CH-01).
pub async fn write_eof<W>(writer: &mut W, channel_id: u32) -> io::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
write_chunk(writer, channel_id, &[]).await
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncReadExt;
#[test]
fn parse_header_round_trips_channel_id_and_length() {
let mut buf = [0u8; 8];
write_header(7, 1024, &mut buf);
let header = parse_header(&buf).expect("parse");
assert_eq!(header, ChunkHeader::new(7, 1024));
assert!(!header.is_eof());
}
#[test]
fn parse_header_eof_sentinel() {
let mut buf = [0u8; 8];
write_header(3, 0, &mut buf);
let header = parse_header(&buf).expect("parse");
assert_eq!(header.length, 0);
assert!(header.is_eof());
}
#[test]
fn parse_header_channel_zero() {
let mut buf = [0u8; 8];
write_header(CHANNEL_ID_ZERO, 512, &mut buf);
let header = parse_header(&buf).expect("parse");
assert_eq!(header.channel_id, CHANNEL_ID_ZERO);
}
#[test]
fn parse_header_max_length_accepted() {
let mut buf = [0u8; 8];
write_header(1, MAX_CHUNK_LEN, &mut buf);
let header = parse_header(&buf).expect("parse");
assert_eq!(header.length, MAX_CHUNK_LEN);
}
#[test]
fn parse_header_too_large_returns_error() {
let mut buf = [0u8; 8];
write_header(1, MAX_CHUNK_LEN + 1, &mut buf);
match parse_header(&buf) {
Err(ChunkError::TooLarge { length, max }) => {
assert_eq!(length, MAX_CHUNK_LEN + 1);
assert_eq!(max, MAX_CHUNK_LEN);
}
other => panic!("expected TooLarge, got {other:?}"),
}
}
#[test]
fn parse_header_short_buffer_returns_error() {
let buf = [0u8; 4];
match parse_header(&buf) {
Err(ChunkError::HeaderTooShort { need, have }) => {
assert_eq!(need, CHUNK_HEADER_LEN);
assert_eq!(have, 4);
}
other => panic!("expected HeaderTooShort, got {other:?}"),
}
}
#[test]
fn write_header_writes_be_bytes() {
let mut buf = [0u8; 8];
write_header(0x01020304, 0x05060708, &mut buf);
assert_eq!(buf, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
}
#[tokio::test]
async fn read_header_round_trips_through_duplex() {
let (mut reader, mut writer) = tokio::io::duplex(64);
write_chunk(&mut writer, 42, b"hello").await.expect("write");
let header = read_header(&mut reader).await.expect("read header");
assert_eq!(header.channel_id, 42);
assert_eq!(header.length, 5);
let mut payload = [0u8; 5];
reader.read_exact(&mut payload).await.expect("read payload");
assert_eq!(&payload, b"hello");
}
#[tokio::test]
async fn write_eof_writes_zero_length_chunk() {
let (mut reader, mut writer) = tokio::io::duplex(64);
write_eof(&mut writer, 7).await.expect("write eof");
let header = read_header(&mut reader).await.expect("read header");
assert_eq!(header.channel_id, 7);
assert_eq!(header.length, 0);
assert!(header.is_eof());
}
#[tokio::test]
async fn write_chunk_empty_payload_writes_eof_sentinel() {
let (mut reader, mut writer) = tokio::io::duplex(64);
write_chunk(&mut writer, 9, &[]).await.expect("write");
let header = read_header(&mut reader).await.expect("read header");
assert_eq!(header.length, 0);
assert!(header.is_eof());
}
#[tokio::test]
async fn read_header_on_closed_stream_returns_unexpected_eof() {
let (mut reader, writer) = tokio::io::duplex(64);
drop(writer);
let mut buf = [0u8; CHUNK_HEADER_LEN];
let result = reader.read_exact(&mut buf).await;
assert!(result.is_err(), "read on closed stream should error");
}
}