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

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

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

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

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

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

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

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

No files matched your search

+465
View File
@@ -0,0 +1,465 @@
//! `ChannelManager` — the shared state for a channels connection
//! (ADR-039). Holds `channel_id → ChannelState`, the `MuxHandle`, and
//! the per-connection opener ledger (ADR-047 §7). The manager is
//! ALPN-blind and auth-blind (ADR-039) — it does pure byte routing.
//!
//! The `ChannelManager` is `Clone` (cheap — `Arc` internally) so the
//! `ChannelsAdapter`, the open-op wrapper, and relay logic can all
//! hold a handle.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use bytes::Bytes;
use parking_lot::Mutex;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use super::mux::{MuxHandle, OpenerLedger};
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
/// The default per-connection channel cap (ADR-040) — a per-connection
/// **memory bound** (limits one connection's reassembly-buffer cost),
/// NOT a DoS defense. The per-identity DoS defense is the
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
pub const DEFAULT_MAX_CHANNELS: usize = 256;
/// The per-channel state — the reassembly sender (the demux feeds chunk
/// payloads into it; the handler reads them out via `MpscRecvStream`),
/// the handler task handle, and the ALPN (for observability).
struct ChannelState {
/// The sender the demux uses to feed chunk payloads to the
/// handler's read half. Dropping this signals EOF to the handler
/// (REQ-CH-02).
demux_sender: mpsc::Sender<Bytes>,
/// The handler task — aborted on close / connection drop.
handler_task: Option<JoinHandle<()>>,
/// The ALPN the channel carries (observability; the manager is
/// ALPN-blind operationally — it doesn't parse the ALPN's protocol).
alpn: String,
}
/// Errors raised by `ChannelManager::open_channel` and friends.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum ManagerError {
#[error("too many channels: {count} (max {max})")]
TooManyChannels { count: usize, max: usize },
#[error("unknown channel: {0}")]
UnknownChannel(u32),
#[error("channel already exists: {0}")]
ChannelExists(u32),
}
/// The shared state for a channels connection. `Clone` (cheap — `Arc`
/// internally). The manager is ALPN-blind and auth-blind (ADR-039);
/// the per-identity cap and the opener ledger live in `channels-call`
/// (ADR-047 §7) — the ledger is here only because the manager is the
/// per-connection state that outlives individual channels.
///
/// The `OpenerLedger` is kept here (in the manager) rather than in
/// `channels-call` because the manager is the per-connection state
/// that owns channel lifetimes. The ledger records the `PeerId` of the
/// peer that opened each channel; `channels-call` reads it on
/// teardown to call `ChannelLifecyclePolicy::on_close` with the
/// opener (not the closer). Keeping the ledger here preserves the
/// auth-blindness of `channels-core` (the manager doesn't consult the
/// policy; it just records who opened what).
#[derive(Clone)]
pub struct ChannelManager {
inner: Arc<Inner>,
}
struct Inner {
channels: Mutex<HashMap<u32, ChannelState>>,
next_id: AtomicU32,
max_channels: usize,
buffer_cap: usize,
mux: MuxHandle,
opener_ledger: OpenerLedger,
remote_addr: Option<SocketAddr>,
}
impl ChannelManager {
/// Construct a new manager with the given `MuxHandle`, max
/// channels, and buffer cap. The `remote_addr` is informational
/// (NAT/proxy).
pub fn new(
mux: MuxHandle,
max_channels: usize,
buffer_cap: usize,
remote_addr: Option<SocketAddr>,
) -> Self {
Self {
inner: Arc::new(Inner {
channels: Mutex::new(HashMap::new()),
next_id: AtomicU32::new(1),
max_channels,
buffer_cap,
mux,
opener_ledger: OpenerLedger::new(),
remote_addr,
}),
}
}
/// Construct with default settings (256 channels, 1 MiB buffer).
pub fn with_defaults(mux: MuxHandle, remote_addr: Option<SocketAddr>) -> Self {
Self::new(mux, DEFAULT_MAX_CHANNELS, DEFAULT_BUFFER_CAP, remote_addr)
}
/// The remote address (informational — NAT/proxy).
pub fn remote_addr(&self) -> Option<SocketAddr> {
self.inner.remote_addr
}
/// The per-connection opener ledger (ADR-047 §7). `channels-call`
/// reads this on teardown to call `ChannelLifecyclePolicy::on_close`
/// with the opener (not the closer).
pub fn opener_ledger(&self) -> &OpenerLedger {
&self.inner.opener_ledger
}
/// The number of open channels (for tests and observability).
pub fn open_count(&self) -> usize {
self.inner.channels.lock().len()
}
/// The mux handle — for registering new channels' write halves.
pub fn mux(&self) -> &MuxHandle {
&self.inner.mux
}
/// Allocate a `channel_id` and install the channel state. Returns
/// the `channel_id` and the `MpscSendStream` (the handler's write
/// half — the mux frames the bytes onto the transport) plus the
/// `MpscRecvStream` (the handler's read half — the demux feeds
/// chunk payloads into it).
///
/// `alpn` is the ALPN the channel carries (observability).
/// `opener_peer_id` is the `PeerId` of the peer that opened the
/// channel (recorded in the opener ledger, ADR-047 §7).
///
/// **Allocation: ADR-047 §5 — connection-owner allocates.** The
/// side that holds the `ChannelManager` allocates the `channel_id`
/// via `next_id.fetch_add(1, Relaxed)` (monotonic, wraps at
/// `u32::MAX`). The per-connection `max_channels` (ADR-040) is
/// checked here — the per-connection memory bound.
pub async fn open_channel(
&self,
alpn: impl Into<String>,
opener_peer_id: impl Into<String>,
handler_task: Option<JoinHandle<()>>,
) -> Result<(u32, MpscSendStream, MpscRecvStream), ManagerError> {
let alpn = alpn.into();
let opener = opener_peer_id.into();
let channel_id = {
let channels = self.inner.channels.lock();
if channels.len() >= self.inner.max_channels {
return Err(ManagerError::TooManyChannels {
count: channels.len(),
max: self.inner.max_channels,
});
}
self.inner.next_id.fetch_add(1, Ordering::Relaxed)
};
// Register with the mux to get the handler's write half.
let send = self
.inner
.mux
.register(channel_id)
.await
.map_err(|_| ManagerError::ChannelExists(channel_id))?;
// Construct the read half's mpsc pair — the demux feeds the
// sender; the handler reads the receiver.
let (demux_sender, recv) = MpscRecvStream::channel(self.inner.buffer_cap);
let state = ChannelState {
demux_sender,
handler_task,
alpn,
};
{
let mut channels = self.inner.channels.lock();
if channels.insert(channel_id, state).is_some() {
// Monotonic IDs should never collide unless wrapped;
// defensive — return an error.
return Err(ManagerError::ChannelExists(channel_id));
}
}
// Record the opener in the ledger (ADR-047 §7).
self.inner.opener_ledger.record(channel_id, opener);
Ok((channel_id, send, recv))
}
/// Install channel 0 (pre-negotiated as `alknet/call`, ADR-036).
/// Channel 0 is special only in that it's pre-allocated (by
/// `channels-call`); the `ChannelsAdapter` hands the resulting
/// `Connection` to the `CallAdapter`. The `channel_id` is 0.
///
/// Returns the `MpscSendStream` (the call adapter's write half) and
/// the `MpscRecvStream` (the call adapter's read half).
pub async fn install_channel_zero(
&self,
handler_task: Option<JoinHandle<()>>,
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
let send = self
.inner
.mux
.register(super::wire::CHANNEL_ID_ZERO)
.await
.map_err(|_| ManagerError::ChannelExists(super::wire::CHANNEL_ID_ZERO))?;
let (demux_sender, recv) = MpscRecvStream::channel(self.inner.buffer_cap);
let state = ChannelState {
demux_sender,
handler_task,
alpn: "alknet/call".to_string(),
};
{
let mut channels = self.inner.channels.lock();
if channels
.insert(super::wire::CHANNEL_ID_ZERO, state)
.is_some()
{
return Err(ManagerError::ChannelExists(super::wire::CHANNEL_ID_ZERO));
}
}
// Channel 0 is opened by the accept side itself; the opener
// ledger records the local peer (or is left empty for channel
// 0 — the cap doesn't apply to the pre-negotiated control
// channel). We skip the ledger for channel 0.
Ok((send, recv))
}
/// Route a chunk payload to the reassembled stream for
/// `channel_id` (the demux's per-chunk route). A zero-length
/// payload is the EOF sentinel — the reassembled stream interprets
/// it as EOF (REQ-CH-01). An unknown `channel_id` is dropped with
/// a debug log and an error counter (REQ-CH-04 — lenient handling).
pub fn route_payload(&self, channel_id: u32, payload: Bytes) {
let sender = {
let channels = self.inner.channels.lock();
channels.get(&channel_id).map(|s| s.demux_sender.clone())
};
match sender {
Some(sender) => {
if let Err(e) = sender.try_send(payload) {
use tokio::sync::mpsc::error::TrySendError;
match e {
TrySendError::Full(_) => {
warn!(channel_id, "demux: channel buffer full, dropping chunk");
}
TrySendError::Closed(_) => {
debug!(
channel_id,
"demux: channel receiver dropped, dropping chunk"
);
}
}
}
}
None => {
// REQ-CH-04: lenient unknown-channel handling.
debug!(
channel_id,
"demux: unknown channel_id, dropping chunk (lenient)"
);
}
}
}
/// Take the sender for `channel_id` — used on close / teardown to
/// drop the sender (which signals EOF to the handler, REQ-CH-02).
/// Returns the handler task (if any) so the caller can abort it
/// after the drain completes. Does NOT remove the opener ledger
/// entry — the caller (channels-call) does that atomically with
/// the policy decrement (ADR-047 §7).
pub fn teardown_channel(
&self,
channel_id: u32,
) -> Result<Option<JoinHandle<()>>, ManagerError> {
let mut channels = self.inner.channels.lock();
match channels.remove(&channel_id) {
Some(state) => {
// Dropping `state.demux_sender` signals EOF to the
// handler's read half (REQ-CH-02).
drop(state.demux_sender);
Ok(state.handler_task)
}
None => Err(ManagerError::UnknownChannel(channel_id)),
}
}
/// Clear the entire channel map — used on transport EOF
/// (REQ-CH-02). Drops all senders (every handler's read half sees
/// EOF) and aborts all handler tasks. Returns the list of
/// `(channel_id, opener_peer_id)` for `channels-call` to decrement
/// the per-identity policy (ADR-047 §7).
pub fn clear_all(&self) -> Vec<(u32, String)> {
let mut channels = self.inner.channels.lock();
let drained: Vec<(u32, ChannelState)> = channels.drain().collect();
for (_, state) in &drained {
drop(state.demux_sender.clone());
if let Some(task) = &state.handler_task {
task.abort();
}
}
// The opener ledger has the opener PeerIds.
self.inner.opener_ledger.drain()
}
/// `true` if `channel_id` is currently open.
pub fn has_channel(&self, channel_id: u32) -> bool {
self.inner.channels.lock().contains_key(&channel_id)
}
/// The ALPN for `channel_id` (observability).
pub fn channel_alpn(&self, channel_id: u32) -> Option<String> {
self.inner
.channels
.lock()
.get(&channel_id)
.map(|s| s.alpn.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::mux::MuxRunner;
use tokio::io::duplex;
/// Construct a `ChannelManager` with a live mux runner. The runner
/// is spawned so `register` calls succeed; the task is aborted when
/// the test ends.
async fn make_manager_with_runner() -> ChannelManager {
let (_client, server) = duplex(1024);
let (_reader, writer) = tokio::io::split(server);
let (handle, runner) = MuxRunner::new(Box::new(writer));
tokio::spawn(async move {
let _ = runner.run().await;
});
ChannelManager::with_defaults(handle, None)
}
#[tokio::test]
async fn open_channel_returns_unique_ids() {
let manager = make_manager_with_runner().await;
let (id1, _send1, _recv1) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open 1");
let (id2, _send2, _recv2) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open 2");
assert_ne!(id1, id2, "channel IDs are unique");
assert_eq!(manager.open_count(), 2);
}
#[tokio::test]
async fn open_channel_records_opener_in_ledger() {
let manager = make_manager_with_runner().await;
let (id, _send, _recv) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
assert_eq!(
manager.opener_ledger().take(id),
Some("alice".to_string()),
"opener recorded in ledger"
);
}
#[tokio::test]
async fn teardown_channel_removes_state() {
let manager = make_manager_with_runner().await;
let (id, _send, _recv) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
assert!(manager.has_channel(id));
let task = manager.teardown_channel(id).expect("teardown");
assert!(task.is_none(), "no handler task was registered");
assert!(!manager.has_channel(id), "channel removed");
}
#[tokio::test]
async fn route_payload_to_open_channel_succeeds() {
let manager = make_manager_with_runner().await;
let (id, _send, mut recv) = manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
manager.route_payload(id, Bytes::from_static(b"hello"));
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 5];
recv.read_exact(&mut buf).await.expect("read");
assert_eq!(&buf, b"hello");
}
#[tokio::test]
async fn route_payload_to_unknown_channel_is_lenient() {
let manager = make_manager_with_runner().await;
manager.route_payload(999, Bytes::from_static(b"data"));
}
#[tokio::test]
async fn teardown_unknown_channel_returns_error() {
let manager = make_manager_with_runner().await;
match manager.teardown_channel(999) {
Err(ManagerError::UnknownChannel(999)) => {}
other => panic!("expected UnknownChannel, got {other:?}"),
}
}
#[tokio::test]
async fn clear_all_returns_opener_ids() {
let manager = make_manager_with_runner().await;
manager
.open_channel("alknet/tty", "alice", None)
.await
.expect("open");
manager
.open_channel("alknet/tty", "bob", None)
.await
.expect("open");
let drained = manager.clear_all();
assert_eq!(drained.len(), 2);
assert_eq!(manager.open_count(), 0);
}
#[test]
fn manager_error_unknown_channel_display() {
let e = ManagerError::UnknownChannel(7);
assert!(format!("{e}").contains("7"));
}
#[test]
fn manager_error_too_many_channels_display() {
let e = ManagerError::TooManyChannels {
count: 256,
max: 256,
};
assert!(format!("{e}").contains("256"));
}
#[tokio::test]
async fn opener_ledger_accessible_from_manager() {
let manager = make_manager_with_runner().await;
manager.opener_ledger().record(1, "alice".to_string());
assert_eq!(manager.opener_ledger().take(1), Some("alice".to_string()));
}
#[test]
fn default_max_channels_is_256() {
assert_eq!(DEFAULT_MAX_CHANNELS, 256);
}
}