feat: consumer-session — TunnelSession (open/adopt, data planes, teardown)

- TunnelSession (src/consumer.rs): open (ChannelClient::open_channel)
  + adopt (ChannelManager::adopt_channel) construction, substrate-
  shaped data planes (raw halves for stream; ADR-003 codec + pending
  frame queue for UDP), pump_against (session-owned pump_bidi handle),
  and teardown ownership per ADR-005: close (abort + ungraceful reap),
  join (await + reap + copy counts, pump-less (0,0,reaped)), Drop
  (abort + sync reap, never leaks). No Clone (compile_fail doc-test).
- open_reverse_channel on the CallConnection (the reverse path's
  two-step: call then adopt — the POC's honest shape).
- Error surfaces (src/error.rs): TunnelOpenError (typed ADR-049 §4
  surface via open_ref/establishment_reason), TunnelIoError (wrong-
  substrate, TruncatedDatagram fail-loud, ChannelTaken), ReverseOpenError.
- Tests: tests/consumer_session.rs (12) over two harness topologies —
  forward (wire_forward/ForwardTopology: consumer connect-side pure
  client, producer serving via adapter) and reverse (the POC shape);
  teardown matrix, W4 half-close, out-of-band close + self-reap (W3),
  F-2 empty datagram, AdoptFailed-on-collision (bogus-id adopt parks
  by design — documented), concurrent sessions. Harness: all_substrate_dial.
- Adopt takes substrate (task-note deviation: the data plane shape
  rode the open call; cannot be inferred from the ID).

Verified: cargo test green (38), clippy -D warnings (native + wasm32),
fmt clean, cargo check --target wasm32-unknown-unknown passes.
This commit is contained in:
2026-09-08 09:33:12 +00:00
parent 2b35da4395
commit fb2389bd62
8 changed files with 1431 additions and 18 deletions
+597
View File
@@ -0,0 +1,597 @@
//! Integration tests for the consumer half — `TunnelSession` per
//! consumer.md + ADR-005, over the duplex harness. Ported from the
//! reverse POC's `ReverseTunnel` suite (the W3/W4 probes) plus the
//! forward POC's session shapes, generalized to the spec's session
//! type.
//!
//! Covered: forward open (session halves drive a duplex round-trip),
//! reverse open+adopt+pump_against (round-trip, W4 half-close, join
//! copy counts, out-of-band close + self-reaping, pump-less join),
//! datagram variant (round-trip incl. the empty datagram — the F-2
//! layering), wrong-substrate errors, and the teardown matrix
//! (close/join/Drop — no leaked channel entries via `channel_ids()`).
mod harness;
use alktunnels::params::{Substrate, TunnelParams};
use alktunnels::producer::ResourceRegistry;
use alktunnels::{open_reverse_channel, TunnelSession};
use harness::{all_substrate_dial, echo_dial, framed_udp_echo_dial, wire, wire_forward, Topology};
fn params(resource: &str, substrate: Substrate) -> TunnelParams {
TunnelParams {
resource: resource.to_string(),
substrate,
}
}
/// Both managers hold only channel 0 (the phantom-channel property,
/// asserted from both topologies).
trait LeakCheck {
fn no_data_channels(&self) -> bool;
}
impl LeakCheck for Topology {
fn no_data_channels(&self) -> bool {
self.consumer_manager
.channel_ids()
.iter()
.all(|&id| id == 0)
&& self
.producer
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0)
}
}
impl LeakCheck for harness::ForwardTopology {
fn no_data_channels(&self) -> bool {
self.consumer
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0)
&& self
.producer_manager
.channel_ids()
.iter()
.all(|&id| id == 0)
}
}
fn only_channel_0<T: LeakCheck>(topo: &T) -> bool {
topo.no_data_channels()
}
#[tokio::test]
async fn forward_open_session_halves_round_trip() {
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire_forward(registry, echo_dial()).await;
let mut session = TunnelSession::open(&topo.consumer, params("echo", Substrate::Tcp))
.await
.expect("open");
assert!(topo.producer_manager.has_channel(session.channel_id));
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (read, write) = session.stream_halves().expect("stream session has halves");
write.write_all(b"ping").await.expect("write");
write.flush().await.expect("flush");
let mut buf = vec![0u8; 4];
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"ping");
// Teardown: the session reaps its adopted entry (ADR-005).
let channel_id = session.channel_id;
let reaped = session.close().await;
assert!(reaped, "close reaped the adopted entry");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(!topo.producer_manager.has_channel(channel_id));
assert!(only_channel_0(&topo), "no leaked entries after close");
}
#[tokio::test]
async fn forward_open_take_halves_then_pumpless_join() {
// consumer.md's pinned semantics: after take_halves the session is
// pump-less — join completes immediately, reaps only, (0, 0, reaped).
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire_forward(registry, echo_dial()).await;
let session = TunnelSession::open(&topo.consumer, params("echo", Substrate::Tcp))
.await
.expect("open");
let taken = session.take_halves();
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut read = taken.read;
let mut write = taken.write;
write.write_all(b"echo-me").await.expect("write");
write.flush().await.expect("flush");
let mut buf = vec![0u8; 7];
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
.await
.expect("round trip through taken halves")
.expect("read");
assert_eq!(&buf, b"echo-me");
let session = taken.session;
// Drop the halves BEFORE joining: EOF propagates through the mux,
// the producer-side wrapper reaps, and the pump-less session reaps
// its own adopted entry on join (nothing to await).
drop(read);
drop(write);
let (c2p, p2c, reaped) = session.join().await;
assert_eq!((c2p, p2c), (0, 0), "pump-less join: no pump to await");
assert!(reaped, "pump-less join reaps the adopted entry");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(only_channel_0(&topo));
}
#[tokio::test]
async fn datagram_round_trip_including_empty() {
// The datagram variant over the codec (ADR-003): round-trip incl.
// the F-2 empty datagram — `len=0` is a legal datagram, never EOF.
let registry = ResourceRegistry::new();
registry.register("dns", Substrate::Udp, "in-process").await;
let topo = wire_forward(registry, framed_udp_echo_dial()).await;
let mut session = TunnelSession::open(&topo.consumer, params("dns", Substrate::Udp))
.await
.expect("udp open");
assert!(
session.stream_halves().is_none(),
"stream_halves is stream-substrate-only"
);
session
.send_datagram(b"query-1")
.await
.expect("send datagram");
session
.send_datagram(b"")
.await
.expect("send empty datagram");
let dg1 = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("recv timed out")
.expect("no io error")
.expect("first datagram");
assert_eq!(&dg1[..], b"query-1");
let dg2 = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("recv timed out")
.expect("no io error")
.expect("second datagram (empty round-trips — F-2)");
assert!(dg2.is_empty(), "the empty datagram round-trips (F-2)");
let reaped = session.close().await;
assert!(reaped);
// The producer-side wrapper reap is async (the pump task exits,
// the wrapper reaps) — settle before asserting both managers.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(only_channel_0(&topo));
}
#[tokio::test]
async fn wrong_substrate_operations_are_typed() {
// The POC's WrongSubstrate shape: substrate-shaped operations fail
// typed on the wrong plane.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
registry.register("dns", Substrate::Udp, "in-process").await;
let topo = wire_forward(registry, all_substrate_dial()).await;
let mut stream_session = TunnelSession::open(&topo.consumer, params("echo", Substrate::Tcp))
.await
.expect("tcp open");
let err = stream_session
.send_datagram(b"nope")
.await
.expect_err("send_datagram on a stream session");
assert!(matches!(err, alktunnels::TunnelIoError::WrongSubstrate));
let err = stream_session
.recv_datagram()
.await
.expect_err("recv_datagram on a stream session");
assert!(matches!(err, alktunnels::TunnelIoError::WrongSubstrate));
stream_session.close().await;
let mut dg_session =
match TunnelSession::open(&topo.consumer, params("dns", Substrate::Udp)).await {
Err(_) => panic!("setup: udp dial must succeed in the framed harness"),
Ok(mut s) => {
assert!(
s.stream_halves().is_none(),
"stream_halves is stream-substrate-only"
);
s
}
};
dg_session.send_datagram(b"late").await.expect("send works");
dg_session.close().await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(only_channel_0(&topo));
}
#[tokio::test]
async fn reverse_open_adopt_pump_round_trip() {
// The reverse flow (the POC's ReverseTunnel shape, generalized):
// open_reverse_channel → adopt → pump_against → round-trip →
// join (copy counts + reap).
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, echo_dial()).await;
let channel_id =
open_reverse_channel(&topo.consumer_call, &params("echo", Substrate::Tcp), None)
.await
.expect("reverse open call");
let session = TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
.expect("adopt");
assert_eq!(session.channel_id, channel_id);
let (accepted_end, local_end) = tokio::io::duplex(64 * 1024);
let session = session.pump_against(accepted_end).await;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut l_read, mut l_write) = tokio::io::split(local_end);
l_write.write_all(b"reverse").await.expect("write");
l_write.flush().await.expect("flush");
let mut buf = vec![0u8; 7];
tokio::time::timeout(
std::time::Duration::from_secs(5),
l_read.read_exact(&mut buf),
)
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"reverse");
// EOF propagates through the two pumps (the two-pump contract).
drop(l_write);
drop(l_read);
let (c2p, p2c, reaped) =
tokio::time::timeout(std::time::Duration::from_secs(5), session.join())
.await
.expect("join timed out");
assert!(c2p > 0 && p2c > 0, "both pumps moved bytes: {c2p}, {p2c}");
assert!(reaped, "join reaps after pump completion");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!topo.producer.manager().has_channel(channel_id),
"producer-side channel reaped (wrapper-managed)"
);
assert!(only_channel_0(&topo), "no leaked entries after join");
}
#[tokio::test]
async fn reverse_half_close_semantics_w4() {
// W4's shape: the local side half-closes (shutdown write); the
// target sees EOF on its read side while the reverse direction
// stays pumpable, and the target's final reply still flows back.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, echo_dial()).await;
let channel_id =
open_reverse_channel(&topo.consumer_call, &params("echo", Substrate::Tcp), None)
.await
.expect("reverse open call");
let session = TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
.expect("adopt");
let (accepted_end, local_end) = tokio::io::duplex(64 * 1024);
let session = session.pump_against(accepted_end).await;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut l_read, mut l_write) = tokio::io::split(local_end);
l_write.write_all(b"half").await.expect("write");
l_write.flush().await.expect("flush");
l_write.shutdown().await.expect("half close");
let mut buf = vec![0u8; 4];
tokio::time::timeout(
std::time::Duration::from_secs(5),
l_read.read_exact(&mut buf),
)
.await
.expect("echo after half close")
.expect("read echo");
assert_eq!(&buf, b"half");
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), session.join())
.await
.expect("join after half close");
assert!(only_channel_0(&topo));
}
#[tokio::test]
async fn out_of_band_close_then_session_self_reap() {
// The POC's reverse_channel_close_out_of_band shape: a peer-initiated
// `channel/close` tears the SERVING side down; the consumer's session
// still owns its adopted entry — close/join reaps it (W3 split).
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, echo_dial()).await;
let channel_id =
open_reverse_channel(&topo.consumer_call, &params("echo", Substrate::Tcp), None)
.await
.expect("reverse open call");
let session = TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
.expect("adopt");
let session = session.pump_against(tokio::io::duplex(1024).0).await;
let response = topo
.consumer_call
.call_with_payload(serde_json::json!({
"operationId": "channel/close",
"input": { "channel_id": channel_id, "reason": "out-of-band" }
}))
.await;
assert!(response.result.is_ok(), "channel/close resolved");
// The close ran on the PRODUCER side; the consumer's entry remains.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(
!topo.producer.manager().has_channel(channel_id),
"producer-side channel reaped by the out-of-band close"
);
assert!(topo.consumer_manager.has_channel(channel_id));
let reaped = session.close().await;
assert!(reaped, "session reaped its adopted entry");
assert!(only_channel_0(&topo));
}
#[tokio::test]
async fn drop_never_leaks_the_channel_entry() {
// ADR-005: Drop = abort + sync reap (best-effort). Dropping a live
// session without close/join never leaks the entry.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let ftopo = wire_forward(registry.clone(), echo_dial()).await;
let session = TunnelSession::open(&ftopo.consumer, params("echo", Substrate::Tcp))
.await
.expect("open");
let channel_id = session.channel_id;
assert!(ftopo.consumer.manager().has_channel(channel_id));
drop(session);
assert!(
!ftopo.consumer.manager().has_channel(channel_id),
"Drop reaped the adopted entry"
);
// Drop with a session-owned pump: the pump aborts too (the reverse
// topology — pump_against is the reverse-flow use).
let topo = wire(registry, echo_dial()).await;
let channel_id =
open_reverse_channel(&topo.consumer_call, &params("echo", Substrate::Tcp), None)
.await
.expect("second reverse open");
let session = TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
.expect("adopt");
let session = session.pump_against(tokio::io::duplex(1024).0).await;
assert!(topo.consumer_manager.has_channel(channel_id));
drop(session);
assert!(!topo.consumer_manager.has_channel(channel_id));
}
#[tokio::test]
async fn oversize_datagram_rejected_at_frame_time() {
// ADR-003: a >65535-byte send is rejected at frame time (`Oversize`),
// never a wire overflow.
let registry = ResourceRegistry::new();
registry.register("dns", Substrate::Udp, "in-process").await;
let topo = wire_forward(registry, framed_udp_echo_dial()).await;
let mut session = TunnelSession::open(&topo.consumer, params("dns", Substrate::Udp))
.await
.expect("udp open");
let err = session
.send_datagram(&vec![0u8; alktunnels::MAX_DATAGRAM_LEN + 1])
.await
.expect_err("oversize must fail");
assert!(
matches!(
err,
alktunnels::TunnelIoError::Codec(alktunnels::wire::DatagramCodecError::Oversize(_, _))
),
"oversize is a codec error at frame time: {err:?}"
);
session.close().await;
}
#[tokio::test]
async fn failed_open_never_yields_a_session() {
// No phantom session: unknown resource → typed error, no channel
// anywhere, and `open_ref()` exposes the typed surface.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire_forward(registry, echo_dial()).await;
let err = match TunnelSession::open(&topo.consumer, params("ghost", Substrate::Tcp)).await {
Ok(_) => panic!("unknown resource must not open"),
Err(e) => e,
};
assert_eq!(
err.open_ref().establishment_reason(),
Some("unknown_resource")
);
assert!(only_channel_0(&topo));
// Reverse path on the reverse topology: the call fails typed;
// adopt is never reached.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, echo_dial()).await;
let err =
match open_reverse_channel(&topo.consumer_call, &params("ghost", Substrate::Tcp), None)
.await
{
Ok(_) => panic!("reverse open of unknown resource must fail"),
Err(e) => e,
};
assert!(matches!(err, alktunnels::ReverseOpenError::Call(_)));
assert!(only_channel_0(&topo));
}
#[tokio::test]
async fn adopt_failure_is_typed_not_establishment() {
// An adopt failure is `AdoptFailed`-class (post-reply local
// failure: ID collision), not an establishment error. A bogus-id
// adopt SUCCEEDS by design — the manager parks early arrivals for
// any not-yet-seen ID (the adoption race cover, ADR-047 §5) — so
// the collision path is the honest probe: adopt the same ID twice.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, echo_dial()).await;
let channel_id =
open_reverse_channel(&topo.consumer_call, &params("echo", Substrate::Tcp), None)
.await
.expect("reverse open");
let first = TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
.expect("first adopt");
let err = match TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
{
Ok(_) => panic!("adopting a live id twice must fail"),
Err(e) => e,
};
match err.open_ref() {
alkcall::channels::client::ChannelOpenError::AdoptFailed(
alkcall::channels::manager::ManagerError::ChannelExists(_),
) => {}
other => panic!("expected AdoptFailed(ChannelExists), got {other:?}"),
}
first.close().await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(only_channel_0(&topo));
}
#[tokio::test]
async fn concurrent_reverse_sessions_independent() {
// Two reverse sessions on one connection: independent channels,
// each round-trips its own marker, both reaped.
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, echo_dial()).await;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut sessions = Vec::new();
for i in 0..2 {
let channel_id =
open_reverse_channel(&topo.consumer_call, &params("echo", Substrate::Tcp), None)
.await
.expect("reverse open");
let session = TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
.expect("adopt");
let (accepted_end, local_end) = tokio::io::duplex(64 * 1024);
let session = session.pump_against(accepted_end).await;
let (mut l_read, mut l_write) = tokio::io::split(local_end);
let msg = format!("marker {i}");
l_write.write_all(msg.as_bytes()).await.expect("write");
l_write.flush().await.expect("flush");
let mut buf = vec![0u8; msg.len()];
tokio::time::timeout(
std::time::Duration::from_secs(5),
l_read.read_exact(&mut buf),
)
.await
.expect("round trip")
.expect("read");
assert_eq!(buf, msg.as_bytes());
sessions.push(session);
}
assert_ne!(sessions[0].channel_id, sessions[1].channel_id);
for session in sessions {
let (_, _, reaped) = session.join().await;
assert!(reaped);
}
assert!(only_channel_0(&topo));
}
#[allow(dead_code)]
fn type_assertions() {
// The `compile_fail` doc-test in consumer.rs is the no-Clone gate;
// this parity stub keeps the type in the test's namespace.
let _ = std::mem::size_of::<TunnelSession>();
}
+196
View File
@@ -1,5 +1,6 @@
//! Test harness — the producer↔consumer wiring over a
//! `tokio::io::duplex` carrying the channels 8-byte chunk format.
#![allow(dead_code)]
//!
//! Topology (the reverse POC's shape, mirrored for both directions):
//! - **producer** (connect side of the channels connection — it runs
@@ -222,6 +223,133 @@ pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn
.await
}
/// The forward topology (the `-L` direction): the consumer dials the
/// transport (connect side — `ChannelClient::from_connection`, a pure
/// consumer serving nothing) and the producer accepts (the adapter +
/// an install hook that registers the channel ops + the tunnel
/// openable on the producer's serving registry). This is the topology
/// `TunnelSession::open(&channel_client, params)` is normative for:
/// the consumer's own client calls the open op on the producer's
/// channel 0.
///
/// Identity on the accept-side serving path (the mTLS posture): the
/// transport authenticated the dialer; the hook attaches the resolved
/// identity to channel 0 so the serving dispatch sees the caller
/// (the accept-side analogue of CF-005 (b)'s propagation).
pub struct ForwardTopology {
/// The consumer's own client (connect side, pure consumer). Its
/// manager adopted the producer-allocated channel IDs; the
/// session reaps via this manager.
pub consumer: Arc<ChannelClient>,
/// The producer's manager (accept side, serving) — for asserting
/// wrapper-managed reaping.
pub producer_manager: ChannelManager,
/// What the establisher's per-call auth carried (the CF-006 probe).
pub identity_witness: Arc<tokio::sync::Mutex<Option<String>>>,
}
pub async fn wire_forward(
registry: ResourceRegistry,
dial: alktunnels::producer::DialFn,
) -> ForwardTopology {
let producer_op_registry = Arc::new(OperationRegistry::new());
let (producer_manager_tx, mut producer_manager_rx) =
tokio::sync::mpsc::channel::<ChannelManager>(1);
let identity_witness = Arc::new(tokio::sync::Mutex::new(None::<String>));
let dial_witness = Arc::clone(&identity_witness);
// --- producer (accept side, serving): adapter + install hook ------
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, _auth| {
let registry_arc = Arc::clone(&producer_op_registry);
let resource_registry = registry.clone();
let dial = Arc::clone(&dial);
let witness = Arc::clone(&dial_witness);
let manager_tx = producer_manager_tx.clone();
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);
// The accept-side transport-identity posture: the transport
// authenticated the dialer; attach the resolved identity to
// channel 0 (the dispatch reads it as the caller identity).
let _ = channel0_conn.set_identity(consumer_identity());
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
// Register the generic channel ops + the tunnel openable
// (the producer.md recipe) before the dispatch loop runs.
let core = alkcall::channels::operations::ChannelCore::new(
manager.clone(),
alkcall::channels::policy::default_policy(),
);
let ops = ChannelOperations::with_default_policy(manager.clone());
ops.register_on(&registry_arc)
.expect("register channel ops on producer");
register_tunnel_openable(
&core,
&resource_registry,
&registry_arc,
AuthContext::anonymous(b"alk/tunnel"),
dial,
Some(witness),
)
.expect("register tunnel openable");
let _ = manager_tx.send(manager).await;
let dp = Dispatcher::new(
registry_arc,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
);
dp.serve_single_stream(call_connection, reader, writer)
.await;
})
});
// --- transport ------------------------------------------------------
let (consumer_end, producer_end) = tokio::io::duplex(64 * 1024);
let consumer_conn = CoreConnection::from_bidi(consumer_end, b"alk/channels".to_vec(), None);
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
// The dialer's transport identity (the mTLS/QUIC posture) — set
// before from_connection. The accept side resolves it out-of-band
// (modeled in the hook above).
consumer_conn
.set_identity(consumer_identity())
.expect("transport identity set once");
let adapter = ChannelsAdapter::new(install_hook, Arc::new(alkcall::channels::policy::NoCap));
let producer_transport_auth = AuthContext::anonymous(b"alk/channels");
let _adapter_task = tokio::spawn(async move {
let _ = alkcall::core::types::ProtocolHandler::handle(
&adapter,
producer_conn,
&producer_transport_auth,
)
.await;
});
// --- consumer (connect side): pure client ---------------------------
let consumer_client = ChannelClient::from_connection(consumer_conn)
.await
.expect("consumer channel client init");
let consumer_client = Arc::new(consumer_client);
let producer_manager = producer_manager_rx
.recv()
.await
.expect("producer manager captured");
ForwardTopology {
consumer: consumer_client,
producer_manager,
identity_witness,
}
}
/// An echo dial closure (the ADR-004 injection point under test): an
/// in-process pipe per dial — the "target" echoes bytes until EOF.
/// The consumer side gets split `DuplexStream` halves; a spawned task
@@ -263,6 +391,17 @@ pub fn echo_dial() -> alktunnels::producer::DialFn {
})
}
/// A both-substrates dial: stream substrates echo via pipes, UDP
/// echoes via the framed codec (the datagram tests' single dial).
pub fn all_substrate_dial() -> alktunnels::producer::DialFn {
let echo = echo_dial();
let udp = framed_udp_echo_dial();
Arc::new(move |substrate: Substrate, backing: &str| match substrate {
Substrate::Udp => udp(substrate, backing),
Substrate::Tcp | Substrate::Unix => echo(substrate, backing),
})
}
/// A failing dial closure (the `dial_failed` stand-in).
pub fn failing_dial(message: &'static str) -> alktunnels::producer::DialFn {
Arc::new(move |_substrate: Substrate, _backing: &str| {
@@ -273,3 +412,60 @@ pub fn failing_dial(message: &'static str) -> alktunnels::producer::DialFn {
})
})
}
/// A framed UDP echo dial (the ADR-003 codec at the target boundary —
/// the in-process stand-in for the real `local` UDP adapter): the
/// dial's halves carry the `[len: u16 BE]` wire framing; a spawned
/// echo task decodes frames from the channel side and re-frames the
/// echoed payloads back. The producer pump copies raw bytes; the
/// codec lives at this boundary (the pump never sees UDP specifics).
pub fn framed_udp_echo_dial() -> alktunnels::producer::DialFn {
Arc::new(move |substrate: Substrate, backing: &str| {
let backing = backing.to_string();
Box::pin(async move {
match substrate {
Substrate::Udp => {
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
tokio::spawn(async move {
use alktunnels::wire::DatagramReader;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut t_read, mut t_write) = tokio::io::split(target_side);
let mut reader = DatagramReader::new();
let mut buf = vec![0u8; 16 * 1024];
loop {
let n = match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let dgs = match reader.feed(&buf[..n]) {
Ok(dgs) => dgs,
Err(_) => return,
};
for dg in dgs {
let framed = match alktunnels::wire::frame_datagram(&dg) {
Ok(f) => f,
Err(_) => return,
};
if t_write.write_all(&framed).await.is_err() {
return;
}
let _ = t_write.flush().await;
}
}
});
let _ = &backing;
let (c_read, c_write) = tokio::io::split(consumer_side);
Ok(alktunnels::producer::TargetHandle {
read: Box::new(c_read),
write: Box::new(c_write),
})
}
Substrate::Tcp | Substrate::Unix => {
Err(alktunnels::producer::TunnelEstablishError::DialFailed(
format!("stream dial not wired in the udp harness: {backing}"),
))
}
}
})
})
}