fix(review 005 Unit 1): concurrent serving loops + stub-exercising gates (G-01, G-02)

- Split dispatch() into dispatch_start() (sync prefix) + spawned
  invocation: both single-stream loops (serve_single_stream and the
  accept-side run_loop_single_stream) spawn Once invocations, Sub
  pumps, and sink response writers; only the Pub sink start stays
  inline (chunk_tx must register before the next call.published).
  Inline dispatch deadlocked same-connection nested composition: the
  read loop awaited the parent handler, which awaited a nested call
  whose response only the same read loop could resolve (resolved only
  via the 30s sweeper). Spawned handles tracked + aborted at loop exit;
  in_flight_sinks behind an Arc<parking_lot::Mutex> with guards dropped
  before awaits.
- run_loop_single_stream gains the pending-resolution arms
  (RESPONDED/COMPLETED/ERROR): the accept side previously served only
  and had no loop resolving its own outbound pendings in single-stream
  mode — the latent accept-side imported-op composition hazard is
  mechanized shut.
- Write-failure in the spawned Once path warns instead of closing the
  loop (matches the Sink arm; dying transport still surfaces via
  ConnectionClosed on the next read).
- G-02 gate: hub_handler_composes_peer_announced_op_via_nested_composition
  — announce -> consumer calls hub/compose -> hub's serving loop
  wire-dispatches it -> handler composes via ctx.env -> forwarding
  stub's nested call crosses back to the consumer. The F-05 gate
  bypassed this path entirely.
- Interleaved-directions gate: outbound_call_resolves_while_inbound_
  subscription_is_being_served — consumer serves a live Sub while a
  wire-dispatched hub handler issues an outbound call on the same
  connection.
- Both gates verified load-bearing: run against the pre-fix loop each
  reproduces the G-01 hang (no progress, bounded-timeout failure);
  post-fix both resolve in <0.2s, no sweeper evictions.

Verification: cargo test 583 / --all-features 600, clippy
(all-targets, all-features, wasm32) clean, fmt clean, doc clean.

Refs docs/reviews/005-...md (G-01, G-02; Units 2-3 open).
This commit is contained in:
glm-5.3-flash committed 2026-09-04 09:36:08 +00:00
1 parent 435ae9da2f
commit 1cbb7c6536
3 files changed
+846 -157

No files matched your search

