feat(channels): implement the single-publish fixes — S-01..S-04 (reviews 010/011; ADR-047 am. 4, ADR-052, ADR-053, ADR-022 am.)
S-01 — the marked-pub open contract (ADR-047 amendment 4): - ChannelCore::register_pub_openable + PubOpenHandler (the put path's handler returns its result for the merged single response; the wrapper awaits inline, teardown is EOF-driven, handler_task stays None); register_openable* refuses Pub-typed specs; the wrapper entry points refuse unmarked/non-Pub shapes - the reserved input key `channel_id` (the initiator-allocated id): stripped pre-validation by resolve_sink_handler (the single source of truth, P-08), handed to the wrapper via OperationContext.metadata, a missing key rejected INVALID_INPUT; adopt parity enforced (ManagerError::InvalidAdopt) and adopt failures answer channel:open_failed / adopt_failed - zero call.published on marked ops: the first publish chunk on a marked op is rejected loudly (channel:publish_chunks_rejected) in both dispatch loops; the handler is never fed - relay put-translation replaces RelayError::PubTypedOpen: hub allocates odd on the producer leg, opens the spoke op in the background (put-acks-in-result), one-direction pump, response-mirror with the producer's reserved key stripped - ChannelClient::open_pub_channel + PubOpenReply (the initiator- allocates put open capability) S-02 — channel/control implemented (ADR-052): establisher-contributed ChannelControl (Establishment::with_control), manager-owned registry, teardown removals, channel:no_control_handle capability answer. S-03 — channel/resources/subscribe implemented (ADR-053): the ResourceEnumerators registry (shared per peer), snapshot-then-changes enumerators, scan-once aggregation, the empty seed; the always-failing one-shot error stream removed. S-04 — op/register forward-through (ADR-022 amendment): announced unmarked Sub/Pub register the real streaming/sink forwarding shapes; marked announcements refused with MARKED_SPEC_UNSUPPORTED; marked specs refuse the generic registration path and register_imported (Result-returning now). Guard (review 010 action 3): tests/stub_guard.rs greps src/ for not_implemented; the four pinned stub tests are deleted and the marked-spec registration refusals are pinned by tests. Verified: cargo test (696 + guard), clippy --all-targets -D warnings, fmt --check, fuzz corpus replay.
This commit is contained in:
1 parent
df4d4d3a9e
commit
944c300c17
18 files changed
+2418
-326
No files matched your search
+130
-2
@@ -70,8 +70,8 @@ impl ChannelOpenError {
|
||||
|
||||
/// The establishment-failure reason code — `details.reason` of a
|
||||
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
|
||||
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
|
||||
/// any other failure shape.
|
||||
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
|
||||
/// `None` for any other failure shape.
|
||||
pub fn establishment_reason(&self) -> Option<&str> {
|
||||
match self {
|
||||
ChannelOpenError::CallFailed { error }
|
||||
@@ -88,6 +88,30 @@ impl ChannelOpenError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The awaited single merged response of a put open
|
||||
/// ([`ChannelClient::open_pub_channel`], ADR-047 amendment 4 §4.9 —
|
||||
/// the put ack rides in the result, ADR-047 amendment 4 §4.5). The
|
||||
/// putter writes bytes on the returned `send` half (or spawns pumps)
|
||||
/// and awaits this for the put's wire contract (e.g. alkblobs' put
|
||||
/// replying `{ token, digest }`). Await this after (or alongside) the
|
||||
/// byte stream, never before it — the response cannot exist before the
|
||||
/// byte stream completes.
|
||||
pub struct PubOpenReply {
|
||||
rx: tokio::sync::oneshot::Receiver<Result<Value, ChannelOpenError>>,
|
||||
}
|
||||
|
||||
impl PubOpenReply {
|
||||
/// The putter-side await: the merged response output (`Ok`);
|
||||
/// `Err` carries the wire `CallError` verbatim
|
||||
/// (`channel:open_failed` + `details.reason` per ADR-049 §3).
|
||||
pub async fn resolve(self) -> Result<Value, ChannelOpenError> {
|
||||
match self.rx.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(ChannelOpenError::MissingChannelId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The client-side handle for a channels connection. Constructed via
|
||||
/// [`ChannelClient::from_connection`] from an established
|
||||
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
|
||||
@@ -382,6 +406,110 @@ impl ChannelClient {
|
||||
.map(|(send, recv)| (channel_id, out, send, recv))
|
||||
}
|
||||
|
||||
/// The putter-facing open (ADR-047 amendment 4 §4.9 — the
|
||||
/// initiator-allocates capability `open_channel`/`open_channel_with_reply`
|
||||
/// cannot provide, because those await the open reply before
|
||||
/// returning the halves and a put's bytes flow before the reply
|
||||
/// exists, review 011 §2.7): allocates the connect-side odd id
|
||||
/// locally, inserts the reserved input key, sends the open op in a
|
||||
/// background task, and returns **immediately** — the caller
|
||||
/// writes bytes on `send` without waiting for anything (the
|
||||
/// put ack rides in the merged response; ADR-047 amendment 4 §4.5).
|
||||
///
|
||||
/// Returns `(channel_id, send, recv, reply)`; `reply` is the
|
||||
/// awaited single merged response — `Ok(Value)` (the full output,
|
||||
/// e.g. `{ token, digest }`) or the wire error. On a failed open
|
||||
/// the local adopt entry is torn down and `send`/`recv` are
|
||||
/// already EOF'd (teardown); the caller still holds them but every
|
||||
/// write is EOF-shaped. The `recv` half is the (bidirectional)
|
||||
/// channel's read side — a put that also receives reads it.
|
||||
pub async fn open_pub_channel(
|
||||
&self,
|
||||
operation_id: &str,
|
||||
input: Value,
|
||||
alpn: &str,
|
||||
) -> Result<(u32, MpscSendStream, MpscRecvStream, PubOpenReply), ChannelOpenError> {
|
||||
use crate::registry::spec::RESERVED_OPEN_INPUT_KEY;
|
||||
|
||||
let (channel_id, send, recv) = self
|
||||
.manager
|
||||
.open_channel(
|
||||
alpn,
|
||||
// The opener ledger is inert on the connect side (it
|
||||
// does not enforce the per-identity cap, ADR-041) —
|
||||
// the record is observability only.
|
||||
"channel-client",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::channels::manager::ManagerError::TooManyChannels { count, max } => {
|
||||
ChannelOpenError::CallFailed {
|
||||
error: crate::protocol::wire::CallError::new(
|
||||
"channel:too_many_channels",
|
||||
format!("per-connection channel cap exceeded: {count}/{max}"),
|
||||
false,
|
||||
)
|
||||
.with_details(serde_json::json!({ "count": count, "max": max })),
|
||||
}
|
||||
}
|
||||
other => ChannelOpenError::CallFailed {
|
||||
error: crate::protocol::wire::CallError::new(
|
||||
"channel:allocation_failed",
|
||||
format!("channel allocation failed: {other}"),
|
||||
false,
|
||||
),
|
||||
},
|
||||
})?;
|
||||
|
||||
let mut op_input = input;
|
||||
match op_input.as_object_mut() {
|
||||
Some(obj) => {
|
||||
obj.insert(
|
||||
RESERVED_OPEN_INPUT_KEY.to_string(),
|
||||
serde_json::json!(channel_id),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
return Err(ChannelOpenError::CallFailed {
|
||||
error: crate::protocol::wire::CallError::invalid_input(
|
||||
"marked pub open op input must be an object (ADR-047 amendment 4 §4.3)",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let call_conn = {
|
||||
let guard = self.call_connection.lock().await;
|
||||
guard.as_ref().map(Arc::clone)
|
||||
};
|
||||
let manager = self.manager.clone();
|
||||
let op_id = operation_id.to_string();
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let response = match &call_conn {
|
||||
Some(conn) => conn.call(&op_id, op_input).await,
|
||||
None => ResponseEnvelope::error(
|
||||
"channel-client",
|
||||
crate::protocol::wire::CallError::internal("channel client closed"),
|
||||
),
|
||||
};
|
||||
// A failed open tears the allocator-side entry down: the
|
||||
// responder refused, the channel is dead, and the local
|
||||
// half must not linger in the connection's channel map.
|
||||
if response.result.is_err() {
|
||||
let _ = manager.teardown_channel(channel_id);
|
||||
}
|
||||
let reply = match response.result {
|
||||
Ok(out) => Ok(out),
|
||||
Err(error) => Err(ChannelOpenError::CallFailed { error }),
|
||||
};
|
||||
let _ = reply_tx.send(reply);
|
||||
});
|
||||
|
||||
Ok((channel_id, send, recv, PubOpenReply { rx: reply_rx }))
|
||||
}
|
||||
|
||||
/// Take the `CallConnection` — used by the consumer to register
|
||||
/// imported ops (`from_call`) on the connection's overlay. After
|
||||
/// this, `call_open_op` returns an error (the connection is owned
|
||||
|
||||
@@ -62,6 +62,7 @@ const TTY_OP: &str = "channels/tty/sub";
|
||||
const TTY_ALPN: &str = "alk/tty";
|
||||
const ALPN: &str = "alk/tunnel";
|
||||
const PLAIN_OP: &str = "fs/read";
|
||||
const PUT_OP: &str = "channels/put/bulk";
|
||||
|
||||
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||
Identity {
|
||||
@@ -256,6 +257,49 @@ fn make_spoke_install(
|
||||
)
|
||||
.expect("spoke registers the tty open op");
|
||||
|
||||
// The marked pub openable (ADR-047 amendment 4 §4.7's
|
||||
// relay-translation gate): a put handler that consumes the
|
||||
// adopted channel's data plane to EOF and replies with the
|
||||
// digest over the streamed bytes.
|
||||
let put_spec = OperationSpec::new(
|
||||
PUT_OP,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({ "type": "object" }),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer" },
|
||||
"digest": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/blobs"));
|
||||
let put_handler: crate::channels::operations::PubOpenHandler =
|
||||
Arc::new(move |_input, _plan, channel_conn, _auth| {
|
||||
Box::pin(async move {
|
||||
let mut bidi = channel_conn.accept_bi().await.map_err(|_| {
|
||||
crate::protocol::wire::CallError::internal(
|
||||
"spoke put: the data plane never opened",
|
||||
)
|
||||
})?;
|
||||
let mut buf = Vec::new();
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut bidi, &mut buf)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::protocol::wire::CallError::internal(format!(
|
||||
"spoke put read failed: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(json!({ "digest": format!("spoke-{}", buf.len()) }))
|
||||
})
|
||||
});
|
||||
core.register_pub_openable(put_spec, None, put_handler, ®istry, auth.clone(), None)
|
||||
.expect("spoke registers the marked pub openable");
|
||||
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
plain_spec(PLAIN_OP),
|
||||
@@ -740,3 +784,51 @@ async fn gate2_tty_standard_shape_relays_with_no_regression() {
|
||||
"the tty relay's ledger decremented"
|
||||
);
|
||||
}
|
||||
|
||||
// --- gate: the pub-openables relay translation (ADR-047 amendment 4 §4.7) ---
|
||||
|
||||
/// A hub translating a pub openable end-to-end (review 011 §3): the
|
||||
/// browser's put via `open_pub_channel` on the consumer leg; bytes
|
||||
/// flow browser → hub → spoke (the hub never parses them); the
|
||||
/// spoke's single response loses its reserved key and its result
|
||||
/// fields ride the consumer reply; the consumer replies exactly once
|
||||
/// with the hub-allocated consumer-leg id.
|
||||
#[tokio::test]
|
||||
async fn gate_pub_relay_put_end_to_end() {
|
||||
let h = start().await;
|
||||
|
||||
let (id, mut send, _recv, reply) = h
|
||||
.consumer
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "g" }), "alk/blobs")
|
||||
.await
|
||||
.expect("the put opens through the relayed pub openable");
|
||||
send.write_all(b"payload!").await.expect("stream the bytes");
|
||||
drop(send);
|
||||
|
||||
let merged = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect("the put's single merged response");
|
||||
assert_eq!(
|
||||
merged["digest"],
|
||||
json!("spoke-8"),
|
||||
"the spoke's result rode the relay (the producer's reserved key stripped)"
|
||||
);
|
||||
assert_eq!(
|
||||
merged["channel_id"],
|
||||
json!(id),
|
||||
"the reply carries the hub-allocated consumer-leg id"
|
||||
);
|
||||
assert_eq!(id % 2, 1, "the browser (connect side) allocated odd");
|
||||
|
||||
wait_for(
|
||||
|| data_channels(&h.hub_consumer_manager) == 0,
|
||||
"the consumer-leg put channel reclaimed at the wrapper's completion teardown",
|
||||
)
|
||||
.await;
|
||||
wait_for(
|
||||
|| data_channels(&h.hub_producer_manager) == 0,
|
||||
"the producer-leg put channel reclaimed at the plan's drop guard",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -668,7 +668,11 @@ async fn template_plain_bundle_registration_failure_is_loud_at_install() {
|
||||
/// call to any op — including the bootstrap discovery ops — does not
|
||||
/// resolve). Never a silent stub.
|
||||
#[tokio::test]
|
||||
async fn template_pub_typed_marked_spec_is_loud_at_install() {
|
||||
async fn template_pub_typed_marked_spec_registers_through_the_relay() {
|
||||
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
|
||||
// the `PubTypedOpen` assembly error — a pub-typed marked spec in
|
||||
// the stash composes through `register_pub_openable` and the op
|
||||
// re-exposes on the consumer leg.
|
||||
let pub_bundle = HandlerRegistration::new(
|
||||
relay_spec(
|
||||
"channels/x/pub",
|
||||
@@ -685,15 +689,19 @@ async fn template_pub_typed_marked_spec_is_loud_at_install() {
|
||||
);
|
||||
let imports = HubLegImports::from_bundles(vec![pub_bundle]);
|
||||
let consumer = start_hub(None, Some(imports), None, false).await;
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
consumer.call_open_op("services/list", json!({})),
|
||||
)
|
||||
.await;
|
||||
let response = consumer.call_open_op("services/list", json!({})).await;
|
||||
let out = response
|
||||
.result
|
||||
.expect("the leg dispatches; the op re-exposed");
|
||||
let names: Vec<&str> = out["operations"]
|
||||
.as_array()
|
||||
.expect("operations")
|
||||
.iter()
|
||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
||||
.collect();
|
||||
assert!(
|
||||
response.is_err(),
|
||||
"the leg's channel 0 never dispatches after the loud assembly error, got {:?}",
|
||||
response
|
||||
names.contains(&"channels/x/pub"),
|
||||
"the pub openable is re-exposed through the relay, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::protocol::wire::CallError;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::mux::{MuxHandle, OpenerLedger};
|
||||
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
|
||||
|
||||
@@ -38,6 +41,15 @@ pub enum ChannelSide {
|
||||
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
|
||||
pub const DEFAULT_MAX_CHANNELS: usize = 256;
|
||||
|
||||
/// The per-channel out-of-band control callback (ADR-052): received
|
||||
/// from the open-op establisher (`Establishment::with_control`),
|
||||
/// installed by the open-op wrapper under the channel's id, routed to
|
||||
/// by `channel/control`, removed by every teardown path. The manager
|
||||
/// stays blind to what the closure does (ADR-039).
|
||||
pub type ChannelControl = Arc<
|
||||
dyn Fn(Value) -> futures::future::BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
|
||||
>;
|
||||
|
||||
/// The per-channel state — the reassembly sender (the demux feeds chunk
|
||||
/// payloads into it; the handler reads them out via `MpscRecvStream`),
|
||||
/// the handler task handle, and the ALPN (for observability).
|
||||
@@ -62,6 +74,13 @@ pub enum ManagerError {
|
||||
UnknownChannel(u32),
|
||||
#[error("channel already exists: {0}")]
|
||||
ChannelExists(u32),
|
||||
#[error(
|
||||
"channel {channel_id} is outside the remote side's allocation range (expected {expected})"
|
||||
)]
|
||||
InvalidAdopt {
|
||||
channel_id: u32,
|
||||
expected: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// The shared state for a channels connection. `Clone` (cheap — `Arc`
|
||||
@@ -101,6 +120,10 @@ struct Inner {
|
||||
/// drains them into the new receiver. `clear_all` drops the map.
|
||||
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
|
||||
early_arrival_count: AtomicU64,
|
||||
/// Per-channel out-of-band control callbacks (ADR-052). Installed
|
||||
/// by the open-op wrapper from the establisher's
|
||||
/// `Establishment::with_control`; removed by every teardown path.
|
||||
control_handles: Mutex<HashMap<u32, ChannelControl>>,
|
||||
}
|
||||
|
||||
/// The per-channel cap on parked early-arrival chunks (`route_payload`
|
||||
@@ -151,6 +174,7 @@ impl ChannelManager {
|
||||
dropped_unknown_chunks: AtomicU64::new(0),
|
||||
early_arrivals: Mutex::new(HashMap::new()),
|
||||
early_arrival_count: AtomicU64::new(0),
|
||||
control_handles: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -317,6 +341,32 @@ impl ChannelManager {
|
||||
alpn: impl Into<String>,
|
||||
handler_task: Option<JoinHandle<()>>,
|
||||
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
|
||||
// The adopt range is the remote side's allocation range (the
|
||||
// odd/even split, ADR-047 §5): the connect side allocates odd,
|
||||
// so the accept side adopts odd; the accept side allocates
|
||||
// even, so the connect side adopts even. Adopting inside the
|
||||
// local range would collide with this side's own monotonic
|
||||
// allocator shortly after. The duplicate check comes first —
|
||||
// adopting an id this side already holds returns
|
||||
// `ChannelExists` and must never touch the live channel (the
|
||||
// 0.8.1 regression pin).
|
||||
let expected = match self.inner.side {
|
||||
ChannelSide::Connect => "even (the accept side's allocated ids; ADR-047 §5)",
|
||||
ChannelSide::Accept => "odd (the connect side's allocated ids; ADR-047 §5)",
|
||||
};
|
||||
if self.inner.channels.lock().contains_key(&channel_id) {
|
||||
return Err(ManagerError::ChannelExists(channel_id));
|
||||
}
|
||||
let parity_ok = match self.inner.side {
|
||||
ChannelSide::Connect => channel_id.is_multiple_of(2),
|
||||
ChannelSide::Accept => !channel_id.is_multiple_of(2),
|
||||
};
|
||||
if !parity_ok {
|
||||
return Err(ManagerError::InvalidAdopt {
|
||||
channel_id,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
let alpn = alpn.into();
|
||||
|
||||
let send = self
|
||||
@@ -381,6 +431,30 @@ impl ChannelManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the out-of-band control callback for a channel
|
||||
/// (ADR-052). Called by the open-op wrapper from the establisher's
|
||||
/// `Establishment::with_control`. Replaces an existing callback for
|
||||
/// the id (the establisher runs once per channel — replacement is
|
||||
/// not an expected path; the last call wins).
|
||||
pub fn set_control_handle(&self, channel_id: u32, control: ChannelControl) {
|
||||
self.inner
|
||||
.control_handles
|
||||
.lock()
|
||||
.insert(channel_id, control);
|
||||
}
|
||||
|
||||
/// The control callback for a channel, for routing (the
|
||||
/// `channel/control` handler). `None` when the channel's ALPN
|
||||
/// registered no control surface (the op answers
|
||||
/// `channel:no_control_handle`).
|
||||
pub fn control_handle(&self, channel_id: u32) -> Option<ChannelControl> {
|
||||
self.inner.control_handles.lock().get(&channel_id).cloned()
|
||||
}
|
||||
|
||||
fn remove_control_handle(&self, channel_id: u32) {
|
||||
self.inner.control_handles.lock().remove(&channel_id);
|
||||
}
|
||||
|
||||
/// Install channel 0 (pre-negotiated as `alk/call`, ADR-036).
|
||||
/// Channel 0 is special only in that it's pre-allocated (by
|
||||
/// `channels-call`); the `ChannelsAdapter` hands the resulting
|
||||
@@ -530,6 +604,7 @@ impl ChannelManager {
|
||||
// Dropping `state.demux_sender` signals EOF to the
|
||||
// handler's read half (REQ-CH-02).
|
||||
drop(state.demux_sender);
|
||||
self.remove_control_handle(channel_id);
|
||||
Ok(state.handler_task)
|
||||
}
|
||||
None => Err(ManagerError::UnknownChannel(channel_id)),
|
||||
@@ -550,6 +625,7 @@ impl ChannelManager {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
self.inner.control_handles.lock().clear();
|
||||
// The early-arrival buffers die with the connection too.
|
||||
self.inner.early_arrivals.lock().clear();
|
||||
// The opener ledger has the opener PeerIds.
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
//! 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.
|
||||
//! - [`resource`]: `ResourceEnumerators` — the enumerator registry
|
||||
//! behind `channel/resources/subscribe`'s live subscription
|
||||
//! (ADR-053).
|
||||
//! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template
|
||||
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
|
||||
//! plain bundles + relay openables + serving identity, composed into
|
||||
@@ -53,5 +56,6 @@ pub mod policy;
|
||||
pub mod pump;
|
||||
pub mod reassembly;
|
||||
pub mod relay;
|
||||
pub mod resource;
|
||||
pub mod source;
|
||||
pub mod wire;
|
||||
+790
-237
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,447 @@
|
||||
//! The marked-pub open contract gates (review 011 §3; ADR-047
|
||||
//! amendment 4). The put path end-to-end over a real channels
|
||||
//! connection: response merge order, the digest-in-result wire
|
||||
//! contract, publish-chunk rejection, establishment failure
|
||||
//! (teardown + ledger + EOF-discarded racing bytes), the reserved-key
|
||||
//! rejections, and adopt parity.
|
||||
|
||||
use crate::channels::adapter::ChannelsAdapter;
|
||||
use crate::channels::client::ChannelClient;
|
||||
use crate::channels::manager::ChannelManager;
|
||||
use crate::channels::operations::{ChannelCore, Establishment, OpenEstablisher, PubOpenHandler};
|
||||
use crate::channels::policy::NoCap;
|
||||
use crate::core::auth::{AuthContext, Identity, IdentityProvider};
|
||||
use crate::core::types::Connection;
|
||||
use crate::protocol::connection::{split_single_stream, CallConnection};
|
||||
use crate::protocol::dispatch::Dispatcher;
|
||||
use crate::protocol::wire::CallError;
|
||||
use crate::registry::discovery::install_bootstrap_discovery;
|
||||
use crate::registry::registration::OperationRegistry;
|
||||
use crate::registry::spec::{
|
||||
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
||||
RESERVED_OPEN_INPUT_KEY,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
|
||||
|
||||
const TEST_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321);
|
||||
const PUT_OP: &str = "channels/blobs/put";
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fn pub_put_spec() -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
PUT_OP,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string" } },
|
||||
"required": ["path"]
|
||||
}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer" },
|
||||
"token": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"priority": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/blobs"))
|
||||
}
|
||||
|
||||
/// The put handler: consume the adopted channel's data plane to EOF,
|
||||
/// return `{ digest, priority }` — the unknown-length put's wire
|
||||
/// contract (the result computed from the streamed bytes). The
|
||||
/// `priority` field exists to pin the merge order (the handler's
|
||||
/// result wins the collision; ADR-047 amendment 4 §4.4).
|
||||
fn digest_handler() -> PubOpenHandler {
|
||||
Arc::new(move |_input, _plan, channel_conn, _auth| {
|
||||
Box::pin(async move {
|
||||
let mut bidi = channel_conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(|_| CallError::internal("put handler: the data plane never opened"))?;
|
||||
let mut buf = Vec::new();
|
||||
bidi.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(|e| CallError::internal(format!("put handler read failed: {e}")))?;
|
||||
Ok(json!({
|
||||
"digest": format!("len-{}", buf.len()),
|
||||
"priority": "handler",
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The establisher: contributes an informative-in-result ack field
|
||||
/// (`token`) and its own `priority` (the ack — which the handler's
|
||||
/// result beats on collision).
|
||||
fn token_establisher() -> OpenEstablisher {
|
||||
Arc::new(|_input: Value, _auth| {
|
||||
Box::pin(async {
|
||||
Ok(Establishment::default().with_reply_field("token", json!("tok-1")))
|
||||
.map(|e: Establishment| e.with_reply_field("priority", json!("ack")))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the put-serving side over the duplex's server end: register
|
||||
/// the marked pub openable (with the given establisher + handler) and
|
||||
/// run the channel-0 dispatch loop. Returns the captured server
|
||||
/// manager (the ledger/teardown observability point).
|
||||
async fn serve_put_side(
|
||||
server_end: DuplexStream,
|
||||
establisher: OpenEstablisher,
|
||||
handler: PubOpenHandler,
|
||||
) -> Arc<tokio::sync::Mutex<Option<ChannelManager>>> {
|
||||
let captured = Arc::new(tokio::sync::Mutex::<Option<ChannelManager>>::new(None));
|
||||
let captured_for_hook = Arc::clone(&captured);
|
||||
let install_hook: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |manager, channel0_conn, auth| {
|
||||
let establisher = establisher.clone();
|
||||
let handler = Arc::clone(&handler);
|
||||
let manager = manager.clone();
|
||||
let captured = Arc::clone(&captured_for_hook);
|
||||
tokio::spawn(async move {
|
||||
let _ = captured.lock().await.replace(manager.clone());
|
||||
let Ok(channel0_bidi) = channel0_conn.accept_bi().await else {
|
||||
return;
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let core = ChannelCore::new(manager, Arc::new(NoCap));
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
install_bootstrap_discovery(®istry).expect("bootstrap discovery");
|
||||
core.register_pub_openable(
|
||||
pub_put_spec(),
|
||||
Some(establisher),
|
||||
handler,
|
||||
®istry,
|
||||
auth.clone(),
|
||||
None,
|
||||
)
|
||||
.expect("register the marked pub openable");
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
Dispatcher::new(registry, provider)
|
||||
.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let server_conn = Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
tokio::spawn(async move {
|
||||
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
captured
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_e2e_merge_order_and_digest() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("channel client init");
|
||||
|
||||
{
|
||||
// The known-length shape: offer + bytes → one response whose
|
||||
// merge order is `{channel_id}` → ack fields → handler result.
|
||||
let (_id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/one" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
send.write_all(b"hello").await.expect("stream the bytes");
|
||||
drop(send);
|
||||
|
||||
let merged = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect("the put resolves with the merged response");
|
||||
let _ = _id;
|
||||
assert_eq!(
|
||||
merged["token"],
|
||||
json!("tok-1"),
|
||||
"the ack field rides the result"
|
||||
);
|
||||
assert_eq!(
|
||||
merged["digest"],
|
||||
json!("len-5"),
|
||||
"the handler result rides the result"
|
||||
);
|
||||
assert_eq!(
|
||||
merged["priority"],
|
||||
json!("handler"),
|
||||
"merge order: handler result wins the collision with the ack (§4.4)"
|
||||
);
|
||||
assert!(
|
||||
merged["channel_id"].as_u64().is_some(),
|
||||
"channel_id reserved"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// The unknown-length shape: larger than the reassembly buffer
|
||||
// (crosses chunk boundaries); the handler's digest is computed
|
||||
// over the streamed bytes.
|
||||
let (_id2, send2, _recv2, reply2) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/two" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
let mut send2 = send2;
|
||||
let chunk = vec![0xAB_u8; 4096];
|
||||
for _ in 0..8 {
|
||||
send2.write_all(&chunk).await.expect("stream a chunk");
|
||||
}
|
||||
drop(send2);
|
||||
|
||||
let merged2 = reply2.resolve().await.expect("unknown-length put");
|
||||
assert_eq!(merged2["digest"], json!("len-32768"));
|
||||
assert_eq!(merged2["token"], json!("tok-1"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The happy-path put's ids: the putter allocates odd (its side is
|
||||
/// Connect); the responder adopts; the reply carries the putter's id.
|
||||
#[tokio::test]
|
||||
async fn put_e2e_client_parity_is_odd() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let (id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/odd" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
send.write_all(b"zz").await.expect("stream");
|
||||
drop(send);
|
||||
let merged = reply.resolve().await.expect("put");
|
||||
assert_eq!(
|
||||
merged["channel_id"],
|
||||
json!(id),
|
||||
"the reply carries the putter's id"
|
||||
);
|
||||
assert_eq!(id % 2, 1, "the connect side allocates odd (ADR-047 §5)");
|
||||
}
|
||||
|
||||
/// Publish-chunk rejection (§4.1): a marked op fed via the call-plane
|
||||
/// publish path rejects loudly in the initiator's direction and the
|
||||
/// handler never consumes a chunk.
|
||||
#[tokio::test]
|
||||
async fn put_rejects_publish_chunks_loudly() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let conn = Arc::clone(&client.take_call_connection().await.expect("call conn"));
|
||||
|
||||
let chunks = Box::pin(futures::stream::iter(vec![
|
||||
json!({ "chunk": 1 }),
|
||||
json!({ "chunk": 2 }),
|
||||
]));
|
||||
// The reserved key is present (this client misuses the wire
|
||||
// contract — the open resolves, the chunks are what get rejected).
|
||||
let response = conn
|
||||
.publish_with_payload(
|
||||
json!({ "operationId": PUT_OP, "input": { "path": "b/violating", RESERVED_OPEN_INPUT_KEY: 3 } }),
|
||||
chunks,
|
||||
)
|
||||
.await;
|
||||
let e = response.result.expect_err("chunks rejected loudly");
|
||||
assert_eq!(e.code, crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED);
|
||||
// The put side's own single response may also arrive (the initiator
|
||||
// already resolved with the rejection); the rejection is what the
|
||||
// pending call carries — assert it was NOT a success-shaped result
|
||||
// (done above by expect_err).
|
||||
let _ = conn;
|
||||
}
|
||||
|
||||
/// Establishment failure (ADR-049 §3 through the pub path): the reply
|
||||
/// is `channel:open_failed` with the mapped reason; the channel is
|
||||
/// torn down on both sides; racing bytes EOF-discard (the write
|
||||
/// completes locally before the failure reply — the adopt entry is
|
||||
/// gone afterward, ledger un-incremented).
|
||||
#[tokio::test]
|
||||
async fn put_establishment_failure_replies_open_failed_and_tears_down() {
|
||||
use crate::channels::operations::EstablishmentError;
|
||||
|
||||
let failing: OpenEstablisher = Arc::new(|_input, _auth| {
|
||||
Box::pin(async {
|
||||
Err(EstablishmentError::UnknownResource {
|
||||
message: "container not found".to_string(),
|
||||
})
|
||||
})
|
||||
});
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let captured = serve_put_side(server_end, failing, digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let (id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/gone" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
// Racing bytes: written before the failure reply arrives — the
|
||||
// responder never adopted, so the bytes park and drop (EOF-shaped
|
||||
// for the putter).
|
||||
let _ = send.write_all(b"racing").await;
|
||||
drop(send);
|
||||
|
||||
let error = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect_err("the put resolves with the establishment failure");
|
||||
assert_eq!(
|
||||
error.establishment_reason(),
|
||||
Some("unknown_resource"),
|
||||
"the spoke establishment reason maps through (ADR-049 §3)"
|
||||
);
|
||||
assert_eq!(
|
||||
error.call_error().map(|e| e.code.as_str()),
|
||||
Some("channel:open_failed")
|
||||
);
|
||||
|
||||
// The responder's side: no channel lingers (open_failed tears
|
||||
// down; nothing was adopted anyway — but the client-side
|
||||
// open-failure teardown removed the allocator-side entry).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let manager = captured.lock().await.clone().expect("server manager");
|
||||
assert!(
|
||||
!manager.has_channel(id),
|
||||
"a failed establishment leaves no channel (ADR-047 amendment 4 §4.4)"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.opener_ledger().take(id),
|
||||
None,
|
||||
"ledger un-incremented"
|
||||
);
|
||||
}
|
||||
|
||||
/// A marked pub op whose input lacks the reserved `channel_id` key is
|
||||
/// rejected (`INVALID_INPUT`) — the initiator-allocated id is
|
||||
/// mandatory (ADR-047 amendment 4 §4.3).
|
||||
#[tokio::test]
|
||||
async fn put_missing_reserved_key_is_rejected() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let response = client
|
||||
.call_open_op(PUT_OP, json!({ "path": "b/no-id" }))
|
||||
.await;
|
||||
let e = response.result.expect_err("the reserved key is mandatory");
|
||||
assert_eq!(e.code, "INVALID_INPUT");
|
||||
assert!(
|
||||
e.message.contains(RESERVED_OPEN_INPUT_KEY),
|
||||
"the error names the key: {}",
|
||||
e.message
|
||||
);
|
||||
}
|
||||
|
||||
/// Adopt parity (§4.4 + ADR-047 §5): a reserved id inside the
|
||||
/// responder's OWN allocation range (even — an id the accept side
|
||||
/// would allocate itself, violating the split) fails the open with
|
||||
/// `adopt_failed`; racing bytes are discarded.
|
||||
#[tokio::test]
|
||||
async fn put_out_of_range_reserved_id_replies_adopt_failed() {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), digest_handler()).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let response = client
|
||||
.call_open_op(
|
||||
PUT_OP,
|
||||
json!({ "path": "b/bad", RESERVED_OPEN_INPUT_KEY: 2 }),
|
||||
)
|
||||
.await;
|
||||
let e = response
|
||||
.result
|
||||
.expect_err("the responder refuses its own range");
|
||||
assert_eq!(e.code, "channel:open_failed");
|
||||
assert_eq!(
|
||||
e.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|r| r.as_str()),
|
||||
Some("adopt_failed"),
|
||||
"adoption failure → adopt_failed (ADR-047 amendment 4 §4.4): {e:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The responder-side put handler that returns an error: the single
|
||||
/// response IS that error (the wrapper passthrough; §4.4).
|
||||
#[tokio::test]
|
||||
async fn put_handler_error_becomes_the_response() {
|
||||
let failing_handler: PubOpenHandler = Arc::new(|_input, _plan, channel_conn, _auth| {
|
||||
Box::pin(async move {
|
||||
let mut bidi = channel_conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(|_| CallError::internal("data plane"))?;
|
||||
let mut buf = [0u8; 3];
|
||||
let _ = bidi.read_exact(&mut buf).await;
|
||||
Err(CallError::new(
|
||||
"blobs:read_only",
|
||||
"the container is read-only",
|
||||
false,
|
||||
))
|
||||
})
|
||||
});
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let client_conn = Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
let _captured = serve_put_side(server_end, token_establisher(), failing_handler).await;
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("client");
|
||||
let (_id, mut send, _recv, reply) = client
|
||||
.open_pub_channel(PUT_OP, json!({ "path": "b/ro" }), "alk/blobs")
|
||||
.await
|
||||
.expect("open_pub_channel");
|
||||
send.write_all(b"abc").await.expect("stream");
|
||||
drop(send);
|
||||
|
||||
let error = reply
|
||||
.resolve()
|
||||
.await
|
||||
.expect_err("the handler error IS the response");
|
||||
assert_eq!(
|
||||
error.call_error().map(|e| e.code.clone()),
|
||||
Some("blobs:read_only".to_string())
|
||||
);
|
||||
}
|
||||
+214
-19
@@ -35,11 +35,13 @@ use crate::registry::spec::{OperationSpec, OperationType};
|
||||
|
||||
use super::manager::ChannelManager;
|
||||
use super::operations::{
|
||||
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
||||
ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||
PubOpenHandler, CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
||||
};
|
||||
use super::pump::pump_bidi;
|
||||
|
||||
use super::reassembly::{MpscRecvStream, MpscSendStream};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// The producer-leg surface the relay holds (ADR-051 §2): the
|
||||
/// `CallConnection` for the translate hop's open call plus the
|
||||
@@ -81,8 +83,17 @@ struct RelayPlan {
|
||||
producer_manager: ChannelManager,
|
||||
spoke_send: Option<MpscSendStream>,
|
||||
spoke_recv: Option<MpscRecvStream>,
|
||||
/// The put path (ADR-047 amendment 4 §4.7): the producer leg's
|
||||
/// open-op response, awaited by the relay pub handler after the
|
||||
/// consumer EOF (put-acks-in-result — the spoke's response cannot
|
||||
/// exist before its byte stream completes, so the establisher
|
||||
/// never awaits it).
|
||||
pub_reply: Option<tokio::sync::oneshot::Receiver<CallConnectionResponse>>,
|
||||
}
|
||||
|
||||
/// The producer leg's open-op response, carried on the put path's plan.
|
||||
type CallConnectionResponse = crate::protocol::wire::ResponseEnvelope;
|
||||
|
||||
impl Drop for RelayPlan {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.producer_manager.teardown_channel(self.spoke_id);
|
||||
@@ -120,9 +131,10 @@ impl ChannelRelay {
|
||||
/// 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
|
||||
/// `Pub`-typed marked spec composes through the put-forwarding
|
||||
/// translation (see [`Self::register_relay_pub_openable`]); an
|
||||
/// unmarked spec is a
|
||||
/// [`RelayError::UnmarkedSpec`] 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
|
||||
@@ -145,14 +157,12 @@ impl ChannelRelay {
|
||||
});
|
||||
}
|
||||
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
|
||||
),
|
||||
});
|
||||
return self.register_relay_pub_openable(
|
||||
consumer_core,
|
||||
consumer_registry,
|
||||
spec,
|
||||
consumer_auth,
|
||||
);
|
||||
}
|
||||
|
||||
let producer_call = Arc::clone(&self.producer_leg.call);
|
||||
@@ -227,6 +237,76 @@ impl ChannelRelay {
|
||||
)
|
||||
.map_err(|e| RelayError::Registration { message: e })
|
||||
}
|
||||
|
||||
/// Register a from_call-imported **marked `Pub`** spec as a relay
|
||||
/// openable — the put-forwarding translation (ADR-047 amendment 4
|
||||
/// §4.7). The consumer leg runs the full marked-pub wrapper (adopt
|
||||
/// → establish → pub handler awaited inline); this side's compose:
|
||||
///
|
||||
/// - the establisher allocates the hub's connect-side id on the
|
||||
/// producer leg (`open_channel` — an odd id, disjoint from the
|
||||
/// spoke's even allocations), sends the open op **in the
|
||||
/// background** (the spoke's put response cannot exist before
|
||||
/// its byte stream completes; an establisher that awaited it
|
||||
/// deadlocks the leg), and plans the spoke-leg streams + the
|
||||
/// response's oneshot; the plan's Drop guard reclaims the
|
||||
/// producer-leg entry on every handler path, as ADR-051 §6 pins.
|
||||
/// - the handler pumps the consumer-leg bytes → the spoke leg
|
||||
/// (one direction, pub), EOFs the spoke side on consumer EOF,
|
||||
/// then awaits the spoke's single response, strips the
|
||||
/// producer's reserved reply key (the consumer reply carries the
|
||||
/// hub-allocated consumer-leg id; a spoke `channel_id` merged
|
||||
/// through would shadow it — the same reserved-key class the
|
||||
/// wrapper refuses), and returns the remainder — the consumer-leg
|
||||
/// wrapper merges it with the pinned order (ack fields are none
|
||||
/// here; the put acks in the spoke's result, §4.5).
|
||||
///
|
||||
/// A spoke open-time establishment failure surfaces verbatim in
|
||||
/// the final response (put-acks-in-result; the spoke's mapped
|
||||
/// `channel:open_failed` rides the response, not the establisher).
|
||||
fn register_relay_pub_openable(
|
||||
&self,
|
||||
consumer_core: &ChannelCore,
|
||||
consumer_registry: &OperationRegistry,
|
||||
spec: OperationSpec,
|
||||
consumer_auth: AuthContext,
|
||||
) -> Result<(), RelayError> {
|
||||
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_pub_on_producer_leg(
|
||||
&producer_call,
|
||||
&producer_manager,
|
||||
&op_name,
|
||||
input,
|
||||
&per_call_auth,
|
||||
)
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
let pub_open_handler: PubOpenHandler =
|
||||
Arc::new(move |_input, plan, channel_conn, _auth| {
|
||||
Box::pin(async move { pub_relay_forward(channel_conn, plan).await })
|
||||
});
|
||||
|
||||
consumer_core
|
||||
.register_pub_openable(
|
||||
spec,
|
||||
Some(establisher),
|
||||
pub_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
|
||||
@@ -288,6 +368,7 @@ async fn open_on_producer_leg(
|
||||
producer_manager: producer_manager.clone(),
|
||||
spoke_send: Some(spoke_send),
|
||||
spoke_recv: Some(spoke_recv),
|
||||
pub_reply: None,
|
||||
}) as super::operations::ChannelPlan);
|
||||
Ok(if reply_fields.is_empty() {
|
||||
establishment
|
||||
@@ -334,6 +415,123 @@ fn map_spoke_error(spoke_error: &CallError) -> EstablishmentError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The put path's translate hop (ADR-047 amendment 4 §4.7): allocate
|
||||
/// the hub's connect-side channel id on the producer leg, send the
|
||||
/// spoke's open op **in the background** with the reserved input key
|
||||
/// set, and plan the spoke-leg streams + the response oneshot. The
|
||||
/// establisher returns promptly; the spoke's response arrives when the
|
||||
/// spoke's put completes (or fails) — put-acks-in-result.
|
||||
async fn open_pub_on_producer_leg(
|
||||
producer_call: &Arc<CallConnection>,
|
||||
producer_manager: &ChannelManager,
|
||||
op_name: &str,
|
||||
input: Value,
|
||||
per_call_auth: &AuthContext,
|
||||
) -> Result<Establishment, EstablishmentError> {
|
||||
let (spoke_id, spoke_send, spoke_recv) = producer_manager
|
||||
.open_channel(
|
||||
"alk/relay",
|
||||
// The opener ledger is inert on the hub's connect side
|
||||
// (the connect side does not enforce the per-identity cap,
|
||||
// ADR-041) — the record is observability only.
|
||||
"hub-relay",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| EstablishmentError::ResourceShortage {
|
||||
message: format!("producer leg: channel allocation failed: {e}"),
|
||||
})?;
|
||||
|
||||
let mut hop_input = input.clone();
|
||||
if let Some(obj) = hop_input.as_object_mut() {
|
||||
obj.insert(
|
||||
crate::registry::spec::RESERVED_OPEN_INPUT_KEY.to_string(),
|
||||
serde_json::json!(spoke_id),
|
||||
);
|
||||
}
|
||||
let payload = build_relay_payload(op_name, hop_input, per_call_auth);
|
||||
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
let producer_call_task = Arc::clone(producer_call);
|
||||
tokio::spawn(async move {
|
||||
let _ = reply_tx.send(producer_call_task.call_with_payload(payload).await);
|
||||
});
|
||||
|
||||
Ok(Establishment::new(Arc::new(RelayPlan {
|
||||
spoke_id,
|
||||
producer_manager: producer_manager.clone(),
|
||||
spoke_send: Some(spoke_send),
|
||||
spoke_recv: Some(spoke_recv),
|
||||
pub_reply: Some(reply_rx),
|
||||
}) as ChannelPlan))
|
||||
}
|
||||
|
||||
/// The relay pub handler (ADR-047 amendment 4 §4.7): pump the
|
||||
/// consumer-leg bytes → the spoke leg (one direction, pub — the
|
||||
/// shutdown-on-completion contract, ADR-050's), then await the spoke's
|
||||
/// single response and strip its reserved reply key. The plan's Drop
|
||||
/// guard reclaims the producer-leg entry on every path.
|
||||
async fn pub_relay_forward(
|
||||
channel_conn: crate::core::types::Connection,
|
||||
plan: Option<ChannelPlan>,
|
||||
) -> Result<Value, CallError> {
|
||||
let Some(plan) = plan else {
|
||||
return Err(CallError::internal(
|
||||
"relay: pub establisher produced no plan",
|
||||
));
|
||||
};
|
||||
let Ok(relay_plan_arc) = plan.downcast::<RelayPlan>() else {
|
||||
return Err(CallError::internal(
|
||||
"relay: establisher plan of unexpected type",
|
||||
));
|
||||
};
|
||||
let Ok(mut relay_plan) = Arc::try_unwrap(relay_plan_arc) else {
|
||||
return Err(CallError::internal(
|
||||
"relay: establisher plan held elsewhere; skipping pump",
|
||||
));
|
||||
};
|
||||
let Some(mut spoke_send) = relay_plan.spoke_send.take() else {
|
||||
return Err(CallError::internal(
|
||||
"relay: pub plan missing the spoke send half",
|
||||
));
|
||||
};
|
||||
relay_plan.spoke_recv.take();
|
||||
|
||||
let Ok(bidi) = channel_conn.accept_bi().await else {
|
||||
return Err(CallError::internal(
|
||||
"relay: consumer leg data plane never opened",
|
||||
));
|
||||
};
|
||||
let (mut read_half, _write_half) = tokio::io::split(bidi);
|
||||
// The one-direction pump: consumer bytes → spoke; shutdown on
|
||||
// consumer EOF (the pump contract, ADR-050).
|
||||
let _copied = tokio::io::copy(&mut read_half, &mut spoke_send)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let _ = spoke_send.shutdown().await;
|
||||
drop(spoke_send);
|
||||
|
||||
match relay_plan.pub_reply.take() {
|
||||
Some(rx) => match rx.await {
|
||||
Ok(response) => match response.result {
|
||||
Ok(mut out) => {
|
||||
if let Some(obj) = out.as_object_mut() {
|
||||
obj.remove(RESERVED_REPLY_KEY);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Err(_) => Err(CallError::internal(
|
||||
"relay: producer-leg open resolved without a response",
|
||||
)),
|
||||
},
|
||||
None => Err(CallError::internal(
|
||||
"relay: pub plan missing the response receiver",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -357,18 +555,15 @@ fn build_relay_payload(op_name: &str, input: Value, per_call_auth: &AuthContext)
|
||||
|
||||
/// The assembly-time errors `ChannelRelay::register_relay_openable`
|
||||
/// produces (ADR-051 §6 — the rejection postures are loud, never
|
||||
/// silent stubs).
|
||||
/// silent stubs; the `PubTypedOpen` variant was removed by ADR-047
|
||||
/// amendment 4 §4.7 — pub-typed marked specs translate now, never
|
||||
/// reject-and-strand).
|
||||
#[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}")]
|
||||
|
||||
+25
-11
@@ -89,15 +89,21 @@ async fn unmarked_spec_is_rejected_at_registration() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pub_typed_marked_spec_is_rejected_at_registration() {
|
||||
async fn pub_typed_marked_spec_registers_through_the_relay() {
|
||||
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
|
||||
// the `PubTypedOpen` rejection — a pub-typed marked spec composes
|
||||
// through `register_pub_openable` (the full marked-pub wrapper
|
||||
// machinery) and lands on the consumer-leg registry, discoverable.
|
||||
let producer_manager = manager_for_test();
|
||||
let relay = ChannelRelay::new(ProducerLeg {
|
||||
call: overlay_connection(),
|
||||
manager: manager_for_test(),
|
||||
manager: producer_manager,
|
||||
});
|
||||
let err = relay
|
||||
let registry = OperationRegistry::new();
|
||||
relay
|
||||
.register_relay_openable(
|
||||
&core_for_test(),
|
||||
&OperationRegistry::new(),
|
||||
®istry,
|
||||
relay_spec(
|
||||
"channels/x/pub",
|
||||
OperationType::Pub,
|
||||
@@ -105,12 +111,10 @@ async fn pub_typed_marked_spec_is_rejected_at_registration() {
|
||||
),
|
||||
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"));
|
||||
.expect("the pub openable registers through the relay");
|
||||
assert!(registry.registration("channels/x/pub").is_some());
|
||||
let registration = registry.registration("channels/x/pub").expect("registered");
|
||||
assert!(registration.spec.channel_open.is_some());
|
||||
}
|
||||
|
||||
// --- ADR-051 §3: the reason mapping --------------------------------------
|
||||
@@ -228,6 +232,7 @@ async fn relay_plan_drop_reclaims_the_adopted_producer_leg_entry() {
|
||||
producer_manager: producer_manager.clone(),
|
||||
spoke_send: Some(spoke_send),
|
||||
spoke_recv: Some(spoke_recv),
|
||||
pub_reply: None,
|
||||
};
|
||||
drop(plan);
|
||||
|
||||
@@ -256,7 +261,16 @@ fn manager_for_test() -> ChannelManager {
|
||||
tokio::spawn(async move {
|
||||
let _ = runner.run().await;
|
||||
});
|
||||
ChannelManager::with_defaults(handle, None)
|
||||
// The producer leg's manager belongs to the hub, which is the
|
||||
// connect side of the hub↔spoke channels connection (ADR-051 §4 —
|
||||
// the spoke's ids are even, the hub's odd).
|
||||
ChannelManager::new(
|
||||
handle,
|
||||
crate::channels::manager::DEFAULT_MAX_CHANNELS,
|
||||
crate::channels::reassembly::DEFAULT_BUFFER_CAP,
|
||||
None,
|
||||
crate::channels::manager::ChannelSide::Connect,
|
||||
)
|
||||
}
|
||||
|
||||
fn core_for_test() -> ChannelCore {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! `ResourceEnumerators` — the registry behind
|
||||
//! `channel/resources/subscribe`'s live subscription (ADR-053).
|
||||
//!
|
||||
//! Each openable-ALPN crate registers one enumerator (its resource
|
||||
//! truth: which containers are running, which sessions are active);
|
||||
//! the subscribe handler aggregates them. The channels layer never
|
||||
//! interprets the payloads — they ride opaquely, merged with the
|
||||
//! producing ALPN's name (ADR-053 §1).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::Stream;
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A registered enumerator's item: `(alpn, payload)` — the payload is
|
||||
/// the ALPN's full current resource state (snapshot-then-changes,
|
||||
/// ADR-053 §1). The channels layer does not interpret it.
|
||||
pub type ResourceItem = (String, Value);
|
||||
|
||||
/// A registered enumerator's merged stream.
|
||||
pub type ResourceStream = Pin<Box<dyn Stream<Item = ResourceItem> + Send>>;
|
||||
|
||||
/// The resource enumerator contract (ADR-053 §1). The producing crate
|
||||
/// owns its change detection; the first item on the returned stream is
|
||||
/// the current snapshot, and each subsequent item is a full snapshot
|
||||
/// of the changed set.
|
||||
pub trait ResourceEnumerator: Send + Sync + 'static {
|
||||
fn subscribe(&self) -> ResourceStream;
|
||||
}
|
||||
|
||||
/// The registry of ALPN resource enumerators (ADR-053 §2). Shared per
|
||||
/// accepting peer (like `PerIdentityChannelPolicy` — ADR-041): one
|
||||
/// `Arc<ResourceEnumerators>` across every channels connection that
|
||||
/// peer accepts. Registered at assembly/install time; the subscribe
|
||||
/// handler scans it once at subscription start (the scan-once pin —
|
||||
/// late registrations are invisible to in-flight subscriptions;
|
||||
/// re-subscribe to see them).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ResourceEnumerators {
|
||||
inner: Arc<RwLock<HashMap<String, Arc<dyn ResourceEnumerator>>>>,
|
||||
}
|
||||
|
||||
impl ResourceEnumerators {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registered at assembly/install time by the composing crate.
|
||||
pub fn register(&self, alpn: &str, enumerator: Arc<dyn ResourceEnumerator>) {
|
||||
self.inner.write().insert(alpn.to_string(), enumerator);
|
||||
}
|
||||
|
||||
/// The registered ALPNs (observability).
|
||||
pub fn alpns(&self) -> Vec<String> {
|
||||
self.inner.read().keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Snapshot the registered set (the subscribe handler's scan-once
|
||||
/// read; ADR-053 §2).
|
||||
pub(crate) fn snapshot(&self) -> Vec<(String, Arc<dyn ResourceEnumerator>)> {
|
||||
self.inner
|
||||
.read()
|
||||
.iter()
|
||||
.map(|(alpn, enumerator)| (alpn.clone(), Arc::clone(enumerator)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user