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,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user