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:
glm-5.3-flash committed 2026-09-03 17:32:45 +00:00
1 parent c0dbf82518
commit f84d214173
18 files changed
+2024 -138

No files matched your search

+330 -11
View File
@@ -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" }));
}
}