fix: Unit 2 — channel 0 single-stream call mode (C-01, C-25 #1)

Channel 0 was dead in both directions (C-01): the call protocol's
stream-per-request model (open_bi per call) is incompatible with
channel 0's single yield-once BiStream — every call_open_op failed
with StreamClosed on the connect side, and channel 0's Connection was
a black hole on the accept side.

The fix is single-stream call mode (ADR-036 amendment): all
EventEnvelope frames are multiplexed on channel 0's one BiStream.

Changes:
- CallConnection gains single_stream_writer: Option<Arc<SharedFrameWriter>>
  and new_single_stream() constructor. call_with_payload,
  subscribe_with_payload, publish_with_payload, and abort branch on
  is_single_stream() — in single-stream mode they write frames through
  the shared writer (mutex-serialized) instead of opening a fresh
  open_bi per call.
- Dispatcher::run_loop_single_stream reads frames off channel 0's read
  half, dispatches call.requested, writes responses through the shared
  writer, and routes in-flight call.published/call.completed/
  call.aborted to the matching Pub sink's chunk_tx by request_id.
- ChannelsAdapter::handle's InstallChannelZero hook now receives
  channel 0's Connection (built by the adapter) and runs the
  single-stream dispatch loop on it — closing the accept-side black
  hole. Mux runner is spawned BEFORE install_channel_zero so
  mux.register(0) can complete.
- ChannelClient::from_connection uses CallConnection::new_single_stream
  and spawns a read pump (read_single_stream_until_closed) that routes
  channel-0 response frames into the PendingRequestMap via
  dispatch_envelope — closing the connect-side StreamClosed path.
- ADR-036 amendment records the single-stream-mode decision (two-way
  door: implementation detail, wire format unchanged).

Acceptance gate (C-25 #1): three end-to-end tests wire
ChannelClient ↔ ChannelsAdapter over a real tokio::io::duplex pair
carrying the channels 8-byte chunk header wire format:
- channel_0_end_to_end_call_round_trip: a Query op round-trip
- channel_0_end_to_end_unknown_op_returns_not_found: NOT_FOUND
- channel_0_end_to_end_publish_delivers_chunks: a Pub op with 3 chunks

Verification: 437 tests pass (was 434; +3 e2e), clippy clean, fmt
clean, doc warnings unchanged (4, pre-existing C-09).
This commit is contained in:
glm-5.2 committed 2026-08-12 21:55:04 +00:00
1 parent 502488cc72
commit 495e04ed43
5 files changed
+925 -68

No files matched your search

+42 -34
View File
@@ -5,9 +5,12 @@
//! 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.
//! Channel 0 is pre-negotiated as `alknet/call` (ADR-036). The
//! `install_channel_zero` hook (ADR-036 amendment — single-stream call
//! mode) receives channel 0's `Connection` (already constructed by the
//! adapter from the reassembled read half + the mux write half) and
//! runs the call dispatch loop on it. `channels-call` provides the
//! implementation; this adapter calls it.
//!
//! See `docs/architecture/channels-adapter.md` for the full contract.
@@ -29,17 +32,21 @@ use super::wire::CHUNK_HEADER_LEN;
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`.
/// channel 0 (ADR-036 amendment — single-stream call mode). The
/// adapter calls this after constructing channel 0's `Connection`
/// (from the reassembled read half + the mux write half); the hook
/// runs the call dispatch loop on it.
///
/// 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>;
/// The callback receives channel 0's `Connection` (carrying the
/// `alknet/call` ALPN) and the `AuthContext` (so the call dispatch
/// loop can resolve the peer's identity). The `ChannelManager` is
/// accessible via the connection's `BidiStreamSource` (the channel-0
/// source closes over it), so the hook does not need it as a separate
/// argument; the manager is passed for callers that want to reach it
/// (e.g., to register channel lifecycle ops' `ChannelCore`).
pub type InstallChannelZero = Arc<
dyn Fn(ChannelManager, Connection, AuthContext) -> tokio::task::JoinHandle<()> + Send + Sync,
>;
/// `ChannelsAdapter` — the `ProtocolHandler` for `alknet/channels`.
/// Its `handle()` receives one `Connection`, installs channel 0 via
@@ -174,33 +181,34 @@ impl ProtocolHandler for ChannelsAdapter {
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).
// Spawn the mux runner BEFORE installing channel 0 —
// `install_channel_zero` calls `mux.register(0).await` which
// needs the runner to be draining `new_pumps`.
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
// 3. Install channel 0 (pre-negotiated as `alknet/call`,
// ADR-036). Channel 0's reassembly buffer + mux write half
// are joined into a `BiStream` and wrapped in a
// `Connection` (via `ChannelBidiStreamSource`). The
// `install_channel_zero` hook (provided by `channels-call`)
// runs the call dispatch loop on this `Connection` — the
// single-stream call mode (ADR-036 amendment) multiplexes
// all `EventEnvelope` frames on channel 0's one `BiStream`.
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.clone(), channel0_conn, auth.clone());
// 4. 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;
+335 -24
View File
@@ -5,6 +5,14 @@
//! `open_channel(alpn, params)` to open data channels via the
//! per-ALPN open ops on channel 0.
//!
//! Channel 0 runs in single-stream call mode (ADR-036 amendment):
//! all `EventEnvelope` frames for call operations are multiplexed on
//! channel 0's one `BiStream`. `CallConnection::new_single_stream`
//! holds the framed write half; the client spawns a read pump that
//! reads `EventEnvelope` frames off channel 0's reassembled read half
//! (fed by the demux) and routes them into the `PendingRequestMap`
//! via `dispatch_envelope`.
//!
//! The dial (TLS, QUIC, WebSocket) lives in the consumer —
//! `ChannelClient` is transport-agnostic by construction (ADR-043).
//!
@@ -41,34 +49,72 @@ 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.
/// ADR-036), wraps it as a `CallConnection` in single-stream call
/// mode (ADR-036 amendment), 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).
/// The call dispatch loop on channel 0 is driven by the client:
/// a read pump task reads `EventEnvelope` frames off channel 0's
/// reassembled read half (fed by the demux) and routes them into
/// the `PendingRequestMap` via `dispatch_envelope`, resolving
/// pending calls. The `CallConnection` holds the framed write
/// half so `call_open_op` can write `call.requested` frames.
pub async fn from_connection(connection: Connection) -> Result<Self, StreamError> {
let remote_addr = connection.remote_addr();
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());
// Spawn the mux runner BEFORE installing channel 0 —
// `install_channel_zero` calls `mux.register(0).await` which
// sends a `Registration` to the runner and waits for the
// response. The runner must be draining `new_pumps` for the
// registration to complete.
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");
}
});
let manager = ChannelManager::with_defaults(mux_handle, 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");
}
// Join channel 0's reassembled read half + mux write half into
// a `BiStream` (via `channel_source`), wrap as a `Connection`
// (for ALPN/addr/identity), then yield the `BiStream` once via
// `accept_bi` and split it into a `SharedFrameWriter` (the
// framed write half for `call.requested` etc.) + a raw read
// half (for the read pump). This is the single-stream call
// mode (ADR-036 amendment): all `EventEnvelope` frames are
// multiplexed on channel 0's one `BiStream`.
let channel0_source =
super::source::channel_source(channel0_recv, channel0_send, remote_addr);
let channel0_conn = Connection::from_source(channel0_source, b"alknet/call".to_vec());
let channel0_bidi = channel0_conn.accept_bi().await?;
let (single_stream_writer, single_stream_reader) =
crate::protocol::connection::split_single_stream(channel0_bidi);
let call_connection =
CallConnection::new_single_stream(channel0_conn, single_stream_writer);
// Spawn the read pump for channel 0's response frames. Reads
// `EventEnvelope` frames off channel 0's reassembled read half
// (fed by the demux) and routes them into the
// `PendingRequestMap` via `dispatch_envelope`, resolving
// pending calls. This is the single-stream analogue of
// `read_stream_until_closed` in stream-per-request mode.
let pending_map = Arc::clone(call_connection.pending());
let _read_pump = tokio::spawn(async move {
crate::protocol::connection::read_single_stream_until_closed(
single_stream_reader,
&pending_map,
)
.await;
});
// Spawn the demux loop (read side). The loop reads 8-byte
@@ -98,12 +144,10 @@ impl ChannelClient {
/// `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).
/// In single-stream call mode (ADR-036 amendment), this writes
/// `call.requested` through channel 0's shared frame writer and
/// awaits the response via the `PendingRequestMap` (resolved by
/// the read pump from the demux).
pub async fn call_open_op(&self, operation_id: &str, input: Value) -> ResponseEnvelope {
let guard = self.call_connection.lock().await;
match guard.as_ref() {
@@ -127,11 +171,278 @@ impl ChannelClient {
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::adapter::ChannelsAdapter;
use crate::core::auth::{AuthContext, IdentityProvider};
use crate::core::types::Connection;
use crate::protocol::connection::split_single_stream;
use crate::protocol::dispatch::Dispatcher;
use crate::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
const TEST_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321);
fn external_query_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
struct NoopIdProvider;
impl IdentityProvider for NoopIdProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<crate::core::auth::Identity> {
None
}
fn resolve_from_token(
&self,
_: &crate::core::auth::AuthToken,
) -> Option<crate::core::auth::Identity> {
None
}
}
/// Build the `install_channel_zero` hook for the accept side: it
/// yields channel 0's `BiStream` once, splits it into the shared
/// writer + reader, constructs a single-stream `CallConnection`,
/// and runs `Dispatcher::run_loop_single_stream` on it. Returns
/// the `JoinHandle` for the spawned dispatch loop.
fn make_install_channel_zero(
registry: Arc<crate::registry::registration::OperationRegistry>,
) -> crate::channels::adapter::InstallChannelZero {
Arc::new(move |_manager, channel0_conn, _auth| {
let registry = Arc::clone(&registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
})
}
#[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>;
}
/// C-25 #1 — the end-to-end acceptance gate for Unit 2 (channel 0
/// single-stream call mode, ADR-036 amendment). Wires
/// `ChannelClient` (connect side) ↔ `ChannelsAdapter` (accept
/// side) over a real `tokio::io::duplex` pair carrying the
/// channels 8-byte chunk header wire format. A `call.requested`
/// from the client is demuxed on channel 0, dispatched by the
/// server's single-stream `Dispatcher::run_loop_single_stream`,
/// and the `call.responded` is muxed back to the client's read
/// pump, resolving the pending call. This would have caught C-01
/// immediately (every `call_open_op` failed with
/// `StreamClosed` on the connect side; channel 0 was a black hole
/// on the accept side).
#[tokio::test]
async fn channel_0_end_to_end_call_round_trip() {
let mut registry = crate::registry::registration::OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_query_spec("echo/run"),
HandlerKind::Once(make_handler(|input, ctx| async move {
ResponseEnvelope::ok(ctx.request_id, input)
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(make_install_channel_zero(Arc::clone(&registry)));
let auth = AuthContext::anonymous(b"alknet/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op("echo/run", serde_json::json!({ "msg": "hi" })),
)
.await
.expect("channel-0 round-trip timed out");
assert!(
response.result.is_ok(),
"channel-0 round-trip should succeed, got {:?}",
response.result
);
assert_eq!(response.result.unwrap(), serde_json::json!({ "msg": "hi" }));
}
/// C-25 #1 (negative case) — an unknown op over channel 0 returns
/// `NOT_FOUND`, proving the single-stream dispatch path reaches
/// the registry's not-found branch end-to-end (not just
/// `StreamClosed` from a dead channel 0 as in C-01).
#[tokio::test]
async fn channel_0_end_to_end_unknown_op_returns_not_found() {
let registry = Arc::new(crate::registry::registration::OperationRegistry::new());
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(make_install_channel_zero(Arc::clone(&registry)));
let auth = AuthContext::anonymous(b"alknet/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.call_open_op("no/such/op", serde_json::json!({})),
)
.await
.expect("channel-0 unknown-op timed out");
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND", "unknown op over channel 0"),
other => panic!("expected NOT_FOUND, got {other:?}"),
}
}
/// C-25 #1 (Pub over channel 0) — a `publish()` through channel
/// 0's single-stream mode delivers chunks to the responder's
/// `SinkHandler` and returns the handler's response. Proves the
/// single-stream dispatch loop's in-flight sink routing works
/// (`call.published` frames arriving after `call.requested` are
/// routed to the matching sink's `chunk_tx`).
#[tokio::test]
async fn channel_0_end_to_end_publish_delivers_chunks() {
use futures::stream::StreamExt;
let mut registry = crate::registry::registration::OperationRegistry::new();
let counting_sink = crate::registry::registration::make_sink_handler(
|_input, ctx, mut stream| async move {
let mut count = 0u32;
let mut last = serde_json::Value::Null;
while let Some(item) = stream.next().await {
match item {
Ok(v) => {
count += 1;
last = v;
}
Err(_) => break,
}
}
ResponseEnvelope::ok(
ctx.request_id,
serde_json::json!({ "count": count, "last": last }),
)
},
);
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/upload",
OperationType::Pub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Sink(counting_sink),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let registry = Arc::new(registry);
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let client_conn =
Connection::from_bidi(client_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let server_conn =
Connection::from_bidi(server_end, b"alknet/channels".to_vec(), Some(TEST_ADDR));
let adapter = ChannelsAdapter::new(make_install_channel_zero(Arc::clone(&registry)));
let auth = AuthContext::anonymous(b"alknet/channels");
let _server_handle = tokio::spawn(async move {
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
let client = ChannelClient::from_connection(client_conn)
.await
.expect("channel client init");
let call_conn = client
.take_call_connection()
.await
.expect("call connection present");
let chunks = vec![
serde_json::json!({"chunk": 1}),
serde_json::json!({"chunk": 2}),
serde_json::json!({"chunk": 3}),
];
let stream: std::pin::Pin<
Box<dyn futures::stream::Stream<Item = serde_json::Value> + Send>,
> = Box::pin(futures::stream::iter(chunks.clone()));
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
call_conn.publish("fs/upload", serde_json::json!({"path": "/x"}), stream),
)
.await
.expect("channel-0 publish timed out");
assert!(
response.result.is_ok(),
"publish should succeed, got {:?}",
response.result
);
let out = response.result.unwrap();
assert_eq!(
out["count"],
serde_json::json!(3),
"responder saw all 3 chunks"
);
assert_eq!(
out["last"],
serde_json::json!({"chunk": 3}),
"last chunk matches"
);
}
}