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:
glm-5.3-flash committed 2026-10-13 09:00:21 +00:00
1 parent df4d4d3a9e
commit 944c300c17
18 files changed
+2418 -326

No files matched your search

+130 -2
View File
@@ -70,8 +70,8 @@ impl ChannelOpenError {
/// The establishment-failure reason code — `details.reason` of a
/// `channel:open_failed` error (`dial_failed`, `unknown_resource`,
/// `resource_shortage`, `handler_error`, `timeout`). `None` for
/// any other failure shape.
/// `resource_shortage`, `handler_error`, `timeout`, `adopt_failed`).
/// `None` for any other failure shape.
pub fn establishment_reason(&self) -> Option<&str> {
match self {
ChannelOpenError::CallFailed { error }
@@ -88,6 +88,30 @@ impl ChannelOpenError {
}
}
/// The awaited single merged response of a put open
/// ([`ChannelClient::open_pub_channel`], ADR-047 amendment 4 §4.9 —
/// the put ack rides in the result, ADR-047 amendment 4 §4.5). The
/// putter writes bytes on the returned `send` half (or spawns pumps)
/// and awaits this for the put's wire contract (e.g. alkblobs' put
/// replying `{ token, digest }`). Await this after (or alongside) the
/// byte stream, never before it — the response cannot exist before the
/// byte stream completes.
pub struct PubOpenReply {
rx: tokio::sync::oneshot::Receiver<Result<Value, ChannelOpenError>>,
}
impl PubOpenReply {
/// The putter-side await: the merged response output (`Ok`);
/// `Err` carries the wire `CallError` verbatim
/// (`channel:open_failed` + `details.reason` per ADR-049 §3).
pub async fn resolve(self) -> Result<Value, ChannelOpenError> {
match self.rx.await {
Ok(result) => result,
Err(_) => Err(ChannelOpenError::MissingChannelId),
}
}
}
/// The client-side handle for a channels connection. Constructed via
/// [`ChannelClient::from_connection`] from an established
/// `Connection`. Holds the `ChannelManager` (for the demux/mux state)
@@ -382,6 +406,110 @@ impl ChannelClient {
.map(|(send, recv)| (channel_id, out, send, recv))
}
/// The putter-facing open (ADR-047 amendment 4 §4.9 — the
/// initiator-allocates capability `open_channel`/`open_channel_with_reply`
/// cannot provide, because those await the open reply before
/// returning the halves and a put's bytes flow before the reply
/// exists, review 011 §2.7): allocates the connect-side odd id
/// locally, inserts the reserved input key, sends the open op in a
/// background task, and returns **immediately** — the caller
/// writes bytes on `send` without waiting for anything (the
/// put ack rides in the merged response; ADR-047 amendment 4 §4.5).
///
/// Returns `(channel_id, send, recv, reply)`; `reply` is the
/// awaited single merged response — `Ok(Value)` (the full output,
/// e.g. `{ token, digest }`) or the wire error. On a failed open
/// the local adopt entry is torn down and `send`/`recv` are
/// already EOF'd (teardown); the caller still holds them but every
/// write is EOF-shaped. The `recv` half is the (bidirectional)
/// channel's read side — a put that also receives reads it.
pub async fn open_pub_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream, PubOpenReply), ChannelOpenError> {
use crate::registry::spec::RESERVED_OPEN_INPUT_KEY;
let (channel_id, send, recv) = self
.manager
.open_channel(
alpn,
// The opener ledger is inert on the connect side (it
// does not enforce the per-identity cap, ADR-041) —
// the record is observability only.
"channel-client",
None,
)
.await
.map_err(|e| match e {
crate::channels::manager::ManagerError::TooManyChannels { count, max } => {
ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:too_many_channels",
format!("per-connection channel cap exceeded: {count}/{max}"),
false,
)
.with_details(serde_json::json!({ "count": count, "max": max })),
}
}
other => ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::new(
"channel:allocation_failed",
format!("channel allocation failed: {other}"),
false,
),
},
})?;
let mut op_input = input;
match op_input.as_object_mut() {
Some(obj) => {
obj.insert(
RESERVED_OPEN_INPUT_KEY.to_string(),
serde_json::json!(channel_id),
);
}
None => {
return Err(ChannelOpenError::CallFailed {
error: crate::protocol::wire::CallError::invalid_input(
"marked pub open op input must be an object (ADR-047 amendment 4 §4.3)",
),
});
}
}
let call_conn = {
let guard = self.call_connection.lock().await;
guard.as_ref().map(Arc::clone)
};
let manager = self.manager.clone();
let op_id = operation_id.to_string();
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let response = match &call_conn {
Some(conn) => conn.call(&op_id, op_input).await,
None => ResponseEnvelope::error(
"channel-client",
crate::protocol::wire::CallError::internal("channel client closed"),
),
};
// A failed open tears the allocator-side entry down: the
// responder refused, the channel is dead, and the local
// half must not linger in the connection's channel map.
if response.result.is_err() {
let _ = manager.teardown_channel(channel_id);
}
let reply = match response.result {
Ok(out) => Ok(out),
Err(error) => Err(ChannelOpenError::CallFailed { error }),
};
let _ = reply_tx.send(reply);
});
Ok((channel_id, send, recv, PubOpenReply { rx: reply_rx }))
}
/// Take the `CallConnection` — used by the consumer to register
/// imported ops (`from_call`) on the connection's overlay. After
/// this, `call_open_op` returns an error (the connection is owned
+92
View File
@@ -62,6 +62,7 @@ const TTY_OP: &str = "channels/tty/sub";
const TTY_ALPN: &str = "alk/tty";
const ALPN: &str = "alk/tunnel";
const PLAIN_OP: &str = "fs/read";
const PUT_OP: &str = "channels/put/bulk";
fn identity(id: &str, scopes: &[&str]) -> Identity {
Identity {
@@ -256,6 +257,49 @@ fn make_spoke_install(
)
.expect("spoke registers the tty open op");
// The marked pub openable (ADR-047 amendment 4 §4.7's
// relay-translation gate): a put handler that consumes the
// adopted channel's data plane to EOF and replies with the
// digest over the streamed bytes.
let put_spec = OperationSpec::new(
PUT_OP,
OperationType::Pub,
Visibility::External,
json!({ "type": "object" }),
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer" },
"digest": { "type": "string" }
}
}),
vec![],
AccessControl::default(),
None,
)
.with_channel_open(ChannelOpenSpec::new("alk/blobs"));
let put_handler: crate::channels::operations::PubOpenHandler =
Arc::new(move |_input, _plan, channel_conn, _auth| {
Box::pin(async move {
let mut bidi = channel_conn.accept_bi().await.map_err(|_| {
crate::protocol::wire::CallError::internal(
"spoke put: the data plane never opened",
)
})?;
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut bidi, &mut buf)
.await
.map_err(|e| {
crate::protocol::wire::CallError::internal(format!(
"spoke put read failed: {e}"
))
})?;
Ok(json!({ "digest": format!("spoke-{}", buf.len()) }))
})
});
core.register_pub_openable(put_spec, None, put_handler, &registry, auth.clone(), None)
.expect("spoke registers the marked pub openable");
registry
.register(HandlerRegistration::new(
plain_spec(PLAIN_OP),
@@ -740,3 +784,51 @@ async fn gate2_tty_standard_shape_relays_with_no_regression() {
"the tty relay's ledger decremented"
);
}
// --- gate: the pub-openables relay translation (ADR-047 amendment 4 §4.7) ---
/// A hub translating a pub openable end-to-end (review 011 §3): the
/// browser's put via `open_pub_channel` on the consumer leg; bytes
/// flow browser → hub → spoke (the hub never parses them); the
/// spoke's single response loses its reserved key and its result
/// fields ride the consumer reply; the consumer replies exactly once
/// with the hub-allocated consumer-leg id.
#[tokio::test]
async fn gate_pub_relay_put_end_to_end() {
let h = start().await;
let (id, mut send, _recv, reply) = h
.consumer
.open_pub_channel(PUT_OP, json!({ "path": "g" }), "alk/blobs")
.await
.expect("the put opens through the relayed pub openable");
send.write_all(b"payload!").await.expect("stream the bytes");
drop(send);
let merged = reply
.resolve()
.await
.expect("the put's single merged response");
assert_eq!(
merged["digest"],
json!("spoke-8"),
"the spoke's result rode the relay (the producer's reserved key stripped)"
);
assert_eq!(
merged["channel_id"],
json!(id),
"the reply carries the hub-allocated consumer-leg id"
);
assert_eq!(id % 2, 1, "the browser (connect side) allocated odd");
wait_for(
|| data_channels(&h.hub_consumer_manager) == 0,
"the consumer-leg put channel reclaimed at the wrapper's completion teardown",
)
.await;
wait_for(
|| data_channels(&h.hub_producer_manager) == 0,
"the producer-leg put channel reclaimed at the plan's drop guard",
)
.await;
}
+17 -9
View File
@@ -668,7 +668,11 @@ async fn template_plain_bundle_registration_failure_is_loud_at_install() {
/// call to any op — including the bootstrap discovery ops — does not
/// resolve). Never a silent stub.
#[tokio::test]
async fn template_pub_typed_marked_spec_is_loud_at_install() {
async fn template_pub_typed_marked_spec_registers_through_the_relay() {
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
// the `PubTypedOpen` assembly error — a pub-typed marked spec in
// the stash composes through `register_pub_openable` and the op
// re-exposes on the consumer leg.
let pub_bundle = HandlerRegistration::new(
relay_spec(
"channels/x/pub",
@@ -685,15 +689,19 @@ async fn template_pub_typed_marked_spec_is_loud_at_install() {
);
let imports = HubLegImports::from_bundles(vec![pub_bundle]);
let consumer = start_hub(None, Some(imports), None, false).await;
let response = tokio::time::timeout(
std::time::Duration::from_secs(3),
consumer.call_open_op("services/list", json!({})),
)
.await;
let response = consumer.call_open_op("services/list", json!({})).await;
let out = response
.result
.expect("the leg dispatches; the op re-exposed");
let names: Vec<&str> = out["operations"]
.as_array()
.expect("operations")
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(
response.is_err(),
"the leg's channel 0 never dispatches after the loud assembly error, got {:?}",
response
names.contains(&"channels/x/pub"),
"the pub openable is re-exposed through the relay, got: {names:?}"
);
}
+76
View File
@@ -18,6 +18,9 @@ use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::debug;
use crate::protocol::wire::CallError;
use serde_json::Value;
use super::mux::{MuxHandle, OpenerLedger};
use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP};
@@ -38,6 +41,15 @@ pub enum ChannelSide {
/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7).
pub const DEFAULT_MAX_CHANNELS: usize = 256;
/// The per-channel out-of-band control callback (ADR-052): received
/// from the open-op establisher (`Establishment::with_control`),
/// installed by the open-op wrapper under the channel's id, routed to
/// by `channel/control`, removed by every teardown path. The manager
/// stays blind to what the closure does (ADR-039).
pub type ChannelControl = Arc<
dyn Fn(Value) -> futures::future::BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
>;
/// The per-channel state — the reassembly sender (the demux feeds chunk
/// payloads into it; the handler reads them out via `MpscRecvStream`),
/// the handler task handle, and the ALPN (for observability).
@@ -62,6 +74,13 @@ pub enum ManagerError {
UnknownChannel(u32),
#[error("channel already exists: {0}")]
ChannelExists(u32),
#[error(
"channel {channel_id} is outside the remote side's allocation range (expected {expected})"
)]
InvalidAdopt {
channel_id: u32,
expected: &'static str,
},
}
/// The shared state for a channels connection. `Clone` (cheap — `Arc`
@@ -101,6 +120,10 @@ struct Inner {
/// drains them into the new receiver. `clear_all` drops the map.
early_arrivals: Mutex<HashMap<u32, VecDeque<Bytes>>>,
early_arrival_count: AtomicU64,
/// Per-channel out-of-band control callbacks (ADR-052). Installed
/// by the open-op wrapper from the establisher's
/// `Establishment::with_control`; removed by every teardown path.
control_handles: Mutex<HashMap<u32, ChannelControl>>,
}
/// The per-channel cap on parked early-arrival chunks (`route_payload`
@@ -151,6 +174,7 @@ impl ChannelManager {
dropped_unknown_chunks: AtomicU64::new(0),
early_arrivals: Mutex::new(HashMap::new()),
early_arrival_count: AtomicU64::new(0),
control_handles: Mutex::new(HashMap::new()),
}),
}
}
@@ -317,6 +341,32 @@ impl ChannelManager {
alpn: impl Into<String>,
handler_task: Option<JoinHandle<()>>,
) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> {
// The adopt range is the remote side's allocation range (the
// odd/even split, ADR-047 §5): the connect side allocates odd,
// so the accept side adopts odd; the accept side allocates
// even, so the connect side adopts even. Adopting inside the
// local range would collide with this side's own monotonic
// allocator shortly after. The duplicate check comes first —
// adopting an id this side already holds returns
// `ChannelExists` and must never touch the live channel (the
// 0.8.1 regression pin).
let expected = match self.inner.side {
ChannelSide::Connect => "even (the accept side's allocated ids; ADR-047 §5)",
ChannelSide::Accept => "odd (the connect side's allocated ids; ADR-047 §5)",
};
if self.inner.channels.lock().contains_key(&channel_id) {
return Err(ManagerError::ChannelExists(channel_id));
}
let parity_ok = match self.inner.side {
ChannelSide::Connect => channel_id.is_multiple_of(2),
ChannelSide::Accept => !channel_id.is_multiple_of(2),
};
if !parity_ok {
return Err(ManagerError::InvalidAdopt {
channel_id,
expected,
});
}
let alpn = alpn.into();
let send = self
@@ -381,6 +431,30 @@ impl ChannelManager {
}
}
/// Install the out-of-band control callback for a channel
/// (ADR-052). Called by the open-op wrapper from the establisher's
/// `Establishment::with_control`. Replaces an existing callback for
/// the id (the establisher runs once per channel — replacement is
/// not an expected path; the last call wins).
pub fn set_control_handle(&self, channel_id: u32, control: ChannelControl) {
self.inner
.control_handles
.lock()
.insert(channel_id, control);
}
/// The control callback for a channel, for routing (the
/// `channel/control` handler). `None` when the channel's ALPN
/// registered no control surface (the op answers
/// `channel:no_control_handle`).
pub fn control_handle(&self, channel_id: u32) -> Option<ChannelControl> {
self.inner.control_handles.lock().get(&channel_id).cloned()
}
fn remove_control_handle(&self, channel_id: u32) {
self.inner.control_handles.lock().remove(&channel_id);
}
/// Install channel 0 (pre-negotiated as `alk/call`, ADR-036).
/// Channel 0 is special only in that it's pre-allocated (by
/// `channels-call`); the `ChannelsAdapter` hands the resulting
@@ -530,6 +604,7 @@ impl ChannelManager {
// Dropping `state.demux_sender` signals EOF to the
// handler's read half (REQ-CH-02).
drop(state.demux_sender);
self.remove_control_handle(channel_id);
Ok(state.handler_task)
}
None => Err(ManagerError::UnknownChannel(channel_id)),
@@ -550,6 +625,7 @@ impl ChannelManager {
task.abort();
}
}
self.inner.control_handles.lock().clear();
// The early-arrival buffers die with the connection too.
self.inner.early_arrivals.lock().clear();
// The opener ledger has the opener PeerIds.
+4
View File
@@ -31,6 +31,9 @@
//! as amended by ADR-051): translate hop + `pump_bidi` byte-forward
//! hop for from_call-imported marked specs re-exposed on a hub's
//! consumer-leg channel-0 registry.
//! - [`resource`]: `ResourceEnumerators` — the enumerator registry
//! behind `channel/resources/subscribe`'s live subscription
//! (ADR-053).
//! - [`hub_leg`]: `HubLegTemplate` — the hub-leg install template
//! (ADR-051 §5): fork + generic channel ops + bootstrap discovery +
//! plain bundles + relay openables + serving identity, composed into
@@ -53,5 +56,6 @@ pub mod policy;
pub mod pump;
pub mod reassembly;
pub mod relay;
pub mod resource;
pub mod source;
pub mod wire;
+790 -237
View File
File diff suppressed because it is too large. Load diff
+447
View File
@@ -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(&registry).expect("bootstrap discovery");
core.register_pub_openable(
pub_put_spec(),
Some(establisher),
handler,
&registry,
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
View File
@@ -35,11 +35,13 @@ use crate::registry::spec::{OperationSpec, OperationType};
use super::manager::ChannelManager;
use super::operations::{
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
ChannelCore, ChannelPlan, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
PubOpenHandler, CHANNEL_OPEN_FAILED, RESERVED_REPLY_KEY,
};
use super::pump::pump_bidi;
use super::reassembly::{MpscRecvStream, MpscSendStream};
use tokio::io::AsyncWriteExt;
/// The producer-leg surface the relay holds (ADR-051 §2): the
/// `CallConnection` for the translate hop's open call plus the
@@ -81,8 +83,17 @@ struct RelayPlan {
producer_manager: ChannelManager,
spoke_send: Option<MpscSendStream>,
spoke_recv: Option<MpscRecvStream>,
/// The put path (ADR-047 amendment 4 §4.7): the producer leg's
/// open-op response, awaited by the relay pub handler after the
/// consumer EOF (put-acks-in-result — the spoke's response cannot
/// exist before its byte stream completes, so the establisher
/// never awaits it).
pub_reply: Option<tokio::sync::oneshot::Receiver<CallConnectionResponse>>,
}
/// The producer leg's open-op response, carried on the put path's plan.
type CallConnectionResponse = crate::protocol::wire::ResponseEnvelope;
impl Drop for RelayPlan {
fn drop(&mut self) {
let _ = self.producer_manager.teardown_channel(self.spoke_id);
@@ -120,9 +131,10 @@ impl ChannelRelay {
/// produced open op.
/// - `spec` must carry the `channel_open` marker (reconstructed
/// WITH the marker by discovery, ADR-047 amendment 3). A
/// `Pub`-typed marked spec is a loud assembly error
/// ([`RelayError::PubTypedOpen`] — the C-08 blocker, ADR-051
/// §6); an unmarked spec is likewise an error — branch on the
/// `Pub`-typed marked spec composes through the put-forwarding
/// translation (see [`Self::register_relay_pub_openable`]); an
/// unmarked spec is a
/// [`RelayError::UnmarkedSpec`] error — branch on the
/// marker before calling.
/// - The translate hop calls the producer leg with the spec's own
/// op name; a hub that re-names the consumer-visible op composes
@@ -145,14 +157,12 @@ impl ChannelRelay {
});
}
if spec.op_type == OperationType::Pub {
return Err(RelayError::PubTypedOpen {
message: format!(
"spec `{}` is a Pub-typed channel-open op — the Pub open path is \
not implemented (C-08 blocker, `channel:pub_open_not_implemented` \
class; ADR-051 §6)",
spec.name
),
});
return self.register_relay_pub_openable(
consumer_core,
consumer_registry,
spec,
consumer_auth,
);
}
let producer_call = Arc::clone(&self.producer_leg.call);
@@ -227,6 +237,76 @@ impl ChannelRelay {
)
.map_err(|e| RelayError::Registration { message: e })
}
/// Register a from_call-imported **marked `Pub`** spec as a relay
/// openable — the put-forwarding translation (ADR-047 amendment 4
/// §4.7). The consumer leg runs the full marked-pub wrapper (adopt
/// → establish → pub handler awaited inline); this side's compose:
///
/// - the establisher allocates the hub's connect-side id on the
/// producer leg (`open_channel` — an odd id, disjoint from the
/// spoke's even allocations), sends the open op **in the
/// background** (the spoke's put response cannot exist before
/// its byte stream completes; an establisher that awaited it
/// deadlocks the leg), and plans the spoke-leg streams + the
/// response's oneshot; the plan's Drop guard reclaims the
/// producer-leg entry on every handler path, as ADR-051 §6 pins.
/// - the handler pumps the consumer-leg bytes → the spoke leg
/// (one direction, pub), EOFs the spoke side on consumer EOF,
/// then awaits the spoke's single response, strips the
/// producer's reserved reply key (the consumer reply carries the
/// hub-allocated consumer-leg id; a spoke `channel_id` merged
/// through would shadow it — the same reserved-key class the
/// wrapper refuses), and returns the remainder — the consumer-leg
/// wrapper merges it with the pinned order (ack fields are none
/// here; the put acks in the spoke's result, §4.5).
///
/// A spoke open-time establishment failure surfaces verbatim in
/// the final response (put-acks-in-result; the spoke's mapped
/// `channel:open_failed` rides the response, not the establisher).
fn register_relay_pub_openable(
&self,
consumer_core: &ChannelCore,
consumer_registry: &OperationRegistry,
spec: OperationSpec,
consumer_auth: AuthContext,
) -> Result<(), RelayError> {
let producer_call = Arc::clone(&self.producer_leg.call);
let producer_manager = self.producer_leg.manager.clone();
let op_name = spec.name.clone();
let establisher: OpenEstablisher = Arc::new(move |input, per_call_auth| {
let producer_call = Arc::clone(&producer_call);
let producer_manager = producer_manager.clone();
let op_name = op_name.clone();
Box::pin(async move {
open_pub_on_producer_leg(
&producer_call,
&producer_manager,
&op_name,
input,
&per_call_auth,
)
.await
})
});
let pub_open_handler: PubOpenHandler =
Arc::new(move |_input, plan, channel_conn, _auth| {
Box::pin(async move { pub_relay_forward(channel_conn, plan).await })
});
consumer_core
.register_pub_openable(
spec,
Some(establisher),
pub_open_handler,
consumer_registry,
consumer_auth,
None,
)
.map_err(|e| RelayError::Registration { message: e })
}
}
/// The translate hop (ADR-051 §1 layer 1, §2): call the producer leg's
@@ -288,6 +368,7 @@ async fn open_on_producer_leg(
producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv),
pub_reply: None,
}) as super::operations::ChannelPlan);
Ok(if reply_fields.is_empty() {
establishment
@@ -334,6 +415,123 @@ fn map_spoke_error(spoke_error: &CallError) -> EstablishmentError {
}
}
/// The put path's translate hop (ADR-047 amendment 4 §4.7): allocate
/// the hub's connect-side channel id on the producer leg, send the
/// spoke's open op **in the background** with the reserved input key
/// set, and plan the spoke-leg streams + the response oneshot. The
/// establisher returns promptly; the spoke's response arrives when the
/// spoke's put completes (or fails) — put-acks-in-result.
async fn open_pub_on_producer_leg(
producer_call: &Arc<CallConnection>,
producer_manager: &ChannelManager,
op_name: &str,
input: Value,
per_call_auth: &AuthContext,
) -> Result<Establishment, EstablishmentError> {
let (spoke_id, spoke_send, spoke_recv) = producer_manager
.open_channel(
"alk/relay",
// The opener ledger is inert on the hub's connect side
// (the connect side does not enforce the per-identity cap,
// ADR-041) — the record is observability only.
"hub-relay",
None,
)
.await
.map_err(|e| EstablishmentError::ResourceShortage {
message: format!("producer leg: channel allocation failed: {e}"),
})?;
let mut hop_input = input.clone();
if let Some(obj) = hop_input.as_object_mut() {
obj.insert(
crate::registry::spec::RESERVED_OPEN_INPUT_KEY.to_string(),
serde_json::json!(spoke_id),
);
}
let payload = build_relay_payload(op_name, hop_input, per_call_auth);
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
let producer_call_task = Arc::clone(producer_call);
tokio::spawn(async move {
let _ = reply_tx.send(producer_call_task.call_with_payload(payload).await);
});
Ok(Establishment::new(Arc::new(RelayPlan {
spoke_id,
producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv),
pub_reply: Some(reply_rx),
}) as ChannelPlan))
}
/// The relay pub handler (ADR-047 amendment 4 §4.7): pump the
/// consumer-leg bytes → the spoke leg (one direction, pub — the
/// shutdown-on-completion contract, ADR-050's), then await the spoke's
/// single response and strip its reserved reply key. The plan's Drop
/// guard reclaims the producer-leg entry on every path.
async fn pub_relay_forward(
channel_conn: crate::core::types::Connection,
plan: Option<ChannelPlan>,
) -> Result<Value, CallError> {
let Some(plan) = plan else {
return Err(CallError::internal(
"relay: pub establisher produced no plan",
));
};
let Ok(relay_plan_arc) = plan.downcast::<RelayPlan>() else {
return Err(CallError::internal(
"relay: establisher plan of unexpected type",
));
};
let Ok(mut relay_plan) = Arc::try_unwrap(relay_plan_arc) else {
return Err(CallError::internal(
"relay: establisher plan held elsewhere; skipping pump",
));
};
let Some(mut spoke_send) = relay_plan.spoke_send.take() else {
return Err(CallError::internal(
"relay: pub plan missing the spoke send half",
));
};
relay_plan.spoke_recv.take();
let Ok(bidi) = channel_conn.accept_bi().await else {
return Err(CallError::internal(
"relay: consumer leg data plane never opened",
));
};
let (mut read_half, _write_half) = tokio::io::split(bidi);
// The one-direction pump: consumer bytes → spoke; shutdown on
// consumer EOF (the pump contract, ADR-050).
let _copied = tokio::io::copy(&mut read_half, &mut spoke_send)
.await
.unwrap_or(0);
let _ = spoke_send.shutdown().await;
drop(spoke_send);
match relay_plan.pub_reply.take() {
Some(rx) => match rx.await {
Ok(response) => match response.result {
Ok(mut out) => {
if let Some(obj) = out.as_object_mut() {
obj.remove(RESERVED_REPLY_KEY);
}
Ok(out)
}
Err(e) => Err(e),
},
Err(_) => Err(CallError::internal(
"relay: producer-leg open resolved without a response",
)),
},
None => Err(CallError::internal(
"relay: pub plan missing the response receiver",
)),
}
}
/// Build the `call.requested` payload for the translate hop: the hub
/// as caller, the consumer as `forwarded_for` (ADR-026 §3, ADR-032 §3
/// — the same shape `build_forwarded_payload` produces for the plain
@@ -357,18 +555,15 @@ fn build_relay_payload(op_name: &str, input: Value, per_call_auth: &AuthContext)
/// The assembly-time errors `ChannelRelay::register_relay_openable`
/// produces (ADR-051 §6 — the rejection postures are loud, never
/// silent stubs).
/// silent stubs; the `PubTypedOpen` variant was removed by ADR-047
/// amendment 4 §4.7 — pub-typed marked specs translate now, never
/// reject-and-strand).
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RelayError {
/// The spec carries no `channel_open` marker — the relay only
/// registers marked specs.
#[error("unmarked spec: {message}")]
UnmarkedSpec { message: String },
/// A `Pub`-typed marked spec — the Pub open path is not
/// implemented (C-08 blocker; `channel:pub_open_not_implemented`
/// class).
#[error("pub-typed open op: {message}")]
PubTypedOpen { message: String },
/// The consumer-leg registration itself failed (schema compile,
/// handler-kind mismatch).
#[error("registration failed: {message}")]
+25 -11
View File
@@ -89,15 +89,21 @@ async fn unmarked_spec_is_rejected_at_registration() {
}
#[tokio::test]
async fn pub_typed_marked_spec_is_rejected_at_registration() {
async fn pub_typed_marked_spec_registers_through_the_relay() {
// ADR-047 amendment 4 §4.7: the put-forwarding translation replaced
// the `PubTypedOpen` rejection — a pub-typed marked spec composes
// through `register_pub_openable` (the full marked-pub wrapper
// machinery) and lands on the consumer-leg registry, discoverable.
let producer_manager = manager_for_test();
let relay = ChannelRelay::new(ProducerLeg {
call: overlay_connection(),
manager: manager_for_test(),
manager: producer_manager,
});
let err = relay
let registry = OperationRegistry::new();
relay
.register_relay_openable(
&core_for_test(),
&OperationRegistry::new(),
&registry,
relay_spec(
"channels/x/pub",
OperationType::Pub,
@@ -105,12 +111,10 @@ async fn pub_typed_marked_spec_is_rejected_at_registration() {
),
AuthContext::anonymous(b"alk/call"),
)
.expect_err("Pub-typed marked spec must be rejected");
assert!(
matches!(err, RelayError::PubTypedOpen { .. }),
"got {err:?}"
);
assert!(err.to_string().contains("pub_open_not_implemented"));
.expect("the pub openable registers through the relay");
assert!(registry.registration("channels/x/pub").is_some());
let registration = registry.registration("channels/x/pub").expect("registered");
assert!(registration.spec.channel_open.is_some());
}
// --- ADR-051 §3: the reason mapping --------------------------------------
@@ -228,6 +232,7 @@ async fn relay_plan_drop_reclaims_the_adopted_producer_leg_entry() {
producer_manager: producer_manager.clone(),
spoke_send: Some(spoke_send),
spoke_recv: Some(spoke_recv),
pub_reply: None,
};
drop(plan);
@@ -256,7 +261,16 @@ fn manager_for_test() -> ChannelManager {
tokio::spawn(async move {
let _ = runner.run().await;
});
ChannelManager::with_defaults(handle, None)
// The producer leg's manager belongs to the hub, which is the
// connect side of the hub↔spoke channels connection (ADR-051 §4 —
// the spoke's ids are even, the hub's odd).
ChannelManager::new(
handle,
crate::channels::manager::DEFAULT_MAX_CHANNELS,
crate::channels::reassembly::DEFAULT_BUFFER_CAP,
None,
crate::channels::manager::ChannelSide::Connect,
)
}
fn core_for_test() -> ChannelCore {
+70
View File
@@ -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()
}
}