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
|
/// The establishment-failure reason code — `details.reason` of a
|
||||||
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
|
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
|
||||||
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
|
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
|
||||||
/// any other failure shape.
|
/// `None` for any other failure shape.
|
||||||
pub fn establishment_reason(&self) -> Option<&str> {
|
pub fn establishment_reason(&self) -> Option<&str> {
|
||||||
match self {
|
match self {
|
||||||
ChannelOpenError::CallFailed { error }
|
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
|
/// The client-side handle for a channels connection. Constructed via
|
||||||
/// [`ChannelClient::from_connection`] from an established
|
/// [`ChannelClient::from_connection`] from an established
|
||||||
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
|
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
|
||||||
@@ -382,6 +406,110 @@ impl ChannelClient {
|
|||||||
.map(|(send, recv)| (channel_id, out, send, recv))
|
.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
|
/// Take the `CallConnection` — used by the consumer to register
|
||||||
/// imported ops (`from_call`) on the connection's overlay. After
|
/// imported ops (`from_call`) on the connection's overlay. After
|
||||||
/// this, `call_open_op` returns an error (the connection is owned
|
/// 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 TTY_ALPN: &str = "alk/tty";
|
||||||
const ALPN: &str = "alk/tunnel";
|
const ALPN: &str = "alk/tunnel";
|
||||||
const PLAIN_OP: &str = "fs/read";
|
const PLAIN_OP: &str = "fs/read";
|
||||||
|
const PUT_OP: &str = "channels/put/bulk";
|
||||||
|
|
||||||
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||||
Identity {
|
Identity {
|
||||||
@@ -256,6 +257,49 @@ fn make_spoke_install(
|
|||||||
)
|
)
|
||||||
.expect("spoke registers the tty open op");
|
.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
|
registry
|
||||||
.register(HandlerRegistration::new(
|
.register(HandlerRegistration::new(
|
||||||
plain_spec(PLAIN_OP),
|
plain_spec(PLAIN_OP),
|
||||||
@@ -740,3 +784,51 @@ async fn gate2_tty_standard_shape_relays_with_no_regression() {
|
|||||||
"the tty relay's ledger decremented"
|
"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
|
/// call to any op — including the bootstrap discovery ops — does not
|
||||||
/// resolve). Never a silent stub.
|
/// resolve). Never a silent stub.
|
||||||
#[tokio::test]
|
#[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(
|
let pub_bundle = HandlerRegistration::new(
|
||||||
relay_spec(
|
relay_spec(
|
||||||
"channels/x/pub",
|
"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 imports = HubLegImports::from_bundles(vec![pub_bundle]);
|
||||||
let consumer = start_hub(None, Some(imports), None, false).await;
|
let consumer = start_hub(None, Some(imports), None, false).await;
|
||||||
let response = tokio::time::timeout(
|
let response = consumer.call_open_op("services/list", json!({})).await;
|
||||||
std::time::Duration::from_secs(3),
|
let out = response
|
||||||
consumer.call_open_op("services/list", json!({})),
|
.result
|
||||||
)
|
.expect("the leg dispatches; the op re-exposed");
|
||||||
.await;
|
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!(
|
assert!(
|
||||||
response.is_err(),
|
names.contains(&"channels/x/pub"),
|
||||||
"the leg's channel 0 never dispatches after the loud assembly error, got {:?}",
|
"the pub openable is re-exposed through the relay, got: {names:?}"
|
||||||
response
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ use tokio::sync::mpsc;
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::protocol::wire::CallError;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::mux::{MuxHandle, OpenerLedger};
|
use super::mux::{MuxHandle, OpenerLedger};
|
||||||
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
|
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
|
||||||
|
|
||||||
@@ -38,6 +41,15 @@ pub enum ChannelSide {
|
|||||||
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
|
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
|
||||||
pub const DEFAULT_MAX_CHANNELS: usize = 256;
|
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
|
/// The per-channel state — the reassembly sender (the demux feeds chunk
|
||||||
/// payloads into it; the handler reads them out via `MpscRecvStream`),
|
/// payloads into it; the handler reads them out via `MpscRecvStream`),
|
||||||
/// the handler task handle, and the ALPN (for observability).
|
/// the handler task handle, and the ALPN (for observability).
|
||||||
@@ -62,6 +74,13 @@ pub enum ManagerError {
|
|||||||
UnknownChannel(u32),
|
UnknownChannel(u32),
|
||||||
#[error("channel already exists: {0}")]
|
#[error("channel already exists: {0}")]
|
||||||
ChannelExists(u32),
|
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`
|
/// 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.
|
/// drains them into the new receiver. `clear_all` drops the map.
|
||||||
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
|
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
|
||||||
early_arrival_count: AtomicU64,
|
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`
|
/// The per-channel cap on parked early-arrival chunks (`route_payload`
|
||||||
@@ -151,6 +174,7 @@ impl ChannelManager {
|
|||||||
dropped_unknown_chunks: AtomicU64::new(0),
|
dropped_unknown_chunks: AtomicU64::new(0),
|
||||||
early_arrivals: Mutex::new(HashMap::new()),
|
early_arrivals: Mutex::new(HashMap::new()),
|
||||||
early_arrival_count: AtomicU64::new(0),
|
early_arrival_count: AtomicU64::new(0),
|
||||||
|
control_handles: Mutex::new(HashMap::new()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,6 +341,32 @@ impl ChannelManager {
|
|||||||
alpn: impl Into<String>,
|
alpn: impl Into<String>,
|
||||||
handler_task: Option<JoinHandle<()>>,
|
handler_task: Option<JoinHandle<()>>,
|
||||||
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
|
) -> 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 alpn = alpn.into();
|
||||||
|
|
||||||
let send = self
|
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).
|
/// Install channel 0 (pre-negotiated as `alk/call`, ADR-036).
|
||||||
/// Channel 0 is special only in that it's pre-allocated (by
|
/// Channel 0 is special only in that it's pre-allocated (by
|
||||||
/// `channels-call`); the `ChannelsAdapter` hands the resulting
|
/// `channels-call`); the `ChannelsAdapter` hands the resulting
|
||||||
@@ -530,6 +604,7 @@ impl ChannelManager {
|
|||||||
// Dropping `state.demux_sender` signals EOF to the
|
// Dropping `state.demux_sender` signals EOF to the
|
||||||
// handler's read half (REQ-CH-02).
|
// handler's read half (REQ-CH-02).
|
||||||
drop(state.demux_sender);
|
drop(state.demux_sender);
|
||||||
|
self.remove_control_handle(channel_id);
|
||||||
Ok(state.handler_task)
|
Ok(state.handler_task)
|
||||||
}
|
}
|
||||||
None => Err(ManagerError::UnknownChannel(channel_id)),
|
None => Err(ManagerError::UnknownChannel(channel_id)),
|
||||||
@@ -550,6 +625,7 @@ impl ChannelManager {
|
|||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.inner.control_handles.lock().clear();
|
||||||
// The early-arrival buffers die with the connection too.
|
// The early-arrival buffers die with the connection too.
|
||||||
self.inner.early_arrivals.lock().clear();
|
self.inner.early_arrivals.lock().clear();
|
||||||
// The opener ledger has the opener PeerIds.
|
// The opener ledger has the opener PeerIds.
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
|
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
|
||||||
//! hop for from_call-imported marked specs re-exposed on a hub's
|
//! hop for from_call-imported marked specs re-exposed on a hub's
|
||||||
//! consumer-leg channel-0 registry.
|
//! 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
|
//! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template
|
||||||
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
|
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
|
||||||
//! plain bundles + relay openables + serving identity, composed into
|
//! plain bundles + relay openables + serving identity, composed into
|
||||||
@@ -53,5 +56,6 @@ pub mod policy;
|
|||||||
pub mod pump;
|
pub mod pump;
|
||||||
pub mod reassembly;
|
pub mod reassembly;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
|
pub mod resource;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod wire;
|
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::manager::ChannelManager;
|
||||||
use super::operations::{
|
use super::operations::{
|
||||||
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||||
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
PubOpenHandler, CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
|
||||||
};
|
};
|
||||||
use super::pump::pump_bidi;
|
use super::pump::pump_bidi;
|
||||||
|
|
||||||
use super::reassembly::{MpscRecvStream, MpscSendStream};
|
use super::reassembly::{MpscRecvStream, MpscSendStream};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
/// The producer-leg surface the relay holds (ADR-051 §2): the
|
/// The producer-leg surface the relay holds (ADR-051 §2): the
|
||||||
/// `CallConnection` for the translate hop's open call plus the
|
/// `CallConnection` for the translate hop's open call plus the
|
||||||
@@ -81,8 +83,17 @@ struct RelayPlan {
|
|||||||
producer_manager: ChannelManager,
|
producer_manager: ChannelManager,
|
||||||
spoke_send: Option<MpscSendStream>,
|
spoke_send: Option<MpscSendStream>,
|
||||||
spoke_recv: Option<MpscRecvStream>,
|
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 {
|
impl Drop for RelayPlan {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.producer_manager.teardown_channel(self.spoke_id);
|
let _ = self.producer_manager.teardown_channel(self.spoke_id);
|
||||||
@@ -120,9 +131,10 @@ impl ChannelRelay {
|
|||||||
/// produced open op.
|
/// produced open op.
|
||||||
/// - `spec` must carry the `channel_open` marker (reconstructed
|
/// - `spec` must carry the `channel_open` marker (reconstructed
|
||||||
/// WITH the marker by discovery, ADR-047 amendment 3). A
|
/// WITH the marker by discovery, ADR-047 amendment 3). A
|
||||||
/// `Pub`-typed marked spec is a loud assembly error
|
/// `Pub`-typed marked spec composes through the put-forwarding
|
||||||
/// ([`RelayError::PubTypedOpen`] — the C-08 blocker, ADR-051
|
/// translation (see [`Self::register_relay_pub_openable`]); an
|
||||||
/// §6); an unmarked spec is likewise an error — branch on the
|
/// unmarked spec is a
|
||||||
|
/// [`RelayError::UnmarkedSpec`] error — branch on the
|
||||||
/// marker before calling.
|
/// marker before calling.
|
||||||
/// - The translate hop calls the producer leg with the spec's own
|
/// - The translate hop calls the producer leg with the spec's own
|
||||||
/// op name; a hub that re-names the consumer-visible op composes
|
/// op name; a hub that re-names the consumer-visible op composes
|
||||||
@@ -145,14 +157,12 @@ impl ChannelRelay {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if spec.op_type == OperationType::Pub {
|
if spec.op_type == OperationType::Pub {
|
||||||
return Err(RelayError::PubTypedOpen {
|
return self.register_relay_pub_openable(
|
||||||
message: format!(
|
consumer_core,
|
||||||
"spec `{}` is a Pub-typed channel-open op — the Pub open path is \
|
consumer_registry,
|
||||||
not implemented (C-08 blocker, `channel:pub_open_not_implemented` \
|
spec,
|
||||||
class; ADR-051 §6)",
|
consumer_auth,
|
||||||
spec.name
|
);
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let producer_call = Arc::clone(&self.producer_leg.call);
|
let producer_call = Arc::clone(&self.producer_leg.call);
|
||||||
@@ -227,6 +237,76 @@ impl ChannelRelay {
|
|||||||
)
|
)
|
||||||
.map_err(|e| RelayError::Registration { message: e })
|
.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
|
/// 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(),
|
producer_manager: producer_manager.clone(),
|
||||||
spoke_send: Some(spoke_send),
|
spoke_send: Some(spoke_send),
|
||||||
spoke_recv: Some(spoke_recv),
|
spoke_recv: Some(spoke_recv),
|
||||||
|
pub_reply: None,
|
||||||
}) as super::operations::ChannelPlan);
|
}) as super::operations::ChannelPlan);
|
||||||
Ok(if reply_fields.is_empty() {
|
Ok(if reply_fields.is_empty() {
|
||||||
establishment
|
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
|
/// Build the `call.requested` payload for the translate hop: the hub
|
||||||
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
|
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
|
||||||
/// — the same shape `build_forwarded_payload` produces for the plain
|
/// — 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`
|
/// The assembly-time errors `ChannelRelay::register_relay_openable`
|
||||||
/// produces (ADR-051 §6 — the rejection postures are loud, never
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
pub enum RelayError {
|
pub enum RelayError {
|
||||||
/// The spec carries no `channel_open` marker — the relay only
|
/// The spec carries no `channel_open` marker — the relay only
|
||||||
/// registers marked specs.
|
/// registers marked specs.
|
||||||
#[error("unmarked spec: {message}")]
|
#[error("unmarked spec: {message}")]
|
||||||
UnmarkedSpec { message: String },
|
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,
|
/// The consumer-leg registration itself failed (schema compile,
|
||||||
/// handler-kind mismatch).
|
/// handler-kind mismatch).
|
||||||
#[error("registration failed: {message}")]
|
#[error("registration failed: {message}")]
|
||||||
|
|||||||
+25
-11
@@ -89,15 +89,21 @@ async fn unmarked_spec_is_rejected_at_registration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 {
|
let relay = ChannelRelay::new(ProducerLeg {
|
||||||
call: overlay_connection(),
|
call: overlay_connection(),
|
||||||
manager: manager_for_test(),
|
manager: producer_manager,
|
||||||
});
|
});
|
||||||
let err = relay
|
let registry = OperationRegistry::new();
|
||||||
|
relay
|
||||||
.register_relay_openable(
|
.register_relay_openable(
|
||||||
&core_for_test(),
|
&core_for_test(),
|
||||||
&OperationRegistry::new(),
|
®istry,
|
||||||
relay_spec(
|
relay_spec(
|
||||||
"channels/x/pub",
|
"channels/x/pub",
|
||||||
OperationType::Pub,
|
OperationType::Pub,
|
||||||
@@ -105,12 +111,10 @@ async fn pub_typed_marked_spec_is_rejected_at_registration() {
|
|||||||
),
|
),
|
||||||
AuthContext::anonymous(b"alk/call"),
|
AuthContext::anonymous(b"alk/call"),
|
||||||
)
|
)
|
||||||
.expect_err("Pub-typed marked spec must be rejected");
|
.expect("the pub openable registers through the relay");
|
||||||
assert!(
|
assert!(registry.registration("channels/x/pub").is_some());
|
||||||
matches!(err, RelayError::PubTypedOpen { .. }),
|
let registration = registry.registration("channels/x/pub").expect("registered");
|
||||||
"got {err:?}"
|
assert!(registration.spec.channel_open.is_some());
|
||||||
);
|
|
||||||
assert!(err.to_string().contains("pub_open_not_implemented"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ADR-051 §3: the reason mapping --------------------------------------
|
// --- 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(),
|
producer_manager: producer_manager.clone(),
|
||||||
spoke_send: Some(spoke_send),
|
spoke_send: Some(spoke_send),
|
||||||
spoke_recv: Some(spoke_recv),
|
spoke_recv: Some(spoke_recv),
|
||||||
|
pub_reply: None,
|
||||||
};
|
};
|
||||||
drop(plan);
|
drop(plan);
|
||||||
|
|
||||||
@@ -256,7 +261,16 @@ fn manager_for_test() -> ChannelManager {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = runner.run().await;
|
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 {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -13,7 +13,10 @@ pub use from_call::{from_call, FromCallConfig};
|
|||||||
// crate-internal surface for the `op/register` bootstrap path (review
|
// crate-internal surface for the `op/register` bootstrap path (review
|
||||||
// 004 F-05): the forwarding-handler constructor and the spec wire
|
// 004 F-05): the forwarding-handler constructor and the spec wire
|
||||||
// parser are shared with `registry::op_register`.
|
// parser are shared with `registry::op_register`.
|
||||||
pub(crate) use from_call::{make_forwarding_handler, rebuild_spec_for};
|
pub(crate) use from_call::{
|
||||||
|
make_forwarding_handler, make_sink_forwarding_handler, make_streaming_forwarding_handler,
|
||||||
|
rebuild_spec_for,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::registry::registration::HandlerRegistration;
|
use crate::registry::registration::HandlerRegistration;
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
);
|
);
|
||||||
conn.register_imported(imported);
|
let _error = conn.register_imported(imported);
|
||||||
let peer_identity = Identity {
|
let peer_identity = Identity {
|
||||||
id: "worker-a".to_string(),
|
id: "worker-a".to_string(),
|
||||||
scopes: vec![],
|
scopes: vec![],
|
||||||
@@ -607,7 +607,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
);
|
);
|
||||||
conn.register_imported(imported);
|
let _error = conn.register_imported(imported);
|
||||||
|
|
||||||
let context =
|
let context =
|
||||||
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
|
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
|
||||||
|
|||||||
+45
-13
@@ -159,9 +159,27 @@ impl CallConnection {
|
|||||||
&self.pending
|
&self.pending
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_imported(&self, registration: HandlerRegistration) {
|
/// Land a peer-announced/discovered bundle on the connection
|
||||||
|
/// overlay. Marked (`channel_open`) specs refuse here: a marked op
|
||||||
|
/// is unreachable by composition (`OperationEnv::invoke` is
|
||||||
|
/// request/response-only; ADR-046 §6) and its data plane has no
|
||||||
|
/// overlay path — registration-time loudness, never an advertised
|
||||||
|
/// dead body (ADR-047 amendment 4 §4.8; ADR-022's 2026-10-13
|
||||||
|
/// amendment). Relay composition
|
||||||
|
/// (`ChannelRelay::register_relay_openable`) is the sanctioned
|
||||||
|
/// marked-op import.
|
||||||
|
pub fn register_imported(&self, registration: HandlerRegistration) -> Result<(), String> {
|
||||||
|
if registration.spec.channel_open.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"register_imported: spec `{}` carries the `channel_open` marker — marked \
|
||||||
|
specs are not overlay-importable; compose `ChannelRelay` or consume via \
|
||||||
|
`ChannelClient` (ADR-047 amendment 4 §4.8)",
|
||||||
|
registration.spec.name
|
||||||
|
));
|
||||||
|
}
|
||||||
let name = registration.spec.name.clone();
|
let name = registration.spec.name.clone();
|
||||||
self.imported_operations.write().insert(name, registration);
|
self.imported_operations.write().insert(name, registration);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `true` when the connection overlay holds a registration for
|
/// `true` when the connection overlay holds a registration for
|
||||||
@@ -176,11 +194,24 @@ impl CallConnection {
|
|||||||
self.imported_operations.read().get(name).cloned()
|
self.imported_operations.read().get(name).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
|
pub fn register_imported_all(
|
||||||
|
&self,
|
||||||
|
registrations: Vec<HandlerRegistration>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let mut overlay = self.imported_operations.write();
|
let mut overlay = self.imported_operations.write();
|
||||||
for reg in registrations {
|
for reg in registrations {
|
||||||
overlay.insert(reg.spec.name.clone(), reg);
|
if reg.spec.channel_open.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"register_imported_all: spec `{}` carries the `channel_open` marker — \
|
||||||
|
marked specs are not overlay-importable; compose `ChannelRelay` or \
|
||||||
|
consume via `ChannelClient` (ADR-047 amendment 4 §4.8)",
|
||||||
|
reg.spec.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let name = reg.spec.name.clone();
|
||||||
|
overlay.insert(name, reg);
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync> {
|
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync> {
|
||||||
@@ -975,7 +1006,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(!env.contains("worker/exec"));
|
assert!(!env.contains("worker/exec"));
|
||||||
|
|
||||||
conn.register_imported(imported_registration("worker/exec"));
|
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||||
|
|
||||||
assert!(env.contains("worker/exec"));
|
assert!(env.contains("worker/exec"));
|
||||||
assert!(!env.contains("worker/missing"));
|
assert!(!env.contains("worker/missing"));
|
||||||
@@ -986,11 +1017,12 @@ mod tests {
|
|||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
let env = conn.overlay_env();
|
let env = conn.overlay_env();
|
||||||
|
|
||||||
conn.register_imported_all(vec![
|
let registrations_result = conn.register_imported_all(vec![
|
||||||
imported_registration("worker/exec"),
|
imported_registration("worker/exec"),
|
||||||
imported_registration("worker/status"),
|
imported_registration("worker/status"),
|
||||||
imported_registration("fs/readFile"),
|
imported_registration("fs/readFile"),
|
||||||
]);
|
]);
|
||||||
|
let _error = registrations_result;
|
||||||
|
|
||||||
assert!(env.contains("worker/exec"));
|
assert!(env.contains("worker/exec"));
|
||||||
assert!(env.contains("worker/status"));
|
assert!(env.contains("worker/status"));
|
||||||
@@ -1001,7 +1033,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_env_dispatches_to_imported_op() {
|
async fn overlay_env_dispatches_to_imported_op() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
conn.register_imported(imported_registration("worker/exec"));
|
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||||
let env = conn.overlay_env();
|
let env = conn.overlay_env();
|
||||||
|
|
||||||
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
||||||
@@ -1018,7 +1050,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_env_contains_returns_false_for_non_imported_op() {
|
async fn overlay_env_contains_returns_false_for_non_imported_op() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
conn.register_imported(imported_registration("worker/exec"));
|
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||||
let env = conn.overlay_env();
|
let env = conn.overlay_env();
|
||||||
|
|
||||||
assert!(!env.contains("worker/missing"));
|
assert!(!env.contains("worker/missing"));
|
||||||
@@ -1039,7 +1071,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() {
|
async fn overlay_env_reachability_check_returns_not_found_for_disallowed_op() {
|
||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
conn.register_imported(imported_registration("worker/exec"));
|
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||||
let env = conn.overlay_env();
|
let env = conn.overlay_env();
|
||||||
|
|
||||||
let scoped = ScopedPeerEnv::empty();
|
let scoped = ScopedPeerEnv::empty();
|
||||||
@@ -1069,7 +1101,7 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
conn.register_imported(HandlerRegistration::new(
|
let _error = conn.register_imported(HandlerRegistration::new(
|
||||||
external_spec("worker/exec"),
|
external_spec("worker/exec"),
|
||||||
HandlerKind::Once(inspect_handler),
|
HandlerKind::Once(inspect_handler),
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
@@ -1115,7 +1147,7 @@ mod tests {
|
|||||||
Arc::new(RwLock::new(HashMap::new()));
|
Arc::new(RwLock::new(HashMap::new()));
|
||||||
{
|
{
|
||||||
let conn = CallConnection::new(stub_connection());
|
let conn = CallConnection::new(stub_connection());
|
||||||
conn.register_imported(imported_registration("worker/exec"));
|
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||||
assert!(conn.overlay_env().contains("worker/exec"));
|
assert!(conn.overlay_env().contains("worker/exec"));
|
||||||
std::mem::swap(
|
std::mem::swap(
|
||||||
&mut *captured.write(),
|
&mut *captured.write(),
|
||||||
@@ -1380,14 +1412,14 @@ mod tests {
|
|||||||
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
||||||
let env = conn.overlay_env();
|
let env = conn.overlay_env();
|
||||||
assert!(!env.contains("worker/exec"));
|
assert!(!env.contains("worker/exec"));
|
||||||
conn.register_imported(imported_registration("worker/exec"));
|
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||||
assert!(env.contains("worker/exec"));
|
assert!(env.contains("worker/exec"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overlay_only_overlay_env_dispatches_imported_op() {
|
async fn overlay_only_overlay_env_dispatches_imported_op() {
|
||||||
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
let conn = CallConnection::new_overlay_only(sample_identity("ws-peer"));
|
||||||
conn.register_imported(imported_registration("worker/exec"));
|
let _error = conn.register_imported(imported_registration("worker/exec"));
|
||||||
let env = conn.overlay_env();
|
let env = conn.overlay_env();
|
||||||
|
|
||||||
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
let scoped = ScopedPeerEnv::new(["worker/exec"]);
|
||||||
@@ -1433,7 +1465,7 @@ mod tests {
|
|||||||
let streaming_handler = make_streaming_handler(|input, ctx| {
|
let streaming_handler = make_streaming_handler(|input, ctx| {
|
||||||
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
|
futures::stream::iter(vec![ResponseEnvelope::ok(ctx.request_id, input)])
|
||||||
});
|
});
|
||||||
conn.register_imported(HandlerRegistration::new(
|
let _error = conn.register_imported(HandlerRegistration::new(
|
||||||
OperationSpec::new(
|
OperationSpec::new(
|
||||||
"events/stream",
|
"events/stream",
|
||||||
OperationType::Sub,
|
OperationType::Sub,
|
||||||
|
|||||||
+110
-2
@@ -86,6 +86,11 @@ pub struct SinkDispatch {
|
|||||||
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
|
pub(crate) handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
|
||||||
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
pub(crate) chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||||
pub(crate) publish_validator: Option<jsonschema::Validator>,
|
pub(crate) publish_validator: Option<jsonschema::Validator>,
|
||||||
|
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
|
||||||
|
/// publish chunks never feed the handler — the first
|
||||||
|
/// `call.published` chunk is rejected loudly in the initiator's
|
||||||
|
/// direction and the rest are ignored.
|
||||||
|
pub(crate) reject_publish: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An in-flight Pub (Sink) on the single-stream dispatch loop
|
/// An in-flight Pub (Sink) on the single-stream dispatch loop
|
||||||
@@ -100,6 +105,11 @@ struct InFlightSink {
|
|||||||
chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||||
publish_validator: Option<jsonschema::Validator>,
|
publish_validator: Option<jsonschema::Validator>,
|
||||||
handler_handle: JoinHandle<()>,
|
handler_handle: JoinHandle<()>,
|
||||||
|
/// Marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1):
|
||||||
|
/// the first `call.published` chunk is rejected loudly in the
|
||||||
|
/// initiator's direction and never fed to the handler.
|
||||||
|
reject_publish: bool,
|
||||||
|
reject_written: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for DispatchResult {
|
impl std::fmt::Debug for DispatchResult {
|
||||||
@@ -356,7 +366,7 @@ impl Dispatcher {
|
|||||||
.get("forwarded_for")
|
.get("forwarded_for")
|
||||||
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
||||||
|
|
||||||
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
let mut input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||||
|
|
||||||
let op_type = self
|
let op_type = self
|
||||||
.registry
|
.registry
|
||||||
@@ -391,9 +401,13 @@ impl Dispatcher {
|
|||||||
context.deadline = None;
|
context.deadline = None;
|
||||||
match self
|
match self
|
||||||
.registry
|
.registry
|
||||||
.resolve_sink_handler(&operation_name, &input, &context)
|
.resolve_sink_handler(&operation_name, &mut input, &mut context)
|
||||||
{
|
{
|
||||||
Ok(sink_handler) => {
|
Ok(sink_handler) => {
|
||||||
|
let reject_publish = self
|
||||||
|
.registry
|
||||||
|
.registration(&operation_name)
|
||||||
|
.is_some_and(|r| r.spec.channel_open.is_some());
|
||||||
let publish_validator = self.registry.publish_validator(&operation_name);
|
let publish_validator = self.registry.publish_validator(&operation_name);
|
||||||
let (chunk_tx, chunk_rx) =
|
let (chunk_tx, chunk_rx) =
|
||||||
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
|
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
|
||||||
@@ -403,6 +417,7 @@ impl Dispatcher {
|
|||||||
handler: Box::pin(handler),
|
handler: Box::pin(handler),
|
||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
|
Err(envelope) => StartedDispatch::Once(Box::pin(std::future::ready(envelope))),
|
||||||
@@ -657,6 +672,7 @@ impl Dispatcher {
|
|||||||
handler,
|
handler,
|
||||||
mut chunk_tx,
|
mut chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
} = sink;
|
} = sink;
|
||||||
|
|
||||||
let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
|
let (abort_tx, mut abort_rx) = oneshot::channel::<()>();
|
||||||
@@ -668,6 +684,7 @@ impl Dispatcher {
|
|||||||
let mut handler = handler.fuse();
|
let mut handler = handler.fuse();
|
||||||
let mut response: Option<ResponseEnvelope> = None;
|
let mut response: Option<ResponseEnvelope> = None;
|
||||||
let mut aborted = false;
|
let mut aborted = false;
|
||||||
|
let mut reject_written = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -698,6 +715,31 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
match envelope.r#type.as_str() {
|
match envelope.r#type.as_str() {
|
||||||
EVENT_PUBLISHED => {
|
EVENT_PUBLISHED => {
|
||||||
|
// Marked ops: the data plane is the adopted
|
||||||
|
// channel's `BiStream` — publish chunks are
|
||||||
|
// rejected loudly, never consumed (ADR-047
|
||||||
|
// amendment 4 §4.1). The handler continues on
|
||||||
|
// the channel plane; the initiator's pending
|
||||||
|
// call resolves with this rejection.
|
||||||
|
if reject_publish {
|
||||||
|
if !reject_written {
|
||||||
|
reject_written = true;
|
||||||
|
let rejection: EventEnvelope = ResponseEnvelope::error(
|
||||||
|
request_id.clone(),
|
||||||
|
CallError::new(
|
||||||
|
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
|
||||||
|
"the data plane of a marked pub op is the adopted \
|
||||||
|
channel's BiStream — call.published chunks are \
|
||||||
|
rejected (ADR-047 amendment 4 §4.1)",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).into();
|
||||||
|
if let Err(err) = writer.write_frame(&rejection).await {
|
||||||
|
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let chunk = envelope
|
let chunk = envelope
|
||||||
.payload
|
.payload
|
||||||
.get("input")
|
.get("input")
|
||||||
@@ -923,6 +965,7 @@ impl Dispatcher {
|
|||||||
handler,
|
handler,
|
||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
} = sink;
|
} = sink;
|
||||||
let handle = Self::spawn_sink_response_writer(
|
let handle = Self::spawn_sink_response_writer(
|
||||||
&writer,
|
&writer,
|
||||||
@@ -935,6 +978,8 @@ impl Dispatcher {
|
|||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
handler_handle: handle,
|
handler_handle: handle,
|
||||||
|
reject_publish,
|
||||||
|
reject_written: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -977,6 +1022,36 @@ impl Dispatcher {
|
|||||||
.unwrap_or(Value::Null);
|
.unwrap_or(Value::Null);
|
||||||
let entry = in_flight_sinks.lock().remove(&request_id);
|
let entry = in_flight_sinks.lock().remove(&request_id);
|
||||||
if let Some(mut entry) = entry {
|
if let Some(mut entry) = entry {
|
||||||
|
if entry.reject_publish {
|
||||||
|
// Marked ops: the data plane is the adopted
|
||||||
|
// channel's `BiStream` — publish chunks are
|
||||||
|
// rejected loudly, never consumed (ADR-047
|
||||||
|
// amendment 4 §4.1). The loud error is written
|
||||||
|
// once per request; the entry stays registered
|
||||||
|
// so the handler's own final response still gets
|
||||||
|
// written. The handler continues on the channel
|
||||||
|
// plane; the initiator's pending call resolves
|
||||||
|
// with this rejection immediately.
|
||||||
|
if !entry.reject_written {
|
||||||
|
entry.reject_written = true;
|
||||||
|
let rejection: EventEnvelope = ResponseEnvelope::error(
|
||||||
|
request_id.clone(),
|
||||||
|
CallError::new(
|
||||||
|
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
|
||||||
|
"the data plane of a marked pub op is the \
|
||||||
|
adopted channel's BiStream — call.published \
|
||||||
|
chunks are rejected (ADR-047 amendment 4 §4.1)",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
if let Err(err) = writer.write_frame(&rejection).await {
|
||||||
|
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in_flight_sinks.lock().insert(request_id, entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let validated = match &entry.publish_validator {
|
let validated = match &entry.publish_validator {
|
||||||
Some(validator) => {
|
Some(validator) => {
|
||||||
if validator.is_valid(&chunk) {
|
if validator.is_valid(&chunk) {
|
||||||
@@ -1146,6 +1221,7 @@ impl Dispatcher {
|
|||||||
handler,
|
handler,
|
||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
|
reject_publish,
|
||||||
} = sink;
|
} = sink;
|
||||||
let handle = Self::spawn_sink_response_writer(
|
let handle = Self::spawn_sink_response_writer(
|
||||||
&writer,
|
&writer,
|
||||||
@@ -1158,6 +1234,8 @@ impl Dispatcher {
|
|||||||
chunk_tx,
|
chunk_tx,
|
||||||
publish_validator,
|
publish_validator,
|
||||||
handler_handle: handle,
|
handler_handle: handle,
|
||||||
|
reject_publish,
|
||||||
|
reject_written: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1200,6 +1278,36 @@ impl Dispatcher {
|
|||||||
.unwrap_or(Value::Null);
|
.unwrap_or(Value::Null);
|
||||||
let entry = in_flight_sinks.lock().remove(&request_id);
|
let entry = in_flight_sinks.lock().remove(&request_id);
|
||||||
if let Some(mut entry) = entry {
|
if let Some(mut entry) = entry {
|
||||||
|
if entry.reject_publish {
|
||||||
|
// Marked ops: the data plane is the adopted
|
||||||
|
// channel's `BiStream` — publish chunks are
|
||||||
|
// rejected loudly, never consumed (ADR-047
|
||||||
|
// amendment 4 §4.1). The loud error is written
|
||||||
|
// once per request; the entry stays registered
|
||||||
|
// so the handler's own final response still gets
|
||||||
|
// written. The handler continues on the channel
|
||||||
|
// plane; the initiator's pending call resolves
|
||||||
|
// with this rejection immediately.
|
||||||
|
if !entry.reject_written {
|
||||||
|
entry.reject_written = true;
|
||||||
|
let rejection: EventEnvelope = ResponseEnvelope::error(
|
||||||
|
request_id.clone(),
|
||||||
|
CallError::new(
|
||||||
|
crate::registry::spec::MARKED_PUB_CHUNKS_REJECTED,
|
||||||
|
"the data plane of a marked pub op is the \
|
||||||
|
adopted channel's BiStream — call.published \
|
||||||
|
chunks are rejected (ADR-047 amendment 4 §4.1)",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
if let Err(err) = writer.write_frame(&rejection).await {
|
||||||
|
warn!(error = %err, request_id = %request_id, "failed to write marked-op publish rejection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in_flight_sinks.lock().insert(request_id, entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let validated = match &entry.publish_validator {
|
let validated = match &entry.publish_validator {
|
||||||
Some(validator) => {
|
Some(validator) => {
|
||||||
if validator.is_valid(&chunk) {
|
if validator.is_valid(&chunk) {
|
||||||
|
|||||||
+151
-22
@@ -178,16 +178,37 @@ pub fn op_register_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut spec = request.spec;
|
let mut spec = request.spec;
|
||||||
|
if spec.channel_open.is_some() {
|
||||||
|
return ResponseEnvelope::error(
|
||||||
|
context.request_id,
|
||||||
|
CallError::new(
|
||||||
|
"MARKED_SPEC_UNSUPPORTED",
|
||||||
|
format!(
|
||||||
|
"op/register: `{}` carries the `channel_open` marker — marked ops \
|
||||||
|
are channel-machinery ops (the data plane is the allocated \
|
||||||
|
channel's `BiStream`, ADR-047 amendment 4); forward them with \
|
||||||
|
`ChannelRelay::register_relay_openable` (a relaying hub) or \
|
||||||
|
consume them directly (`ChannelClient`), never as an \
|
||||||
|
announcement",
|
||||||
|
spec.name
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
spec.visibility = Visibility::Internal;
|
spec.visibility = Visibility::Internal;
|
||||||
let remote_name = spec.name.clone();
|
let remote_name = spec.name.clone();
|
||||||
|
|
||||||
let handler = forwarding_stub_for_announced_op(
|
let handler = forwarding_kind_for_announced_op(
|
||||||
Arc::clone(&connection),
|
Arc::clone(&connection),
|
||||||
remote_name,
|
remote_name,
|
||||||
spec.op_type,
|
spec.op_type,
|
||||||
);
|
);
|
||||||
|
|
||||||
connection.register_imported(HandlerRegistration::new(
|
// The marked-refusal arm is unreachable here (marked
|
||||||
|
// announcements were refused above); an invariant break
|
||||||
|
// maps to the wire rather than a panic.
|
||||||
|
let registered = connection.register_imported(HandlerRegistration::new(
|
||||||
spec.clone(),
|
spec.clone(),
|
||||||
handler,
|
handler,
|
||||||
OperationProvenance::FromCall,
|
OperationProvenance::FromCall,
|
||||||
@@ -195,6 +216,12 @@ pub fn op_register_handler(
|
|||||||
None,
|
None,
|
||||||
Capabilities::new(),
|
Capabilities::new(),
|
||||||
));
|
));
|
||||||
|
if let Err(e) = registered {
|
||||||
|
return ResponseEnvelope::error(
|
||||||
|
context.request_id,
|
||||||
|
CallError::internal(format!("op/register: overlay landing refused: {e}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ResponseEnvelope::ok(
|
ResponseEnvelope::ok(
|
||||||
context.request_id,
|
context.request_id,
|
||||||
@@ -204,15 +231,14 @@ pub fn op_register_handler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The forwarding stub for a peer-announced op. Query/Mutation ops get
|
/// The forwarding handler kind for a peer-announced op. Query/Mutation
|
||||||
/// the `from_call` forwarding shape (nested `call.requested` back over
|
/// get the `from_call` forwarding shape (nested `call.requested` back
|
||||||
/// channel 0, `forwarded_for` populated per ADR-032 §3). Announced
|
/// over channel 0, `forwarded_for` populated per ADR-032 §3); unmarked
|
||||||
/// Sub/Pub ops are registered as stubs that return
|
/// `Sub`/`Pub` get the real streaming/sink forwarding shapes (review
|
||||||
/// `INVALID_OPERATION_TYPE` on invocation — nested composition is
|
/// 010 S-04's remedy: never accept-then-fail — the handler actually
|
||||||
/// request/response-only (`OverlayOperationEnv`'s contract); the
|
/// forwards). Marked specs refuse at this boundary (ADR-047 amendment
|
||||||
/// streaming/sink forwarding shapes ride on the `from_call` import
|
/// 4 — checked before this function is reached).
|
||||||
/// path, which the announcing side can use in the other direction.
|
fn forwarding_kind_for_announced_op(
|
||||||
fn forwarding_stub_for_announced_op(
|
|
||||||
connection: Arc<CallConnection>,
|
connection: Arc<CallConnection>,
|
||||||
remote_name: String,
|
remote_name: String,
|
||||||
op_type: OperationType,
|
op_type: OperationType,
|
||||||
@@ -221,17 +247,13 @@ fn forwarding_stub_for_announced_op(
|
|||||||
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
|
OperationType::Query | OperationType::Mutation => HandlerKind::Once(
|
||||||
crate::client::make_forwarding_handler(connection, remote_name),
|
crate::client::make_forwarding_handler(connection, remote_name),
|
||||||
),
|
),
|
||||||
OperationType::Sub | OperationType::Pub => {
|
OperationType::Sub => HandlerKind::Stream(
|
||||||
HandlerKind::Once(make_handler(|_input, context| async move {
|
crate::client::make_streaming_forwarding_handler(connection, remote_name),
|
||||||
ResponseEnvelope::error(
|
),
|
||||||
context.request_id,
|
OperationType::Pub => HandlerKind::Sink(crate::client::make_sink_forwarding_handler(
|
||||||
CallError::invalid_operation_type(
|
connection,
|
||||||
"peer-announced Sub/Pub ops are not invocable over nested \
|
remote_name,
|
||||||
composition (request/response only)",
|
)),
|
||||||
),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,4 +779,111 @@ mod tests {
|
|||||||
.expect("marker survives the announce");
|
.expect("marker survives the announce");
|
||||||
assert_eq!(marker.alpn, "alk/tunnel");
|
assert_eq!(marker.alpn, "alk/tunnel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- review 010 S-04's remedy (ADR-022, 2026-10-13 amendment) --------
|
||||||
|
|
||||||
|
/// Forward-through: an announced unmarked `Sub` op registers the
|
||||||
|
/// real streaming forwarding shape, not an always-failing stub.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn announced_unmarked_sub_registers_real_streaming_forward() {
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
|
||||||
|
|
||||||
|
let input = OpRegisterRequest {
|
||||||
|
spec: OperationSpec::new(
|
||||||
|
"worker/events",
|
||||||
|
OperationType::Sub,
|
||||||
|
Visibility::External,
|
||||||
|
json!({}),
|
||||||
|
json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
replace: false,
|
||||||
|
}
|
||||||
|
.to_json();
|
||||||
|
let response = handler(input, test_context("req-s04-1")).await;
|
||||||
|
assert!(response.result.is_ok(), "got {:?}", response.result);
|
||||||
|
|
||||||
|
let registration = conn
|
||||||
|
.overlay_registration("worker/events")
|
||||||
|
.expect("registered");
|
||||||
|
assert!(
|
||||||
|
matches!(registration.handler, HandlerKind::Stream(_)),
|
||||||
|
"the announced sub op is a real forwarding handler, not a stub"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward-through: an announced unmarked `Pub` op registers the
|
||||||
|
/// real sink forwarding shape.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn announced_unmarked_pub_registers_real_sink_forward() {
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
|
||||||
|
|
||||||
|
let input = OpRegisterRequest {
|
||||||
|
spec: OperationSpec::new(
|
||||||
|
"worker/ingest",
|
||||||
|
OperationType::Pub,
|
||||||
|
Visibility::External,
|
||||||
|
json!({}),
|
||||||
|
json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
replace: false,
|
||||||
|
}
|
||||||
|
.to_json();
|
||||||
|
let response = handler(input, test_context("req-s04-2")).await;
|
||||||
|
assert!(response.result.is_ok(), "got {:?}", response.result);
|
||||||
|
|
||||||
|
let registration = conn
|
||||||
|
.overlay_registration("worker/ingest")
|
||||||
|
.expect("registered");
|
||||||
|
assert!(
|
||||||
|
matches!(registration.handler, HandlerKind::Sink(_)),
|
||||||
|
"the announced pub op is a real forwarding handler, not a stub"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marked-spec refusal (ADR-047 amendment 4 §4.8's wire side): a
|
||||||
|
/// marked announcement is rejected with the typed code, no overlay
|
||||||
|
/// entry, and the message names the sanctioned compositions.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn announced_marked_spec_is_refused() {
|
||||||
|
use crate::registry::spec::ChannelOpenSpec;
|
||||||
|
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let handler = op_register_handler(Arc::clone(&conn), Arc::new(OperationRegistry::new()));
|
||||||
|
|
||||||
|
let input = OpRegisterRequest {
|
||||||
|
spec: OperationSpec::new(
|
||||||
|
"channels/tunnel/direct",
|
||||||
|
OperationType::Sub,
|
||||||
|
Visibility::External,
|
||||||
|
json!({}),
|
||||||
|
json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_channel_open(ChannelOpenSpec::new("alk/tunnel")),
|
||||||
|
replace: false,
|
||||||
|
}
|
||||||
|
.to_json();
|
||||||
|
let response = handler(input, test_context("req-s04-3")).await;
|
||||||
|
let e = response.result.expect_err("marked announcement refused");
|
||||||
|
assert_eq!(e.code, "MARKED_SPEC_UNSUPPORTED");
|
||||||
|
assert!(
|
||||||
|
e.message.contains("ChannelRelay"),
|
||||||
|
"the refusal names the sanctioned composition: {}",
|
||||||
|
e.message
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!conn.overlay_contains("channels/tunnel/direct"),
|
||||||
|
"the refused announcement lands nowhere"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,12 @@ pub struct HandlerRegistration {
|
|||||||
pub composition_authority: Option<CompositionAuthority>,
|
pub composition_authority: Option<CompositionAuthority>,
|
||||||
pub scoped_env: Option<ScopedPeerEnv>,
|
pub scoped_env: Option<ScopedPeerEnv>,
|
||||||
pub capabilities: Capabilities,
|
pub capabilities: Capabilities,
|
||||||
|
/// Set only by the channels-layer wrapper builders — a marked
|
||||||
|
/// (`channel_open`) spec is registered only through the
|
||||||
|
/// channel-machinery seam (`register_openable*`, `register_pub_openable`,
|
||||||
|
/// the relay). Generic registrations of marked specs are refused
|
||||||
|
/// (ADR-047 amendment 4 §4.8 — the registered-but-incapable class).
|
||||||
|
pub(crate) channel_wrapper: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HandlerRegistration {
|
impl HandlerRegistration {
|
||||||
@@ -102,8 +108,17 @@ impl HandlerRegistration {
|
|||||||
composition_authority,
|
composition_authority,
|
||||||
scoped_env,
|
scoped_env,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
channel_wrapper: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The channel-machinery wrapper family's constructor seam
|
||||||
|
/// (crate-internal — the only sanctioned registrations of marked
|
||||||
|
/// specs).
|
||||||
|
pub(crate) fn with_channel_wrapper(mut self) -> Self {
|
||||||
|
self.channel_wrapper = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct OperationRegistry {
|
pub struct OperationRegistry {
|
||||||
@@ -215,6 +230,23 @@ impl OperationRegistry {
|
|||||||
registration.spec.op_type, expected, actual
|
registration.spec.op_type, expected, actual
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if registration.spec.channel_open.is_some() && !registration.channel_wrapper {
|
||||||
|
return Err(format!(
|
||||||
|
"spec `{}` carries the `channel_open` marker — marked ops are registered \
|
||||||
|
only through the channel-machinery seam (`ChannelCore::register_openable*`, \
|
||||||
|
`ChannelCore::register_pub_openable`, `ChannelRelay::register_relay_openable`); \
|
||||||
|
a generic registration cannot serve the marker's data plane (ADR-047 amendment 4 §4.8)",
|
||||||
|
registration.spec.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if registration.spec.channel_open.is_some() && registration.spec.publish_schema.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"spec `{}` carries both the `channel_open` marker and a `publish_schema` — \
|
||||||
|
publish chunks never exist on a marked op (the data plane is the adopted \
|
||||||
|
channel's `BiStream`); the field is dead machinery here (ADR-047 amendment 4 §4.2)",
|
||||||
|
registration.spec.name
|
||||||
|
));
|
||||||
|
}
|
||||||
Self::validate_and_cache_publish_schema(&mut self.validators.write(), ®istration.spec)?;
|
Self::validate_and_cache_publish_schema(&mut self.validators.write(), ®istration.spec)?;
|
||||||
Self::validate_and_cache_input_schema(
|
Self::validate_and_cache_input_schema(
|
||||||
&mut self.input_validators.write(),
|
&mut self.input_validators.write(),
|
||||||
@@ -436,12 +468,21 @@ impl OperationRegistry {
|
|||||||
/// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the
|
/// dispatch path (`Dispatcher::dispatch` Pub branch) call it, so the
|
||||||
/// two paths cannot diverge (P-08: the `invoke_sink` path IS the
|
/// two paths cannot diverge (P-08: the `invoke_sink` path IS the
|
||||||
/// wire path).
|
/// wire path).
|
||||||
|
///
|
||||||
|
/// Marked `Pub` ops (ADR-047 amendment 4 §4.3): the
|
||||||
|
/// wrapper-reserved input key ([`crate::registry::spec::
|
||||||
|
/// RESERVED_OPEN_INPUT_KEY`]) is stripped **before**
|
||||||
|
/// `input_schema` validation — the producing crate's schema never
|
||||||
|
/// contains it — and the initiator-allocated channel id is handed
|
||||||
|
/// to the wrapper via `context.metadata`. A marked pub op whose
|
||||||
|
/// input lacks the reserved key is rejected here (`INVALID_INPUT`)
|
||||||
|
/// — the id is mandatory, not optional plumbing.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub(crate) fn resolve_sink_handler(
|
pub(crate) fn resolve_sink_handler(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
input: &Value,
|
input: &mut Value,
|
||||||
context: &OperationContext,
|
context: &mut OperationContext,
|
||||||
) -> Result<SinkHandler, ResponseEnvelope> {
|
) -> Result<SinkHandler, ResponseEnvelope> {
|
||||||
let request_id = context.request_id.clone();
|
let request_id = context.request_id.clone();
|
||||||
let registration = match self.registration(name) {
|
let registration = match self.registration(name) {
|
||||||
@@ -477,6 +518,43 @@ impl OperationRegistry {
|
|||||||
return Err(ResponseEnvelope::forbidden(request_id, message));
|
return Err(ResponseEnvelope::forbidden(request_id, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if registration.spec.channel_open.is_some()
|
||||||
|
&& registration.spec.op_type == OperationType::Pub
|
||||||
|
{
|
||||||
|
let reserved_id = match input
|
||||||
|
.as_object_mut()
|
||||||
|
.and_then(|obj| obj.remove(crate::registry::spec::RESERVED_OPEN_INPUT_KEY))
|
||||||
|
{
|
||||||
|
Some(value) => match value.as_u64() {
|
||||||
|
Some(id) if id <= u32::MAX as u64 => id as u32,
|
||||||
|
_ => {
|
||||||
|
return Err(ResponseEnvelope::error(
|
||||||
|
request_id,
|
||||||
|
CallError::invalid_input(format!(
|
||||||
|
"marked pub open op: the reserved `{}` key must be a u32 \
|
||||||
|
channel id (the initiator-allocated id; ADR-047 amendment 4 §4.3)",
|
||||||
|
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
return Err(ResponseEnvelope::error(
|
||||||
|
request_id,
|
||||||
|
CallError::invalid_input(format!(
|
||||||
|
"marked pub open op input missing the reserved `{}` key \
|
||||||
|
(the initiator-allocated channel id; ADR-047 amendment 4 §4.3)",
|
||||||
|
crate::registry::spec::RESERVED_OPEN_INPUT_KEY
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
context.metadata.insert(
|
||||||
|
crate::registry::spec::RESERVED_OPEN_METADATA_KEY.to_string(),
|
||||||
|
serde_json::json!(reserved_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
self.check_input_schema(®istration, input, &request_id)?;
|
self.check_input_schema(®istration, input, &request_id)?;
|
||||||
|
|
||||||
match ®istration.handler {
|
match ®istration.handler {
|
||||||
@@ -505,14 +583,16 @@ impl OperationRegistry {
|
|||||||
pub async fn invoke_sink(
|
pub async fn invoke_sink(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
input: Value,
|
mut input: Value,
|
||||||
publish_stream: PublishStream,
|
publish_stream: PublishStream,
|
||||||
context: OperationContext,
|
mut context: OperationContext,
|
||||||
) -> ResponseEnvelope {
|
) -> ResponseEnvelope {
|
||||||
match self.resolve_sink_handler(name, &input, &context) {
|
let publish_stream_resolved =
|
||||||
Err(envelope) => envelope,
|
match self.resolve_sink_handler(name, &mut input, &mut context) {
|
||||||
Ok(sink_handler) => sink_handler(input, context, publish_stream).await,
|
Err(envelope) => return envelope,
|
||||||
}
|
Ok(sink_handler) => sink_handler,
|
||||||
|
};
|
||||||
|
publish_stream_resolved(input, context, publish_stream).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2942,3 +3022,85 @@ mod tests {
|
|||||||
assert_eq!(err.code, "INVALID_INPUT");
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod marked_spec_gates {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn marked_spec(name: &str, op_type: OperationType) -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
op_type,
|
||||||
|
crate::registry::spec::Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
crate::registry::spec::AccessControl::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_channel_open(crate::registry::spec::ChannelOpenSpec::new("alk/x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-047 amendment 4 §4.2 gate: `publish_schema` on a marked
|
||||||
|
/// spec refuses at registration — publish chunks never exist on a
|
||||||
|
/// marked op. (Registered through the sanctioned wrapper seam, so
|
||||||
|
/// the §4.8 invariant is satisfied and this refusal is the one
|
||||||
|
/// that fires.)
|
||||||
|
#[test]
|
||||||
|
fn marked_spec_with_publish_schema_refused() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
let spec = marked_spec("channels/x/pub", OperationType::Pub)
|
||||||
|
.with_publish_schema(serde_json::json!({ "type": "object" }));
|
||||||
|
let registration = HandlerRegistration::new(
|
||||||
|
spec,
|
||||||
|
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
|
||||||
|
ResponseEnvelope::ok("r", serde_json::json!({}))
|
||||||
|
})),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
crate::core::types::Capabilities::new(),
|
||||||
|
)
|
||||||
|
.with_channel_wrapper();
|
||||||
|
let e = registry.register(registration).expect_err("refused");
|
||||||
|
assert!(e.contains("publish_schema"), "got: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-047 amendment 4 §4.8 gate: a marked spec through the
|
||||||
|
/// generic registration path is refused — the registered-but-
|
||||||
|
/// incapable class review 010 outlaws; the sanctioned seam is the
|
||||||
|
/// channel wrapper family.
|
||||||
|
#[test]
|
||||||
|
fn marked_spec_refused_outside_the_wrapper_seam() {
|
||||||
|
let registry = OperationRegistry::new();
|
||||||
|
let spec = marked_spec("channels/x/pub", OperationType::Pub);
|
||||||
|
let registration = HandlerRegistration::new(
|
||||||
|
spec,
|
||||||
|
HandlerKind::Sink(make_sink_handler(|_input, _ctx, _stream| async {
|
||||||
|
ResponseEnvelope::ok("r", serde_json::json!({}))
|
||||||
|
})),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
crate::core::types::Capabilities::new(),
|
||||||
|
);
|
||||||
|
let e = registry.register(registration).expect_err("refused");
|
||||||
|
assert!(e.contains("channel-machinery seam"), "got: {e}");
|
||||||
|
|
||||||
|
// A marked `Sub` spec — the same class.
|
||||||
|
let registry2 = OperationRegistry::new();
|
||||||
|
let spec2 = marked_spec("channels/x/sub", OperationType::Sub);
|
||||||
|
let registration2 = HandlerRegistration::new(
|
||||||
|
spec2,
|
||||||
|
HandlerKind::Stream(make_streaming_handler(|_input, _ctx| {
|
||||||
|
Box::pin(futures::stream::empty())
|
||||||
|
})),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
crate::core::types::Capabilities::new(),
|
||||||
|
);
|
||||||
|
let e2 = registry2.register(registration2).expect_err("refused");
|
||||||
|
assert!(e2.contains("channel-machinery seam"), "got: {e2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,28 @@ pub struct ChannelOpenSpec {
|
|||||||
pub alpn: Cow<'static, str>,
|
pub alpn: Cow<'static, str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The wrapper-reserved input key on a **marked `Pub` op's** input
|
||||||
|
/// (ADR-047 amendment 4 §4.3): the initiator-allocated `channel_id` the
|
||||||
|
/// responder adopts. The same key the open wrapper owns on the reply
|
||||||
|
/// side (the channels layer's `RESERVED_REPLY_KEY`); the wrapper owns
|
||||||
|
/// `channel_id` on both sides of a marked open op. Stripped
|
||||||
|
/// pre-validation in the sink resolve path and delivered to the
|
||||||
|
/// wrapper via `OperationContext.metadata`
|
||||||
|
/// ([`RESERVED_OPEN_METADATA_KEY`]).
|
||||||
|
pub const RESERVED_OPEN_INPUT_KEY: &str = "channel_id";
|
||||||
|
|
||||||
|
/// The `OperationContext.metadata` key the sink resolve path uses to
|
||||||
|
/// hand the stripped reserved input value (the initiator-allocated
|
||||||
|
/// channel id) to the marked-pub open wrapper (ADR-047 amendment 4
|
||||||
|
/// §4.3).
|
||||||
|
pub const RESERVED_OPEN_METADATA_KEY: &str = "channel_open_channel_id";
|
||||||
|
|
||||||
|
/// The wire error code for a `call.published` chunk arriving on a
|
||||||
|
/// marked (`channel_open`) `Pub` op (ADR-047 amendment 4 §4.1): the
|
||||||
|
/// marked op's data plane is the adopted channel's `BiStream`; publish
|
||||||
|
/// chunks are rejected loudly and never consumed.
|
||||||
|
pub const MARKED_PUB_CHUNKS_REJECTED: &str = "channel:publish_chunks_rejected";
|
||||||
|
|
||||||
impl ChannelOpenSpec {
|
impl ChannelOpenSpec {
|
||||||
pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self {
|
pub fn new(alpn: impl Into<Cow<'static, str>>) -> Self {
|
||||||
Self { alpn: alpn.into() }
|
Self { alpn: alpn.into() }
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! The stub guard (review 010 action 3): no `*:not_implemented` path
|
||||||
|
//! may exist in `src/`. A handler that registers successfully and
|
||||||
|
//! returns an error of this form on every invocation is a bug, not a
|
||||||
|
//! documented limitation — shipping one is a hard failure per the
|
||||||
|
//! repo ruling (reviews 010/011; four stubs shipped across nine
|
||||||
|
//! published versions before this guard existed).
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_tree_has_no_not_implemented_path() {
|
||||||
|
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||||
|
let src = std::path::Path::new(manifest).join("src");
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
walk(&src, &mut offenders);
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"no `not_implemented` string may appear in src/ — a registered surface \
|
||||||
|
that always fails is the stub class (review 010 S-01..S-04); offended \
|
||||||
|
by: {offenders:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(dir: &std::path::Path, offenders: &mut Vec<String>) {
|
||||||
|
let entries = match std::fs::read_dir(dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
walk(&path, offenders);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||||
|
let contents = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for (line_no, line) in contents.lines().enumerate() {
|
||||||
|
if line.contains("not_implemented") {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{}",
|
||||||
|
path.strip_prefix(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.display(),
|
||||||
|
line_no + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user