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:
1 parent
ea66398c88
commit
f305f8c0a5
23 files changed
+3753
-204
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user