+437 -1
View File
@@ -271,7 +271,7 @@ mod tests {
use crate::protocol::dispatch::Dispatcher;
use crate::registry::context::OperationContext;
use crate::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
@@ -1542,6 +1542,442 @@ mod tests {
);
}
/// Interleaved-directions gate (review 005 G-01, third consequence):
/// the consumer's outbound call resolves **while** its serving loop
/// is mid-pump on a long-lived inbound Sub to the same peer. Under
/// the pre-G-01 loop the Sub pump held the read loop inline and the
/// outbound call's `call.responded` queued unresolved until the
/// sweeper; sequential-direction gates (hub→consumer, *then*
/// consumer→hub) could never catch it. The consumer also issues the
/// outbound call *from inside* a wire-dispatched handler, the
/// tightest interleaving.
#[tokio::test]
async fn outbound_call_resolves_while_inbound_subscription_is_being_served() {
// Consumer side: serves `consumer/echo` (Once) and
// `consumer/tick` (Sub, unbounded — held open until aborted).
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();
consumer_registry
.register(HandlerRegistration::new(
OperationSpec::new(
"consumer/tick",
OperationType::Sub,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
),
HandlerKind::Stream(make_streaming_handler(|_input, ctx: OperationContext| {
let request_id = ctx.request_id.clone();
futures::stream::unfold((0u32, request_id), |(n, request_id)| async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Some((
ResponseEnvelope::ok(
request_id.clone(),
serde_json::json!({ "tick": n }),
),
(n + 1, request_id),
))
})
})),
OperationProvenance::Local,
None,
None,
crate::core::types::Capabilities::new(),
))
.unwrap();
let consumer_registry = Arc::new(consumer_registry);
// Handle channel: the install hook yields the accept side's
// channel-0 `CallConnection` so the test driver can subscribe
// (the accept side initiates the Sub) and so the
// `hub/interleave` handler can reach it.
let (accept_conn_tx, mut accept_conn_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
// Accept side (hub): serves `accept/echo` and
// `hub/interleave` — the latter, when wire-dispatched from the
// consumer, issues an outbound call on the *same* connection.
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_conn_for_handler =
Arc::new(tokio::sync::Mutex::new(None::<Arc<CallConnection>>));
let accept_conn_slot = Arc::clone(&accept_conn_for_handler);
accept_registry
.register(HandlerRegistration::new(
external_query_spec("hub/interleave"),
HandlerKind::Once(make_handler(move |input, ctx| {
let conn_slot = Arc::clone(&accept_conn_for_handler);
async move {
let conn = conn_slot
.lock()
.await
.clone()
.expect("hub call connection set");
let response = conn
.call("consumer/echo", serde_json::json!({ "via": "interleave" }))
.await;
let echo = response.result.expect("outbound call resolves");
ResponseEnvelope::ok(
ctx.request_id,
serde_json::json!({
"input": input,
"outbound": echo,
}),
)
}
})),
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();
let accept_conn_slot = Arc::clone(&accept_conn_slot);
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),
));
accept_conn_slot
.lock()
.await
.replace(Arc::clone(&call_connection));
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
let dp = Dispatcher::new(
accept_registry,
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");
// Phase 1: the consumer subscribes to the hub's `consumer/tick`
// — the consumer's serving loop pumps the Sub inline (pre-fix)
// or in a spawned task (post-fix). The stream stays open.
use futures::stream::StreamExt;
let mut ticks = tokio::time::timeout(
std::time::Duration::from_secs(5),
accept_conn.subscribe("consumer/tick", serde_json::json!({})),
)
.await
.expect("subscribe timed out");
let first = tokio::time::timeout(std::time::Duration::from_secs(5), ticks.next())
.await
.expect("first tick timed out")
.expect("first tick item");
assert!(
first.result.is_ok(),
"first tick ok, got {:?}",
first.result
);
// Phase 2: with the Sub live, the consumer calls the hub's
// `hub/interleave` — the wire-dispatched handler issues an
// outbound hub→consumer call on the same connection while the
// consumer's serving loop is serving the Sub. Pre-fix, the
// consumer's serving loop held the Sub pump inline and this
// call starved (30s sweeper); post-fix it resolves.
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.call_open_op(
"hub/interleave",
serde_json::json!({ "when": "while-sub-live" }),
),
)
.await
.expect("interleaved outbound call starved while Sub served (G-01 shape)");
assert!(
response.result.is_ok(),
"interleaved call resolves, got {:?}",
response.result
);
let out = response.result.unwrap();
assert_eq!(
out.get("outbound"),
Some(&serde_json::json!({ "via": "interleave" })),
"the nested outbound hub→consumer call resolved through the serving loop"
);
// The Sub is still live after the interleaved call.
let second = tokio::time::timeout(std::time::Duration::from_secs(5), ticks.next())
.await
.expect("second tick timed out")
.expect("second tick item");
assert!(
second.result.is_ok(),
"sub still live after interleaved call, got {:?}",
second.result
);
}
/// G-02 gate (review 005): the forwarding stub is exercised
/// through **nested composition from a wire-dispatched hub
/// handler** — the exact path the direct wire call in
/// `op_register_announce_then_hub_call_routes_back_to_consumer`
/// bypasses, and the path G-01 deadlocked (the inline dispatch
/// blocked the read loop that had to resolve the stub's nested
/// call; it resolved only via the 30s sweeper). The hub registry
/// serves `hub/compose`, whose handler invokes the announced op
/// through `context.env` — the `PeerCompositeEnv` →
/// `OverlayOperationEnv` resolution a real hub handler produces.
/// Resolving quickly (bounded well under the sweeper interval)
/// proves the serving loop resolves same-connection nested
/// composition live.
#[tokio::test]
async fn hub_handler_composes_peer_announced_op_via_nested_composition() {
// 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 (hub): serves `op/register` + `hub/compose` on a
// fork. `hub/compose` composes the announced op via
// `context.env` — the nested-composition shape ADR-022's
// amendment promises. The stub is not invoked by the assertion
// below directly; it is resolved through the connection
// overlay `compose_root_env` attaches. The channel-0
// connection carries the peer's identity (the assembly layer
// resolves it from the accepted connection's `AuthContext`) —
// `compose_root_env` attaches the connection overlay keyed by
// that identity (ADR-030 §5); an identity-less connection gets
// no overlay.
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| {
if let Some(identity) = auth.identity.clone() {
let _ = channel0_conn.set_identity(identity);
}
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();
reg.register(HandlerRegistration::new(
external_query_spec("hub/compose"),
HandlerKind::Once(make_handler(|input, ctx| async move {
let name = input
.get("op")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let (namespace, operation) = match name.split_once('/') {
Some(parts) => parts,
None => {
return ResponseEnvelope::error(
ctx.request_id,
crate::protocol::wire::CallError::invalid_input(
"hub/compose input missing `op` as `ns/op`",
),
)
}
};
let response = ctx.env.invoke(namespace, operation, input, &ctx).await;
ResponseEnvelope {
request_id: ctx.request_id,
result: response.result,
}
})),
OperationProvenance::Local,
None,
Some(crate::registry::context::ScopedPeerEnv::new([
"consumer/exec",
])),
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 {
identity: Some(crate::core::auth::Identity {
id: "consumer-peer".to_string(),
scopes: vec![],
resources: Default::default(),
}),
..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
);
// A wire-dispatched hub handler composes the announced op via
// nested composition: the consumer calls `hub/compose` over the
// wire, the hub's serving loop dispatches it, and the handler
// resolves `consumer/exec` through the forwarding stub. Under
// G-01 this resolved only at 30s via the sweeper; the bounded
// timeout proves live resolution.
let response = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.call_open_op(
"hub/compose",
serde_json::json!({ "op": "consumer/exec", "task": "nested" }),
),
)
.await
.expect("nested composition through the forwarding stub timed out (G-01 shape)");
assert!(
response.result.is_ok(),
"nested composition resolves through the forwarding stub, got {:?}",
response.result
);
assert_eq!(
response.result.unwrap(),
serde_json::json!({ "ran_on": "consumer", "input": { "op": "consumer/exec", "task": "nested" } })
);
}
// --- 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