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
+330
-11
@@ -32,7 +32,7 @@ use super::abort::AbortCascade;
|
||||
use super::connection::CallConnection;
|
||||
use super::wire::{
|
||||
CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, ResponseEnvelope,
|
||||
EVENT_ABORTED, EVENT_COMPLETED, EVENT_ERROR, EVENT_PUBLISHED, EVENT_REQUESTED,
|
||||
EVENT_ABORTED, EVENT_COMPLETED, EVENT_ERROR, EVENT_PUBLISHED, EVENT_REQUESTED, EVENT_RESPONDED,
|
||||
};
|
||||
use crate::protocol::adapter::SessionOverlaySource;
|
||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
@@ -932,6 +932,229 @@ impl Dispatcher {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full-duplex single-stream serving loop (review 004 F-04):
|
||||
/// composes the dispatch arms (`run_loop_single_stream`) and the
|
||||
/// pending-resolution arms (`read_single_stream_until_closed`) in
|
||||
/// one loop. Both sides of a channels connection can be both
|
||||
/// producer and consumer (AGENTS.md §8; ADR-022 §2) — on channel 0
|
||||
/// the two directions' frames are multiplexed on one byte stream,
|
||||
/// so the loop branches per frame:
|
||||
///
|
||||
/// - `call.requested` → dispatch against this loop's registry and
|
||||
/// write the response frame(s) (the serving half — what the
|
||||
/// accept side's `run_loop_single_stream` does).
|
||||
/// - `call.responded` / `call.completed` / `call.error` → resolve
|
||||
/// the matching outbound pending entry (the calling half — what
|
||||
/// the client read pump does). `call.responded` frames for
|
||||
/// *inbound* Sub responses are `call.responded` too; direction is
|
||||
/// disambiguated by the pending map (an id that is one of *our*
|
||||
/// outbound pendings resolves there; an unknown id is dropped
|
||||
/// with a debug line, never an error — the peer may legitimately
|
||||
/// stream a Sub's responses that this side does not track).
|
||||
/// - `call.aborted` → try both tables: the in-flight sink aborts
|
||||
/// *and* the outbound pending map's abort-cascade
|
||||
/// (`handle_abort`), then fall through to serving-side sink
|
||||
/// cancellation (the `run_loop_single_stream` arm) when the id
|
||||
/// matches an inbound sink. An id in neither table is a no-op.
|
||||
/// - `call.published` / `call.completed` (initiator→responder) →
|
||||
/// route to the matching inbound in-flight sink; ids that are
|
||||
/// outbound pending *call* entries whose `call.responded` already
|
||||
/// removed them are no-ops.
|
||||
///
|
||||
/// IDs are UUID-generated per side (`generate_request_id()`), so
|
||||
/// cross-correlation between the two directions is not a hazard.
|
||||
///
|
||||
/// On read-half close, outbound pendings are failed (`connection
|
||||
/// closed`) and in-flight inbound sinks are dropped — the same
|
||||
/// teardown both arms perform separately today.
|
||||
pub async fn serve_single_stream(
|
||||
self,
|
||||
connection: Arc<CallConnection>,
|
||||
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
|
||||
writer: Arc<super::connection::SharedFrameWriter>,
|
||||
) {
|
||||
let pending = Arc::clone(connection.pending());
|
||||
|
||||
let sweeper_pending = Arc::clone(&pending);
|
||||
let sweeper_handle: JoinHandle<()> = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(SWEEPER_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let evicted = sweeper_pending.lock().evict_expired();
|
||||
if !evicted.is_empty() {
|
||||
debug!(
|
||||
count = evicted.len(),
|
||||
"serve loop: sweeper evicted expired pending entries"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut reader = FrameFramedReader::new(reader);
|
||||
let mut in_flight_sinks: HashMap<String, InFlightSink> = HashMap::new();
|
||||
|
||||
loop {
|
||||
let envelope = match reader.read_frame().await {
|
||||
Ok(env) => env,
|
||||
Err(super::wire::FrameError::ConnectionClosed) => break,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "serve loop: frame read error; closing loop");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match envelope.r#type.as_str() {
|
||||
EVENT_REQUESTED => {
|
||||
let request_id = envelope.id.clone();
|
||||
let payload = envelope.payload.clone();
|
||||
let dispatch_result = self
|
||||
.dispatch(&connection, request_id.clone(), payload)
|
||||
.await;
|
||||
|
||||
match dispatch_result {
|
||||
DispatchResult::Once(response) => {
|
||||
let event: EventEnvelope = response.into();
|
||||
if let Err(err) = writer.write_frame(&event).await {
|
||||
warn!(
|
||||
error = %err,
|
||||
"serve loop: failed to write Once response; closing loop"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
DispatchResult::Stream(stream) => {
|
||||
self.pump_stream_single_stream(&writer, &request_id, stream)
|
||||
.await;
|
||||
}
|
||||
DispatchResult::Sink(sink) => {
|
||||
let SinkDispatch {
|
||||
handler,
|
||||
chunk_tx,
|
||||
publish_validator,
|
||||
} = sink;
|
||||
let writer_clone = Arc::clone(&writer);
|
||||
let request_id_for_handler = request_id.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let response = handler.await;
|
||||
let event: EventEnvelope = response.into();
|
||||
if let Err(err) = writer_clone.write_frame(&event).await {
|
||||
warn!(
|
||||
error = %err,
|
||||
request_id = %request_id_for_handler,
|
||||
"serve loop: failed to write sink response frame"
|
||||
);
|
||||
}
|
||||
});
|
||||
in_flight_sinks.insert(
|
||||
request_id.clone(),
|
||||
InFlightSink {
|
||||
chunk_tx,
|
||||
publish_validator,
|
||||
handler_handle: handle,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
EVENT_RESPONDED => {
|
||||
let request_id = envelope.id.clone();
|
||||
let output = envelope
|
||||
.payload
|
||||
.get("output")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
pending.lock().handle_responded(&request_id, output);
|
||||
}
|
||||
EVENT_COMPLETED => {
|
||||
let request_id = envelope.id.clone();
|
||||
if in_flight_sinks.remove(&request_id).is_none() {
|
||||
pending.lock().handle_completed(&request_id);
|
||||
}
|
||||
}
|
||||
EVENT_ABORTED => {
|
||||
let request_id = envelope.id.clone();
|
||||
if let Some(mut entry) = in_flight_sinks.remove(&request_id) {
|
||||
entry.handler_handle.abort();
|
||||
let _ = entry
|
||||
.chunk_tx
|
||||
.send(Err(CallError::internal("publish aborted by initiator")))
|
||||
.await;
|
||||
} else {
|
||||
self.handle_abort(&connection, &request_id).await;
|
||||
}
|
||||
}
|
||||
EVENT_PUBLISHED => {
|
||||
let request_id = envelope.id.clone();
|
||||
let chunk = envelope
|
||||
.payload
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
if let Some(mut entry) = in_flight_sinks.remove(&request_id) {
|
||||
let validated = match &entry.publish_validator {
|
||||
Some(validator) => {
|
||||
if validator.is_valid(&chunk) {
|
||||
Ok(chunk)
|
||||
} else {
|
||||
let details = serde_json::json!({ "chunk": chunk });
|
||||
Err(CallError::invalid_input(
|
||||
"published chunk failed publish_schema validation",
|
||||
)
|
||||
.with_details(details))
|
||||
}
|
||||
}
|
||||
None => Ok(chunk),
|
||||
};
|
||||
let keep = validated.is_ok();
|
||||
let _ = entry.chunk_tx.send(validated).await;
|
||||
if keep {
|
||||
in_flight_sinks.insert(request_id, entry);
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
request_id = %request_id,
|
||||
"serve loop: call.published for unknown in-flight sink; dropping"
|
||||
);
|
||||
}
|
||||
}
|
||||
EVENT_ERROR => {
|
||||
let request_id = envelope.id.clone();
|
||||
let call_error: CallError = serde_json::from_value(envelope.payload)
|
||||
.unwrap_or_else(|_| {
|
||||
CallError::internal("publish error from initiator (malformed)")
|
||||
});
|
||||
if let Some(mut entry) = in_flight_sinks.remove(&request_id) {
|
||||
let _ = entry.chunk_tx.send(Err(call_error)).await;
|
||||
} else {
|
||||
pending.lock().handle_error(&request_id, call_error);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
debug!(
|
||||
event_type = %other,
|
||||
id = %envelope.id,
|
||||
"serve loop: ignoring unknown event type"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
in_flight_sinks.clear();
|
||||
|
||||
let failed = pending
|
||||
.lock()
|
||||
.fail_all(CallError::internal("connection closed"));
|
||||
if !failed.is_empty() {
|
||||
debug!(
|
||||
count = failed.len(),
|
||||
"serve loop: failed pending requests on connection close"
|
||||
);
|
||||
}
|
||||
|
||||
sweeper_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Dispatcher {
|
||||
@@ -1014,7 +1237,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
@@ -1049,7 +1272,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn dispatch_authorized_peer_dispatches_and_populates_capabilities() {
|
||||
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
let handler = make_handler(|_input, context| async move {
|
||||
let has_google = context.capabilities.get("google").is_some();
|
||||
ResponseEnvelope::ok(
|
||||
@@ -1086,7 +1309,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn dispatch_unauthorized_peer_returns_forbidden_capabilities_never_populated() {
|
||||
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
let handler = make_handler(|_input, context| async move {
|
||||
let has_google = context.capabilities.get("google").is_some();
|
||||
ResponseEnvelope::ok(
|
||||
@@ -1211,7 +1434,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_extract_forwarded_for_from_payload_into_context() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
let handler = make_handler(|_input, context| async move {
|
||||
let forwarded_id = context.forwarded_for.as_ref().map(|i| i.id.clone());
|
||||
ResponseEnvelope::ok(
|
||||
@@ -1252,7 +1475,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_without_forwarded_for_field_is_none() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
let handler = make_handler(|_input, context| async move {
|
||||
let present = context.forwarded_for.is_some();
|
||||
ResponseEnvelope::ok(
|
||||
@@ -1342,7 +1565,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_requested_overlay_only_attaches_peer_keyed_by_stored_identity() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
let handler = make_handler(|_input, context| async move {
|
||||
let peer_ids = context.env.peer_ids();
|
||||
ResponseEnvelope::ok(
|
||||
@@ -1532,7 +1755,7 @@ mod tests {
|
||||
name: &str,
|
||||
handler: crate::registry::registration::StreamingHandler,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
subscription_spec(name, AccessControl::default()),
|
||||
@@ -1611,7 +1834,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_query_keeps_deadline_some() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
let handler = make_handler(|_input, ctx| async move {
|
||||
let deadline_is_some = ctx.deadline.is_some();
|
||||
ResponseEnvelope::ok(
|
||||
@@ -1876,7 +2099,7 @@ mod tests {
|
||||
name: &str,
|
||||
handler: crate::registry::registration::SinkHandler,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec(name, AccessControl::default()),
|
||||
@@ -2043,7 +2266,7 @@ mod tests {
|
||||
publish_schema: Value,
|
||||
handler: crate::registry::registration::SinkHandler,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec_with_publish_schema(name, publish_schema),
|
||||
@@ -2579,4 +2802,100 @@ mod tests {
|
||||
"in_flight_sink_aborts map is cleaned up after pump_sink exits"
|
||||
);
|
||||
}
|
||||
|
||||
// --- review 004 F-04: the full-duplex serving loop ---------------------
|
||||
|
||||
/// The F-04 probe: two `serve_single_stream` loops over one duplex
|
||||
/// — the exact hub↔consumer shape. Each side holds a
|
||||
/// `CallConnection` over its own writer; when side A calls
|
||||
/// `echo/run`, its `call.requested` crosses the duplex into side
|
||||
/// B's serving loop, which dispatches and writes `call.responded`
|
||||
/// back, resolving A's pending through the `EVENT_RESPONDED` arm.
|
||||
/// This is the direction `read_single_stream_until_closed` used to
|
||||
/// drop.
|
||||
#[tokio::test]
|
||||
async fn serve_single_stream_self_call_resolves_pending() {
|
||||
fn echo_registry() -> Arc<crate::registry::registration::OperationRegistry> {
|
||||
let registry = crate::registry::registration::OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo/run", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|input, context| async move {
|
||||
ResponseEnvelope::ok(context.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
|
||||
// Side B: the duplex end wrapped as one BiStream (read+write);
|
||||
// `split_single_stream` divides it into the frame writer and
|
||||
// reader the serving loop consumes.
|
||||
let server_bidi = crate::core::types::BiStream::from_bidi(server_end);
|
||||
let (server_writer, server_reader) =
|
||||
crate::protocol::connection::split_single_stream(server_bidi);
|
||||
let dp_server = Dispatcher::new(echo_registry(), Arc::new(StaticIdentityProvider::new()));
|
||||
let server_call_conn = Arc::new(CallConnection::new_single_stream(
|
||||
crate::protocol::sink_empty_connection(),
|
||||
Arc::clone(&server_writer),
|
||||
));
|
||||
let server_conn_for_loop = Arc::clone(&server_call_conn);
|
||||
let _serve_b = tokio::spawn(async move {
|
||||
dp_server
|
||||
.serve_single_stream(server_conn_for_loop, server_reader, server_writer)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Side A: the same shape on the other duplex end.
|
||||
let client_bidi = crate::core::types::BiStream::from_bidi(client_end);
|
||||
let (client_writer, client_reader) =
|
||||
crate::protocol::connection::split_single_stream(client_bidi);
|
||||
let dp_client = Dispatcher::new(echo_registry(), Arc::new(StaticIdentityProvider::new()));
|
||||
let client_call_conn = Arc::new(CallConnection::new_single_stream(
|
||||
crate::protocol::sink_empty_connection(),
|
||||
Arc::clone(&client_writer),
|
||||
));
|
||||
let client_conn_for_loop = Arc::clone(&client_call_conn);
|
||||
let _serve_a = tokio::spawn(async move {
|
||||
dp_client
|
||||
.serve_single_stream(client_conn_for_loop, client_reader, client_writer)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Side A calls `echo/run` — side B serves it.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client_call_conn.call("echo/run", serde_json::json!({ "from": "a" })),
|
||||
)
|
||||
.await
|
||||
.expect("A→B call through serving loops timed out");
|
||||
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"A→B call resolves, got {:?}",
|
||||
response.result
|
||||
);
|
||||
assert_eq!(response.result.unwrap(), serde_json::json!({ "from": "a" }));
|
||||
|
||||
// And B calls back — A serves it (the both-directions gate).
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
server_call_conn.call("echo/run", serde_json::json!({ "from": "b" })),
|
||||
)
|
||||
.await
|
||||
.expect("B→A call through serving loops timed out");
|
||||
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"B→A call resolves, got {:?}",
|
||||
response.result
|
||||
);
|
||||
assert_eq!(response.result.unwrap(), serde_json::json!({ "from": "b" }));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user