feat: per-session fork registry, connect-side serving loop, op/register (review 004 Units 1-3)
Remediates all six findings of review 004 (per-connection dispatch resolution and client-side op serving). All claims re-verified in source before remediation; F-02's member list gains ScopedPeerEnv (also Clone — fork surface simpler than estimated). - OperationRegistry: interior mutability (parking_lot RwLock on both maps); register takes &self; registration/list_operations return owned clones; fork() deep-copies registrations + cached publish-schema validators (F-02/F-03); OperationRegistryBuilder::from_registry. - install_bootstrap_discovery: services/list, services/list-peers, services/schema registered closed over the fork itself, so per-session openables are discoverable and services/schema answers from the fork (F-06). - Dispatcher::serve_single_stream: full-duplex single-stream loop — call.requested dispatches inbound; responded/completed/error resolve outbound pendings; aborted tries both tables (in-flight sink aborts + pending cascade); published routes inbound sinks (F-04). - ChannelClient::from_connection_with_serving(connection, Option<ServingConfig>): opt-in serving; from_connection keeps the pure-consumer default. - registry::op_register: OpRegisterRequest wire DTO (spec in services/schema JSON + replace flag), op_register_spec, op_register_handler (rebuild -> forwarding stub -> register_imported, forced Internal/FromCall), announce_op; CallError::already_exists; spec_to_json_pub; from_call's rebuild_spec_for + forwarding-handler constructors crate-shared (F-05). - ADR-047 §4 amendment #2: per-session fork is the dispatch-registry mechanism; overlay stays nested-invocation/peer-announced landing zone (F-01/F-02). - ADR-022 amendment 2026-09-03: bootstrap-op set (services/list, services/schema, op/register), opt-in connect-side serving, op/register wire shape (F-04/F-05). - alkhttp ADR-048 reconciliation note + OQ-05 re-pointed at the alkcall ADRs (Unit 1b). - Review 004 status -> remediated; remediation log with gates. Verification: - cargo test: 581 passed, 0 failed (565 baseline + 16 new) - cargo test --all-features: 598 passed, 0 failed - cargo clippy --all-targets -- -D warnings: clean - cargo clippy --all-features --all-targets -- -D warnings: clean - cargo clippy --target wasm32-unknown-unknown -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: clean Gates: fork_registry_open_op_resolves_and_is_discoverable (open op via fork + services/list shows openable + services/schema validates), serving_loop_hub_to_consumer_call_resolves (hub->consumer call through consumer's serving loop, consumer->hub still resolves), op_register_announce_then_hub_call_routes_back_to_consumer (announce -> overlay -> hub call -> forwarding stub -> consumer serves).
This commit is contained in:
1 parent
c0dbf82518
commit
f84d214173
18 files changed
+2024
-138
No files matched your search
+568
-23
@@ -26,6 +26,7 @@ use tokio::sync::Mutex;
|
||||
use crate::core::types::{Connection, StreamError};
|
||||
use crate::protocol::connection::CallConnection;
|
||||
use crate::protocol::wire::ResponseEnvelope;
|
||||
use crate::registry::registration::OperationRegistry;
|
||||
|
||||
use super::manager::{ChannelManager, ChannelSide};
|
||||
use super::mux::MuxRunner;
|
||||
@@ -43,7 +44,20 @@ use super::reassembly::{MpscRecvStream, MpscSendStream};
|
||||
/// (`channels/<alpn>/sub`, `channels/<alpn>/pub`) on channel 0.
|
||||
pub struct ChannelClient {
|
||||
manager: ChannelManager,
|
||||
call_connection: Arc<Mutex<Option<CallConnection>>>,
|
||||
call_connection: Arc<Mutex<Option<Arc<CallConnection>>>>,
|
||||
}
|
||||
|
||||
/// Opt-in serving configuration for a `ChannelClient` (review 004
|
||||
/// F-04). A pure consumer has no registry to serve — `from_connection`
|
||||
/// keeps the resolution-only read pump. A consumer that also serves
|
||||
/// ops passes its registry (and identity provider) via
|
||||
/// [`ServingConfig`]; the read pump becomes the full-duplex serving
|
||||
/// loop (`Dispatcher::serve_single_stream`), so inbound
|
||||
/// `call.requested` frames from the peer dispatch and resolve instead
|
||||
/// of being dropped.
|
||||
pub struct ServingConfig {
|
||||
pub registry: Arc<OperationRegistry>,
|
||||
pub identity_provider: Arc<dyn crate::core::auth::IdentityProvider>,
|
||||
}
|
||||
|
||||
impl ChannelClient {
|
||||
@@ -60,7 +74,21 @@ impl ChannelClient {
|
||||
/// the `PendingRequestMap` via `dispatch_envelope`, resolving
|
||||
/// pending calls. The `CallConnection` holds the framed write
|
||||
/// half so `call_open_op` can write `call.requested` frames.
|
||||
pub async fn from_connection(connection: Connection) -> Result<Self, StreamError> {
|
||||
///
|
||||
/// Serving (review 004 F-04): with `serving: None` (the default)
|
||||
/// the read pump resolves responses only — inbound
|
||||
/// `call.requested` frames are dropped (a pure consumer has no
|
||||
/// registry to serve). With `Some(ServingConfig { .. })` the read
|
||||
/// pump is the full-duplex serving loop
|
||||
/// (`Dispatcher::serve_single_stream`): inbound requests dispatch
|
||||
/// against the configured registry and resolve back to the peer,
|
||||
/// and outbound pendings still resolve. This is the opt-in that
|
||||
/// makes the connect side a serving half on channel 0 (ADR-022 §2
|
||||
/// — both sides can be both).
|
||||
pub async fn from_connection_with_serving(
|
||||
connection: Connection,
|
||||
serving: Option<ServingConfig>,
|
||||
) -> Result<Self, StreamError> {
|
||||
let remote_addr = connection.remote_addr();
|
||||
let bidi = connection.accept_bi().await?;
|
||||
let (reader, writer) = tokio::io::split(bidi);
|
||||
@@ -98,17 +126,39 @@ impl ChannelClient {
|
||||
let (single_stream_writer, single_stream_reader) =
|
||||
crate::protocol::connection::split_single_stream(channel0_bidi);
|
||||
|
||||
let call_connection =
|
||||
CallConnection::new_single_stream(channel0_conn, single_stream_writer);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&single_stream_writer),
|
||||
));
|
||||
|
||||
let pending_map = Arc::clone(call_connection.pending());
|
||||
let _read_pump = tokio::spawn(async move {
|
||||
crate::protocol::connection::read_single_stream_until_closed(
|
||||
single_stream_reader,
|
||||
&pending_map,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
match serving {
|
||||
None => {
|
||||
let _read_pump = tokio::spawn(async move {
|
||||
crate::protocol::connection::read_single_stream_until_closed(
|
||||
single_stream_reader,
|
||||
&pending_map,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
Some(config) => {
|
||||
let dispatcher = crate::protocol::dispatch::Dispatcher::new(
|
||||
config.registry,
|
||||
config.identity_provider,
|
||||
);
|
||||
let call_conn_for_loop = Arc::clone(&call_connection);
|
||||
let _serve_loop = tokio::spawn(async move {
|
||||
dispatcher
|
||||
.serve_single_stream(
|
||||
call_conn_for_loop,
|
||||
single_stream_reader,
|
||||
single_stream_writer,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The demux loop ends on transport EOF, clearing the channel
|
||||
// map (REQ-CH-02). The connect side passes `policy: None` (it
|
||||
@@ -129,6 +179,13 @@ impl ChannelClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct from an established `Connection` with serving
|
||||
/// disabled (the pure-consumer default). See
|
||||
/// [`ChannelClient::from_connection_with_serving`].
|
||||
pub async fn from_connection(connection: Connection) -> Result<Self, StreamError> {
|
||||
Self::from_connection_with_serving(connection, None).await
|
||||
}
|
||||
|
||||
/// The `ChannelManager` — for relay logic and tests.
|
||||
pub fn manager(&self) -> &ChannelManager {
|
||||
&self.manager
|
||||
@@ -193,8 +250,11 @@ impl ChannelClient {
|
||||
/// 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
|
||||
/// by the consumer).
|
||||
pub async fn take_call_connection(&self) -> Option<CallConnection> {
|
||||
/// by the consumer). The `Arc` is shared with the serving loop when
|
||||
/// serving is enabled; taking it detaches the client's own calling
|
||||
/// surface, not the serving loop's dispatch (the loop holds its own
|
||||
/// `Arc` clone).
|
||||
pub async fn take_call_connection(&self) -> Option<Arc<CallConnection>> {
|
||||
self.call_connection.lock().await.take()
|
||||
}
|
||||
}
|
||||
@@ -329,7 +389,7 @@ mod tests {
|
||||
/// on the accept side).
|
||||
#[tokio::test]
|
||||
async fn channel_0_end_to_end_call_round_trip() {
|
||||
let mut registry = crate::registry::registration::OperationRegistry::new();
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("echo/run"),
|
||||
@@ -428,7 +488,7 @@ mod tests {
|
||||
async fn channel_0_end_to_end_publish_delivers_chunks() {
|
||||
use futures::stream::StreamExt;
|
||||
|
||||
let mut registry = crate::registry::registration::OperationRegistry::new();
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
let counting_sink = crate::registry::registration::make_sink_handler(
|
||||
|_input, ctx, mut stream| async move {
|
||||
let mut count = 0u32;
|
||||
@@ -559,7 +619,7 @@ mod tests {
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "ok": oks, "err": err }))
|
||||
},
|
||||
);
|
||||
let mut registry = crate::registry::registration::OperationRegistry::new();
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
@@ -667,7 +727,7 @@ mod tests {
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "ok": oks, "err": err }))
|
||||
},
|
||||
);
|
||||
let mut registry = crate::registry::registration::OperationRegistry::new();
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
@@ -797,7 +857,7 @@ mod tests {
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
|
||||
let core = ChannelCore::new(manager, policy);
|
||||
let mut registry = crate::registry::registration::OperationRegistry::new();
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
let spec = OperationSpec::new(
|
||||
"channels/tty/sub",
|
||||
OperationType::Sub,
|
||||
@@ -823,7 +883,7 @@ mod tests {
|
||||
core.register_openable(
|
||||
spec,
|
||||
Arc::clone(&open_handler),
|
||||
&mut registry,
|
||||
®istry,
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("register_openable");
|
||||
@@ -1043,8 +1103,8 @@ mod tests {
|
||||
});
|
||||
let manager = ChannelManager::with_defaults(handle, None);
|
||||
let ops = ChannelOperations::new(manager, Arc::new(NoCap));
|
||||
let mut registry = crate::registry::registration::OperationRegistry::new();
|
||||
ops.register_on(&mut registry).expect("register");
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
ops.register_on(®istry).expect("register");
|
||||
|
||||
let handler = registry
|
||||
.registration("channel/close")
|
||||
@@ -1117,7 +1177,7 @@ mod tests {
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let core = ChannelCore::new(manager, Arc::new(NoCap));
|
||||
let mut registry = crate::registry::registration::OperationRegistry::new();
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
let spec = OperationSpec::new(
|
||||
"channels/tty/sub",
|
||||
OperationType::Sub,
|
||||
@@ -1139,7 +1199,7 @@ mod tests {
|
||||
core.register_openable(
|
||||
spec,
|
||||
Arc::clone(&open_handler),
|
||||
&mut registry,
|
||||
®istry,
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("register_openable");
|
||||
@@ -1195,4 +1255,489 @@ mod tests {
|
||||
"handler received ping through adopted channel"
|
||||
);
|
||||
}
|
||||
|
||||
// --- review 004 Unit 3 acceptance gates (F-04 serving half) -----------
|
||||
|
||||
/// F-04 gate 1: hub→consumer call over an existing `ChannelClient`
|
||||
/// session resolves. The consumer (connect side) dials with
|
||||
/// `Some(ServingConfig { .. })`; the accept side's dispatcher later
|
||||
/// calls an op the consumer serves (by opening a fresh stream from
|
||||
/// its side is not available on channel 0 — the accept side writes
|
||||
/// a `call.requested` through its channel-0 writer, and the
|
||||
/// consumer's serving loop dispatches it and writes the response,
|
||||
/// which resolves on the accept side's pending).
|
||||
///
|
||||
/// Mechanically: the accept side's `install_channel_zero` hook
|
||||
/// returns a clone of the channel-0 `CallConnection` back to the
|
||||
/// test via a channel; the test then calls
|
||||
/// `call_open_op("consumer/echo")` — wait, that calls *through* the
|
||||
/// accept side's registry. The consumer-serving direction needs the
|
||||
/// accept side to *initiate*: it writes `call.requested` for
|
||||
/// `consumer/echo` on channel 0. The client's serving loop (with
|
||||
/// the `consumer/echo` op in its registry) dispatches it and writes
|
||||
/// the response, resolving the accept side's pending.
|
||||
#[tokio::test]
|
||||
async fn serving_loop_hub_to_consumer_call_resolves() {
|
||||
// Consumer-side (connect side) registry: serves `consumer/echo`.
|
||||
let consumer_registry = crate::registry::registration::OperationRegistry::new();
|
||||
consumer_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("consumer/echo"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let consumer_registry = Arc::new(consumer_registry);
|
||||
|
||||
// Accept side: a plain echo registry plus a handle back to the
|
||||
// accept side's channel-0 `CallConnection` so the test can
|
||||
// initiate a call *from* the accept side.
|
||||
let (accept_conn_tx, mut accept_conn_rx) =
|
||||
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||
let accept_registry = crate::registry::registration::OperationRegistry::new();
|
||||
accept_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("accept/echo"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let accept_registry = Arc::new(accept_registry);
|
||||
|
||||
let install_hook: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |manager, channel0_conn, _auth| {
|
||||
let accept_registry = Arc::clone(&accept_registry);
|
||||
let accept_conn_tx = accept_conn_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = manager;
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
|
||||
let dp = Dispatcher::new(
|
||||
accept_registry,
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
);
|
||||
// The serving loop on the accept side too: the
|
||||
// consumer may call back over the same session.
|
||||
dp.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
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 server_conn =
|
||||
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _server_handle = tokio::spawn(async move {
|
||||
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
|
||||
let client = ChannelClient::from_connection_with_serving(
|
||||
client_conn,
|
||||
Some(ServingConfig {
|
||||
registry: Arc::clone(&consumer_registry),
|
||||
identity_provider: Arc::new(NoopIdProvider),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("channel client init");
|
||||
|
||||
let accept_conn = accept_conn_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("accept side channel-0 connection handle");
|
||||
|
||||
// Accept side (hub) initiates a call to an op the consumer
|
||||
// serves — the exact direction F-04 says was dropped.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
accept_conn.call("consumer/echo", serde_json::json!({ "from": "hub" })),
|
||||
)
|
||||
.await
|
||||
.expect("hub→consumer call timed out");
|
||||
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"hub→consumer call resolves through the consumer's serving loop, got {:?}",
|
||||
response.result
|
||||
);
|
||||
assert_eq!(
|
||||
response.result.unwrap(),
|
||||
serde_json::json!({ "from": "hub" })
|
||||
);
|
||||
|
||||
// The consumer's own calling half still works (outbound pendings
|
||||
// resolve in the same loop).
|
||||
let outbound = client
|
||||
.call_open_op("accept/echo", serde_json::json!({ "to": "hub" }))
|
||||
.await;
|
||||
assert!(
|
||||
outbound.result.is_ok(),
|
||||
"consumer→hub call still resolves, got {:?}",
|
||||
outbound.result
|
||||
);
|
||||
}
|
||||
|
||||
// --- review 004 Unit 3 acceptance gates (F-05 op/register) ------------
|
||||
|
||||
/// F-05 gate: the consumer announces an op over channel 0
|
||||
/// (`op/register` served on the accept side's fork), the hub
|
||||
/// registers it in the connection overlay, and the hub then calls
|
||||
/// it back — the forwarding stub issues a nested `call.requested`
|
||||
/// over channel 0 to the consumer, whose serving loop dispatches
|
||||
/// the real handler. Peer-announced op, discoverable and callable,
|
||||
/// end-to-end.
|
||||
#[tokio::test]
|
||||
async fn op_register_announce_then_hub_call_routes_back_to_consumer() {
|
||||
// Consumer side: serves `consumer/exec` locally; announces it.
|
||||
let consumer_registry = crate::registry::registration::OperationRegistry::new();
|
||||
consumer_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_query_spec("consumer/exec"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
serde_json::json!({ "ran_on": "consumer", "input": input }),
|
||||
)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let consumer_registry = Arc::new(consumer_registry);
|
||||
|
||||
// Accept side: serves the `op/register` bootstrap op composed on
|
||||
// a fork (the F-02(a) shape — the fork is fresh here, so the
|
||||
// bootstrap set is just `op/register`).
|
||||
let (accept_conn_tx, mut accept_conn_rx) =
|
||||
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||
let accept_registry_for_hook =
|
||||
Arc::new(crate::registry::registration::OperationRegistry::new());
|
||||
let accept_tx_for_hook = accept_conn_tx.clone();
|
||||
let install_hook: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |_manager, channel0_conn, _auth| {
|
||||
let accept_registry = Arc::clone(&accept_registry_for_hook);
|
||||
let accept_conn_tx = accept_tx_for_hook.clone();
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
|
||||
|
||||
let reg = accept_registry.fork();
|
||||
reg.register(HandlerRegistration::new(
|
||||
crate::registry::op_register::op_register_spec(AccessControl::default()),
|
||||
HandlerKind::Once(crate::registry::op_register::op_register_handler(
|
||||
Arc::clone(&call_connection),
|
||||
)),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dp = Dispatcher::new(
|
||||
Arc::new(reg),
|
||||
Arc::new(NoopIdProvider) as Arc<dyn IdentityProvider>,
|
||||
);
|
||||
dp.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
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 server_conn =
|
||||
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _server_handle = tokio::spawn(async move {
|
||||
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
|
||||
let client = ChannelClient::from_connection_with_serving(
|
||||
client_conn,
|
||||
Some(ServingConfig {
|
||||
registry: Arc::clone(&consumer_registry),
|
||||
identity_provider: Arc::new(NoopIdProvider),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("channel client init");
|
||||
|
||||
let accept_conn = accept_conn_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("accept side channel-0 connection handle");
|
||||
|
||||
// Consumer announces `consumer/exec` over channel 0.
|
||||
let announce = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.call_open_op(
|
||||
crate::registry::op_register::OP_REGISTER_NAME,
|
||||
crate::registry::op_register::OpRegisterRequest {
|
||||
spec: external_query_spec("consumer/exec"),
|
||||
replace: false,
|
||||
}
|
||||
.to_json(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("op/register announce timed out");
|
||||
assert!(
|
||||
announce.result.is_ok(),
|
||||
"announce ok, got {:?}",
|
||||
announce.result
|
||||
);
|
||||
|
||||
// The hub's overlay now holds the announced op; the hub calls
|
||||
// it — the forwarding stub routes the nested call back over
|
||||
// channel 0 to the consumer's serving loop.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
accept_conn.call("consumer/exec", serde_json::json!({ "task": "greet" })),
|
||||
)
|
||||
.await
|
||||
.expect("hub call to peer-announced op timed out");
|
||||
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"hub→peer-announced-op call resolves, got {:?}",
|
||||
response.result
|
||||
);
|
||||
assert_eq!(
|
||||
response.result.unwrap(),
|
||||
serde_json::json!({ "ran_on": "consumer", "input": { "task": "greet" } })
|
||||
);
|
||||
}
|
||||
|
||||
// --- review 004 Unit 2 acceptance gate (F-02/F-06 fork) ----------------
|
||||
|
||||
/// F-02/F-06 gate: the open op is registered on a **fork** of the
|
||||
/// base registry (base carries a placeholder op; the fork adds the
|
||||
/// openable + bootstrap discovery via
|
||||
/// `install_bootstrap_discovery`), the session dispatches over the
|
||||
/// fork, and — after the open op resolves over the live channels
|
||||
/// connection — the per-session openable is discoverable through
|
||||
/// `services/list` **on the fork** (the F-06 self-referential
|
||||
/// closure) and `services/schema` on the fork still validates.
|
||||
#[tokio::test]
|
||||
async fn fork_registry_open_op_resolves_and_is_discoverable() {
|
||||
use crate::channels::operations::{ChannelCore, OpenHandler};
|
||||
use crate::channels::policy::PerIdentityChannelPolicy;
|
||||
use crate::registry::spec::ChannelOpenSpec;
|
||||
|
||||
// Base registry: a plain op + bootstrap discovery closed over
|
||||
// the *base* (which will NOT see per-session openables — that
|
||||
// is the F-06 finding; the fork's own discovery does).
|
||||
let base = crate::registry::registration::OperationRegistry::new();
|
||||
base.register(HandlerRegistration::new(
|
||||
external_query_spec("base/ping"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let policy: Arc<PerIdentityChannelPolicy> = Arc::new(PerIdentityChannelPolicy::new(256));
|
||||
let policy_for_hook: Arc<dyn ChannelLifecyclePolicy> =
|
||||
Arc::clone(&policy) as Arc<dyn ChannelLifecyclePolicy>;
|
||||
|
||||
let open_handler: OpenHandler = Arc::new(|_input, _channel_conn, _auth| {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
})
|
||||
});
|
||||
|
||||
let install_hook: crate::channels::adapter::InstallChannelZero =
|
||||
Arc::new(move |manager, channel0_conn, auth| {
|
||||
let open_handler = Arc::clone(&open_handler);
|
||||
let policy = Arc::clone(&policy_for_hook);
|
||||
let base = Arc::new(crate::registry::registration::OperationRegistry::new());
|
||||
base.register(HandlerRegistration::new(
|
||||
external_query_spec("base/ping"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
crate::core::types::Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let base = Arc::clone(&base);
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
|
||||
let core = ChannelCore::new(manager, policy);
|
||||
// The F-02(a) composition: fork the base, register
|
||||
// the openable on the fork, install bootstrap
|
||||
// discovery on the fork (F-06 — closed over the
|
||||
// fork), dispatch over the fork.
|
||||
let fork = base.fork();
|
||||
core.register_openable(
|
||||
OperationSpec::new(
|
||||
"channels/tty/sub",
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"container": { "type": "string" }
|
||||
},
|
||||
"required": ["container"]
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/tty")),
|
||||
Arc::clone(&open_handler),
|
||||
&fork,
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("register_openable on fork");
|
||||
let fork = Arc::new(fork);
|
||||
crate::registry::discovery::install_bootstrap_discovery(&fork)
|
||||
.expect("bootstrap discovery on fork");
|
||||
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
let dp = Dispatcher::new(fork, provider);
|
||||
dp.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
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 server_conn =
|
||||
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _server_handle = tokio::spawn(async move {
|
||||
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
|
||||
let client = ChannelClient::from_connection(client_conn)
|
||||
.await
|
||||
.expect("channel client init");
|
||||
|
||||
// 1. The open op resolves over the live connection through the fork.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.call_open_op(
|
||||
"channels/tty/sub",
|
||||
serde_json::json!({ "container": "abc" }),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("open op timed out");
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"open op resolves through the fork, got {:?}",
|
||||
response.result
|
||||
);
|
||||
let channel_id = response
|
||||
.result
|
||||
.unwrap()
|
||||
.get("channel_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.expect("channel_id in response");
|
||||
assert!(channel_id > 0);
|
||||
|
||||
// 2. The per-session openable is discoverable via services/list
|
||||
// (F-06) — the fork's own discovery closure sees the fork's ops.
|
||||
let listing = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.call_open_op("services/list", serde_json::json!({})),
|
||||
)
|
||||
.await
|
||||
.expect("services/list timed out");
|
||||
assert!(
|
||||
listing.result.is_ok(),
|
||||
"services/list resolves, got {:?}",
|
||||
listing.result
|
||||
);
|
||||
let names: Vec<String> = listing
|
||||
.result
|
||||
.unwrap()
|
||||
.get("operations")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("operations array")
|
||||
.iter()
|
||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str().map(String::from)))
|
||||
.collect();
|
||||
assert!(
|
||||
names.contains(&"channels/tty/sub".to_string()),
|
||||
"per-session openable discoverable through the fork's discovery: {names:?}"
|
||||
);
|
||||
|
||||
// 3. services/schema on the fork still validates input (the
|
||||
// bootstrap op is on the fork and answers).
|
||||
let schema = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.call_open_op(
|
||||
"services/schema",
|
||||
serde_json::json!({ "name": "channels/tty/sub" }),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("services/schema timed out");
|
||||
assert!(
|
||||
schema.result.is_ok(),
|
||||
"services/schema resolves on the fork, got {:?}",
|
||||
schema.result
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -62,7 +62,7 @@ impl ChannelOperations {
|
||||
/// Register the three generic ops on the call `OperationRegistry`.
|
||||
/// The per-ALPN open ops are registered separately by the ALPN
|
||||
/// crates via [`ChannelCore::register_openable`] (ADR-047 §3).
|
||||
pub fn register_on(&self, registry: &mut OperationRegistry) -> Result<(), String> {
|
||||
pub fn register_on(&self, registry: &OperationRegistry) -> Result<(), String> {
|
||||
let manager = self.manager.clone();
|
||||
let policy = Arc::clone(&self.policy);
|
||||
registry.register(HandlerRegistration::new(
|
||||
@@ -400,7 +400,7 @@ impl ChannelCore {
|
||||
&self,
|
||||
spec: OperationSpec,
|
||||
open_handler: OpenHandler,
|
||||
registry: &mut OperationRegistry,
|
||||
registry: &OperationRegistry,
|
||||
auth: AuthContext,
|
||||
) -> Result<(), String> {
|
||||
let op_type = spec.op_type;
|
||||
@@ -765,8 +765,8 @@ mod tests {
|
||||
async fn register_on_registers_three_ops() {
|
||||
let manager = make_manager().await;
|
||||
let ops = ChannelOperations::with_default_policy(manager);
|
||||
let mut registry = OperationRegistry::new();
|
||||
ops.register_on(&mut registry).expect("register");
|
||||
let registry = OperationRegistry::new();
|
||||
ops.register_on(®istry).expect("register");
|
||||
assert!(registry.registration(OP_CHANNEL_CLOSE).is_some());
|
||||
assert!(registry.registration(OP_CHANNEL_CONTROL).is_some());
|
||||
assert!(registry
|
||||
@@ -899,8 +899,8 @@ mod tests {
|
||||
let manager = make_manager().await;
|
||||
let policy = super::super::policy::default_policy();
|
||||
let ops = ChannelOperations::new(manager, policy);
|
||||
let mut registry = OperationRegistry::new();
|
||||
ops.register_on(&mut registry).expect("register");
|
||||
let registry = OperationRegistry::new();
|
||||
ops.register_on(®istry).expect("register");
|
||||
assert!(registry.registration(OP_CHANNEL_CLOSE).is_some());
|
||||
}
|
||||
|
||||
@@ -1129,11 +1129,11 @@ mod tests {
|
||||
spawned_clone.store(true, Ordering::SeqCst);
|
||||
tokio::spawn(async {})
|
||||
});
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
core.register_openable(
|
||||
spec,
|
||||
open_handler,
|
||||
&mut registry,
|
||||
®istry,
|
||||
AuthContext::anonymous(b"alk/call"),
|
||||
)
|
||||
.expect("register");
|
||||
@@ -1172,11 +1172,11 @@ mod tests {
|
||||
spawned_clone.store(true, Ordering::SeqCst);
|
||||
tokio::spawn(async {})
|
||||
});
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
core.register_openable(
|
||||
spec,
|
||||
open_handler,
|
||||
&mut registry,
|
||||
®istry,
|
||||
AuthContext::anonymous(b"alk/call"),
|
||||
)
|
||||
.expect("register");
|
||||
@@ -1209,11 +1209,11 @@ mod tests {
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/tty"));
|
||||
let open_handler: OpenHandler = Arc::new(|_input, _conn, _auth| tokio::spawn(async {}));
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
core.register_openable(
|
||||
spec,
|
||||
open_handler,
|
||||
&mut registry,
|
||||
®istry,
|
||||
AuthContext::anonymous(b"alk/call"),
|
||||
)
|
||||
.expect("register");
|
||||
@@ -1239,11 +1239,11 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
let open_handler: OpenHandler = Arc::new(|_input, _conn, _auth| tokio::spawn(async {}));
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
let result = core.register_openable(
|
||||
spec,
|
||||
open_handler,
|
||||
&mut registry,
|
||||
®istry,
|
||||
AuthContext::anonymous(b"alk/call"),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
Reference in new issue
Block a user