feat(review 008 Unit 3a): in-tree ChannelRelay (ADR-051, ADR-042 amendment)
- src/channels/relay.rs: ChannelRelay::register_relay_openable — registers a from_call-imported marked spec on a consumer-leg channel-0 registry via the full open-op wrapper (translate hop = the ADR-049 establisher calling the producer leg with the forwarded payload; byte-forward hop = pump_bidi, awaited inline). The relay map dissolves (implicit per-channel mapping; ids are per-connection), the spoke reply's channel_id is stripped, the reply's other fields ride with_reply_fields (bound flows end-to-end), reason mapping per ADR-051 §3 (spoke code+message preserved; timeout → dial_failed). - Rejection postures (ADR-051 §6): unmarked and Pub-typed marked specs are loud assembly errors. - Gate tests (9): registration rejections, reason mapping, forwarded_for payload shape, the real-wire consumer → hub → spoke round trip (bound survives, data both directions, the hub never parses the data plane), spoke-failure teardown (ledger decremented, no spoke channel leak), and a channels/tty/sub standard-shape run pinning no regression. Verification: cargo test (658 passed), clippy --all-targets -D warnings, fmt --check, doc --no-deps — all clean.
This commit is contained in:
@@ -27,6 +27,10 @@
|
||||
//! (ADR-041, amended by ADR-047 §7 — opener ledger).
|
||||
//! - [`pump`]: `pump_bidi` — the two-pump data-plane helper (alknet
|
||||
//! ADR-078, pinned upstream by ADR-050).
|
||||
//! - [`relay`]: `ChannelRelay` — the in-tree relay component (ADR-042
|
||||
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
|
||||
//! hop for from_call-imported marked specs re-exposed on a hub's
|
||||
//! consumer-leg channel-0 registry.
|
||||
//! - [`client`]: `ChannelClient` — transport-agnostic
|
||||
//! `from_connection` (ADR-043).
|
||||
//! - [`self::env`]: `ChannelOperationEnv` extension trait (ADR-047 §4 —
|
||||
@@ -43,5 +47,6 @@ pub mod operations;
|
||||
pub mod policy;
|
||||
pub mod pump;
|
||||
pub mod reassembly;
|
||||
pub mod relay;
|
||||
pub mod source;
|
||||
pub mod wire;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! `ChannelRelay` — the in-tree channel relay component (ADR-042 as
|
||||
//! amended by ADR-051; review 008 Unit 3a).
|
||||
//!
|
||||
//! A relay hub imports a producer leg's channel-open op (via
|
||||
//! `from_call` discovery — the spec arrives reconstructed WITH the
|
||||
//! `channel_open` marker, ADR-047 amendment 3) and re-exposes it on
|
||||
//! its own consumer-leg channel-0 fork registry. The open op resolves
|
||||
//! on the hub with a hub-allocated `channel_id`; the relay's
|
||||
//! establisher (the translate hop, ADR-042 layer 1) opens the channel
|
||||
//! on the producer leg and adopts the spoke-allocated id into the
|
||||
//! producer-leg `ChannelManager`; the relay's `OpenHandler` (the
|
||||
//! byte-forward hop, layer 2) pumps bytes between the two legs' data
|
||||
//! planes with `pump_bidi` — the hub never parses chunk framing
|
||||
//! (ADR-034/ADR-035: ids are per-connection, no rewrite exists).
|
||||
//!
|
||||
//! The consumer leg keeps the full open-op wrapper machinery (ACL,
|
||||
//! per-identity cap/ledger, establishment bound, teardown-on-failure)
|
||||
//! — the relay registers through
|
||||
//! [`ChannelCore::register_openable_with_establisher`], never a
|
||||
//! parallel authorization path.
|
||||
//!
|
||||
//! See ADR-042 (the relay contract), ADR-051 (the in-tree design:
|
||||
//! §2 what the relay holds, §3 the reason mapping, §4 the
|
||||
//! registration seam, §6 the rejection postures).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::auth::AuthContext;
|
||||
use crate::protocol::connection::CallConnection;
|
||||
use crate::protocol::wire::CallError;
|
||||
use crate::registry::registration::OperationRegistry;
|
||||
use crate::registry::spec::{OperationSpec, OperationType};
|
||||
|
||||
use super::manager::ChannelManager;
|
||||
use super::operations::{
|
||||
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
||||
};
|
||||
use super::pump::pump_bidi;
|
||||
use super::reassembly::{MpscRecvStream, MpscSendStream};
|
||||
|
||||
/// The producer-leg surface the relay holds (ADR-051 §2): the
|
||||
/// `CallConnection` for the translate hop's open call plus the
|
||||
/// producer-leg `ChannelManager` for `adopt_channel` and teardown
|
||||
/// visibility. Not a `ChannelClient` — the hub's `CallConnection` is
|
||||
/// shared by import, relay establisher, and hub ops (three claimants),
|
||||
/// so `take_call_connection`'s detach is wrong for a hub. A hub that
|
||||
/// dials the spoke with `ChannelClient::from_connection`, imports via
|
||||
/// `from_call`, then tears the client down keeps the relay alive on
|
||||
/// these `Arc`s.
|
||||
#[derive(Clone)]
|
||||
pub struct ProducerLeg {
|
||||
/// The producer leg's call connection — the hub's channel-0
|
||||
/// connection to the spoke, the one `from_call` imported the open
|
||||
/// op over.
|
||||
pub call: Arc<CallConnection>,
|
||||
/// The producer leg's channel manager — the relay's establisher
|
||||
/// adopts the spoke-allocated channel id here.
|
||||
pub manager: ChannelManager,
|
||||
}
|
||||
|
||||
/// The plan payload the relay's establisher hands its pump handler:
|
||||
/// the spoke-leg streams. The establisher and the handler agree on
|
||||
/// this concrete type (the `ChannelPlan` downcast contract, ADR-049
|
||||
/// amendment 2); alkcall never inspects it.
|
||||
struct RelayPlan {
|
||||
spoke_send: MpscSendStream,
|
||||
spoke_recv: MpscRecvStream,
|
||||
}
|
||||
|
||||
/// The relay component (ADR-051). Holds the producer-leg surface;
|
||||
/// [`ChannelRelay::register_relay_openable`] registers a
|
||||
/// from_call-imported marked spec on a consumer-leg channel-0 fork
|
||||
/// registry with the translate + byte-forward machinery.
|
||||
#[derive(Clone)]
|
||||
pub struct ChannelRelay {
|
||||
producer_leg: ProducerLeg,
|
||||
}
|
||||
|
||||
impl ChannelRelay {
|
||||
/// Construct a relay over a producer leg.
|
||||
pub fn new(producer_leg: ProducerLeg) -> Self {
|
||||
Self { producer_leg }
|
||||
}
|
||||
|
||||
/// The producer-leg surface this relay translates onto.
|
||||
pub fn producer_leg(&self) -> &ProducerLeg {
|
||||
&self.producer_leg
|
||||
}
|
||||
|
||||
/// Register a from_call-imported marked spec as a relay openable
|
||||
/// on the consumer-leg channel-0 registry (ADR-051 §4 phase 2 —
|
||||
/// the fork is the `consumer_registry` argument).
|
||||
///
|
||||
/// - `consumer_core` is the consumer leg's [`ChannelCore`] (its
|
||||
/// channel-0 manager + lifecycle policy) — the full open-op
|
||||
/// wrapper machinery (ACL, cap/ledger, establishment bound,
|
||||
/// teardown) runs on the consumer leg exactly as for a locally
|
||||
/// produced open op.
|
||||
/// - `spec` must carry the `channel_open` marker (reconstructed
|
||||
/// WITH the marker by discovery, ADR-047 amendment 3). A
|
||||
/// `Pub`-typed marked spec is a loud assembly error
|
||||
/// ([`RelayError::PubTypedOpen`] — the C-08 blocker, ADR-051
|
||||
/// §6); an unmarked spec is likewise an error — branch on the
|
||||
/// marker before calling.
|
||||
/// - The translate hop calls the producer leg with the spec's own
|
||||
/// op name; a hub that re-names the consumer-visible op composes
|
||||
/// its own establisher via
|
||||
/// [`ChannelCore::register_openable_with_establisher`] instead.
|
||||
pub fn register_relay_openable(
|
||||
&self,
|
||||
consumer_core: &ChannelCore,
|
||||
consumer_registry: &OperationRegistry,
|
||||
spec: OperationSpec,
|
||||
consumer_auth: AuthContext,
|
||||
) -> Result<(), RelayError> {
|
||||
if spec.channel_open.is_none() {
|
||||
return Err(RelayError::UnmarkedSpec {
|
||||
message: format!(
|
||||
"spec `{}` has no channel_open marker — relay registration is \
|
||||
for from_call-imported marked specs (ADR-051 §6)",
|
||||
spec.name
|
||||
),
|
||||
});
|
||||
}
|
||||
if spec.op_type == OperationType::Pub {
|
||||
return Err(RelayError::PubTypedOpen {
|
||||
message: format!(
|
||||
"spec `{}` is a Pub-typed channel-open op — the Pub open path is \
|
||||
not implemented (C-08 blocker, `channel:pub_open_not_implemented` \
|
||||
class; ADR-051 §6)",
|
||||
spec.name
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let producer_call = Arc::clone(&self.producer_leg.call);
|
||||
let producer_manager = self.producer_leg.manager.clone();
|
||||
let op_name = spec.name.clone();
|
||||
let establisher: OpenEstablisher = Arc::new(move |input, per_call_auth| {
|
||||
let producer_call = Arc::clone(&producer_call);
|
||||
let producer_manager = producer_manager.clone();
|
||||
let op_name = op_name.clone();
|
||||
Box::pin(async move {
|
||||
open_on_producer_leg(
|
||||
&producer_call,
|
||||
&producer_manager,
|
||||
&op_name,
|
||||
input,
|
||||
&per_call_auth,
|
||||
)
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
let open_handler: OpenHandler = Arc::new(move |_input, plan, channel_conn, _auth| {
|
||||
tokio::spawn(async move {
|
||||
let Some(plan) = plan else {
|
||||
return;
|
||||
};
|
||||
// The plan is uniquely held here (establisher →
|
||||
// wrapper → handler, moved each hop), so `try_unwrap`
|
||||
// succeeds; on the phantom-holder case the wrapper's
|
||||
// no-accept telemetry fires and the spoke leg EOFs
|
||||
// when the plan finally drops.
|
||||
let Ok(relay_plan) = plan.downcast::<RelayPlan>() else {
|
||||
tracing::debug!("relay: establisher plan of unexpected type");
|
||||
return;
|
||||
};
|
||||
let Ok(relay_plan) = Arc::try_unwrap(relay_plan) else {
|
||||
tracing::debug!("relay: establisher plan held elsewhere; skipping pump");
|
||||
return;
|
||||
};
|
||||
let Ok(bidi) = channel_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
// The byte-forward hop (ADR-042 layer 2, ADR-050):
|
||||
// two pumps joined inline (R-02). On completion the
|
||||
// pump's drops EOF the spoke leg; the wrapper's
|
||||
// handler-exit teardown covers the consumer leg.
|
||||
let _ = pump_bidi(bidi, relay_plan.spoke_recv, relay_plan.spoke_send).await;
|
||||
})
|
||||
});
|
||||
|
||||
consumer_core
|
||||
.register_openable_with_establisher(
|
||||
spec,
|
||||
Some(establisher),
|
||||
open_handler,
|
||||
consumer_registry,
|
||||
consumer_auth,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| RelayError::Registration { message: e })
|
||||
}
|
||||
}
|
||||
|
||||
/// The translate hop (ADR-051 §1 layer 1, §2): call the producer leg's
|
||||
/// open op with the forwarded payload, adopt the returned spoke
|
||||
/// `channel_id` into the producer-leg manager, and return
|
||||
/// `Establishment::new(plan)` whose plan carries the spoke-leg
|
||||
/// streams. The spoke reply's `channel_id` is stripped (the consumer
|
||||
/// reply carries the hub-allocated id); the reply's other fields ride
|
||||
/// `with_reply_fields` (`bound` flows end-to-end, per-hop truthful).
|
||||
async fn open_on_producer_leg(
|
||||
producer_call: &Arc<CallConnection>,
|
||||
producer_manager: &ChannelManager,
|
||||
op_name: &str,
|
||||
input: Value,
|
||||
per_call_auth: &AuthContext,
|
||||
) -> Result<Establishment, EstablishmentError> {
|
||||
let payload = build_relay_payload(op_name, input, per_call_auth);
|
||||
let response = producer_call.call_with_payload(payload).await;
|
||||
let out = match response.result {
|
||||
Ok(out) => out,
|
||||
Err(spoke_error) => return Err(map_spoke_error(&spoke_error)),
|
||||
};
|
||||
|
||||
let spoke_id = out
|
||||
.get("channel_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| EstablishmentError::HandlerError {
|
||||
message: format!(
|
||||
"spoke open reply for `{op_name}` missing `channel_id` — malformed responder"
|
||||
),
|
||||
})? as u32;
|
||||
|
||||
// Adopt the spoke-allocated id into the producer-leg manager so
|
||||
// the spoke leg's mux/demux route this channel — the same shape
|
||||
// `ChannelClient::open_channel` performs, minus the client. The
|
||||
// producer leg is the connect side of the hub↔spoke channels
|
||||
// connection, so the spoke's ids are even and disjoint from the
|
||||
// hub's odd connect-side allocations.
|
||||
let (spoke_send, spoke_recv) = producer_manager
|
||||
.adopt_channel(spoke_id, "alk/relay", None)
|
||||
.await
|
||||
.map_err(|e| EstablishmentError::ResourceShortage {
|
||||
message: format!(
|
||||
"spoke channel {spoke_id} adopt failed on the hub's producer leg: {e}"
|
||||
),
|
||||
})?;
|
||||
|
||||
let mut reply_fields = Map::new();
|
||||
if let Some(obj) = out.as_object() {
|
||||
for (key, value) in obj {
|
||||
if key != RESERVED_REPLY_KEY {
|
||||
reply_fields.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let establishment = Establishment::new(Arc::new(RelayPlan {
|
||||
spoke_send,
|
||||
spoke_recv,
|
||||
}) as super::operations::ChannelPlan);
|
||||
Ok(if reply_fields.is_empty() {
|
||||
establishment
|
||||
} else {
|
||||
establishment.with_reply_fields(reply_fields)
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a spoke failure into the establisher vocabulary (ADR-051 §3 —
|
||||
/// the reason table; the spoke's code and message are preserved in the
|
||||
/// mapped variant's `message`, never discarded — a consumer debugging
|
||||
/// a FORBIDDEN-through-relay sees the spoke's grant failure).
|
||||
///
|
||||
/// The `timeout` row is the one non-1:1 case: the consumer-visible
|
||||
/// reason is `dial_failed` because establishment timeouts are
|
||||
/// wrapper-generated (ADR-049 §3) and not an establisher vocabulary
|
||||
/// word; the spoke's message carries the truth.
|
||||
fn map_spoke_error(spoke_error: &CallError) -> EstablishmentError {
|
||||
if spoke_error.code == CHANNEL_OPEN_FAILED {
|
||||
let reason = spoke_error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("");
|
||||
let detail = spoke_error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("message"))
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(spoke_error.message.as_str());
|
||||
let message = format!("spoke leg: {reason}: {detail}");
|
||||
return match reason {
|
||||
"dial_failed" | "timeout" => EstablishmentError::DialFailed { message },
|
||||
"unknown_resource" => EstablishmentError::UnknownResource { message },
|
||||
"resource_shortage" => EstablishmentError::ResourceShortage { message },
|
||||
_ => EstablishmentError::HandlerError { message },
|
||||
};
|
||||
}
|
||||
// `NOT_FOUND` (spoke predates the op), `FORBIDDEN` (hub lacks the
|
||||
// spoke grant), and every other call-error class.
|
||||
EstablishmentError::HandlerError {
|
||||
message: format!("spoke leg: {} ({})", spoke_error.code, spoke_error.message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `call.requested` payload for the translate hop: the hub
|
||||
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
|
||||
/// — the same shape `build_forwarded_payload` produces for the plain
|
||||
/// forwarding stubs). `forwarded_for` is metadata, never consulted by
|
||||
/// any `AccessControl::check`; it is omitted when the consumer's call
|
||||
/// carried no resolved identity.
|
||||
fn build_relay_payload(op_name: &str, input: Value, per_call_auth: &AuthContext) -> Value {
|
||||
let mut payload = Map::new();
|
||||
payload.insert(
|
||||
"operationId".to_string(),
|
||||
Value::String(op_name.to_string()),
|
||||
);
|
||||
payload.insert("input".to_string(), input);
|
||||
if let Some(originator) = &per_call_auth.identity {
|
||||
if let Ok(value) = serde_json::to_value(originator) {
|
||||
payload.insert("forwarded_for".to_string(), value);
|
||||
}
|
||||
}
|
||||
Value::Object(payload)
|
||||
}
|
||||
|
||||
/// The assembly-time errors `ChannelRelay::register_relay_openable`
|
||||
/// produces (ADR-051 §6 — the rejection postures are loud, never
|
||||
/// silent stubs).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum RelayError {
|
||||
/// The spec carries no `channel_open` marker — the relay only
|
||||
/// registers marked specs.
|
||||
#[error("unmarked spec: {message}")]
|
||||
UnmarkedSpec { message: String },
|
||||
/// A `Pub`-typed marked spec — the Pub open path is not
|
||||
/// implemented (C-08 blocker; `channel:pub_open_not_implemented`
|
||||
/// class).
|
||||
#[error("pub-typed open op: {message}")]
|
||||
PubTypedOpen { message: String },
|
||||
/// The consumer-leg registration itself failed (schema compile,
|
||||
/// handler-kind mismatch).
|
||||
#[error("registration failed: {message}")]
|
||||
Registration { message: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "relay_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,893 @@
|
||||
//! Unit 3a gates (review 008; ADR-051 §"Verification gates").
|
||||
//!
|
||||
//! - The establisher populates `forwarded_for` from the consumer's
|
||||
//! per-call identity and strips the spoke reply's `channel_id`.
|
||||
//! - A spoke `channel:open_failed` maps to the consumer-leg
|
||||
//! `channel:open_failed` with the mapped reason and the spoke's
|
||||
//! message preserved; the consumer leg tears down (ledger
|
||||
//! decremented); no spoke channel leaks (the adopt-then-fail window).
|
||||
//! - `bound` (Unit 1's projection) survives the relay to the consumer
|
||||
//! reply.
|
||||
//! - Pub-typed and unmarked specs are rejected at registration.
|
||||
//! - The full relay round trip over real channels connections
|
||||
//! (consumer → hub → spoke), data both directions, the hub never
|
||||
//! parsing the data plane.
|
||||
|
||||
use super::*;
|
||||
use crate::channels::adapter::ChannelsAdapter;
|
||||
use crate::channels::operations::ChannelCore;
|
||||
use crate::channels::policy::NoCap;
|
||||
use crate::core::auth::{AuthContext, Identity, IdentityProvider};
|
||||
struct NoopIdProvider;
|
||||
impl IdentityProvider for NoopIdProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _: &crate::core::auth::AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
use crate::core::types::Connection;
|
||||
use crate::protocol::connection::{split_single_stream, CallConnection};
|
||||
use crate::protocol::dispatch::Dispatcher;
|
||||
use crate::registry::registration::OperationRegistry;
|
||||
use crate::registry::spec::{AccessControl, ChannelOpenSpec, Visibility};
|
||||
use serde_json::json;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const TEST_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321);
|
||||
const OP: &str = "channels/tunnel/direct";
|
||||
const ALPN: &str = "alk/tunnel";
|
||||
|
||||
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||
Identity {
|
||||
id: id.to_string(),
|
||||
scopes: scopes.iter().map(|s| (*s).to_string()).collect(),
|
||||
resources: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_spec(name: &str, op_type: OperationType, alpn: Option<String>) -> OperationSpec {
|
||||
let mut spec = OperationSpec::new(
|
||||
name,
|
||||
op_type,
|
||||
Visibility::External,
|
||||
json!({ "type": "object" }),
|
||||
json!({ "type": "object" }),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
);
|
||||
if let Some(alpn) = alpn {
|
||||
spec = spec.with_channel_open(ChannelOpenSpec::new(alpn));
|
||||
}
|
||||
spec
|
||||
}
|
||||
|
||||
// --- ADR-051 §6: the rejection postures ----------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn unmarked_spec_is_rejected_at_registration() {
|
||||
let producer_manager = manager_for_test();
|
||||
let relay = ChannelRelay::new(ProducerLeg {
|
||||
call: overlay_connection(),
|
||||
manager: producer_manager,
|
||||
});
|
||||
let err = relay
|
||||
.register_relay_openable(
|
||||
&core_for_test(),
|
||||
&OperationRegistry::new(),
|
||||
relay_spec("plain/op", OperationType::Query, None),
|
||||
AuthContext::anonymous(b"alk/call"),
|
||||
)
|
||||
.expect_err("unmarked spec must be rejected");
|
||||
assert!(
|
||||
matches!(err, RelayError::UnmarkedSpec { .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pub_typed_marked_spec_is_rejected_at_registration() {
|
||||
let relay = ChannelRelay::new(ProducerLeg {
|
||||
call: overlay_connection(),
|
||||
manager: manager_for_test(),
|
||||
});
|
||||
let err = relay
|
||||
.register_relay_openable(
|
||||
&core_for_test(),
|
||||
&OperationRegistry::new(),
|
||||
relay_spec(
|
||||
"channels/x/pub",
|
||||
OperationType::Pub,
|
||||
Some("alk/x".to_string()),
|
||||
),
|
||||
AuthContext::anonymous(b"alk/call"),
|
||||
)
|
||||
.expect_err("Pub-typed marked spec must be rejected");
|
||||
assert!(
|
||||
matches!(err, RelayError::PubTypedOpen { .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(err.to_string().contains("pub_open_not_implemented"));
|
||||
}
|
||||
|
||||
// --- ADR-051 §3: the reason mapping --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn spoke_open_failed_reasons_map_per_the_table() {
|
||||
let dial = super::map_spoke_error(
|
||||
&CallError::new("channel:open_failed", "x", false)
|
||||
.with_details(json!({ "reason": "dial_failed", "message": "refused" })),
|
||||
);
|
||||
assert!(matches!(dial, EstablishmentError::DialFailed { .. }));
|
||||
assert!(dial.message().contains("refused"), "{}", dial.message());
|
||||
|
||||
let timeout = super::map_spoke_error(
|
||||
&CallError::new("channel:open_failed", "x", false)
|
||||
.with_details(json!({ "reason": "timeout", "message": "spoke bound" })),
|
||||
);
|
||||
assert!(
|
||||
matches!(timeout, EstablishmentError::DialFailed { .. }),
|
||||
"timeout maps to dial_failed (ADR-051 §3)"
|
||||
);
|
||||
assert_eq!(
|
||||
timeout.message(),
|
||||
"spoke leg: timeout: spoke bound",
|
||||
"the spoke's message is preserved"
|
||||
);
|
||||
|
||||
let unknown = super::map_spoke_error(
|
||||
&CallError::new("channel:open_failed", "x", false)
|
||||
.with_details(json!({ "reason": "unknown_resource", "message": "no op" })),
|
||||
);
|
||||
assert!(matches!(
|
||||
unknown,
|
||||
EstablishmentError::UnknownResource { .. }
|
||||
));
|
||||
|
||||
let shortage = super::map_spoke_error(
|
||||
&CallError::new("channel:open_failed", "x", false)
|
||||
.with_details(json!({ "reason": "resource_shortage", "message": "ports" })),
|
||||
);
|
||||
assert!(matches!(
|
||||
shortage,
|
||||
EstablishmentError::ResourceShortage { .. }
|
||||
));
|
||||
|
||||
let handler = super::map_spoke_error(
|
||||
&CallError::new("channel:open_failed", "x", false)
|
||||
.with_details(json!({ "reason": "handler_error", "message": "boom" })),
|
||||
);
|
||||
assert!(matches!(handler, EstablishmentError::HandlerError { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoke_call_error_classes_map_to_handler_error_with_code_preserved() {
|
||||
for code in ["NOT_FOUND", "FORBIDDEN", "TIMEOUT"] {
|
||||
let mapped = super::map_spoke_error(&CallError::new(code, format!("{code} detail"), false));
|
||||
assert!(
|
||||
matches!(mapped, EstablishmentError::HandlerError { .. }),
|
||||
"{code} maps to HandlerError"
|
||||
);
|
||||
assert!(
|
||||
mapped.message().contains(code),
|
||||
"the spoke's code is preserved in the message: {}",
|
||||
mapped.message()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADR-026 §3: the forwarded payload -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn relay_payload_populates_forwarded_for_from_consumer_identity() {
|
||||
let auth = AuthContext {
|
||||
identity: Some(identity("alice", &["tunnel:direct"])),
|
||||
..AuthContext::anonymous(b"alk/call")
|
||||
};
|
||||
let payload = super::build_relay_payload("channels/tunnel/direct", json!({ "t": "x" }), &auth);
|
||||
assert_eq!(payload["operationId"], "channels/tunnel/direct");
|
||||
assert_eq!(payload["input"], json!({ "t": "x" }));
|
||||
assert_eq!(payload["forwarded_for"]["id"], "alice");
|
||||
assert_eq!(payload["forwarded_for"]["scopes"][0], "tunnel:direct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_payload_omits_forwarded_for_when_identity_none() {
|
||||
let auth = AuthContext::anonymous(b"alk/call");
|
||||
let payload = super::build_relay_payload("channels/tunnel/direct", json!({}), &auth);
|
||||
assert!(payload.get("forwarded_for").is_none());
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
fn overlay_connection() -> Arc<CallConnection> {
|
||||
Arc::new(CallConnection::new_overlay_only(identity("spoke", &[])))
|
||||
}
|
||||
|
||||
fn manager_for_test() -> ChannelManager {
|
||||
let (_client, server) = tokio::io::duplex(1024);
|
||||
let (_reader, writer) = tokio::io::split(server);
|
||||
let (handle, runner) = super::super::mux::MuxRunner::new(Box::new(writer));
|
||||
tokio::spawn(async move {
|
||||
let _ = runner.run().await;
|
||||
});
|
||||
ChannelManager::with_defaults(handle, None)
|
||||
}
|
||||
|
||||
fn core_for_test() -> ChannelCore {
|
||||
ChannelCore::new(manager_for_test(), super::super::policy::default_policy())
|
||||
}
|
||||
|
||||
// --- the real-wire relay round trip (ADR-051 gates 1/2/3) -----------------
|
||||
//
|
||||
// Topology: consumer —(alk/channels)— hub —(alk/channels)— spoke.
|
||||
// The spoke serves `channels/tunnel/direct` (an establisher that
|
||||
// contributes `bound` and an echo data-plane handler). The hub imports
|
||||
// it with `from_call` and re-exposes the reconstructed marked spec on
|
||||
// the consumer leg's channel-0 fork via
|
||||
// `ChannelRelay::register_relay_openable`. The consumer opens through
|
||||
// the hub: the open resolves with the hub-allocated `channel_id` (the
|
||||
// spoke reply's id is stripped), the spoke's `bound` survives the
|
||||
// relay, and data flows both directions without the hub parsing the
|
||||
// data plane.
|
||||
|
||||
fn spoke_open_handler(data_tx: tokio::sync::mpsc::Sender<String>) -> OpenHandler {
|
||||
Arc::new(move |_input, _plan, channel_conn, _auth| {
|
||||
let data_tx = data_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(mut bidi) = channel_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
if tokio::io::AsyncReadExt::read_to_end(&mut bidi, &mut buf)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = data_tx
|
||||
.send(String::from_utf8_lossy(&buf).to_string())
|
||||
.await;
|
||||
// Echo back upper-cased, then EOF.
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = bidi.write_all(buf.to_ascii_uppercase().as_slice()).await;
|
||||
let _ = bidi.shutdown().await;
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_round_trip_consumer_hub_spoke_with_bound_and_data() {
|
||||
use crate::channels::operations::Establishment;
|
||||
use crate::client::from_call;
|
||||
use crate::client::FromCallConfig;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// ---- the spoke (producer leg) ----
|
||||
let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::<String>(4);
|
||||
let spoke_open_handler = spoke_open_handler(data_tx);
|
||||
let spoke_establisher: OpenEstablisher = Arc::new(|_input, _auth| {
|
||||
Box::pin(async {
|
||||
Ok(Establishment::default()
|
||||
.with_reply_field("bound", json!({ "host": "203.0.113.9", "port": 42113 })))
|
||||
})
|
||||
});
|
||||
let spoke_install: crate::channels::adapter::InstallChannelZero = {
|
||||
let establisher = Arc::clone(&spoke_establisher);
|
||||
Arc::new(move |manager, channel0_conn, auth| {
|
||||
let establisher = Arc::clone(&establisher);
|
||||
let open_handler = spoke_open_handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let core = ChannelCore::new(manager, super::super::policy::default_policy());
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
crate::registry::discovery::install_bootstrap_discovery(®istry)
|
||||
.expect("spoke bootstrap discovery");
|
||||
let spec = relay_spec(
|
||||
"channels/tunnel/direct",
|
||||
OperationType::Sub,
|
||||
Some("alk/tunnel".to_string()),
|
||||
);
|
||||
core.register_openable_with_establisher(
|
||||
spec,
|
||||
Some(establisher),
|
||||
open_handler,
|
||||
®istry,
|
||||
auth.clone(),
|
||||
None,
|
||||
)
|
||||
.expect("spoke registers the direct open op");
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(
|
||||
registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
)
|
||||
.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let (hub_spoke_client_end, hub_spoke_server_end) = tokio::io::duplex(64 * 1024);
|
||||
let hub_conn_to_spoke = Connection::from_bidi(
|
||||
hub_spoke_client_end,
|
||||
b"alk/channels".to_vec(),
|
||||
Some(TEST_ADDR),
|
||||
);
|
||||
let spoke_conn = Connection::from_bidi(
|
||||
hub_spoke_server_end,
|
||||
b"alk/channels".to_vec(),
|
||||
Some(TEST_ADDR),
|
||||
);
|
||||
|
||||
let spoke_adapter = ChannelsAdapter::new(spoke_install, Arc::new(NoCap));
|
||||
let _spoke_task = tokio::spawn(async move {
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _ =
|
||||
crate::core::types::ProtocolHandler::handle(&spoke_adapter, spoke_conn, &auth).await;
|
||||
});
|
||||
|
||||
// ---- the hub: dial the spoke, discover, stash ----
|
||||
let hub_client_to_spoke =
|
||||
crate::channels::client::ChannelClient::from_connection(hub_conn_to_spoke)
|
||||
.await
|
||||
.expect("hub's channel client to the spoke");
|
||||
|
||||
let producer_call = hub_client_to_spoke
|
||||
.take_call_connection()
|
||||
.await
|
||||
.expect("hub takes its channel-0 CallConnection to the spoke");
|
||||
let bundles = from_call(&producer_call, FromCallConfig::new())
|
||||
.await
|
||||
.expect("from_call discovers the spoke's ops");
|
||||
let imported = bundles
|
||||
.into_iter()
|
||||
.find(|b| b.spec.name == OP)
|
||||
.expect("the spoke's direct op is discovered");
|
||||
assert!(
|
||||
imported.spec.channel_open.is_some(),
|
||||
"the flavor-form spec is reconstructed WITH the marker (ADR-047 amendment 3)"
|
||||
);
|
||||
assert_eq!(
|
||||
imported
|
||||
.spec
|
||||
.channel_open
|
||||
.as_ref()
|
||||
.expect("marker")
|
||||
.alpn
|
||||
.as_ref(),
|
||||
ALPN
|
||||
);
|
||||
|
||||
// The hub keeps the producer-leg surface for the relay (ADR-051
|
||||
// §2): the shared CallConnection + the client's manager. The
|
||||
// client is dropped afterward — its calling surface detaches, the
|
||||
// relay holds the Arcs.
|
||||
let producer_leg = ProducerLeg {
|
||||
call: producer_call,
|
||||
manager: hub_client_to_spoke.manager().clone(),
|
||||
};
|
||||
drop(hub_client_to_spoke);
|
||||
|
||||
let relay = Arc::new(ChannelRelay::new(producer_leg));
|
||||
let imported_spec = Arc::new(imported.spec);
|
||||
|
||||
// ---- the hub's consumer leg: the fork-registry install hook ----
|
||||
// (ADR-051 §4 phase 2 — fork, generic ops, bootstrap discovery,
|
||||
// the imported marked spec via the relay registration, dispatch.)
|
||||
let consumer_install: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |consumer_manager, channel0_conn, auth| {
|
||||
let relay = Arc::clone(&relay);
|
||||
let imported_spec = Arc::clone(&imported_spec);
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
crate::channels::operations::ChannelOperations::with_default_policy(
|
||||
consumer_manager.clone(),
|
||||
)
|
||||
.register_on(®istry)
|
||||
.expect("generic channel ops");
|
||||
crate::registry::discovery::install_bootstrap_discovery(®istry)
|
||||
.expect("bootstrap discovery");
|
||||
let consumer_core = ChannelCore::new(consumer_manager.clone(), Arc::new(NoCap));
|
||||
relay
|
||||
.register_relay_openable(
|
||||
&consumer_core,
|
||||
®istry,
|
||||
(*imported_spec).clone(),
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("relay openable registers on the fork");
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(
|
||||
registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
)
|
||||
.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
let (consumer_end, hub_consumer_end) = tokio::io::duplex(64 * 1024);
|
||||
let consumer_conn =
|
||||
Connection::from_bidi(consumer_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let hub_consumer_side =
|
||||
Connection::from_bidi(hub_consumer_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let consumer_adapter = ChannelsAdapter::new(consumer_install, Arc::new(NoCap));
|
||||
let _hub_task = tokio::spawn(async move {
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _ = crate::core::types::ProtocolHandler::handle(
|
||||
&consumer_adapter,
|
||||
hub_consumer_side,
|
||||
&auth,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// ---- the consumer opens through the hub ----
|
||||
let consumer = crate::channels::client::ChannelClient::from_connection(consumer_conn)
|
||||
.await
|
||||
.expect("consumer's channel client");
|
||||
|
||||
let (consumer_id, reply, mut send, mut recv) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
consumer.open_channel_with_reply(OP, json!({ "target": "example.internal:443" }), ALPN),
|
||||
)
|
||||
.await
|
||||
.expect("relay open timed out")
|
||||
.expect("open through the relay");
|
||||
|
||||
// The consumer reply carries the hub-allocated id (the consumer
|
||||
// leg's accept side allocates even ids), NOT the spoke's.
|
||||
assert!(consumer_id > 0);
|
||||
assert_eq!(reply["channel_id"], json!(consumer_id));
|
||||
assert_eq!(
|
||||
reply["bound"],
|
||||
json!({ "host": "203.0.113.9", "port": 42113 }),
|
||||
"the spoke establisher's `bound` survives the relay (per-hop truthful)"
|
||||
);
|
||||
|
||||
// Data flows both directions; the hub never parses the data plane.
|
||||
send.write_all(b"ping-payload").await.expect("write");
|
||||
send.shutdown().await.expect("shutdown");
|
||||
drop(send);
|
||||
|
||||
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), data_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for the spoke's data-plane bytes")
|
||||
.expect("the spoke handler received the consumer's bytes through the relay");
|
||||
assert_eq!(seen, "ping-payload");
|
||||
|
||||
let mut echoed = Vec::new();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
recv.read_to_end(&mut echoed),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for the echo")
|
||||
.expect("read echo");
|
||||
assert_eq!(
|
||||
echoed, b"PING-PAYLOAD",
|
||||
"the spoke's echo flows back through the relay"
|
||||
);
|
||||
}
|
||||
|
||||
/// Gate: a spoke `channel:open_failed` maps to the consumer-leg
|
||||
/// `channel:open_failed` with the mapped reason and the spoke's
|
||||
/// message preserved; the consumer leg tears down (ledger
|
||||
/// decremented) and no spoke channel leaks (the adopt-then-fail
|
||||
/// window — the hub never adopts because the spoke open failed
|
||||
/// before replying).
|
||||
#[tokio::test]
|
||||
async fn relay_spoke_open_failure_maps_reason_and_tears_down() {
|
||||
use crate::channels::client::ChannelClient;
|
||||
use crate::channels::operations::EstablishmentError;
|
||||
use crate::channels::policy::PerIdentityChannelPolicy;
|
||||
|
||||
// The spoke serves the direct op with an establisher that always
|
||||
// fails `dial_failed` (the target refused).
|
||||
let spoke_establisher: OpenEstablisher = Arc::new(|_input, _auth| {
|
||||
Box::pin(async {
|
||||
Err(EstablishmentError::DialFailed {
|
||||
message: "target refused the connection".to_string(),
|
||||
})
|
||||
})
|
||||
});
|
||||
let spoke_install: crate::channels::adapter::InstallChannelZero = {
|
||||
let establisher = Arc::clone(&spoke_establisher);
|
||||
Arc::new(move |manager, channel0_conn, auth| {
|
||||
let establisher = Arc::clone(&establisher);
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let policy = super::super::policy::default_policy();
|
||||
let core = ChannelCore::new(manager.clone(), policy);
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
crate::registry::discovery::install_bootstrap_discovery(®istry)
|
||||
.expect("spoke bootstrap discovery");
|
||||
let spec = relay_spec(
|
||||
"channels/tunnel/direct",
|
||||
OperationType::Sub,
|
||||
Some("alk/tunnel".to_string()),
|
||||
);
|
||||
core.register_openable_with_establisher(
|
||||
spec,
|
||||
Some(establisher),
|
||||
Arc::new(|_i, _p, _c, _a| tokio::spawn(async {})),
|
||||
®istry,
|
||||
auth.clone(),
|
||||
None,
|
||||
)
|
||||
.expect("spoke registers the direct open op");
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(
|
||||
registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
)
|
||||
.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let (hub_spoke_client_end, hub_spoke_server_end) = tokio::io::duplex(64 * 1024);
|
||||
let hub_conn_to_spoke = Connection::from_bidi(
|
||||
hub_spoke_client_end,
|
||||
b"alk/channels".to_vec(),
|
||||
Some(TEST_ADDR),
|
||||
);
|
||||
let spoke_conn = Connection::from_bidi(
|
||||
hub_spoke_server_end,
|
||||
b"alk/channels".to_vec(),
|
||||
Some(TEST_ADDR),
|
||||
);
|
||||
|
||||
let spoke_adapter = ChannelsAdapter::new(spoke_install, Arc::new(NoCap));
|
||||
let _spoke_task = tokio::spawn(async move {
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _ =
|
||||
crate::core::types::ProtocolHandler::handle(&spoke_adapter, spoke_conn, &auth).await;
|
||||
});
|
||||
|
||||
let hub_client_to_spoke = ChannelClient::from_connection(hub_conn_to_spoke)
|
||||
.await
|
||||
.expect("hub's channel client to the spoke");
|
||||
let producer_call = hub_client_to_spoke
|
||||
.take_call_connection()
|
||||
.await
|
||||
.expect("hub takes its channel-0 CallConnection");
|
||||
let bundles = crate::client::from_call(&producer_call, crate::client::FromCallConfig::new())
|
||||
.await
|
||||
.expect("discovery");
|
||||
let imported = bundles
|
||||
.into_iter()
|
||||
.find(|b| b.spec.name == OP)
|
||||
.expect("direct op discovered");
|
||||
|
||||
let producer_leg = ProducerLeg {
|
||||
call: producer_call,
|
||||
manager: hub_client_to_spoke.manager().clone(),
|
||||
};
|
||||
let spoke_manager = producer_leg.manager.clone();
|
||||
drop(hub_client_to_spoke);
|
||||
|
||||
let relay = Arc::new(ChannelRelay::new(producer_leg));
|
||||
let imported_spec = Arc::new(imported.spec);
|
||||
|
||||
// The consumer leg runs a per-identity cap so the ledger
|
||||
// decrement is observable.
|
||||
let consumer_policy = Arc::new(PerIdentityChannelPolicy::new(8));
|
||||
let consumer_policy_dyn: Arc<dyn crate::channels::policy::ChannelLifecyclePolicy> =
|
||||
Arc::clone(&consumer_policy) as Arc<dyn crate::channels::policy::ChannelLifecyclePolicy>;
|
||||
let consumer_install: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |consumer_manager, channel0_conn, auth| {
|
||||
let relay = Arc::clone(&relay);
|
||||
let imported_spec = Arc::clone(&imported_spec);
|
||||
let policy = Arc::clone(&consumer_policy_dyn);
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
crate::registry::discovery::install_bootstrap_discovery(®istry)
|
||||
.expect("bootstrap discovery");
|
||||
let consumer_core = ChannelCore::new(consumer_manager.clone(), policy);
|
||||
relay
|
||||
.register_relay_openable(
|
||||
&consumer_core,
|
||||
®istry,
|
||||
(*imported_spec).clone(),
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("relay openable");
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(
|
||||
registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
)
|
||||
.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
let (consumer_end, hub_consumer_end) = tokio::io::duplex(64 * 1024);
|
||||
let consumer_conn =
|
||||
Connection::from_bidi(consumer_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let hub_consumer_side =
|
||||
Connection::from_bidi(hub_consumer_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let consumer_adapter = ChannelsAdapter::new(consumer_install, Arc::new(NoCap));
|
||||
let _hub_task = tokio::spawn(async move {
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _ = crate::core::types::ProtocolHandler::handle(
|
||||
&consumer_adapter,
|
||||
hub_consumer_side,
|
||||
&auth,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let consumer = ChannelClient::from_connection(consumer_conn)
|
||||
.await
|
||||
.expect("consumer's channel client");
|
||||
|
||||
let err = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
consumer.open_channel(OP, json!({ "target": "refused.internal:1" }), ALPN),
|
||||
)
|
||||
.await
|
||||
.expect("relay open timed out");
|
||||
let call_error = match err {
|
||||
Err(e) => e
|
||||
.call_error()
|
||||
.expect("CallFailed carries the CallError")
|
||||
.clone(),
|
||||
Ok(_) => panic!("the spoke failure must surface as an Err through the relay"),
|
||||
};
|
||||
assert_eq!(call_error.code, "channel:open_failed");
|
||||
let details = call_error.details.expect("details");
|
||||
assert_eq!(details["reason"], "dial_failed", "the mapped reason");
|
||||
assert!(
|
||||
details["message"]
|
||||
.as_str()
|
||||
.expect("message")
|
||||
.contains("target refused the connection"),
|
||||
"the spoke's message is preserved: {}",
|
||||
details["message"]
|
||||
);
|
||||
|
||||
// No spoke channel leaks: the hub never adopted (the spoke open
|
||||
// failed before replying a channel_id), the consumer leg tore its
|
||||
// just-allocated channel down.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(
|
||||
consumer
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.into_iter()
|
||||
.all(|id| id == 0),
|
||||
"no consumer-leg data channel exists after the failed relay open"
|
||||
);
|
||||
assert!(
|
||||
spoke_manager.channel_ids().into_iter().all(|id| id == 0),
|
||||
"no spoke channel leaked (the adopt-then-fail window)"
|
||||
);
|
||||
let anonymous = identity("anonymous", &[]);
|
||||
assert_eq!(
|
||||
consumer_policy.count_for(&anonymous),
|
||||
0,
|
||||
"the consumer leg's ledger decremented (teardown on establisher failure)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Gate (ADR-051 gate 2's companion): a standard-shape
|
||||
/// `channels/tty/sub` spec relays identically — no regression on the
|
||||
/// existing derivation's shape.
|
||||
#[tokio::test]
|
||||
async fn relay_round_trip_pins_standard_shape_no_regression() {
|
||||
use crate::channels::operations::Establishment;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::<String>(4);
|
||||
let spoke_open_handler = spoke_open_handler(data_tx);
|
||||
let spoke_establisher: OpenEstablisher =
|
||||
Arc::new(|_input, _auth| Box::pin(async { Ok(Establishment::default()) }));
|
||||
let spoke_install: crate::channels::adapter::InstallChannelZero = {
|
||||
let establisher = Arc::clone(&spoke_establisher);
|
||||
Arc::new(move |manager, channel0_conn, auth| {
|
||||
let establisher = Arc::clone(&establisher);
|
||||
let open_handler = spoke_open_handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let core = ChannelCore::new(manager, super::super::policy::default_policy());
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
crate::registry::discovery::install_bootstrap_discovery(®istry)
|
||||
.expect("spoke bootstrap discovery");
|
||||
let spec = relay_spec(
|
||||
"channels/tty/sub",
|
||||
OperationType::Sub,
|
||||
Some("alk/tty".to_string()),
|
||||
);
|
||||
core.register_openable_with_establisher(
|
||||
spec,
|
||||
Some(establisher),
|
||||
open_handler,
|
||||
®istry,
|
||||
auth.clone(),
|
||||
None,
|
||||
)
|
||||
.expect("spoke registers the tty open op");
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(
|
||||
registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
)
|
||||
.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let (hub_spoke_client_end, hub_spoke_server_end) = tokio::io::duplex(64 * 1024);
|
||||
let hub_conn_to_spoke = Connection::from_bidi(
|
||||
hub_spoke_client_end,
|
||||
b"alk/channels".to_vec(),
|
||||
Some(TEST_ADDR),
|
||||
);
|
||||
let spoke_conn = Connection::from_bidi(
|
||||
hub_spoke_server_end,
|
||||
b"alk/channels".to_vec(),
|
||||
Some(TEST_ADDR),
|
||||
);
|
||||
|
||||
let spoke_adapter = ChannelsAdapter::new(spoke_install, Arc::new(NoCap));
|
||||
let _spoke_task = tokio::spawn(async move {
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _ =
|
||||
crate::core::types::ProtocolHandler::handle(&spoke_adapter, spoke_conn, &auth).await;
|
||||
});
|
||||
|
||||
let hub_client_to_spoke =
|
||||
crate::channels::client::ChannelClient::from_connection(hub_conn_to_spoke)
|
||||
.await
|
||||
.expect("hub's channel client to the spoke");
|
||||
let producer_call = hub_client_to_spoke
|
||||
.take_call_connection()
|
||||
.await
|
||||
.expect("hub takes its channel-0 CallConnection");
|
||||
let bundles = crate::client::from_call(&producer_call, crate::client::FromCallConfig::new())
|
||||
.await
|
||||
.expect("discovery");
|
||||
let imported = bundles
|
||||
.into_iter()
|
||||
.find(|b| b.spec.name == "channels/tty/sub")
|
||||
.expect("the tty op is discovered");
|
||||
|
||||
let producer_leg = ProducerLeg {
|
||||
call: producer_call,
|
||||
manager: hub_client_to_spoke.manager().clone(),
|
||||
};
|
||||
drop(hub_client_to_spoke);
|
||||
|
||||
let relay = Arc::new(ChannelRelay::new(producer_leg));
|
||||
let imported_spec = Arc::new(imported.spec);
|
||||
|
||||
let consumer_install: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |consumer_manager, channel0_conn, auth| {
|
||||
let relay = Arc::clone(&relay);
|
||||
let imported_spec = Arc::clone(&imported_spec);
|
||||
tokio::spawn(async move {
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
crate::registry::discovery::install_bootstrap_discovery(®istry)
|
||||
.expect("bootstrap discovery");
|
||||
let consumer_core = ChannelCore::new(consumer_manager.clone(), Arc::new(NoCap));
|
||||
relay
|
||||
.register_relay_openable(
|
||||
&consumer_core,
|
||||
®istry,
|
||||
(*imported_spec).clone(),
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("relay openable");
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(
|
||||
registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
)
|
||||
.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
let (consumer_end, hub_consumer_end) = tokio::io::duplex(64 * 1024);
|
||||
let consumer_conn =
|
||||
Connection::from_bidi(consumer_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let hub_consumer_side =
|
||||
Connection::from_bidi(hub_consumer_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let consumer_adapter = ChannelsAdapter::new(consumer_install, Arc::new(NoCap));
|
||||
let _hub_task = tokio::spawn(async move {
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _ = crate::core::types::ProtocolHandler::handle(
|
||||
&consumer_adapter,
|
||||
hub_consumer_side,
|
||||
&auth,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let consumer = crate::channels::client::ChannelClient::from_connection(consumer_conn)
|
||||
.await
|
||||
.expect("consumer's channel client");
|
||||
let (consumer_id, reply, mut send, mut recv) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
consumer.open_channel_with_reply("channels/tty/sub", json!({}), "alk/tty"),
|
||||
)
|
||||
.await
|
||||
.expect("relay open timed out")
|
||||
.expect("standard-shape open through the relay");
|
||||
|
||||
assert!(consumer_id > 0);
|
||||
assert_eq!(
|
||||
reply.as_object().expect("object").len(),
|
||||
1,
|
||||
"no establisher reply fields: the reply is exactly channel_id"
|
||||
);
|
||||
|
||||
send.write_all(b"tty-bytes").await.expect("write");
|
||||
send.shutdown().await.expect("shutdown");
|
||||
drop(send);
|
||||
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), data_rx.recv())
|
||||
.await
|
||||
.expect("timed out")
|
||||
.expect("the spoke handler received the bytes");
|
||||
assert_eq!(seen, "tty-bytes");
|
||||
|
||||
let mut echoed = Vec::new();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
recv.read_to_end(&mut echoed),
|
||||
)
|
||||
.await
|
||||
.expect("timed out")
|
||||
.expect("read echo");
|
||||
assert_eq!(echoed, b"TTY-BYTES");
|
||||
}
|
||||
Reference in New Issue
Block a user